diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index e3e99d702f578..093dba2a97168 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -311,9 +311,9 @@ All of the above applies only when the session's provider advertises the `multip - **Central and mirrored.** Session flags and config: `configValues`, `customizationEnablement`, `isArchived`, `isRead`, `defaultChatIsRead`, `agentHost.autoArchivedAt`, `agentHost.workspaceless`, `agentHost.workspaceConversionQuarantined`, `agentHost.createdBySession`. Workspace: `agentHost.sessionWorkingDirectories`, `agentHost.additionalWorktrees`, `copilot.worktree.*`, `sessionArtifacts`. Git, GitHub and source control, per folder: `agentHost.git`/`gitData`, `agentHost.github`/`githubData`, `pendingRecordedPullRequests`, `sourceControl`, `diffBaseBranch` (a legacy-catalog peer's base branch is mirrored into the database of the folder's first live chat). - **Chat summaries.** Titles and title sources, read state and changes summaries are central (the normalized catalog, or `session_chat_state` for legacy-catalog sessions); the session database keeps receiving main's writes (`customTitle`, `customChatTitle:`, `agentHost.changes`, `agentHost.changes:`) and nothing more inline: a normalized-catalog chat's summaries are not rendered into it, so a title, summary or changes write opens no database (a listed peer's title reaches older builds with the deferred copy below). A legacy-catalog peer's own database keeps `agentHost.chatProviderData`, `chatOrigin`, `chatInheritedTurnId`, `chatWorkingDirectories` and `isRead` as on main, and `peerChats` is written on every membership write. A normalized catalog publishes the same older-build copy (`AgentHostPeerChatStore._publishNormalizedCompatibilityState`): `peerChats` (visible peers only, never private chats), those keys in each listed peer's own database (only for entries that differ from the acknowledged payload, and only when the peer is stored at its own URI), the listed peers' titles where 1.140 and 1.141 read and write them (`customChatTitle:` and its source in the session database, `customTitle` and `customTitleSource` in the peer's own database), and the acknowledgements (`legacy_mirrored_revision` and payload, and the published titles: the next import's merge bases). It is published only for membership, read, archive, provider, origin, inherited-turn, folder and title changes, after the session has been idle for `COMPATIBILITY_PUBLISH_IDLE_DELAY` (bounded), or by `flushCompatibilityPublishes` at shutdown, session release (with the session database still held) and before a move or promotion; `whenIdle` and hot paths never publish. Per-session paths (opening a session, its list-visible writes, passive metadata, disposing a chat, a saved continuation) wait for their own sessions' peer-store writes only (`whenSessionsIdle`: the session, plus a disposed chat's recorded owner or a continuation's destination; a write touching two sessions, such as Block 4's moves and promotions or a saved-chat continuation, must pass both and guard both with `guardOlderBuildEdits`), never the global `whenIdle`, which is for shutdown and tests: a background conversion pass keeps other sessions' writes in flight continuously. Each publish reads its source in one transaction, writes the session database, then acknowledges in one short transaction: the acknowledged payload always equals `peerChats` (a newer revision with an older matching pair is safe, an acknowledgement ahead of `peerChats` is not). A peer's own database is opened only when one of its keys changed (`isPeerKeyReadByOlderBuilds`). Opening a session with its database held reads `peerChats` and the listed peers' titles in one statement and imports, against the published titles, a title an older build changed (the peer's own database first, then the session database's; an older-build edit wins, as in the 3a mirror); the publisher does the same before it overwrites them. An older build's edit of a peer's draft in the session database is detected at load in the read that already takes the turn watermark (`getRowsWatermark`), against the draft mirror bases read with the session state markers, and imported like the 3a row edits. Its peers' folders are also written to `agentHost.chatWorkingDirectories` right away, only when they change. `normalizedPeerProjectionSql` (agentHostDatabase.ts) is the single projection for `session_chats`, the older-build edit check and `peerChats`; `OLDER_BUILD_LISTED_PEER_SQL` is the one place deciding which visible peers older builds see. Chats Block 4 moved are shown to them; the renames and drafts they route to a moved chat's source session by its address are imported for the chat under its recorded owner, and a moved-in chat missing from their copy is never a delete (see agentHostChatCatalogV2.md). - **Chat data, not owned by the store.** Each chat's own database (the session database for the default chat) holds its drafts and local and failed turns (`store.chatRows`; a peer's rows are also mirrored write-only into the session database, keyed by chat, for older builds), the Copilot pinned working directory, its title as a peer, Mission Control cursors (`missionControl.sdkSequence.`, `sdkJournalCursor.`) and the ehcli markers (`agentHost.ehcliAdopted`, `agentHost.ehcliLastMigratedTurn`). `vscode.scheduleCursors` is automation data. -- **A chat's own config.** The values a chat sets for itself (`ChatState.config`, only properties its session's schema marks `chatMutable`) are chat data: they live in the chat's own database under `agentHost.chatConfigValues` as `{ chat, values }` (the default chat's in the session database under that key, so older builds keep reading `configValues` as the session's). The `chat` tag keeps a database copied for a fork from being read as the fork's own; a fork's values are an explicit write. Tool (subagent) chats store none. `chatConfigContribution` persists accepted changes and hydrates them on restore. +- **A chat's own config.** With `multipleChatProviders.enabled` off, a chat's values (`ChatState.config`, only properties its schema marks `chatMutable`) are an overlay on its session's, exactly as before chats could use their own agents: a property it didn't set follows its session, and nothing below about completion, the default chat following session writes, or persisting complete values happens (`AgentConfigurationService._chatValuesComplete`; clients read the same way, `hostPublishesCompleteChatConfig`). A peer completed during a period with the setting on keeps those copied values when it is turned off again: they now read as values it set itself, so that peer no longer follows later session changes of those properties (inherent to reading complete values as an overlay; the default chat republishes only its own values and follows its session again). With it on, a chat's values are its complete config for those properties (AHP: a session's config never changes a chat's; a missing key is the schema's `default`). Turning it on completes loaded chats (unloaded ones when restored); turning it off reads complete values as overlays again, which they remain valid as, and the default chat publishes only its own values again. They are chat data: they live in the chat's own database under `agentHost.chatConfigValues` as `{ chat, values }` (the default chat's in the session database under that key, so older builds keep reading `configValues` as the session's). The `chat` tag keeps a database copied for a fork from being read as the fork's own; a fork's values are an explicit write. Tool (subagent) chats store none. `chatConfigContribution` persists accepted changes and hydrates them on restore. A row written by an older build is an overlay on the session; once its session's config is known it is completed (the session's `chatMutable`, non-read-only values without Agent Merge's elevation, with the row over them) and persisted complete, which an older build still reads correctly as an overlay. The default chat persists only what it set itself (below). - **A new chat's config.** A new chat that is not a fork, side chat or subagent starts with the default chat's current effective `chatMutable` values as its own (decision D1, as before chats had their own values); forks and side chats copy their source chat's own values. The copy is written to the new chat's own database before creation resolves. -- **Older clients and a chat's own config.** An older client knows only `session/configChanged`, which keeps editing the session's values. Those reach every chat that has no value of its own for the property; a chat that has its own (for example the default chat's own mode or approvals, once a newer client set them) keeps it, so the older client's change does not show in that chat (accepted, Q1). +- **The default chat and the session's config.** The session's config is its default chat's: older clients, the session-level pickers, the sandbox and its managed policy, and Agent Merge write `session/configChanged`. The default chat takes the session's `chatMutable` values for the properties it has not set itself and publishes them, so its values are complete too (`AgentConfigurationService._syncDefaultChatConfig`, tagged `isChatConfigCompletion`, so they fire no chat change or telemetry). What it sets itself stays its own until a session write of that property (from a client or the host, including a sandbox or policy rollback) replaces it: the session's pickers are the default chat's, so the written `chatMutable`, non-read-only properties become inherited again and the cleared own values are persisted; a `replace` leaves it only what it set. Peers, forks and side chats keep their own. Every other chat never takes a session change of a `chatMutable` property (accepted, Q1); its session-wide (non-`chatMutable`) properties keep following the session. - **Mixed client writes.** A newer client splits a patch that touches both kinds of property into two dispatches: the `chat/configChanged` for the `chatMutable` part first, then the `session/configChanged` for the rest. They are not atomic: if the second fails, the first stays applied. - **Chat origin.** `chats_v2.origin_session_uri` (agent-host.db migration 17) records the session a chat was created in, written once at insert; rows written before it or by an older build read as created in their owner. - **Not stored.** Branch and session-wide changeset blobs are computed live; existing values on disk are left for older builds and never read back. @@ -351,6 +351,17 @@ Behind the root setting `chatMoves.enabled` (host schema default off; VS Code cl - **Agent tool.** The `move_session` server tool (`node/shared/sessionMoveTools.ts`, Block 6) lets an agent reorder a nested session, move it into another session, make it its own session, or move a single-chat session under another, through the same `moveChat` / `moveSession` operations and refusals. It is a group of its own, never materialized: offered only while `chatMoves.enabled` is on (read per call; sessions are advertised again when it changes) to an agent with `multipleChats`, so tool lists are unchanged while it is off: `list_sessions` and `get_current_session` mention it in their descriptions only while it is offered (`getDefinitionForSession`). Their results always list a session's main session and nested sessions (`chats`: title, `?chat=` link, `main`, status; list data only), a general improvement whose links also work with `send_message`, `get_session_context` and `rename_chat`. Every call needs confirmation (`sessionConfirmationToolNames`), unless the user turned approvals off (Allow All, Claude `bypassPermissions`, Codex full access), as for `create_session`. Providers that fix their tool list at launch (Claude's allow-list) see the tool after their next launch once the setting turns on. Its check and its move run in the move sequencer like AHP moves, so the sessions they restore never overlap another move. Its confirmation, built by the host (`IAgentServerToolHost.getConfirmationDisplay`), links only sessions and chats the host knows. It resolves sessions only from links and recorded chats, never from a chat URI, and places a chat at the end of its destination by resolving its anchor in the accessor. A move that includes the calling chat is checked when requested and applied when that turn succeeds (`IAgentHostScheduledChatMoves`, contribution order 160); the chat's queued turns are held from that turn end until the move is done, so queue drain (200) starts the next one in the session the chat moved to; a refusal then is only logged. Process-wide backstop: 50 moves. - **Clients.** Clients without `vscode.ahpChatUris` see a chat only in the session it was created in, and nothing that may have moved while the moved-chat origins can't be read. The origins load in the background from startup: such a client's session listing waits for them, any other client's doesn't, and until a chat first moved (a `metadata` marker the move writes) loading them reads that one row instead of scanning `chats_v2`. Clients pair a chat leaving one catalog with the same backend chat appearing in another (`AgentHostChatMoveTracker`) and keep showing it. Mission Control replicates only a session's default chat: promotion starts the new session's SDK stream, and a default chat that moves under another session stops its own. +## 3d. Chats With Their Own Agent + +Behind the root setting `multipleChatProviders.enabled` (workbench `chat.agentHost.multipleChatProviders.enabled`, default off), a chat of a session may use another agent than its session. The host advertises `InitializeResult.multipleChatProviders` only with the setting on and a negotiated protocol of at least 1.1.0; with the setting off nothing changes and `CreateChatParams.provider` is rejected with `InvalidParams`. + +- **Creation.** `createChat` resolves the chat's agent: the requested `provider`, else a fork or side chat's source chat's agent, else the session's. A requested agent must be registered (a policy-disabled agent is not) and advertise `multipleChats`; a fork or side chat must use its source chat's agent (`InvalidParams` otherwise). The agent creates the chat from the exhaustive `IAgentChatContext` like any peer chat: it needs no session of its own, since every harness mints a per-chat backing for a peer chat. +- **Routing.** The state manager records each chat's agent at registration (subagents take their spawning chat's) and reports `provider` on `ChatState`, `ChatSummary` and `SessionChatSummary` only when it differs from the session's; `session/chatUpdated` never carries it. `IAgentHostProviderService.getProviderForChat(session, chat)` routes every chat-addressed operation (turns, input, steering, aborts, canvases, background work, history watches, release, dispose, telemetry, utility model); session-scoped operations stay on `getProviderForSession`. +- **Persistence.** A chat's agent is kept in agent-host.db `metadata` under `chatAgentProvider:` (`IAgentHostDatabase.readChatAgentProviders` / `recordChatAgentProvider` / `deleteChatAgentProviders`), outside every validated payload: `chats_v2` metadata and the sessions_v2 list payload stay byte-identical to what older builds write, so they read them as canonical and never strip or rewrite the agent. The key is written before the chat's catalog row (a crash leaves at most a key without a chat, never a chat restorable under the session's agent), at spawn for a subagent of such a chat, and deleted with the chat or its session; a move keeps it. Agent Host reads all keys once (one indexed range scan) and stamps listed chats (`IAgentSessionChatMetadata.provider`), restored chats before any hydration, and the chats of an unloaded session it deletes. Absent means the session's agent. A chat whose recorded agent is not registered is never routed to the session's agent: `getProviderForChat` returns `undefined` and release, dispose and similar sweeps skip it with a warning. While the agent creates a chat, before it is registered, the chat is already routed to its agent. +- **Config.** Such a chat publishes its own `ChatConfigState.schema` (the agent's `chatMutable` properties; host-side `ownConfigSchema`, never persisted) and starts with that agent's defaults; it never takes the session's values, a change it makes never reaches the session, and keys outside its schema are rejected. A fork or side chat copies its source's values and schema. On restore the schema is resolved from the agent again, once per agent and folder for a restore pass, and the persisted values are filtered to the keys it still has. A fork or side chat whose source has no loaded own schema takes its agent's, never the session's. With no chat using its own agent, config reads skip the own-schema lookup. +- **Session-scoped surfaces.** Session customization publishing, MCP server start/stop and the customization snapshot handed to providers stay scoped to the session's agent for now. Archiving and unarchiving a session also notify (`onArchivedChanged`) the agents of its chats that use their own. +- **Moves.** A chat keeps its agent. Between sessions of different agents only a chat that uses its own agent in both moves (with the setting on), keeping its config; a chat that would gain or lose its own agent, a session moved under a session of another agent and the promotion of a chat with its own agent are refused with `differentProvider`, because a chat's schema cannot change while clients observe it. + ## 4. Capabilities Gating `AgentCapabilities` (`common/state/protocol/channels-root/state.ts:AgentCapabilities`) is the protocol-level contract: @@ -814,7 +825,7 @@ Copilot model `configSchema` advertises both `thinkingLevel` / `reasoningEffort` Live provider runtimes that react to session config subscribe to `IAgentConfigurationService.onDidSessionConfigChange` with their explicit config resource. `AgentSideEffects` does not enumerate chats or fan config values through provider hooks. -**Per-chat config.** A session schema property marked `chatMutable` (approvals, mode, tool permissions, sandbox, Copilot's effective approval mode, Claude's permission mode, Codex's permissions preset) is a chat's own. `IAgentConfigurationService.getChatConfigValues` / `getChatEffectiveValue({ session, chat })` resolve a chat's values as: its own, then those of the chats that spawned it (recorded `getChatParent` edges, never URI shape), then the session's, then the host's. Providers read their chat's values and write SDK-driven changes back to that chat with `updateChatConfig` (a subagent's go to its nearest non-tool ancestor); runtimes listen to `onDidChatConfigChange`, which also fans out session changes to the chats that do not override them. Clients send `chat/configChanged` for `chatMutable` properties and `session/configChanged` for the rest, and the host validates the former (registered chat, not a tool chat, writable `chatMutable` properties only). A host without `chatMutable` properties behaves exactly as before. A new chat (not a fork, side chat or subagent) starts with the default chat's current effective values for the `chatMutable` properties, its own else the session's, as its own, so it begins where the default chat is, as when chats shared the session's values; read-only (host-reported) values and Agent Merge's elevation are not copied. Forks and side chats start with a copy of their source chat's own values; subagents store none. +**Per-chat config.** A session schema property marked `chatMutable` (approvals, mode, tool permissions, sandbox, Copilot's effective approval mode, Claude's permission mode, Codex's permissions preset) is a chat's own. `IAgentConfigurationService.getChatConfigValues` / `getChatEffectiveValue({ session, chat })` resolve a chat's values: a `chatMutable` property from its values (or, for a subagent, those of the chat that spawned it, by recorded `getChatParent` edges, never URI shape), then the schema's `default`, then the host's; any other property from the session, then the host. A session's default chat, and a chat whose values are not completed yet, still apply their own over the session's. Providers read their chat's values and write SDK-driven changes back to that chat with `updateChatConfig` (a subagent's go to its nearest non-tool ancestor); runtimes listen to `onDidChatConfigChange`, which also fans out session changes of session-wide properties to every chat, and of `chatMutable` ones to the default chat where it has not set them. Clients send `chat/configChanged` for `chatMutable` properties and `session/configChanged` for the rest, and the host validates the former (registered chat, not a tool chat, writable `chatMutable` properties only). A host without `chatMutable` properties behaves exactly as before. A new chat (not a fork, side chat or subagent) starts with the default chat's current effective values for the `chatMutable` properties, its own else the session's, as its own, so it begins where the default chat is, as when chats shared the session's values; read-only (host-reported) values and Agent Merge's elevation are not copied. Forks and side chats start with a copy of their source chat's own values; subagents store none. Copilot advertises the optional `sandboxEnabled` session property (`default`, `on`, or `off`). Omission and `default` follow the root sandbox settings; the successfully applied enablement is saved in session-config metadata, not a pending or rejected selection. Restore reconciles saved values against current root settings and the runtime's current managed policy before notifying live providers. A saved `off` that conflicts with required sandboxing becomes `on`; successful application persists `on` so removing the policy cannot revive `off`. Each chat has its own selection (subagents use their spawning chat's); new sessions do not copy it, while an in-session fork or side chat starts from its source chat's. Codex retains its native sandbox/permission preset; Claude does not advertise this unsupported control. diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 65b6ee734eb7f..c6fd77772b690 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -64,7 +64,9 @@ import { MessageAttachmentKind, SessionSummary, ROOT_STATE_URI, StateComponents, import type { AgentChatMoveDestination } from '../common/agentHostChatMove.js'; import { ChatMoveDestinationKind } from '../common/state/protocol/channels-chat/commands.js'; import { normalizeLegacyActionEnvelope } from '../common/state/legacyProtocolCompatibility.js'; -import { getAgentHostSupportedProtocolVersions } from '../common/agentHostProtocolCompatibility.js'; +import { getAgentHostClientProtocolVersions } from '../common/agentHostProtocolCompatibility.js'; +import { ChatProvidersConfigKey, ChatProvidersSettingId } from '../common/agentHostChatProviders.js'; +import { compareProtocolVersions } from '../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, ReconnectResultType, JSON_RPC_INTERNAL_ERROR, type ProtocolMessage, type IStateSnapshot, type SubscribeResult, type JsonRpcNotification } from '../common/state/sessionProtocol.js'; import { type IVscodeUpgradeResult } from '../common/state/protocolUpgrade.js'; import { isClientTransport, NonReconnectableTransportError, type AgentHostTransportFailureReason, type IProtocolTransport } from '../common/state/sessionTransport.js'; @@ -100,6 +102,9 @@ import type { IDevContainerAgentHostMainService } from '../common/devContainerAg const AHP_CLIENT_CONNECTION_CLOSED = -32000; +/** The first protocol version whose hosts may advertise `multipleChatProviders`. */ +const MULTIPLE_CHAT_PROVIDERS_PROTOCOL_VERSION = '1.1.0'; + function toAgentExtensionMode(value: unknown): AgentExtensionMode | undefined { if (typeof value !== 'string') { return undefined; @@ -337,6 +342,12 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect private _relayRootRefresh: { readonly transport: IProtocolTransport; readonly promise: Promise } | undefined; /** Disposable holding the listeners attached to the current transport. */ private readonly _transportListeners = this._register(new MutableDisposable()); + /** The next reconnect sends `initialize` instead of `reconnect`, so the host decides its capabilities again. */ + private _freshInitializeRequested = false; + /** Whether to name the chat subscriptions held once the re-initialize for chats with their own agents connects. */ + private _describeChatSubscriptionsOnReconnect = false; + /** Whether this connection already re-initialized for `multipleChatProviders` since the host's gate last turned on. */ + private _reinitializedForChatProviders = false; private readonly _connectionAuthority: string; readonly resourceUris: IAgentHostResourceUriMapper; private _serverSeq = 0; @@ -562,6 +573,11 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect : undefined; } + /** The versions offered in `initialize`; read on every connection, so the setting applies from the next one. */ + private _offeredProtocolVersions(): string[] { + return getAgentHostClientProtocolVersions(this._configurationService.getValue(ChatProvidersSettingId.Enabled) === true); + } + /** * The latest `initialize` response from the host, or `undefined` if * the handshake has not completed yet. Exposed observably so callers can @@ -627,6 +643,9 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect this._register(this.onDidAction(envelope => { this._subscriptionManager.receiveEnvelope(envelope); })); + // The host's gate for chats with their own agents usually turns on through this client's own mirror, after the handshake. + this._register(this._subscriptionManager.rootState.onDidChange(() => this._maybeReinitializeForChatProviders())); + this._register(this.onDidChangeConnectionState(() => this._maybeReinitializeForChatProviders())); this._register(this._configurationService.onDidChangeConfiguration(e => { if (this._state.kind !== AgentHostClientState.Connected || this._isWebPubSubRelay()) { @@ -656,6 +675,10 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect this._logService.info(`[RemoteAgentHostProtocol] Mirroring configuration to host root config from ${ConfigurationTargetToString(e.source)}: ${mirrored.join(', ')}`); this._dispatchRootConfig(patch); } + if (e.affectsConfiguration(ChatProvidersSettingId.Enabled)) { + // The host's gate may already be on (e.g. mirrored by another window), so its root state won't change. + this._maybeReinitializeForChatProviders(); + } if (e.affectsConfiguration(GLOBAL_AUTO_APPROVE_SETTING_ID)) { this._updateAutoApprovePolicyRestriction(); } @@ -860,7 +883,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect // Advertise every compatible version, most-preferred first, so an // older host (a cloud sandbox running a 0.5.x `copilotd`) can negotiate down // instead of rejecting the connection. A current host still picks the newest. - protocolVersions: getAgentHostSupportedProtocolVersions(), + protocolVersions: this._offeredProtocolVersions(), clientId: this._clientId, clientInfo: this._clientInfo, _meta: this._clientMeta(), @@ -1221,7 +1244,12 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } private async _reconnectOrInitialize(lastSeenServerSeq: number, subscriptions: string[]): Promise<{ result: CommandMap['reconnect']['result'] & { readonly _meta?: Record }; freshInitialize: boolean }> { + const freshInitialize = this._freshInitializeRequested; + this._freshInitializeRequested = false; try { + if (freshInitialize) { + throw new ProtocolError(AhpErrorCodes.NotFound, 'A fresh initialize was requested'); + } const result = await this._dispatchRequest }>('reconnect', { channel: ROOT_STATE_URI, clientId: this._clientId, @@ -1236,10 +1264,12 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } } - this._logService.info(`[RemoteAgentHostProtocol] Server forgot client ${this._clientId}; initializing a fresh connection.`); + this._logService.info(freshInitialize + ? `[RemoteAgentHostProtocol] Re-initializing ${this._address} so the host decides its capabilities again.` + : `[RemoteAgentHostProtocol] Server forgot client ${this._clientId}; initializing a fresh connection.`); const initializeResult = await this._dispatchRequest('initialize', { channel: ROOT_STATE_URI, - protocolVersions: getAgentHostSupportedProtocolVersions(), + protocolVersions: this._offeredProtocolVersions(), clientId: this._clientId, clientInfo: this._clientInfo, _meta: this._clientMeta(), @@ -1539,6 +1569,68 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } } + /** + * The host decides `multipleChatProviders` from its root config at the + * handshake, but this client mirrors `multipleChatProviders.enabled` only + * after it, and offers the protocol version that may advertise it only + * while the setting is on. When the setting is on, the host's gate is on + * (as confirmed by the host's root state), and the handshake didn't + * advertise the capability, re-initialize once so the host decides again, + * including when the handshake negotiated an older version because the + * setting was off then. At most once until the host's gate or the setting + * turns off again, so it never loops; nothing happens with the setting off. + */ + /** + * The chat subscriptions this client holds, by holder: every one is restored after a re-initialize, so this + * names the components that make a re-initialize restore many chats. + */ + private _describeChatSubscriptions(): string { + const holders = new Map(); + let count = 0; + for (const subscription of this._subscriptionManager.getActiveSubscriptions()) { + if (subscription.kind === StateComponents.Chat) { + count++; + for (const holder of subscription.holders) { + holders.set(holder.owner, (holders.get(holder.owner) ?? 0) + holder.count); + } + } + } + return `${count} chat subscription(s) held by: ${[...holders].map(([owner, holderCount]) => `${owner}=${holderCount}`).join(', ') || 'none'}`; + } + + private _maybeReinitializeForChatProviders(): void { + if (this._describeChatSubscriptionsOnReconnect && this._state.kind === AgentHostClientState.Connected) { + this._describeChatSubscriptionsOnReconnect = false; + this._logService.trace(`[RemoteAgentHostProtocol] ${this._address} re-initialized for chats with their own agents with ${this._describeChatSubscriptions()}.`); + } + if (this._configurationService.getValue(ChatProvidersSettingId.Enabled) !== true) { + this._reinitializedForChatProviders = false; + return; + } + if (this._state.kind !== AgentHostClientState.Connected || !this._transportFactory) { + return; + } + const root = this._subscriptionManager.rootState.value; + if (!root || root instanceof Error || root.config?.values[ChatProvidersConfigKey.Enabled] !== true) { + this._reinitializedForChatProviders = false; + return; + } + const result = this._initializeResult.get(); + if (!result || result.multipleChatProviders || this._reinitializedForChatProviders + || !this._offeredProtocolVersions().some(version => compareProtocolVersions(version, MULTIPLE_CHAT_PROVIDERS_PROTOCOL_VERSION) >= 0)) { + return; + } + this._reinitializedForChatProviders = true; + this._freshInitializeRequested = true; + this._logService.info(`[RemoteAgentHostProtocol] ${this._address} enabled chats with their own agents after the handshake; re-initializing.`); + this._logService.trace(`[RemoteAgentHostProtocol] ${this._address} re-initializing with ${this._describeChatSubscriptions()}.`); + this._describeChatSubscriptionsOnReconnect = true; + // Disposing the listeners closes the transport; route the close through the soft-reconnect path, as the watchdog does. + this._transportListeners.clear(); + this._handleTransportClose(); + } + + private _updateDynamicAutoTiers(): void { const enabled = getGlobalConfigurationValue(this._configurationService, AgentHostDynamicAutoTiersEnabledSettingId) === true; this._dispatchRootConfig({ [CopilotCliConfigKey.DynamicAutoTiers]: enabled }); @@ -1853,7 +1945,21 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect private async _subscribe(resource: URI): Promise { this._logService.trace(`[RemoteAgentHostProtocol] subscribe start: ${resource.toString()}`); - const result = await this._sendRequest('subscribe', { channel: resource.toString() }); + let result: CommandMap['subscribe']['result']; + while (true) { + try { + result = await this._sendRequest('subscribe', { channel: resource.toString() }); + break; + } catch (error) { + // A subscribe in flight when the transport drops (a soft reconnect, or the re-initialize that enables + // chats with their own agents) is rejected only because its reply was lost: send it again once the + // client reconnects, as any request issued while reconnecting is, instead of failing the subscription. + if (!isConnectionClosedError(error) || (this._state as ClientState).kind !== AgentHostClientState.Reconnecting) { + throw error; + } + this._logService.trace(`[RemoteAgentHostProtocol] subscribe lost to a reconnect, retrying once reconnected: ${resource.toString()}`); + } + } if (!result.snapshot) { throw new Error(`subscribe to ${resource.toString()} returned no snapshot`); } @@ -2542,6 +2648,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect channel: session.toString(), chat: chat.toString(), ...(options?.title !== undefined ? { title: options.title } : {}), + ...(options?.provider !== undefined ? { provider: options.provider } : {}), ...(initialMessage ? { initialMessage } : {}), ...(options?.workingDirectories !== undefined && !options.fork ? { workingDirectories: options.workingDirectories.map(directory => fromAgentHostUri(directory).toString()) } @@ -2635,6 +2742,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect summary: chat.title, kind: s.defaultChat === chat.resource ? 'default' : 'peer', origin: chat.origin, + ...(chat.provider !== undefined ? { provider: chat.provider } : {}), ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), ...(isSessionChatArchived(chat) ? { archived: true } : {}), ...(chat.status !== undefined ? { status: chat.status, isRead: isSessionStatusRead(chat.status) } : {}), diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 806ce25d79b33..9a160d54f1ded 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -204,6 +204,8 @@ export interface IAgentSessionChatMetadata { readonly kind: 'default' | 'peer'; readonly origin?: ChatOrigin; readonly interactivity?: ChatInteractivity; + /** The chat's own agent when it differs from its session's; absence means the session's agent. */ + readonly provider?: string; readonly archived?: boolean; /** Exact chat read state when known; absence means the provider did not supply it. */ readonly isRead?: boolean; @@ -1013,6 +1015,13 @@ export interface IAgentCreateChatRequestOptions extends IAgentCreateChatOptions * the provider, while retaining the side-chat provenance itself. */ readonly sideChat?: IAgentCreateChatSideChatSource; + /** + * The agent requested for the new chat (`CreateChatParams.provider`). The + * host resolves and validates it and creates the chat in that agent; + * absent means the source chat's agent for a fork or side chat, else the + * session's. Never passed to providers. + */ + readonly provider?: AgentProvider; /** * Host-owned workspace requested for a new peer chat. The host prepares it * (adding the folder to the session, creating a worktree or a scratch diff --git a/src/vs/platform/agentHost/common/agentHostChatMove.ts b/src/vs/platform/agentHost/common/agentHostChatMove.ts index 5bcb8ab168c5c..98cb9f2466e10 100644 --- a/src/vs/platform/agentHost/common/agentHostChatMove.ts +++ b/src/vs/platform/agentHost/common/agentHostChatMove.ts @@ -116,6 +116,24 @@ export const enum AgentChatMoveRefusal { * credentials than the destination session." */ DifferentCredentialOwner = 'differentCredentialOwner', + /** + * Chats with their own agents are on, but the chat's agent is unknown or disabled on the host, so it can't move to a + * session of another agent or become a session of its own agent. Client text: enable the chat's agent. Distinct from + * {@link DifferentProvider}, which means chats with their own agents are off. + */ + AgentUnavailable = 'agentUnavailable', + /** + * Chats with their own agents are on, but the destination's agent can't host chats of other agents, or the chat's + * agent can't join a session of another agent: one of them doesn't support multiple chats. Client text: pick a + * session of an agent that supports chats of other agents. + */ + AgentWithoutChats = 'agentWithoutChats', + /** + * The chat's agent couldn't describe its settings for the chat when it moved into a session of another agent or + * into a new session of its own agent. Transient: client text asks to try again. Distinct from {@link Unsupported} + * (no conversation of its own to take into a new session). + */ + AgentConfigUnavailable = 'agentConfigUnavailable', } /** A chat move the host refused before changing anything. */ diff --git a/src/vs/platform/agentHost/common/agentHostChatProviders.ts b/src/vs/platform/agentHost/common/agentHostChatProviders.ts new file mode 100644 index 0000000000000..795be243d4d19 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostChatProviders.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../nls.js'; +import { createSchema, schemaProperty } from './agentHostSchema.js'; + +/** Host root config keys for chats of one session using different agents. */ +export const ChatProvidersConfigKey = { + Enabled: 'multipleChatProviders.enabled', +} as const; + +/** Workbench settings mirrored into {@link ChatProvidersConfigKey}. */ +export const ChatProvidersSettingId = { + Enabled: 'chat.agentHost.multipleChatProviders.enabled', +} as const; + +export const chatProvidersRootConfigSchema = createSchema({ + [ChatProvidersConfigKey.Enabled]: schemaProperty({ + type: 'boolean', + title: localize('multipleChatProviders.config.enabled', "Multiple Chat Providers"), + default: false, + }), +}); + +/** + * The user-facing harness id of an agent provider, as clients present it: + * Copilot's `copilotcli` agent is the `copilot` harness; every other agent id + * is its own harness id. + */ +export function toAgentHostChatHarness(agentProvider: string): string { + return agentProvider === 'copilotcli' ? 'copilot' : agentProvider; +} + +/** The agent provider of a user-facing harness id; the inverse of {@link toAgentHostChatHarness}. */ +export function fromAgentHostChatHarness(harness: string): string { + return harness === 'copilot' ? 'copilotcli' : harness; +} diff --git a/src/vs/platform/agentHost/common/agentHostProtocolCompatibility.ts b/src/vs/platform/agentHost/common/agentHostProtocolCompatibility.ts index b9245585ae28b..9951384bc2b26 100644 --- a/src/vs/platform/agentHost/common/agentHostProtocolCompatibility.ts +++ b/src/vs/platform/agentHost/common/agentHostProtocolCompatibility.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { getErrorMessage } from '../../../base/common/errors.js'; -import { compareProtocolVersions, negotiateProtocolVersion, SUPPORTED_PROTOCOL_VERSIONS } from './state/protocol/version/registry.js'; +import { compareProtocolVersions, negotiateProtocolVersion, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from './state/protocol/version/registry.js'; import { JsonRpcErrorCodes, ProtocolError } from './state/sessionProtocol.js'; const legacyCompatibleProtocolVersion = '0.10.0'; @@ -25,3 +25,15 @@ export function negotiateAgentHostProtocolVersion(offered: readonly string[]): s export function getAgentHostSupportedProtocolVersions(): string[] { return [...SUPPORTED_PROTOCOL_VERSIONS, legacyCompatibleProtocolVersion].sort((a, b) => compareProtocolVersions(b, a)); } + +/** + * The protocol versions a client offers in `initialize`, most-preferred first. + * With chats of one session using different agents enabled, the client also + * offers the current {@link PROTOCOL_VERSION} first, which hosts require + * before advertising `multipleChatProviders`; otherwise the offer is exactly + * {@link getAgentHostSupportedProtocolVersions}. + */ +export function getAgentHostClientProtocolVersions(multipleChatProviders: boolean): string[] { + const supported = getAgentHostSupportedProtocolVersions(); + return multipleChatProviders && !supported.includes(PROTOCOL_VERSION) ? [PROTOCOL_VERSION, ...supported] : supported; +} diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index 52f9ab250c11e..111a639369412 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -58,6 +58,7 @@ import { AgentHostWorkspaceSnapshotEnabledConfigKey, } from './agentHostSchema.js'; import { AgentMergeConfigKey, AgentMergeSettingId, AGENT_MERGE_SETTING_TAG } from './agentMerge.js'; +import { ChatProvidersConfigKey, ChatProvidersSettingId } from './agentHostChatProviders.js'; import { artifactToolsConfigurationProperties } from './artifactToolsConfiguration.js'; import { chatMoveConfigurationProperties } from './chatMoveConfiguration.js'; import { titleGenerationConfigurationProperties } from './titleGenerationConfiguration.js'; @@ -217,6 +218,14 @@ configurationRegistry.registerConfiguration({ tags: ['experimental', AGENT_MERGE_SETTING_TAG], agentHost: { key: AgentMergeConfigKey.ReplyAttribution }, }, + [ChatProvidersSettingId.Enabled]: { + type: 'boolean', + description: nls.localize('chat.agentHost.multipleChatProviders.enabled', "Enables chats in one Agent Host session to use different agents."), + default: false, + scope: ConfigurationScope.APPLICATION, + tags: ['experimental'], + agentHost: { key: ChatProvidersConfigKey.Enabled }, + }, ...chatMoveConfigurationProperties, ...titleGenerationConfigurationProperties, ...artifactToolsConfigurationProperties, diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 2e4564aa7f0d9..d10248b62dcb1 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -249,6 +249,14 @@ export const AgentHostClaudeSdkRootEnvVar = 'VSCODE_AGENT_HOST_CLAUDE_SDK_ROOT'; */ export const AgentHostClaudeAgentEnabledEnvVar = 'VSCODE_AGENT_HOST_CLAUDE_AGENT_ENABLED'; +/** + * Environment variable form of `chat.agentHost.multipleChatProviders.enabled`, set by the local agent host starters + * only when the setting is on (`'true'`). The host seeds its root `multipleChatProviders.enabled` from it before its + * first handshake, so the first `initialize` can already advertise `multipleChatProviders`. The renderer's mirror of + * the setting stays authoritative afterwards. Not a control of its own: a mirror of the existing setting. + */ +export const AgentHostMultipleChatProvidersEnabledEnvVar = 'VSCODE_AGENT_HOST_MULTIPLE_CHAT_PROVIDERS_ENABLED'; + /** * Environment variable form of {@link AgentHostCodexAgentEnabledSettingId}. * Set by the agent host starters from the setting. Accepts `'true'` / @@ -728,6 +736,8 @@ export interface IAgentSdkStarterSettings { readonly codexBinaryArgs?: readonly string[]; readonly claudeAgentEnabled?: boolean; readonly codexAgentEnabled?: boolean; + /** `chat.agentHost.multipleChatProviders.enabled`; forwarded only when on, so with it off the env is unchanged. */ + readonly multipleChatProvidersEnabled?: boolean; } export function buildAgentSdkEnv( @@ -752,6 +762,9 @@ export function buildAgentSdkEnv( if (settings.codexAgentEnabled !== undefined) { setIfMissing(AgentHostCodexAgentEnabledEnvVar, settings.codexAgentEnabled ? 'true' : 'false'); } + if (settings.multipleChatProvidersEnabled === true) { + setIfMissing(AgentHostMultipleChatProvidersEnabledEnvVar, 'true'); + } return out; } @@ -1236,6 +1249,8 @@ export interface IAgentService { readonly onDidNotification: Event; readonly automationCapabilities: AutomationCapabilities | undefined; + /** Whether chats of one session may use different agents (the `multipleChatProviders.enabled` host setting). */ + readonly multipleChatProvidersEnabled?: boolean; listAutomationTriggerDefinitions(params: ListAutomationTriggerDefinitionsParams): Promise; runAutomation(params: RunAutomationParams): Promise; fetchAutomationRuns(params: FetchAutomationRunsParams): Promise; diff --git a/src/vs/platform/agentHost/common/sessionConfigProperties.ts b/src/vs/platform/agentHost/common/sessionConfigProperties.ts index 209a6b2a67e8f..d8dccab9d3929 100644 --- a/src/vs/platform/agentHost/common/sessionConfigProperties.ts +++ b/src/vs/platform/agentHost/common/sessionConfigProperties.ts @@ -179,10 +179,119 @@ export function filterChatConfigValues(schema: SessionConfigSchema | undefined, return Object.fromEntries(Object.entries(values ?? {}).filter(([key, value]) => value !== undefined && isChatConfigProperty(schema, key))); } -/** A chat's effective config: the session's config with the chat's own values applied over it (see {@link applyChatConfigLayers}). */ -export function getEffectiveChatConfig(session: SessionConfigState, chat: ChatConfigState | undefined): SessionConfigState { - const own = filterChatConfigValues(session.schema, chat?.values); - return Object.keys(own).length === 0 ? session : { schema: session.schema, values: applyChatConfigLayers(session.values, [own]) }; +/** + * The defaults of a chat's own schema with `values` applied over them: the + * effective config of a chat whose agent differs from its session's. An omitted + * value falls back to its schema default. + */ +export function withChatSchemaDefaults(schema: SessionConfigSchema, values: Readonly> | undefined): Record { + const result: Record = {}; + for (const [key, property] of Object.entries(schema.properties)) { + if (property.default !== undefined) { + result[key] = property.default; + } + } + for (const [key, value] of Object.entries(values ?? {})) { + if (value !== undefined) { + result[key] = value; + } + } + return result; +} + +/** + * The chat-applicable part of an agent's config, for a chat whose agent + * differs from its session's: the properties a chat may set, seeded with the + * agent's defaults (`values`) and then with `existing` values the schema accepts. + */ +export function toOwnChatConfig(agentConfig: SessionConfigState, existing?: Readonly>): SessionConfigState { + const properties = Object.fromEntries(Object.entries(agentConfig.schema.properties).filter(([, property]) => property.chatMutable === true)); + const schema: SessionConfigSchema = { ...agentConfig.schema, properties }; + const candidates = { ...agentConfig.values, ...existing }; + const values: Record = {}; + for (const [key, value] of Object.entries(candidates)) { + if (value !== undefined && Object.hasOwn(properties, key) && !getSessionConfigWriteError(agentConfig.schema, candidates, key, value, () => true)) { + values[key] = value; + } + } + return { schema, values }; +} + +/** + * The values of `values` a chat may keep under `schema`: properties the schema marks `chatMutable`, not host-reported + * (`readOnly`), with a value the schema offers. Used when a moved chat's config schema changes with its agent. + */ +export function getValidChatConfigValues(schema: SessionConfigSchema, values: Readonly>): Record { + const valid: Record = {}; + for (const [key, value] of Object.entries(values)) { + const property = schema.properties[key]; + if (value !== undefined && property?.chatMutable === true && property.readOnly !== true && !getSessionConfigWriteError(schema, values, key, value, () => true)) { + valid[key] = value; + } + } + return valid; +} + +/** + * The config schema of a chat: its own (`ChatConfigState.schema`, set for a + * chat whose agent differs from its session's), else its session's. + */ +export function getChatConfigSchema(session: SessionConfigState | undefined, chat: ChatConfigState | undefined): SessionConfigSchema | undefined { + return chat?.schema ?? session?.schema; +} + +/** + * The values a chat is created with, or that an older build's overlay of a + * chat's own values completes to: the `chatMutable` properties of the session's + * effective `values`, with `overlay` (the chat's own values) applied over them. + * Properties that are not `chatMutable` stay session-wide, and `readOnly` ones + * are host-reported, so neither is copied from the session. + */ +export function getCompleteChatConfigValues(session: SessionConfigState, overlay?: Readonly>): Record { + const inherited = Object.entries(filterChatConfigValues(session.schema, session.values)).filter(([key]) => session.schema.properties[key]?.readOnly !== true); + return { ...Object.fromEntries(inherited), ...filterChatConfigValues(session.schema, overlay) }; +} + +/** + * A chat's effective config. A chat with its own `schema` (its agent differs + * from its session's) is that schema with its values over the schema's defaults. + * A chat without config is the session's. + * + * Otherwise it depends on `completeValues`, whether the host publishes a chat's + * complete values (only while chats may use their own agents, the + * `multipleChatProviders.enabled` root config): + * - `false` (default; older hosts, and hosts with the setting off): the chat's + * values are an overlay on its session's: the session's config with the + * chat's own values applied over it (see {@link applyChatConfigLayers}), so a + * property the chat didn't set follows its session. + * - `true`: the chat's values are its complete config, which the session's + * config never changes: its `chatMutable` properties come from its values, + * falling back to the schema's `default`, and the other, session-wide + * properties from the session. + */ +export function getEffectiveChatConfig(session: SessionConfigState, chat: ChatConfigState | undefined, completeValues = false): SessionConfigState { + if (chat?.schema) { + return { schema: chat.schema, values: withChatSchemaDefaults(chat.schema, chat.values) }; + } + if (!chat) { + return session; + } + if (!completeValues) { + const own = filterChatConfigValues(session.schema, chat.values); + return Object.keys(own).length === 0 ? session : { schema: session.schema, values: applyChatConfigLayers(session.values, [own]) }; + } + const values: Record = {}; + for (const [key, value] of Object.entries(session.values)) { + if (!isChatConfigProperty(session.schema, key)) { + values[key] = value; + } + } + for (const [key, property] of Object.entries(session.schema.properties)) { + if (property.chatMutable === true && property.default !== undefined) { + values[key] = property.default; + } + } + return { schema: session.schema, values: { ...values, ...filterChatConfigValues(session.schema, chat.values) } }; } const APPROVAL_KEYS: readonly string[] = [SessionConfigKey.AutoApprove, 'approvalMode']; @@ -235,10 +344,14 @@ export function getChatConfigWriteTarget(chats: readonly Pick config !== undefined); - return present.length === 0 ? undefined : { values: applyChatConfigLayers({}, present.map(config => config.values)) }; + const schema = present.find(config => config.schema !== undefined)?.schema; + return present.length === 0 ? undefined : { ...(schema ? { schema } : {}), values: applyChatConfigLayers({}, present.map(config => config.values)) }; } /** Splits a config patch into the values a chat sets for itself and the values set on the session. */ diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index 28889d0be9b0a..b803973c686e4 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -1037,6 +1037,8 @@ export class AgentSubscriptionManager extends Disposable { const snapshot = await this._subscribe(resource); if (this._subscriptions.get(resource) === entry && entry.snapshotVersion === snapshotVersion) { entry.sub.handleSnapshot(snapshot.state as never, snapshot.fromSeq); + } else { + this._unsubscribeIfReleased(resource); } } catch (error) { if (this._subscriptions.get(resource) === entry && entry.snapshotVersion === snapshotVersion) { @@ -1128,6 +1130,8 @@ export class AgentSubscriptionManager extends Disposable { const snapshot = await this._subscribe(resource); if (this._subscriptions.get(resource) === entry && entry.snapshotVersion === snapshotVersion) { sub.handleSnapshot(snapshot.state as never, snapshot.fromSeq); + } else { + this._unsubscribeIfReleased(resource); } } catch (err) { if (this._subscriptions.get(resource) === entry && entry.snapshotVersion === snapshotVersion) { @@ -1175,6 +1179,17 @@ export class AgentSubscriptionManager extends Disposable { entry.sub.dispose(); } + /** + * Called when a subscribe resolves for an entry that is no longer current. If nothing holds the resource any more, + * its unsubscribe may have reached the host before this subscribe did (e.g. a subscribe resent after a reconnect), + * leaving the host subscribed: release it again. + */ + private _unsubscribeIfReleased(resource: URI): void { + if (!this._subscriptions.has(resource)) { + this._tryUnsubscribe(resource); + } + } + private _tryUnsubscribe(resource: URI): void { try { this._unsubscribe(resource); diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index f6f4b88b400b8..d51683b6ada87 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -1d639930 +5872a476 diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index 08c3738dbda7f..d983ed3475243 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -9,7 +9,7 @@ // Generated from types/actions.ts — do not edit // Run `npm run generate` to regenerate. -import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionChatsReorderedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionWorkingDirectorySetAction, type SessionWorkingDirectoryRemovedAction, type SessionWorkingDirectoryReplacedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionMcpServerBackgroundRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatToolCallAuthRequiredAction, type ChatToolCallAuthResolvedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatTurnResumeAction, type ChatActivityChangedAction, type ChatBackgroundWorkSetAction, type ChatBackgroundWorkRemovedAction, type ChatMovableChangedAction, type ChatChangesetsChangedAction, type ChatCanvasesChangedAction, type CanvasStateChangedAction, type ChatWorkingDirectorySetAction, type ChatWorkingDirectoryRemovedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatIsReadChangedAction, type ChatIsArchivedChangedAction, type ChatConfigChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction, type AutomationCreateRequestedAction, type AutomationUpdateRequestedAction, type AutomationSetAction, type AutomationRemovedAction, type AutomationRunLifecycleChangedAction, type AutomationRunSessionSetAction, type AutomationRunSessionRemovedAction, type AutomationRunPrimarySessionChangedAction, type AutomationRunCancelRequestedAction } from './actions.js'; +import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionChatsReorderedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionWorkingDirectorySetAction, type SessionWorkingDirectoryRemovedAction, type SessionWorkingDirectoryReplacedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionMcpServerBackgroundRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatToolCallAuthRequiredAction, type ChatToolCallAuthResolvedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatTurnResumeAction, type ChatActivityChangedAction, type ChatBackgroundWorkSetAction, type ChatBackgroundWorkRemovedAction, type ChatMovableChangedAction, type ChatChangesetsChangedAction, type ChatCanvasesChangedAction, type CanvasStateChangedAction, type ChatWorkingDirectorySetAction, type ChatWorkingDirectoryRemovedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatIsReadChangedAction, type ChatIsArchivedChangedAction, type ChatConfigChangedAction, type ChatMovedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction, type AutomationCreateRequestedAction, type AutomationUpdateRequestedAction, type AutomationSetAction, type AutomationRemovedAction, type AutomationRunLifecycleChangedAction, type AutomationRunSessionSetAction, type AutomationRunSessionRemovedAction, type AutomationRunPrimarySessionChangedAction, type AutomationRunCancelRequestedAction } from './actions.js'; // ─── Per-channel Action Unions ─────────────────────────────────────────────── @@ -141,6 +141,7 @@ export type ChatAction = | ChatIsReadChangedAction | ChatIsArchivedChangedAction | ChatConfigChangedAction + | ChatMovedAction | ChatInputRequestedAction | ChatInputAnswerChangedAction | ChatInputCompletedAction @@ -190,6 +191,7 @@ export type ServerChatAction = | ChatCanvasesChangedAction | ChatUsageAction | ChatReasoningAction + | ChatMovedAction | ChatInputRequestedAction | ChatTurnsLoadedAction ; @@ -426,6 +428,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChatIsReadChanged]: true, [ActionType.ChatIsArchivedChanged]: true, [ActionType.ChatConfigChanged]: true, + [ActionType.ChatMoved]: false, [ActionType.ChatInputRequested]: false, [ActionType.ChatInputAnswerChanged]: true, [ActionType.ChatInputCompleted]: true, diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts index b681bdc520fb6..db20e7d2b67d3 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts @@ -10,7 +10,7 @@ import { ActionType } from '../common/actions.js'; import type { StringOrMarkdown, FileEditCollection, UsageInfo, URI } from '../common/state.js'; import type { CanvasReference } from '../channels-canvas/state.js'; import type { Changeset } from '../channels-changeset/state.js'; -import type { McpAuthRequirement } from '../channels-session/state.js'; +import type { McpAuthRequirement, SessionConfigSchema } from '../channels-session/state.js'; import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type BackgroundWork, type Message, type ResponsePart, type ToolCallResult, type ToolResultContent, type ChatInputAnswer, type ChatInputRequest, type ChatInputResponseKind, type ConfirmationOption, type ErrorResponsePart, type ToolCallContributor, type ToolCallRiskAssessment, type ToolInput, type Turn } from './state.js'; // ─── Tool Call Action Base ─────────────────────────────────────────────────── @@ -887,10 +887,10 @@ export interface ChatIsArchivedChangedAction { * Config values set on a chat changed. * * Merges `config` into the chat's - * {@link ChatConfigState | `ChatState.config`} overlay, or replaces the - * overlay when `replace` is `true`. Only properties with `chatMutable: true` - * in the session's config schema may be set. To reset a property to the - * session's value, replace the overlay with one that omits the property. + * {@link ChatConfigState | `ChatState.config`} values, or replaces them when + * `replace` is `true`; keys missing after a replace take their property's + * `default`. Only properties with `chatMutable: true` in + * the effective schema may be set. * * @category Chat Actions * @version 1 @@ -904,6 +904,30 @@ export interface ChatConfigChangedAction { replace?: boolean; } +/** + * The chat moved to another session. + * + * Dispatched by the host on the moved chat's channel after a cross-session + * `moveChat`. `provider` and `schema` are the chat's + * {@link ChatState.provider | `provider`} and + * {@link ChatConfigState.schema | `config.schema`} relative to the new + * session; absent clears them. `ChatState` has no session field, so the + * reducer does not store `session`; it tells subscribers which session now + * owns the chat. + * + * @category Chat Actions + * @version 1 + */ +export interface ChatMovedAction { + type: ActionType.ChatMoved; + /** The new owning session URI. */ + session: URI; + /** The chat's provider in the new session; absent clears it. */ + provider?: string; + /** The chat's config schema in the new session; absent clears it. */ + schema?: SessionConfigSchema; +} + // ─── Session Input Actions ────────────────────────────────────────────────── /** @@ -999,6 +1023,7 @@ export type ChatAction = | ChatIsReadChangedAction | ChatIsArchivedChangedAction | ChatConfigChangedAction + | ChatMovedAction | ChatInputRequestedAction | ChatInputAnswerChangedAction | ChatInputCompletedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts index b95911db17862..347b293eca66b 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts @@ -122,6 +122,18 @@ export interface CreateChatParams extends BaseParams { workingDirectories?: URI[]; /** Client-selected initial display title for the new chat. */ title?: string; + /** + * Agent provider ID for the new chat. Absent means the session's provider. + * + * Requires {@link InitializeResult.multipleChatProviders}. Hosts reject with + * `InvalidParams` a provider that is not in the root state's `agents` or + * whose agent does not advertise `multipleChats`. Forks and side chats + * (`source` present) continue the source chat's history, so they always use + * the source chat's agent: absent means that agent, and a different value is + * rejected. Agent capability checks for this request use the chat's agent. + * @stability 1.0 + */ + provider?: string; } // ─── moveChat ──────────────────────────────────────────────────────────────── @@ -191,6 +203,19 @@ export type ChatMoveDestination = * Rejection leaves ownership, catalog order, chat state, and root summaries * unchanged. * + * A moved chat keeps its agent. After the move the host reports + * {@link ChatState.provider | `provider`} on the chat whenever it differs from + * the destination session's provider, including when the chat had none + * because it matched its old session. Hosts MAY refuse, as an unsupported + * destination, a move they cannot support, such as one that changes whether + * the chat's agent matches its session's. + * + * A moved chat's `config.values` move with it unchanged. If a move changes + * whether the chat needs its own `config.schema`, the host sets or clears it, + * or refuses the move as an unsupported destination. After a cross-session + * move the host dispatches `chat/moved` on the chat's channel with its new + * session, `provider` and `config.schema`. + * * On success every moved chat keeps its URI, state, and immutable * `ChatOrigin`. The host commits ownership and catalog order before publishing * `session/chatRemoved`, `session/chatAdded`, `session/chatsReordered`, and diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts index bf367ef4155cc..5c33f287c4bd9 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts @@ -960,6 +960,18 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st }, }; + case ActionType.ChatMoved: { + const { provider: _provider, config, ...rest } = state; + const next: ChatState = action.provider === undefined ? rest : { ...rest, provider: action.provider }; + if (config) { + const { schema: _schema, ...values } = config; + next.config = action.schema === undefined ? values : { ...values, schema: action.schema }; + } else if (action.schema !== undefined) { + next.config = { schema: action.schema, values: {} }; + } + return next; + } + default: softAssertNever(action, log); return state; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts index 9e8fc1fa5d855..803723a634dcd 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts @@ -7,7 +7,7 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import type { ModelSelection } from '../channels-root/state.js'; -import type { AgentSelection, ChangesSummary, McpAuthRequirement, SessionStatus } from '../channels-session/state.js'; +import type { AgentSelection, ChangesSummary, McpAuthRequirement, SessionConfigSchema, SessionStatus } from '../channels-session/state.js'; import type { Changeset } from '../channels-changeset/state.js'; import type { CanvasReference } from '../channels-canvas/state.js'; import type { ContentRef, ErrorInfo, FileEdit, FileEditCollection, StringOrMarkdown, TextRange, TextSelection, URI, UsageInfo } from '../common/state.js'; @@ -66,6 +66,16 @@ export interface ChatState { * compatibility. */ interactivity?: ChatInteractivity; + /** + * Agent provider ID backing this chat. Absent means the session's + * {@link SessionMetadata.provider | `provider`}; hosts set it when it + * differs. The chat's agent never changes, but `provider` is reported + * relative to its session, so `chat/moved` may set or clear it. Like + * `resource`, it is omitted from `session/chatUpdated` changes. Forks and + * side chats use the source chat's agent. + * @stability 1.0 + */ + provider?: string; /** * The subset of the session's * {@link SessionState.workingDirectories | `workingDirectories`} that this @@ -111,9 +121,7 @@ export interface ChatState { */ canvases?: CanvasReference[]; /** - * Configuration values set on this chat, applied over the owning session's - * {@link SessionState.config | `SessionState.config`}. See - * {@link ChatConfigState}. + * This chat's configuration. See {@link ChatConfigState}. */ config?: ChatConfigState; @@ -207,6 +215,11 @@ export interface ChatSummary { * compatibility. */ interactivity?: ChatInteractivity; + /** + * Agent provider ID backing this chat. See {@link ChatState.provider}. + * @stability 1.0 + */ + provider?: string; /** * The subset of the session's working directories this chat uses. * See {@link ChatState.workingDirectories} for the full semantics. @@ -401,18 +414,33 @@ export const enum ChatInteractivity { // ─── Chat Config Types ─────────────────────────────────────────────────────── /** - * Configuration values set on a chat: an overlay on the owning session's - * {@link SessionState.config | `SessionState.config`}. + * A chat's configuration. `values` holds the chat's `chatMutable` properties + * of the effective schema. For a chat without its own `schema`, other + * properties are session-wide and come from the session's config; a chat with + * its own `schema` does not use the session's config. It does not inherit + * from the session: a chat's `values` change only through + * `chat/configChanged` on the chat. * - * It carries only the chat's own values; the session's config schema - * applies. The effective config of a chat is the session's values with - * these values applied over them. Only properties with `chatMutable: true` - * in the session's config schema can be set on a chat. + * Only `chatMutable` properties can be changed with `chat/configChanged`; + * hosts reject other keys. Hosts set the initial `values` when they create + * the chat. When the session has a config, hosts set `config` on every chat. * * @category Chat Config Types */ export interface ChatConfigState { - /** Config values set on this chat, keyed by property id */ + /** + * Config schema for this chat's `chatMutable` properties. Absent means the + * session's schema. Hosts set it, as the chat's own schema, when the chat's + * {@link ChatState.provider | `provider`} differs from the session's; when + * present, clients use it instead of the session's schema. + * @stability 1.0 + */ + schema?: SessionConfigSchema; + /** + * The chat's values for the `chatMutable` properties of the effective + * schema, keyed by property id. A missing key takes that property's + * `default`. + */ values: Record; } diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts index 2b9a74b91e3e6..be601d98e96c1 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts @@ -89,7 +89,7 @@ export interface SessionChatUpdatedAction { /** * Mutable summary fields that changed; omitted fields are unchanged. * - * Identity fields (`resource`) never change and MUST be omitted by + * Identity fields (`resource`, `provider`) never change and MUST be omitted by * senders; receivers SHOULD ignore them if present. */ changes: Partial; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts index 6bd13a1d15fd8..822f13a89dede 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts @@ -175,7 +175,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: if (idx < 0) { return state; } - const { resource: _ignored, ...changes } = action.changes; + const { resource: _resource, provider: _provider, ...changes } = action.changes; const updated = list.slice(); updated[idx] = { ...list[idx], ...changes }; return { ...state, chats: updated }; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index 3aa321b4d2741..e39aa74729f38 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -528,6 +528,11 @@ export interface SessionChatSummary { * backward compatibility. */ interactivity?: ChatInteractivity; + /** + * Agent provider ID backing this chat. See {@link ChatState.provider}. + * @stability 1.0 + */ + provider?: string; /** * Current chat status, matching {@link ChatSummary.status}. * @@ -607,8 +612,8 @@ export interface SessionConfigPropertySchema extends ConfigPropertySchema { /** When `true`, the user may change this property after session creation */ sessionMutable?: boolean; /** - * When `true`, the property may be set per chat with `chat/configChanged`. - * The chat's value overrides the session's value for that chat only. + * When `true`, the property may be changed per chat with + * `chat/configChanged`, affecting only that chat. */ chatMutable?: boolean; } diff --git a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts index cbf8126571a4a..3e7b57f57b841 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts @@ -12,7 +12,7 @@ import type { RootAgentsChangedAction, RootActiveSessionsChangedAction, RootTerm import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionChatsReorderedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionWorkingDirectorySetAction, SessionWorkingDirectoryRemovedAction, SessionWorkingDirectoryReplacedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionMcpServerStartRequestedAction, SessionMcpServerStopRequestedAction, SessionMcpServerBackgroundRequestedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; -import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatTurnResumeAction, ChatActivityChangedAction, ChatBackgroundWorkSetAction, ChatBackgroundWorkRemovedAction, ChatMovableChangedAction, ChatChangesetsChangedAction, ChatCanvasesChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatIsReadChangedAction, ChatIsArchivedChangedAction, ChatConfigChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js'; +import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatTurnResumeAction, ChatActivityChangedAction, ChatBackgroundWorkSetAction, ChatBackgroundWorkRemovedAction, ChatMovableChangedAction, ChatChangesetsChangedAction, ChatCanvasesChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatIsReadChangedAction, ChatIsArchivedChangedAction, ChatConfigChangedAction, ChatMovedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js'; import type { CanvasStateChangedAction } from '../channels-canvas/actions.js'; @@ -87,6 +87,7 @@ export const enum ActionType { ChatIsReadChanged = 'chat/isReadChanged', ChatIsArchivedChanged = 'chat/isArchivedChanged', ChatConfigChanged = 'chat/configChanged', + ChatMoved = 'chat/moved', ChatInputRequested = 'chat/inputRequested', ChatInputAnswerChanged = 'chat/inputAnswerChanged', ChatInputCompleted = 'chat/inputCompleted', @@ -246,6 +247,7 @@ export type StateAction = | ChatIsReadChangedAction | ChatIsArchivedChangedAction | ChatConfigChangedAction + | ChatMovedAction | ChatInputRequestedAction | ChatInputAnswerChangedAction | ChatInputCompletedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts index 57afd07d78524..52b49dbbd6e9d 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts @@ -300,8 +300,24 @@ export interface InitializeResult { * @see {@link /specification/tcp-channel | TCP Channel} */ tcpConnections?: TcpConnectionsCapability; + /** + * Present when chats in one session may be backed by different agents + * (see {@link CreateChatParams.provider} and {@link ChatState.provider}). + * Clients MUST NOT send `CreateChatParams.provider` without it. + * @stability 1.0 + * @see {@link /specification/chat-channel#chat-providers | Chat Channel: Chat providers} + */ + multipleChatProviders?: MultipleChatProvidersCapability; } +/** + * Presence capability for {@link InitializeResult.multipleChatProviders}. + * + * @category Commands + * @stability 1.0 + */ +export interface MultipleChatProvidersCapability { } + /** * Automation features supported by this host authority. * diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index 1a5ab45af272d..e9d2a702b0cfc 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -166,6 +166,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatIsReadChanged]: '0.9.0', [ActionType.ChatIsArchivedChanged]: '0.9.0', [ActionType.ChatConfigChanged]: '1.1.0', + [ActionType.ChatMoved]: '1.1.0', [ActionType.ChatInputRequested]: '0.4.0', [ActionType.ChatInputAnswerChanged]: '0.4.0', [ActionType.ChatInputCompleted]: '0.4.0', diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 97b7b36e98dba..a89ec6732e518 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -669,6 +669,7 @@ export function createChatState(summary: ChatSummary): ChatState { workingDirectories: summary.workingDirectories, ...(summary.changes !== undefined ? { changes: summary.changes } : {}), ...(summary.movable !== undefined ? { movable: summary.movable } : {}), + ...(summary.provider !== undefined ? { provider: summary.provider } : {}), turns: [], activeTurn: undefined, }; @@ -771,6 +772,7 @@ export function chatSummaryFromState(state: ChatState): ChatSummary { if (state.workingDirectories !== undefined) { summary.workingDirectories = state.workingDirectories; } if (state.changes !== undefined) { summary.changes = state.changes; } if (state.movable !== undefined) { summary.movable = state.movable; } + if (state.provider !== undefined) { summary.provider = state.provider; } return summary; } diff --git a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts index c765e05678d88..74558dcaf2ae9 100644 --- a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts +++ b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts @@ -28,6 +28,7 @@ import { AgentHostExitMarker, AgentHostProcessExitDiagnosticsCollector, IAgentHo import { buildAgentHostTelemetryIdEnv, IAgentHostForwardedTelemetryIds } from '../common/agentHostTelemetryEnv.js'; import { AgentHostLaunchKind, AgentHostLaunchKindEnvVar, telemetryLevelToAgentHostValue } from '../common/agentHostTelemetry.js'; import { AgentHostAhpJsonlLoggingSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostIpcChannels, AgentHostOTelCaptureContentSettingId, AgentHostOTelCaptureIdentitySettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, AgentHostOTelPolicyIpcChannel, AgentHostOTelPolicyState, AgentHostRestartIpcChannel, AgentHostWillRestartIpcChannel, buildAgentHostOTelEnv, buildAgentSdkEnv, IAgentHostManagementService, IAgentHostOTelSettings, readAgentHostOTelPolicySettings } from '../common/agentService.js'; +import { ChatProvidersSettingId } from '../common/agentHostChatProviders.js'; import { deepClone } from '../../../base/common/objects.js'; import '../common/agentHostStarter.config.contribution.js'; import { AgentHostAhpJsonlLoggingEnvVar } from '../common/ahpJsonlLogger.js'; @@ -162,6 +163,7 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt codexHome: this._configurationService.getValue(AgentHostCodexAgentCodexHomeSettingId), codexBinaryArgs: this._configurationService.getValue(AgentHostCodexAgentBinaryArgsSettingId), claudeAgentEnabled: this._configurationService.getValue(AgentHostClaudeAgentEnabledSettingId), + multipleChatProvidersEnabled: this._configurationService.getValue(ChatProvidersSettingId.Enabled) === true, codexAgentEnabled: this._configurationService.getValue(AgentHostCodexAgentEnabledSettingId), }, process.env); diff --git a/src/vs/platform/agentHost/node/agentConfigurationService.ts b/src/vs/platform/agentHost/node/agentConfigurationService.ts index 8be2c731ac0ba..8fc4424549ee1 100644 --- a/src/vs/platform/agentHost/node/agentConfigurationService.ts +++ b/src/vs/platform/agentHost/node/agentConfigurationService.ts @@ -17,16 +17,19 @@ import { resolveAgentHostSession } from '../common/agentHostSubscriptionService. import { AgentHostConfigKey, agentHostCustomizationConfigSchema, defaultAgentHostCustomizationConfigValues } from '../common/agentHostCustomizationConfig.js'; import { getAgentCustomizationSettingsEntries, getProviderBackedRootConfigKeys, withAgentCustomizationSettings, type IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js'; import { copilotCliConfigSchema } from '../common/copilotCliConfig.js'; -import { agentMergeRootConfigSchema } from '../common/agentMerge.js'; +import { agentMergeRootConfigSchema, readAgentMergeInjectedConfiguration, withoutAgentMergeElevation } from '../common/agentMerge.js'; import { chatMoveRootConfigSchema } from '../common/agentHostChatMove.js'; +import { ChatProvidersConfigKey, chatProvidersRootConfigSchema } from '../common/agentHostChatProviders.js'; +import { AgentHostMultipleChatProvidersEnabledEnvVar } from '../common/agentService.js'; import { automationRootConfigSchema } from '../common/automationConfig.js'; import { sandboxConfigSchema } from '../common/sandboxConfigSchema.js'; import { agentHostProxyConfigSchema, clientOwnedApprovalRootConfigKeys, platformRootSchema, type ISchema, type SchemaDefinition, type SchemaValue } from '../common/agentHostSchema.js'; import { ProtocolError } from '../common/state/sessionProtocol.js'; import { ActionType, type ActionOrigin } from '../common/state/sessionActions.js'; import type { ChatConfigChangedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; +import type { SessionConfigSchema } from '../common/state/protocol/commands.js'; import { ChatOriginKind, isAhpChatChannel, parseSubagentSessionUri, readSessionWorkspaceless, ROOT_STATE_URI, type SessionConfigState, type URI as ProtocolURI } from '../common/state/sessionState.js'; -import { applyChatConfigLayers, filterChatConfigValues, splitChatConfigPatch } from '../common/sessionConfigProperties.js'; +import { applyChatConfigLayers, filterChatConfigValues, getCompleteChatConfigValues, getEffectiveChatConfig, isChatConfigProperty, splitChatConfigPatch, withChatSchemaDefaults } from '../common/sessionConfigProperties.js'; import { inheritedChatFolders } from './agentChatContext.js'; import { getSessionSummarySources } from './agentHostChatsInUse.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; @@ -124,6 +127,40 @@ export interface IAgentChatConfigurationChangeEvent { readonly origin: ActionOrigin | undefined; } +/** + * The `ChatConfigChanged` actions the host dispatches to complete a chat's values, or to publish what a session's + * default chat takes from its session: they change no chat's effective config. + */ +const chatConfigCompletionActions = new WeakSet(); + +/** The completion actions that leave a chat's own (persisted) values unchanged. */ +const ownConfigUnchangedActions = new WeakSet(); + +/** + * Whether `action` leaves the values a chat persists unchanged: it only publishes what a session's default chat takes + * from its session (for example once its session's config is restored), so persisting it again would write the same row. + */ +export function isChatOwnConfigUnchanged(action: object): boolean { + return ownConfigUnchangedActions.has(action); +} + +/** Whether `action` only completes a chat's values or publishes what a default chat takes from its session. */ +export function isChatConfigCompletion(action: object): boolean { + return chatConfigCompletionActions.has(action); +} + +/** + * Seeds the host's root `multipleChatProviders.enabled` from {@link AgentHostMultipleChatProvidersEnabledEnvVar}, which + * the local starters set when the setting is on, before the host's first handshake: the first `initialize` then + * advertises `multipleChatProviders` and the client needn't re-initialize. Only turns it on: the client's mirror of the + * setting stays authoritative afterwards, and with the setting off nothing is forwarded, so nothing changes. + */ +export function seedMultipleChatProvidersFromEnvironment(configurationService: IAgentConfigurationService, env: Readonly>): void { + if (env[AgentHostMultipleChatProvidersEnabledEnvVar] === 'true' && configurationService.getRootValue(chatProvidersRootConfigSchema, ChatProvidersConfigKey.Enabled) !== true) { + configurationService.updateRootConfig({ [ChatProvidersConfigKey.Enabled]: true }); + } +} + /** The config values a chat being created starts with, live until the chat is registered. */ export interface IAgentChatConfigurationSeed extends IDisposable { /** The chat's own values, including changes made while it was being created. */ @@ -226,7 +263,25 @@ export interface IAgentConfigurationService { * writes during its creation see them. Disposing ends the seed; the caller * registers the chat with {@link IAgentChatConfigurationSeed.values}. */ - seedChatConfig(chat: ProtocolURI, values: Readonly>): IAgentChatConfigurationSeed; + seedChatConfig(chat: ProtocolURI, values: Readonly>, schema?: SessionConfigSchema): IAgentChatConfigurationSeed; + + /** + * Gives the chats of `session` that were registered before its config was known their complete values (a chat's + * values are its complete config: the session's config never changes them). + */ + completeChatConfigs(session: ProtocolURI): void; + + /** + * The complete values of a chat of `session` whose own values are `own`: the session's values of the properties a + * chat may set (without Agent Merge's elevation and host-reported read-only ones), with `own` over them. + */ + getCompleteChatConfigValues(session: ProtocolURI, own: Readonly>): Record; + + /** + * The values of a chat to persist: its published values, except a session's default chat's that its session gives + * it (which follow the session). + */ + getChatPersistedConfigValues(chat: ProtocolURI): Readonly> | undefined; /** Returns all effective session roots, including inherited parent-session roots. */ getEffectiveWorkingDirectories(session: ProtocolURI): readonly string[] | undefined; @@ -327,7 +382,25 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi readonly onDidSessionConfigChange: Event = this._onDidSessionConfigChange.event; private readonly _onDidChatConfigChange = this._register(new Emitter()); readonly onDidChatConfigChange: Event = this._onDidChatConfigChange.event; - private readonly _chatConfigSeeds = new Map }>(); + /** + * Chats whose `ChatState.config` values are their complete config. A chat registered before its session has config, + * or restored from an older build's overlay, is completed once its session's config is known (see + * {@link _completeChatConfig}); until then its values apply over the session's, as older builds read them. + */ + private readonly _completedChats = new Set(); + /** + * The properties a session's default chat publishes from its session rather than sets itself. The session's config + * is its default chat's, as older clients and the session-level pickers and policies write it: the default chat + * takes the session's values of the properties it has not set, and publishes them so its values are complete. + */ + private readonly _inheritedChatKeys = new Map>(); + /** A default chat's own values, by its published values. */ + private readonly _ownValues = new WeakMap>(); + /** Whether chat values were complete when last checked (see {@link _onDidChangeChatValuesComplete}). */ + private _wasChatValuesComplete: boolean | undefined; + /** How many live seeds carry their own schema. */ + private _chatSchemaSeeds = 0; + private readonly _chatConfigSeeds = new Map; readonly schema?: SessionConfigSchema }>(); /** A chat's effective values with the inputs they were computed from, so unchanged inputs return the same object. */ private readonly _chatValues = new Map }>(); /** The keys the config change being reduced changed, by channel, until its envelope is processed. */ @@ -351,17 +424,19 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi const copilotCliSchema = copilotCliConfigSchema.toProtocol(); const agentMergeSchema = agentMergeRootConfigSchema.toProtocol(); const chatMoveSchema = chatMoveRootConfigSchema.toProtocol(); + const chatProvidersSchema = chatProvidersRootConfigSchema.toProtocol(); const automationSchema = automationRootConfigSchema.toProtocol(); this._stateManager.rootState.config = { schema: { type: 'object', - properties: { ...existing?.schema.properties, ...ownSchema.properties, ...sandboxSchema.properties, ...copilotCliSchema.properties, ...agentMergeSchema.properties, ...chatMoveSchema.properties, ...automationSchema.properties }, + properties: { ...existing?.schema.properties, ...ownSchema.properties, ...sandboxSchema.properties, ...copilotCliSchema.properties, ...agentMergeSchema.properties, ...chatMoveSchema.properties, ...chatProvidersSchema.properties, ...automationSchema.properties }, }, values: { ...existing?.values, ...this._loadPersistedRootConfig() }, }; for (const registration of providerConfigurations) { this.registerProviderConfiguration(registration); } + this._wasChatValuesComplete = this._chatValuesComplete; this._register(this._stateManager.onDidRemoveSession(session => { this._sessionSandboxPolicies.delete(session); this._sessionSandboxUserSelections.delete(session); @@ -369,7 +444,23 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi // Reduction precedes the envelope and knows the previous values, so a replacement's removed keys are known too. this._register(this._stateManager.onDidChangeSessionConfig(event => this._reducedChanges.set(event.session.toString(), changedConfigKeys(event.previous?.values, event.current?.values)))); this._register(this._stateManager.onDidChangeChatConfig(event => this._reducedChanges.set(event.chat.toString(), changedConfigKeys(event.previous?.values, event.current?.values)))); + this._register(this._stateManager.onDidRegisterChat(({ chat, session }) => { + // After the registration: the chat is announced with the values it was registered with. + queueMicrotask(() => { + if (!this._store.isDisposed && this._stateManager.getChatOwner(chat) === session) { + this._completeChatConfig(session, chat); + } + }); + })); + this._register(this._stateManager.onDidChangeChatOwner(({ chat, current }) => { + // A default chat that moves becomes a peer: what it took from its session is its own from then on. + if (this._stateManager.getDefaultChat(current) !== chat && this._inheritedChatKeys.delete(chat)) { + this._completedChats.add(chat); + } + })); this._register(this._stateManager.onDidUnregisterChat(({ chat }) => { + this._completedChats.delete(chat); + this._inheritedChatKeys.delete(chat); this._chatValues.delete(chat); this._chatSandboxChanges.delete(chat); this._chatSandboxUserSelections.delete(chat); @@ -377,11 +468,13 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi this._register(this._stateManager.onDidEmitEnvelope(envelope => { if (envelope.action.type === ActionType.RootConfigChanged) { + this._onDidChangeChatValuesComplete(); for (const session of this._stateManager.getSessionUris()) { this._publishSessionSandboxPolicy(session); } this._onDidRootConfigChange.fire(); } else if (envelope.action.type === ActionType.SessionConfigChanged) { + const clearedDefaultChatKeys = this._clearDefaultChatOwnValues(envelope.channel, envelope.action.replace ? [...new Set([...Object.keys(envelope.action.config), ...(this._reducedChanges.get(envelope.channel) ?? [])])] : Object.keys(envelope.action.config)); const sandboxConfigChanged = Object.hasOwn(envelope.action.config, SessionConfigKey.SandboxEnabled); if (sandboxConfigChanged && !this._sandboxRollbackActions.has(envelope.action)) { // A new selection for the chats that take the session's. @@ -398,14 +491,35 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi if (Object.hasOwn(envelope.action.config, SessionConfigKey.SandboxEnabled)) { this._recordSandboxChanges(envelope.channel, undefined); } + const changedKeys = this._changedKeys(envelope.channel, envelope.action.config); + // The session may have just got its config: the chats registered before it take theirs now. + this.completeChatConfigs(envelope.channel); this._fireSessionConfigChange({ session: envelope.channel, config: envelope.action.config, origin: envelope.origin, - }, this._changedKeys(envelope.channel, envelope.action.config)); + }, changedKeys); + this._syncDefaultChatConfig(envelope.channel, clearedDefaultChatKeys); } else if (envelope.action.type === ActionType.ChatConfigChanged) { const session = this._stateManager.getChatOwner(envelope.channel); - if (session !== undefined) { + if (chatConfigCompletionActions.has(envelope.action)) { + // Completing a chat's values changes none of its effective values. + this._changedKeys(envelope.channel, envelope.action.config); + } else if (session !== undefined) { + const inherited = this._inheritedChatKeys.get(envelope.channel); + if (inherited) { + // What a default chat sets is its own from here on; a replacement leaves it only what it sets. + for (const key of envelope.action.replace ? [...inherited] : Object.keys(envelope.action.config)) { + inherited.delete(key); + } + if (envelope.action.replace) { + queueMicrotask(() => { + if (!this._store.isDisposed) { + this._syncDefaultChatConfig(session); + } + }); + } + } if (Object.hasOwn(envelope.action.config, SessionConfigKey.SandboxEnabled)) { if (!this._sandboxRollbackActions.has(envelope.action)) { const changed = envelope.channel; @@ -458,34 +572,56 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi schema: ISchema, key: K, ): SchemaValue | undefined { - return this._firstValid(this._chatChain(target), schema, key, target.chat); + return this._firstValid(this._chatChain(target, key), schema, key, target.chat); } getChatConfigState(target: IAgentChatConfigurationTarget): SessionConfigState | undefined { + const own = this._ownChatSchema(target.chat); + if (own) { + return { schema: own.schema, values: this.getChatConfigValues(target)! }; + } const config = this._stateManager.getSessionState(target.session)?.config; const values = this.getChatConfigValues(target); return config && values && values !== config.values ? { schema: config.schema, values } : config; } getChatConfigValues(target: IAgentChatConfigurationTarget): Record | undefined { + const own = this._ownChatSchema(target.chat); + if (own) { + // A chat whose agent differs from its session's has its own config: it does not inherit the session's. + const inputs = [own.schema, ...[...this._chatAncestry(target.chat)].map(chat => this._ownChatValues(chat))]; + const cached = this._chatValues.get(target.chat); + if (cached && cached.inputs.length === inputs.length && cached.inputs.every((input, index) => input === inputs[index])) { + return cached.values; + } + const values = applyChatConfigLayers(withChatSchemaDefaults(own.schema, undefined), [...this._chatLayers(target)]); + this._chatValues.set(target.chat, { inputs, values }); + return values; + } const session = this._stateManager.getSessionState(target.session)?.config; if (!session) { return undefined; } - const inputs = [target.session, session, ...[...this._chatAncestry(target.chat)].map(chat => this._ownChatValues(chat))]; + const complete = this._isChatConfigComplete(target.chat); + const inputs = [target.session, session, complete, ...[...this._chatAncestry(target.chat)].map(chat => this._ownChatValues(chat))]; const cached = this._chatValues.get(target.chat); if (cached && cached.inputs.length === inputs.length && cached.inputs.every((input, index) => input === inputs[index])) { return cached.values; } - const layers = [...this._chatLayers(target)]; - const values = layers.length === 0 ? session.values : applyChatConfigLayers(session.values, layers); + let values: Record; + if (complete) { + // The chat's values are its complete config; only session-wide properties come from the session. + values = getEffectiveChatConfig(session, { values: this._ownChatValues(this._configurableChat(target.chat)) ?? {} }).values; + } else { + const layers = [...this._chatLayers(target)]; + values = layers.length === 0 ? session.values : applyChatConfigLayers(session.values, layers); + } this._chatValues.set(target.chat, { inputs, values }); return values; } getChatOwnConfigValues(target: IAgentChatConfigurationTarget): Record { - const schema = this._stateManager.getSessionState(target.session)?.config?.schema; - return filterChatConfigValues(schema, this._ownChatValues(this._configurableChat(target.chat))); + return filterChatConfigValues(this._chatSchema(target), this._ownChatValues(this._configurableChat(target.chat))); } updateChatConfig(target: IAgentChatConfigurationTarget, patch: Record): void { @@ -493,9 +629,15 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi } private _updateChatConfig(target: IAgentChatConfigurationTarget, patch: Record, isSandboxRollback: boolean): void { - const { chat, session } = splitChatConfigPatch(this._stateManager.getSessionState(target.session)?.config?.schema, patch); + const ownSchema = this._ownChatSchema(target.chat)?.schema; + const { chat, session } = splitChatConfigPatch(ownSchema ?? this._stateManager.getSessionState(target.session)?.config?.schema, patch); if (Object.keys(session).length > 0) { - this.updateSessionConfig(target.session, session); + if (ownSchema) { + // The session's agent owns the session's config; a chat with its own agent never changes it. + this._logService.warn(`[AgentConfigurationService] Dropping config the schema of chat ${target.chat} does not have: ${Object.keys(session).join(', ')}`); + } else { + this.updateSessionConfig(target.session, session); + } } if (Object.keys(chat).length === 0) { return; @@ -512,10 +654,16 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi } } - seedChatConfig(chat: ProtocolURI, values: Readonly>): IAgentChatConfigurationSeed { - const seed = { values: { ...values } }; + seedChatConfig(chat: ProtocolURI, values: Readonly>, schema?: SessionConfigSchema): IAgentChatConfigurationSeed { + const seed = { values: { ...values }, schema }; this._chatConfigSeeds.set(chat, seed); + if (schema) { + this._chatSchemaSeeds++; + } const disposable = toDisposable(() => { + if (schema) { + this._chatSchemaSeeds--; + } if (this._chatConfigSeeds.get(chat) === seed) { this._chatConfigSeeds.delete(chat); } @@ -528,7 +676,22 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi /** The chat's own values, from its seed while it is being created. */ private _ownChatValues(chat: ProtocolURI): Readonly> | undefined { - return this._chatConfigSeeds.get(chat)?.values ?? this._stateManager.getChatConfig(chat)?.values; + const seed = this._chatConfigSeeds.get(chat)?.values; + if (seed) { + return seed; + } + const values = this._stateManager.getChatConfig(chat)?.values; + const inherited = this._inheritedChatKeys.get(chat); + if (!values || !inherited || inherited.size === 0) { + return values; + } + const cached = this._ownValues.get(values); + if (cached) { + return cached; + } + const own = Object.fromEntries(Object.entries(values).filter(([key]) => !inherited.has(key))); + this._ownValues.set(values, own); + return own; } /** The chat whose own values `chat` uses: the chat itself, or for a subagent the nearest ancestor that is not one. */ @@ -557,21 +720,264 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi } } - /** The own values of the chat and of the chats it was spawned by, innermost first, restricted to properties a chat may set. */ + /** + * The own values of the chat and of the chats it was spawned by, innermost first, restricted to properties a chat + * may set. A chat with its own agent ends the chain: it does not take values of chats of another agent. + */ private *_chatLayers(target: IAgentChatConfigurationTarget): Iterable> { - const schema = this._stateManager.getSessionState(target.session)?.config?.schema; + const own = this._ownChatSchema(target.chat); + const schema = own?.schema ?? this._stateManager.getSessionState(target.session)?.config?.schema; for (const chat of this._chatAncestry(target.chat)) { - const own = filterChatConfigValues(schema, this._ownChatValues(chat)); - if (Object.keys(own).length > 0) { - yield own; + const values = filterChatConfigValues(schema, this._ownChatValues(chat)); + if (Object.keys(values).length > 0) { + yield values; + } + if (chat === own?.chat) { + return; + } + } + } + + /** + * The value bags of a chat's effective config for `key`, in precedence order. A chat's values are its complete + * config, so a property it may set comes from its values, its schema's default and the host, never the session; a + * session-wide property comes from the session and the host. A chat with its own agent takes everything from its + * own values and schema. A chat whose values are not complete yet still applies them over the session's. + */ + private *_chatChain(target: IAgentChatConfigurationTarget, key: string): Iterable> { + const host = this._stateManager.rootState.config?.values; + const own = this._ownChatSchema(target.chat); + if (own) { + yield* this._chatLayers(target); + yield withChatSchemaDefaults(own.schema, undefined); + if (host) { + yield host; + } + return; + } + if (!this._isChatConfigComplete(target.chat)) { + yield* this._chatLayers(target); + yield* this._effectiveChain(target.session); + return; + } + const schema = this._stateManager.getSessionState(target.session)?.config?.schema; + if (!isChatConfigProperty(schema, key)) { + yield* this._effectiveChain(target.session); + return; + } + yield filterChatConfigValues(schema, this._ownChatValues(this._configurableChat(target.chat))); + yield withChatSchemaDefaults({ type: 'object', properties: { [key]: schema!.properties[key] } }, undefined); + if (host) { + yield host; + } + } + + /** + * Whether a chat's values are its complete config: only while chats may use their own agents (the root + * `multipleChatProviders.enabled`). Otherwise a chat's values are an overlay on its session's, as before: a property + * it didn't set follows its session, and the host neither completes, mirrors nor rewrites any chat's values. + */ + private get _chatValuesComplete(): boolean { + return this.getRootValue(chatProvidersRootConfigSchema, ChatProvidersConfigKey.Enabled) === true; + } + + /** + * Follows the setting at runtime. Turned on: the loaded chats take their complete values now (unloaded ones when + * restored). Turned off: chats read their values as overlays again, which complete values remain valid as; a session's + * default chat publishes only its own values again, so it follows its session, and nothing is written. + */ + private _onDidChangeChatValuesComplete(): void { + const complete = this._chatValuesComplete; + const previous = this._wasChatValuesComplete; + this._wasChatValuesComplete = complete; + if (previous === undefined || previous === complete) { + return; + } + if (complete) { + for (const session of this._stateManager.getSessionUris()) { + this.completeChatConfigs(session); + } + return; + } + this._completedChats.clear(); + for (const [chat, inherited] of [...this._inheritedChatKeys]) { + this._inheritedChatKeys.delete(chat); + const values = this._stateManager.getChatConfig(chat)?.values; + if (!values || inherited.size === 0 || this._stateManager.getChatOwner(chat) === undefined) { + continue; } + const action = { type: ActionType.ChatConfigChanged as const, config: Object.fromEntries(Object.entries(values).filter(([key]) => !inherited.has(key))), replace: true }; + chatConfigCompletionActions.add(action); + ownConfigUnchangedActions.add(action); + this._stateManager.dispatchServerAction(chat, action); + } + } + + /** Whether the chat `chat` takes its config from has complete values (always so for a chat with its own agent). */ + private _isChatConfigComplete(chat: ProtocolURI): boolean { + const configurable = this._configurableChat(chat); + if (this._ownChatSchema(configurable) !== undefined) { + return true; + } + if (!this._chatValuesComplete) { + return false; + } + // A session's default chat follows its session for what it has not set itself, also one a move promoted. + const owner = this._stateManager.getChatOwner(configurable); + return this._completedChats.has(configurable) && (owner === undefined || this._stateManager.getDefaultChat(owner) !== configurable); + } + + /** + * Completes the values of the chats of `session` registered before its config was known, or restored from an + * older build's overlay (see {@link _completeChatConfig}). + */ + completeChatConfigs(session: ProtocolURI): void { + for (const chat of this._stateManager.getSessionState(session)?.chats ?? []) { + this._completeChatConfig(session, chat.resource); } } - /** The value bags of a chat's effective config in precedence order: its layers, the session, the host. */ - private *_chatChain(target: IAgentChatConfigurationTarget): Iterable> { - yield* this._chatLayers(target); - yield* this._effectiveChain(target.session); + /** + * Makes a chat's values its complete config, once, as soon as its session has config: the session's values of the + * properties a chat may set, with the chat's own values over them. A chat created with complete values, or one + * completed by an earlier run, changes at most by properties its session's schema gained since. Subagents have no + * values of their own, and a chat with its own agent is complete from its creation. + */ + private _completeChatConfig(session: ProtocolURI, chat: ProtocolURI): void { + if (!this._chatValuesComplete) { + // A chat's values are an overlay on its session's, as before chats could use their own agents. + return; + } + if (this._stateManager.getDefaultChat(session) === chat) { + this._syncDefaultChatConfig(session); + return; + } + if (this._completedChats.has(chat) || this._stateManager.getChatOrigin(chat)?.kind === ChatOriginKind.Tool || this._ownChatSchema(chat)) { + return; + } + const config = this._stateManager.getSessionState(session)?.config; + if (!config) { + return; + } + this._completedChats.add(chat); + if (this._chatConfigSeeds.has(chat)) { + // A chat being created is registered with the complete values it was seeded with. + return; + } + const own = this._ownChatValues(chat) ?? {}; + const complete = this.getCompleteChatConfigValues(session, own); + const patch = Object.fromEntries(Object.entries(complete).filter(([key, value]) => !Object.hasOwn(own, key) || !equals(own[key], value))); + // Every chat of a session with config publishes its config, even with no values. + if (Object.keys(patch).length === 0 && this._stateManager.getChatConfig(chat) !== undefined) { + return; + } + const action = { type: ActionType.ChatConfigChanged as const, config: patch }; + chatConfigCompletionActions.add(action); + this._stateManager.dispatchServerAction(chat, action); + } + + /** + * A session config write changes the session's default chat too: the default chat's own values of the written + * properties it may set are dropped, so it takes the session's again (session-level pickers are its pickers). Peers, + * forks and side chats keep theirs. Host-reported (read-only) properties are not the default chat's choice and stay. + * Returns whether any own value was dropped. + */ + private _clearDefaultChatOwnValues(session: ProtocolURI, keys: readonly string[]): boolean { + if (!this._chatValuesComplete) { + return false; + } + const chat = this._stateManager.getDefaultChat(session); + const schema = this._stateManager.getSessionState(session)?.config?.schema; + const values = chat === undefined ? undefined : this._stateManager.getChatConfig(chat)?.values; + if (chat === undefined || !schema || !values || this._ownChatSchema(chat) || this._chatConfigSeeds.has(chat)) { + return false; + } + const own = this._ownChatValues(chat) ?? {}; + const cleared = keys.filter(key => isChatConfigProperty(schema, key) && schema.properties[key]?.readOnly !== true && Object.hasOwn(own, key)); + if (cleared.length === 0) { + return false; + } + let inherited = this._inheritedChatKeys.get(chat); + if (!inherited) { + inherited = new Set(); + this._inheritedChatKeys.set(chat, inherited); + } + for (const key of cleared) { + inherited.add(key); + } + // The own values are cached by the published ones, which have not changed yet. + this._ownValues.delete(values); + if (cleared.includes(SessionConfigKey.SandboxEnabled)) { + this._chatSandboxUserSelections.delete(chat); + } + return true; + } + + getChatPersistedConfigValues(chat: ProtocolURI): Readonly> | undefined { + return this._chatConfigSeeds.has(chat) ? this._stateManager.getChatConfig(chat)?.values : this._ownChatValues(chat); + } + + getCompleteChatConfigValues(session: ProtocolURI, own: Readonly>): Record { + const config = this._stateManager.getSessionState(session)?.config; + if (!config || !this._chatValuesComplete) { + return { ...own }; + } + // Agent Merge's elevation belongs to the session, not to a copy in a chat. + const values = withoutAgentMergeElevation(config.values, readAgentMergeInjectedConfiguration(config.values)); + return getCompleteChatConfigValues({ schema: config.schema, values: { ...values } }, own); + } + + /** + * Publishes a session's default chat's complete values: the session's values of the properties a chat may set that + * the default chat has not set itself, with its own over them (see {@link _inheritedChatKeys}). + */ + private _syncDefaultChatConfig(session: ProtocolURI, force = false): void { + if (!this._chatValuesComplete) { + return; + } + const chat = this._stateManager.getDefaultChat(session); + if (chat === undefined || !this._stateManager.getSessionState(session)?.config || this._stateManager.getChatOwner(chat) === undefined || this._ownChatSchema(chat)) { + return; + } + const own = this._ownChatValues(chat) ?? {}; + const inheritedValues = Object.fromEntries(Object.entries(this.getCompleteChatConfigValues(session, {})).filter(([key]) => !Object.hasOwn(own, key))); + const next = { ...inheritedValues, ...own }; + const current = this._stateManager.getChatConfig(chat)?.values; + this._inheritedChatKeys.set(chat, new Set(Object.keys(inheritedValues))); + // Forced when the chat's own values changed but its published ones did not, so that they are persisted. + if (!force && current && equals(current, next)) { + return; + } + const action = { type: ActionType.ChatConfigChanged as const, config: next, replace: true }; + chatConfigCompletionActions.add(action); + if (!force) { + // Its own values are unchanged: only what it takes from its session is published, which is never persisted. + ownConfigUnchangedActions.add(action); + } + this._stateManager.dispatchServerAction(chat, action); + } + + /** + * The chat whose agent differs from its session's that `chat` takes its config from, with its own schema: the chat + * itself or the chat that spawned it. `undefined` when the chat uses its session's config. + */ + private _ownChatSchema(chat: ProtocolURI): { readonly chat: ProtocolURI; readonly schema: SessionConfigSchema } | undefined { + if (this._chatSchemaSeeds === 0 && !this._stateManager.hasChatsWithOwnConfigSchema()) { + // No chat uses an agent other than its session's (always the case behind the off setting). + return undefined; + } + for (const candidate of this._chatAncestry(chat)) { + const schema = this._chatConfigSeeds.get(candidate)?.schema ?? this._stateManager.getChatOwnConfigSchema(candidate); + if (schema) { + return { chat: candidate, schema }; + } + } + return undefined; + } + + /** The schema of the properties of a chat's config: its own, or the session's. */ + private _chatSchema(target: IAgentChatConfigurationTarget): SessionConfigSchema | undefined { + return this._ownChatSchema(target.chat)?.schema ?? this._stateManager.getSessionState(target.session)?.config?.schema; } private _effectiveValuesOf(target: IAgentChatConfigurationTarget, keys: readonly string[]): Record { @@ -591,8 +997,15 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi this._onDidSessionConfigChange.fire(event); for (const chat of this._stateManager.getSessionState(event.session)?.chats ?? []) { const target = { session: event.session, chat: chat.resource }; - const overridden = new Set([...this._chatLayers(target)].flatMap(layer => Object.keys(layer))); - const changed = keys.filter(key => !overridden.has(key)); + if (this._ownChatSchema(chat.resource)) { + // A chat with its own agent does not take the session's values. + continue; + } + // A chat with complete values takes only the session-wide properties from its session. + const schema = this._stateManager.getSessionState(event.session)?.config?.schema; + const changed = this._isChatConfigComplete(chat.resource) + ? keys.filter(key => !isChatConfigProperty(schema, key)) + : (overridden => keys.filter(key => !overridden.has(key)))(new Set([...this._chatLayers(target)].flatMap(layer => Object.keys(layer)))); if (changed.length > 0) { this._onDidChatConfigChange.fire({ ...target, config: this._effectiveValuesOf(target, changed), origin: event.origin }); } @@ -637,6 +1050,8 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi /** Reconciles restored selections with current policy before notifying live provider runtimes. */ restoreSessionConfig(session: ProtocolURI, config: SessionConfigState): void { this._stateManager.setSessionConfig(session, config); + // Chats restored from an older build's overlay of their own values take their complete values now. + this.completeChatConfigs(session); this._clearSandboxRejectedSelections(session, () => true, true); if (!this._publishSessionSandboxPolicy(session)) { this._fireSessionConfigChange({ @@ -691,7 +1106,7 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi const target = { session, chat: chat.resource }; const preserveChatSelection = !policyChanged && this._chatSandboxUserSelections.has(chat.resource) && policy?.allowBypass === true; if (!policy?.failClosed && !preserveChatSelection && this._stateManager.getChatOrigin(chat.resource)?.kind !== ChatOriginKind.Tool - && this._stateManager.getChatConfig(chat.resource)?.values[SessionConfigKey.SandboxEnabled] === 'off' + && this._ownChatValues(chat.resource)?.[SessionConfigKey.SandboxEnabled] === 'off' && getSessionSandboxOverrides(this, target).enabled === AgentSandboxEnabledValue.On) { this.updateChatConfig(target, { [SessionConfigKey.SandboxEnabled]: AgentSandboxEnabledValue.On }); } @@ -710,7 +1125,7 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi getSessionSandboxEnabled(session: ProtocolURI): boolean | undefined { const defaultChat = this._stateManager.getDefaultChat(session); - if (defaultChat !== undefined && !Object.hasOwn(this._stateManager.getChatConfig(defaultChat)?.values ?? {}, SessionConfigKey.SandboxEnabled)) { + if (defaultChat !== undefined && !Object.hasOwn(this._ownChatValues(defaultChat) ?? {}, SessionConfigKey.SandboxEnabled)) { return readSessionSandboxState(this._stateManager.getSessionState(session))?.enabled; } const selection = this.getSessionConfigValues(session)?.[SessionConfigKey.SandboxEnabled]; @@ -956,6 +1371,7 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi ...copilotCliConfigSchema.validateOrDefault(parsed, {}), ...agentMergeRootConfigSchema.validateOrDefault(parsed, {}), ...chatMoveRootConfigSchema.validateOrDefault(parsed, {}), + ...chatProvidersRootConfigSchema.validateOrDefault(parsed, {}), ...automationRootConfigSchema.validateOrDefault(parsed, {}), ...agentHostProxyConfigSchema.validateOrDefault(parsed, {}), }; diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index 5ff530d98c8b3..a4892a3d67cb9 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -1032,7 +1032,8 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC private _waitForTurnEdits(owner: ProtocolURI, turnId: string): Promise | undefined { const session = this._containingSessionUri(owner); const chat = this._chatOwnership.getChatOwner(owner) !== undefined ? owner : this._chatOwnership.getDefaultChat(session); - return chat ? this._providerService.getProviderForSession(session)?.waitForTurnEdits?.(URI.parse(chat), turnId) : undefined; + // The chat's own agent writes its edits. + return chat ? this._providerService.getProviderForChat(session, chat)?.waitForTurnEdits?.(URI.parse(chat), turnId) : undefined; } private async _computeTurnDiffsUsingGit(session: ProtocolURI, turnId: string, source: GitTurnDiffSource): Promise { diff --git a/src/vs/platform/agentHost/node/agentHostChatInputService.ts b/src/vs/platform/agentHost/node/agentHostChatInputService.ts index c41e3f1eee395..109946edfa8e8 100644 --- a/src/vs/platform/agentHost/node/agentHostChatInputService.ts +++ b/src/vs/platform/agentHost/node/agentHostChatInputService.ts @@ -63,7 +63,7 @@ export class AgentHostChatInputService extends Disposable implements IAgentHostC const state = this._stateManager.getChatState(chatKey); const sessionState = this._stateManager.getSessionState(session); const previous = readChatInputState(sessionState, chatKey); - const provider = this._providers.getProviderForSession(session); + const provider = this._providers.getProviderForChat(session, chatKey); if (this._store.isDisposed || !state || !sessionState || !provider?.chats.prepareChat || state.activeTurn || state.interactivity === ChatInteractivity.Hidden || isSessionStatusArchived(sessionState.status) || isSessionStatusArchived(state.status) diff --git a/src/vs/platform/agentHost/node/agentHostDatabase.ts b/src/vs/platform/agentHost/node/agentHostDatabase.ts index d34222876771a..124fcb0a0ddbc 100644 --- a/src/vs/platform/agentHost/node/agentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/agentHostDatabase.ts @@ -423,6 +423,15 @@ export interface IAgentHostDatabase extends IDisposable, IAgentHostSessionStateD * storage identity record gets one. */ recordChatStorageIdentity(session: string, chat: string, role: ChatStorageRole): void; + /** + * The agents of chats whose agent differs from their session's, keyed by chat URI. Kept in `metadata` under + * `chatAgentProvider:`, outside every validated catalog payload, so older builds never see them. + */ + readChatAgentProviders?(): Promise>; + /** Records the agent of a chat whose agent differs from its session's. */ + recordChatAgentProvider?(chat: string, provider: string): Promise; + /** Forgets the agents of deleted chats. */ + deleteChatAgentProviders?(chats: readonly string[]): Promise; /** * Records an identity in the legacy session registry for compatibility. @@ -1192,6 +1201,9 @@ function hiddenSessionKey(session: string): string { /** Metadata key prefixes of the records that map runtime identities to their storage spelling. */ export type StorageIdentityMetadataPrefix = 'sessionStorageIdentity:' | 'chatStorageIdentity:'; +/** Metadata key prefix of the agent of a chat whose agent differs from its session's. */ +const CHAT_AGENT_PROVIDER_KEY_PREFIX = 'chatAgentProvider:'; + function isStorageIdentityMetadataKey(key: string): boolean { return key.startsWith('sessionStorageIdentity:') || key.startsWith('chatStorageIdentity:'); } @@ -1310,6 +1322,23 @@ export class AgentHostDatabase implements IAgentHostDatabase { // This database stores runtime identities as they are. } + async readChatAgentProviders(): Promise> { + // `;` follows `:`, so the range holds exactly the keys starting with the prefix. + const rows = await all(await this._ensureDatabase(), 'SELECT key, value FROM metadata WHERE key >= ? AND key < ?', [CHAT_AGENT_PROVIDER_KEY_PREFIX, CHAT_AGENT_PROVIDER_KEY_PREFIX.slice(0, -1) + ';']); + return new Map(rows.map(row => [(row.key as string).slice(CHAT_AGENT_PROVIDER_KEY_PREFIX.length), row.value as string])); + } + + recordChatAgentProvider(chat: string, provider: string): Promise { + return this._run('INSERT INTO metadata (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', [CHAT_AGENT_PROVIDER_KEY_PREFIX + chat, provider]); + } + + async deleteChatAgentProviders(chats: readonly string[]): Promise { + for (let offset = 0; offset < chats.length; offset += SESSION_CHAT_INSERT_BATCH_SIZE) { + const batch = chats.slice(offset, offset + SESSION_CHAT_INSERT_BATCH_SIZE); + await this._run(`DELETE FROM metadata WHERE key IN (${batch.map(() => '?').join(', ')})`, batch.map(chat => CHAT_AGENT_PROVIDER_KEY_PREFIX + chat)); + } + } + async registerSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { const { provider, startTime, modifiedTime = startTime, source } = sessionOptions; diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 44f7ac0e5b444..d7c3453ae73cf 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -21,7 +21,7 @@ import { AgentHostCodexEnabledConfigKey, platformRootSchema } from '../common/ag import { AgentModelRefreshScheduler, MODEL_REFRESH_INTERVAL_MS } from './agentModelRefreshScheduler.js'; import { AgentService } from './agentService.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; -import { IAgentConfigurationService } from './agentConfigurationService.js'; +import { IAgentConfigurationService, seedMultipleChatProvidersFromEnvironment } from './agentConfigurationService.js'; import { IAgentHostCompletions } from './agentHostCompletions.js'; import { CopilotAgent } from './copilot/copilotAgent.js'; import { ClaudeAgent } from './claude/claudeAgent.js'; @@ -180,6 +180,10 @@ async function startAgentHost(): Promise { completions: accessor.get(IAgentHostCompletions), })); const agentConfigurationService = runtimeServices.configurationService; + // The starter forwards the multiple-chat-providers setting when it is on: seeded before the first handshake, so the + // first `initialize` advertises the capability and the client needn't re-initialize. The client's mirror of the + // setting stays authoritative afterwards; with the setting off nothing is forwarded and nothing changes. + seedMultipleChatProvidersFromEnvironment(agentConfigurationService, process.env); fileService = runtimeServices.fileService; proxyResolver = runtimeServices.proxyResolver; stateManager = runtimeServices.stateManager; diff --git a/src/vs/platform/agentHost/node/agentHostProviderService.ts b/src/vs/platform/agentHost/node/agentHostProviderService.ts index ae9b69ac23e57..9fcebf49a7369 100644 --- a/src/vs/platform/agentHost/node/agentHostProviderService.ts +++ b/src/vs/platform/agentHost/node/agentHostProviderService.ts @@ -34,9 +34,19 @@ export interface IAgentHostProviderService { resolveProvider(provider?: AgentProvider): IAgent | undefined; getProvider(provider: AgentProvider): IAgent | undefined; getProviderForSession(session: URI | string): IAgent | undefined; + /** + * Resolves the agent backing `chat` of `session`: the chat's own agent when + * one is associated with it (a chat whose agent differs from its session's), + * otherwise the session's agent. `undefined` when the chat's own agent is not + * registered: such a chat never falls back to its session's agent. + */ + getProviderForChat(session: URI | string, chat: URI | string | undefined): IAgent | undefined; getProviders(): readonly IAgent[]; associateSession(session: URI | string, provider: AgentProvider): void; releaseSession(session: URI | string, expectedProvider?: AgentProvider): void; + /** Records the agent of a chat whose agent differs from its session's. A chat's agent never changes. */ + associateChat(chat: URI | string, provider: AgentProvider): void; + releaseChat(chat: URI | string): void; authenticate(params: AuthenticateParams, scope?: IAgentHostAuthenticationScope): Promise; handleMcpRequest(channel: string, method: string, params: Record | undefined): Promise; getNetworkDiagnostics(): Promise; @@ -50,6 +60,7 @@ export class AgentHostProviderService extends Disposable implements IAgentHostPr private readonly _providerRegistrations = this._register(new DisposableMap()); private readonly _providers = this._register(new DisposableMap()); private readonly _sessionToProvider = new Map(); + private readonly _chatToProvider = new Map(); private readonly _agents: ISettableObservable = observableValue(this, []); readonly agents: IObservable = this._agents; private readonly _onDidRegisterProvider = this._register(new Emitter()); @@ -140,6 +151,11 @@ export class AgentHostProviderService extends Disposable implements IAgentHostPr return undefined; } + getProviderForChat(session: URI | string, chat: URI | string | undefined): IAgent | undefined { + const chatProvider = chat === undefined ? undefined : this._chatToProvider.get(typeof chat === 'string' ? chat : chat.toString()); + return chatProvider !== undefined ? this._providers.get(chatProvider) : this.getProviderForSession(session); + } + getProviders(): readonly IAgent[] { return [...this._providers.values()]; } @@ -156,6 +172,14 @@ export class AgentHostProviderService extends Disposable implements IAgentHostPr this._sessionToProvider.delete(key); } + associateChat(chat: URI | string, provider: AgentProvider): void { + this._chatToProvider.set(typeof chat === 'string' ? chat : chat.toString(), provider); + } + + releaseChat(chat: URI | string): void { + this._chatToProvider.delete(typeof chat === 'string' ? chat : chat.toString()); + } + authenticate(params: AuthenticateParams, scope?: IAgentHostAuthenticationScope): Promise { return this._authenticationController.authenticate(params, this._providers.values(), scope); } @@ -243,6 +267,7 @@ export class AgentHostProviderService extends Disposable implements IAgentHostPr this._agents.set([], undefined); this._defaultProvider = undefined; this._sessionToProvider.clear(); + this._chatToProvider.clear(); } } } diff --git a/src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts b/src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts index cc8b3c7b242a6..29594ee14900c 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts @@ -351,7 +351,7 @@ export class AgentHostSessionOpenTelemetry extends Disposable implements IAgentH return undefined; } const session = URI.parse(owner); - const provider = this._providerService.getProviderForSession(session)?.id; + const provider = this._providerService.getProviderForChat(session, isAhpChatChannel(resourceString) ? resourceString : undefined)?.id; if (!provider) { return undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index a744c2b8b8c40..f5a8c109310b6 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -10,7 +10,7 @@ import { equals } from '../../../base/common/objects.js'; import { ILogService } from '../../log/common/log.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; -import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, isAutomationAction, isAutomationRunAction, isPassiveSessionMetadataAction, type AuthRequiredParams, type ClientAutomationAction, type ClientAutomationRunAction, type ProgressParams, type SessionSummaryChangedParams, type SessionSummaryChanges } from '../common/state/sessionActions.js'; +import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, isAutomationAction, isAutomationRunAction, isPassiveSessionMetadataAction, type AuthRequiredParams, type ChatConfigChangedAction, type ClientAutomationAction, type ClientAutomationRunAction, type ProgressParams, type SessionSummaryChangedParams, type SessionSummaryChanges } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer, automationReducer, automationRunReducer } from '../common/state/sessionReducers.js'; import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, parseSubagentSessionUri, isAhpChatChannel, isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isChatInSessionReadAggregate, isSessionStatusArchived, isSessionStatusRead, mergeSessionWithDefaultChat, isAhpRootChannel, readSessionExternal, SessionLifecycle, withHostBuildInfo, withSessionStatusFlag, type AutomationState, type AutomationRunState, type Changeset, type ChangesetState, type AnnotationsState, type ChatConfigState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, ChatOriginKind, IHostBuildInfo, SessionStatus, ResponsePartKind, ToolCallStatus } from '../common/state/sessionState.js'; @@ -22,6 +22,7 @@ import { AgentHostChangesetStateCache, type IAgentHostChangesetStateRetentionOpt import { ChangesSummary, ChatInteractivity, type ChatOrigin, type SessionChatSummary } from '../common/state/protocol/state.js'; import { arrayEquals, structuralEquals } from '../../../base/common/equals.js'; import { canvasReducer } from '../common/state/protocol/channels-canvas/reducer.js'; +import type { SessionConfigSchema } from '../common/state/protocol/commands.js'; import type { CanvasState } from '../common/state/protocol/channels-canvas/state.js'; import { parseCanvasChatUri } from '../common/canvasUri.js'; import { URI as ResourceURI } from '../../../base/common/uri.js'; @@ -124,6 +125,11 @@ function registrationParentChat(origin: ChatOrigin | undefined): string | undefi return origin?.kind === ChatOriginKind.Tool ? origin.chat : undefined; } +/** A chat's own agent: `provider` when it differs from its session's, else `undefined` (the session's agent). */ +function ownChatProvider(sessionProvider: string | undefined, provider: string | undefined): string | undefined { + return provider !== undefined && provider !== sessionProvider ? provider : undefined; +} + interface IRestoredChatState { readonly turns: Turn[]; readonly draft?: Message; @@ -154,6 +160,8 @@ export interface IChatRegistration { readonly chat: URI; readonly session: URI; readonly storageResource: URI; + /** The chat's own agent when it differs from its session's; `undefined` means the session's agent. A chat's agent never changes. */ + readonly provider?: string; } /** A registered chat whose owning session changed. */ @@ -170,6 +178,8 @@ interface IChatEntry { readonly storageResource: string; /** Chat whose tool call spawned this chat, recorded at registration; `undefined` for chats not spawned by a tool call. */ parentChat: string | undefined; + /** The chat's agent, recorded once at registration (its session's unless the chat has its own); `undefined` when unknown. */ + readonly provider?: string; summary: ChatSummary; state?: ChatState; providerData?: string; @@ -177,6 +187,11 @@ interface IChatEntry { draft?: Message; /** The chat's own config values, kept here so they exist before the chat's state is resolved. */ config?: ChatConfigState; + /** + * Host-side only, never published: the chat-applicable schema of the chat's agent when it differs from its + * session's. The chat's config then overlays that agent's values instead of the session's. + */ + ownConfigSchema?: SessionConfigSchema; resolver?: RestoredChatResolver; inFlight?: Promise; valid: boolean; @@ -452,6 +467,7 @@ export class AgentHostStateManager extends Disposable { * Unlike {@link onDidRemoveSession}, it does not fire when an idle session is only evicted from memory. */ readonly onDidDeleteSession: Event = this._onDidDeleteSession.event; + private readonly _chatsWithOwnConfigSchema = new Set(); private readonly _onDidRegisterChat = this._register(new Emitter()); /** Fires when a chat enters the registry with its recorded owner and storage. */ readonly onDidRegisterChat: Event = this._onDidRegisterChat.event; @@ -482,9 +498,9 @@ export class AgentHostStateManager extends Disposable { private readonly _onDidChangeSessionConfig = this._register(new Emitter<{ session: URI; previous: SessionConfigState | undefined; current: SessionConfigState | undefined; clientContext?: IAgentHostClientTelemetryContext }>()); readonly onDidChangeSessionConfig: Event<{ session: URI; previous: SessionConfigState | undefined; current: SessionConfigState | undefined; clientContext?: IAgentHostClientTelemetryContext }> = this._onDidChangeSessionConfig.event; - private readonly _onDidChangeChatConfig = this._register(new Emitter<{ session: URI; chat: URI; previous: ChatConfigState | undefined; current: ChatConfigState | undefined; clientContext?: IAgentHostClientTelemetryContext }>()); + private readonly _onDidChangeChatConfig = this._register(new Emitter<{ session: URI; chat: URI; previous: ChatConfigState | undefined; current: ChatConfigState | undefined; clientContext?: IAgentHostClientTelemetryContext; readonly action: ChatConfigChangedAction }>()); /** Fires when a `chat/configChanged` changed a chat's own config, also before the chat's state is resolved. */ - readonly onDidChangeChatConfig: Event<{ session: URI; chat: URI; previous: ChatConfigState | undefined; current: ChatConfigState | undefined; clientContext?: IAgentHostClientTelemetryContext }> = this._onDidChangeChatConfig.event; + readonly onDidChangeChatConfig: Event<{ session: URI; chat: URI; previous: ChatConfigState | undefined; current: ChatConfigState | undefined; clientContext?: IAgentHostClientTelemetryContext; readonly action: ChatConfigChangedAction }> = this._onDidChangeChatConfig.event; private readonly _onDidChangeSessionWorkingDirectories = this._register(new Emitter<{ session: string }>()); readonly onDidChangeSessionWorkingDirectories: Event<{ session: string }> = this._onDidChangeSessionWorkingDirectories.event; @@ -672,6 +688,7 @@ export class AgentHostStateManager extends Disposable { resource: chat.resource, title: chat.title, origin: chat.origin, + ...(chat.provider !== undefined ? { provider: chat.provider } : {}), ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), status: chat.status, ...(isSessionStatusArchived(chat.status) ? { archived: true } : {}), @@ -707,6 +724,7 @@ export class AgentHostStateManager extends Disposable { && chat.title === other.title && equals(chat.origin, other.origin) && chat.interactivity === other.interactivity + && chat.provider === other.provider && chat.status === other.status && structuralEquals(chat.changes, other.changes); }); @@ -849,11 +867,53 @@ export class AgentHostStateManager extends Disposable { return this._chatEntries.get(chat)?.summary.origin; } + /** + * Returns the agent backing a registered chat: its own recorded agent, else + * its owning session's. `undefined` for an unregistered chat. + */ + getChatProvider(chat: URI): string | undefined { + const entry = this._chatEntries.get(chat); + return entry?.provider ?? (entry ? this._sessionStates.get(entry.session)?.state.provider : undefined); + } + + /** A subagent runs in the agent of the chat that spawned it. */ + private _spawningChatProvider(origin: ChatOrigin | undefined): string | undefined { + return origin?.kind === ChatOriginKind.Tool ? this._chatEntries.get(origin.chat)?.provider : undefined; + } + /** Returns the provider-derived inherited boundary retained by the host catalog. */ getChatInheritedTurnId(chat: URI): string | undefined { return this._chatEntries.get(chat)?.inheritedTurnId; } + /** The host-side config schema of a registered chat whose agent differs from its session's; never part of protocol state. */ + getChatOwnConfigSchema(chat: URI): SessionConfigSchema | undefined { + return this._chatEntries.get(chat)?.ownConfigSchema; + } + + /** + * Sets or clears the own config schema of a registered chat whose agent starts or stops differing from its session's + * because it moves between sessions of different agents. Host bookkeeping only: the chat's subscribers learn it from + * the `chat/moved` the move publishes. A chat whose state is not materialized publishes it when it is opened. + */ + setChatOwnConfigSchema(chat: URI, schema: SessionConfigSchema | undefined): void { + const entry = this._chatEntries.get(chat); + if (!entry) { + return; + } + entry.ownConfigSchema = schema; + this._noteChatConfig(chat, schema); + if (!entry.state) { + const values = entry.config?.values ?? {}; + entry.config = schema ? { schema, values } : (entry.config ? { values } : undefined); + } + } + + /** Whether any registered chat has its own config schema, so config reads that only such chats need can be skipped. */ + hasChatsWithOwnConfigSchema(): boolean { + return this._chatsWithOwnConfigSchema.size > 0; + } + /** Returns a registered chat's own config values (`ChatState.config`), also before its state is resolved. */ getChatConfig(chat: URI): ChatConfigState | undefined { return this._chatEntries.get(chat)?.config; @@ -1507,6 +1567,7 @@ export class AgentHostStateManager extends Disposable { session: key, storageResource, parentChat: undefined, + provider: summary.provider, summary: chatSummary, providerData: defaultChat.providerData, draft: defaultChat.draft, @@ -1553,6 +1614,7 @@ export class AgentHostStateManager extends Disposable { title: chat.title, origin: chat.origin, interactivity: chat.interactivity, + provider: chat.provider, archived: chat.status === undefined ? undefined : isSessionStatusArchived(chat.status), isRead: chat.status === undefined ? undefined : isSessionStatusRead(chat.status), changes: chat.changes, @@ -1594,6 +1656,7 @@ export class AgentHostStateManager extends Disposable { session: sessionKey, storageResource, parentChat: undefined, + provider: summary.provider, summary: chatSummary, state: { ...createChatState(chatSummary), turns: turns ?? [], draft, ...(config ? { config } : {}) }, config, @@ -1655,7 +1718,7 @@ export class AgentHostStateManager extends Disposable { * data is keyed by; a caller creating a new chat applies the creation-time * rule ({@link creationTimeChatStorageResource}). */ - addChat(session: URI, chatUri: URI, storageResource: string, options?: { readonly title?: string; readonly turns?: Turn[]; readonly origin?: ChatOrigin; readonly providerData?: string; readonly inheritedTurnId?: string; readonly interactivity?: ChatInteractivity; readonly workingDirectories?: readonly string[]; readonly config?: ChatConfigState }): ChatSummary | undefined { + addChat(session: URI, chatUri: URI, storageResource: string, options?: { readonly title?: string; readonly turns?: Turn[]; readonly origin?: ChatOrigin; readonly providerData?: string; readonly inheritedTurnId?: string; readonly interactivity?: ChatInteractivity; readonly workingDirectories?: readonly string[]; readonly config?: ChatConfigState; readonly configSchema?: SessionConfigSchema; readonly provider?: string }): ChatSummary | undefined { const entry = this._sessionStates.get(session); if (!entry) { this._logService.warn(`[AgentHostStateManager] addChat for unknown session: ${session}`); @@ -1666,6 +1729,7 @@ export class AgentHostStateManager extends Disposable { if (existing) { return existing; } + const config = withOwnConfigSchema(options?.config, options?.configSchema); // A session gains its first additional chat here: snapshot the current // session title onto the still-inheriting default chat so the two @@ -1674,6 +1738,8 @@ export class AgentHostStateManager extends Disposable { // would also move the default chat tab and vice-versa. this._snapshotDefaultChatTitle(session, sessionState); + const chatProvider = options?.provider ?? this._spawningChatProvider(options?.origin) ?? sessionState.provider; + const provider = ownChatProvider(sessionState.provider, chatProvider); const chatSummary: ChatSummary = { ...createDefaultChatSummary(this._toSummary(session, entry), chatUri), title: options?.title ?? '', @@ -1681,14 +1747,17 @@ export class AgentHostStateManager extends Disposable { ...(options?.origin ? { origin: options.origin } : {}), interactivity: options?.interactivity, ...(options?.workingDirectories !== undefined ? { workingDirectories: [...options.workingDirectories] } : {}), + ...(provider !== undefined ? { provider } : {}), }; this._setChatEntry(chatUri, { session, storageResource, parentChat: registrationParentChat(options?.origin), + provider: chatProvider, summary: chatSummary, - state: { ...createChatState(chatSummary), turns: options?.turns ?? [], ...(options?.config ? { config: options.config } : {}) }, - config: options?.config, + state: { ...createChatState(chatSummary), turns: options?.turns ?? [], ...(config ? { config } : {}) }, + config, + ownConfigSchema: options?.configSchema, providerData: options?.providerData, inheritedTurnId: options?.inheritedTurnId, valid: true, @@ -1702,7 +1771,7 @@ export class AgentHostStateManager extends Disposable { * creating conversation state. The state-manager-owned resolver installs a * complete state only through {@link resolveChatState}. */ - registerRestoredChatSummary(session: URI, chatUri: URI, options: { readonly storageResource: string; readonly title?: string; readonly modifiedAt?: string; readonly origin?: ChatOrigin; readonly interactivity?: ChatInteractivity; readonly archived?: boolean; readonly isRead?: boolean; readonly draft?: Message; readonly providerData?: string; readonly inheritedTurnId?: string; readonly workingDirectories?: readonly string[]; readonly changes?: ChangesSummary; readonly config?: ChatConfigState; readonly resolver?: RestoredChatResolver }): ChatSummary | undefined { + registerRestoredChatSummary(session: URI, chatUri: URI, options: { readonly storageResource: string; readonly title?: string; readonly modifiedAt?: string; readonly origin?: ChatOrigin; readonly interactivity?: ChatInteractivity; readonly archived?: boolean; readonly isRead?: boolean; readonly draft?: Message; readonly providerData?: string; readonly inheritedTurnId?: string; readonly workingDirectories?: readonly string[]; readonly changes?: ChangesSummary; readonly config?: ChatConfigState; readonly configSchema?: SessionConfigSchema; readonly resolver?: RestoredChatResolver; readonly provider?: string }): ChatSummary | undefined { const entry = this._sessionStates.get(session); if (!entry) { this._logService.warn(`[AgentHostStateManager] registerRestoredChatSummary for unknown session: ${session}`); @@ -1736,15 +1805,20 @@ export class AgentHostStateManager extends Disposable { existingEntry.providerData = options.providerData; existingEntry.inheritedTurnId = options.inheritedTurnId; existingEntry.draft = options.draft; - existingEntry.config = options.config; + existingEntry.config = withOwnConfigSchema(options.config, options.configSchema); + existingEntry.ownConfigSchema = options.configSchema; + this._noteChatConfig(chatUri, options.configSchema); existingEntry.resolver = options.resolver; return summary; } return existing; } this._snapshotDefaultChatTitle(session, sessionState); + const chatProvider = options.provider ?? this._spawningChatProvider(options.origin) ?? sessionState.provider; + const provider = ownChatProvider(sessionState.provider, chatProvider); const chatSummary: ChatSummary = { ...createDefaultChatSummary(this._toSummary(session, entry), chatUri), + ...(provider !== undefined ? { provider } : {}), title: options.title ?? '', ...(options.modifiedAt !== undefined ? { modifiedAt: options.modifiedAt } : {}), status: withSessionStatusFlag( @@ -1765,11 +1839,13 @@ export class AgentHostStateManager extends Disposable { session, storageResource: options.storageResource, parentChat: registrationParentChat(options.origin), + provider: chatProvider, summary: chatSummary, providerData: options.providerData, inheritedTurnId: options.inheritedTurnId, draft: options.draft, - config: options.config, + config: withOwnConfigSchema(options.config, options.configSchema), + ownConfigSchema: options.configSchema, resolver: options.resolver, valid: true, }); @@ -1843,10 +1919,7 @@ export class AgentHostStateManager extends Disposable { throw new Error(`Cannot place a moved chat after ${after}: it is not a chat of ${target}`); } this._snapshotDefaultChatTitle(target, targetEntry.state); - const summaries = chats.map(chat => { - const entry = this._chatEntries.get(chat)!; - return entry.state ? chatSummaryFromState(entry.state) : entry.summary; - }); + const summaries = chats.map(chat => this._movedChatSummary(chat, targetEntry.state.provider)); this._inTransfer(chats, () => { for (const chat of chats) { this.reassignChatOwner(chat, target); @@ -1862,6 +1935,7 @@ export class AgentHostStateManager extends Disposable { const order = this._sessionStates.get(target)!.state.chats.map(summary => summary.resource).filter(chat => !moved.has(chat)); order.splice(after === undefined ? 0 : order.indexOf(after) + 1, 0, ...chats); this.dispatchServerAction(target, { type: ActionType.SessionChatsReordered, chats: order }); + this._announceChatsMoved(chats, target); for (const chat of chats) { // Added before it is removed, so the active-session count never drops to zero while the turn runs. if (this._sessionsWithActiveTurn.get(source)?.has(chat)) { @@ -1915,8 +1989,7 @@ export class AgentHostStateManager extends Disposable { this._snapshotDefaultChatTitle(source, sourceEntry.state); this._snapshotDefaultChatTitle(target, targetEntry.state); const summaries = chats.map((chat, index) => { - const entry = this._chatEntries.get(chat)!; - const summary = entry.state ? chatSummaryFromState(entry.state) : entry.summary; + const summary = this._movedChatSummary(chat, targetEntry.state.provider); return index === 0 ? { ...summary, movable } : summary; }); // Read before the announcement, which also updates the chat's own state, without telling its subscribers. @@ -1936,6 +2009,7 @@ export class AgentHostStateManager extends Disposable { const order = this._sessionStates.get(target)!.state.chats.map(summary => summary.resource).filter(chat => !moved.has(chat)); order.splice(after === undefined ? 0 : order.indexOf(after) + 1, 0, ...chats); this.dispatchServerAction(target, { type: ActionType.SessionChatsReordered, chats: order }); + this._announceChatsMoved(chats, target); for (const chat of chats) { // Added before the source ends, so the active-session count never drops to zero while the turn runs. if (this._sessionsWithActiveTurn.get(source)?.has(chat)) { @@ -1967,10 +2041,7 @@ export class AgentHostStateManager extends Disposable { } // The new session's default chat is never movable: announced that way, never as a transient `true`. this.setChatMovable(summary.defaultChat, false); - const summaries = chats.map(chat => { - const entry = this._chatEntries.get(chat)!; - return entry.state ? chatSummaryFromState(entry.state) : entry.summary; - }); + const summaries = chats.map(chat => this._movedChatSummary(chat, summary.provider)); const state = createSessionState(summary); state.lifecycle = SessionLifecycle.Ready; // The new session is read exactly when the chats it takes are, as every read change keeps it. @@ -2002,10 +2073,42 @@ export class AgentHostStateManager extends Disposable { } this._applySessionAggregate(target, summaries); this._emitSessionAdded(this._toSummary(target, this._sessionStates.get(target)!)); + this._announceChatsMoved(chats, target); this._applySessionAggregate(source, this._sessionStates.get(source)!.state.chats); this._synchronizeSessionReadAfterMove(summaries, source, undefined); } + /** A moving chat's summary as its destination session lists it: it keeps its agent, reported when it differs from the destination's. */ + private _movedChatSummary(chat: URI, targetProvider: string | undefined): ChatSummary { + const entry = this._chatEntries.get(chat)!; + const { provider: _previous, ...summary } = entry.state ? chatSummaryFromState(entry.state) : entry.summary; + const provider = ownChatProvider(targetProvider, entry.provider); + return provider !== undefined ? { ...summary, provider } : summary; + } + + /** + * Tells each moved chat's subscribers its new owner (`chat/moved`, from protocol 1.1.0): its provider when it differs + * from its new session's, and its own config schema, if any. A chat whose state is not materialized has no subscribers; + * its snapshot is taken under its new owner. + */ + private _announceChatsMoved(chats: readonly URI[], target: URI): void { + const targetProvider = this._sessionStates.get(target)?.state.provider; + for (const chat of chats) { + const entry = this._chatEntries.get(chat); + if (!entry?.state) { + continue; + } + const provider = ownChatProvider(targetProvider, entry.provider); + const schema = entry.ownConfigSchema; + this.dispatchServerAction(chat, { + type: ActionType.ChatMoved, + session: target, + ...(provider !== undefined ? { provider } : {}), + ...(schema !== undefined ? { schema } : {}), + }); + } + } + private _inTransfer(chats: readonly URI[], transfer: () => void): void { for (const chat of chats) { this._chatsInTransfer.add(chat); @@ -2671,12 +2774,22 @@ export class AgentHostStateManager extends Disposable { // ---- Internal ----------------------------------------------------------- + /** Tracks whether a registered chat has its own config schema (its agent differs from its session's). */ + private _noteChatConfig(chat: URI, schema: SessionConfigSchema | undefined): void { + if (schema) { + this._chatsWithOwnConfigSchema.add(chat); + } else { + this._chatsWithOwnConfigSchema.delete(chat); + } + } + private _invalidateChatEntry(chat: URI): void { this._pruneChatCanvases(chat, new Set()); const entry = this._chatEntries.get(chat); if (entry) { entry.valid = false; this._chatEntries.delete(chat); + this._chatsWithOwnConfigSchema.delete(chat); this._unindexChatStorage(chat, entry.storageResource); this._onDidUnregisterChat.fire({ chat, session: entry.session, storageResource: entry.storageResource }); } @@ -2701,7 +2814,9 @@ export class AgentHostStateManager extends Disposable { this._chatsByStorage.set(entry.storageResource, chats); } chats.add(chat); - this._onDidRegisterChat.fire({ chat, session: entry.session, storageResource: entry.storageResource }); + this._noteChatConfig(chat, entry.ownConfigSchema); + const provider = ownChatProvider(this._sessionStates.get(entry.session)?.state.provider, entry.provider); + this._onDidRegisterChat.fire({ chat, session: entry.session, storageResource: entry.storageResource, ...(provider !== undefined ? { provider } : {}) }); } private _synchronizeChatEntries(session: URI, summaries: readonly ChatSummary[]): void { @@ -2718,6 +2833,7 @@ export class AgentHostStateManager extends Disposable { session, storageResource: creationTimeChatStorageResource(session, summary.resource, this.getDefaultChat(session)), parentChat: registrationParentChat(summary.origin), + provider: summary.provider ?? this._sessionStates.get(session)?.state.provider, summary, valid: true, }); @@ -2731,6 +2847,11 @@ export class AgentHostStateManager extends Disposable { } private _applyAndEmit(channel: URI, action: StateAction, origin: ActionOrigin | undefined, clientContext?: IAgentHostClientTelemetryContext, credentials?: IAgentHostCredentialSource): unknown { + if (action.type === ActionType.SessionChatUpdated && Object.hasOwn(action.changes, 'provider')) { + // A chat's agent never changes. + const { provider: _provider, ...changes } = action.changes; + action = { ...action, changes }; + } if (action.type === ActionType.SessionMetaChanged) { const workingDirectories = this._sessionStates.get(channel)?.state.workingDirectories; action = { ...action, _meta: withAgentSessionGitHubReferences({ _meta: action._meta, workingDirectories })._meta }; @@ -2831,10 +2952,14 @@ export class AgentHostStateManager extends Disposable { if (chat && chatEntry && sessionKey !== undefined) { const newChat = chatReducer(chat, chatAction, this._log); chatEntry.state = newChat; + if (chatAction.type === ActionType.ChatMoved) { + // Its values are unchanged; only the schema it publishes follows its new owner. + chatEntry.config = newChat.config; + } if (chatAction.type === ActionType.ChatConfigChanged) { const previous = chatEntry.config; chatEntry.config = newChat.config; - this._onDidChangeChatConfig.fire({ session: sessionKey, chat: channel, previous, current: chatEntry.config, clientContext }); + this._onDidChangeChatConfig.fire({ session: sessionKey, chat: channel, previous, current: chatEntry.config, clientContext, action: chatAction }); } if (chatAction.type === ActionType.ChatCanvasesChanged) { this._pruneChatCanvases(channel, new Set(newChat.canvases?.map(canvas => canvas.resource) ?? [])); @@ -2848,7 +2973,7 @@ export class AgentHostStateManager extends Disposable { // A chat's own config exists before its state is resolved; reduce it alone. const previous = chatEntry.config; chatEntry.config = chatReducer({ ...createChatState(chatEntry.summary), ...(previous ? { config: previous } : {}) }, chatAction, this._log).config; - this._onDidChangeChatConfig.fire({ session: chatEntry.session, chat: channel, previous, current: chatEntry.config, clientContext }); + this._onDidChangeChatConfig.fire({ session: chatEntry.session, chat: channel, previous, current: chatEntry.config, clientContext, action: chatAction }); } else if (chatEntry?.valid && sessionKey !== undefined && (chatAction.type === ActionType.ChatIsReadChanged || chatAction.type === ActionType.ChatIsArchivedChanged)) { // Read and archived state live on the chat summary, so a registered chat // whose history is not materialized yet takes them without resolving that history. @@ -3317,3 +3442,8 @@ export function resolveChatStateForUri(stateManager: AgentHostStateManager, chat } return undefined; } + +/** A chat's config as published: a chat with its own agent carries that agent's schema in its `ChatConfigState`. */ +function withOwnConfigSchema(config: ChatConfigState | undefined, schema: SessionConfigSchema | undefined): ChatConfigState | undefined { + return schema === undefined ? config : { schema, values: config?.values ?? {} }; +} diff --git a/src/vs/platform/agentHost/node/agentHostTurnStarter.ts b/src/vs/platform/agentHost/node/agentHostTurnStarter.ts index ce7ca4b280535..78466ed761d09 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnStarter.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnStarter.ts @@ -21,7 +21,7 @@ import { IAgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostProviderService } from './agentHostProviderService.js'; import { IAgentHostSessionTitleController } from './agentHostSessionTitleController.js'; import { getMessageOriginTelemetryKind, IAgentHostTelemetryReporter } from './agentHostTelemetryReporter.js'; -import { getTurnTelemetryContext } from './agentHostTurnTelemetryContext.js'; +import { getApplicableTurnModel, getTurnTelemetryContext } from './agentHostTurnTelemetryContext.js'; import { IAgentHostTurnTracker } from './agentHostTurnTracker.js'; /** The resolved agent for a turn that has passed host admission. */ @@ -116,7 +116,7 @@ export function startTurn(accessor: ServicesAccessor, request: ITurnStartRequest } titleController.seedTitleFromFirstMessage(request.session, request.message.text, request.chat, request.message.model); - const agent = providerService.getProviderForSession(request.session); + const agent = providerService.getProviderForChat(request.session, request.turnChannel); if (!agent) { const error: ErrorInfo = { errorType: 'noAgent', message: 'No agent found for session' }; stateManager.dispatchServerAction(request.turnChannel, { @@ -139,7 +139,7 @@ export function startTurn(accessor: ServicesAccessor, request: ITurnStartRequest const isEphemeralSession = stateManager.isEphemeralSession(request.session); const messageOriginKind = getMessageOriginTelemetryKind(request.message, isEphemeralSession); telemetryReporter.userMessageSent(agent.id, request.clientId, request.clientContext, request.chat, request.session, request.turnId, state, request.source, request.message, isEphemeralSession, stateManager.getChatParent(request.chat) !== undefined); - const { model, modelTelemetryKind, modelSelectionKind, autoTier, permissionLevel, interactionMode } = getTurnTelemetryContext(agent, request.chat, createAgentChatContext(stateManager, authenticationService, request.session, request.chat, stateManager.resolveChatStorageResource(request.session, request.chat), sessionStateStore.peek(URI.parse(request.session))), configurationService.getChatConfigState({ session: request.session, chat: request.chat }), request.message.model); + const { model, modelTelemetryKind, modelSelectionKind, autoTier, permissionLevel, interactionMode } = getTurnTelemetryContext(agent, request.chat, createAgentChatContext(stateManager, authenticationService, request.session, request.chat, stateManager.resolveChatStorageResource(request.session, request.chat), sessionStateStore.peek(URI.parse(request.session))), configurationService.getChatConfigState({ session: request.session, chat: request.chat }), getApplicableTurnModel(agent, stateManager.getSessionState(request.session)?.provider, request.message.model)); turnTracker.turnStarted(agent, request.chat, request.session, request.turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, request.clientContext, request.clientId, undefined, undefined, messageOriginKind, undefined, undefined, stateManager.getChatParent(request.chat) !== undefined ? 'task' : undefined); if (autoTier !== undefined) { turnTracker.updateFusionTelemetry(request.chat, request.turnId, { autoTier }); diff --git a/src/vs/platform/agentHost/node/agentHostTurnTelemetryContext.ts b/src/vs/platform/agentHost/node/agentHostTurnTelemetryContext.ts index 405ecb64474dc..88a0bee641d9c 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnTelemetryContext.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnTelemetryContext.ts @@ -41,6 +41,19 @@ export function getConfiguredSessionMode(config: SessionConfigState | undefined) } } +/** + * The model a turn's message selects for its chat, or `undefined` when the chat keeps its current one: a chat whose + * agent is not its session's ignores a model that agent does not offer. Older clients, unaware of a chat's own agent, + * offer the session agent's models. A same-agent chat, or an agent whose model list is not known, takes the model as is. + */ +export function getApplicableTurnModel(agent: IAgent, sessionProvider: string | undefined, model: T | undefined): T | undefined { + if (model === undefined || sessionProvider === undefined || agent.id === sessionProvider) { + return model; + } + const models = agent.models.get(); + return models.length === 0 || models.some(candidate => candidate.id === model.id) ? model : undefined; +} + /** `config` is the chat's effective config, which may set its own approvals and mode. */ export function getTurnTelemetryContext(agent: IAgent, chat: ProtocolURI, context: IAgentChatContext, config: SessionConfigState | undefined, selection: ModelSelection | undefined): IAgentHostTurnTelemetryContext { const permissionValue = config?.values[SessionConfigKey.AutoApprove]; diff --git a/src/vs/platform/agentHost/node/agentHostUtilityModelService.ts b/src/vs/platform/agentHost/node/agentHostUtilityModelService.ts index c7a1f2b6f2f58..d30df44b60a25 100644 --- a/src/vs/platform/agentHost/node/agentHostUtilityModelService.ts +++ b/src/vs/platform/agentHost/node/agentHostUtilityModelService.ts @@ -236,14 +236,14 @@ export class AgentHostUtilityModelService implements IAgentHostUtilityModelServi /** The agent model id of the chat's selected main agent model when it is a BYOK model. */ private _resolveByokMainAgentModel(context: IAgentHostUtilityModelContext): string | undefined { - const agent = this._providerService.getProviderForSession(context.session); - if (!agent) { - return undefined; - } const chat = context.chat && isAhpChatChannel(context.chat) ? context.chat : this._chatOwnership.getDefaultChat(context.session); if (chat === undefined) { return undefined; } + const agent = this._providerService.getProviderForChat(context.session, chat); + if (!agent) { + return undefined; + } const state = this._stateManager.getChatState(chat) ?? (this._chatOwnership.isDefaultChat(context.session, chat) ? this._stateManager.getSessionState(context.session) : undefined); // Prefer the model of the turn being served: the provider applies a turn's // model after admission, and a chat that is not resident has no selection. diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 6fb9ba045fa17..7c68a0e264b74 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -38,13 +38,13 @@ import { EDIT_ATTRIBUTION_RESOURCE_SCHEME, IAgentEditAttributionService } from ' import { MCP_RESOURCE_SCHEME, TOOL_RESULT_RESOURCE_SCHEME } from '../common/mcpResourceUri.js'; import { SESSION_DB_SCHEME } from '../common/sessionDbUri.js'; import { getPersistedSessionConfigValues, getSessionPullRequestUrl, omitTransientSessionConfigValues, SessionConfigKey } from '../common/sessionConfigKeys.js'; -import { filterChatConfigValues, getSessionApprovalProperty, validateChatConfigWrite, validateSessionConfigWrite } from '../common/sessionConfigProperties.js'; +import { filterChatConfigValues, getSessionApprovalProperty, getValidChatConfigValues, toOwnChatConfig, validateChatConfigWrite, validateSessionConfigWrite } from '../common/sessionConfigProperties.js'; import type { IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js'; import { buildAnnotationsUri, parseAnnotationsUri } from '../common/annotationsUri.js'; import { changesetSession, parseChangesetUri } from '../common/changesetUri.js'; import { ActionType, ActionEnvelope, AuthRequiredReason, INotification, isAnnotationsAction, isPassiveSessionMetadataAction, isSessionAction, type ChatAction, type ClientAutomationAction, type ClientAutomationRunAction, type IIsArchivedChangedAction, type IIsReadChangedAction, type IRootConfigChangedAction, type SessionAction, type SessionWorkingDirectoryAction, type TerminalAction, type ClientAnnotationsAction, type ClientChangesetAction } from '../common/state/sessionActions.js'; import { resolveSessionWorkingDirectoryAction } from '../common/state/sessionWorkingDirectories.js'; -import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult, SessionConfigPropertySchema } from '../common/state/protocol/commands.js'; +import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult, SessionConfigPropertySchema, SessionConfigSchema } from '../common/state/protocol/commands.js'; import { AHP_CANVAS_SCHEME } from '../common/canvasUri.js'; import type { AutomationCapabilities } from '../common/state/protocol/common/commands.js'; import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../common/state/protocol/channels-automation/commands.js'; @@ -149,13 +149,14 @@ import { getNonMergeSessionConfigValues, isAnyAgentMergeEnabled, mergeClientAgen import { isAgentMergeEnabled } from './agentMergeConfiguration.js'; import { readChatSandboxState, withChatSandboxState } from '../common/meta/agentSandboxStateMeta.js'; import { AgentChatMoveRefusal, AgentChatMoveRefusedError, ChatMoveConfigKey, chatMoveRootConfigSchema, type AgentChatMoveDestination } from '../common/agentHostChatMove.js'; +import { ChatProvidersConfigKey, chatProvidersRootConfigSchema } from '../common/agentHostChatProviders.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { IAgentHostStartupPerformance, type IAgentHostStartupMetrics, type IAgentHostStartupTiming } from './agentHostStartupPerformance.js'; import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; import { AgentHostAuthenticationService, SESSION_CREDENTIAL_PRINCIPAL_KEY } from './agentHostAuthenticationService.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; import type { IAgentHostCopilotSkuClassification, IAgentHostCopilotSkuTelemetry } from './agentHostTelemetryReporter.js'; -import { AgentHostArtifactToolsConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; +import { AgentHostArtifactToolsConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostCodexEnabledConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { supersedeTitleGenerationStrategyForLegacyUpdate } from '../common/titleGenerationConfiguration.js'; import { IAgentHostChangesetService, CHANGES_SUMMARY_METADATA_KEYS, getChatChangesSummaryMetadataKey, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_DATA_STATE, META_GIT_STATE, META_GITHUB_DATA_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.js'; @@ -261,6 +262,19 @@ type IChatMoveRequest = | { readonly kind: 'reorder' | 'move'; readonly destination: Extract } | { readonly kind: 'promote'; readonly provider: IAgent }; +/** How a moved chat changes with its agent (see `AgentService._prepareMovedChatAgent`). */ +interface IMovedChatAgent { + readonly kind: 'gain' | 'lose'; + /** The chat's agent, which it keeps. */ + readonly agent: string; + /** The moved chats of that agent: the chat and its descendants. */ + readonly chats: readonly string[]; + /** The chat's own config schema in its new session, when it gains its own agent. */ + readonly schema?: SessionConfigSchema; + /** The chat's values in its new session. */ + readonly values: Record; +} + /** What a chat move validates against and plans its worktrees by (see `_readChatMoveInputs`). */ interface IChatMoveInputs { /** The chat and its host-managed descendants. */ @@ -701,6 +715,8 @@ interface ICatalogChat { readonly title?: string; readonly origin?: ChatOrigin; readonly interactivity?: ChatInteractivity; + /** The chat's own agent when it differs from its session's. */ + readonly provider?: string; readonly archived?: boolean; readonly isRead?: boolean; readonly inheritedTurnId?: string; @@ -1389,6 +1405,22 @@ export class AgentService extends Disposable implements IAgentService { persistSurfacedSessionTitle: (session, title) => this._persistSurfacedSessionTitle(URI.parse(session), title), }); this._logService.info('AgentService initialized'); + // Chat-addressed routing follows each registered chat's recorded agent, which may differ from its session's. + this._register(this._stateManager.onDidRegisterChat(({ chat, provider }) => { + if (provider !== undefined) { + this._providerService.associateChat(chat, provider); + } + })); + this._register(this._stateManager.onDidUnregisterChat(({ chat }) => this._providerService.releaseChat(chat))); + this._register(this._stateManager.onDidChangeChatOwner(({ chat, current }) => { + // A moved chat keeps its agent, whatever its new session's. + const provider = this._stateManager.getChatProvider(chat); + if (provider !== undefined && provider !== this._stateManager.getSessionState(current)?.provider) { + this._providerService.associateChat(chat, provider); + } else { + this._providerService.releaseChat(chat); + } + })); this._register(this._stateManager.onDidEmitEnvelope(e => this._onDidAction.fire(e))); this._register(toDisposable(() => { for (const entries of [...this._preparedChatWorkingDirectories.values()]) { @@ -2172,8 +2204,11 @@ export class AgentService extends Disposable implements IAgentService { }, getCreationDefaults: source => this._getServerToolCreationDefaults(source), startPrompt: (session, chat, prompt, delegation, credentials) => this._startSessionPrompt(session, chat, prompt, delegation, credentials), - createChat: (session, chat, options, credentials) => this.createChat(session, chat, (options?.title !== undefined || options?.model !== undefined || options?.workingDirectories !== undefined) + getChatHarnesses: session => this._getServerToolChatHarnesses(session), + canCreateSessions: provider => this._canCreateServerToolSessions(provider), + createChat: (session, chat, options, credentials) => this.createChat(session, chat, (options?.title !== undefined || options?.model !== undefined || options?.workingDirectories !== undefined || options?.provider !== undefined) ? { + ...(options.provider !== undefined ? { provider: options.provider } : {}), ...(options.title !== undefined ? { title: options.title } : {}), ...(options.model !== undefined ? { model: options.model } : {}), ...(options.workingDirectories !== undefined ? { workingDirectories: options.workingDirectories } : {}), @@ -2383,7 +2418,7 @@ export class AgentService extends Disposable implements IAgentService { private async _runSandboxCertificateOperation(chat: string, scopeId: string, operation: 'installSandboxCertificate' | 'rotateSandboxCertificate' | 'removeSandboxCertificate'): Promise { const owner = this._chatOwnership.getChatOwner(chat); - const provider = owner === undefined ? undefined : this._providerService.getProviderForSession(URI.parse(owner)); + const provider = owner === undefined ? undefined : this._providerService.getProviderForChat(URI.parse(owner), chat); if (!provider?.[operation]) { throw new Error('Sandbox certificate management is unavailable for this chat.'); } @@ -2397,7 +2432,7 @@ export class AgentService extends Disposable implements IAgentService { this._logService.trace(`[AgentService] stopBackgroundWork ignored for unregistered chat: ${chat.toString()}`); return false; } - return await this._providerService.getProviderForSession(URI.parse(owner))?.stopBackgroundWork?.(chat, id) ?? false; + return await this._providerService.getProviderForChat(URI.parse(owner), chat)?.stopBackgroundWork?.(chat, id) ?? false; } private _isArtifactToolsEnabled(): boolean { @@ -2466,6 +2501,37 @@ export class AgentService extends Disposable implements IAgentService { } } + /** + * The agents a `create_session` chat of `session` may request (`harness`): `undefined` unless chats of one session + * may use different agents and the session's agent supports multiple chats; otherwise the registered agents that do. + */ + private _getServerToolChatHarnesses(session: URI): readonly AgentProvider[] | undefined { + const sessionProvider = this._providerService.getProviderForSession(session); + if (!this.multipleChatProvidersEnabled || !sessionProvider || !this._supportsChats(sessionProvider)) { + return undefined; + } + return this._providerService.getProviders().filter(provider => this._supportsChats(provider) && !this._isAgentDisabledOnHost(provider.id)).map(provider => provider.id); + } + + /** + * Whether a registered agent is disabled by its host-mirrored enablement. Agents disabled when the host starts + * (setting or policy) are never registered, but Codex is registered once its mirrored `codexAgentEnabled` turns on + * and stays registered when a setting or policy change turns it off; such an agent hosts no new chat or session. + */ + private _isAgentDisabledOnHost(provider: AgentProvider): boolean { + return provider === CODEX_AGENT_PROVIDER_ID && this._configurationService.getRootValue(platformRootSchema, AgentHostCodexEnabledConfigKey) === false; + } + + /** Whether `provider` can start a new top-level session now. */ + private _canCreateServerToolSessions(provider: AgentProvider): boolean { + return !!this._providerService.getProvider(provider) && !this._isAgentDisabledOnHost(provider); + } + + /** + * What a chat created by a session server tool from `source` inherits. `source` is the calling chat: its turns and + * model are its own, and so are its agent (`provider`) and, for a chat whose agent differs from its session's, its + * config. `sessionProvider` is the agent of the session it belongs to. + */ private _getServerToolCreationDefaults(source: URI): ISessionCreationDefaults | undefined { const session = this._stateManager.getSessionState(source.toString()); if (!session) { @@ -2477,11 +2543,18 @@ export class AgentService extends Disposable implements IAgentService { : session.draft ? session.draft.model : session.turns.at(-1)?.message.model; - const sourceConfig = getNonMergeSessionConfigValues(session.config?.values); - const config = this._providerService.getProvider(session.provider)?.getInheritedChatConfig(sourceConfig); - const isolation = sourceConfig[SessionConfigKey.Isolation]; + const chatProvider = this._stateManager.getChatProvider(source.toString()) ?? session.provider; + // The calling chat's own effective config: a chat's values are its complete config, and a chat on another + // agent never hands on the session's values, which belong to the session's agent. + const owner = this._stateManager.getChatOwner(source.toString()) ?? source.toString(); + const sourceConfig = getNonMergeSessionConfigValues(chatProvider !== session.provider + ? this._stateManager.getChatConfig(source.toString())?.values ?? {} + : this._configurationService.getChatConfigValues({ session: owner, chat: source.toString() }) ?? session.config?.values); + const config = this._providerService.getProvider(chatProvider)?.getInheritedChatConfig(sourceConfig); + const isolation = getNonMergeSessionConfigValues(session.config?.values)[SessionConfigKey.Isolation]; return { - provider: session.provider, + provider: chatProvider, + sessionProvider: session.provider, ...(model !== undefined ? { model } : {}), ...(config !== undefined ? { config } : {}), ...(isolation === 'folder' || isolation === 'worktree' ? { isolation } : {}), @@ -3114,6 +3187,7 @@ export class AgentService extends Disposable implements IAgentService { summary: chat.title, kind: defaultChat === chat.resource ? 'default' : 'peer', origin: chat.origin, + ...(chat.provider !== undefined ? { provider: chat.provider } : {}), ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), ...(chat.status !== undefined ? { status: chat.status, @@ -3841,6 +3915,7 @@ export class AgentService extends Disposable implements IAgentService { ...(chat.origin?.kind === ChatOriginKind.Tool ? { interactivity: ChatInteractivity.Hidden } : chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), + ...(chat.provider !== undefined ? { provider: chat.provider } : {}), ...(isSessionStatusArchived(chat.status) && defaultChat !== chat.resource ? { archived: true } : {}), isRead: isSessionStatusRead(chat.status), inheritedTurnId: this._stateManager.getChatInheritedTurnId(chat.resource), @@ -3849,6 +3924,7 @@ export class AgentService extends Disposable implements IAgentService { })); } + private _chatAgentProviders: Promise> | undefined; private _agentMergeRestore: Promise = Promise.resolve(); private _agentMergeIndexWrites: Promise = Promise.resolve(); /** Agent Merge notices waiting for a session's in-flight turn to finish. */ @@ -4981,10 +5057,10 @@ export class AgentService extends Disposable implements IAgentService { const epoch = this._registryEpoch; const inFlight = this._inFlightListSessions.get(mode); if (!inFlight) { - return [...await this._startSessionListComputation(mode).promise]; + return this._withChatAgentProviders([...await this._startSessionListComputation(mode).promise]); } if (inFlight.epoch === epoch) { - return [...await inFlight.promise]; + return this._withChatAgentProviders([...await inFlight.promise]); } if (!inFlight.trailing) { const startTrailing = () => this._startSessionListComputation(mode).promise; @@ -5008,7 +5084,7 @@ export class AgentService extends Disposable implements IAgentService { }; void trailing.then(clearTrailing, clearTrailing); } - return [...await inFlight.trailing]; + return this._withChatAgentProviders([...await inFlight.trailing]); } /** @@ -6282,6 +6358,8 @@ export class AgentService extends Disposable implements IAgentService { this._stateManager.setSessionMeta(session.toString(), withSessionFolderPickerDecision(this._stateManager.getSessionState(session.toString())?._meta, folderPickerDecision)); } this._publishWorkingDirectoryIdentities(session.toString()); + // The default chat registered before its session had config: its values are its complete config from here on. + this._configurationService.completeChatConfigs(session.toString()); // Seeded config bypasses `onDidChangeSessionConfig`, so index a session // created with Agent Merge already enabled. this._syncAgentMergeIndex(session, undefined, sessionConfig); @@ -6429,7 +6507,7 @@ export class AgentService extends Disposable implements IAgentService { async listSessionCanvases(session: URI, chat: URI): Promise { this._assertCanvasesEnabled(); await this._restoreSessionChat(session, chat, 'Canvas operation'); - const provider = this._providerService.getProviderForSession(session); + const provider = this._providerService.getProviderForChat(session, chat); if (!provider?.listSessionCanvases) { throw new Error(`Canvas inventory is unavailable for session '${session.toString()}'.`); } @@ -6439,7 +6517,7 @@ export class AgentService extends Disposable implements IAgentService { async openSessionCanvas(session: URI, request: IAgentCanvasOpenRequest, chat: URI): Promise { this._assertCanvasesEnabled(); await this._restoreSessionChat(session, chat, 'Canvas operation'); - const provider = this._providerService.getProviderForSession(session); + const provider = this._providerService.getProviderForChat(session, chat); if (!provider?.openSessionCanvas) { throw new Error(`Opening canvases is unavailable for session '${session.toString()}'.`); } @@ -6458,7 +6536,7 @@ export class AgentService extends Disposable implements IAgentService { return; } this._assertSessionOwnsChat(session, chat, 'Canvas operation'); - const provider = this._providerService.getProviderForSession(session); + const provider = this._providerService.getProviderForChat(session, chat); if (!provider?.closeSessionCanvas) { throw new Error(`Closing canvases is unavailable for session '${session.toString()}'.`); } @@ -6590,8 +6668,9 @@ export class AgentService extends Disposable implements IAgentService { } async listAgentExtensions(session?: URI, chat?: URI): Promise { + // A chat's extensions are its own agent's. const provider = session - ? this._providerService.getProviderForSession(session) + ? this._providerService.getProviderForChat(session, chat) : this._providerService.getProvider(COPILOT_CLI_AGENT_PROVIDER_ID); if (!provider?.listAgentExtensions) { throw new Error('Copilot extension inventory is unavailable.'); @@ -6600,8 +6679,9 @@ export class AgentService extends Disposable implements IAgentService { } async setAgentExtensionEnabled(extensionId: string, enabled: boolean, session?: URI, chat?: URI): Promise { + // A chat's extensions are its own agent's. const provider = session - ? this._providerService.getProviderForSession(session) + ? this._providerService.getProviderForChat(session, chat) : this._providerService.getProvider(COPILOT_CLI_AGENT_PROVIDER_ID); if (!provider?.setAgentExtensionEnabled) { throw new Error('Copilot extension management is unavailable.'); @@ -6641,17 +6721,26 @@ export class AgentService extends Disposable implements IAgentService { await scratch?.commit(); } + /** + * The agent backing `chat` of `session`: the one `createChat` requests for it, else the chat's own agent + * (a chat whose agent differs from its session's), else the session's. + */ + private _getChatWorkspaceAgent(session: URI, chat: URI, options: IAgentCreateChatRequestOptions | undefined): IAgent | undefined { + return options?.provider !== undefined ? this._providerService.getProvider(options.provider) : this._providerService.getProviderForChat(session, chat); + } + /** * Refuses a requested chat workspace the session cannot honor: combined with folders, a fork or a side chat, - * for the default chat, or with a provider without chats or per-chat folders. Returns the session's provider. + * for the default chat, or with an agent without chats or per-chat folders. The agent is the chat's own, which + * differs from the session's for a chat using another agent. Returns that agent. */ private _requireChatWorkspaceSupport(session: URI, chat: URI, options: IAgentCreateChatRequestOptions | undefined, operation: 'createChat' | 'setChatWorkspace' = 'createChat'): IAgent { if (options?.workingDirectories !== undefined || options?.fork !== undefined || options?.sideChat !== undefined) { throw new Error(`[AgentService] ${operation}: a chat workspace cannot be combined with working directories, a fork or a side chat`); } - const provider = this._providerService.getProviderForSession(session); + const provider = this._getChatWorkspaceAgent(session, chat, options); if (!provider) { - throw new Error(`[AgentService] ${operation}: no provider for session ${session.toString()}`); + throw new Error(`[AgentService] ${operation}: no provider for chat ${chat.toString()} of session ${session.toString()}`); } if (!provider.getDescriptor().capabilities?.multipleWorkingDirectories) { throw new Error(`[AgentService] ${operation}: provider ${provider.id} does not support a workspace per chat`); @@ -6944,17 +7033,19 @@ export class AgentService extends Disposable implements IAgentService { try { const session = URI.parse(sessionKey); const chat = URI.parse(chatKey); - const provider = this._providerService.getProviderForSession(session); + // The chat's own agent, with its own config when it differs from its session's, as when it was created. + const provider = this._providerService.getProviderForChat(session, chat); if (!provider) { - throw new Error(`[AgentService] no provider for session ${sessionKey}`); + throw new Error(`[AgentService] no provider for chat ${chatKey}`); } + const ownConfig = provider.id !== this._stateManager.getSessionState(sessionKey)?.provider ? this._stateManager.getChatConfig(chatKey)?.values ?? {} : undefined; const summary = this._stateManager.getSessionState(sessionKey)?.chats.find(candidate => candidate.resource === chatKey); const directories = summary?.workingDirectories; const options = { ...this._carriedChatCreateOptions(chatKey), ...(directories?.length ? { workingDirectories: directories.map(directory => URI.parse(directory)) } : {}), }; - await this._recordChatBacking(session, chat, summary?.origin, await this._createChat(provider, chat, session, options, credentials)); + await this._recordChatBacking(session, chat, summary?.origin, await this._createChat(provider, chat, session, options, credentials, ownConfig)); } catch (error) { this._chatsNeedingBacking.add(chatKey); this._logService.error(`[AgentService] Failed to create the backing of chat ${chatKey} before its turn: ${toErrorMessage(error)}`); @@ -7068,7 +7159,7 @@ export class AgentService extends Disposable implements IAgentService { if (options?.workingDirectories !== undefined || options?.fork !== undefined || options?.sideChat !== undefined) { return undefined; } - provider = this._providerService.getProviderForSession(session); + provider = this._getChatWorkspaceAgent(session, chat, options); if (!provider?.getDescriptor().capabilities?.multipleWorkingDirectories || !this._supportsChats(provider) || !readSessionWorkspaceless(this._stateManager.getSessionState(sessionKey)?._meta) || chat.toString() === this._requireDefaultChat(session)) { return undefined; @@ -7154,10 +7245,12 @@ export class AgentService extends Disposable implements IAgentService { if (creationError) { throw creationError; } - const provider = this._providerService.getProviderForSession(session); - if (!provider) { + const sessionProvider = this._providerService.getProviderForSession(session); + if (!sessionProvider) { throw new Error(`[AgentService] createChat: no provider for session ${sessionKey}`); } + // The chat's agent: the requested one, a fork or side chat's source chat's, else the session's. + const provider = this._resolveCreateChatProvider(session, sessionProvider, options); // When forking, resolve the source chat's turns up to the fork point and // mint fresh turn IDs for the new chat. The agent uses the mapping to // remap per-turn data in the forked chat; the seeded turns make @@ -7166,7 +7259,7 @@ export class AgentService extends Disposable implements IAgentService { let forkedTitle: string | undefined; let forkedSourceTitle: string | undefined; let persistForkedLocalTurnsAfterDuplicateCheck: (() => void) | undefined; - const { sideChat, title: requestedTitle, initialMessage, ...providerOptions } = options ?? {}; + const { sideChat, title: requestedTitle, initialMessage, provider: _requestedProvider, ...providerOptions } = options ?? {}; if (initialMessage && !initialMessageContext) { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'An initial chat message requires trusted submitting-client context.'); } @@ -7175,6 +7268,9 @@ export class AgentService extends Disposable implements IAgentService { } const initialFork = initialMessage !== undefined && providerOptions.fork !== undefined; if (chat.toString() === defaultChat && initialMessage && initialMessageContext) { + if (provider !== sessionProvider) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'The default chat uses its session\'s agent.'); + } if (initialFork) { await this._chatCatalogMutationSequencer.queue(sessionKey, async () => { this._throwIfInitialForkTargetExists(sessionKey, chat, defaultChat); @@ -7297,6 +7393,13 @@ export class AgentService extends Disposable implements IAgentService { } } + // A chat whose agent differs from its session's starts with that agent's defaults, not the session's config. + // A fork or side chat shares its source's own schema; without a loaded one it takes its agent's, never the session's. + const copiesSource = peerChatOrigin?.kind === ChatOriginKind.Fork || peerChatOrigin?.kind === ChatOriginKind.SideChat; + const sourceOwnSchema = provider !== sessionProvider && copiesSource ? this._sourceChatConfigSchema(peerChatOrigin) : undefined; + const ownAgentConfig = provider !== sessionProvider && !sourceOwnSchema + ? await this._resolveOwnAgentChatConfig(provider, session) + : undefined; const createResult = await this._chatCatalogMutationSequencer.queue(sessionKey, async () => { if (initialFork) { this._throwIfInitialForkTargetExists(sessionKey, chat, defaultChat); @@ -7309,12 +7412,23 @@ export class AgentService extends Disposable implements IAgentService { throw new Error('The session workspace changed while preparing the new chat. Try creating the chat again.'); } // The seed makes a new chat's own config values visible to the provider while it creates the chat. - const configSeed = this._configurationService.seedChatConfig(chat.toString(), this._initialChatConfig(sessionKey, chat.toString(), peerChatOrigin)); + const ownConfigSchema = provider === sessionProvider ? undefined : sourceOwnSchema ?? ownAgentConfig?.schema; + const configSeed = ownConfigSchema === undefined + ? this._configurationService.seedChatConfig(chat.toString(), this._configurationService.getCompleteChatConfigValues(sessionKey, this._initialChatConfig(sessionKey, chat.toString(), peerChatOrigin))) + : copiesSource + // The source's own values, restricted to what the new chat's schema has. + ? this._configurationService.seedChatConfig(chat.toString(), toOwnChatConfig({ schema: ownConfigSchema, values: {} }, this._initialChatConfig(sessionKey, chat.toString(), peerChatOrigin)).values, ownConfigSchema) + : this._configurationService.seedChatConfig(chat.toString(), ownAgentConfig!.values, ownConfigSchema); + // Routed to its own agent already while the agent creates it, before the chat is registered. + const routedBeforeRegistration = provider !== sessionProvider && this._stateManager.getChatOwner(chat.toString()) === undefined; + if (routedBeforeRegistration) { + this._providerService.associateChat(chat, provider.id); + } try { persistForkedLocalTurnsAfterDuplicateCheck?.(); // Create the backing chat before publishing `session/chatAdded` so // subscribers only see a chat that can already receive messages. - const createResult = await this._createChat(provider, chat, session, createOptions, credentials); + const createResult = await this._createChat(provider, chat, session, createOptions, credentials, ownConfigSchema !== undefined ? configSeed.values : undefined); const providerData = createResult?.providerData; const title = explicitTitle ?? forkedTitle; const selectedTitleSource = explicitTitle !== undefined @@ -7333,6 +7447,7 @@ export class AgentService extends Disposable implements IAgentService { const newCatalogChat = { uri: chat.toString(), kind: 'peer' as const, + ...(provider !== sessionProvider ? { provider: provider.id } : {}), ...(title !== undefined ? { title } : {}), ...(peerChatOrigin !== undefined ? { origin: peerChatOrigin } : {}), ...(createOptions?.workingDirectories !== undefined ? { workingDirectories: createOptions.workingDirectories.map(directory => directory.toString()) } : {}), @@ -7345,6 +7460,10 @@ export class AgentService extends Disposable implements IAgentService { : existing); this._catalogSyncSuppressedSessions.add(sessionKey); try { + if (provider !== sessionProvider) { + // Before the chat's catalog row, so a restored or listed session never routes it to the session's agent. + await this._recordChatAgentProvider(chat.toString(), provider.id); + } await this._peerChatStore.upsert( session, chat, @@ -7368,17 +7487,20 @@ export class AgentService extends Disposable implements IAgentService { }, catalogChats); // A fork or side chat's copied values are persisted before it is announced; a failure fails the creation. const initialConfig = configSeed.values; - if (Object.keys(initialConfig).length > 0) { + if (Object.keys(initialConfig).length > 0 || ownConfigSchema !== undefined) { await this._sessionStateStore.chatRows.setChatConfig(session, chat, initialConfig); } this._stateManager.addChat(sessionKey, chat.toString(), creationTimeChatStorageResource(sessionKey, chat.toString(), this._defaultChat(sessionKey)), { + ...(provider !== sessionProvider ? { provider: provider.id } : {}), ...(title !== undefined ? { title } : {}), ...(forkedTurns !== undefined ? { turns: forkedTurns } : {}), ...(providerData !== undefined ? { providerData } : {}), ...(peerChatOrigin !== undefined ? { origin: peerChatOrigin } : {}), ...(createResult?.inheritedTurnId !== undefined ? { inheritedTurnId: createResult.inheritedTurnId } : {}), ...(createOptions?.workingDirectories !== undefined ? { workingDirectories: createOptions.workingDirectories.map(directory => directory.toString()) } : {}), - ...(Object.keys(initialConfig).length > 0 ? { config: { values: initialConfig } } : {}), + ...(ownConfigSchema !== undefined + ? { config: { schema: ownConfigSchema, values: initialConfig }, configSchema: ownConfigSchema } + : Object.keys(initialConfig).length > 0 ? { config: { values: initialConfig } } : {}), }); // The registered chat owns its values from here on. configSeed.dispose(); @@ -7394,6 +7516,8 @@ export class AgentService extends Disposable implements IAgentService { } catch (rollbackError) { rollbackErrors.push(rollbackError instanceof Error ? rollbackError : new Error(String(rollbackError))); } + // Its own step, so a failed removal never leaves the agent record behind. Never throws. + await this._deleteChatAgentProviders([chat.toString()]); try { await provider.chats.disposeChat(chat, this._chatContext(session, chat, this._chatStorageResource(session, chat))); } catch (rollbackError) { @@ -7414,6 +7538,11 @@ export class AgentService extends Disposable implements IAgentService { this._flushDeferredCatalogMetadataOverrides(session); } return createResult; + } catch (error) { + if (routedBeforeRegistration && this._stateManager.getChatOwner(chat.toString()) === undefined) { + this._providerService.releaseChat(chat); + } + throw error; } finally { configSeed.dispose(); } @@ -7576,7 +7705,7 @@ export class AgentService extends Disposable implements IAgentService { this._logService.warn(`[AgentService] Failed to restore session ${sessionKey} before deleting chat ${chatKey}; its folders stay attached: ${toErrorMessage(error)}`); } } - const provider = this._providerService.getProviderForSession(session); + const provider = this._providerService.getProviderForChat(session, chat); this._disposingPeerChats.add(chatKey); let deletedChatDirectories: readonly string[] | undefined; try { @@ -7596,6 +7725,7 @@ export class AgentService extends Disposable implements IAgentService { await this._peerChatStore.whenSessionsIdle(owner && owner !== sessionKey ? [session, URI.parse(owner)] : [session]); await this._peerChatStore.remove(session, chat); membershipRemoved = true; + await this._deleteChatAgentProviders([chatKey, ...storage.descendants.map(descendant => descendant.chat.toString())]); await this._clearChatDraft(session, chat); await this._deletePeerChatData(session, chat, storage); await this._deletePeerChatSummaryKeys(session, chatKey); @@ -7893,7 +8023,9 @@ export class AgentService extends Disposable implements IAgentService { const sourceDataOnly = await this._restoreForMove(session, AgentChatMoveRefusal.SessionNotReady, leases, true); if (destination.kind === 'newSession') { this._checkMovedChat(sourceKey, chat); - const provider = this._providerService.getProviderForSession(session); + // A chat's new session is a session of the chat's agent: its own, when chats may use their own agents. + const provider = (this.multipleChatProvidersEnabled ? this._providerService.getProviderForChat(session, chat) : undefined) + ?? this._providerService.getProviderForSession(session); if (!provider) { return refuse(AgentChatMoveRefusal.SessionNotReady, `Session ${sourceKey} has no agent.`); } @@ -8028,6 +8160,8 @@ export class AgentService extends Disposable implements IAgentService { // The target gets the moved chat's folders before anything else changes; its other chats keep their own (PIN). const releases: (() => Promise)[] = []; let restoreConfig: (() => Promise) | undefined; + let restoreAgent: (() => Promise) | undefined; + let agentSwitch: IMovedChatAgent | undefined; let restoreScratchRow: (() => Promise) | undefined; let committed: IMovedChats; let worktreePlan: IWorktreeMoveDecisions; @@ -8071,6 +8205,21 @@ export class AgentService extends Disposable implements IAgentService { } let config: Record = {}; let previousConfig: Record | undefined; + // A chat that gains or loses its own agent takes the config of its new schema, persisted before the commit like + // its other values; one that gains it records its agent first, so no restart routes it to its new session's. + agentSwitch = await this._prepareMovedChatAgent(sourceKey, targetKey, chatKey, moved, chatFolders); + if (agentSwitch) { + const previous = previousConfig = this._stateManager.getChatConfig(chatKey)?.values ?? {}; + await this._sessionStateStore.chatRows.setChatConfig(session, chat, agentSwitch.values); + restoreConfig = () => this._sessionStateStore.chatRows.setChatConfig(session, chat, previous); + if (agentSwitch.kind === 'gain') { + const recorded = agentSwitch; + for (const candidate of recorded.chats) { + await this._recordChatAgentProvider(candidate, recorded.agent); + } + restoreAgent = () => this._deleteChatAgentProviders(recorded.chats); + } + } // Artifacts recorded meanwhile wait for the move, so the moved chats' artifacts are carried from their current lists. artifactsHold = await SessionArtifacts.hold(this._stateManager, [sourceKey, targetKey]); movedArtifacts = this._movedChatArtifacts(sourceKey, targetKey, chatKey, moved, sessionMove); @@ -8083,7 +8232,7 @@ export class AgentService extends Disposable implements IAgentService { // The Agent Merge of the moved chats moves in the same transaction, so no restart finds it in both sessions or in neither. const agentMerge = this._movedAgentMergeInto(sourceKey, targetKey, moved, sessionMove); // The chat keeps Agent Merge's elevation only if it runs it on in the target, as this commit decides. - config = this._movedChatConfig(sourceKey, targetKey, chatKey, agentMerge?.elevated.has(chatKey)); + config = agentSwitch ? {} : this._movedChatConfig(sourceKey, targetKey, chatKey, agentMerge?.elevated.has(chatKey)); if (Object.keys(config).length > 0) { // Persisted before the commit, in the chat's own database: its values in the source don't change by it, and // a crash after the commit keeps them. A refused move restores the chat's previous values. @@ -8156,6 +8305,7 @@ export class AgentService extends Disposable implements IAgentService { this._writeAgentMergeIndex(target, isAnyAgentMergeEnabled(this._stateManager.getSessionState(targetKey)?.config?.values, this._sessionFolderKeyOf(targetKey))); } await restoreConfig?.().catch(restoreError => this._logService.warn(`[AgentService] Failed to restore the config of ${chatKey} after a refused move`, restoreError)); + await restoreAgent?.(); await restoreScratchRow?.().catch(restoreError => this._logService.warn(`[AgentService] Failed to undo the scratch folder record of ${chatKey} after a refused move`, restoreError)); await this._undoWorktreeMovePreparation(withholdings, handOver, chatKey); // A pinned target chat keeps the target's folders as its own; the release removes only a folder no chat uses. @@ -8174,13 +8324,24 @@ export class AgentService extends Disposable implements IAgentService { // The target lists a handed-over worktree now, so reconciliation may see its record. handOver?.dispose(); try { + // Its own schema is known before its subscribers learn its new owner, which `chat/moved` publishes it with. + if (agentSwitch) { + this._stateManager.setChatOwnConfigSchema(chatKey, agentSwitch.schema); + } this._transferMovedChats(sourceKey, targetKey, chatKey, committed, after, chatFolders, sourceSummary?.workingDirectories === undefined ? chatFolders : undefined, sessionMove, movedArtifacts); + if (agentSwitch) { + this._applyMovedChatAgentConfig(targetKey, chatKey, agentSwitch); + } } catch (error) { this._logService.error(error, `[AgentService] Chat ${chatKey} moved to ${targetKey}, but its in-memory move failed; it shows up after the sessions reload`); await this._reloadAfterFailedMove([sourceKey, targetKey]); } artifactsHold?.dispose(); artifactsHold = undefined; + if (agentSwitch?.kind === 'lose') { + // Its agent is its new session's now; a stale record would only name that agent again. + await this._deleteChatAgentProviders(agentSwitch.chats); + } // A chat that never started a turn hasn't announced its worktree yet: its first send does. if (handOver && this._hasStartedTurn(this._stateManager.getChatState(chatKey))) { this._chatContributions.didHandOverWorktree(targetKey, chatKey); @@ -8344,6 +8505,10 @@ export class AgentService extends Disposable implements IAgentService { * (`keepsElevation`), which takes its own elevation along. */ private _movedChatConfig(source: string, target: string, chat: string, keepsElevation = false): Record { + if (this._stateManager.getChatOwnConfigSchema(chat)) { + // A chat with its own agent keeps its own config wherever it moves. + return {}; + } const schema = this._stateManager.getSessionState(target)?.config?.schema; const sourceValues = this._configurationService.getSessionConfigValues(source) ?? {}; const sessionValues = withoutAgentMergeElevation(sourceValues, readAgentMergeInjectedConfiguration(sourceValues)); @@ -8572,7 +8737,7 @@ export class AgentService extends Disposable implements IAgentService { } /** - * Promotes a peer chat and its descendants into a new session of the same agent (AHP `moveChat` to a new session, + * Promotes a peer chat and its descendants into a new session of its agent (AHP `moveChat` to a new session, * Block 4): the chat becomes its default chat and keeps its runtime, which relaunches with the new session's * configuration at its next turn (3c). The new session starts with the source's config and the chat's own values * over it, the chat's folders and the folder state it carries. Runs once {@link _checkChatMoveRequest} accepted the @@ -8615,7 +8780,20 @@ export class AgentService extends Disposable implements IAgentService { const sourceConfig = this._stateManager.getSessionState(sourceKey)?.config; const promotedAgentMerge = () => this._movedAgentMerge(sourceKey, { values: undefined, sessionFolderKey: chatFolders[0] === undefined ? undefined : getWorkingDirectoryKey(chatFolders[0]) }, moved, false); // The chat's own values become the new session's: its future chats start from them. So does the Agent Merge it runs. - const config: SessionConfigState | undefined = sourceConfig && { schema: sourceConfig.schema, values: { ...this._promotedSessionConfigValues(sourceConfig.values, sourceKey, chatKey), ...this._promotedIsolationConfig(sourceConfig, session, chatKey, promotedRepository), ...definedValues(promotedAgentMerge()?.target) } }; + let config: SessionConfigState | undefined = sourceConfig && { schema: sourceConfig.schema, values: { ...this._promotedSessionConfigValues(sourceConfig.values, sourceKey, chatKey), ...this._promotedIsolationConfig(sourceConfig, session, chatKey, promotedRepository), ...definedValues(promotedAgentMerge()?.target) } }; + // A chat with its own agent becomes a session of that agent: the session starts from that agent's config for the + // chat's folder, with the chat's values over it, and the chat, its default chat, no longer has a schema of its own. + const agentSwitch = await this._prepareMovedChatAgent(sourceKey, undefined, chatKey, moved, chatFolders); + if (agentSwitch) { + let agentConfig: SessionConfigState; + try { + agentConfig = await this._resolveAgentChatConfig(provider, session, undefined, chatFolders[0]); + } catch (error) { + return refuse(AgentChatMoveRefusal.AgentConfigUnavailable, `The agent ${provider.id} couldn't describe its settings for the new session (${toErrorMessage(error)}); try again.`); + } + const ownValues = this._configurationService.getChatOwnConfigValues({ session: sourceKey, chat: chatKey }); + config = { schema: agentConfig.schema, values: { ...agentConfig.values, ...getValidChatConfigValues(agentConfig.schema, ownValues), ...this._promotedIsolationConfig(agentConfig, session, chatKey, promotedRepository), ...definedValues(promotedAgentMerge()?.target) } }; + } try { this._checkMovedChat(sourceKey, chatKey); const current = chatSubtree(this._stateManager, sourceKey, chatKey); @@ -8732,6 +8910,10 @@ export class AgentService extends Disposable implements IAgentService { // A rename not persisted yet is known in memory only, while the chat is still the source's. const renamed = this._sideEffects.isTitleRenamed(sourceKey, chatKey); try { + if (agentSwitch) { + // Announced without a schema: as its session's default chat, it uses the session's. + this._stateManager.setChatOwnConfigSchema(chatKey, undefined); + } const project = worktreeProjectFromRepositoryRoot(promotedRepository); this._promoteMovedChats(provider, sourceKey, targetKey, chatKey, committed, chatFolders, config, sourceSummary?.workingDirectories === undefined, promotedWorkspaceless, project && { uri: project.uri.toString(), displayName: project.displayName }, movedArtifacts); } catch (error) { @@ -8742,6 +8924,10 @@ export class AgentService extends Disposable implements IAgentService { } artifactsHold?.dispose(); artifactsHold = undefined; + if (agentSwitch) { + // Its agent is its new session's. + await this._deleteChatAgentProviders(agentSwitch.chats); + } await this._persistPromotedSessionTitle(target, chatKey, renamed); await this._completeMovedWorktrees(session, target, worktreePlan, undefined, false); // The new session is created with the chat's folders, so none were in transit; the source lets go of those no other chat uses. @@ -9111,6 +9297,85 @@ export class AgentService extends Disposable implements IAgentService { } } + /** + * How a chat moving from `source` to `target` (`undefined`: a new session of its own agent) changes with its agent, + * which never changes: it gains its own agent when it joins a session of another agent, then takes its agent's chat + * schema, resolved for its folder, with the values it had that are valid there; it loses it when it joins a session + * of its agent, then keeps the values valid in that session's schema. `chats` are the moved chats of its agent, the + * chat and its descendants. `undefined` when its agent keeps relating to its session as before. + */ + private async _prepareMovedChatAgent(source: string, target: string | undefined, chat: string, moved: readonly string[], chatFolders: readonly string[]): Promise { + const sourceProvider = this._stateManager.getSessionState(source)?.provider; + const agent = this._stateManager.getChatProvider(chat) ?? sourceProvider; + const targetProvider = target === undefined ? agent : this._stateManager.getSessionState(target)?.provider; + if (agent === undefined || (agent === sourceProvider) === (agent === targetProvider)) { + return undefined; + } + const chats = moved.filter(candidate => (this._stateManager.getChatProvider(candidate) ?? sourceProvider) === agent); + if (agent === sourceProvider) { + const provider = this._providerService.getProvider(agent); + if (!provider) { + throw new AgentChatMoveRefusedError(AgentChatMoveRefusal.AgentUnavailable, `The agent ${agent} of this chat is unknown or disabled; enable it before moving the chat.`); + } + let agentConfig: SessionConfigState; + try { + agentConfig = await this._resolveAgentChatConfig(provider, URI.parse(source), undefined, chatFolders[0]); + } catch (error) { + throw new AgentChatMoveRefusedError(AgentChatMoveRefusal.AgentConfigUnavailable, `The agent ${agent} couldn't describe its settings for this chat (${toErrorMessage(error)}); try again.`); + } + const effective = this._configurationService.getChatConfigValues({ session: source, chat }) ?? {}; + const own = toOwnChatConfig({ schema: agentConfig.schema, values: {} }, getValidChatConfigValues(agentConfig.schema, effective)); + return { kind: 'gain', agent, chats, schema: own.schema, values: own.values }; + } + const schema = target === undefined ? undefined : this._stateManager.getSessionState(target)?.config?.schema; + const current = this._configurationService.getChatOwnConfigValues({ session: source, chat }); + return { kind: 'lose', agent, chats, values: schema ? getValidChatConfigValues(schema, current) : { ...current } }; + } + + /** + * Publishes the values of a moved chat that gained or lost its own agent, after `chat/moved` published its schema: + * a replacement only when they changed. A chat that lost its own agent then takes its new session's values of what + * else it may set, as its complete config. + */ + private _applyMovedChatAgentConfig(session: string, chat: string, agentSwitch: IMovedChatAgent): void { + if (!this._stateManager.isChatRegistered(chat)) { + return; + } + if (!equals(this._stateManager.getChatConfig(chat)?.values ?? {}, agentSwitch.values)) { + this._stateManager.dispatchServerAction(chat, { type: ActionType.ChatConfigChanged, config: { ...agentSwitch.values }, replace: true }); + } + if (agentSwitch.kind === 'lose') { + this._configurationService.completeChatConfigs(session); + } + } + + /** + * Refuses moving a chat of agent `chatProvider` across agents: into a session of `targetProvider`, or (`undefined`) + * into a new session of its own agent. It needs chats with their own agents to be on, its agent to be available, and, + * when it keeps an agent other than its new session's, both agents to support multiple chats. + */ + private _checkCrossAgentChatMove(chatProvider: string | undefined, targetProvider: string | undefined): void { + const refuse = (reason: AgentChatMoveRefusal, message: string): never => { + throw new AgentChatMoveRefusedError(reason, message); + }; + if (!this.multipleChatProvidersEnabled) { + refuse(AgentChatMoveRefusal.DifferentProvider, 'Chats can only move between sessions of the same agent. Turn on chats with their own agents to move it to a session of another agent.'); + } + const agent = chatProvider === undefined ? undefined : this._providerService.getProvider(chatProvider); + if (!agent || this._isAgentDisabledOnHost(chatProvider!)) { + refuse(AgentChatMoveRefusal.AgentUnavailable, `The agent ${chatProvider} of this chat is unknown or disabled; enable it before moving the chat.`); + } + if (targetProvider !== undefined && chatProvider !== targetProvider) { + const target = this._providerService.getProvider(targetProvider); + if (!target || !this._supportsChats(target)) { + refuse(AgentChatMoveRefusal.AgentWithoutChats, `The destination's agent ${targetProvider} can't host chats of other agents; pick a session of an agent that supports multiple chats.`); + } + if (!this._supportsChats(agent!)) { + refuse(AgentChatMoveRefusal.AgentWithoutChats, `The agent ${chatProvider} of this chat doesn't support multiple chats, so it can't join a session of another agent.`); + } + } + } + /** * Refuses a move the host does not support (Block 4 design, section 1.5). Synchronous, so a move runs it again * right before committing; nothing has changed when it throws. @@ -9132,8 +9397,19 @@ export class AgentService extends Disposable implements IAgentService { } const source = URI.parse(sourceKey); const target = targetKey === undefined ? undefined : URI.parse(targetKey); - if (target && this._providerService.getProviderForSession(source)?.id !== this._providerService.getProviderForSession(target)?.id) { - refuse(AgentChatMoveRefusal.DifferentProvider, 'Chats can only move between sessions of the same agent.'); + const sourceProvider = this._providerService.getProviderForSession(source)?.id; + const chatProvider = this._stateManager.getChatProvider(chat) ?? sourceProvider; + // A chat keeps its agent wherever it moves. Between sessions of different agents (or into a new session of its own + // agent, for a chat with its own agent) it may move only while chats may use their own agents. + if (target === undefined) { + if (chatProvider !== sourceProvider) { + this._checkCrossAgentChatMove(chatProvider, undefined); + } + } else { + const targetProvider = this._providerService.getProviderForSession(target)?.id; + if (sourceProvider !== targetProvider) { + this._checkCrossAgentChatMove(chatProvider, targetProvider); + } } if (target && !this._authService.hasSameCredentialOwner(sourceKey, targetKey!)) { refuse(AgentChatMoveRefusal.DifferentCredentialOwner, 'Sessions with different credential owners can\'t be combined; pick a destination that uses the same account, or move it to its own session.'); @@ -9164,7 +9440,7 @@ export class AgentService extends Disposable implements IAgentService { } if (target === undefined) { // A new session takes the chat's folders. - if (chatFolders.length > 1 && !this._providerService.getProviderForSession(source)?.getDescriptor().capabilities?.multipleWorkingDirectories) { + if (chatFolders.length > 1 && !this._providerService.getProviderForChat(source, chat)?.getDescriptor().capabilities?.multipleWorkingDirectories) { refuse(AgentChatMoveRefusal.DestinationSingleFolder, `A new session of ${sourceKey}'s agent can't take more than one folder.`); } return; @@ -9333,6 +9609,125 @@ export class AgentService extends Disposable implements IAgentService { // chat-addressed surface ({@link IAgent.chats}) and session lifecycle // ({@link IAgent.createSession}/{@link IAgent.disposeSession}). + /** + * The config of a restored chat whose agent differs from its session's: its + * persisted values that the agent's current chat schema still has, with that + * schema. A chat whose agent is unavailable keeps its values with an empty + * schema, so it still never takes the session's config. `cache` shares one + * resolution per agent and working directory across a restore pass. + */ + private async _restoreOwnChatConfig(session: URI, provider: AgentProvider, values: Readonly> | undefined, cache?: Map>): Promise { + const agent = this._providerService.getProvider(provider); + if (agent) { + try { + const agentConfig = await this._resolveAgentChatConfig(agent, session, cache); + const own = toOwnChatConfig(agentConfig); + return { schema: own.schema, values: values !== undefined ? toOwnChatConfig({ schema: agentConfig.schema, values: {} }, values).values : own.values }; + } catch (error) { + this._logService.warn(`[AgentService] Failed to resolve the config schema of agent ${provider} for a chat of ${session.toString()}: ${toErrorMessage(error)}`); + } + } + return { schema: { type: 'object', properties: {} }, values: values !== undefined ? { ...values } : {} }; + } + + /** Resolves an agent's config for a chat of `session` whose agent differs from the session's, once per `cache` key. */ + private _resolveAgentChatConfig(provider: IAgent, session: URI, cache?: Map>, workingDirectory?: string): Promise { + const primary = workingDirectory ?? this._stateManager.getSessionSummary(session.toString())?.workingDirectories?.[0]; + const key = `${provider.id}\n${primary ?? ''}`; + const cached = cache?.get(key); + if (cached) { + return cached; + } + const resolved = (async () => { + const result = await provider.resolveChatConfig({ provider: provider.id, ...(primary !== undefined ? { workingDirectory: URI.parse(primary) } : {}) }); + return { schema: result.schema, values: result.values }; + })(); + cache?.set(key, resolved); + return resolved; + } + + /** The own config schema of a fork or side chat's source chat, which the new chat shares with its agent; `undefined` when none is loaded. */ + private _sourceChatConfigSchema(origin: ChatOrigin | undefined): SessionConfigSchema | undefined { + if (origin?.kind !== ChatOriginKind.Fork && origin?.kind !== ChatOriginKind.SideChat) { + return undefined; + } + return this._stateManager.getChatOwnConfigSchema(origin.chat); + } + + /** The config a new chat of an agent other than its session's starts with; an empty schema when the agent can't resolve one. */ + private async _resolveOwnAgentChatConfig(provider: IAgent, session: URI): Promise { + try { + return toOwnChatConfig(await this._resolveAgentChatConfig(provider, session)); + } catch (error) { + this._logService.warn(`[AgentService] Failed to resolve the config of agent ${provider.id} for a new chat of ${session.toString()}: ${toErrorMessage(error)}`); + return { schema: { type: 'object', properties: {} }, values: {} }; + } + } + + /** + * Resolves the agent a new chat is created in. A requested agent needs the + * `multipleChatProviders` capability, must be registered and support + * multiple chats, and a fork or side chat must use its source chat's agent + * (history never transfers between agents). Without a request, a fork or + * side chat uses its source chat's agent and any other chat its session's. + */ + private _resolveCreateChatProvider(session: URI, sessionProvider: IAgent, options: IAgentCreateChatRequestOptions | undefined): IAgent { + const source = options?.sideChat?.source ?? options?.fork?.source; + const sourceProvider = source === undefined ? undefined : this._resolveSourceChatProvider(session, source.toString()); + const requested = options?.provider; + if (requested === undefined) { + return sourceProvider ?? sessionProvider; + } + if (!this.multipleChatProvidersEnabled) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'This host does not support chats with their own agent.'); + } + const provider = this._providerService.getProvider(requested); + if (!provider || this._isAgentDisabledOnHost(requested)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Agent ${requested} is unknown or disabled.`); + } + if (!this._supportsChats(provider)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Agent ${requested} does not support multiple chats.`); + } + if (sourceProvider !== undefined && sourceProvider !== provider) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'A fork or side chat uses its source chat\'s agent.'); + } + return provider; + } + + /** + * The agent a fork or side chat of `source` keeps: its source chat's. A source restored but not loaded (registered + * from its owner's catalog only) is resolved through the state manager, whose restored summary knows its owner and + * recorded agent. With chats with their own agents off, only a source that already uses its own agent imposes it; + * any other fork uses its new session's agent, as before (`undefined`). Throws InvalidParams, never falling back to + * the session's agent, when the source's agent is unavailable. + */ + private _resolveSourceChatProvider(session: URI, source: string): IAgent | undefined { + const owner = this._chatOwnership.getChatOwner(source) ?? this._stateManager.getChatOwner(source); + if (owner === undefined) { + if (!this.multipleChatProvidersEnabled) { + return undefined; + } + // A source the host doesn't know: a chat of the new chat's session, as an unregistered chat is routed. + const provider = this._providerService.getProviderForChat(session, source); + if (!provider) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `The agent of chat ${source} is unavailable.`); + } + return provider; + } + const ownerProvider = this._stateManager.getSessionState(owner)?.provider ?? this._providerService.getProviderForSession(owner)?.id; + const recorded = this._stateManager.getChatProvider(source); + const ownAgent = recorded !== undefined && recorded !== ownerProvider ? recorded : undefined; + const agent = ownAgent ?? (this.multipleChatProvidersEnabled ? ownerProvider : undefined); + if (agent === undefined) { + return undefined; + } + const provider = this._providerService.getProvider(agent); + if (!provider) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `The agent of chat ${source} is unavailable.`); + } + return provider; + } + /** Whether `provider` can host additional (peer) chats. */ private _supportsChats(provider: IAgent): boolean { // Gate additional chats on the advertised `multipleChats` capability, @@ -9440,7 +9835,7 @@ export class AgentService extends Disposable implements IAgentService { } private async _bindExecutionCredentials(session: string, credentials: IAgentHostCredentialSource, chat?: string): Promise { - const provider = this._providerService.getProviderForSession(session); + const provider = this._providerService.getProviderForChat(session, chat); if (!provider) { throw new ProtocolError(AhpErrorCodes.AuthRequired, 'The isolated session provider is unavailable'); } @@ -9520,6 +9915,86 @@ export class AgentService extends Disposable implements IAgentService { return recordedChatStorage(snapshot, this._logService).get(defaultChat.toString()) ?? this._newDefaultChatStorageResource(session, defaultChat); } + /** + * The recorded agents of chats whose agent differs from their session's, read once from agent-host.db. They live + * outside every validated catalog payload (see {@link IAgentHostDatabase.readChatAgentProviders}), so older builds + * neither see nor rewrite them. + */ + private _readChatAgentProviders(): Promise> { + if (!this._chatAgentProviders) { + const loading = (async () => new Map(await this._orchestratorDatabase.readChatAgentProviders?.() ?? []))(); + this._chatAgentProviders = loading; + loading.catch(error => { + this._logService.warn(`[AgentService] Failed to read the agents of chats: ${toErrorMessage(error)}`); + if (this._chatAgentProviders === loading) { + this._chatAgentProviders = undefined; + } + }); + } + return this._chatAgentProviders; + } + + /** + * The recorded agents of chats, or `undefined` with a warning when they can't be read: callers then treat every chat + * whose agent they don't know otherwise as unknown, never as a chat of its session's agent. + */ + private async _tryReadChatAgentProviders(): Promise | undefined> { + try { + return await this._readChatAgentProviders(); + } catch (error) { + this._logService.warn(`[AgentService] The agents of chats are unavailable: ${toErrorMessage(error)}`); + return undefined; + } + } + + private async _recordChatAgentProvider(chat: string, provider: AgentProvider): Promise { + if (!this._orchestratorDatabase.recordChatAgentProvider) { + // Without a durable record the chat would revert to its session's agent after a restart. + throw new Error(`Cannot record the agent ${provider} of chat ${chat}: this host can't persist it.`); + } + await this._orchestratorDatabase.recordChatAgentProvider(chat, provider); + (await this._readChatAgentProviders()).set(chat, provider); + } + + /** Records the agent of a chat that differs from its session's, when it does. */ + private async _recordOwnChatAgentProvider(session: string, chat: string): Promise { + const provider = this._stateManager.getChatProvider(chat); + if (provider !== undefined && provider !== this._stateManager.getSessionState(session)?.provider) { + await this._recordChatAgentProvider(chat, provider); + } + } + + /** Forgets the recorded agents of deleted chats; failures are logged, a stale key never routes a chat that no longer exists. */ + private async _deleteChatAgentProviders(chats: readonly string[]): Promise { + if (chats.length === 0 || !this._orchestratorDatabase.deleteChatAgentProviders) { + return; + } + try { + await this._orchestratorDatabase.deleteChatAgentProviders(chats); + const providers = await this._tryReadChatAgentProviders(); + if (providers instanceof Map) { + for (const chat of chats) { + providers.delete(chat); + } + } + } catch (error) { + this._logService.warn(`[AgentService] Failed to forget the agents of deleted chats: ${toErrorMessage(error)}`); + } + } + + /** Stamps listed chats with their recorded agents, which the catalog payloads never carry. */ + private async _withChatAgentProviders(sessions: IAgentSessionMetadata[]): Promise { + const providers = await this._readChatAgentProviders().catch(() => undefined); + if (!providers?.size) { + return sessions; + } + // A record naming the session's own agent is stale: the chat follows its session. + const recorded = (session: IAgentSessionMetadata, chat: string) => providers.get(chat) !== undefined && providers.get(chat) !== session.provider; + return sessions.map(session => session.chats?.some(chat => chat.provider === undefined && recorded(session, chat.chat.toString())) + ? { ...session, chats: session.chats.map(chat => chat.provider === undefined && recorded(session, chat.chat.toString()) ? { ...chat, provider: providers.get(chat.chat.toString()) } : chat) } + : session); + } + /** Storage resources the normalized catalog records for a session's chats. */ private async _readRecordedChatStorage(session: string): Promise> { const [snapshot] = await this._orchestratorDatabase.readCatalogSnapshot([session]); @@ -9752,7 +10227,7 @@ export class AgentService extends Disposable implements IAgentService { * Destructively tears a session down: dispose peer chats first and the * default chat last, and still visit every chat if one rejects. */ - private async _disposeSession(provider: IAgent, session: URI, persistedPeers: readonly IPersistedPeerChat[] | undefined, defaultChat: string, privateChats: readonly IPersistedPeerChat[] = []): Promise { + private async _disposeSession(provider: IAgent, session: URI, persistedPeers: readonly IPersistedPeerChat[] | undefined, defaultChat: string, privateChats: readonly IPersistedPeerChat[] = [], chatProviders?: ReadonlyMap): Promise { await this._defaultChatBackingWrites.get(session.toString())?.catch(() => { }); const state = this._stateManager.getSessionState(session.toString()); const catalog = persistedPeers ?? (!state ? await provider.listLegacyChatBackings?.(session) : undefined); @@ -9765,17 +10240,31 @@ export class AgentService extends Disposable implements IAgentService { ], defaultChat); const defaultChatStorage = await this._resolveDefaultChatStorageResource(session, URI.parse(defaultChat)); const storageOf = (chat: URI) => chat.toString() === defaultChat ? defaultChatStorage : this._chatStorageResource(session, chat); + // A chat with its own agent is disposed by that agent, also when it is not loaded. + const agentOf = (chat: URI): IAgent | undefined => { + if (!chatProviders && chat.toString() !== defaultChat && this._stateManager.getChatProvider(chat.toString()) === undefined) { + // Without the recorded agents an unloaded chat's agent is unknown: it is skipped, never disposed by the session's agent. + return undefined; + } + return this._chatAgentOf(provider, chat, chatProviders?.get(chat.toString())); + }; for (const chat of chats) { const persisted = catalog?.find(entry => isEqual(URI.parse(entry.uri.toString()), chat)) ?? privateBackings.find(entry => entry.uri === chat.toString()); - if (!this._stateManager.getChatState(chat.toString()) && (chat.toString() === defaultChat || persisted)) { + const agent = agentOf(chat); + if (agent && !this._stateManager.getChatState(chat.toString()) && (chat.toString() === defaultChat || persisted)) { const providerData = chat.toString() === defaultChat ? await this._readDefaultChatProviderData(session) : persisted?.providerData; - await provider.materializeChat(chat, this._chatContext(session, chat, storageOf(chat)), providerData); + await agent.materializeChat(chat, this._chatContext(session, chat, storageOf(chat)), providerData); } } let firstError: unknown; for (const chat of chats) { + const agent = agentOf(chat); + if (!agent) { + this._logService.warn(`[AgentService] Cannot dispose chat ${chat.toString()} of ${session.toString()}: its agent is not registered`); + continue; + } try { - await provider.chats.disposeChat(chat, this._chatContext(session, chat, storageOf(chat))); + await agent.chats.disposeChat(chat, this._chatContext(session, chat, storageOf(chat))); } catch (err) { firstError ??= err; } @@ -9786,13 +10275,28 @@ export class AgentService extends Disposable implements IAgentService { return chats; } + /** + * The agent of a chat of a session whose agent is `sessionProvider`: the chat's own agent when it records one + * (`recorded` for a chat that is not loaded), else the session's. `undefined` when the chat's own agent is not + * registered: such a chat never falls back to its session's agent. + */ + private _chatAgentOf(sessionProvider: IAgent, chat: URI, recorded?: AgentProvider): IAgent | undefined { + const own = this._stateManager.getChatProvider(chat.toString()) ?? recorded; + return own === undefined || own === sessionProvider.id ? sessionProvider : this._providerService.getProvider(own); + } + /** * Releases a session's in-memory footprint without deleting durable data. * Idle eviction must use {@link IAgentChats.releaseChat}, not destructive * session finalization, so the session remains resumable. */ - private async _canReleaseSession(provider: IAgent, session: URI, chats: readonly URI[]): Promise { + private async _canReleaseSession(sessionProvider: IAgent, session: URI, chats: readonly URI[]): Promise { for (const chat of chats) { + const provider = this._chatAgentOf(sessionProvider, chat); + if (!provider) { + this._logService.warn(`[AgentService] Cannot check the release of chat ${chat.toString()} of ${session.toString()}: its agent is not registered`); + continue; + } if (provider.chats.canReleaseChat && !await provider.chats.canReleaseChat(chat, this._chatContext(session, chat, this._chatStorageResource(session, chat)))) { return false; } @@ -9800,12 +10304,17 @@ export class AgentService extends Disposable implements IAgentService { return true; } - private async _releaseSession(provider: IAgent, session: URI, chats: readonly URI[]): Promise { + private async _releaseSession(sessionProvider: IAgent, session: URI, chats: readonly URI[]): Promise { await this._defaultChatBackingWrites.get(session.toString())?.catch(() => { }); // Still release every catalog chat if one rejects; otherwise an idle-evicted // session could leave a chat resident indefinitely. let firstError: unknown; for (const chat of chats) { + const provider = this._chatAgentOf(sessionProvider, chat); + if (!provider) { + this._logService.warn(`[AgentService] Cannot release chat ${chat.toString()} of ${session.toString()}: its agent is not registered`); + continue; + } try { await provider.chats.releaseChat(chat, this._chatContext(session, chat, this._chatStorageResource(session, chat))); } catch (err) { @@ -9849,7 +10358,7 @@ export class AgentService extends Disposable implements IAgentService { await this._restoreSessionInFlight.get(session.toString()); const previous = this._stateManager.getChatState(chat.toString()); const watch = this._chatWatches.get(chat); - if (!previous || this._store.isDisposed || this._providerService.getProviderForSession(session) !== provider || !this._subscriptions.hasSubscribers(chat)) { + if (!previous || this._store.isDisposed || this._providerService.getProviderForChat(session, chat) !== provider || !this._subscriptions.hasSubscribers(chat)) { return; } if (previous.activeTurn) { @@ -9863,7 +10372,7 @@ export class AgentService extends Disposable implements IAgentService { const refreshed = previous.turns.map(turn => byId.get(turn.id) ?? turn); const existing = new Set(previous.turns.map(turn => turn.id)); refreshed.push(...turns.filter(turn => !existing.has(turn.id))); - if (!this._store.isDisposed && this._providerService.getProviderForSession(session) === provider && this._subscriptions.hasSubscribers(chat) && this._chatWatches.get(chat) === watch) { + if (!this._store.isDisposed && this._providerService.getProviderForChat(session, chat) === provider && this._subscriptions.hasSubscribers(chat) && this._chatWatches.get(chat) === watch) { const current = this._stateManager.getChatState(chat.toString()); if (current?.activeTurn) { this._pendingChatHistories.set(chat.toString(), { provider, chat, turns: providerTurns }); @@ -9942,8 +10451,10 @@ export class AgentService extends Disposable implements IAgentService { * always a peer URI here (the default chat is created implicitly with * the session), so no default-chat resolution is needed. */ - private async _createChat(provider: IAgent, chat: URI, session: URI, options: IAgentCreateChatOptions | undefined, credentials?: IAgentHostCredentialSource): Promise { - const placement = this._buildChatPlacement(session); + private async _createChat(provider: IAgent, chat: URI, session: URI, options: IAgentCreateChatOptions | undefined, credentials?: IAgentHostCredentialSource, ownConfig?: Readonly>): Promise { + const sessionPlacement = this._buildChatPlacement(session); + // A chat with its own agent is created with its own config, never the session's. + const placement = sessionPlacement && ownConfig !== undefined ? { ...sessionPlacement, config: Object.keys(ownConfig).length > 0 ? { ...ownConfig } : undefined } : sessionPlacement; const isEphemeral = this._stateManager.isEphemeralSession(session.toString()); const convOptions: IAgentCreateChatOptions | undefined = (options?.title !== undefined || options?.model !== undefined || options?.workingDirectories !== undefined || placement || isEphemeral) ? { @@ -10196,6 +10707,13 @@ export class AgentService extends Disposable implements IAgentService { if (configValues && Object.keys(configValues).length > 0) { this._persistConfigValues(session, configValues); } + // With complete chat values, its default chat's values were not persisted while the session was provisional. + const defaultChatConfig = state.defaultChat === undefined || !this.multipleChatProvidersEnabled ? undefined : this._configurationService.getChatPersistedConfigValues(state.defaultChat); + if (state.defaultChat && defaultChatConfig && Object.keys(defaultChatConfig).length > 0) { + this._sessionStateStore.chatRows.setChatConfig(session, URI.parse(state.defaultChat), defaultChatConfig).catch(err => { + this._logService.warn(`[AgentService] Failed to persist the config of ${state.defaultChat}`, err); + }); + } // Persist the AH-owned workspace-less marker now that the session has a // real on-disk database (deferred from create for provisional sessions). this._queueCatalogSync(session, this._creationMetadataOverrides(state._meta)); @@ -10464,6 +10982,10 @@ export class AgentService extends Disposable implements IAgentService { return this._automationService.capabilities; } + get multipleChatProvidersEnabled(): boolean { + return this._configurationService.getRootValue(chatProvidersRootConfigSchema, ChatProvidersConfigKey.Enabled) === true; + } + async listAutomationTriggerDefinitions(params: ListAutomationTriggerDefinitionsParams): Promise { return this._automationService.listTriggerDefinitions(params); } @@ -10671,7 +11193,8 @@ export class AgentService extends Disposable implements IAgentService { await catalogDeletionFence.whenDrained; if (provider) { traceStage('disposeProviderChats'); - const disposed = await this._disposeSession(provider, session, persistedPeerChats, defaultChat, recordedChats.privateChats); + const chatProviders = await this._tryReadChatAgentProviders(); + const disposed = await this._disposeSession(provider, session, persistedPeerChats, defaultChat, recordedChats.privateChats, chatProviders); chatsToDelete = this._orderSessionChatsForTeardown(session, [...disposed.map(chat => chat.toString()), ...recordedChats.storage.keys()], defaultChat); } traceStage('drainSessionState'); @@ -10702,6 +11225,7 @@ export class AgentService extends Disposable implements IAgentService { traceStage('drainSessionData'); await this._whenSessionDataIdle(session); traceStage('deleteChatData'); + await this._deleteChatAgentProviders(chatsToDelete.map(chat => chat.toString())); await this._deleteChatStorage(session, chatsToDelete.map(chat => ({ chat, storage: recordedChats.storage.get(chat.toString()) ?? creationTimeChatStorageResource(sessionKey, chat.toString(), defaultChat), @@ -11138,7 +11662,7 @@ export class AgentService extends Disposable implements IAgentService { return; } const owner = this._chatOwnership.requireChatOwner(chat.toString()); - const provider = this._providerService.getProviderForSession(URI.parse(owner)); + const provider = this._providerService.getProviderForChat(URI.parse(owner), chat); if (provider?.watchChatHistory || provider?.watchChatBackgroundWork) { const watches = new DisposableStore(); this._chatWatches.set(chat, watches); @@ -12413,7 +12937,8 @@ export class AgentService extends Disposable implements IAgentService { || isSessionChatArchived(chatSummary) === attached) { return; } - const provider = this._providerService.getProviderForSession(session); + // Per-chat folders are the chat's agent's capability. + const provider = this._providerService.getProviderForChat(session, chat); if (!provider?.getDescriptor().capabilities?.multipleWorkingDirectories) { return; } @@ -12503,7 +13028,7 @@ export class AgentService extends Disposable implements IAgentService { const sessionKey = session.toString(); return this._additionalWorktreeSequencer.queue(sessionKey, async () => { const state = this._stateManager.getSessionState(sessionKey); - if (!state || state.lifecycle !== SessionLifecycle.Ready || !this._providerService.getProviderForSession(session)?.getDescriptor().capabilities?.multipleWorkingDirectories) { + if (!state || state.lifecycle !== SessionLifecycle.Ready || !this._providerService.getProviderForChat(session, chat)?.getDescriptor().capabilities?.multipleWorkingDirectories) { return; } await this._detachChatWorkingDirectories(session, state.chats, chat, chatDirectories); @@ -12517,7 +13042,7 @@ export class AgentService extends Disposable implements IAgentService { if (!workingDirectories?.length) { return; } - const { capability } = this._getChatWorkingDirectoryContext(session); + const { capability } = this._getChatWorkingDirectoryContext(session, chat); const usedScopes = this._getWorkingDirectoriesInUse(chats, chat, this._sessionFoldersOfChatWithoutOwn(sessionKey)); if (usedScopes.some(scope => scope === undefined)) { return; @@ -12578,14 +13103,15 @@ export class AgentService extends Disposable implements IAgentService { return canonicalDirectory; } - private _getChatWorkingDirectoryContext(session: URI): { workingDirectories: readonly string[]; capability: { immutablePrimary: boolean; primaryReplacement: boolean } } { + /** The session's folders and the folder capability of `chat`'s agent, or of the session's when no chat is given. */ + private _getChatWorkingDirectoryContext(session: URI, chat?: string): { workingDirectories: readonly string[]; capability: { immutablePrimary: boolean; primaryReplacement: boolean } } { const sessionKey = session.toString(); const state = this._stateManager.getSessionState(sessionKey); const workingDirectories = this._stateManager.getSessionSummary(sessionKey)?.workingDirectories; if (!state || state.lifecycle !== SessionLifecycle.Ready || !workingDirectories?.length) { throw new Error(`Session is not ready for working-directory changes: ${sessionKey}`); } - const provider = this._providerService.getProviderForSession(session); + const provider = this._providerService.getProviderForChat(session, chat); const capability = provider?.getDescriptor().capabilities?.multipleWorkingDirectories; if (!provider || !capability) { throw new Error(`Provider does not support chat working directories: ${AgentSession.provider(session) ?? '(unknown)'}`); @@ -12694,11 +13220,12 @@ export class AgentService extends Disposable implements IAgentService { if (this._stateManager.getChatOrigin(chat)?.kind === ChatOriginKind.Tool) { throw new Error('A subagent chat inherits its config from the chat that spawned it.'); } - const schema = this._stateManager.getSessionState(session)?.config?.schema; + const target = { session, chat }; + // A chat whose agent differs from its session's is validated against its own schema. + const schema = this._configurationService.getChatConfigState(target)?.schema ?? this._stateManager.getSessionState(session)?.config?.schema; if (!schema) { throw new Error('The session has no config.'); } - const target = { session, chat }; const values = this._configurationService.getChatConfigValues(target) ?? {}; const hostWritten = (key: string) => (HOST_WRITTEN_SESSION_CONFIG_KEYS as readonly string[]).includes(key) || schema.properties[key]?.readOnly === true; for (const [key, value] of Object.entries(action.config)) { @@ -12857,7 +13384,7 @@ export class AgentService extends Disposable implements IAgentService { const sessionArchived = ((sessionState?.status ?? 0) & SessionStatus.IsArchived) === SessionStatus.IsArchived; const turn = chatState?.turns.at(-1); const errorPart = getErrorResponsePart(turn); - const provider = this._providerService.getProviderForSession(sessionChannel); + const provider = this._providerService.getProviderForChat(sessionChannel, channel); if (chatState?.activeTurn) { return { rejection: 'Cannot resume while a turn is active.' }; } @@ -12879,14 +13406,14 @@ export class AgentService extends Disposable implements IAgentService { const id = action.id; const state = this._stateManager.getChatState(channel); const existing = state?.queuedMessages?.find(message => message.id === id); - if (action.type === ActionType.ChatPendingMessageSet && action.kind === PendingMessageKind.Steering && !this._providerService.getProviderForSession(sessionChannel)?.sendSteeringMessage) { + if (action.type === ActionType.ChatPendingMessageSet && action.kind === PendingMessageKind.Steering && !this._providerService.getProviderForChat(sessionChannel, channel)?.sendSteeringMessage) { this._stateManager.rejectClientAction(channel, action, origin, 'The session provider does not support steering messages.'); return; } // Steering handed to the provider cannot be edited, and can be removed only if the provider can withdraw it. if (state?.activeTurn && existing?.kind === PendingMessageKind.Steering && (action.type === ActionType.ChatPendingMessageRemoved - ? action.kind === PendingMessageKind.Steering && !this._providerService.getProviderForSession(sessionChannel)?.withdrawSteeringMessage + ? action.kind === PendingMessageKind.Steering && !this._providerService.getProviderForChat(sessionChannel, channel)?.withdrawSteeringMessage : action.kind !== existing.kind || !equals(existing.message, action.message))) { this._stateManager.rejectClientAction(channel, action, origin, 'Cannot change a steering message while it is being processed.'); return; @@ -14079,6 +14606,7 @@ export class AgentService extends Disposable implements IAgentService { title: chat.title ?? '', ...(chat.origin !== undefined ? { origin: chat.origin } : {}), ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), + ...(chat.provider !== undefined ? { provider: chat.provider } : {}), // An archived chat keeps its read state; dropping it would restore the session unread. ...(chat.archived === true ? { status: withSessionStatusFlag(SessionStatus.IsArchived, SessionStatus.IsRead, chat.isRead !== false) } : {}), ...(chat.changes !== undefined ? { changes: chat.changes } : {}), @@ -14501,12 +15029,19 @@ export class AgentService extends Disposable implements IAgentService { } return undefined; } - return result.data.chats.map(chat => ({ + // Unreadable records: the peer chats' agents are unknown, so none is restored under its session's agent; a later restore retries. + const chatProviders = await this._tryReadChatAgentProviders(); + if (!chatProviders) { + this._logService.warn(`[AgentService] Restoring ${session.toString()} without its peer chats: their agents are unknown`); + } + const sessionProvider = this._providerService.getProviderForSession(session)?.id; + return result.data.chats.filter(chat => chatProviders || chat.kind === 'default').map(chat => ({ uri: chat.uri.toString(), kind: chat.kind, title: chat.summary, origin: fromCatalogChatOrigin(chat.origin), ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), + ...(chatProviders?.has(chat.uri.toString()) && chatProviders.get(chat.uri.toString()) !== sessionProvider ? { provider: chatProviders.get(chat.uri.toString()) } : {}), ...(chat.archived === true ? { archived: true } : {}), ...(chat.isRead !== undefined ? { isRead: chat.isRead } : {}), inheritedTurnId: chat.inheritedTurnId, @@ -14585,6 +15120,21 @@ export class AgentService extends Disposable implements IAgentService { } private async _restorePeerChatsFromCatalog(session: URI, entries: readonly IPersistedPeerChat[], recordedStorage: ReadonlyMap, cachedChats?: readonly ICatalogChat[]): Promise { + // Read before any chat is registered or hydrated, so every chat is routed to its own agent from the start. + const chatProviders = await this._tryReadChatAgentProviders(); + if (!chatProviders) { + // Their agents are unknown: none is restored under its session's agent. A later restore retries the read. + this._logService.warn(`[AgentService] Skipping the restore of ${entries.length} peer chat(s) of ${session.toString()}: their agents are unknown`); + return; + } + const sessionProvider = this._providerService.getProviderForSession(session)?.id; + // A record naming the session's own agent is stale (a move or rollback that couldn't delete it): the chat follows its session. + const staleRecords = entries.map(entry => entry.uri).filter(chat => sessionProvider !== undefined && chatProviders.get(chat) === sessionProvider); + if (staleRecords.length > 0) { + void this._deleteChatAgentProviders(staleRecords); + } + // One config resolution per agent of the chats with their own agent restored here. + const ownConfigCache = new Map>(); const restored = await Promise.all(entries.map(async (entry) => { let chatUri: URI; try { @@ -14597,21 +15147,30 @@ export class AgentService extends Disposable implements IAgentService { const cachedTitle = cachedChat?.title; // A legacy catalog records no storage, so its chats follow the creation-time rule. const storageResource = recordedStorage.get(chatUri.toString()) ?? creationTimeChatStorageResource(session.toString(), chatUri.toString(), this._defaultChat(session)); - const { title, draft, config } = await this._chatContributions.hydrateChat({ + const { title, draft, config: hydratedConfig } = await this._chatContributions.hydrateChat({ session: session.toString(), chat: chatUri.toString(), isDefaultChat: false, storageResource, }, cachedTitle ? { title: cachedTitle } : {}); + const recordedProvider = chatProviders.get(chatUri.toString()); + const chatProvider = recordedProvider !== sessionProvider ? recordedProvider : undefined; + // A chat on another agent keeps that agent's schema host-side only; its published config is its values. + const ownConfig = chatProvider !== undefined && entry.origin?.kind !== ChatOriginKind.Tool + ? await this._restoreOwnChatConfig(session, chatProvider, hydratedConfig?.values, ownConfigCache) + : undefined; + const config = ownConfig ? { values: ownConfig.values } : hydratedConfig && { values: hydratedConfig.values }; return { chatUri, storageResource, title, draft, config, + configSchema: ownConfig?.schema, providerData: entry.providerData, origin: entry.origin, interactivity: cachedChat?.interactivity, + provider: chatProvider, inheritedTurnId: entry.inheritedTurnId, workingDirectories: entry.workingDirectories ?? cachedChat?.workingDirectories, archived: entry.archived, @@ -14623,7 +15182,7 @@ export class AgentService extends Disposable implements IAgentService { if (!item) { continue; } - const { chatUri, storageResource, title, draft, config, providerData, origin, interactivity, inheritedTurnId, workingDirectories, archived, isRead, changes } = item; + const { chatUri, storageResource, title, draft, config, configSchema, providerData, origin, interactivity, provider, inheritedTurnId, workingDirectories, archived, isRead, changes } = item; if (this._stateManager.getChatState(chatUri.toString())) { continue; } @@ -14632,9 +15191,11 @@ export class AgentService extends Disposable implements IAgentService { title, draft, config, + configSchema, providerData, origin, interactivity, + provider, inheritedTurnId, workingDirectories, archived, @@ -14702,7 +15263,7 @@ export class AgentService extends Disposable implements IAgentService { throw new RestoredPeerChatUnavailableError(chatKey, `it is the default chat of ${owner}, loaded for a move only until its session is opened`); } const session = URI.parse(owner); - const agent = this._providerService.getProviderForSession(session); + const agent = this._providerService.getProviderForChat(session, chat); if (!agent) { throw new Error(`No agent provider for restored peer chat: ${chatKey}`); } @@ -14864,13 +15425,14 @@ export class AgentService extends Disposable implements IAgentService { this._resolvePendingSubagentChat(e.chat.toString()); const summary = this._stateManager.getSessionState(e.session.toString())?.chats.find(chat => chat.resource === e.chat.toString()); void this._recordLegacyPrivateChatStorage(e.session, e.chat); - void this._peerChatStore.persistPrivateChat(e.session, { + // A subagent of a chat with its own agent runs in that agent, also once restored: its agent is recorded before its row. + void this._recordOwnChatAgentProvider(e.session.toString(), e.chat.toString()).then(() => this._peerChatStore.persistPrivateChat(e.session, { chat: e.chat.toString(), parentChat: e.parent?.chat.toString(), origin: summary?.origin === undefined ? undefined : JSON.stringify(summary.origin), workingDirectories: summary?.workingDirectories, metadata: { ...(e.title !== undefined ? { summary: e.title } : {}), interactivity: ChatInteractivity.Hidden }, - }).catch(error => this._logService.error(error, `[AgentService] Failed to persist spawned chat ${e.chat.toString()}`)); + })).catch(error => this._logService.error(error, `[AgentService] Failed to persist spawned chat ${e.chat.toString()}`)); } /** @@ -15336,11 +15898,11 @@ export class AgentService extends Disposable implements IAgentService { } async getSessionStateFile(session: URI, chat?: URI): Promise { - return this._providerService.getProviderForSession(session)?.getSessionStateFile?.(session, chat); + return this._providerService.getProviderForChat(session, chat)?.getSessionStateFile?.(session, chat); } getSessionPlanFile(session: URI, chat: URI): URI | undefined { - return this._providerService.getProviderForSession(session)?.getSessionPlanFile?.(session, chat); + return this._providerService.getProviderForChat(session, chat)?.getSessionPlanFile?.(session, chat); } async collectDebugLogs(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind, chat?: URI, progressToken?: string): Promise { @@ -15348,7 +15910,7 @@ export class AgentService extends Disposable implements IAgentService { throw new Error('Agent Host debug log collection is unavailable'); } const providers = session - ? [this._providerService.getProviderForSession(session)].filter((provider): provider is IAgent => provider !== undefined) + ? [this._providerService.getProviderForChat(session, chat)].filter((provider): provider is IAgent => provider !== undefined) : this._providerService.getProviders(); if (providers.length === 0) { throw new Error(session @@ -15419,8 +15981,7 @@ export class AgentService extends Disposable implements IAgentService { return; } const parentState = this._stateManager.getSessionState(parentSessionKey); - const agent = this._providerService.getProviderForSession(parentSession); - if (!parentState || !agent) { + if (!parentState) { return; } // A subagent can be spawned from any chat in the session, including peer @@ -15432,6 +15993,21 @@ export class AgentService extends Disposable implements IAgentService { chat: spawnPoint?.chat ?? parentState.defaultChat ?? this._requireDefaultChat(parentSession), toolCallId, } as const; + // A subagent runs in the agent of the chat that spawned it, recorded at its spawn for when the spawning chat isn't + // found, so it is never read through the session's agent. + const chatProviders = spawnPoint ? undefined : await this._tryReadChatAgentProviders(); + if (!spawnPoint && !chatProviders) { + this._logService.warn(`[AgentService] Cannot restore subagent chat ${chatUri}: its agent is unknown`); + return; + } + const recordedProvider = chatProviders?.get(chatUri); + const agent = recordedProvider !== undefined && recordedProvider !== parentState.provider + ? this._providerService.getProvider(recordedProvider) + : this._providerService.getProviderForChat(parentSession, origin.chat); + if (!agent) { + this._logService.warn(`[AgentService] Cannot restore subagent chat ${chatUri}: its agent is not registered`); + return; + } // A subagent without a recorded storage resource (a legacy one) follows the creation-time rule. const storageResource = this._chatOwnership.getChatStorageResource(chatUri) ?? (await this._readRecordedChatStorage(parentSessionKey)).get(chatUri) @@ -15444,6 +16020,7 @@ export class AgentService extends Disposable implements IAgentService { this._stateManager.addChat(parentSessionKey, chatUri, storageResource, { title: spawnPoint?.title ?? 'Subagent', turns: mergedTurns, + provider: agent.id, origin, interactivity: ChatInteractivity.ReadOnly, }); @@ -15564,7 +16141,7 @@ export class AgentService extends Disposable implements IAgentService { // Load the subagent's turns from the agent (which knows how to // extract them from the parent session's event log). let childTurns: readonly Turn[] = []; - const agent = this._providerService.getProviderForSession(parentSession); + const agent = this._providerService.getProviderForChat(parentSession, subagentUri); if (agent) { try { const parsedSubagent = parseSubagentSessionUri(URI.parse(subagentUri)); @@ -15715,6 +16292,7 @@ export class AgentService extends Disposable implements IAgentService { chat: child.chatUri, parentChat, origin: JSON.stringify(child.origin), + // A subagent runs in the agent of the chat that spawned it. metadata: { interactivity: ChatInteractivity.Hidden }, }, true, isCurrent); const { title } = await this._chatContributions.hydrateChat({ diff --git a/src/vs/platform/agentHost/node/agentServiceComposition.ts b/src/vs/platform/agentHost/node/agentServiceComposition.ts index 2f7b3c5812221..92fd208410b75 100644 --- a/src/vs/platform/agentHost/node/agentServiceComposition.ts +++ b/src/vs/platform/agentHost/node/agentServiceComposition.ts @@ -194,7 +194,7 @@ export function createAgentServiceComposition( stateManager, customizationEnablementService, { - getAgent: session => providerService.getProviderForSession(session), + getAgent: (session, chat) => providerService.getProviderForChat(session, chat), sessionDataService, localTurns, agents: providerService.agents, diff --git a/src/vs/platform/agentHost/node/agentServiceFoundation.ts b/src/vs/platform/agentHost/node/agentServiceFoundation.ts index 6e76b44d4f406..42da618d6c018 100644 --- a/src/vs/platform/agentHost/node/agentServiceFoundation.ts +++ b/src/vs/platform/agentHost/node/agentServiceFoundation.ts @@ -36,6 +36,8 @@ export class AgentServiceCallbackAdapter implements IAgentServiceCallbackBinder isWorkspaceless: session => this.value.sessionServerToolAccessor.isWorkspaceless(session), canConvertWorkspace: session => this.value.sessionServerToolAccessor.canConvertWorkspace(session), supportsChatWorkingDirectories: session => this.value.sessionServerToolAccessor.supportsChatWorkingDirectories(session), + getChatHarnesses: session => this.value.sessionServerToolAccessor.getChatHarnesses(session), + canCreateSessions: provider => this.value.sessionServerToolAccessor.canCreateSessions(provider), listSessions: () => this.value.sessionServerToolAccessor.listSessions(), getSession: session => this.value.sessionServerToolAccessor.getSession(session), getWorktreeRoots: workspace => this.value.sessionServerToolAccessor.getWorktreeRoots(workspace), diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 08d9184b6bc16..899e822f58774 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -42,7 +42,8 @@ import { AgentHostOverlapProviderPreparationSettingId } from '../common/agentSer import { AgentHostSettingExperimentTrigger } from './agentHostSettingExperimentTrigger.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import { getSessionPullRequestUrl, SessionConfigKey } from '../common/sessionConfigKeys.js'; -import { getEffectiveChatConfig } from '../common/sessionConfigProperties.js'; +import { getEffectiveChatConfig, withChatSchemaDefaults } from '../common/sessionConfigProperties.js'; +import { ChatProvidersConfigKey, chatProvidersRootConfigSchema } from '../common/agentHostChatProviders.js'; import { resolveChatAttachment } from '../common/state/chatAttachmentContext.js'; import { buildOpenSessionLinkForChat } from '../common/openSessionLink.js'; import { McpServerStatus, ToolCallContributorKind, type AgentInfo, type SessionActiveClient } from '../common/state/protocol/state.js'; @@ -85,14 +86,14 @@ import { AgentHostInputRequestTracker } from './agentHostInputRequestTracker.js' import { AgentHostLocalTurns } from './agentHostLocalTurns.js'; import { IAgentHostSessionTitleController } from './agentHostSessionTitleController.js'; import { AgentHostStateManager, resolveChatStateForUri } from './agentHostStateManager.js'; -import { IAgentConfigurationService } from './agentConfigurationService.js'; +import { IAgentConfigurationService, isChatConfigCompletion } from './agentConfigurationService.js'; import { IAgentHostSessionStateStore } from '../common/agentHostSessionStateStore.js'; import { createAgentChatContext, getSessionChatsForFanOut } from './agentChatContext.js'; import { IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { AgentHostTelemetryReporter, getMessageOriginTelemetryKind, IAgentHostTelemetryReporter, type AgentHostMessageOriginTelemetryKind, type AgentHostTurnFailureStage, type AgentHostTurnResult, type IAgentHostTurnFailure } from './agentHostTelemetryReporter.js'; import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from './agentHostToolCallTracker.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; -import { getConfiguredSessionMode, getModelTelemetryContext, getTurnTelemetryContext } from './agentHostTurnTelemetryContext.js'; +import { getApplicableTurnModel, getConfiguredSessionMode, getModelTelemetryContext, getTurnTelemetryContext } from './agentHostTurnTelemetryContext.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker } from './agentHostTurnTracker.js'; import { IAgentHostTurnService } from './agentHostTurnService.js'; import type { IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; @@ -110,7 +111,8 @@ import { IAgentHostChatOwnershipService, mintSubagentChatUri } from './agentHost */ export interface IAgentSideEffectsOptions { /** Resolve the agent responsible for a given session URI. */ - readonly getAgent: (session: ProtocolURI) => IAgent | undefined; + /** Resolves the agent of `chat` of `session`, or the session's agent when no chat is given. */ + readonly getAgent: (session: ProtocolURI, chat?: ProtocolURI) => IAgent | undefined; /** Observable set of registered agents. Triggers `root/agentsChanged` when it changes. */ readonly agents: IObservable; /** Session data service for cleaning up per-session data on disposal. */ @@ -354,13 +356,21 @@ export class AgentSideEffects extends Disposable { this._telemetryReporter.executionModeChanged(agent.id, e.session, previousMode, currentMode, sessionState.turns.length, e.clientContext); })); this._register(this._stateManager.onDidChangeChatConfig(e => { - const sessionConfig = this._stateManager.getSessionState(e.session)?.config; - const previousMode = getConfiguredSessionMode(sessionConfig && getEffectiveChatConfig(sessionConfig, e.previous)); - const currentMode = getConfiguredSessionMode(sessionConfig && getEffectiveChatConfig(sessionConfig, e.current)); - const agent = this._options.getAgent(e.session); + // A chat on another agent overlays that agent's config, never the session's. + const ownSchema = this._stateManager.getChatOwnConfigSchema(e.chat); + const sessionConfig = ownSchema ? { schema: ownSchema, values: withChatSchemaDefaults(ownSchema, undefined) } : this._stateManager.getSessionState(e.session)?.config; + const completeValues = this._agentConfigService.getRootValue(chatProvidersRootConfigSchema, ChatProvidersConfigKey.Enabled) === true; + const previousMode = getConfiguredSessionMode(sessionConfig && getEffectiveChatConfig(sessionConfig, e.previous, completeValues)); + const currentMode = getConfiguredSessionMode(sessionConfig && getEffectiveChatConfig(sessionConfig, e.current, completeValues)); + const agent = this._options.getAgent(e.session, e.chat); if (!previousMode || !currentMode || previousMode === currentMode || !agent) { return; } + if (isChatConfigCompletion(e.action)) { + // Completing a chat's values, or publishing what a default chat takes from its session (whose change is + // reported for the session), changes no mode. + return; + } this._telemetryReporter.executionModeChanged(agent.id, e.session, previousMode, currentMode, this._stateManager.getChatState(e.chat)?.turns.length ?? 0, e.clientContext); })); this._register(this._customizationEnablementService.onDidChange(event => { @@ -441,7 +451,7 @@ export class AgentSideEffects extends Disposable { case ActionType.ChatInputRequested: { const turnId = chatState?.activeTurn?.id; const owner = this._chatOwnership.getChatOwner(envelope.channel); - const provider = owner === undefined ? undefined : this._options.getAgent(owner)?.id; + const provider = owner === undefined ? undefined : this._options.getAgent(owner, envelope.channel)?.id; if (turnId && provider && owner !== undefined) { this._inputRequestTracker.inputRequested(provider, envelope.channel, owner, turnId, action.request); } @@ -533,8 +543,7 @@ export class AgentSideEffects extends Disposable { /** Hands a client's contribution to each exact chat Agent Host owns. */ private _fanOutActiveClient(session: ProtocolURI, activeClient: SessionActiveClient): void { - const agent = this._options.getAgent(session); - if (!agent) { + if (!this._options.getAgent(session)) { return; } const chats = getSessionChatsForFanOut(this._stateManager, session); @@ -544,6 +553,10 @@ export class AgentSideEffects extends Disposable { } const hostCustomizations = this._hostCustomizations(session); for (const chat of chats) { + const agent = this._options.getAgent(session, chat.toString()); + if (!agent) { + continue; + } const handle = agent.getOrCreateActiveClient(chat, this._chatContext(session, chat.toString()), { clientId: activeClient.clientId, displayName: activeClient.displayName, @@ -554,9 +567,8 @@ export class AgentSideEffects extends Disposable { } private _removeActiveClient(session: ProtocolURI, clientId: string): void { - const agent = this._options.getAgent(session); for (const chat of getSessionChatsForFanOut(this._stateManager, session) ?? []) { - agent?.removeActiveClient(chat, this._chatContext(session, chat.toString()), clientId); + this._options.getAgent(session, chat.toString())?.removeActiveClient(chat, this._chatContext(session, chat.toString()), clientId); } } @@ -912,7 +924,7 @@ export class AgentSideEffects extends Disposable { if (session === undefined) { return; } - const { model, modelTelemetryKind, modelSelectionKind, autoTier, permissionLevel, interactionMode } = getTurnTelemetryContext(agent, channel, this._chatContext(session, channel), this._agentConfigService.getChatConfigState({ session, chat: channel }), action.message.model); + const { model, modelTelemetryKind, modelSelectionKind, autoTier, permissionLevel, interactionMode } = getTurnTelemetryContext(agent, channel, this._chatContext(session, channel), this._agentConfigService.getChatConfigState({ session, chat: channel }), getApplicableTurnModel(agent, this._stateManager.getSessionState(session)?.provider, action.message.model)); const clientContext = { ...createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown), hostLaunchKind: this._options.hostLaunchKind ?? AgentHostLaunchKind.Unknown, @@ -1326,7 +1338,7 @@ export class AgentSideEffects extends Disposable { startedAt: new Date().toISOString(), message: { text: taskPrompt ?? '', origin: { kind: MessageKind.Tool } }, }); - const agent = this._options.getAgent(parentSessionUri); + const agent = this._options.getAgent(parentSessionUri, chatURI.toString()); if (agent) { const interactionMode = getConfiguredSessionMode(this._agentConfigService.getChatConfigState({ session: parentSessionUri, chat: subagentChatUri })); this._turnTracker.turnStarted(agent, subagentChatUri, parentSessionUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, messageOriginKind, taskModelSource, URI.parse(chatURI), subagentKind); @@ -1403,7 +1415,7 @@ export class AgentSideEffects extends Disposable { startedAt: new Date().toISOString(), message: message ?? { text: '', origin: { kind: MessageKind.Tool } }, }); - const agent = this._options.getAgent(subagent.sessionUri); + const agent = this._options.getAgent(subagent.sessionUri, subagent.chatUri.toString()); if (agent) { const interactionMode = getConfiguredSessionMode(this._agentConfigService.getChatConfigState({ session: subagent.sessionUri, chat: subagent.chatUri })); this._turnTracker.turnStarted(agent, subagent.chatUri, subagent.sessionUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, messageOriginKind, subagent.taskModelSource, URI.parse(parentChatURI), subagent.subagentKind); @@ -1651,7 +1663,7 @@ export class AgentSideEffects extends Disposable { } } const completionChat = this._toolCallCompletionChat(chatChannel); - const agent = this._options.getAgent(sessionChannel); + const agent = this._options.getAgent(sessionChannel, completionChat.toString()); if (!agent) { this._logService.warn(`[AgentSideEffects] No agent for client tool completion: source=${source}, session=${sessionChannel}, chat=${chatChannel}, completionChat=${completionChat}, toolCallId=${toolCallId}`); return; @@ -1803,11 +1815,11 @@ export class AgentSideEffects extends Disposable { if (!chatChannel || !resumedTurn) { throw new Error(`ChatTurnResume must be accepted with its previous turn on an AHP chat channel: ${channel}`); } - const agent = this._options.getAgent(sessionChannel); + const agent = this._options.getAgent(sessionChannel, chatChannel); if (!agent?.chats.resumeTurn) { throw new Error(`ChatTurnResume reached side effects without provider support: ${sessionChannel}`); } - const { model, modelTelemetryKind, modelSelectionKind, autoTier, permissionLevel, interactionMode } = getTurnTelemetryContext(agent, channel, this._chatContext(sessionChannel, channel), this._agentConfigService.getChatConfigState({ session: sessionChannel, chat: channel }), resumedTurn.message.model); + const { model, modelTelemetryKind, modelSelectionKind, autoTier, permissionLevel, interactionMode } = getTurnTelemetryContext(agent, channel, this._chatContext(sessionChannel, channel), this._agentConfigService.getChatConfigState({ session: sessionChannel, chat: channel }), getApplicableTurnModel(agent, this._stateManager.getSessionState(sessionChannel)?.provider, resumedTurn.message.model)); this._turnTracker.turnStarted(agent, channel, sessionChannel, action.turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, clientContext, clientId, undefined, undefined, getMessageOriginTelemetryKind(resumedTurn.message, this._stateManager.isEphemeralSession(sessionChannel)), undefined, URI.parse(channel), this._chatOwnership.getChatParent(channel) !== undefined ? 'task' : undefined); if (autoTier !== undefined) { this._turnTracker.updateFusionTelemetry(channel, action.turnId, { autoTier }); @@ -1900,7 +1912,7 @@ export class AgentSideEffects extends Disposable { if (!chatChannel) { throw new Error(`ChatInputCompleted must be handled on an AHP chat channel: ${channel}`); } - const agent = this._options.getAgent(sessionChannel); + const agent = this._options.getAgent(sessionChannel, chatChannel); agent?.respondToUserInputRequest(action.requestId, action.response, action.answers); break; } @@ -1926,7 +1938,7 @@ export class AgentSideEffects extends Disposable { } void this._checkpointService.discardTurnStartCheckpoint(URI.parse(sessionChannel), URI.parse(channel), action.turnId).catch(() => undefined); this.cancelSubagentSessions(channel, { retainRegistrations: true }); - const agent = this._options.getAgent(sessionChannel); + const agent = this._options.getAgent(sessionChannel, channel); if (agent) { const chat = URI.parse(channel); agent.chats.abort(chat, { ...this._chatContext(sessionChannel, channel), clientTelemetryContext: clientContext }).catch(err => { @@ -1956,7 +1968,7 @@ export class AgentSideEffects extends Disposable { throw new Error(`ChatTruncated must be handled on an AHP chat channel: ${channel}`); } void this._checkpointService.discardChatTurnStartCheckpoints(URI.parse(sessionChannel), URI.parse(chatChannel)).catch(() => undefined); - const agent = this._options.getAgent(sessionChannel); + const agent = this._options.getAgent(sessionChannel, chatChannel); // When the truncation boundary is a host-injected local turn // (`/rename` / `!command`), redirect the SDK truncation to the // preceding concrete turn so the agent keeps everything up to @@ -2067,10 +2079,13 @@ export class AgentSideEffects extends Disposable { ? this._worktreeCleanup.requestSessionWorktreeRemoval(sessionUri, automaticArchive ? 'synced' : 'commit') : this._worktreeCleanup.restoreSessionWorktree(sessionUri); worktreeOp.catch(err => this._logService.warn(`[AgentSideEffects] worktree ${action.isArchived ? 'cleanup' : 'recreate'} failed for ${channel}`, err)); - const agent = this._options.getAgent(channel); - agent?.onArchivedChanged?.(URI.parse(channel), action.isArchived).catch(err => { - this._logService.warn(`[AgentSideEffects] onArchivedChanged failed for ${channel}`, err); - }); + // The session's agent and the agents of its chats that use their own. + const agents = new Set([this._options.getAgent(channel), ...(this._stateManager.getSessionState(channel)?.chats ?? []).map(chat => this._options.getAgent(channel, chat.resource))]); + for (const agent of agents) { + agent?.onArchivedChanged?.(URI.parse(channel), action.isArchived).catch(err => { + this._logService.warn(`[AgentSideEffects] onArchivedChanged failed for ${channel} in agent ${agent.id}`, err); + }); + } break; } case ActionType.SessionConfigChanged: { @@ -2208,9 +2223,13 @@ export class AgentSideEffects extends Disposable { const selectionUpdates: Promise[] = []; this._turnTracker.setCurrentStage(turnChannel, turnId, 'modelSelection'); this._turnTracker.markSendStage(turnChannel, turnId, 'modelSelection'); - if (message.model) { + const model = getApplicableTurnModel(agent, this._stateManager.getSessionState(sessionChannel)?.provider, message.model); + if (message.model && !model) { + this._logService.info(`[AgentSideEffects] Chat ${chat} keeps its model: its agent ${agent.id} does not offer the turn's model ${message.model.id}`); + } + if (model) { failureStage = 'modelSelection'; - selectionUpdates.push(agent.chats.changeModel(chatUri, message.model, clientOperationContext)); + selectionUpdates.push(agent.chats.changeModel(chatUri, model, clientOperationContext)); } selectionUpdates.push(agent.chats.changeAgent(chatUri, message.agent, clientOperationContext).catch(err => { this._logService.error('[AgentSideEffects] changeAgent failed', err); @@ -2224,9 +2243,9 @@ export class AgentSideEffects extends Disposable { if (agent.id === CODEX_AGENT_PROVIDER_ID) { const state = this._stateManager.getSessionState(sessionChannel); if (state?.defaultChat === chat) { - const model = agent.chats.getModel?.(chatUri, clientOperationContext) ?? message.model; - if (model && readCodexSessionModel(state)?.id !== model.id) { - this._stateManager.setSessionMeta(sessionChannel, withCodexSessionModel(state._meta, model)); + const codexModel = agent.chats.getModel?.(chatUri, clientOperationContext) ?? model; + if (codexModel && readCodexSessionModel(state)?.id !== codexModel.id) { + this._stateManager.setSessionMeta(sessionChannel, withCodexSessionModel(state._meta, codexModel)); } } } diff --git a/src/vs/platform/agentHost/node/chatContributions/chatConfig/chatConfigContribution.ts b/src/vs/platform/agentHost/node/chatContributions/chatConfig/chatConfigContribution.ts index fc60739c78fe5..21fff15c87de2 100644 --- a/src/vs/platform/agentHost/node/chatContributions/chatConfig/chatConfigContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/chatConfig/chatConfigContribution.ts @@ -10,6 +10,8 @@ import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IDi import { IAgentHostSessionStateStore } from '../../../common/agentHostSessionStateStore.js'; import { ActionType } from '../../../common/state/sessionActions.js'; import { ChatOriginKind, isAhpChatChannel } from '../../../common/state/sessionState.js'; +import { ChatProvidersConfigKey, chatProvidersRootConfigSchema } from '../../../common/agentHostChatProviders.js'; +import { IAgentConfigurationService, isChatOwnConfigUnchanged } from '../../agentConfigurationService.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; /** @@ -27,16 +29,23 @@ export class ChatConfigContribution extends Disposable implements IAgentHostChat @ILogService private readonly _logService: ILogService, @IAgentHostSessionStateStore private readonly _sessionStateStore: IAgentHostSessionStateStore, @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, + @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, ) { super(); } onDidDispatchAction(dispatched: IDispatchedAction): void { if (dispatched.action.type !== ActionType.ChatConfigChanged || dispatched.rejectionReason || !isAhpChatChannel(dispatched.channel) - || this._stateManager.getChatOrigin(dispatched.channel)?.kind === ChatOriginKind.Tool) { + || this._stateManager.getChatOrigin(dispatched.channel)?.kind === ChatOriginKind.Tool + // With complete values, a session before its first message has no database yet: its default chat's values are + // persisted when it materializes. Otherwise they are persisted as before. + || (this._stateManager.isIdleProvisionalSession(dispatched.session) && this._configurationService.getRootValue(chatProvidersRootConfigSchema, ChatProvidersConfigKey.Enabled) === true) + // Publishes only what a default chat takes from its session: the row it persisted is unchanged. + || isChatOwnConfigUnchanged(dispatched.action)) { return; } - const values = this._stateManager.getChatConfig(dispatched.channel)?.values ?? {}; + // A default chat's values the session gives it are the session's, not the chat's own. + const values = this._configurationService.getChatPersistedConfigValues(dispatched.channel) ?? {}; this._sessionStateStore.chatRows.setChatConfig(URI.parse(dispatched.session), URI.parse(dispatched.channel), values).catch(err => { this._logService.warn(`[ChatConfigContribution] Failed to persist the config of ${dispatched.channel}`, err); }); diff --git a/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts b/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts index 50c2d8402af07..c8c5d07b58f64 100644 --- a/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts @@ -168,7 +168,7 @@ export class QueueDrainContribution extends Disposable implements IAgentHostChat return; } const session = this._chatOwnership.getChatOwner(channel); - const provider = session !== undefined ? this._providerService.getProviderForSession(session) : undefined; + const provider = session !== undefined ? this._providerService.getProviderForChat(session, channel) : undefined; provider?.withdrawSteeringMessage?.(URI.parse(channel), id); } @@ -185,7 +185,7 @@ export class QueueDrainContribution extends Disposable implements IAgentHostChat if (session === undefined) { return; } - const provider = this._providerService.getProviderForSession(session); + const provider = this._providerService.getProviderForChat(session, channel); const pending = getPendingSteeringMessages(state); const submitted = this._context.memento(SubmittedSteering, channel); const pendingIds = new Set(pending.map(message => message.id)); diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionInputNeeded/sessionInputNeededContribution.ts b/src/vs/platform/agentHost/node/chatContributions/sessionInputNeeded/sessionInputNeededContribution.ts index 2748b0df6d89b..52bdf3f1e8406 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sessionInputNeeded/sessionInputNeededContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sessionInputNeeded/sessionInputNeededContribution.ts @@ -180,7 +180,7 @@ export class SessionInputNeededContribution extends Disposable implements IAgent this._turnTracker.turnBlocked(chatUri, blockedTurnId, request.id, request.kind, blockedToolCallId); } if (request.kind !== SessionInputRequestKind.ChatInput) { - const agent = this._providerService.getProviderForSession(sessionUri); + const agent = this._providerService.getProviderForChat(sessionUri, chatUri); if (agent) { this._toolCallTracker.toolCallBlocked(agent.id, chatUri, sessionUri, request); } diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts index 41db548cddab2..35b1de79add2a 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts @@ -254,7 +254,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe ?? (owner !== undefined && sessionDirectories && sessionDirectories.length > 1 && this._chatOwnership.isDefaultChat(owner, chat.toString()) && this._isChatWorkspaceless(owner, chat) ? sessionDirectories.slice(0, 1) : sessionDirectories); - const provider = owner !== undefined ? this._providerService.getProviderForSession(URI.parse(owner)) : undefined; + const provider = owner !== undefined ? this._providerService.getProviderForChat(URI.parse(owner), chat) : undefined; const supportsMultipleWorkingDirectories = !!provider?.getDescriptor().capabilities?.multipleWorkingDirectories; const inheritsSessionWorkspace = summary?.workingDirectories === undefined && owner !== undefined @@ -494,7 +494,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe const { chat, workspaceFolder, initiatingClientId } = pending; const session = URI.parse(pending.owner); const storage = URI.parse(this._chatOwnership.resolveChatStorageResource(pending.owner, chat.toString())); - const provider = this._providerService.getProviderForSession(session)!; + const provider = this._providerService.getProviderForChat(session, chat)!; let workspaceFinalized = false; const assertWorkspace = (expected = pending.previousWorkingDirectories ?? [pending.previousWorkingDirectory ?? workspaceFolder]) => { const state = this._stateManager.getSessionState(session.toString()); @@ -507,7 +507,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe || (summary.interactivity !== undefined && summary.interactivity !== ChatInteractivity.Full) || (pending.isolation && readSessionWorkspaceless(state._meta)) || readSessionExternal(state._meta) || (pending.isolation && !this._worktreeIsolation.supported) || !provider?.agentHostCapabilities.workspaceConversion || !provider.setChatWorkingDirectory - || this._providerService.getProviderForSession(session) !== provider + || this._providerService.getProviderForChat(session, chat) !== provider || this._pending.get(chat.toString()) !== pending || this._quarantinedChats.has(chat.toString()) || (!workspaceFinalized && pending.isolation && state.config?.values[SessionConfigKey.Isolation] === 'worktree' && isEqual(workspaceFolder, pending.previousWorkingDirectory) @@ -689,7 +689,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe private async _convert(pending: IPendingSessionWorkspaceConversion, continuation: IDeferredAgentHostTurn): Promise { const { chat, workspaceFolder, isolation, initiatingClientId, prompt, transition } = pending; const { session, state, previousWorkingDirectory } = this._validateConversion(chat, workspaceFolder); - const provider = this._providerService.getProviderForSession(session); + const provider = this._providerService.getProviderForChat(session, chat); if (!provider?.agentHostCapabilities.workspaceConversion) { throw new Error(`Provider does not support changing the working directory: ${AgentSession.provider(session) ?? '(unknown)'}`); } diff --git a/src/vs/platform/agentHost/node/compatibleAgentHostDatabase.ts b/src/vs/platform/agentHost/node/compatibleAgentHostDatabase.ts index 2c6ce0ebab12a..ad31027d9c6eb 100644 --- a/src/vs/platform/agentHost/node/compatibleAgentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/compatibleAgentHostDatabase.ts @@ -74,6 +74,18 @@ export class CompatibleAgentHostDatabase extends Disposable implements IAgentHos write.finally(() => this._pendingChatRecords.delete(write)); } + readChatAgentProviders(): Promise> { + return this.raw.readChatAgentProviders(); + } + + recordChatAgentProvider(chat: string, provider: string): Promise { + return this.raw.recordChatAgentProvider(chat, provider); + } + + deleteChatAgentProviders(chats: readonly string[]): Promise { + return this.raw.deleteChatAgentProviders(chats); + } + async prepareSessionStorage(session: string, provider: string): Promise { return this._allocations.queue(() => this._prepareSessionStorage(session, provider)); } diff --git a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts index 7f6c30a8146e2..e2e6c539e17b8 100644 --- a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts +++ b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts @@ -20,6 +20,7 @@ import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; import { AgentHostLaunchKind, AgentHostLaunchKindEnvVar, telemetryLevelToAgentHostValue } from '../common/agentHostTelemetry.js'; import { AgentHostAhpJsonlLoggingEnvVar } from '../common/ahpJsonlLogger.js'; import { AgentHostAhpJsonlLoggingSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostIpcChannels, AgentHostOTelCaptureContentSettingId, AgentHostOTelCaptureIdentitySettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, buildAgentHostOTelEnv, buildAgentSdkEnv, IAgentHostManagementService, readAgentHostOTelPolicySettings } from '../common/agentService.js'; +import { ChatProvidersSettingId } from '../common/agentHostChatProviders.js'; /** * Options for configuring the agent host WebSocket server in the child process. @@ -107,6 +108,7 @@ export class NodeAgentHostStarter extends Disposable implements IAgentHostStarte codexHome: this._configurationService.getValue(AgentHostCodexAgentCodexHomeSettingId), codexBinaryArgs: this._configurationService.getValue(AgentHostCodexAgentBinaryArgsSettingId), claudeAgentEnabled: this._configurationService.getValue(AgentHostClaudeAgentEnabledSettingId), + multipleChatProvidersEnabled: this._configurationService.getValue(ChatProvidersSettingId.Enabled) === true, codexAgentEnabled: this._configurationService.getValue(AgentHostCodexAgentEnabledSettingId), }, process.env); Object.assign(env, sdkEnv); diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 3b4619349c341..a8c7956897223 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -60,7 +60,7 @@ import type { CommandMap } from '../common/state/protocol/messages.js'; import type { ChatConfigChangedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; import type { AutomationEntry, RootConfigState } from '../common/state/protocol/state.js'; import { ActionEnvelope, ActionType, INotification, isAnnotationsAction, isAutomationAction, isAutomationRunAction, isChangesetAction, isChatAction, isSessionAction, isTerminalAction } from '../common/state/sessionActions.js'; -import { PROTOCOL_VERSION } from '../common/state/protocol/version/registry.js'; +import { compareProtocolVersions, isActionKnownToVersion, PROTOCOL_VERSION } from '../common/state/protocol/version/registry.js'; import { AgentHostTcpBudget } from '../common/agentHostTcpConnection.js'; import { AgentHostChannelScheduler } from '../common/agentHostChannelDelivery.js'; import { AgentHostTcpConnections } from './agentHostTcpConnections.js'; @@ -161,6 +161,7 @@ export function toListedSessionSummary(s: IAgentSessionMetadata): ListSessionsRe resource: chat.chat.toString(), title: chat.summary ?? '', origin: chat.origin, + ...(chat.provider !== undefined ? { provider: chat.provider } : {}), ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), // Forward the chat's activity bits so clients can present them without subscribing. ...(chat.status !== undefined || chat.isRead !== undefined ? { @@ -455,6 +456,9 @@ function projectCopilotAutomation(automation: AutomationEntry, vscode: boolean): }; } +/** The first protocol version whose clients may be told about `multipleChatProviders`. */ +const MULTIPLE_CHAT_PROVIDERS_PROTOCOL_VERSION = '1.1.0'; + /** * Represents a connected protocol client with its subscription state. */ @@ -993,6 +997,17 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien // ---- Handshake handlers ---------------------------------------------------- + /** + * Whether a connection that negotiated `protocolVersion` is told that chats + * of one session may use different agents (`multipleChatProviders`): only + * behind the host setting and from protocol version 1.1.0. + */ + private _supportsMultipleChatProviders(protocolVersion: string): boolean { + return !this._config.relayRoots + && this._agentService.multipleChatProvidersEnabled === true + && compareProtocolVersions(protocolVersion, MULTIPLE_CHAT_PROVIDERS_PROTOCOL_VERSION) >= 0; + } + private _handleInitialize( params: InitializeParams, transport: IProtocolTransport, @@ -1137,6 +1152,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien terminalCommandPrefix: this._config.terminalCommandPrefix, telemetry: this._config.otlpLogEmitter ? { logs: OTLP_LOGS_CHANNEL_TEMPLATE } : undefined, automations: this._config.relayRoots || this._config.scopedRelayCredentials ? undefined : this._agentService.automationCapabilities, + ...(this._supportsMultipleChatProviders(negotiated) ? { multipleChatProviders: {} } : {}), tcpConnections: this._tcpConnections.get(transport)?.supported && !this._config.relayRoots ? {} : undefined, }; return { @@ -2228,6 +2244,9 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien if (chatUriError !== undefined) { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, chatUriError); } + if (params.provider !== undefined && (typeof params.provider !== 'string' || !this._supportsMultipleChatProviders(_client.protocolVersion))) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'This host does not support chats with their own agent.'); + } if (workspace) { await this._requireChatWorkspaceGrants(_client, workspace); } @@ -2263,6 +2282,9 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Unsupported createChat source kind: ${String((source as { kind?: unknown }).kind)}`); } } + if (params.provider !== undefined) { + options = { ...options, provider: params.provider }; + } const create = () => this._agentService.createChat( URI.parse(params.channel), URI.parse(params.chat), @@ -4306,6 +4328,10 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien break; } } + if (envelope.action.type === ActionType.ChatMoved && !isActionKnownToVersion(envelope.action, client.protocolVersion)) { + // Older clients learn a moved chat's owner from the sessions' chat lists only. + return undefined; + } if (!client.legacyChatUris) { return envelope; } diff --git a/src/vs/platform/agentHost/node/shared/sessionMoveTools.ts b/src/vs/platform/agentHost/node/shared/sessionMoveTools.ts index 38f1b126ad9d8..d60acc9d1a9eb 100644 --- a/src/vs/platform/agentHost/node/shared/sessionMoveTools.ts +++ b/src/vs/platform/agentHost/node/shared/sessionMoveTools.ts @@ -35,6 +35,10 @@ const moveSessionInputSchema: ToolDefinition['inputSchema'] = { }, }; +/** `move_session`'s sentence on agents, and its wording where chats of the session may use their own agents. */ +const moveSessionSameAgentSentence = 'A moved session keeps its history, folders and running work, including a session without a workspace; both sessions must use the same agent.'; +const moveSessionOwnAgentSentence = 'A moved session keeps its history, folders, running work and agent, including a session without a workspace; it can move into a session of another agent when that agent supports multiple chats.'; + /** The `move_session` tool, offered only while moving chats is enabled. */ export const moveSessionToolDefinition: IAgentServerToolDefinition = { name: SessionServerToolName.MoveSession, @@ -214,6 +218,9 @@ const refusalMessages: { readonly [K in AgentChatMoveRefusal]: string } = { [AgentChatMoveRefusal.NotSingleChat]: 'This session has nested sessions; move them out first, or move each nested session on its own.', [AgentChatMoveRefusal.NotStarted]: 'This session has no messages yet; it can be moved after its first message.', [AgentChatMoveRefusal.DifferentCredentialOwner]: 'Sessions with different credential owners can\'t be combined; pick a destination that uses the same account, or move it to its own session.', + [AgentChatMoveRefusal.AgentUnavailable]: 'The agent of this session is unknown or disabled on this host, so it can\'t move to a session of another agent or become its own session; ask the user to enable that agent.', + [AgentChatMoveRefusal.AgentWithoutChats]: 'The destination session\'s agent can\'t host sessions of another agent, or this session\'s agent can\'t join one; pick a destination session whose agent supports multiple chats, or one of the same agent.', + [AgentChatMoveRefusal.AgentConfigUnavailable]: 'The agent of this session couldn\'t describe its settings for the move; try again.', }; /** For a whole session, `notMovable` means it isn't a session of its own anymore or couldn't be opened, not the reasons a nested session stays. */ @@ -400,6 +407,10 @@ export function createSessionMoveToolGroup(accessor: ISessionServerToolAccessor, getDisplay: (_toolName, args, result) => getMoveSessionDisplay(args, result, (session, chatId) => accessor.isKnownSessionLink(session, chatId)), isEnabled: toolName => toolName === SessionServerToolName.MoveSession && accessor.isChatMoveEnabled(), isEnabledForSession: (_toolName, sessionUri) => accessor.supportsChatMoves(URI.parse(sessionUri)), + // Mentions moves across agents only while chats of the session may use their own agents; otherwise it reads as before. + getDefinitionForSession: (definition, sessionUri) => definition.name === SessionServerToolName.MoveSession && accessor.getChatHarnesses?.(URI.parse(sessionUri)) !== undefined + ? { ...definition, description: definition.description?.replace(moveSessionSameAgentSentence, moveSessionOwnAgentSentence) } + : definition, canRequireConfirmation: toolName => sessionToolRequiresConfirmation(toolName), async execute(_stateManager, context, _toolName, rawArgs): Promise { if (!accessor.isChatMoveEnabled()) { diff --git a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts index e267fa2297dc2..176c1d24a3550 100644 --- a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts @@ -10,7 +10,8 @@ import { Schemas } from '../../../../base/common/network.js'; import { findAgentSessionMessage, getPendingSteeringMessages, readOwnedAgentSessionMessage } from '../../common/agentSessionMessage.js'; import { toAgentMessageDelegationMeta, type IAgentMessageDelegationMeta, type IAgentMessageSessionDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; import { localize } from '../../../../nls.js'; -import { AgentSession, type AgentProvider, type IAgentCreateSessionConfig, type IAgentModelInfo, type IAgentSessionMetadata } from '../../common/agent.js'; +import { AgentSession, type AgentProvider, type IAgentCreateSessionConfig, type IAgentModelInfo, type IAgentSessionChatMetadata, type IAgentSessionMetadata } from '../../common/agent.js'; +import { fromAgentHostChatHarness, toAgentHostChatHarness } from '../../common/agentHostChatProviders.js'; import { SessionStatus } from '../../common/state/protocol/channels-session/state.js'; import { ActionType } from '../../common/state/sessionActions.js'; import type { IAgentServerToolDefinition } from '../../common/agentServerTools.js'; @@ -163,6 +164,23 @@ const createSessionWithSharedWorkspaceInputSchema: ToolDefinition['inputSchema'] required: ['relationship', 'prompt', 'title'], }; +const createSessionHarnessSentence = ' With `currentSession`, set `harness` to create the chat on another agent, for example to have a different agent review the work.'; + +/** `create_session` offering `harness`, with the harnesses a chat of the session may use, for a session whose chats may use another agent. */ +function withCreateSessionHarness(definition: IAgentServerToolDefinition, available: readonly AgentProvider[]): IAgentServerToolDefinition { + const inputSchema = definition.inputSchema ?? { type: 'object' as const }; + const harness = { + type: 'string', + description: `Optional harness (agent) for the new chat: one of ${available.map(provider => `\`${toAgentHostChatHarness(provider)}\``).join(', ')}. Only valid with relationship \`currentSession\`. Defaults to the current session's harness. A chat on another harness starts with that harness's defaults; \`model\`, when given, must belong to it.`, + }; + const model = { ...inputSchema.properties?.model, description: createSessionHarnessModelDescription }; + return { + ...definition, + description: `${definition.description}${createSessionHarnessSentence}`, + inputSchema: { ...inputSchema, properties: { ...inputSchema.properties, model, harness } }, + }; +} + const createSessionWithSharedWorkspaceDescription = 'Create delegated work and start it with an initial prompt, either in a new chat sharing the current session\'s workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.'; /** The sentences of `list_sessions` and `get_current_session` that describe a session's `chats`, by tool. */ @@ -172,6 +190,13 @@ const sessionChatsSentences: ReadonlyMap = new Map([ [SessionServerToolName.ListSessions, listSessionsChatsSentence], [SessionServerToolName.GetCurrentSession, getCurrentSessionChatsSentence], ]); +/** {@link sessionChatsSentences} for a session whose chats may use another agent, which lists their `harness`. */ +const sessionChatsHarnessSentences: ReadonlyMap = new Map([ + [SessionServerToolName.ListSessions, 'A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title`, an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`, and a `harness` when it runs on a different agent than its session.'], + [SessionServerToolName.GetCurrentSession, 'Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`, including the `harness` of a nested session that runs on a different agent.'], +]); +/** `create_session`'s `model` description for a session whose chats may use another agent. */ +const createSessionHarnessModelDescription = 'Optional model ID or display name. Defaults to the current chat\'s model when the new chat uses the same agent. For `currentSession`, the model must belong to the current session\'s provider; for `independent`, the model selects the new session\'s provider.'; /** Follows a tool's {@link sessionChatsSentences} entry while `move_session` is offered. */ const moveSessionLinksSentence = ' `move_session` takes these links too.'; @@ -333,6 +358,7 @@ interface ICreateSessionArgs { readonly prompt?: unknown; readonly title?: unknown; readonly model?: unknown; + readonly harness?: unknown; } export type IResolvedCreateSessionArgs = { @@ -396,7 +422,14 @@ export interface IAgentServiceSessionServerToolAccessor { readonly getModels: () => readonly IAgentModelInfo[]; readonly getCreationDefaults: (source: URI) => ISessionCreationDefaults | undefined; readonly startPrompt: (session: URI, chat: URI, prompt: string, delegation?: IAgentMessageDelegationMeta, credentials?: IAgentHostCredentialSource) => Promise; - readonly createChat: (session: URI, chat: URI, options?: { title?: string; model?: ModelSelection; workingDirectories?: readonly URI[] }, credentials?: IAgentHostCredentialSource) => Promise; + readonly createChat: (session: URI, chat: URI, options?: { title?: string; model?: ModelSelection; workingDirectories?: readonly URI[]; provider?: AgentProvider }, credentials?: IAgentHostCredentialSource) => Promise; + /** + * The agents a new chat of `session` may use (`create_session`'s `harness`): `undefined` unless chats of one session + * may use different agents (`multipleChatProviders.enabled`) and the session's agent supports multiple chats. + */ + readonly getChatHarnesses: (session: URI) => readonly AgentProvider[] | undefined; + /** Whether `provider` is registered and enabled, so it can start a new top-level session. */ + readonly canCreateSessions: (provider: AgentProvider) => boolean; readonly prepareChatWorkingDirectory: (session: URI, directory: URI, options: IAddSessionWorkingDirectoryOptions) => Promise; readonly renameChat: (session: URI, chat: URI, title: string) => Promise; readonly reportToolError: (toolName: SessionServerToolName, error: unknown) => void; @@ -482,7 +515,10 @@ export interface IRenameTitleResult { } export interface ISessionCreationDefaults { + /** The calling chat's own agent, which its model and config belong to. */ readonly provider?: AgentProvider; + /** The agent of the calling chat's session, which a current-session chat uses unless it requests another. */ + readonly sessionProvider?: AgentProvider; readonly model?: ModelSelection; readonly config?: Record; readonly isolation?: 'folder' | 'worktree'; @@ -551,6 +587,8 @@ interface ISerializedSessionChat { /** Whether this is the session's main session. */ readonly main?: true; readonly status?: string; + /** The harness (agent) of a nested session whose harness differs from its session's. */ + readonly harness?: string; } /** @@ -567,15 +605,22 @@ function serializeSessionChats(session: IAgentSessionMetadata): readonly ISerial const main = chat.kind === 'default'; const title = chat.summary ?? (main ? session.summary : undefined); const status = chat.status !== undefined ? describeSessionStatusBits(chat.status).join(',') || undefined : undefined; + const harness = chatHarness(session, chat); return { ...(title !== undefined ? { title } : {}), openLink: buildOpenSessionLinkForChat(session.session, chat.chat, defaultChat, undefined) ?? buildOpenSessionLinkUri(session.session), ...(main ? { main: true as const } : {}), ...(status !== undefined ? { status } : {}), + ...(harness !== undefined ? { harness } : {}), }; }); } +/** The user-facing harness of a chat whose agent differs from its session's; `undefined` otherwise. */ +function chatHarness(session: IAgentSessionMetadata, chat: IAgentSessionChatMetadata | undefined): string | undefined { + return chat?.provider !== undefined && chat.provider !== session.provider ? toAgentHostChatHarness(chat.provider) : undefined; +} + function getRequiredString(value: unknown, field: string, toolName: string): string { if (typeof value !== 'string' || value.length === 0) { throw new Error(`Invalid ${toolName} input: ${field} must be a non-empty string.`); @@ -1088,7 +1133,10 @@ export async function applyCreateSessionTool(accessor: ISessionServerToolAccesso const supportsChatWorkingDirectories = currentSession === undefined || accessor.supportsChatWorkingDirectories(currentSession); const sessions = await getCreateSessionCatalog(accessor, rawArgs, supportsChatWorkingDirectories); const defaults = source ? accessor.getCreationDefaults(source) : undefined; - const currentProvider = defaults?.provider ?? (currentSession ? (await accessor.getSession(currentSession))?.provider ?? AgentSession.provider(currentSession) : undefined); + const sessionProvider = defaults?.sessionProvider ?? (currentSession ? (await accessor.getSession(currentSession))?.provider ?? AgentSession.provider(currentSession) : undefined); + const harnessProvider = resolveCreateSessionHarness(accessor, rawArgs, currentSession, supportsChatWorkingDirectories); + // The agent a current-session chat is created on: the requested harness, else the session's (never the calling chat's). + const currentProvider = harnessProvider ?? sessionProvider; const args = getCreateSessionArgs(rawArgs, sessions, accessor.getModels(), currentProvider, supportsChatWorkingDirectories); if (args.relationship === 'currentSession') { if (!currentSession) { @@ -1112,6 +1160,7 @@ export async function applyCreateSessionTool(accessor: ISessionServerToolAccesso title: args.title, model: args.model, ...(prepared !== undefined ? { workingDirectories: [prepared.directory] } : {}), + ...(harnessProvider !== undefined && harnessProvider !== sessionProvider ? { provider: harnessProvider } : {}), }, source, sourceTurnId, prepared?.associateWithChat); } catch (error) { await prepared?.release(); @@ -1132,6 +1181,9 @@ export async function applyCreateSessionTool(accessor: ISessionServerToolAccesso } } const provider = args.model?.provider ?? defaults?.provider; + if (args.model === undefined && provider !== undefined && !accessor.canCreateSessions(provider)) { + throw new Error(`Invalid ${SessionServerToolName.CreateSession} input: the current chat's agent "${toAgentHostChatHarness(provider)}" can't start a new session because it is unavailable or disabled. Pass \`model\` with a model of another agent to choose the new session's agent.`); + } const inheritsSourceProvider = provider !== undefined && provider === defaults?.provider; const inheritedProviderConfig = inheritsSourceProvider ? defaults?.config : undefined; let isolation: 'folder' | 'worktree' | undefined; @@ -1179,6 +1231,29 @@ export async function applyCreateSessionTool(accessor: ISessionServerToolAccesso return { relationship: args.relationship, session: session.toString(), chat: chat.toString(), openLink: buildOpenSessionLinkUri(session) }; } +/** + * Resolves `create_session`'s `harness` to the agent provider of the new chat, with the checks the host applies to a + * chat created with its own agent. `undefined` when no harness is requested. + */ +function resolveCreateSessionHarness(accessor: ISessionServerToolAccessor, rawArgs: unknown, currentSession: URI | undefined, supportsChatWorkingDirectories: boolean): AgentProvider | undefined { + const harness = getOptionalString((rawArgs as ICreateSessionArgs | undefined)?.harness, 'harness', SessionServerToolName.CreateSession); + if (harness === undefined) { + return undefined; + } + if (getCreateSessionRelationship(rawArgs, supportsChatWorkingDirectories) !== 'currentSession') { + throw new Error(`Invalid ${SessionServerToolName.CreateSession} input: harness is only valid with relationship "currentSession". Omit harness, or create the chat in the current session.`); + } + const available = currentSession ? accessor.getChatHarnesses(currentSession) : undefined; + if (!available) { + throw new Error(`Invalid ${SessionServerToolName.CreateSession} input: chats in this session can't use another harness because multiple chat providers is off, or this session's agent doesn't support multiple chats. Omit harness to use the session's own agent.`); + } + const provider = available.includes(harness) ? harness : fromAgentHostChatHarness(harness); + if (!available.includes(provider)) { + throw new Error(`Invalid ${SessionServerToolName.CreateSession} input: harness "${harness}" is not available. Use one of: ${available.map(toAgentHostChatHarness).join(', ')}, or omit harness to use the session's own agent.`); + } + return provider; +} + /** * Chooses how a folder requested for a current-session chat is prepared: an * explicit `worktree` choice wins (a requested worktree is always a fresh one), @@ -1219,6 +1294,8 @@ interface IResolvedCreateChatArgs { readonly title?: string; readonly model?: IAgentModelInfo; readonly workingDirectories?: readonly URI[]; + /** The agent of the new chat when it differs from its session's. */ + readonly provider?: AgentProvider; } /** @@ -1270,11 +1347,13 @@ async function createChat(accessor: ISessionServerToolAccessor, args: IResolvedC const credentials = source ? accessor.getCredentialSource(source) : undefined; const currentSession = source ? currentSessionUri(accessor, source.toString()) : undefined; const defaults = source ? accessor.getCreationDefaults(source) : undefined; - const targetProvider = (await accessor.getSession(args.session))?.provider ?? AgentSession.provider(args.session); + const targetProvider = args.provider ?? (await accessor.getSession(args.session))?.provider ?? AgentSession.provider(args.session); + // The calling chat's model is inherited only by a chat of the same agent. const model = args.model !== undefined ? { id: args.model.id } : targetProvider === defaults?.provider ? defaults?.model : undefined; const chat = URI.parse(mintPeerChatUri(chatUriFormatOf(accessor.getDefaultChat(args.session.toString())), args.session.toString())); await onChatAllocated?.(chat); await accessor.createChat(args.session, chat, { + ...(args.provider !== undefined ? { provider: args.provider } : {}), title: args.title, model, ...(args.workingDirectories !== undefined ? { workingDirectories: args.workingDirectories } : {}), @@ -1753,6 +1832,8 @@ function describeTurnState(state: TurnState | 'inProgress'): string { interface ISerializedSessionContext { readonly session: string; readonly openLink: string; + /** The harness (agent) of a chat whose harness differs from its session's. */ + readonly harness?: string; readonly detail: SessionContextDetail; readonly transcript: readonly ISerializedContextTurn[]; readonly messagesSentByMe?: readonly { @@ -1768,7 +1849,7 @@ interface ISerializedSessionContext { } /** Builds the compacted, model-facing session-context payload from a snapshot. */ -export function serializeSessionContext(session: URI, chatId: string | undefined, snapshot: IChatContextSnapshot, detail: SessionContextDetail, transcriptLimit: number, sourceChat?: URI): string { +export function serializeSessionContext(session: URI, chatId: string | undefined, snapshot: IChatContextSnapshot, detail: SessionContextDetail, transcriptLimit: number, sourceChat?: URI, harness?: string): string { const caps = contextCaps[detail]; let truncated = false; const trunc = (text: string, max: number): string | undefined => { @@ -1827,6 +1908,7 @@ export function serializeSessionContext(session: URI, chatId: string | undefined const payload: ISerializedSessionContext = { session: session.toString(), openLink: buildOpenSessionLinkUri(session, chatId), + ...(harness !== undefined ? { harness } : {}), detail, transcript, ...(messagesSentByMe !== undefined ? { messagesSentByMe } : {}), @@ -1841,19 +1923,22 @@ export async function applyGetSessionContextTool(accessor: ISessionServerToolAcc const sessions = await accessor.listSessions(); const { session, chatId, detail, transcriptLimit } = getSessionContextArgs(rawArgs, sessions); const snapshot = await accessor.getChatContext(session, chatId); + const metadata = sessions.find(candidate => candidate.session.toString() === session.toString()); + const harness = metadata && chatId !== undefined ? chatHarness(metadata, metadata.chats?.find(chat => chat.chat.toString() === chatId || getAgentHostChatId(chat.chat) === chatId)) : undefined; if (!snapshot) { // No live conversation state (e.g. a cold/unsubscribed session): return the // identity + an empty transcript. Metadata is available via list_sessions. return JSON.stringify({ session: session.toString(), openLink: buildOpenSessionLinkUri(session, chatId), + ...(harness !== undefined ? { harness } : {}), detail, transcript: [], hasMoreHistory: false, truncated: false, } satisfies ISerializedSessionContext); } - return serializeSessionContext(session, chatId, snapshot, detail, transcriptLimit, currentChannel ? URI.parse(currentChannel) : undefined); + return serializeSessionContext(session, chatId, snapshot, detail, transcriptLimit, currentChannel ? URI.parse(currentChannel) : undefined, harness); } @@ -2045,18 +2130,26 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess getDefinitionForSession(definition, sessionUri): IAgentServerToolDefinition { // Mentions `move_session` only while it is offered (`chatMoves.enabled`): with moves off, these read as before. // Also only where its session's agent can move chats, as `move_session` itself. - if ((definition.name === SessionServerToolName.ListSessions || definition.name === SessionServerToolName.GetCurrentSession) && accessor?.isChatMoveEnabled() === true && accessor.supportsChatMoves(URI.parse(sessionUri))) { + // Mentions `harness` only while a chat of this session may use another agent: with that off, these read as before. + if (definition.name === SessionServerToolName.ListSessions || definition.name === SessionServerToolName.GetCurrentSession) { const chatsSentence = sessionChatsSentences.get(definition.name)!; - return { ...definition, description: definition.description?.replace(chatsSentence, `${chatsSentence}${moveSessionLinksSentence}`) }; + const harnessSentence = accessor?.getChatHarnesses?.(URI.parse(sessionUri)) !== undefined ? sessionChatsHarnessSentences.get(definition.name)! : chatsSentence; + const moves = accessor?.isChatMoveEnabled() === true && accessor.supportsChatMoves(URI.parse(sessionUri)); + return harnessSentence === chatsSentence && !moves + ? definition + : { ...definition, description: definition.description?.replace(chatsSentence, `${harnessSentence}${moves ? moveSessionLinksSentence : ''}`) }; } if (definition.name === SessionServerToolName.SetWorkspace && accessor?.isWorkspaceless(URI.parse(sessionUri)) === true) { return { ...definition, deferLoading: false }; } if (definition.name === SessionServerToolName.CreateSession) { // Without multi-root support a current-session chat cannot get its own folder. - return accessor?.supportsChatWorkingDirectories(URI.parse(sessionUri)) === false + const forSession = accessor?.supportsChatWorkingDirectories(URI.parse(sessionUri)) === false ? { ...definition, description: createSessionWithSharedWorkspaceDescription, inputSchema: createSessionWithSharedWorkspaceInputSchema } : definition; + // `harness` is offered only while a chat of this session may use another agent; otherwise the schema is unchanged. + const harnesses = accessor?.getChatHarnesses(URI.parse(sessionUri)); + return harnesses !== undefined ? withCreateSessionHarness(forSession, harnesses) : forSession; } if (definition.name !== SessionServerToolName.RenameChat) { return definition; diff --git a/src/vs/platform/agentHost/test/common/agentHostProtocolCompatibility.test.ts b/src/vs/platform/agentHost/test/common/agentHostProtocolCompatibility.test.ts index 423e0fa664378..9385f8556551a 100644 --- a/src/vs/platform/agentHost/test/common/agentHostProtocolCompatibility.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostProtocolCompatibility.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { getAgentHostSupportedProtocolVersions, negotiateAgentHostProtocolVersion } from '../../common/agentHostProtocolCompatibility.js'; +import { getAgentHostClientProtocolVersions, getAgentHostSupportedProtocolVersions, negotiateAgentHostProtocolVersion } from '../../common/agentHostProtocolCompatibility.js'; import { createRemoteAgentHostState } from '../../common/remoteAgentHostMetadata.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; @@ -25,6 +25,20 @@ suite('Agent Host protocol compatibility', () => { }); }); + test('clients offer the current version only while chats may use their own agents', () => { + assert.deepStrictEqual({ + off: getAgentHostClientProtocolVersions(false), + on: getAgentHostClientProtocolVersions(true), + negotiatedOff: negotiateAgentHostProtocolVersion(getAgentHostClientProtocolVersions(false)), + negotiatedOn: negotiateAgentHostProtocolVersion(getAgentHostClientProtocolVersions(true)), + }, { + off: ['1.0.0', '0.10.0', '0.9.0'], + on: [PROTOCOL_VERSION, '1.0.0', '0.10.0', '0.9.0'], + negotiatedOff: '1.0.0', + negotiatedOn: PROTOCOL_VERSION, + }); + }); + test('selects the highest compatible offered version regardless of client order', () => { const offered = [ ['0.9.0', '1.0.0', '1.5.0'], diff --git a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts index d240916a80085..e3a86f1df418f 100644 --- a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts +++ b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts @@ -1321,6 +1321,49 @@ suite('AgentSubscriptionManager', () => { assert.ok(unsubscribedResources.includes(sessionUri)); }); + test('a subscribe that resolves after its last ref was released is unsubscribed again, unless the resource is held again', async () => { + const hostSubscribed = new Set(); + const pending: { resource: string; resolve: () => void }[] = []; + const mgr = disposables.add(new AgentSubscriptionManager('c1', () => ++seq, noop, resource => new Promise(resolve => { + const key = resource.toString(); + // The host subscribes when it answers, after any unsubscribe that overtook this subscribe on the wire. + pending.push({ resource: key, resolve: () => { hostSubscribed.add(key); resolve({ resource: key, state: makeSessionState(key), fromSeq: 0 }); } }); + }), resource => { hostSubscribed.delete(resource.toString()); })); + const released = URI.parse('copilot:/released'); + const reacquired = URI.parse('copilot:/reacquired'); + + mgr.getSubscription(StateComponents.Session, released, 'test').dispose(); + mgr.getSubscription(StateComponents.Session, reacquired, 'test').dispose(); + const held = mgr.getSubscription(StateComponents.Session, reacquired, 'test'); + for (const request of pending) { + request.resolve(); + } + await new Promise(r => setTimeout(r, 0)); + + assert.deepStrictEqual({ hostSubscribed: [...hostSubscribed].sort(), heldTitle: held.object.value instanceof Error ? undefined : held.object.value?.title !== undefined }, { hostSubscribed: [reacquired.toString()], heldTitle: true }); + held.dispose(); + }); + + test('a refresh that resolves after its last ref was released is unsubscribed again', async () => { + const hostSubscribed = new Set(); + const pending: (() => void)[] = []; + const mgr = disposables.add(new AgentSubscriptionManager('c1', () => ++seq, noop, resource => new Promise(resolve => { + const key = resource.toString(); + pending.push(() => { hostSubscribed.add(key); resolve({ resource: key, state: makeSessionState(key), fromSeq: 0 }); }); + }), resource => { hostSubscribed.delete(resource.toString()); })); + const resource = URI.parse('copilot:/refreshed'); + const ref = mgr.getSubscription(StateComponents.Session, resource, 'test'); + pending.shift()!(); + await new Promise(r => setTimeout(r, 0)); + + const refresh = mgr.refreshSubscription(resource); + ref.dispose(); + pending.shift()!(); + await refresh; + + assert.deepStrictEqual([...hostSubscribed], []); + }); + test('receiveEnvelope routes to root and all active subscriptions', async () => { const mgr = createManager(); mgr.handleRootSnapshot(makeRootState(), 0); diff --git a/src/vs/platform/agentHost/test/common/sessionConfigProperties.test.ts b/src/vs/platform/agentHost/test/common/sessionConfigProperties.test.ts index e85d6e5c351c0..ad44848368532 100644 --- a/src/vs/platform/agentHost/test/common/sessionConfigProperties.test.ts +++ b/src/vs/platform/agentHost/test/common/sessionConfigProperties.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { applyChatConfigLayers, filterChatConfigValues, filterSessionConfigValues, getChatConfigChain, getChatConfigWriteTarget, getEffectiveChatConfig, getAvailableSessionApprovalValues, getEffectiveSessionApprovalValue, getWithheldSessionApprovalValues, getSessionApprovalProperty, getSessionBaseBranchProperty, getSessionConfigPresentationKey, getSessionIsolationProperty, getSessionModeProperty, getSessionWorkspaceProperties, isChatConfigProperty, isSessionConfigWritable, readSessionApprovalLevel, readSessionIsolation, mergeChatConfigChain, splitChatConfigPatch, validateChatConfigWrite, validateChatPickerConfigWrite, validateSessionConfigWrite, writeSessionApprovalLevel, writeSessionIsolation } from '../../common/sessionConfigProperties.js'; +import { applyChatConfigLayers, filterChatConfigValues, filterSessionConfigValues, getChatConfigChain, getChatConfigSchema, getChatConfigWriteTarget, getCompleteChatConfigValues, getEffectiveChatConfig, getAvailableSessionApprovalValues, getEffectiveSessionApprovalValue, getWithheldSessionApprovalValues, getSessionApprovalProperty, getSessionBaseBranchProperty, getSessionConfigPresentationKey, getSessionIsolationProperty, getSessionModeProperty, getSessionWorkspaceProperties, isChatConfigProperty, isSessionConfigWritable, readSessionApprovalLevel, readSessionIsolation, mergeChatConfigChain, splitChatConfigPatch, validateChatConfigWrite, validateChatPickerConfigWrite, validateSessionConfigWrite, writeSessionApprovalLevel, writeSessionIsolation } from '../../common/sessionConfigProperties.js'; import type { SessionConfigSchema } from '../../common/state/protocol/commands.js'; import { ChatOriginKind, type ChatSummary } from '../../common/state/protocol/state.js'; @@ -311,6 +311,28 @@ suite('Chat config properties', () => { }); }); + test('with complete values (chats with their own agents on), a chat\'s values are its complete config: the session gives only its session-wide properties', () => { + const session = { schema, values: { autoApprove: 'autoApprove', mode: 'plan', webSearch: true, isolation: 'worktree' } }; + const ownSchema: SessionConfigSchema = { type: 'object', properties: { mode: { type: 'string', title: 'Mode', enum: ['ask', 'agent'], default: 'agent', chatMutable: true } } }; + assert.deepStrictEqual({ + absent: getEffectiveChatConfig(session, undefined) === session, + // A missing property is the schema's default, never the session's value. + empty: getEffectiveChatConfig(session, { values: {} }, true).values, + complete: getEffectiveChatConfig(session, { values: { autoApprove: 'assisted', mode: 'interactive', webSearch: false } }, true).values, + ownSchema: getEffectiveChatConfig(session, { schema: ownSchema, values: {} }, false), + schemaOf: [getChatConfigSchema(session, undefined) === schema, getChatConfigSchema(session, { schema: ownSchema, values: {} }) === ownSchema], + // What a new chat starts with, or an older build's overlay completes to: the session's settable, non-reported values with the chat's over them. + completed: getCompleteChatConfigValues({ schema, values: { ...session.values, effectiveApprovalMode: 'autoApprove' } }, { mode: 'autopilot' }), + }, { + absent: true, + empty: { webSearch: true, isolation: 'worktree', autoApprove: 'default', mode: 'interactive' }, + complete: { webSearch: true, isolation: 'worktree', autoApprove: 'assisted', mode: 'interactive' }, + ownSchema: { schema: ownSchema, values: { mode: 'agent' } }, + schemaOf: [true, true], + completed: { autoApprove: 'autoApprove', mode: 'autopilot' }, + }); + }); + test('a chat that chose its own approvals is shown them until a mode is reported for the chat', () => { const session = { schema, values: { autoApprove: 'default', mode: 'interactive', effectiveApprovalMode: 'autoApprove' } }; const approval = (own: Record | undefined) => { diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index 80656e7839b28..e46c5d0695282 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -31,6 +31,8 @@ import { readAgentSandboxCertificate, withAgentSandboxCertificate, type IAgentSa import { getAgentHostArtifactIntegrationsCapability } from '../../common/meta/agentHostArtifactIntegrationMeta.js'; import { agentHostAuthority, toAgentHostUri } from '../../common/agentHostUri.js'; import { AgentHostFileSystemProvider } from '../../common/agentHostFileSystemProvider.js'; +import { ChatProvidersConfigKey, ChatProvidersSettingId } from '../../common/agentHostChatProviders.js'; +import { negotiateAgentHostProtocolVersion } from '../../common/agentHostProtocolCompatibility.js'; import { AgentHostPermissionMode, AgentHostResourceIdentity, AgentHostResourcePermissionError, IAgentHostResourceService, LOCAL_AGENT_HOST_RESOURCE_IDENTITY } from '../../common/agentHostResourceService.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; import { CODEX_SESSION_MODEL_META_KEY, withCodexSessionModel } from '../../common/meta/codexSessionModel.js'; @@ -3011,6 +3013,27 @@ suite('AgentHostProtocolClient', () => { })); } + test('initialize offers the current protocol version first only while chats may use their own agents', async () => { + const offer = async (enabled: boolean) => { + const transport = disposables.add(new TestClientProtocolTransport()); + const { client } = createClientForIdentity('test.example:1234', transport, createPermissionService(), undefined, new NullLogService(), new TestConfigurationService({ [ChatProvidersSettingId.Enabled]: enabled })); + const connectPromise = client.connect(); + transport.connectDeferred.complete(); + while (transport.sentMessages.length === 0) { + await Promise.resolve(); + } + const sent = transport.sentMessages[0] as JsonRpcRequest; + const offered = (sent.params as { protocolVersions: string[] }).protocolVersions; + transport.fireMessage({ jsonrpc: '2.0', id: sent.id, result: { protocolVersion: negotiateAgentHostProtocolVersion(offered)!, serverSeq: 0, snapshots: [] } }); + await connectPromise; + return { offered, negotiated: negotiateAgentHostProtocolVersion(offered) }; + }; + assert.deepStrictEqual({ off: await offer(false), on: await offer(true) }, { + off: { offered: ['1.0.0', '0.10.0', '0.9.0'], negotiated: '1.0.0' }, + on: { offered: [PROTOCOL_VERSION, '1.0.0', '0.10.0', '0.9.0'], negotiated: PROTOCOL_VERSION }, + }); + }); + test('initialize handshake includes protocol version and client info', async () => { const transport = disposables.add(new TestClientProtocolTransport(AgentHostClientConnectionKind.DevTunnel)); const clientInfo = agentsWindowAgentHostClientInfo; @@ -5146,6 +5169,200 @@ suite('AgentHostProtocolClient', () => { await connectPromise; } + /** + * Connects a client whose host's `multipleChatProviders` gate is off at the handshake (no capability), then turns + * the gate on through a root config action, as this client's own mirror does. Returns the requests each transport sent. + */ + async function connectWithHostGateTurningOn(clientSetting: boolean) { + const { client, transports, configurationService } = createFactoryClient(undefined, undefined, undefined, { autoRestore: true, initialDelayMs: 1, maxDelayMs: 1, maxAttempts: 3 }); + configurationService.setUserConfiguration(ChatProvidersSettingId.Enabled, clientSetting); + const connecting = client.connect(); + transports[0].connectDeferred.complete(); + const initialize = await waitForRequest(transports[0], 'initialize'); + transports[0].fireMessage({ + jsonrpc: '2.0', id: initialize.id, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 1, snapshots: [{ resource: ROOT_STATE_URI, fromSeq: 1, state: { agents: [], config: { schema: { type: 'object', properties: {} }, values: { [ChatProvidersConfigKey.Enabled]: false } } } }] }, + }); + await connecting; + transports[0].fireMessage({ + jsonrpc: '2.0', method: 'action', params: { + channel: ROOT_STATE_URI, serverSeq: 2, origin: undefined, action: { type: ActionType.RootConfigChanged, config: { [ChatProvidersConfigKey.Enabled]: true } }, + }, + }); + await flushMicrotasks(); + return { client, transports }; + } + + test('re-initializes once when the host enables chats with their own agents after the handshake', async () => { + const { client, transports } = await connectWithHostGateTurningOn(true); + const replacement = await waitForTransport(transports, 1); + replacement.connectDeferred.complete(); + const initialize = await waitForRequest(replacement, 'initialize'); + replacement.fireMessage({ + jsonrpc: '2.0', id: initialize.id, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 3, multipleChatProviders: {}, snapshots: [{ resource: ROOT_STATE_URI, fromSeq: 3, state: { agents: [], config: { schema: { type: 'object', properties: {} }, values: { [ChatProvidersConfigKey.Enabled]: true } } } }] }, + }); + await waitForConnectedWithin(client); + // Another config change on the re-initialized connection does not re-initialize again. + replacement.fireMessage({ + jsonrpc: '2.0', method: 'action', params: { + channel: ROOT_STATE_URI, serverSeq: 4, origin: undefined, action: { type: ActionType.RootConfigChanged, config: { other: 1 } }, + }, + }); + await timeout(10); + + assert.deepStrictEqual({ + transports: transports.length, + replacementMethods: replacement.sentMessages.filter((message): message is JsonRpcRequest => 'id' in message && 'method' in message).map(message => message.method).filter(method => method === 'initialize' || method === 'reconnect'), + capability: client.initializeResult.get()?.multipleChatProviders, + }, { transports: 2, replacementMethods: ['initialize'], capability: {} }); + }); + + test('a subscribe in flight when the host enables chats with their own agents succeeds after the re-initialize', async () => { + const { client, transports, configurationService } = createFactoryClient(undefined, undefined, undefined, { autoRestore: true, initialDelayMs: 1, maxDelayMs: 1, maxAttempts: 3 }); + configurationService.setUserConfiguration(ChatProvidersSettingId.Enabled, true); + const connecting = client.connect(); + transports[0].connectDeferred.complete(); + const initialize = await waitForRequest(transports[0], 'initialize'); + transports[0].fireMessage({ + jsonrpc: '2.0', id: initialize.id, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 1, snapshots: [{ resource: ROOT_STATE_URI, fromSeq: 1, state: { agents: [], config: { schema: { type: 'object', properties: {} }, values: { [ChatProvidersConfigKey.Enabled]: false } } } }] }, + }); + await connecting; + const session = URI.parse('copilot:/in-flight'); + const subscribed = client.subscribe(session); + await waitForRequest(transports[0], 'subscribe'); + // The re-initialize drops the transport before the host answers the subscribe. + transports[0].fireMessage({ + jsonrpc: '2.0', method: 'action', params: { + channel: ROOT_STATE_URI, serverSeq: 2, origin: undefined, action: { type: ActionType.RootConfigChanged, config: { [ChatProvidersConfigKey.Enabled]: true } }, + }, + }); + const replacement = await waitForTransport(transports, 1); + replacement.connectDeferred.complete(); + const reinitialize = await waitForRequest(replacement, 'initialize'); + replacement.fireMessage({ + jsonrpc: '2.0', id: reinitialize.id, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 3, multipleChatProviders: {}, snapshots: [{ resource: ROOT_STATE_URI, fromSeq: 3, state: { agents: [], config: { schema: { type: 'object', properties: {} }, values: { [ChatProvidersConfigKey.Enabled]: true } } } }] }, + }); + const retried = await waitForRequest(replacement, 'subscribe'); + replacement.fireMessage({ jsonrpc: '2.0', id: retried.id, result: { snapshot: { resource: session.toString(), fromSeq: 3, state: { title: 'restored' } } } }); + + const snapshot = await subscribed; + assert.deepStrictEqual({ resource: snapshot.resource, title: (snapshot.state as { title?: string }).title, retriedChannel: (retried.params as { channel: string }).channel }, { resource: session.toString(), title: 'restored', retriedChannel: session.toString() }); + }); + + test('does not re-initialize when the first handshake already advertises chats with their own agents (a local host seeded by its starter)', async () => { + const { client, transports, configurationService } = createFactoryClient(undefined, undefined, undefined, { autoRestore: true, initialDelayMs: 1, maxDelayMs: 1, maxAttempts: 3 }); + configurationService.setUserConfiguration(ChatProvidersSettingId.Enabled, true); + const connecting = client.connect(); + transports[0].connectDeferred.complete(); + const initialize = await waitForRequest(transports[0], 'initialize'); + transports[0].fireMessage({ + jsonrpc: '2.0', id: initialize.id, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 1, multipleChatProviders: {}, snapshots: [{ resource: ROOT_STATE_URI, fromSeq: 1, state: { agents: [], config: { schema: { type: 'object', properties: {} }, values: { [ChatProvidersConfigKey.Enabled]: true } } } }] }, + }); + await connecting; + // The client's own mirror of the setting confirms the host's gate. + transports[0].fireMessage({ + jsonrpc: '2.0', method: 'action', params: { + channel: ROOT_STATE_URI, serverSeq: 2, origin: undefined, action: { type: ActionType.RootConfigChanged, config: { [ChatProvidersConfigKey.Enabled]: true } }, + }, + }); + await timeout(10); + + assert.deepStrictEqual({ transports: transports.length, capability: client.initializeResult.get()?.multipleChatProviders }, { transports: 1, capability: {} }); + }); + + test('does not re-initialize for chats with their own agents with the setting off', async () => { + const { transports } = await connectWithHostGateTurningOn(false); + await timeout(10); + assert.strictEqual(transports.length, 1); + }); + + /** + * Connects a client with the `multipleChatProviders` setting off, so the handshake negotiates `1.0.0`, then turns + * the setting on and lets the host echo the mirrored gate (unless it was already on). Returns the offered versions. + */ + async function connectBelowChatProvidersVersionThenEnable(hostGateAlreadyOn = false) { + const { client, transports, configurationService } = createFactoryClient(undefined, undefined, undefined, { autoRestore: true, initialDelayMs: 1, maxDelayMs: 1, maxAttempts: 3 }); + configurationService.setUserConfiguration(ChatProvidersSettingId.Enabled, false); + const connecting = client.connect(); + transports[0].connectDeferred.complete(); + const initialize = await waitForRequest(transports[0], 'initialize'); + transports[0].fireMessage({ + jsonrpc: '2.0', id: initialize.id, + result: { protocolVersion: '1.0.0', serverSeq: 1, snapshots: [{ resource: ROOT_STATE_URI, fromSeq: 1, state: { agents: [], config: { schema: { type: 'object', properties: {} }, values: { [ChatProvidersConfigKey.Enabled]: hostGateAlreadyOn } } } }] }, + }); + await connecting; + configurationService.setUserConfiguration(ChatProvidersSettingId.Enabled, true); + fireConfigurationChange(configurationService, ChatProvidersSettingId.Enabled); + if (!hostGateAlreadyOn) { + transports[0].fireMessage({ + jsonrpc: '2.0', method: 'action', params: { + channel: ROOT_STATE_URI, serverSeq: 2, origin: undefined, action: { type: ActionType.RootConfigChanged, config: { [ChatProvidersConfigKey.Enabled]: true } }, + }, + }); + } + await flushMicrotasks(); + return { client, transports, configurationService, firstOffered: (initialize.params as { protocolVersions: string[] }).protocolVersions }; + } + + async function completeChatProvidersReinitialize(transports: TestClientProtocolTransport[], client: AgentHostProtocolClient, advertise: boolean) { + const replacement = await waitForTransport(transports, 1); + replacement.connectDeferred.complete(); + const initialize = await waitForRequest(replacement, 'initialize'); + replacement.fireMessage({ + jsonrpc: '2.0', id: initialize.id, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 3, ...(advertise ? { multipleChatProviders: {} } : {}), snapshots: [{ resource: ROOT_STATE_URI, fromSeq: 3, state: { agents: [], config: { schema: { type: 'object', properties: {} }, values: { [ChatProvidersConfigKey.Enabled]: true } } } }] }, + }); + await waitForConnectedWithin(client); + return { replacement, offered: (initialize.params as { protocolVersions: string[] }).protocolVersions }; + } + + for (const hostGateAlreadyOn of [false, true]) { + test(`re-initializes once offering ${PROTOCOL_VERSION} when the setting turns on after an older handshake (host gate already on: ${hostGateAlreadyOn})`, async () => { + const { client, transports, firstOffered } = await connectBelowChatProvidersVersionThenEnable(hostGateAlreadyOn); + const { offered } = await completeChatProvidersReinitialize(transports, client, true); + + assert.deepStrictEqual({ + transports: transports.length, + firstOffered: firstOffered.includes(PROTOCOL_VERSION), + offered: offered.includes(PROTOCOL_VERSION), + capability: client.initializeResult.get()?.multipleChatProviders, + }, { transports: 2, firstOffered: false, offered: true, capability: {} }); + }); + } + + test('re-initializes at most once when the host still does not advertise chats with their own agents', async () => { + const { client, transports, configurationService } = await connectBelowChatProvidersVersionThenEnable(); + const { replacement } = await completeChatProvidersReinitialize(transports, client, false); + replacement.fireMessage({ + jsonrpc: '2.0', method: 'action', params: { + channel: ROOT_STATE_URI, serverSeq: 4, origin: undefined, action: { type: ActionType.RootConfigChanged, config: { other: 1 } }, + }, + }); + fireConfigurationChange(configurationService, ChatProvidersSettingId.Enabled); + await timeout(10); + + assert.deepStrictEqual({ transports: transports.length, capability: client.initializeResult.get()?.multipleChatProviders }, { transports: 2, capability: undefined }); + }); + + test('does not reconnect when the setting turns off', async () => { + const { client, transports, configurationService } = await connectBelowChatProvidersVersionThenEnable(); + await completeChatProvidersReinitialize(transports, client, true); + configurationService.setUserConfiguration(ChatProvidersSettingId.Enabled, false); + fireConfigurationChange(configurationService, ChatProvidersSettingId.Enabled); + transports[1].fireMessage({ + jsonrpc: '2.0', method: 'action', params: { + channel: ROOT_STATE_URI, serverSeq: 4, origin: undefined, action: { type: ActionType.RootConfigChanged, config: { [ChatProvidersConfigKey.Enabled]: false } }, + }, + }); + await timeout(10); + + assert.deepStrictEqual({ transports: transports.length, state: client.connectionState }, { transports: 2, state: AgentHostClientState.Connected }); + }); + const advertisedAgent = { provider: 'copilot' as const, displayName: 'Test', description: 'test', models: [], protectedResources: [ diff --git a/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts index 2b2c423e6cb86..5e0e4a92efbdf 100644 --- a/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts @@ -18,8 +18,10 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import type { RootConfigState } from '../../common/state/protocol/state.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { buildDefaultChatUri, buildChatUri, buildSubagentSessionUri, ChatOriginKind, SessionStatus, type SessionSummary } from '../../common/state/sessionState.js'; -import { AgentConfigurationService, getEffectiveWorkingDirectories, getEffectiveWorkingDirectory, getSessionSummaryWorkingDirectories } from '../../node/agentConfigurationService.js'; +import { AgentConfigurationService, seedMultipleChatProvidersFromEnvironment, getEffectiveWorkingDirectories, getEffectiveWorkingDirectory, getSessionSummaryWorkingDirectories } from '../../node/agentConfigurationService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { ChatProvidersConfigKey, chatProvidersRootConfigSchema } from '../../common/agentHostChatProviders.js'; +import { AgentHostMultipleChatProvidersEnabledEnvVar, buildAgentSdkEnv } from '../../common/agentService.js'; suite('AgentConfigurationService', () => { @@ -237,6 +239,17 @@ suite('AgentConfigurationService', () => { assert.strictEqual(effective(peer), 'low'); }); + test('with chats with their own agents on, a malformed own value falls back to the schema\'s default and the host, not the session', async () => { + service.updateRootConfig({ [ChatProvidersConfigKey.Enabled]: true }); + setUpSession({ level: 'low' }); + // A chat registered with its session's config takes its complete values once registered. + await Promise.resolve(); + manager.dispatchServerAction(peer, { type: ActionType.ChatConfigChanged, config: { level: 'bogus' } }); + const withoutHost = effective(peer); + seedRootConfig({ level: 'high', [ChatProvidersConfigKey.Enabled]: true }); + assert.deepStrictEqual([withoutHost, effective(peer)], [undefined, 'high']); + }); + test('updates route settable properties to the chat (a subagent\'s to its spawning chat) and the rest to the session', () => { setUpSession({ level: 'low', limit: 1 }); service.updateChatConfig({ session, chat: nested }, { level: 'low', limit: 2 }); @@ -256,23 +269,29 @@ suite('AgentConfigurationService', () => { }); }); - test('fires chat changes for the changed chat and its subagents, and session changes for chats that do not set the key', () => { - setUpSession({ level: 'low', limit: 1 }); - const events: string[] = []; - disposables.add(service.onDidChatConfigChange(e => events.push(`${e.chat.substring(session.length)} ${JSON.stringify(e.config)}`))); - service.updateChatConfig({ session, chat: peer }, { level: 'low' }); - service.updateSessionConfig(session, { level: 'high', limit: 3 }); - assert.deepStrictEqual(events, [ - `${peer.substring(session.length)} {"level":"low"}`, - `${subagent.substring(session.length)} {"level":"low"}`, - `${nested.substring(session.length)} {"level":"low"}`, - `${defaultChat.substring(session.length)} {"level":"high","limit":3}`, - `${peer.substring(session.length)} {"limit":3}`, - `${subagent.substring(session.length)} {"limit":3}`, - `${nested.substring(session.length)} {"limit":3}`, - `${lookalike.substring(session.length)} {"level":"high","limit":3}`, - ]); - }); + for (const complete of [false, true]) { + test(`fires chat changes for the changed chat and its subagents, and session changes for chats that do not set the key${complete ? ' (with chats with their own agents on, only session-wide properties reach chats other than the default chat: their values are their complete config)' : ''}`, async () => { + if (complete) { + service.updateRootConfig({ [ChatProvidersConfigKey.Enabled]: true }); + } + setUpSession({ level: 'low', limit: 1 }); + await Promise.resolve(); + const events: string[] = []; + disposables.add(service.onDidChatConfigChange(e => events.push(`${e.chat.substring(session.length)} ${JSON.stringify(e.config)}`))); + service.updateChatConfig({ session, chat: peer }, { level: 'low' }); + service.updateSessionConfig(session, { level: 'high', limit: 3 }); + assert.deepStrictEqual(events, [ + `${peer.substring(session.length)} {"level":"low"}`, + `${subagent.substring(session.length)} {"level":"low"}`, + `${nested.substring(session.length)} {"level":"low"}`, + `${defaultChat.substring(session.length)} {"level":"high","limit":3}`, + `${peer.substring(session.length)} {"limit":3}`, + `${subagent.substring(session.length)} {"limit":3}`, + `${nested.substring(session.length)} {"limit":3}`, + `${lookalike.substring(session.length)} ${complete ? '{"limit":3}' : '{"level":"high","limit":3}'}`, + ]); + }); + } test('a replacement fans out the keys it removed, from the session and from a chat', () => { setUpSession({ level: 'low', limit: 1 }); @@ -287,6 +306,36 @@ suite('AgentConfigurationService', () => { assert.deepStrictEqual(events, ['default level,limit', 'peer limit', 'peer level,limit']); }); + test('without chats with their own agents, a session write leaves the default chat\'s own value, as before', async () => { + setUpSession({ level: 'low', limit: 1 }); + service.updateChatConfig({ session, chat: defaultChat }, { level: 'high' }); + manager.dispatchClientAction(session, { type: ActionType.SessionConfigChanged, config: { level: 'low' } }, { clientId: 'client', clientSeq: 1 }); + await Promise.resolve(); + assert.deepStrictEqual({ effective: effective(defaultChat), published: manager.getChatConfig(defaultChat)?.values }, { effective: 'high', published: { level: 'high' } }); + }); + + test('with chats with their own agents on, a session write replaces the default chat\'s own value of a property it may set, persisted cleared; a peer keeps its own', async () => { + service.updateRootConfig({ [ChatProvidersConfigKey.Enabled]: true }); + setUpSession({ level: 'low', limit: 1 }); + await Promise.resolve(); + service.updateChatConfig({ session, chat: defaultChat }, { level: 'high' }); + const before = { effective: effective(defaultChat), persisted: service.getChatPersistedConfigValues(defaultChat) }; + manager.dispatchClientAction(session, { type: ActionType.SessionConfigChanged, config: { level: 'low' } }, { clientId: 'client', clientSeq: 1 }); + assert.deepStrictEqual({ + before, + effective: effective(defaultChat), + published: manager.getChatConfig(defaultChat)?.values, + persisted: service.getChatPersistedConfigValues(defaultChat), + peer: [effective(peer), service.getChatOwnConfigValues({ session, chat: peer })], + }, { + before: { effective: 'high', persisted: { level: 'high' } }, + effective: 'low', + published: { level: 'low' }, + persisted: {}, + peer: ['high', { level: 'high' }], + }); + }); + test('a seed gives a chat being created its values until it is registered', () => { setUpSession({ level: 'low' }); const fork = buildChatUri(session, 'fork'); @@ -618,4 +667,22 @@ suite('AgentConfigurationService', () => { assert.deepStrictEqual(getAgentCustomizationSettingsEntries(manager.rootState).map(entry => entry.provider), ['valid']); }); + + test('a local host is seeded with chats with their own agents from its starter\'s environment, only when the setting is on', () => { + const sdkEnv = (multipleChatProvidersEnabled: boolean | undefined, inherited: Record = {}) => buildAgentSdkEnv({ multipleChatProvidersEnabled }, inherited)[AgentHostMultipleChatProvidersEnabledEnvVar]; + const seeded = (env: Record) => { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const configuration = disposables.add(new AgentConfigurationService(stateManager, new NullLogService())); + seedMultipleChatProvidersFromEnvironment(configuration, env); + return configuration.getRootValue(chatProvidersRootConfigSchema, ChatProvidersConfigKey.Enabled); + }; + assert.deepStrictEqual({ + starter: [sdkEnv(true), sdkEnv(false), sdkEnv(undefined), sdkEnv(true, { [AgentHostMultipleChatProvidersEnabledEnvVar]: 'false' })], + host: [seeded({ [AgentHostMultipleChatProvidersEnabledEnvVar]: 'true' }), seeded({}), seeded({ [AgentHostMultipleChatProvidersEnabledEnvVar]: 'false' })], + }, { + // Forwarded only when on; a developer's own value wins. + starter: ['true', undefined, undefined, undefined], + host: [true, undefined, undefined], + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts index da9e2dfd75894..7aa5486a68173 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts @@ -182,6 +182,13 @@ class TestConfigurationService implements IAgentConfigurationService { seedChatConfig(): { readonly values: Record; dispose(): void } { return { values: {}, dispose: () => { } }; } + completeChatConfigs(): void { } + getChatPersistedConfigValues(): undefined { + return undefined; + } + getCompleteChatConfigValues(_session: string, own: Readonly>): Record { + return { ...own }; + } isWorkingDirectoryPending(): boolean { return false; diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index 652b52ec076c6..c9c510a7433e7 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -108,6 +108,8 @@ class TestAgentHostChangesetService extends AgentHostChangesetService { readonly stateStore: IAgentHostSessionStateStore; readonly provider: IAgent; + /** The agent chats are routed to, when a test gives chats their own agent; else the session's. */ + readonly chatRouting: { agent: IAgent | undefined }; readonly storedTurnEdits: Emitter<{ readonly chat: URI; readonly turnIds: readonly string[] }>; readonly fileEditCoordinator: AgentHostFileEditCoordinator; @@ -119,11 +121,13 @@ class TestAgentHostChangesetService extends AgentHostChangesetService { override waitForTurnEdits = async (_chat: URI, _turnId: string): Promise => { }; }; const fileEditCoordinator = new AgentHostFileEditCoordinator(); + const chatRouting: { agent: IAgent | undefined } = { agent: undefined }; super(...args, TEST_GIT_STATE_SERVICE, new NullAgentHostWorktreeIsolation(), { _serviceBrand: undefined, setRead: async () => { }, setArchived: async () => { }, ...createLegacyChatMetadataPersistence(args[2]), }, createTestChatOwnershipService(args[0]), - mirrorStore, { ...createTestAgentHostProviderService(() => provider), getProviders: () => [provider] }, fileEditCoordinator); + mirrorStore, { ...createTestAgentHostProviderService(() => provider), getProviderForChat: () => chatRouting.agent ?? provider, getProviders: () => [provider] }, fileEditCoordinator); + this.chatRouting = chatRouting; this.stateStore = mirrorStore; this.provider = provider; this.storedTurnEdits = this._register(storedTurnEdits); @@ -1894,6 +1898,17 @@ suite('AgentHostChangesetService - multi-root and recomputation', () => { }); }); + test('turn changesets wait for the chat\'s own agent, not its session\'s', async () => { + const { svc } = build({ workingDirectories: ['file:///repo'], git: createNoopGitService(), checkpoint: NULL_CHECKPOINT_SERVICE, db: new TestSessionDatabase() }); + const calls: string[] = []; + svc.provider.waitForTurnEdits = async () => { throw new Error('The session\'s agent does not know the chat'); }; + svc.chatRouting.agent = new class extends mock() { + override waitForTurnEdits = async (chat: URI, turnId: string): Promise => { calls.push(`${chat.toString()} ${turnId}`); }; + }; + await svc.computeTurnChangeset(sessionStr, 'own-agent-turn', 'fileEditTracker'); + assert.deepStrictEqual(calls, [`${buildDefaultChatUri(sessionStr)} own-agent-turn`]); + }); + test('late snapshots refresh session changes without waiting for a native turn', async () => { const db = new TestSessionDatabase(); const { svc, stateManager } = build({ workingDirectories: ['file:///repo'], git: createNoopGitService(), checkpoint: NULL_CHECKPOINT_SERVICE, db }); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index 8a526c158aec4..b2dbcfc9688e7 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -372,6 +372,7 @@ function setup(disposables: Pick, gitService: TestGitSer }() : gitHubClient; const providerService = new class extends mock() { override getProviderForSession() { return undefined; } + override getProviderForChat() { return undefined; } }(); const utilityModelService = new AgentHostUtilityModelService(true, configurationService, copilotApiService, new NullByokLmBridgeRegistry(), providerService, stateManager, authenticationService, gitHubEndpointService, logService, createTestChatOwnershipService(stateManager), new class extends mock() { override peek() { return undefined; } }()); return { diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionOpenTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionOpenTelemetry.test.ts index fdeecd88c91a2..67a196aab40e4 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSessionOpenTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSessionOpenTelemetry.test.ts @@ -51,17 +51,19 @@ suite('AgentHostSessionOpenTelemetry', () => { findRetainedChatOwner: () => undefined, }); instantiationService.stub(IAgentHostChatOwnershipService, createLiveChatOwnership(chat => chatOwners.get(chat), session => buildDefaultChatUri(session))); + const getProviderForSession = (session: URI | string) => { + const provider = associations.get(session.toString()) ?? AgentSession.provider(session); + if (!provider || !providers.includes(provider)) { + return undefined; + } + const providerId = provider; + return new class extends mock() { + override readonly id = providerId; + }; + }; instantiationService.stub(IAgentHostProviderService, { - getProviderForSession: session => { - const provider = associations.get(session.toString()) ?? AgentSession.provider(session); - if (!provider || !providers.includes(provider)) { - return undefined; - } - const providerId = provider; - return new class extends mock() { - override readonly id = providerId; - }; - }, + getProviderForSession, + getProviderForChat: getProviderForSession, }); return disposables.add(instantiationService.createInstance(AgentHostSessionOpenTelemetry)); }; diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 1353e1e502d8f..60c4c506f4453 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -3530,7 +3530,8 @@ suite('AgentHostStateManager', () => { sourceStatus: manager.getSessionState(sessionUri)!.status & SessionStatus.InProgress, targetStatus: manager.getSessionState(target)!.status & SessionStatus.InProgress, }, { - envelopes: ['source session/chatRemoved', 'source session/chatRemoved', 'target session/chatAdded', 'target session/chatAdded', 'target session/chatsReordered', 'root root/activeSessionsChanged', 'root root/activeSessionsChanged'], + // Each moved chat, its subagent included, then tells its own subscribers its new owner. + envelopes: ['source session/chatRemoved', 'source session/chatRemoved', 'target session/chatAdded', 'target session/chatAdded', 'target session/chatsReordered', 'other chat/moved', 'other chat/moved', 'root root/activeSessionsChanged', 'root root/activeSessionsChanged'], owners: ['peer true', 'sub true'], removalReasons: [ChatRemovalReason.Moved, ChatRemovalReason.Moved], sourceChats: [sessionChatUri], @@ -3613,7 +3614,7 @@ suite('AgentHostStateManager', () => { targetActive: manager.getSessionState(target)!.status & SessionStatus.InProgress, }, { // The former default chat is announced as the movable peer it is now; its own state follows once the target lists it. - published: ['target session/chatAdded', 'target session/chatAdded', 'other chat/movableChanged', 'target session/chatsReordered', 'notification root/sessionRemoved'], + published: ['target session/chatAdded', 'target session/chatAdded', 'other chat/movableChanged', 'target session/chatsReordered', 'other chat/moved', 'other chat/moved', 'notification root/sessionRemoved'], source: undefined, targetChats: [[sessionChatUri, 'Moved session', true], [sub, '', false], [targetDefault, 'Test', false]], movable: true, diff --git a/src/vs/platform/agentHost/test/node/agentHostUtilityModelService.test.ts b/src/vs/platform/agentHost/test/node/agentHostUtilityModelService.test.ts index 8ac7c3c0dfdf9..7ef54b808ff6d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostUtilityModelService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostUtilityModelService.test.ts @@ -111,6 +111,7 @@ suite('AgentHostUtilityModelService', () => { }); const providerService = new class extends mock() { override getProviderForSession() { return agent; } + override getProviderForChat() { return agent; } }(); const authenticationService = scenario.authenticationService ?? disposables.add(new class extends AgentHostAuthenticationService { override getSessionCredentialSource(resource: string) { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 738dfc46f8f2d..75f2481de5c90 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -47,7 +47,7 @@ import { getTelemetryMigrationSessionId } from '../../common/agentTelemetryCorre import { FileService } from '../../../files/common/fileService.js'; import { workspacelessChatsRoot, workspacelessScratchDir } from '../../common/workspacelessScratchDir.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; -import { AgentChatMigrationDeferred, AgentSession, type AgentChatMigrationResult, AgentWorkingDirectoryChangedError, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, SubagentChatSignal, resolveAgentChatContext, resolveAgentChatOrigin, type IAgent, type IAgentChatAdoptionResult, type IAgentChatContext, type IAgentChatDataChange, type IAgentChatMetadata, type IAgentChatMetadataOptions, type IAgentChats, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatRequestOptions, type IAgentCapabilities, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentDiscoveredChat, type IAgentListedChat, type IAgentLegacyChat, type IAgentMaterializeChatEvent, type IAgentSessionMetadata, type IAgentSpawnChatEvent, fixedChatOwnerRef } from '../../common/agent.js'; +import { AgentChatMigrationDeferred, AgentSession, type AgentChatMigrationResult, type IAgentResolveChatConfigParams, AgentWorkingDirectoryChangedError, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, SubagentChatSignal, resolveAgentChatContext, resolveAgentChatOrigin, type IAgent, type IAgentChatAdoptionResult, type IAgentChatContext, type IAgentChatDataChange, type IAgentChatMetadata, type IAgentChatMetadataOptions, type IAgentChats, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatRequestOptions, type IAgentCapabilities, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentDiscoveredChat, type IAgentListedChat, type IAgentLegacyChat, type IAgentMaterializeChatEvent, type IAgentSessionMetadata, type IAgentSpawnChatEvent, fixedChatOwnerRef } from '../../common/agent.js'; import { AgentHostPrewarmSessionsSettingId, IConnectionTrackerService } from '../../common/agentService.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { AgentHostActiveAgentTitleGenerationConfigKey, platformRootSchema, platformSessionSchema, AgentHostDeferredTitleGenerationConfigKey, AgentHostTitleGenerationConfigKey, AgentHostAutoArchiveMergedSessionsAfterDaysConfigKey, AgentHostAutoDeleteArchivedMergedSessionsAfterDaysConfigKey, AgentHostArtifactIntegrationsConfigKey, AgentHostArtifactToolsConfigKey, AgentHostAutoAttachPullRequestsConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostPrewarmSessionsConfigKey, AgentHostShowExternalSessionsConfigKey, AgentHostWorkspaceTrustConfigKey } from '../../common/agentHostSchema.js'; @@ -74,7 +74,7 @@ import { getSessionSandboxConfig } from '../../node/sessionSandbox.js'; import { buildSandboxConfigForSdk } from '../../node/copilot/sandboxConfigForSdk.js'; import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase, sessionDatabaseMigrations } from '../../node/sessionDatabase.js'; -import { ActionType, ActionEnvelope, NotificationType, type ChatAction, type ChatConfigChangedAction, type INotification, type SessionAction, type SessionSummaryChanges } from '../../common/state/sessionActions.js'; +import { ActionType, ActionEnvelope, isChatAction, NotificationType, type ChatAction, type ChatConfigChangedAction, type INotification, type SessionAction, type SessionSummaryChanges } from '../../common/state/sessionActions.js'; import { AH_META_AUTO_ARCHIVED_AT_DB_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, createErrorResponsePart, customizationId, isMessageRequestHiddenFromTranscript, getAgentHostChatId, isSessionStatusArchived, isSessionStatusRead, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubData, readSessionGitHubState, readSessionGitState, readWorkingDirectoryKeys, readWorkingDirectoryScopeIds, SESSION_META_GITHUB_DATA_KEY, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, readSessionWorkspaceless, withSessionEhcliAdoptable, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionWorkspaceless, withWorkingDirectoryKey, withWorkingDirectoryScopeId, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionGitHubState, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type SessionSummaryMeta, type ChatState, type ToolCallCompletedState, type ToolCallResponsePart, type Turn, readSessionScratchFolders, withSessionScratchFolders } from '../../common/state/sessionState.js'; import { BackgroundWorkKind, ChatInteractivity, McpServerStatus, PendingMessageKind, SessionOriginKind, type BackgroundWork, type Message, type MessageAttachment, TerminalClaimKind, type TerminalClaim, type SystemNotificationResponsePart } from '../../common/state/protocol/state.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; @@ -138,6 +138,10 @@ import { parseSessionArtifacts, readSessionArtifacts, SESSION_META_ARTIFACTS_KEY import { creationTimeChatStorageResource } from '../../node/agentHostStateManager.js'; import { deriveLegacyDefaultChat } from '../../common/agentHostLegacyDefaultChat.js'; import { editChatListAsOldBuild, editChatStateAsOldBuild } from './sessionStateCompatibilityFixtures.js'; +import type { AgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { ChatProvidersConfigKey } from '../../common/agentHostChatProviders.js'; +import type { ResolveSessionConfigResult } from '../../common/state/protocol/commands.js'; +import { chatReducer } from '../../common/state/protocol/channels-chat/reducer.js'; /** * Replace individual operations on an agent's chat surface, delegating every @@ -9610,6 +9614,160 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('with chats with their own agents on, restoring a session whose default chat\'s and peer\'s configs were completed before writes nothing, and publishes only what the default chat takes from its session', async () => { + const perSession = createPerSessionDataService(); + const orchestratorDb = new TestAgentHostOrchestratorDatabase(); + const sdkMetadata = new Map(); + const createService = () => disposables.add(createTestAgentService( + new NullLogService(), fileService, perSession.service, + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, orchestratorDb, + )); + const createAgent = () => disposables.add(new class extends PersistedMockAgent { + override async createChat(_session: URI, _chat: URI): Promise { + return { providerData: 'peer-backing' }; + } + }('copilotcli', sdkMetadata)); + const chatMutable = { type: 'string' as const, sessionMutable: true, chatMutable: true }; + const config = { + schema: { + type: 'object' as const, properties: { + [SessionConfigKey.AutoApprove]: { ...chatMutable, title: 'Approvals', enum: ['default', 'assisted', 'autoApprove'] }, + [SessionConfigKey.Mode]: { ...chatMutable, title: 'Mode', enum: ['interactive', 'plan', 'autopilot'] }, + [SessionConfigKey.Isolation]: { type: 'string' as const, title: 'Isolation', enum: ['folder', 'worktree'], sessionMutable: true }, + }, + }, + values: { [SessionConfigKey.AutoApprove]: 'default', [SessionConfigKey.Mode]: 'interactive', [SessionConfigKey.Isolation]: 'folder' }, + }; + const creating = createService(); + registerTestAgentProvider(creating, createAgent()); + getConfigurationService(creating).updateRootConfig({ [ChatProvidersConfigKey.Enabled]: true }); + const session = await creating.createSession({ legacyChatUris: true, provider: 'copilotcli' }); + const defaultChat = buildDefaultChatUri(session); + const state = getStateManager(creating); + (getConfigurationService(creating) as AgentConfigurationService).restoreSessionConfig(session.toString(), config); + state.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnStarted, turnId: 'source-turn', startedAt: new Date().toISOString(), message: { text: 'Source', origin: { kind: MessageKind.User } } }); + state.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnComplete, turnId: 'source-turn', duration: 1 }); + const peer = URI.parse(buildChatUri(session, 'peer')); + await creating.createChat(session, peer); + // A session-level picker change, persisted as the session's values; the peer keeps its own (complete) values. + getConfigurationService(creating).updateSessionConfig(session.toString(), { [SessionConfigKey.AutoApprove]: 'assisted' }); + // The default chat sets one property itself; it takes the session's others (Decision A). + getConfigurationService(creating).updateChatConfig({ session: session.toString(), chat: defaultChat }, { [SessionConfigKey.Mode]: 'plan' }); + await timeout(0); + const before = { defaultChat: state.getChatConfig(defaultChat), peer: state.getChatConfig(peer.toString()) }; + await getTestSessionStateStore(creating).whenIdle(undefined); + const stored = { defaultChat: await getTestSessionStateStore(creating).chatRows.getChatConfig(session, URI.parse(defaultChat)), peer: await getTestSessionStateStore(creating).chatRows.getChatConfig(session, peer) }; + creating.dispose(); + + const restarted = createService(); + registerTestAgentProvider(restarted, createAgent()); + getConfigurationService(restarted).updateRootConfig({ [ChatProvidersConfigKey.Enabled]: true }); + const restoredState = getStateManager(restarted); + const dispatched: string[] = []; + disposables.add(restoredState.onDidEmitEnvelope(envelope => { + if (envelope.action.type === ActionType.ChatConfigChanged) { + dispatched.push(envelope.channel === defaultChat ? 'default' : envelope.channel === peer.toString() ? 'peer' : envelope.channel); + } + })); + const rows = getTestSessionStateStore(restarted).chatRows; + const setChatConfig = rows.setChatConfig; + const written: string[] = []; + rows.setChatConfig = (owner, chat, values) => { + written.push(chat.toString() === defaultChat ? 'default' : chat.toString() === peer.toString() ? 'peer' : chat.toString()); + return setChatConfig.call(rows, owner, chat, values); + }; + await restarted.restoreSession(session); + // The provider's resolved schema; the values are the persisted ones. + (getConfigurationService(restarted) as AgentConfigurationService).restoreSessionConfig(session.toString(), { schema: config.schema, values: restoredState.getSessionState(session.toString())!.config!.values }); + await timeout(0); + await getTestSessionStateStore(restarted).whenIdle(undefined); + + assert.deepStrictEqual({ + before, + stored, + dispatched, + written, + after: { defaultChat: restoredState.getChatConfig(defaultChat), peer: restoredState.getChatConfig(peer.toString()) }, + }, { + // The default chat publishes what it takes from the session; it stores only what it set itself. + before: { defaultChat: { values: { [SessionConfigKey.AutoApprove]: 'assisted', [SessionConfigKey.Mode]: 'plan' } }, peer: { values: { [SessionConfigKey.AutoApprove]: 'default', [SessionConfigKey.Mode]: 'interactive' } } }, + stored: { defaultChat: { [SessionConfigKey.Mode]: 'plan' }, peer: { [SessionConfigKey.AutoApprove]: 'default', [SessionConfigKey.Mode]: 'interactive' } }, + // The default chat publishes what it takes from its restored session, for subscribers that took its state before. + dispatched: ['default'], + written: [], + after: before, + }); + }); + + test('with chats with their own agents on, a subscriber of the default chat while its session is restoring ends with its complete config values', async () => { + const perSession = createPerSessionDataService(); + const orchestratorDb = new TestAgentHostOrchestratorDatabase(); + const sdkMetadata = new Map(); + const createService = () => disposables.add(createTestAgentService( + new NullLogService(), fileService, perSession.service, + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, orchestratorDb, + )); + const chatMutable = { type: 'string' as const, sessionMutable: true, chatMutable: true }; + const schema = { + type: 'object' as const, properties: { + [SessionConfigKey.AutoApprove]: { ...chatMutable, title: 'Approvals', enum: ['default', 'assisted', 'autoApprove'] }, + [SessionConfigKey.Mode]: { ...chatMutable, title: 'Mode', enum: ['interactive', 'plan', 'autopilot'] }, + }, + }; + const resolving = new DeferredPromise(); + let holdResolution = false; + const resolveStarted = new DeferredPromise(); + const createAgent = () => disposables.add(new class extends PersistedMockAgent { + override async resolveChatConfig(params: IAgentResolveChatConfigParams): Promise { + if (holdResolution) { + resolveStarted.complete(); + await resolving.p; + } + return { schema, values: { [SessionConfigKey.AutoApprove]: 'default', [SessionConfigKey.Mode]: 'interactive', ...params.config } }; + } + }('copilotcli', sdkMetadata)); + const creating = createService(); + registerTestAgentProvider(creating, createAgent()); + getConfigurationService(creating).updateRootConfig({ [ChatProvidersConfigKey.Enabled]: true }); + const session = await creating.createSession({ legacyChatUris: true, provider: 'copilotcli' }); + const defaultChat = buildDefaultChatUri(session); + const state = getStateManager(creating); + state.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnStarted, turnId: 'source-turn', startedAt: new Date().toISOString(), message: { text: 'Source', origin: { kind: MessageKind.User } } }); + state.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnComplete, turnId: 'source-turn', duration: 1 }); + getConfigurationService(creating).updateSessionConfig(session.toString(), { [SessionConfigKey.AutoApprove]: 'assisted' }); + getConfigurationService(creating).updateChatConfig({ session: session.toString(), chat: defaultChat }, { [SessionConfigKey.Mode]: 'plan' }); + await timeout(0); + const complete = state.getChatConfig(defaultChat)?.values; + await getTestSessionStateStore(creating).whenIdle(undefined); + creating.dispose(); + + const restarted = createService(); + registerTestAgentProvider(restarted, createAgent()); + getConfigurationService(restarted).updateRootConfig({ [ChatProvidersConfigKey.Enabled]: true }); + holdResolution = true; + const restoring = restarted.restoreSession(session); + await resolveStarted.p; + // Subscribed while the restore waits for the agent's config: its snapshot has the default chat's own values only. + const snapshot = await restarted.subscribe(URI.parse(defaultChat), 'client'); + let subscriber = snapshot.state as ChatState; + disposables.add(getStateManager(restarted).onDidEmitEnvelope(envelope => { + if (envelope.channel === defaultChat && isChatAction(envelope.action)) { + subscriber = chatReducer(subscriber, envelope.action); + } + })); + resolving.complete(); + await restoring; + await timeout(0); + + assert.deepStrictEqual({ taken: (snapshot.state as ChatState).config?.values, complete, subscriber: subscriber.config?.values }, { + taken: { [SessionConfigKey.Mode]: 'plan' }, + complete: { [SessionConfigKey.AutoApprove]: 'assisted', [SessionConfigKey.Mode]: 'plan' }, + subscriber: { [SessionConfigKey.AutoApprove]: 'assisted', [SessionConfigKey.Mode]: 'plan' }, + }); + }); + test('a new chat starts from the default chat\'s current values, a fork from its source\'s own, also after a restart', async () => { const perSession = createPerSessionDataService(); const orchestratorDb = new TestAgentHostOrchestratorDatabase(); @@ -36197,6 +36355,41 @@ suite('AgentService (node dispatcher)', () => { return { db, service, agent, source, target, peer, folder }; } + /** The `chat/moved` actions dispatched while `run` runs. */ + async function chatMovedDuring(service: AgentService, run: () => Promise): Promise { + const announced: unknown[] = []; + const listener = getStateManager(service).onDidEmitEnvelope(envelope => { + if (envelope.action.type === ActionType.ChatMoved) { + announced.push({ channel: envelope.channel, action: envelope.action }); + } + }); + try { + await run(); + } finally { + listener.dispose(); + } + return announced; + } + + test('a chat moved to another session, or promoted to a new one, announces chat/moved; a reorder does not', async () => { + const { service, source, target, peer } = await setupMove(); + const other = URI.parse(buildChatUri(target, 'other')); + await service.createChat(target, other); + await timeout(0); + + const moved = await chatMovedDuring(service, () => service.moveChat(source, peer, { kind: 'session', session: target, after: undefined })); + const reordered = await chatMovedDuring(service, () => service.moveChat(target, peer, { kind: 'session', session: target, after: other })); + completeTurn(service, peer); + let promotedSession: URI | undefined; + const promoted = await chatMovedDuring(service, async () => { promotedSession = await service.moveChat(target, peer, { kind: 'newSession' }); }); + + assert.deepStrictEqual({ moved, reordered, promoted }, { + moved: [{ channel: peer.toString(), action: { type: ActionType.ChatMoved, session: target.toString() } }], + reordered: [], + promoted: [{ channel: peer.toString(), action: { type: ActionType.ChatMoved, session: promotedSession!.toString() } }], + }); + }); + test('a moved chat snapshots new attachments in its own storage and keeps reading those of the session it came from', async () => { const { service, agent, source, target, peer } = await setupMove(); const sessionDataService = createSessionDataService(); @@ -36725,6 +36918,8 @@ suite('AgentService (node dispatcher)', () => { }; stateManager.setSessionConfig(source.toString(), { schema, values: { autoApprove: 'autoApprove', effectiveApprovalMode: 'autoApprove', isolation: 'folder' } }); stateManager.setSessionConfig(target.toString(), { schema, values: { autoApprove: 'default', effectiveApprovalMode: 'default', isolation: 'worktree' } }); + // A chat's values are its complete config: the session's config set after the chat was created is not the chat's. + stateManager.dispatchServerAction(peer.toString(), { type: ActionType.ChatConfigChanged, config: { autoApprove: 'autoApprove' } }); await service.moveChat(source, peer, { kind: 'session', session: target, after: undefined }); @@ -40466,6 +40661,8 @@ suite('AgentService (node dispatcher)', () => { const schema = { type: 'object' as const, properties: { autoApprove: { type: 'string' as const, title: 'Approvals', enum: ['default', 'assisted', 'autoApprove'], sessionMutable: true, chatMutable: true } } }; stateManager.setSessionConfig(source.toString(), { schema, values: { autoApprove: 'autoApprove' } }); stateManager.setSessionConfig(target.toString(), { schema, values: { autoApprove: 'default' } }); + // A chat's values are its complete config: the session's config set after the chat was created is not the chat's. + stateManager.dispatchServerAction(peer.toString(), { type: ActionType.ChatConfigChanged, config: { autoApprove: 'autoApprove' } }); const store = getTestSessionStateStore(service); stub(stateManager, 'transferChats').throws(new Error('memory failure')); diff --git a/src/vs/platform/agentHost/test/node/agentServiceChatProviders.test.ts b/src/vs/platform/agentHost/test/node/agentServiceChatProviders.test.ts new file mode 100644 index 0000000000000..132b219e8a4c2 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentServiceChatProviders.test.ts @@ -0,0 +1,1067 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../log/common/log.js'; +import type { IProductService } from '../../../product/common/productService.js'; +import type { IAgent, IAgentChatContext, IAgentChatMetadata, IAgentCreateChatResult, IAgentResolveChatConfigParams, IAgentSpawnChatEvent } from '../../common/agent.js'; +import { ChatMoveConfigKey } from '../../common/agentHostChatMove.js'; +import { ChatProvidersConfigKey } from '../../common/agentHostChatProviders.js'; +import { AgentHostCodexEnabledConfigKey } from '../../common/agentHostSchema.js'; +import type { ResolveSessionConfigResult } from '../../common/state/protocol/commands.js'; +import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; +import { ActionType } from '../../common/state/sessionActions.js'; +import { buildChatUri, MessageKind } from '../../common/state/sessionState.js'; +import { decodeAgentHostCatalogPayload } from '../../node/agentHostCatalogProjection.js'; +import { AgentHostDatabase } from '../../node/agentHostDatabase.js'; +import type { AgentService } from '../../node/agentService.js'; +import { createNoopGitService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; +import { disposeTestSessionStateDatabases } from './chatMetadataTestHelpers.js'; +import { seedMultipleChatProvidersFromEnvironment } from '../../node/agentConfigurationService.js'; +import { AgentHostMultipleChatProvidersEnabledEnvVar } from '../../common/agentService.js'; +import { createTestAgentService, getTestAgentHostDatabase, getTestSessionStateStore, getTestAgentHostProviderService, getTestAgentServiceComposition, getTestAgentStateManager, registerTestAgentProvider } from './agentServiceTestUtils.js'; +import { MockAgent } from './mockAgent.js'; + +/** A multi-chat agent whose chat config has a chat-settable `mode` and a session-only `isolationHint`. */ +class ChatAgent extends MockAgent { + readonly disposedChats: string[] = []; + /** Runs inside the agent's chat creation, before Agent Host registers the chat. */ + onCreateChat: ((chat: URI) => void) | undefined; + readonly spawnEmitter = new Emitter(); + override readonly onDidSpawnChat = this.spawnEmitter.event; + + constructor(id: string, private readonly _defaultMode: string, multipleChats = true, multipleWorkingDirectories = false) { + super(id, { ...(multipleChats ? { multipleChats: { fork: true, sideChat: true } } : {}), ...(multipleWorkingDirectories ? { multipleWorkingDirectories: {} } : {}) }); + const createChat = this.chats.createChat; + this.chats.createChat = (chat, context, options) => { + this.onCreateChat?.(chat); + return createChat(chat, context, options); + }; + } + + override async resolveChatConfig(params: IAgentResolveChatConfigParams): Promise { + return { + schema: { + type: 'object', + properties: { + mode: { type: 'string', title: 'Mode', enum: ['agent', 'plan', 'ask'], default: this._defaultMode, chatMutable: true, sessionMutable: true }, + isolationHint: { type: 'string', title: 'Hint', default: `${this.id}-hint` }, + }, + }, + values: { mode: this._defaultMode, isolationHint: `${this.id}-hint`, ...params.config }, + }; + } + + override async createChat(_session: URI, _chat: URI): Promise { + return { providerData: `${this.id}-backing` }; + } + + override async disposeChat(_session: URI, chat: URI): Promise { + this.disposedChats.push(chat.toString()); + } + + /** Knows every session, as a restarted provider whose native catalog holds them does. */ + knowsAllSessions = false; + + override async getChatMetadata(chat: URI, context: URI | IAgentChatContext): Promise { + return this.knowsAllSessions ? { chat, startTime: 1, modifiedTime: 1 } : super.getChatMetadata(chat, context); + } + + override dispose(): void { + this.spawnEmitter.dispose(); + super.dispose(); + } + + override async materializeChat(_chat: URI, _context: URI | IAgentChatContext, _providerData: string | undefined): Promise { } +} + +suite('AgentService - chats with their own agent', () => { + + const disposables = new DisposableStore(); + let fileService: FileService; + + setup(() => { + fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + }); + + teardown(() => disposables.clear()); + teardown(() => disposeTestSessionStateDatabases()); + ensureNoDisposablesAreLeakedInTestSuite(); + + function createService(enabled: boolean, options?: { readonly secondSupportsChats?: boolean; readonly catalogDatabase?: AgentHostDatabase; readonly sessionDatabase?: TestSessionDatabase; readonly folderPerChat?: 'copilot' | 'second' }) { + const catalogDatabase = options?.catalogDatabase ?? disposables.add(new AgentHostDatabase(':memory:')); + const service = disposables.add(createTestAgentService( + new NullLogService(), fileService, createSessionDataService(options?.sessionDatabase ?? new TestSessionDatabase()), + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, catalogDatabase, + )); + const copilot = disposables.add(new ChatAgent('copilot', 'plan', true, options?.folderPerChat === 'copilot')); + const second = disposables.add(new ChatAgent('second', 'agent', options?.secondSupportsChats ?? true, options?.folderPerChat === 'second')); + registerTestAgentProvider(service, copilot); + registerTestAgentProvider(service, second); + if (enabled) { + getTestAgentServiceComposition(service).configurationService.updateRootConfig({ [ChatProvidersConfigKey.Enabled]: true }); + } + return { service, copilot, second, catalogDatabase }; + } + + async function createSessionWithSecondChat(service: AgentService, name = 'second-chat') { + const session = await service.createSession({ legacyChatUris: true, provider: 'copilot', workingDirectories: [URI.file('/repo')] }); + const chat = URI.parse(buildChatUri(session, name)); + await service.createChat(session, chat, { provider: 'second' }); + return { session, chat }; + } + + /** Completes a first turn in `chat`: a chat moves only once its first turn started. */ + function startTurn(service: AgentService, chat: URI | string): void { + const stateManager = getTestAgentStateManager(service); + stateManager.dispatchServerAction(chat.toString(), { type: ActionType.ChatTurnStarted, turnId: 'first-turn', startedAt: new Date(0).toISOString(), message: { text: 'Hello', origin: { kind: MessageKind.User } } }); + stateManager.dispatchServerAction(chat.toString(), { type: ActionType.ChatTurnComplete, turnId: 'first-turn', duration: 1 }); + } + + async function rejection(promise: Promise): Promise { + try { + await promise; + return undefined; + } catch (error) { + return error instanceof ProtocolError && error.code === JsonRpcErrorCodes.InvalidParams ? 'InvalidParams' : String(error); + } + } + + /** The chats an agent was asked to create, its session's default chat included. */ + function createdIn(agent: MockAgent): string[] { + return agent.chatContexts.filter(entry => entry.boundary === 'createChat').map(entry => entry.chat.toString()); + } + + function chatSummary(service: AgentService, session: URI, chat: URI) { + return getTestAgentStateManager(service).getSessionState(session.toString())?.chats.find(candidate => candidate.resource === chat.toString()); + } + + test('a chat created with another agent is created in that agent and routed to it', async () => { + const { service, copilot, second } = createService(true); + const { session, chat } = await createSessionWithSecondChat(service); + const providers = getTestAgentHostProviderService(service); + const stateManager = getTestAgentStateManager(service); + + assert.deepStrictEqual({ + createdInSecond: createdIn(second), + createdInCopilot: createdIn(copilot), + summaryProvider: chatSummary(service, session, chat)?.provider, + stateProvider: stateManager.getChatState(chat.toString())?.provider, + listedProvider: stateManager.getSessionSummary(session.toString())?.chats?.find(candidate => candidate.resource === chat.toString())?.provider, + chatAgent: providers.getProviderForChat(session, chat)?.id, + defaultChatAgent: providers.getProviderForChat(session, stateManager.getSessionState(session.toString())?.defaultChat)?.id, + }, { + createdInSecond: [chat.toString()], + createdInCopilot: [stateManager.getSessionState(session.toString())!.defaultChat], + summaryProvider: 'second', + stateProvider: 'second', + listedProvider: 'second', + chatAgent: 'second', + defaultChatAgent: 'copilot', + }); + }); + + for (const folderPerChat of ['copilot', 'second'] as const) { + test(`a chat's own workspace is checked against the chat's own agent, not its session's (folders per chat: ${folderPerChat})`, async () => { + const { service } = createService(true, { folderPerChat }); + const session = await service.createSession({ legacyChatUris: true, provider: 'copilot', workingDirectories: [URI.file('/repo')] }); + const created = URI.parse(buildChatUri(session, 'created')); + const placeholder = URI.parse(buildChatUri(session, 'placeholder')); + await service.createChat(session, placeholder, { provider: 'second' }); + const refusal = (error: string | undefined) => error?.match(/provider (\w+) does not support a workspace per chat/)?.[1]; + + assert.deepStrictEqual({ + createChat: refusal(await rejection(service.createChat(session, created, { provider: 'second', workspace: { kind: 'scratch' } }))), + setChatWorkspace: refusal(await rejection(service.setChatWorkspace(session, placeholder, { kind: 'scratch' }))), + }, folderPerChat === 'copilot' + // The session's agent gives each chat its own folders, but the chat's own agent doesn't. + ? { createChat: 'second', setChatWorkspace: 'second' } + : { createChat: undefined, setChatWorkspace: undefined }); + }); + } + + test('a chat without a requested agent, or requesting its session\'s, uses the session\'s agent and reports none', async () => { + const { service, copilot } = createService(true); + const session = await service.createSession({ legacyChatUris: true, provider: 'copilot' }); + const plain = URI.parse(buildChatUri(session, 'plain')); + const explicit = URI.parse(buildChatUri(session, 'explicit')); + await service.createChat(session, plain); + await service.createChat(session, explicit, { provider: 'copilot' }); + + assert.deepStrictEqual({ + created: createdIn(copilot).slice(1), + providers: [chatSummary(service, session, plain)?.provider, chatSummary(service, session, explicit)?.provider], + }, { + created: [plain.toString(), explicit.toString()], + providers: [undefined, undefined], + }); + }); + + test('rejects a requested agent when the setting is off, unknown, or without multiple chats', async () => { + const off = createService(false); + const offSession = await off.service.createSession({ legacyChatUris: true, provider: 'copilot' }); + const on = createService(true, { secondSupportsChats: false }); + const onSession = await on.service.createSession({ legacyChatUris: true, provider: 'copilot' }); + + assert.deepStrictEqual({ + settingOff: await rejection(off.service.createChat(offSession, URI.parse(buildChatUri(offSession, 'off')), { provider: 'second' })), + unknown: await rejection(on.service.createChat(onSession, URI.parse(buildChatUri(onSession, 'unknown')), { provider: 'missing' })), + noMultipleChats: await rejection(on.service.createChat(onSession, URI.parse(buildChatUri(onSession, 'single')), { provider: 'second' })), + created: [...createdIn(off.second), ...createdIn(on.second)], + chats: [getTestAgentStateManager(off.service).getSessionState(offSession.toString())?.chats.length, getTestAgentStateManager(on.service).getSessionState(onSession.toString())?.chats.length], + }, { + settingOff: 'InvalidParams', + unknown: 'InvalidParams', + noMultipleChats: 'InvalidParams', + created: [], + chats: [1, 1], + }); + }); + + test('a fork keeps its source chat\'s agent and rejects another one', async () => { + const { service, copilot, second } = createService(true); + const { session, chat } = await createSessionWithSecondChat(service); + const fork = URI.parse(buildChatUri(session, 'fork')); + const mismatched = URI.parse(buildChatUri(session, 'mismatched')); + + const mismatch = await rejection(service.createChat(session, mismatched, { provider: 'copilot', fork: { source: chat, turnId: 'missing-turn' } })); + await service.createChat(session, fork, { fork: { source: chat, turnId: 'missing-turn' } }); + + assert.deepStrictEqual({ + mismatch, + forkProvider: chatSummary(service, session, fork)?.provider, + createdInSecond: createdIn(second), + createdInCopilot: createdIn(copilot).filter(candidate => candidate !== getTestAgentStateManager(service).getSessionState(session.toString())?.defaultChat), + }, { + mismatch: 'InvalidParams', + forkProvider: 'second', + createdInSecond: [chat.toString(), fork.toString()], + createdInCopilot: [], + }); + }); + + test('a chat with another agent starts with that agent\'s defaults and its own schema, apart from the session\'s config', async () => { + const { service } = createService(true); + const { session, chat } = await createSessionWithSecondChat(service); + const configuration = getTestAgentServiceComposition(service).configurationService; + const stateManager = getTestAgentStateManager(service); + const defaultChat = stateManager.getSessionState(session.toString())!.defaultChat!; + + configuration.updateSessionConfig(session.toString(), { mode: 'ask' }); + configuration.updateChatConfig({ session: session.toString(), chat: chat.toString() }, { mode: 'plan', isolationHint: 'ignored' }); + + assert.deepStrictEqual({ + chatConfig: stateManager.getChatState(chat.toString())?.config, + ownSchema: stateManager.getChatOwnConfigSchema(chat.toString()), + chatValues: configuration.getChatConfigValues({ session: session.toString(), chat: chat.toString() }), + sessionMode: configuration.getSessionConfigValues(session.toString())?.mode, + defaultChatMode: configuration.getChatConfigValues({ session: session.toString(), chat: defaultChat })?.mode, + }, { + chatConfig: { schema: { type: 'object', properties: { mode: { type: 'string', title: 'Mode', enum: ['agent', 'plan', 'ask'], default: 'agent', chatMutable: true, sessionMutable: true } } }, values: { mode: 'plan' } }, + ownSchema: { type: 'object', properties: { mode: { type: 'string', title: 'Mode', enum: ['agent', 'plan', 'ask'], default: 'agent', chatMutable: true, sessionMutable: true } } }, + chatValues: { mode: 'plan' }, + sessionMode: 'ask', + defaultChatMode: 'ask', + }); + }); + + test('a chat on another agent publishes its agent\'s schema with complete values, and clients can set only that schema\'s chat-settable keys', async () => { + const { service } = createService(true); + const { chat } = await createSessionWithSecondChat(service); + const stateManager = getTestAgentStateManager(service); + service.dispatchAction(chat.toString(), { type: ActionType.ChatConfigChanged, config: { isolationHint: 'client' } }, 'test-client', 1); + service.dispatchAction(chat.toString(), { type: ActionType.ChatConfigChanged, config: { mode: 'ask' } }, 'test-client', 2); + await timeout(0); + + assert.deepStrictEqual(stateManager.getChatState(chat.toString())?.config, { + schema: { type: 'object', properties: { mode: { type: 'string', title: 'Mode', enum: ['agent', 'plan', 'ask'], default: 'agent', chatMutable: true, sessionMutable: true } } }, + values: { mode: 'ask' }, + }); + }); + + test('a chat on the session\'s agent has complete values and no schema, and a later session change does not change it', async () => { + const { service } = createService(true); + const session = await service.createSession({ legacyChatUris: true, provider: 'copilot', workingDirectories: [URI.file('/repo')] }); + const chat = URI.parse(buildChatUri(session, 'peer')); + await service.createChat(session, chat); + const configuration = getTestAgentServiceComposition(service).configurationService; + const stateManager = getTestAgentStateManager(service); + const before = stateManager.getChatState(chat.toString())?.config; + + configuration.updateSessionConfig(session.toString(), { mode: 'ask', isolationHint: 'changed' }); + // Only what a chat may set can be changed for it. + service.dispatchAction(chat.toString(), { type: ActionType.ChatConfigChanged, config: { isolationHint: 'client' } }, 'test-client', 1); + await timeout(0); + + assert.deepStrictEqual({ + before, + after: stateManager.getChatState(chat.toString())?.config, + effective: configuration.getChatConfigValues({ session: session.toString(), chat: chat.toString() }), + }, { + before: { values: { mode: 'plan' } }, + after: { values: { mode: 'plan' } }, + // A property a chat cannot set stays session-wide. + effective: { mode: 'plan', isolationHint: 'changed' }, + }); + }); + + test('an older build\'s overlay row of a chat on the session\'s agent is restored as complete values', async () => { + const { service } = createService(true); + const session = await service.createSession({ legacyChatUris: true, provider: 'copilot', workingDirectories: [URI.file('/repo')] }); + const chat = URI.parse(buildChatUri(session, 'peer')); + await service.createChat(session, chat); + const configuration = getTestAgentServiceComposition(service).configurationService; + configuration.updateSessionConfig(session.toString(), { mode: 'ask' }); + await timeout(0); + // An older build stored only what the chat set for itself. + await getTestSessionStateStore(service).chatRows.setChatConfig(session, chat, {}); + + getTestAgentStateManager(service).deleteSession(session.toString()); + await service.restoreSession(session); + await timeout(0); + + assert.deepStrictEqual({ + config: getTestAgentStateManager(service).getChatConfig(chat.toString()), + stored: await getTestSessionStateStore(service).chatRows.getChatConfig(session, chat), + }, { + config: { values: { mode: 'ask' } }, + stored: { mode: 'ask' }, + }); + }); + + test('with chats with their own agents off, a peer\'s values stay an overlay: it follows its session\'s mode, and nothing is completed, dispatched or written', async () => { + const { service } = createService(false); + const session = await service.createSession({ legacyChatUris: true, provider: 'copilot', workingDirectories: [URI.file('/repo')] }); + const chat = URI.parse(buildChatUri(session, 'peer')); + await service.createChat(session, chat); + await timeout(0); + // A peer that set nothing itself, as an older build stored it. + await getTestSessionStateStore(service).chatRows.setChatConfig(session, chat, {}); + const stateManager = getTestAgentStateManager(service); + stateManager.deleteSession(session.toString()); + const dispatched: string[] = []; + const listener = stateManager.onDidEmitEnvelope(envelope => { + if (envelope.action.type === ActionType.ChatConfigChanged) { + dispatched.push(envelope.channel); + } + }); + try { + await service.restoreSession(session); + await timeout(0); + getTestAgentServiceComposition(service).configurationService.updateSessionConfig(session.toString(), { mode: 'ask' }); + await timeout(0); + } finally { + listener.dispose(); + } + + assert.deepStrictEqual({ + effective: getTestAgentServiceComposition(service).configurationService.getChatConfigValues({ session: session.toString(), chat: chat.toString() })?.mode, + published: stateManager.getChatConfig(chat.toString()), + stored: await getTestSessionStateStore(service).chatRows.getChatConfig(session, chat), + dispatched, + }, { effective: 'ask', published: { values: {} }, stored: {}, dispatched: [] }); + }); + + test('turning chats with their own agents on completes a loaded peer\'s overlay; turning them off reads its complete values as an overlay again', async () => { + const { service } = createService(false); + const session = await service.createSession({ legacyChatUris: true, provider: 'copilot', workingDirectories: [URI.file('/repo')] }); + const chat = URI.parse(buildChatUri(session, 'peer')); + await service.createChat(session, chat); + await timeout(0); + await getTestSessionStateStore(service).chatRows.setChatConfig(session, chat, {}); + const stateManager = getTestAgentStateManager(service); + stateManager.deleteSession(session.toString()); + await service.restoreSession(session); + await timeout(0); + const configuration = getTestAgentServiceComposition(service).configurationService; + configuration.updateSessionConfig(session.toString(), { mode: 'ask' }); + const defaultChat = stateManager.getSessionState(session.toString())!.defaultChat!; + const read = async () => ({ + defaultChat: stateManager.getChatConfig(defaultChat)?.values, + published: stateManager.getChatConfig(chat.toString())?.values, + effective: configuration.getChatConfigValues({ session: session.toString(), chat: chat.toString() })?.mode, + stored: await getTestSessionStateStore(service).chatRows.getChatConfig(session, chat), + }); + + configuration.updateRootConfig({ [ChatProvidersConfigKey.Enabled]: true }); + await timeout(0); + await getTestSessionStateStore(service).whenIdle(undefined); + const on = await read(); + configuration.updateSessionConfig(session.toString(), { mode: 'agent' }); + const onAfterSessionChange = (await read()).effective; + configuration.updateRootConfig({ [ChatProvidersConfigKey.Enabled]: false }); + await timeout(0); + const off = await read(); + + assert.deepStrictEqual({ on, onAfterSessionChange, off }, { + // Completed from its session's values, and persisted so. + // The default chat publishes what it takes from its session. + on: { defaultChat: { mode: 'ask' }, published: { mode: 'ask' }, effective: 'ask', stored: { mode: 'ask' } }, + // Its values are its complete config: a later session change doesn't reach it. + onAfterSessionChange: 'ask', + // Read as an overlay again, its complete values still decide every property it may set. + // The default chat publishes only its own values again, so it follows its session. + off: { defaultChat: {}, published: { mode: 'ask' }, effective: 'ask', stored: { mode: 'ask' } }, + }); + }); + + test('turning chats with their own agents off republishes only the default chat\'s own values, without a write or chat events, and it follows its session again', async () => { + const { service } = createService(false); + const session = await service.createSession({ legacyChatUris: true, provider: 'copilot', workingDirectories: [URI.file('/repo')] }); + await timeout(0); + const stateManager = getTestAgentStateManager(service); + const configuration = getTestAgentServiceComposition(service).configurationService; + const defaultChat = stateManager.getSessionState(session.toString())!.defaultChat!; + configuration.updateRootConfig({ [ChatProvidersConfigKey.Enabled]: true }); + configuration.updateSessionConfig(session.toString(), { mode: 'ask' }); + await timeout(0); + await getTestSessionStateStore(service).whenIdle(undefined); + const storedBefore = await getTestSessionStateStore(service).chatRows.getChatConfig(session, URI.parse(defaultChat)); + const published: unknown[] = []; + let chatEvents = 0; + const listeners = [ + stateManager.onDidEmitEnvelope(envelope => { + if (envelope.channel === defaultChat && envelope.action.type === ActionType.ChatConfigChanged) { + published.push(envelope.action.config); + } + }), + configuration.onDidChatConfigChange(() => chatEvents++), + ]; + let off: Record | undefined; + try { + configuration.updateRootConfig({ [ChatProvidersConfigKey.Enabled]: false }); + await timeout(0); + await getTestSessionStateStore(service).whenIdle(undefined); + off = stateManager.getChatConfig(defaultChat)?.values; + } finally { + listeners.forEach(listener => listener.dispose()); + } + configuration.updateSessionConfig(session.toString(), { mode: 'agent' }); + await timeout(0); + + assert.deepStrictEqual({ + off, + published, + chatEvents, + stored: await getTestSessionStateStore(service).chatRows.getChatConfig(session, URI.parse(defaultChat)), + followsSession: configuration.getChatConfigValues({ session: session.toString(), chat: defaultChat })?.mode, + }, { + // It set nothing itself: what it took from its session is no longer published as its own. + off: {}, + // Republished once, with only its own values; no chat config change is announced. + published: [{}], + chatEvents: 0, + stored: storedBefore, + followsSession: 'agent', + }); + }); + + test('a restored session routes a chat with its own agent to that agent with its own config', async () => { + const { service, second } = createService(true); + const { session, chat } = await createSessionWithSecondChat(service); + getTestAgentServiceComposition(service).configurationService.updateChatConfig({ session: session.toString(), chat: chat.toString() }, { mode: 'ask' }); + await timeout(0); + // A key the agent's schema no longer has is dropped on restore. + await getTestSessionStateStore(service).chatRows.setChatConfig(session, chat, { mode: 'ask', removedKey: true }); + + getTestAgentStateManager(service).deleteSession(session.toString()); + await service.restoreSession(session); + const stateManager = getTestAgentStateManager(service); + + assert.deepStrictEqual({ + summaryProvider: chatSummary(service, session, chat)?.provider, + chatAgent: getTestAgentHostProviderService(service).getProviderForChat(session, chat)?.id, + config: stateManager.getChatConfig(chat.toString()), + hasOwnSchema: stateManager.getChatOwnConfigSchema(chat.toString()) !== undefined, + listed: (await service.listSessions()).find(candidate => candidate.session.toString() === session.toString())?.chats?.map(candidate => ({ kind: candidate.kind, provider: candidate.provider })), + materializedIn: createdIn(second), + }, { + summaryProvider: 'second', + chatAgent: 'second', + config: { schema: { type: 'object', properties: { mode: { type: 'string', title: 'Mode', enum: ['agent', 'plan', 'ask'], default: 'agent', chatMutable: true, sessionMutable: true } } }, values: { mode: 'ask' } }, + hasOwnSchema: true, + listed: [{ kind: 'default', provider: undefined }, { kind: 'peer', provider: 'second' }], + materializedIn: [chat.toString()], + }); + }); + + suite('a turn\'s model', () => { + const modelInfo = (provider: string, id: string) => ({ provider, id, name: id, maxContextWindow: 1000, supportsVision: false }); + + /** Starts a turn and resolves once `agent` was sent its message. */ + function sendTurn(service: AgentService, agent: MockAgent, chat: URI, model: string): Promise { + const sent = Event.toPromise(agent.onDidSendMessage); + service.dispatchAction(chat.toString(), { type: ActionType.ChatTurnStarted, turnId: `turn-${model}`, startedAt: new Date().toISOString(), message: { text: 'Hello', origin: { kind: MessageKind.User }, model: { id: model } } }, 'test-client', 1); + return sent; + } + + test('a chat on another agent keeps its model for a model only its session\'s agent offers, and takes one its agent offers', async () => { + const { service, copilot, second } = createService(true); + copilot.setModels([modelInfo('copilot', 'copilot-model')]); + second.setModels([modelInfo('second', 'second-model')]); + const { chat } = await createSessionWithSecondChat(service); + + await sendTurn(service, second, chat, 'copilot-model'); + const ignored = { changed: second.changeModelCalls.map(call => call.model.id), sent: second.sendMessageCalls.length }; + second.sendMessageCalls.length = 0; + getTestAgentStateManager(service).dispatchServerAction(chat.toString(), { type: ActionType.ChatTurnComplete, turnId: 'turn-copilot-model', duration: 1 }); + await sendTurn(service, second, chat, 'second-model'); + + assert.deepStrictEqual({ ignored, applied: second.changeModelCalls.map(call => call.model.id), copilot: copilot.changeModelCalls.length }, { + ignored: { changed: [], sent: 1 }, + applied: ['second-model'], + copilot: 0, + }); + }); + + test('a chat on its session\'s agent takes any model, as before', async () => { + const { service, copilot } = createService(true); + copilot.setModels([modelInfo('copilot', 'copilot-model')]); + const session = await service.createSession({ legacyChatUris: true, provider: 'copilot', workingDirectories: [URI.file('/repo')] }); + const chat = URI.parse(buildChatUri(session, 'peer')); + await service.createChat(session, chat); + + await sendTurn(service, copilot, chat, 'unknown-model'); + + assert.deepStrictEqual(copilot.changeModelCalls.map(call => call.model.id), ['unknown-model']); + }); + }); + + test('disposing a session disposes its chats in their own agents', async () => { + const { service, copilot, second } = createService(true); + const { session, chat } = await createSessionWithSecondChat(service); + + await service.disposeSession(session); + + assert.deepStrictEqual({ second: second.disposedChats, copilotPeers: copilot.disposedChats.filter(candidate => candidate === chat.toString()) }, { second: [chat.toString()], copilotPeers: [] }); + }); + + test('a chat is routed to its own agent and sees its own config while the agent creates it', async () => { + const { service, second } = createService(true); + const session = await service.createSession({ legacyChatUris: true, provider: 'copilot', workingDirectories: [URI.file('/repo')] }); + const chat = URI.parse(buildChatUri(session, 'creating')); + let seen: { readonly registered: boolean; readonly agent: string | undefined; readonly mode: unknown } | undefined; + second.onCreateChat = created => { + seen = { + registered: getTestAgentStateManager(service).getChatOwner(created.toString()) !== undefined, + agent: getTestAgentHostProviderService(service).getProviderForChat(session, created)?.id, + mode: getTestAgentServiceComposition(service).configurationService.getChatConfigValues({ session: session.toString(), chat: created.toString() })?.mode, + }; + }; + await service.createChat(session, chat, { provider: 'second' }); + + assert.deepStrictEqual(seen, { registered: false, agent: 'second', mode: 'agent' }); + }); + + test('a chat whose own agent is unavailable is never routed to the session\'s agent', async () => { + const { service } = createService(true); + const { session, chat } = await createSessionWithSecondChat(service); + const providers = getTestAgentHostProviderService(service); + providers.associateChat(chat, 'unregistered'); + + assert.deepStrictEqual(providers.getProviderForChat(session, chat)?.id, undefined); + }); + + test('a subagent of a chat with its own agent runs in that agent and records it with its row', async () => { + const { service, second } = createService(true); + const { session, chat } = await createSessionWithSecondChat(service); + const subagent = URI.parse(buildChatUri(session, 'subagent')); + second.spawnEmitter.fire({ session, chat: subagent, parent: { chat, toolCallId: 'tool-1' }, title: 'Worker' }); + await timeout(10); + const recorded = await getTestAgentHostDatabase(service).readChatAgentProviders?.(); + + assert.deepStrictEqual({ + stateProvider: getTestAgentStateManager(service).getChatProvider(subagent.toString()), + agent: getTestAgentHostProviderService(service).getProviderForChat(session, subagent)?.id, + recorded: recorded?.get(subagent.toString()), + }, { stateProvider: 'second', agent: 'second', recorded: 'second' }); + }); + + test('the catalog payloads never carry a chat\'s agent, which older builds read as canonical, and listing and restore still route the chat', async () => { + const { service } = createService(true); + const { session, chat } = await createSessionWithSecondChat(service); + await timeout(10); + const database = getTestAgentHostDatabase(service); + const row = await database.getSessionV2(session.toString()); + const decoded = row && decodeAgentHostCatalogPayload(row.payload); + const [snapshot] = await database.readCatalogSnapshot([session.toString()]); + + getTestAgentStateManager(service).deleteSession(session.toString()); + await service.restoreSession(session); + + assert.deepStrictEqual({ + payloadMentionsProvider: row?.payload.includes('"provider"'), + // The release/1.143 validators are these unchanged: a payload equal to its canonical re-encoding is accepted as is. + canonical: decoded?.ok ? decoded.value.payload === row?.payload : decoded?.error, + chatMetadata: snapshot?.chats.find(candidate => candidate.chat === chat.toString())?.metadata, + recorded: (await database.readChatAgentProviders?.())?.get(chat.toString()), + listed: (await service.listSessions()).find(candidate => candidate.session.toString() === session.toString())?.chats?.find(candidate => candidate.chat.toString() === chat.toString())?.provider, + restoredAgent: getTestAgentHostProviderService(service).getProviderForChat(session, chat)?.id, + }, { + payloadMentionsProvider: false, + canonical: true, + chatMetadata: { interactivity: 'full' }, + recorded: 'second', + listed: 'second', + restoredAgent: 'second', + }); + }); + + test('deleting a chat or its session forgets the chat\'s agent', async () => { + const { service } = createService(true); + const { session, chat } = await createSessionWithSecondChat(service); + const other = URI.parse(buildChatUri(session, 'other')); + await service.createChat(session, other, { provider: 'second' }); + const database = getTestAgentHostDatabase(service); + const before = [...(await database.readChatAgentProviders?.())?.keys() ?? []]; + + await service.disposeChat(session, other); + const afterChat = [...(await database.readChatAgentProviders?.())?.keys() ?? []]; + await service.disposeSession(session); + const afterSession = [...(await database.readChatAgentProviders?.())?.keys() ?? []]; + + assert.deepStrictEqual({ before, afterChat, afterSession }, { + before: [chat.toString(), other.toString()].sort(), + afterChat: [chat.toString()], + afterSession: [], + }); + }); + + test('deleting a chat forgets the agents of its subagents too', async () => { + const { service, second } = createService(true); + const { session, chat } = await createSessionWithSecondChat(service); + const subagent = URI.parse(buildChatUri(session, 'subagent')); + second.spawnEmitter.fire({ session, chat: subagent, parent: { chat, toolCallId: 'tool-1' }, title: 'Worker' }); + await timeout(10); + const database = getTestAgentHostDatabase(service); + const before = [...(await database.readChatAgentProviders?.())?.keys() ?? []].sort(); + + await service.disposeChat(session, chat); + + assert.deepStrictEqual({ before, after: [...(await database.readChatAgentProviders?.())?.keys() ?? []] }, { + before: [chat.toString(), subagent.toString()].sort(), + after: [], + }); + }); + + test('when the agents of chats can\'t be read, a restarted host neither restores nor disposes a chat with its own agent under the session\'s agent', async () => { + const sessionDatabase = new TestSessionDatabase(); + const first = createService(true, { sessionDatabase }); + const { session, chat } = await createSessionWithSecondChat(first.service); + await timeout(10); + + const restarted = createService(true, { sessionDatabase, catalogDatabase: first.catalogDatabase }); + restarted.copilot.knowsAllSessions = true; + restarted.second.knowsAllSessions = true; + const read = first.catalogDatabase.readChatAgentProviders; + first.catalogDatabase.readChatAgentProviders = () => Promise.reject(new Error('unreadable')); + try { + await restarted.service.restoreSession(session); + const restoredChats = getTestAgentStateManager(restarted.service).getSessionState(session.toString())?.chats.map(candidate => candidate.resource); + getTestAgentStateManager(restarted.service).deleteSession(session.toString()); + await restarted.service.disposeSession(session); + + assert.deepStrictEqual({ + restoredPeer: restoredChats?.includes(chat.toString()), + disposedBySecond: restarted.second.disposedChats, + disposedByCopilot: restarted.copilot.disposedChats.includes(chat.toString()), + }, { restoredPeer: false, disposedBySecond: [], disposedByCopilot: false }); + } finally { + first.catalogDatabase.readChatAgentProviders = read; + } + }); + + test('a peer chat skipped because the agents of chats can\'t be read is never disposed through the session\'s agent while its session stays loaded', async () => { + const sessionDatabase = new TestSessionDatabase(); + const first = createService(true, { sessionDatabase }); + const { session, chat } = await createSessionWithSecondChat(first.service); + await timeout(10); + + const restarted = createService(true, { sessionDatabase, catalogDatabase: first.catalogDatabase }); + restarted.copilot.knowsAllSessions = true; + restarted.second.knowsAllSessions = true; + const read = first.catalogDatabase.readChatAgentProviders; + first.catalogDatabase.readChatAgentProviders = () => Promise.reject(new Error('unreadable')); + try { + await restarted.service.restoreSession(session); + // The session stays loaded with the peer skipped; disposing it must not hand the peer to the session's agent. + await restarted.service.disposeSession(session); + + assert.deepStrictEqual({ + registered: getTestAgentStateManager(restarted.service).isChatRegistered(chat.toString()), + disposedByCopilot: restarted.copilot.disposedChats.includes(chat.toString()), + copilotContexts: restarted.copilot.chatContexts.filter(entry => entry.chat.toString() === chat.toString()).map(entry => entry.boundary), + }, { registered: false, disposedByCopilot: false, copilotContexts: [] }); + } finally { + first.catalogDatabase.readChatAgentProviders = read; + } + }); + + test('a chat with its own agent whose backing must be recreated after a failed workspace change gets it from its own agent, with its own config', async () => { + const { service, copilot, second } = createService(true); + const { session, chat } = await createSessionWithSecondChat(service); + getTestAgentServiceComposition(service).configurationService.updateChatConfig({ session: session.toString(), chat: chat.toString() }, { mode: 'plan' }); + const configs: unknown[] = []; + const createChat = second.chats.createChat; + second.chats.createChat = (candidate, context, options) => { + configs.push(options?.config); + return createChat(candidate, context, options); + }; + const internals = service as unknown as { _chatsNeedingBacking: Set; _recreatePendingChatBacking(session: string, chat: string, credentials: undefined): Promise }; + internals._chatsNeedingBacking.add(chat.toString()); + + await internals._recreatePendingChatBacking(session.toString(), chat.toString(), undefined); + + assert.deepStrictEqual({ second: createdIn(second), copilotCreatedChat: createdIn(copilot).includes(chat.toString()), configs }, { + second: [chat.toString(), chat.toString()], + copilotCreatedChat: false, + configs: [{ mode: 'plan' }], + }); + }); + + test('a host seeded from its starter\'s environment allows chats with their own agents before any client mirrors the setting', async () => { + const { service } = createService(false); + const before = service.multipleChatProvidersEnabled; + seedMultipleChatProvidersFromEnvironment(getTestAgentServiceComposition(service).configurationService, { [AgentHostMultipleChatProvidersEnabledEnvVar]: 'true' }); + const session = await service.createSession({ legacyChatUris: true, provider: 'copilot', workingDirectories: [URI.file('/repo')] }); + assert.deepStrictEqual({ before, after: service.multipleChatProvidersEnabled, created: await rejection(service.createChat(session, URI.parse(buildChatUri(session, 'second')), { provider: 'second' })) }, { before: false, after: true, created: undefined }); + }); + + test('a fork of a restored, unloaded chat of another session keeps that chat\'s agent; with chats with their own agents off it uses its new session\'s agent', async () => { + const { service, copilot } = createService(true); + const { session, chat } = await createSessionWithSecondChat(service); + const native = URI.parse(buildChatUri(session, 'native')); + await service.createChat(session, native); + // A chat on its own session's agent, 'second', which differs from the fork's new session's. + const secondSession = await service.createSession({ legacyChatUris: true, provider: 'second', workingDirectories: [URI.file('/repo')] }); + const secondNative = URI.parse(buildChatUri(secondSession, 'second-native')); + await service.createChat(secondSession, secondNative); + await timeout(10); + const stateManager = getTestAgentStateManager(service); + stateManager.deleteSession(session.toString()); + await service.restoreSession(session); + stateManager.deleteSession(secondSession.toString()); + await service.restoreSession(secondSession); + const target = await service.createSession({ legacyChatUris: true, provider: 'copilot', workingDirectories: [URI.file('/repo')] }); + const resolver = service as unknown as { _resolveCreateChatProvider(session: URI, sessionProvider: IAgent, options: { fork: { source: URI; turnId: string } }): IAgent }; + const forkAgent = (source: URI) => resolver._resolveCreateChatProvider(target, copilot, { fork: { source, turnId: 't' } }).id; + const on = { unloaded: !stateManager.getChatState(chat.toString()) && !stateManager.getChatState(secondNative.toString()), ownAgent: forkAgent(chat), sessionAgent: forkAgent(native), otherSessionAgent: forkAgent(secondNative) }; + getTestAgentServiceComposition(service).configurationService.updateRootConfig({ [ChatProvidersConfigKey.Enabled]: false }); + const off = { ownAgent: forkAgent(chat), sessionAgent: forkAgent(native), otherSessionAgent: forkAgent(secondNative), unknown: forkAgent(URI.parse(buildChatUri(session, 'unknown'))) }; + + assert.deepStrictEqual({ on, off }, { + // A fork keeps its source chat's agent: its own, else its session's ('second' for a chat of a 'second' session). + on: { unloaded: true, ownAgent: 'second', sessionAgent: 'copilot', otherSessionAgent: 'second' }, + // A chat that already uses its own agent keeps it; any other fork uses its new session's agent, as before. + off: { ownAgent: 'second', sessionAgent: 'copilot', otherSessionAgent: 'copilot', unknown: 'copilot' }, + }); + }); + + test('a stale record naming the session\'s own agent is ignored and deleted on restore: the chat follows its session', async () => { + const { service, catalogDatabase } = createService(true); + const session = await service.createSession({ legacyChatUris: true, provider: 'copilot', workingDirectories: [URI.file('/repo')] }); + const chat = URI.parse(buildChatUri(session, 'peer')); + await service.createChat(session, chat); + await timeout(0); + await catalogDatabase.recordChatAgentProvider!(chat.toString(), 'copilot'); + (service as unknown as { _chatAgentProviders: unknown })._chatAgentProviders = undefined; + + getTestAgentStateManager(service).deleteSession(session.toString()); + await service.restoreSession(session); + await timeout(0); + + assert.deepStrictEqual({ + provider: chatSummary(service, session, chat)?.provider, + ownSchema: getTestAgentStateManager(service).getChatOwnConfigSchema(chat.toString()), + recorded: (await catalogDatabase.readChatAgentProviders?.())?.has(chat.toString()), + }, { provider: undefined, ownSchema: undefined, recorded: false }); + }); + + test('a chat with its own agent hands its own agent and model to chats it creates, and lists the agents chats may use', async () => { + const { service } = createService(true); + const { session, chat } = await createSessionWithSecondChat(service); + getTestAgentStateManager(service).dispatchServerAction(chat.toString(), { type: ActionType.ChatDraftChanged, draft: { text: '', origin: { kind: MessageKind.User }, model: { id: 'second-model' } } }); + const tools = service as unknown as { + _getServerToolCreationDefaults(source: URI): { readonly provider?: string; readonly sessionProvider?: string; readonly model?: { readonly id: string } } | undefined; + _getServerToolChatHarnesses(session: URI): readonly string[] | undefined; + }; + const defaults = tools._getServerToolCreationDefaults(chat); + const harnessesOn = tools._getServerToolChatHarnesses(session); + getTestAgentServiceComposition(service).configurationService.updateRootConfig({ [ChatProvidersConfigKey.Enabled]: false }); + + assert.deepStrictEqual({ + provider: defaults?.provider, + sessionProvider: defaults?.sessionProvider, + model: defaults?.model?.id, + harnessesOn, + harnessesOff: tools._getServerToolChatHarnesses(session), + }, { provider: 'second', sessionProvider: 'copilot', model: 'second-model', harnessesOn: ['copilot', 'second'], harnessesOff: undefined }); + }); + + test('a chat with another agent and no own config hands on none of the session\'s config', async () => { + const { service } = createService(true); + const session = await service.createSession({ legacyChatUris: true, provider: 'copilot' }); + const stateManager = getTestAgentStateManager(service); + stateManager.setSessionConfig(session.toString(), { schema: { type: 'object', properties: {} }, values: { mode: 'ask' } }); + // As a restored chat whose own config is not resolved yet: registered on its agent without a config. + const chat = URI.parse(buildChatUri(session, 'unresolved')); + stateManager.addChat(session.toString(), chat.toString(), chat.toString(), { provider: 'second' }); + const configs: Record[] = []; + const second = getTestAgentHostProviderService(service).getProvider('second')!; + second.getInheritedChatConfig = values => { configs.push({ ...values }); return values; }; + const tools = service as unknown as { _getServerToolCreationDefaults(source: URI): { readonly config?: Record } | undefined }; + const config = tools._getServerToolCreationDefaults(chat)?.config; + assert.deepStrictEqual({ handedToAgent: configs, config }, { handedToAgent: [{}], config: {} }); + }); + + test('an agent turned off on the host is neither offered nor accepted for a chat', async () => { + const { service } = createService(true); + const codex = disposables.add(new ChatAgent('codex', 'agent')); + registerTestAgentProvider(service, codex); + const session = await service.createSession({ legacyChatUris: true, provider: 'copilot' }); + getTestAgentServiceComposition(service).configurationService.updateRootConfig({ [AgentHostCodexEnabledConfigKey]: false }); + const tools = service as unknown as { _getServerToolChatHarnesses(session: URI): readonly string[] | undefined }; + + assert.deepStrictEqual({ + offered: tools._getServerToolChatHarnesses(session), + created: await rejection(service.createChat(session, URI.parse(buildChatUri(session, 'codex-chat')), { provider: 'codex' })), + }, { offered: ['copilot', 'second'], created: 'InvalidParams' }); + }); + + test('a chat with its own agent keeps it when moved to a session of another agent of the same kind', async () => { + const { service, second } = createService(true); + getTestAgentServiceComposition(service).configurationService.updateRootConfig({ [ChatMoveConfigKey.Enabled]: true }); + const { session: source, chat } = await createSessionWithSecondChat(service); + const copilotTarget = await service.createSession({ legacyChatUris: true, provider: 'copilot', workingDirectories: [URI.file('/repo')] }); + const subagent = URI.parse(buildChatUri(source, 'subagent')); + second.spawnEmitter.fire({ session: source, chat: subagent, parent: { chat, toolCallId: 'tool-1' }, title: 'Worker' }); + await timeout(10); + startTurn(service, chat); + + const announced: unknown[] = []; + const listener = getTestAgentStateManager(service).onDidEmitEnvelope(envelope => { + if (envelope.action.type === ActionType.ChatMoved) { + announced.push({ channel: envelope.channel, action: envelope.action }); + } + }); + await service.moveChat(source, chat, { kind: 'session', session: copilotTarget, after: undefined }); + listener.dispose(); + const ownSchema = getTestAgentStateManager(service).getChatOwnConfigSchema(chat.toString()); + + assert.deepStrictEqual({ + movedProvider: chatSummary(service, copilotTarget, chat)?.provider, + chatAgent: getTestAgentHostProviderService(service).getProviderForChat(copilotTarget, chat)?.id, + configSchema: ownSchema !== undefined, + announced, + chatState: { provider: getTestAgentStateManager(service).getChatState(chat.toString())?.provider, schema: getTestAgentStateManager(service).getChatState(chat.toString())?.config?.schema === ownSchema }, + }, { + movedProvider: 'second', + chatAgent: 'second', + configSchema: true, + // Its subscribers learn its new owner, and that it keeps its own agent and schema there. + // Its subagent moves with it; it has no config schema of its own. + announced: [ + { channel: chat.toString(), action: { type: ActionType.ChatMoved, session: copilotTarget.toString(), provider: 'second', schema: ownSchema } }, + { channel: subagent.toString(), action: { type: ActionType.ChatMoved, session: copilotTarget.toString(), provider: 'second' } }, + ], + chatState: { provider: 'second', schema: true }, + }); + }); + + suite('moves across agents', () => { + const modeSchema = { type: 'object' as const, properties: { mode: { type: 'string' as const, title: 'Mode', enum: ['agent', 'plan', 'ask'], chatMutable: true, sessionMutable: true } } }; + + function createMoveService(enabled: boolean, options?: { readonly secondSupportsChats?: boolean }) { + const created = createService(enabled, options); + getTestAgentServiceComposition(created.service).configurationService.updateRootConfig({ [ChatMoveConfigKey.Enabled]: true }); + return created; + } + + async function createSessionOf(service: AgentService, provider: string, mode: string): Promise { + const session = await service.createSession({ legacyChatUris: true, provider, workingDirectories: [URI.file('/repo')] }); + getTestAgentStateManager(service).setSessionConfig(session.toString(), { schema: modeSchema, values: { mode } }); + return session; + } + + /** The `chat/moved` actions dispatched while `run` runs. */ + async function chatMovedDuring(service: AgentService, run: () => Promise): Promise { + const announced: unknown[] = []; + const listener = getTestAgentStateManager(service).onDidEmitEnvelope(envelope => { + if (envelope.action.type === ActionType.ChatMoved) { + announced.push({ channel: envelope.channel, action: envelope.action }); + } + }); + try { + await run(); + } finally { + listener.dispose(); + } + return announced; + } + + async function refusal(promise: Promise): Promise { + return promise.then(() => 'moved', (error: { reason?: string }) => error.reason ?? String(error)); + } + + test('a chat that joins a session of another agent gains its own agent: its schema, valid values and recorded agent, with its subagent, also after a restart', async () => { + const { service, copilot } = createMoveService(true); + const source = await createSessionOf(service, 'copilot', 'agent'); + const target = await createSessionOf(service, 'second', 'plan'); + const chat = URI.parse(buildChatUri(source, 'native')); + await service.createChat(source, chat); + await timeout(0); + const configuration = getTestAgentServiceComposition(service).configurationService; + configuration.updateChatConfig({ session: source.toString(), chat: chat.toString() }, { mode: 'ask' }); + const subagent = URI.parse(buildChatUri(source, 'native-subagent')); + copilot.spawnEmitter.fire({ session: source, chat: subagent, parent: { chat, toolCallId: 'tool-1' }, title: 'Worker' }); + await timeout(10); + startTurn(service, chat); + const stateManager = getTestAgentStateManager(service); + + const announced = await chatMovedDuring(service, () => service.moveChat(source, chat, { kind: 'session', session: target, after: undefined })); + const ownSchema = stateManager.getChatOwnConfigSchema(chat.toString()); + const moved = { + announced, + ownSchema: ownSchema?.properties && Object.keys(ownSchema.properties), + config: stateManager.getChatState(chat.toString())?.config?.values, + agents: [chat, subagent].map(candidate => getTestAgentHostProviderService(service).getProviderForChat(target, candidate)?.id), + recorded: [chat, subagent].map(async candidate => (await getTestAgentHostDatabase(service).readChatAgentProviders?.())?.get(candidate.toString())), + stored: await getTestSessionStateStore(service).chatRows.getChatConfig(source, chat), + }; + const recorded = await Promise.all(moved.recorded); + stateManager.deleteSession(target.toString()); + await service.restoreSession(target); + + assert.deepStrictEqual({ ...moved, recorded, restored: { provider: chatSummary(service, target, chat)?.provider, hasOwnSchema: stateManager.getChatOwnConfigSchema(chat.toString()) !== undefined } }, { + announced: [ + { channel: chat.toString(), action: { type: ActionType.ChatMoved, session: target.toString(), provider: 'copilot', schema: ownSchema } }, + { channel: subagent.toString(), action: { type: ActionType.ChatMoved, session: target.toString(), provider: 'copilot' } }, + ], + ownSchema: ['mode'], + config: { mode: 'ask' }, + agents: ['copilot', 'copilot'], + recorded: ['copilot', 'copilot'], + stored: { mode: 'ask' }, + restored: { provider: 'copilot', hasOwnSchema: true }, + }); + }); + + test('a chat with its own agent that joins a session of its agent loses it: no schema or recorded agent, and its valid values completed from its new session', async () => { + const { service } = createMoveService(true); + const { session: source, chat } = await createSessionWithSecondChat(service); + getTestAgentServiceComposition(service).configurationService.updateChatConfig({ session: source.toString(), chat: chat.toString() }, { mode: 'ask' }); + const target = await createSessionOf(service, 'second', 'plan'); + await timeout(0); + startTurn(service, chat); + const stateManager = getTestAgentStateManager(service); + + const announced = await chatMovedDuring(service, () => service.moveChat(source, chat, { kind: 'session', session: target, after: undefined })); + await timeout(0); + + assert.deepStrictEqual({ + announced, + summaryProvider: chatSummary(service, target, chat)?.provider, + ownSchema: stateManager.getChatOwnConfigSchema(chat.toString()), + config: stateManager.getChatState(chat.toString())?.config, + agent: getTestAgentHostProviderService(service).getProviderForChat(target, chat)?.id, + recorded: (await getTestAgentHostDatabase(service).readChatAgentProviders?.())?.has(chat.toString()), + }, { + announced: [{ channel: chat.toString(), action: { type: ActionType.ChatMoved, session: target.toString() } }], + summaryProvider: undefined, + ownSchema: undefined, + config: { values: { mode: 'ask' } }, + agent: 'second', + recorded: false, + }); + }); + + test('a chat with its own agent becomes a session of its agent, with that agent\'s config and its own values', async () => { + const { service, second } = createMoveService(true); + // Its agent records a conversation of its own for it, which a new session needs. + const createChat = second.chats.createChat; + second.chats.createChat = async (chat, context, options) => ({ ...(await createChat(chat, context, options)), providerData: 'second-backing' }); + const { session: source, chat } = await createSessionWithSecondChat(service); + getTestAgentServiceComposition(service).configurationService.updateChatConfig({ session: source.toString(), chat: chat.toString() }, { mode: 'ask' }); + startTurn(service, chat); + const stateManager = getTestAgentStateManager(service); + + let promoted: URI | undefined; + const announced = await chatMovedDuring(service, async () => { promoted = await service.moveChat(source, chat, { kind: 'newSession' }); }); + + assert.deepStrictEqual({ + announced, + sessionAgent: getTestAgentHostProviderService(service).getProviderForSession(promoted!)?.id, + defaultChat: stateManager.getSessionState(promoted!.toString())?.defaultChat, + sessionMode: stateManager.getSessionState(promoted!.toString())?.config?.values.mode, + ownSchema: stateManager.getChatOwnConfigSchema(chat.toString()), + recorded: (await getTestAgentHostDatabase(service).readChatAgentProviders?.())?.has(chat.toString()), + }, { + announced: [{ channel: chat.toString(), action: { type: ActionType.ChatMoved, session: promoted!.toString() } }], + sessionAgent: 'second', + defaultChat: chat.toString(), + sessionMode: 'ask', + ownSchema: undefined, + recorded: false, + }); + }); + + test('a session moved under a session of another agent: its default chat gains its own agent', async () => { + const { service } = createMoveService(true); + const source = await createSessionOf(service, 'copilot', 'agent'); + const target = await createSessionOf(service, 'second', 'plan'); + const defaultChat = getTestAgentStateManager(service).getSessionState(source.toString())!.defaultChat!; + startTurn(service, defaultChat); + + const announced = await chatMovedDuring(service, () => service.moveSession(source, target, undefined)); + + assert.deepStrictEqual({ + announced: announced.map(entry => (entry as { action: { provider?: string } }).action.provider), + summaryProvider: chatSummary(service, target, URI.parse(defaultChat))?.provider, + agent: getTestAgentHostProviderService(service).getProviderForChat(target, defaultChat)?.id, + hasOwnSchema: getTestAgentStateManager(service).getChatOwnConfigSchema(defaultChat) !== undefined, + }, { announced: ['copilot'], summaryProvider: 'copilot', agent: 'copilot', hasOwnSchema: true }); + }); + + test('moves across agents are refused while chats can\'t use their own agents, or when the destination\'s agent can\'t host other agents\' chats', async () => { + const off = createMoveService(false); + const offSource = await createSessionOf(off.service, 'copilot', 'agent'); + const offTarget = await createSessionOf(off.service, 'second', 'plan'); + const offChat = URI.parse(buildChatUri(offSource, 'native')); + await off.service.createChat(offSource, offChat); + const noChats = createMoveService(true, { secondSupportsChats: false }); + const noChatsSource = await createSessionOf(noChats.service, 'copilot', 'agent'); + const noChatsTarget = await createSessionOf(noChats.service, 'second', 'plan'); + const noChatsChat = URI.parse(buildChatUri(noChatsSource, 'native')); + await noChats.service.createChat(noChatsSource, noChatsChat); + await timeout(0); + startTurn(off.service, offChat); + startTurn(noChats.service, noChatsChat); + const noConfig = createMoveService(true); + const noConfigSource = await createSessionOf(noConfig.service, 'copilot', 'agent'); + const noConfigTarget = await createSessionOf(noConfig.service, 'second', 'plan'); + const noConfigChat = URI.parse(buildChatUri(noConfigSource, 'native')); + await noConfig.service.createChat(noConfigSource, noConfigChat); + await timeout(0); + startTurn(noConfig.service, noConfigChat); + noConfig.copilot.resolveChatConfig = async () => { throw new Error('offline'); }; + + assert.deepStrictEqual({ + gateOff: await refusal(off.service.moveChat(offSource, offChat, { kind: 'session', session: offTarget, after: undefined })), + noMultipleChats: await refusal(noChats.service.moveChat(noChatsSource, noChatsChat, { kind: 'session', session: noChatsTarget, after: undefined })), + noConfig: await refusal(noConfig.service.moveChat(noConfigSource, noConfigChat, { kind: 'session', session: noConfigTarget, after: undefined })), + }, { gateOff: 'differentProvider', noMultipleChats: 'agentWithoutChats', noConfig: 'agentConfigUnavailable' }); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index b94b59b6a88e0..0e607d6b0779a 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -6047,6 +6047,8 @@ suite('AgentSideEffects', () => { schema: { type: 'object', properties: {} }, values: { autoApprove: 'autoApprove' }, }); } + // The chat publishes its config once registered with its session's; that is not the approval's. + await timeout(0); disposables.add(sideEffects.registerProgressListener(agent)); const response = new DeferredPromise(); agent.respondToPermissionRequest = (_requestId, approved) => { void response.complete(approved); }; diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 916952f4d030f..a5426269861cf 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -1637,6 +1637,9 @@ async function createAgentSession(disposables: DisposableStore, options?: { getChatOwnConfigValues: target => target.chat === chatChannelUri.toString() ? { ...options?.chatConfigValues } : {}, updateChatConfig: (target, patch) => { chatConfigUpdates.push({ chat: target.chat, patch }); }, seedChatConfig: () => ({ values: {}, dispose: () => { } }), + completeChatConfigs: () => { }, + getChatPersistedConfigValues: () => undefined, + getCompleteChatConfigValues: (_session: string, own: Readonly>) => ({ ...own }), getEffectiveWorkingDirectories: () => undefined, // Own values only, like the real service; `inheritedConfigValues` is // visible through `getEffectiveValue` alone so tests can prove a diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts index 5080f53da0a71..3ec2ce7b5bad1 100644 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts @@ -146,6 +146,9 @@ suite('CopilotShellTools', () => { getChatOwnConfigValues: () => ({}), updateChatConfig: (target, config) => service.updateSessionConfig(target.session, config), seedChatConfig: () => ({ values: {}, dispose: () => { } }), + completeChatConfigs: () => { }, + getChatPersistedConfigValues: () => undefined, + getCompleteChatConfigValues: (_session: string, own: Readonly>) => ({ ...own }), getEffectiveWorkingDirectories: () => undefined, getSessionConfigValues: session => sessionValues.get(session), resolveConfigurationOwner: session => session, diff --git a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md index f3ca0ee9ea148..4dcda766eddcf 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -1104,3 +1104,11 @@ The entire Claude or Codex suite also skips when its bundled SDK package is unav 2. Reevaluate broad provider gates one title at a time and check whether a capture exists. 3. Re-record narrowly after SDK/CLI behavior changes and review every generated artifact. 4. Remove fixed gates, entries, comments, and orphaned captures together. + +### Chats with different agents in one session are not covered for Codex + +A session whose default chat runs in Codex and which also has a chat in the Copilot agent sends model requests in two wire formats (Responses and Anthropic). A capture holds a single `dialect`, so this mix cannot be replayed. + +- Tests: `chats of one session run turns in their own agent`, `a fork stays on its source chat's agent and rejects another provider` and `chats keep their own agent across a host restart` in `suites/multipleChatProvidersSuite.ts`. +- Scope: the Codex parity run. They run for Claude, whose session and the Copilot agent both use the Anthropic dialect. +- Gate: the suite registers them for `claude` only. diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-a-fork-stays-on-its-source-chat-s-agent-and-rejects-another-provider.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-a-fork-stays-on-its-source-chat-s-agent-and-rejects-another-provider.yaml new file mode 100644 index 0000000000000..db252464bd812 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-a-fork-stays-on-its-source-chat-s-agent-and-rejects-another-provider.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "gamma". + response: + content: gamma + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-chats-keep-their-own-agent-across-a-host-restart.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-chats-keep-their-own-agent-across-a-host-restart.yaml new file mode 100644 index 0000000000000..67fbf35b7b571 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-chats-keep-their-own-agent-across-a-host-restart.yaml @@ -0,0 +1,47 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Reply exactly "alpha". + response: + content: alpha + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "beta". + response: + content: beta + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Reply exactly "alpha". + - role: assistant + content: alpha + - role: user + content: Reply exactly "delta". + response: + content: delta + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "beta". + - role: assistant + content: beta + - role: user + content: Reply exactly "epsilon". + response: + content: epsilon + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-chats-of-one-session-run-turns-in-their-own-agent.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-chats-of-one-session-run-turns-in-their-own-agent.yaml new file mode 100644 index 0000000000000..aa37f086d61f2 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-chats-of-one-session-run-turns-in-their-own-agent.yaml @@ -0,0 +1,21 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Reply exactly "alpha". + response: + content: alpha + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "beta". + response: + content: beta + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts b/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts index 610045f277b3d..21bb4a139fad2 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts @@ -18,6 +18,7 @@ import { defineSessionPersistenceTests } from './sessionPersistenceSuite.js'; import { defineFileOperationsTests } from './fileOperationsSuite.js'; import { defineHostFeaturesTests } from './hostFeaturesSuite.js'; import { defineMultiChatTests } from './multiChatSuite.js'; +import { defineMultipleChatProvidersTests } from './multipleChatProvidersSuite.js'; import { defineChatMoveTests } from './chatMoveSuite.js'; import { defineMcpPluginTests } from './mcpPluginSuite.js'; import { defineMcpSideChannelTests } from './mcpSideChannelSuite.js'; @@ -190,6 +191,7 @@ function defineSuite(config: IAgentHostE2EProviderConfig, options: IDefineOption // peer turns and capability advertisement are provider-dependent // (parity). The registrars self-select on `context.tier`. defineMultiChatTests(context); + defineMultipleChatProvidersTests(context); defineChatMoveTests(context); defineChangesetTests(context); defineMcpPluginTests(context); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/multipleChatProvidersSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/multipleChatProvidersSuite.ts new file mode 100644 index 0000000000000..54a51d5b3a7ab --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/suites/multipleChatProvidersSuite.ts @@ -0,0 +1,282 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { tmpdir } from 'os'; +import { join } from '../../../../../../base/common/path.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../../../common/agent.js'; +import { ChatProvidersConfigKey } from '../../../../common/agentHostChatProviders.js'; +import { ActionType, type StateAction } from '../../../../common/state/sessionActions.js'; +import { ChatSourceKind, type InitializeResult, type ListSessionsResult, type SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; +import { MessageKind, ROOT_STATE_URI, type ChatState, type RootState, type SessionState } from '../../../../common/state/sessionState.js'; +import { JsonRpcErrorCodes, ProtocolError } from '../../../../common/state/sessionProtocol.js'; +import { newTestChatUri } from '../../hostDefaultChats.js'; +import { defaultChatChannel, getActionEnvelope, isActionNotification, type TestProtocolClient } from '../../serverIntegrationTestHelpers.js'; +import { createRealSession, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; +import { createTestDirectory, ownCodexProtectedDirectories } from '../harness/testDirectories.js'; +import { conformanceTest, type IAgentHostE2ETestContext } from './e2eTestContext.js'; + +const SECOND_AGENT = 'copilotcli'; + +/** + * Chats of one session backed by different agents (`multipleChatProviders`). + * + * The advertisement and the rejections that need no second agent are + * conformance scenarios against the reference host. Scenarios that run a turn in + * chats of two agents are parity scenarios of the Claude and Codex runs, which + * register the Copilot agent next to their own; they need no mock agent. They run + * for Claude only: a capture holds one wire dialect and Codex speaks Responses + * while the Copilot agent speaks Anthropic (see KNOWN_ISSUES.md). The Copilot run + * registers one agent only, so it has nothing to mix. + */ +export function defineMultipleChatProvidersTests(context: IAgentHostE2ETestContext): void { + const { config, createdSessions, tempDirs } = context; + let nextClientSeq = 30_000; + + function workspace(prefix: string): URI { + const directory = createTestDirectory(join(tmpdir(), `ahp-chat-providers-${prefix}-`)); + tempDirs.push(directory); + ownCodexProtectedDirectories(directory, config.provider, context.isLinux); + return URI.file(directory); + } + + async function dispatchAndWait(channel: string, action: StateAction): Promise { + const clientSeq = nextClientSeq++; + context.client.dispatch({ channel, clientSeq, action }); + await context.client.waitForNotification(n => + isActionNotification(n, action.type) + && getActionEnvelope(n).channel === channel + && getActionEnvelope(n).origin?.clientSeq === clientSeq, + 30_000, + ); + } + + async function setGate(enabled: boolean): Promise { + const root = await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + if ((root.snapshot!.state as RootState).config?.values?.[ChatProvidersConfigKey.Enabled] === enabled) { + return; + } + await dispatchAndWait(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, config: { [ChatProvidersConfigKey.Enabled]: enabled } }); + } + + /** Runs `run` with the gate on; the shared host is left with it off again. */ + async function withGate(run: () => Promise): Promise { + await setGate(true); + try { + await run(); + } finally { + await setGate(false); + } + } + + async function initializeResult(clientId: string): Promise { + const client = await context.connectClient(); + try { + return await client.call('initialize', { channel: ROOT_STATE_URI, protocolVersions: [PROTOCOL_VERSION], clientId }); + } finally { + client.close(); + } + } + + async function createSession(prefix: string): Promise { + return createRealSession(context.client, config, `${prefix}-${config.provider}`, createdSessions, workspace(prefix)); + } + + async function sessionState(sessionUri: string): Promise { + const result = await context.client.call('subscribe', { channel: sessionUri }); + return result.snapshot!.state as SessionState; + } + + async function chatState(chatUri: string): Promise { + const result = await context.client.call('subscribe', { channel: chatUri }); + return result.snapshot!.state as ChatState; + } + + async function createChat(sessionUri: string, extra: Record): Promise { + const chat = newTestChatUri(); + await context.client.call('createChat', { channel: sessionUri, chat, ...extra }, 30_000); + return chat; + } + + async function rejection(run: () => Promise): Promise<{ readonly code: number | undefined; readonly message: string }> { + try { + await run(); + } catch (error) { + return { code: error instanceof ProtocolError ? error.code : undefined, message: error instanceof Error ? error.message : String(error) }; + } + return { code: undefined, message: 'accepted' }; + } + + /** Runs one turn to completion in `chatUri` and returns its markdown response. */ + async function runTurn(chatUri: string, turnId: string, text: string): Promise { + await chatState(chatUri); + context.client.clearReceived(); + context.client.dispatch({ + channel: chatUri, + clientSeq: nextClientSeq++, + action: { type: ActionType.ChatTurnStarted, turnId, startedAt: new Date().toISOString(), message: { text, origin: { kind: MessageKind.User } } }, + }); + const done = await context.client.waitForNotification(n => + (isActionNotification(n, 'chat/turnComplete') || isActionNotification(n, 'chat/error')) + && getActionEnvelope(n).channel === chatUri + && (getActionEnvelope(n).action as { turnId: string }).turnId === turnId, + 90_000, + ); + assert.ok(isActionNotification(done, 'chat/turnComplete'), `turn ${turnId} failed: ${JSON.stringify(getActionEnvelope(done).action)}`); + const state = await chatState(chatUri); + const turn = state.turns.find(candidate => candidate.id === turnId); + return JSON.stringify(turn?.responseParts ?? []); + } + + function parityTest(title: string, run: Mocha.AsyncFunc): void { + // A fixture has one wire dialect: only Claude shares the Copilot agent's Anthropic dialect. + if (context.tier !== 'parity' || config.provider !== 'claude') { + return; + } + test(title, function () { + this.timeout(240_000); + return run.call(this); + }); + } + + conformanceTest(context, 'initialize advertises multipleChatProviders only behind the setting', async function () { + await createSession('providers-advertise'); + const off = await initializeResult('chat-providers-off'); + await setGate(true); + let on: InitializeResult; + try { + on = await initializeResult('chat-providers-on'); + } finally { + await setGate(false); + } + assert.deepStrictEqual({ + version: PROTOCOL_VERSION, + off: Object.hasOwn(off, 'multipleChatProviders'), + on: on.multipleChatProviders, + }, { version: '1.1.0', off: false, on: {} }); + }); + + conformanceTest(context, 'createChat rejects a provider while the setting is off', async function () { + const sessionUri = await createSession('providers-off'); + const result = await rejection(() => createChat(sessionUri, { provider: SECOND_AGENT })); + const state = await sessionState(sessionUri); + assert.deepStrictEqual({ code: result.code, chats: state.chats.length }, { code: JsonRpcErrorCodes.InvalidParams, chats: 1 }); + }); + + conformanceTest(context, 'createChat rejects an unknown provider', async function () { + const sessionUri = await createSession('providers-unknown'); + await withGate(async () => { + const result = await rejection(() => createChat(sessionUri, { provider: 'no-such-agent' })); + assert.deepStrictEqual({ code: result.code, message: result.message }, { code: JsonRpcErrorCodes.InvalidParams, message: 'Agent no-such-agent is unknown or disabled.' }); + }); + }); + + conformanceTest(context, 'createChat with the session\'s own provider has no chat config schema of its own', async function () { + const sessionUri = await createSession('providers-same'); + await withGate(async () => { + const chat = await createChat(sessionUri, { provider: config.provider }); + const state = await chatState(chat); + // Only a chat on another agent than its session's carries a schema of its own. + assert.strictEqual(Object.hasOwn(state.config ?? {}, 'schema'), false); + }); + }); + + parityTest('chats of one session run turns in their own agent', async function () { + const sessionUri = await createSession('providers-turns'); + await withGate(async () => { + const defaultChat = defaultChatChannel(sessionUri); + const peer = await createChat(sessionUri, { provider: SECOND_AGENT }); + + const session = await sessionState(sessionUri); + const summary = session.chats.find(chat => chat.resource === peer); + const peerState = await chatState(peer); + const defaultState = await chatState(defaultChat); + assert.deepStrictEqual({ + summaryProvider: summary?.provider, + peerProvider: peerState.provider, + peerHasSchema: Object.hasOwn(peerState.config ?? {}, 'schema'), + defaultProvider: defaultState.provider ?? config.provider, + defaultHasSchema: Object.hasOwn(defaultState.config ?? {}, 'schema'), + }, { + summaryProvider: SECOND_AGENT, + peerProvider: SECOND_AGENT, + peerHasSchema: true, + defaultProvider: config.provider, + defaultHasSchema: false, + }); + + const defaultReply = await runTurn(defaultChat, 'turn-default', 'Reply exactly "alpha".'); + const defaultBodies = context.observedModelRequestBodies.length; + const peerReply = await runTurn(peer, 'turn-peer', 'Reply exactly "beta".'); + const bodies = context.observedModelRequestBodies; + assert.ok(defaultReply.includes('alpha'), defaultReply); + assert.ok(peerReply.includes('beta'), peerReply); + // Each agent talks to the model in its own wire format. + const shape = (body: string) => Object.keys(JSON.parse(body) as object).sort().join(','); + const defaultShapes = new Set(bodies.slice(0, defaultBodies).map(shape)); + const peerShapes = new Set(bodies.slice(defaultBodies).map(shape)); + assert.ok([...peerShapes].some(candidate => !defaultShapes.has(candidate)), `peer chat requests look like the default chat's: ${[...peerShapes]}`); + }); + }); + + parityTest('a fork stays on its source chat\'s agent and rejects another provider', async function () { + const sessionUri = await createSession('providers-fork'); + await withGate(async () => { + const peer = await createChat(sessionUri, { provider: SECOND_AGENT }); + await runTurn(peer, 'turn-source', 'Reply exactly "gamma".'); + + const source = { kind: ChatSourceKind.Fork, chat: peer, turnId: 'turn-source' }; + const mismatch = await rejection(() => createChat(sessionUri, { source, provider: config.provider })); + const fork = await createChat(sessionUri, { source }); + const session = await sessionState(sessionUri); + const forkState = await chatState(fork); + assert.deepStrictEqual({ + mismatch: mismatch.code, + summaryProvider: session.chats.find(chat => chat.resource === fork)?.provider, + forkProvider: forkState.provider, + }, { mismatch: JsonRpcErrorCodes.InvalidParams, summaryProvider: SECOND_AGENT, forkProvider: SECOND_AGENT }); + }); + }); + + parityTest('chats keep their own agent across a host restart', async function () { + const sessionUri = await createSession('providers-restart'); + const workingDirectory = workspace('providers-restart-client'); + await setGate(true); + const defaultChat = defaultChatChannel(sessionUri); + const peer = await createChat(sessionUri, { provider: SECOND_AGENT }); + await runTurn(defaultChat, 'turn-default', 'Reply exactly "alpha".'); + await runTurn(peer, 'turn-peer', 'Reply exactly "beta".'); + + await context.restartServer(); + const client: TestProtocolClient = context.client; + client.setWorkingDirectory(workingDirectory.fsPath); + await client.call('initialize', { channel: ROOT_STATE_URI, protocolVersions: [PROTOCOL_VERSION], clientId: `providers-restart-${config.provider}` }, 30_000); + await client.call('authenticate', { + channel: ROOT_STATE_URI, + resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, + token: config.githubToken ?? resolveGitHubToken(), + }, 30_000); + await setGate(true); + + // Listed before any chat is hydrated. + const listed = await client.call('listSessions', {}); + const entry = listed.items.find(item => item.resource === sessionUri); + const session = await sessionState(sessionUri); + assert.deepStrictEqual({ + listed: entry?.chats?.map(chat => chat.provider ?? config.provider).sort(), + state: session.chats.map(chat => chat.provider ?? config.provider).sort(), + }, { + listed: [config.provider, SECOND_AGENT].sort(), + state: [config.provider, SECOND_AGENT].sort(), + }); + + const defaultReply = await runTurn(defaultChat, 'turn-default-2', 'Reply exactly "delta".'); + const peerReply = await runTurn(peer, 'turn-peer-2', 'Reply exactly "epsilon".'); + assert.ok(defaultReply.includes('delta') && peerReply.includes('epsilon'), `${defaultReply} ${peerReply}`); + await setGate(false); + }); +} diff --git a/src/vs/platform/agentHost/test/node/nodeAgentHostStarter.test.ts b/src/vs/platform/agentHost/test/node/nodeAgentHostStarter.test.ts index 6b7b045801172..cab6eee76e2eb 100644 --- a/src/vs/platform/agentHost/test/node/nodeAgentHostStarter.test.ts +++ b/src/vs/platform/agentHost/test/node/nodeAgentHostStarter.test.ts @@ -18,7 +18,8 @@ import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.j import { createCopilotCliEnvironment } from '../../node/copilot/copilotCliEnvironment.js'; import { NodeAgentHostStarter } from '../../node/nodeAgentHostStarter.js'; import { AgentHostAhpJsonlLoggingEnvVar } from '../../common/ahpJsonlLogger.js'; -import { AgentHostAhpJsonlLoggingSettingId } from '../../common/agentService.js'; +import { AgentHostAhpJsonlLoggingSettingId, AgentHostMultipleChatProvidersEnabledEnvVar } from '../../common/agentService.js'; +import { ChatProvidersSettingId } from '../../common/agentHostChatProviders.js'; class TestNodeAgentHostStarter extends NodeAgentHostStarter { readonly environments: IProcessEnvironment[] = []; @@ -59,6 +60,15 @@ suite('NodeAgentHostStarter', () => { assert.deepStrictEqual(starter.environments.map(env => env[AgentHostAhpJsonlLoggingEnvVar]), ['true', 'false']); }); + test('forwards chats with their own agents to the host only while the setting is on', async () => { + const configuration = new TestConfigurationService({ [ChatProvidersSettingId.Enabled]: true }); + const starter = createStarter(configuration); + disposables.add((await starter.start()).store); + await configuration.setUserConfiguration(ChatProvidersSettingId.Enabled, false); + disposables.add((await starter.start()).store); + assert.deepStrictEqual(starter.environments.map(env => env[AgentHostMultipleChatProvidersEnabledEnvVar]), ['true', undefined]); + }); + test('applies resolver overrides without replacing launcher-owned environment values', async () => { const starter = createStarter(); starter.setWebSocketConfig({ port: '12345', connectionToken: 'launcher-connection-token' }); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index ac6adcbd8e5da..dc503a26954c4 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -62,6 +62,7 @@ import type { InvokeChangesetOperationParams } from '../../common/state/protocol import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../../common/state/protocol/channels-automation/commands.js'; import { ActionType, type ActionEnvelope, type ChatAction, type ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, type ClientChangesetAction, type IRootConfigChangedAction, type ProgressParams, type SessionAction, type TerminalAction } from '../../common/state/sessionActions.js'; import { rootReducer } from '../../common/state/sessionReducers.js'; +import { getAgentHostClientProtocolVersions } from '../../common/agentHostProtocolCompatibility.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, JsonRpcErrorCodes, ProtocolError, AhpErrorCodes, AHP_UNSUPPORTED_PROTOCOL_VERSION, AHP_SESSION_NOT_FOUND, AHP_AUTH_REQUIRED, type AhpNotification, type InitializeResult, type ProtocolMessage, type JsonRpcResponse, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot, type SubscribeResult } from '../../common/state/sessionProtocol.js'; import { ROOT_STATE_URI, AUTOMATION_CATALOG_URI, ChatInteractivity, ChatOriginKind, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, readSessionExternal, readSessionWorkspaceless, withSessionExternal, withSessionWorkspaceless, type AgentInfo, type ChangesetState, type ChatState, type Message, type RootState, type SessionState, type SessionSummary, type Turn } from '../../common/state/sessionState.js'; @@ -339,6 +340,7 @@ class MockAgentService extends Disposable implements IAgentService { async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return { items: [] }; } async completions(_params: CompletionsParams): Promise { return { items: [] }; } automationCapabilities: AutomationCapabilities | undefined; + multipleChatProvidersEnabled = false; async listAutomationTriggerDefinitions(_params: ListAutomationTriggerDefinitionsParams): Promise { return { items: [] }; } async runAutomation(params: RunAutomationParams): Promise { this.automationRunRequests.push(params); @@ -8812,6 +8814,106 @@ suite('ProtocolServerHandler', () => { suite('createChat / disposeChat', () => { const peerChat = buildChatUri(sessionUri, 'peer-1'); + test('chat/moved is server-only, and reaches only clients from protocol 1.1.0', async () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady }); + const connect = async (clientId: string, protocolVersions: readonly string[]) => { + const transport = disposables.add(new MockProtocolTransport()); + server.simulateConnection(transport); + const response = waitForResponse(transport, 1); + transport.simulateMessage(request(1, 'initialize', { protocolVersions, clientId, initialSubscriptions: [sessionUri, defaultChatUri] })); + await response; + transport.sent.length = 0; + return transport; + }; + const current = await connect('moved-current', [PROTOCOL_VERSION]); + const older = await connect('moved-older', ['1.0.0']); + const hostActions: ActionType[] = []; + disposables.add(stateManager.onDidEmitEnvelope(envelope => hostActions.push(envelope.action.type))); + + current.simulateMessage(notification('dispatchAction', { channel: defaultChatUri, clientSeq: 1, action: { type: ActionType.ChatMoved, session: sessionUri } })); + await timeout(0); + const rejected = findNotifications(current.sent, 'action').at(-1)?.params as ActionEnvelope | undefined; + current.sent.length = 0; + older.sent.length = 0; + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatMoved, session: sessionUri, provider: 'other' }); + const received = (transport: MockProtocolTransport) => findNotifications(transport.sent, 'action').map(message => (message.params as ActionEnvelope).action.type); + + assert.deepStrictEqual({ + rejection: { action: rejected?.action.type, reason: rejected?.rejectionReason }, + hostActions, + current: received(current), + older: received(older), + }, { + rejection: { action: ActionType.ChatMoved, reason: `Server-only action: ${ActionType.ChatMoved}` }, + hostActions: [ActionType.ChatMoved], + current: [ActionType.ChatMoved], + older: [], + }); + }); + + test('advertises multipleChatProviders only behind the setting and from protocol 1.1.0', () => { + const capability = (clientId: string, protocolVersions: readonly string[]) => { + const transport = disposables.add(new MockProtocolTransport()); + server.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { protocolVersions, clientId })); + const resp = findResponse(transport.sent, 1); + const result = resp && hasKey(resp, { result: true }) ? resp.result as InitializeResult : undefined; + return { advertised: result?.multipleChatProviders, hasKey: !!result && Object.hasOwn(result, 'multipleChatProviders') }; + }; + const off = capability('providers-off', [PROTOCOL_VERSION]); + agentService.multipleChatProvidersEnabled = true; + assert.deepStrictEqual({ + off, + on: capability('providers-on', [PROTOCOL_VERSION]), + older: capability('providers-older', ['1.0.0']), + }, { + off: { advertised: undefined, hasKey: false }, + on: { advertised: {}, hasKey: true }, + older: { advertised: undefined, hasKey: false }, + }); + }); + + test('the offer a client makes advertises multipleChatProviders only with the client setting on (regression: the default offer never reached 1.1.0)', () => { + agentService.multipleChatProvidersEnabled = true; + const advertised = (clientId: string, clientSetting: boolean) => { + const transport = disposables.add(new MockProtocolTransport()); + server.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { protocolVersions: getAgentHostClientProtocolVersions(clientSetting), clientId })); + const resp = findResponse(transport.sent, 1); + const result = resp && hasKey(resp, { result: true }) ? resp.result as InitializeResult : undefined; + return { negotiated: result?.protocolVersion, multipleChatProviders: result?.multipleChatProviders }; + }; + assert.deepStrictEqual({ off: advertised('offer-off', false), on: advertised('offer-on', true) }, { + off: { negotiated: '1.0.0', multipleChatProviders: undefined }, + on: { negotiated: PROTOCOL_VERSION, multipleChatProviders: {} }, + }); + }); + + test('createChat rejects a provider unless multipleChatProviders is advertised, and forwards it when it is', async () => { + stateManager.createSession(makeSessionSummary()); + const off = connectClient('client-provider-off'); + off.sent.length = 0; + const rejected = waitForResponse(off, 2); + off.simulateMessage(request(2, 'createChat', { channel: sessionUri, chat: peerChat, provider: 'codex' })); + const rejectedResponse = await rejected; + + agentService.multipleChatProvidersEnabled = true; + const on = connectClient('client-provider-on'); + on.sent.length = 0; + const accepted = waitForResponse(on, 2); + on.simulateMessage(request(2, 'createChat', { channel: sessionUri, chat: peerChat, provider: 'codex' })); + await accepted; + + assert.deepStrictEqual({ + rejectedCode: hasKey(rejectedResponse as JsonRpcResponse, { error: true }) ? (rejectedResponse as { error: { code: number } }).error.code : undefined, + created: agentService.createdChats.map(created => created.options), + }, { + rejectedCode: JsonRpcErrorCodes.InvalidParams, + created: [{ provider: 'codex' }], + }); + }); + test('createChat on the default chat URI is a no-op', async () => { stateManager.createSession(makeSessionSummary()); const transport = connectClient('client-cc'); diff --git a/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts b/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts index 2a1f0c1e9abfe..cbdc0873a8bd3 100644 --- a/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionSandbox.test.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { timeout } from '../../../../base/common/async.js'; +import { ChatProvidersConfigKey } from '../../common/agentHostChatProviders.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; @@ -912,6 +914,29 @@ suite('Session sandbox configuration', () => { }); }); + test('with chats with their own agents on, the session\'s "Disable sandboxing" replaces the default chat\'s own selection, not a peer\'s, and the default chat follows a managed floor', async () => { + const { manager, configuration, owner, peer, defaultTarget, peerTarget } = setupChats({ sandboxEnabled: 'on' }); + configuration.updateRootConfig({ [ChatProvidersConfigKey.Enabled]: true }); + await timeout(0); + manager.dispatchClientAction(defaultTarget.chat, { type: ActionType.ChatConfigChanged, config: { sandboxEnabled: 'on' } }, { clientId: 'client', clientSeq: 1 }); + manager.dispatchClientAction(peer, { type: ActionType.ChatConfigChanged, config: { sandboxEnabled: 'on' } }, { clientId: 'client', clientSeq: 2 }); + manager.dispatchClientAction(owner, { type: ActionType.SessionConfigChanged, config: { sandboxEnabled: 'off' } }, { clientId: 'client', clientSeq: 3 }); + const read = () => ({ + enabled: [defaultTarget, peerTarget].map(target => getSessionSandboxConfig(configuration, target).enabled), + published: manager.getChatConfig(defaultTarget.chat)?.values.sandboxEnabled, + defaultOwn: configuration.getChatPersistedConfigValues(defaultTarget.chat)?.sandboxEnabled, + peerOwn: configuration.getChatOwnConfigValues(peerTarget).sandboxEnabled, + }); + const disabled = read(); + // A managed floor without bypass rolls the session's off back; the default chat follows it. + configuration.setSessionSandboxPolicy(owner, { enabled: true }); + assert.deepStrictEqual({ disabled, floored: read(), session: configuration.getSessionConfigValues(owner)?.sandboxEnabled }, { + disabled: { enabled: ['off', 'on'], published: 'off', defaultOwn: undefined, peerOwn: 'on' }, + floored: { enabled: ['on', 'on'], published: 'on', defaultOwn: undefined, peerOwn: 'on' }, + session: 'on', + }); + }); + test('a client\'s own off for a chat survives policy republishing while the policy allows bypass, and not its revocation', () => { const { manager, configuration, owner, peer, peerTarget } = setupChats({ sandboxEnabled: 'on' }); configuration.setSessionSandboxPolicy(owner, { enabled: true, allowBypass: true }); diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index 27d228a3b7b2f..c40b0d1d35515 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -88,6 +88,8 @@ suite('SessionServerTools', () => { isWorkspaceless: overrides?.isWorkspaceless ?? (() => true), canConvertWorkspace: overrides?.canConvertWorkspace ?? (() => true), supportsChatWorkingDirectories: overrides?.supportsChatWorkingDirectories ?? (() => true), + getChatHarnesses: overrides?.getChatHarnesses ?? (() => undefined), + canCreateSessions: overrides?.canCreateSessions ?? (() => true), listSessions: overrides?.listSessions ?? (async () => [sessionMeta('s1', SessionStatus.InProgress, workspace)]), getSession: overrides?.getSession ?? (async session => session.toString() === 'copilot:/s1' ? sessionMeta('s1', SessionStatus.InProgress, workspace) : undefined), getWorktreeRoots: overrides?.getWorktreeRoots ?? (async () => []), @@ -2291,6 +2293,160 @@ suite('SessionServerTools', () => { store.dispose(); }); + suite('harness', () => { + const claudeModel: IAgentModelInfo = { provider: 'claude', id: 'claude-opus', name: 'Claude Opus', supportsVision: false }; + + async function create(args: Record, options: { harnesses?: readonly string[]; defaults?: ReturnType } = {}) { + let created: { chat: URI; options?: Parameters[2] } | undefined; + const accessor = createAccessor({ + getModels: () => [model, claudeModel], + getChatHarnesses: () => options.harnesses, + getCreationDefaults: () => options.defaults, + onCreateChat: (_session, chat, chatOptions) => { created = { chat, options: chatOptions }; }, + }); + await applyCreateSessionTool(accessor, { prompt: 'review it', title: 'Review', ...args }, URI.parse(buildDefaultChatUri('copilot:/s1'))); + return created?.options; + } + + async function rejection(args: Record, harnesses?: readonly string[]): Promise { + try { + await create(args, { harnesses }); + return 'created'; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } + + test('a chat of a Claude chat in a Copilot session gets no Claude model on the session\'s agent', async () => { + const options = await create({ relationship: 'currentSession' }, { defaults: { provider: 'claude', sessionProvider: 'copilot', model: { id: 'claude-opus' } } }); + assert.deepStrictEqual({ provider: options?.provider, model: options?.model }, { provider: undefined, model: undefined }); + }); + + test('creates the chat on the requested harness with its own model', async () => { + const defaults = { provider: 'copilot', sessionProvider: 'copilot', model: { id: 'gpt-4o' } }; + assert.deepStrictEqual({ + claude: await create({ harness: 'claude' }, { harnesses: ['copilot', 'claude'], defaults }), + claudeModel: (await create({ harness: 'claude', model: 'Claude Opus' }, { harnesses: ['copilot', 'claude'], defaults }))?.model, + copilot: (await create({ harness: 'copilot' }, { harnesses: ['copilot', 'claude'], defaults }))?.provider, + }, { + claude: { provider: 'claude', title: 'Review', model: undefined }, + claudeModel: { id: 'claude-opus' }, + copilot: undefined, + }); + }); + + test('rejects an unavailable harness, a harness while chats can\'t use another agent, and an independent harness', async () => { + assert.deepStrictEqual({ + unknown: await rejection({ harness: 'codex' }, ['copilot', 'claude']), + gateOff: await rejection({ harness: 'claude' }, undefined), + independent: await rejection({ relationship: 'independent', harness: 'claude' }, ['copilot', 'claude']), + foreignModel: await rejection({ harness: 'claude', model: 'gpt-4o' }, ['copilot', 'claude']), + }, { + unknown: 'Invalid create_session input: harness "codex" is not available. Use one of: copilot, claude, or omit harness to use the session\'s own agent.', + gateOff: 'Invalid create_session input: chats in this session can\'t use another harness because multiple chat providers is off, or this session\'s agent doesn\'t support multiple chats. Omit harness to use the session\'s own agent.', + independent: 'Invalid create_session input: harness is only valid with relationship "currentSession". Omit harness, or create the chat in the current session.', + foreignModel: 'Invalid create_session input: model must match an available model id or name for provider "claude".', + }); + }); + + test('offers harness in the create_session schema only while chats of the session may use another agent', () => { + const stateManager = new AgentHostStateManager(new NullLogService()); + const session = 'copilot:/s1'; + stateManager.createSession({ resource: session, provider: 'copilot', title: 'Session', status: SessionStatus.Idle, createdAt: new Date(0).toISOString(), modifiedAt: new Date(0).toISOString(), defaultChat: buildDefaultChatUri(session) }); + const gate: { harnesses?: readonly string[] } = {}; + const host = new AgentServerToolHost(stateManager, [createSessionServerToolGroup(createAccessor({ getChatHarnesses: () => gate.harnesses }))]); + const definition = () => host.getDefinitionsForSession(session).find(tool => tool.name === SessionServerToolName.CreateSession)!; + const off = definition(); + gate.harnesses = ['copilotcli', 'claude']; + const on = definition(); + const { harness, model: onModel, ...otherProperties } = on.inputSchema?.properties ?? {}; + const { model: offModel, ...offProperties } = off.inputSchema?.properties ?? {}; + assert.deepStrictEqual({ + offHarness: off.inputSchema?.properties?.harness, + onOtherwiseUnchanged: { ...on.inputSchema, properties: otherProperties }, + model: [offModel, onModel], + harness, + addedDescription: on.description?.slice(off.description?.length), + }, { + offHarness: undefined, + onOtherwiseUnchanged: { ...off.inputSchema, properties: offProperties }, + model: [ + { type: 'string', description: 'Optional model ID or display name. Defaults to the current chat\'s model. For `currentSession`, the model must belong to the current session\'s provider; for `independent`, the model selects the new session\'s provider.' }, + { type: 'string', description: 'Optional model ID or display name. Defaults to the current chat\'s model when the new chat uses the same agent. For `currentSession`, the model must belong to the current session\'s provider; for `independent`, the model selects the new session\'s provider.' }, + ], + harness: { type: 'string', description: 'Optional harness (agent) for the new chat: one of `copilot`, `claude`. Only valid with relationship `currentSession`. Defaults to the current session\'s harness. A chat on another harness starts with that harness\'s defaults; `model`, when given, must belong to it.' }, + addedDescription: ' With `currentSession`, set `harness` to create the chat on another agent, for example to have a different agent review the work.', + }); + stateManager.dispose(); + }); + + test('with chats unable to use another agent, the tool definitions read as before multiple agents (gate-off parity)', () => { + const stateManager = new AgentHostStateManager(new NullLogService()); + const session = 'copilot:/s1'; + stateManager.createSession({ resource: session, provider: 'copilot', title: 'Session', status: SessionStatus.Idle, createdAt: new Date(0).toISOString(), modifiedAt: new Date(0).toISOString(), defaultChat: buildDefaultChatUri(session) }); + const gate: { harnesses?: readonly string[] } = {}; + const accessor = createAccessor({ getChatHarnesses: () => gate.harnesses, isChatMoveEnabled: () => true, supportsChatMoves: () => true }); + const host = new AgentServerToolHost(stateManager, [createSessionServerToolGroup(accessor), createSessionMoveToolGroup(accessor, () => true)]); + const read = () => { + const definitions = host.getDefinitionsForSession(session); + const description = (name: string) => definitions.find(tool => tool.name === name)!.description; + return { + listSessions: description(SessionServerToolName.ListSessions)?.includes('A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`.'), + getCurrentSession: description(SessionServerToolName.GetCurrentSession)?.includes('Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`.'), + model: definitions.find(tool => tool.name === SessionServerToolName.CreateSession)!.inputSchema?.properties?.model, + mentionsHarness: definitions.some(tool => JSON.stringify(tool).includes('harness') || JSON.stringify(tool).includes('uses the same agent')), + moveSession: description(SessionServerToolName.MoveSession), + }; + }; + const off = read(); + gate.harnesses = ['copilotcli', 'claude']; + const on = read(); + // The strings of origin/main before chats could use their own agents (5893cd55828). + assert.deepStrictEqual({ off, onMentionsHarness: on.mentionsHarness, onListSessions: on.listSessions, onMoveSession: on.moveSession?.includes('it can move into a session of another agent when that agent supports multiple chats.') }, { + off: { + moveSession: 'Move a nested session or a whole session when the user asks to reorganize sessions: reorder a nested session within its session, move it into another session, make it its own session (`independent`), or move a session that has no nested sessions into another session as a nested session. Pass `into` for every move except `independent`, which takes neither `into` nor `after`. Use this only when the user asks to move or reorganize sessions; never to tidy up sessions on your own. To start new work, use `create_session` instead. A moved session keeps its history, folders and running work, including a session without a workspace; both sessions must use the same agent. Get links from `list_sessions` or `get_current_session`: a session\'s `openLink`, or for a nested session the `openLink` listed for it in the session\'s `chats`. The user is asked to confirm every move. When it moves the chat this tool runs in, the move happens after this turn ends: make it your final tool call.', + listSessions: true, + getCurrentSession: true, + model: { type: 'string', description: 'Optional model ID or display name. Defaults to the current chat\'s model. For `currentSession`, the model must belong to the current session\'s provider; for `independent`, the model selects the new session\'s provider.' }, + mentionsHarness: false, + }, + onMentionsHarness: true, + onListSessions: false, + onMoveSession: true, + }); + stateManager.dispose(); + }); + + test('an independent session of a chat whose agent can\'t start sessions asks for another agent\'s model', async () => { + const accessor = createAccessor({ + getModels: () => [model, claudeModel], + canCreateSessions: provider => provider !== 'claude', + getCreationDefaults: () => ({ provider: 'claude', sessionProvider: 'copilot' }), + }); + await assert.rejects( + applyCreateSessionTool(accessor, { relationship: 'independent', prompt: 'p', title: 'T' }, URI.parse(buildDefaultChatUri('copilot:/s1'))), + { message: 'Invalid create_session input: the current chat\'s agent "claude" can\'t start a new session because it is unavailable or disabled. Pass `model` with a model of another agent to choose the new session\'s agent.' }, + ); + }); + + test('lists the harness of a nested session only when it differs from its session\'s', async () => { + const session: IAgentSessionMetadata = { + ...sessionMeta('s1', SessionStatus.Idle, workspace), + provider: 'copilotcli', + chats: [ + { chat: URI.parse(buildDefaultChatUri('copilot:/s1')), kind: 'default' }, + { chat: URI.parse(buildChatUri('copilot:/s1', 'same')), kind: 'peer', provider: 'copilotcli' }, + { chat: URI.parse(buildChatUri('copilot:/s1', 'other')), kind: 'peer', provider: 'claude' }, + ], + }; + const stateManager = new AgentHostStateManager(new NullLogService()); + const group = createSessionServerToolGroup(createAccessor({ listSessions: async () => [session] })); + const listed = JSON.parse(await group.execute(stateManager, executionContext('copilot:/s1'), SessionServerToolName.ListSessions, {})) as { sessions: { chats?: { harness?: string }[] }[] }; + assert.deepStrictEqual(listed.sessions[0].chats?.map(chat => chat.harness), [undefined, undefined, 'claude']); + stateManager.dispose(); + }); + }); + test('getCreateChatArgs resolves an explicit session, model, falls back to current, and validates', () => { const sessions = [sessionMeta('s1', SessionStatus.Idle, workspace)]; const explicit = getCreateChatArgs({ session: 'copilot:/s1', prompt: 'hi', title: 'My chat', model: 'gpt-4o' }, sessions, [model]); @@ -3410,6 +3566,9 @@ suite('SessionServerTools', () => { [AgentChatMoveRefusal.NotSingleChat]: true, [AgentChatMoveRefusal.NotStarted]: true, [AgentChatMoveRefusal.DifferentCredentialOwner]: true, + [AgentChatMoveRefusal.AgentUnavailable]: true, + [AgentChatMoveRefusal.AgentWithoutChats]: true, + [AgentChatMoveRefusal.AgentConfigUnavailable]: true, }; const refusing = (reason: AgentChatMoveRefusal) => createMoveGroup({ applyChatMove: async () => { throw new AgentChatMoveRefusedError(reason, 'host detail'); } }).group; const messages: string[] = []; @@ -3425,8 +3584,8 @@ suite('SessionServerTools', () => { endAnchorGone: await run(refusing(AgentChatMoveRefusal.InvalidAnchor), { session: link(source, 'peer'), into: target }), givenAnchorGone: await run(refusing(AgentChatMoveRefusal.InvalidAnchor), { session: link(source, 'peer'), into: target, after: link(target) }), }, { - count: 19, - distinct: 19, + count: 22, + distinct: 22, oneSentenceEach: true, hostDetailHidden: true, endAnchorGone: `error: ${getMoveRefusalMessage(AgentChatMoveRefusal.Conflict, false)}`, diff --git a/src/vs/platform/agentHost/test/node/shared/editArcReporter.test.ts b/src/vs/platform/agentHost/test/node/shared/editArcReporter.test.ts index 0fa83e6c9c953..4afc02b24f8cc 100644 --- a/src/vs/platform/agentHost/test/node/shared/editArcReporter.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/editArcReporter.test.ts @@ -531,6 +531,9 @@ function createConfigurationService(enabled: boolean, disposables: DisposableSto getChatOwnConfigValues: () => ({}), updateChatConfig: () => { }, seedChatConfig: () => ({ values: {}, dispose: () => { } }), + completeChatConfigs: () => { }, + getChatPersistedConfigValues: () => undefined, + getCompleteChatConfigValues: (_session: string, own: Readonly>) => ({ ...own }), getEffectiveWorkingDirectories: () => undefined, updateSessionConfig: () => { }, getSessionConfigValues: () => undefined, diff --git a/src/vs/platform/agentHost/test/node/testAgentHostProviderService.ts b/src/vs/platform/agentHost/test/node/testAgentHostProviderService.ts index 1fc41114663f4..c9bfc7fd7c706 100644 --- a/src/vs/platform/agentHost/test/node/testAgentHostProviderService.ts +++ b/src/vs/platform/agentHost/test/node/testAgentHostProviderService.ts @@ -22,9 +22,12 @@ export function createTestAgentHostProviderService(getProviderForSession: (sessi resolveProvider: () => undefined, getProvider: () => undefined, getProviderForSession, + getProviderForChat: session => getProviderForSession(session), getProviders: () => [], associateSession: () => { }, releaseSession: () => { }, + associateChat: () => { }, + releaseChat: () => { }, authenticate: async () => ({ authenticated: false }), handleMcpRequest: async () => { throw new Error('Not implemented'); }, getNetworkDiagnostics: async () => ({ endpoints: [], account: undefined }), diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 4e49bf7258738..a78b68c95f0de 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -257,6 +257,8 @@ A chat view binds its transcript to the content provider serving the chat's sess Creating or forking a peer chat in the same session is a capability-gated provider operation routed by the management service. Provider-neutral fork options may carry an optional initial title; each provider decides whether its transport supports that option. An older Agent Host omits the unsupported title and performs an ordinary fork rather than failing. A fork that includes a first user message requires the owning Agent Host's composed-operation capability; an unsupported host does not silently create a fork without that message. +A chat may expose its own harness identity in the same identity space as the session's; absent means the session's harness. Creating a peer chat may request a harness among the choices the session advertises through its capabilities; shared code gates that choice on the advertised capability, never on provider ids. Reusing an unsent chat for such a request requires that chat to use the requested harness. Providers present a harness id (label and icon) through the optional `getHarnessPresentation`, so shared surfaces can show a chat's harness without knowing provider ids. + Opening an existing chat is view orchestration: `ISessionsService` activates the session, resolves the chat from `session.chats`, and updates visible and active state. Chat-tab presentation remains view-owned configuration and is not carried through service open options. ### Remote delegation diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index 32af4c6b69631..f7bbffa1f6e29 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -12,7 +12,7 @@ import { $, addDisposableGenericMouseDownListener, addDisposableGenericMouseUpLi import { applyDragImage } from '../../../base/browser/ui/dnd/dnd.js'; import { ScrollableElement } from '../../../base/browser/ui/scrollbar/scrollableElement.js'; import { ScrollbarVisibility } from '../../../base/common/scrollable.js'; -import { autorun, IObservable } from '../../../base/common/observable.js'; +import { autorun, IObservable, IReader } from '../../../base/common/observable.js'; import { isLinux } from '../../../base/common/platform.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { Action, Separator } from '../../../base/common/actions.js'; @@ -30,7 +30,8 @@ import { IKeyboardEvent, StandardKeyboardEvent } from '../../../base/browser/key import { KeyCode } from '../../../base/common/keyCodes.js'; import { onUnexpectedError } from '../../../base/common/errors.js'; import { localize } from '../../../nls.js'; -import { ChatInteractivity, getChatCapabilities, IChat, SessionStatus } from '../../services/sessions/common/session.js'; +import { ChatInteractivity, getChatCapabilities, getChatHarness, IChat, SessionStatus } from '../../services/sessions/common/session.js'; +import { getHarnessPresentationOrDefault, IHarnessPresentation } from '../../services/sessions/common/sessionsProvider.js'; import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; import { clearChatReferenceDragData, fillChatReferenceDragData, fillSessionChatDragData, isSessionDragFromPaneEdge } from '../dnd.js'; import { IHoverService } from '../../../platform/hover/browser/hover.js'; @@ -102,6 +103,11 @@ export interface IChatCompositeBarDelegate { onTabDragEnd?(): void; } +/** The tab hover: the chat's title path, naming its harness when it differs from the main chat's. */ +function getChatTabHover(titlePath: string, harness: IHarnessPresentation | undefined): string { + return harness ? localize('chatTabHarnessHover', "{0} ({1})", titlePath, harness.label) : titlePath; +} + /** * A composite bar that displays the chats of a single chat group as tabs. * Selecting a tab activates that chat within the group; tabs can be dragged to @@ -306,6 +312,14 @@ export class ChatCompositeBar extends Disposable { this._tabsContainer.setAttribute('aria-label', label); } + /** + * Presents a chat's harness, re-evaluating for `reader` as the provider learns its + * harnesses (a restored session can render before its host advertises them). + */ + private _presentChatHarness(session: IActiveSession, chat: IChat, reader: IReader | undefined): IHarnessPresentation { + return getHarnessPresentationOrDefault(this._sessionsProvidersService.getProvider(session.providerId), getChatHarness(session, chat), reader); + } + private _rebuildTabs(chats: readonly IChat[], mainChatId: string): void { this._cancelTabEditing(); this._tabDisposables.clear(); @@ -381,6 +395,10 @@ export class ChatCompositeBar extends Disposable { const labelEl = $('.chat-composite-bar-tab-label.modern-ui-editor-tab-label'); const labelWidget = this._tabDisposables.add(new IconLabel(labelEl)); + // Only a chat on another harness than the main chat's names it. A chat's harness never changes, and the + // tabs rebuild when the main chat does, so a tab of a single-harness session never reads it. + const marksHarness = !!session && getChatHarness(session, chat) !== getChatHarness(session, session.mainChat.get()); + const harness = (reader: IReader | undefined) => session && marksHarness ? this._presentChatHarness(session, chat, reader) : undefined; let titlePath = ''; this._tabDisposables.add(autorun(reader => { const title = chat.title.read(reader); @@ -394,7 +412,9 @@ export class ChatCompositeBar extends Disposable { labelWidget.setLabel(title, description); tab.classList.toggle('has-description', description.length > 0); titlePath = [...ancestors, title].join(' / '); - const label = localize('chatTabAriaLabel', "{0}, {1}", titlePath, getSessionConversationStatusAriaLabel(status)); + const statusLabel = localize('chatTabAriaLabel', "{0}, {1}", titlePath, getSessionConversationStatusAriaLabel(status)); + const chatHarness = harness(reader); + const label = chatHarness ? localize('chatTabHarnessAriaLabel', "{0}, uses harness {1}", statusLabel, chatHarness.label) : statusLabel; tab.setAttribute('aria-label', isRead ? label : localize('chatTabUnreadAriaLabel', "{0}, unread", label)); })); @@ -415,7 +435,7 @@ export class ChatCompositeBar extends Disposable { tab.appendChild(inputContainer); // Delayed hover showing the full chat title (useful when the title is truncated) - this._tabDisposables.add(this._hoverService.setupDelayedHover(tab, () => ({ content: titlePath, style: HoverStyle.Pointer }), this._tabHoverLifecycleOptions)); + this._tabDisposables.add(this._hoverService.setupDelayedHover(tab, () => ({ content: getChatTabHover(titlePath, harness(undefined)), style: HoverStyle.Pointer }), this._tabHoverLifecycleOptions)); this._tabDisposables.add(addDisposableListener(tab, EventType.MOUSE_ENTER, () => { this._connectedTabOverflowEdge.classList.toggle('connected-tab-hovered', tab.classList.contains('active')); })); diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index aa20e0d2d4b71..ed70023c46e01 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -14,7 +14,7 @@ import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { IKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; -import { autorun, constObservable, derived, IObservable, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { autorun, constObservable, derived, IObservable, IReader, observableFromEvent, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; @@ -36,7 +36,7 @@ import { EDITOR_DRAG_AND_DROP_BACKGROUND } from '../../../../workbench/common/th import { ChatContentMarkdownRenderer } from '../../../../workbench/contrib/chat/browser/widget/chatContentMarkdownRenderer.js'; import { chatPersistentContentVisibleClass, ChatWidget } from '../../../../workbench/contrib/chat/browser/widget/chatWidget.js'; import { SESSIONS_CHAT_CONTENT_HORIZONTAL_PADDING } from '../../../../workbench/contrib/chat/browser/widget/chatOptions.js'; -import { isResponseSelectionSupported, setModelPreservingInputTypedWhileLoading } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { IChatRespondent, isResponseSelectionSupported, setModelPreservingInputTypedWhileLoading } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IChatOpen, IChatOpenDiagnosticsService } from '../../../../workbench/contrib/chat/common/chatOpenDiagnosticsService.js'; import { IChatModelReference, IChatService, ResponseModelState } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { isChatTranscriptContextVariableEntry, IChatRequestTranscriptContextVariableEntry, IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; @@ -46,8 +46,8 @@ import { ChatRequestTextPart } from '../../../../workbench/contrib/chat/common/r import { Range } from '../../../../editor/common/core/range.js'; import { OffsetRange } from '../../../../editor/common/core/ranges/offsetRange.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { getHarnessPresentationOrDefault, ISendRequestOptions, ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; -import { ISendRequestOptions } from '../../../services/sessions/common/sessionsProvider.js'; import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; import { getChatSessionType, getNewChatSessionResource } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { IChatSessionsService, localChatSessionType } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; @@ -55,7 +55,7 @@ import { IChatSessionHistoryStatus } from '../../../../platform/chat/common/chat import { isPhoneLayout } from '../../../browser/parts/mobile/mobileLayout.js'; import { AbstractChatView, ChatViewKind, IChatViewOptions, ISelectNoWorkspaceOptions, ISelectWorkspaceOptions, WorkspaceSelectionResult } from '../../../browser/parts/chatView.js'; import { IsPhoneLayoutContext, SessionUsesExperimentalComposerLayoutContext } from '../../../common/contextkeys.js'; -import { ChatInteractivity, getSessionStatusMessage, IChat, isActiveSessionStatus, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; +import { ChatInteractivity, getChatHarness, getSessionStatusMessage, IChat, isActiveSessionStatus, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; import { IChatViewFactory } from '../../../services/chatView/browser/chatViewFactory.js'; import { isExperimentalSessionComposerLayoutEnabled, NewChatWidget } from './newChatWidget.js'; import { NewChatInSessionWidget } from './newChatInSessionWidget.js'; @@ -138,6 +138,40 @@ export interface INewChatViewOptions extends IChatViewOptions { readonly initialAttachments?: readonly IChatRequestVariableEntry[]; } +const providerModelSignals = new WeakMap>(); + +/** Signals when a provider's models or harnesses may have changed; one per provider, alive while observed. */ +function readProviderModelsChanged(provider: ISessionsProvider, reader: IReader | undefined): void { + let signal = providerModelSignals.get(provider); + if (!signal) { + signal = observableSignalFromEvent(provider, Event.any(provider.onDidChangeModels, provider.onDidChangeSessionTypes)); + providerModelSignals.set(provider, signal); + } + signal.read(reader); +} + +/** + * The respondent of a chat whose harness differs from its session's: its harness's presentation + * (with the provider's generic fallback while unknown) and the session type its models come from, + * `undefined` until the provider resolves that agent's models apart from the session's. + */ +export function getChatRespondent(session: ISession, chatResource: URI, sessionsProvidersService: ISessionsProvidersService, reader: IReader | undefined): IChatRespondent | undefined { + const chat = session.chats.read(reader).find(candidate => isEqual(candidate.resource, chatResource)); + const harness = chat && getChatHarness(session, chat); + if (!chat || !harness || harness === session.harness) { + return undefined; + } + const provider = sessionsProvidersService.getProvider(session.providerId); + if (provider) { + readProviderModelsChanged(provider, reader); + } + const modelTarget = provider?.getModelsSnapshot(session.sessionId, undefined, chat.resource).modelTarget; + // The session's own target is never this chat's: it means the chat's agent is not resolved yet. + const modelSessionType = modelTarget && modelTarget !== getChatSessionType(session.resource) ? modelTarget : undefined; + const presentation = getHarnessPresentationOrDefault(provider, harness, reader); + return { label: presentation.label, icon: presentation.icon, modelSessionType }; +} + export class NewChatView extends AbstractChatView { static readonly TYPE = 'sessions.newSession'; @@ -308,6 +342,8 @@ export class ChatView extends AbstractChatView { /** Reference to the loaded chat model; disposing releases the model. */ private readonly _modelRef = this._register(new MutableDisposable()); + /** Keeps the bound chat presented as answered by its own harness while it differs from its session's. */ + private readonly _respondentBinding = this._register(new MutableDisposable()); private readonly _preparationModel = this._register(new MutableDisposable()); private readonly _transcriptDisposables = this._register(new DisposableStore()); @@ -989,6 +1025,8 @@ export class ChatView extends AbstractChatView { this._pendingLoadInput = undefined; this._modelRef.value = ref; this._updateWidgetLockState(getChatSessionType(ref.object.sessionResource)); + // Bound ahead of the model so a chat answered by another agent never validates against its session's models. + this._respondentBinding.value = session ? autorun(reader => this._widget.setRespondent(getChatRespondent(session, resource, this.sessionsProvidersService, reader), resource)) : undefined; if (inputState) { this._widget.setModel(ref.object); } else { @@ -1052,6 +1090,7 @@ export class ChatView extends AbstractChatView { this.sessionOpenTelemetryService.modelUnbound(previousSession.resource, previousChatResource); } this._widget.clear().catch(err => this.logService.error('[ChatView] Failed to clear chat widget', err)); + this._respondentBinding.clear(); this._widget.setModel(undefined); this._selectionSideChatController.setChat(undefined, constObservable(true)); this._modelRef.clear(); diff --git a/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts index 6ae36a5e10c6e..49ce11e20f197 100644 --- a/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts @@ -83,7 +83,7 @@ export class MobileSessionTypePicker extends SessionTypePicker { return; } const additionalAction = this._getVisibleAdditionalAction(); - if (this._folderSessionTypes.length <= 1 && this._pickServedByFolder(this._picked) && !additionalAction) { + if (this._isHiddenPeerChatPicker() || (this._folderSessionTypes.length <= 1 && this._pickServedByFolder(this._picked) && !additionalAction)) { return; } @@ -144,7 +144,7 @@ export class MobileSessionTypePicker extends SessionTypePicker { try { const id = await showMobilePickerSheet( this.layoutService.mainContainer, - localize('mobileSessionTypePicker.title', "Session Type"), + this._options?.peerChat ? localize('mobileSessionTypePicker.peerChatTitle', "Harness for New Chat") : localize('mobileSessionTypePicker.title', "Session Type"), sheetItems, ); trigger.setAttribute('aria-expanded', 'false'); diff --git a/src/vs/sessions/contrib/chat/browser/nestedChatWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/nestedChatWorkspacePicker.ts index caa7902f16929..36f7f528c59b3 100644 --- a/src/vs/sessions/contrib/chat/browser/nestedChatWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/nestedChatWorkspacePicker.ts @@ -8,6 +8,7 @@ import { status } from '../../../../base/browser/ui/aria/aria.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { IHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate.js'; import { createInstantHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { raceCancellation } from '../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; @@ -79,6 +80,11 @@ export function getNestedChatFolderOptions(support: INestedChatWorkspaceSupport, return { choices, browseActions: support.browseActions, openFolder: sources.canOpenLocalFolder }; } +/** Whether `choice` is one the support offers: any folder or "No workspace" with multiple folders, otherwise only the default. */ +export function isNestedChatFolderOffered(support: INestedChatWorkspaceSupport, choice: NestedChatFolderChoice): boolean { + return support.multipleFolders || isSameNestedChatFolder(choice, getDefaultNestedChatFolder(support)); +} + export function isSameNestedChatFolder(a: NestedChatFolderChoice, b: NestedChatFolderChoice): boolean { return a.kind === 'scratch' ? b.kind === 'scratch' : b.kind === 'folder' && isEqual(a.folder, b.folder); } @@ -178,6 +184,8 @@ export class NestedChatWorkspaceControls extends Disposable { private readonly _sessionFolderMissing = observableValue(this, false); /** Checks whether the preselected session folder still exists; settles once it is known or can't be told. */ private _folderCheck: Promise | undefined; + /** Settles once a {@link refresh} has re-read its folder (or was superseded), which restores the kept worktree choice. */ + private _refreshResolution: Promise | undefined; private readonly _branches = observableValue(this, []); private readonly _resolve = this._register(new MutableDisposable()); private readonly _folderRenderStore = this._register(new DisposableStore()); @@ -220,7 +228,8 @@ export class NestedChatWorkspaceControls extends Disposable { /** {@link getWorkspace} once a pending check of the session folder is done, so a chat never starts in a missing folder. */ async resolveWorkspace(): Promise { - await this._folderCheck; + // Until a refresh re-reads its folder, the kept worktree choice reads as "no worktree". + await Promise.all([this._folderCheck, this._refreshResolution]); return this.getWorkspace(); } @@ -231,7 +240,22 @@ export class NestedChatWorkspaceControls extends Disposable { this._select(support ? getDefaultNestedChatFolder(support) : undefined); } - private _select(choice: NestedChatFolderChoice | undefined): void { + /** + * Re-reads the facts of the chosen folder, e.g. after the new chat's agent changed. A choice the current + * support no longer offers goes back to the session's default (its folder, without a worktree); otherwise + * the worktree choice and branch are kept where the folder still supports them. + */ + refresh(): void { + const support = this._host.support.get(); + const folder = this._folder.get(); + if (!support || !folder || !isNestedChatFolderOffered(support, folder)) { + this.reset(); + return; + } + this._select(folder, { worktree: this._worktree.get(), branch: this._branch.get() }); + } + + private _select(choice: NestedChatFolderChoice | undefined, keep?: { readonly worktree: boolean; readonly branch: string | undefined }): void { const store = new DisposableStore(); this._resolve.value = store; transaction(tx => { @@ -264,19 +288,30 @@ export class NestedChatWorkspaceControls extends Disposable { } })); } - this._host.resolveFolder(choice.folder, cts.token).then(info => { + const resolution = this._host.resolveFolder(choice.folder, cts.token).then(info => { if (cts.token.isCancellationRequested) { return; } transaction(tx => { this._folderInfo.set(info, tx); - this._branch.set(info.defaultBranch, tx); + this._worktree.set(!!keep?.worktree && info.supportsWorktree, tx); + this._branch.set(keep?.worktree && info.supportsWorktree ? keep.branch ?? info.defaultBranch : info.defaultBranch, tx); }); }, error => { if (!cts.token.isCancellationRequested) { onUnexpectedError(error); } }); + if (keep) { + // Settle when superseded too, as a cancelled resolution may never answer. + const refreshResolution = raceCancellation(resolution, cts.token).then(() => undefined); + this._refreshResolution = refreshResolution; + store.add(toDisposable(() => { + if (this._refreshResolution === refreshResolution) { + this._refreshResolution = undefined; + } + })); + } } private _loadBranches(): void { diff --git a/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts index 389a79a4d3fc0..cd55f62b836e0 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts @@ -10,7 +10,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { isCancellationError } from '../../../../base/common/errors.js'; import { toErrorMessage } from '../../../../base/common/errorMessage.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, constObservable, derived, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { autorun, constObservable, derived, IObservable, IReader, ISettableObservable, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; import { Event } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; @@ -25,7 +25,7 @@ import { IActiveSession, ISessionsManagementService } from '../../../services/se import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { INestedChatWorkspace, INestedChatWorkspaceSupport, NestedChatWorkspaceError } from '../../../services/sessions/common/sessionsProvider.js'; -import { IChat, SessionStatus } from '../../../services/sessions/common/session.js'; +import { getChatHarness, IChat, SessionStatus } from '../../../services/sessions/common/session.js'; import { getNoWorkspaceChoicePresentation } from './noWorkspaceChoice.js'; import { INestedChatFolderSources, isSameNestedChatWorkspace, NestedChatWorkspaceControls } from './nestedChatWorkspacePicker.js'; import { ISessionsRecentWorkspacesService } from '../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; @@ -52,16 +52,40 @@ const STORAGE_KEY_SESSION_OPTIONS_EXPANDED = 'sessions.nestedSession.sessionOpti /** How long a send waits for a check of the session folder before keeping it, e.g. while a remote host reconnects. */ const FOLDER_EXISTS_TIMEOUT_MS = 2000; +/** Telemetry source of the session-type picker when it picks a new peer chat's harness. */ +const NEW_CHAT_IN_SESSION_HARNESS_PICKER_TELEMETRY_SOURCE = 'NewChatInSessionHarnessPicker'; + +/** + * The session's unsent peer chat, whose harness the composer may choose; `undefined` for its main chat or a sent one. + * `composerChat` is the chat the composer shows, which can differ from the session's active chat (e.g. one open in + * another group); the session's active chat until it is known. + */ +export function getUnsentPeerChat(session: IActiveSession | undefined, reader: IReader | undefined, composerChat?: IChat): IChat | undefined { + if (!session || !session.isCreated.read(reader)) { + return undefined; + } + const chat = composerChat ?? session.activeChat.read(reader); + return !isEqual(chat.resource, session.mainChat.read(reader).resource) && chat.status.read(reader) === SessionStatus.Untitled ? chat : undefined; +} + +/** The chat the composer creates: its unsent peer draft, or the main chat (on the session's agent) until one exists. */ +function getNewChatResource(session: IActiveSession, reader: IReader | undefined, composerChat: IChat | undefined): URI { + return (getUnsentPeerChat(session, reader, composerChat) ?? session.mainChat.read(reader)).resource; +} + /** * A widget for composing a secondary chat within an existing session. * Looks like the New Session composer: the same input, options row and picker placement. There is - * no harness (session type) or environment picker: the nested chat uses the parent session's agent - * and host. Where the host supports it, folder, isolation and branch pickers choose the nested + * no environment picker: the nested chat uses the parent session's host. It uses the session's agent + * unless the session offers its chats more than one harness: then the harness picker in its session + * options, where New Session has it, picks the agent of the new chat. Where the host supports it, folder, isolation and branch pickers choose the nested * chat's own workspace, which is applied to the composer's untitled chat when its first request is sent. */ export class NewChatInSessionWidget extends Disposable { private readonly _newChatInput: SharedChatInputComposer; + /** The harness switch in flight; a send waits for it so it reaches the chat of the picked harness. */ + private _harnessSwitch: Promise | undefined; private readonly _tipDisposable = this._register(new MutableDisposable()); private readonly _session: IObservable; /** @@ -140,12 +164,18 @@ export class NewChatInSessionWidget extends Disposable { } session.workspace.read(reader); session.capabilities.read(reader); - return this.sessionsProvidersService.getProvider(session.providerId)?.getNestedChatWorkspaceSupport(session.sessionId, reader); + return this.sessionsProvidersService.getProvider(session.providerId)?.getNestedChatWorkspaceSupport(session.sessionId, getNewChatResource(session, reader, this._chat.read(reader)), reader); }); this._workspaceControls = this._register(this.instantiationService.createInstance(NestedChatWorkspaceControls, { support: this._workspaceSupport, - resolveFolder: (folder, token) => this._requireProvider().resolveNestedChatFolder(this._requireSession().sessionId, folder, token), - getBranches: (folder, token) => this._requireProvider().getNestedChatBranches(this._requireSession().sessionId, folder, '', token), + resolveFolder: (folder, token) => { + const session = this._requireSession(); + return this._requireProvider().resolveNestedChatFolder(session.sessionId, getNewChatResource(session, undefined, this._chat.get()), folder, token); + }, + getBranches: (folder, token) => { + const session = this._requireSession(); + return this._requireProvider().getNestedChatBranches(session.sessionId, getNewChatResource(session, undefined, this._chat.get()), folder, '', token); + }, getFolderSources: () => this._getFolderSources(), describeNoWorkspace: () => { const session = this._session.get(); @@ -169,6 +199,17 @@ export class NewChatInSessionWidget extends Disposable { this._workspaceControls.reset(); } })); + // A harness switch replaces the draft with one of another agent: re-read what that agent allows and the + // folder's facts for it, keeping the user's choice where it still applies. + let lastChat = this._session.get() && getNewChatResource(this._session.get()!, undefined, this._chat.get()); + this._register(autorun(reader => { + const session = this._session.read(reader); + const chat = session && getNewChatResource(session, reader, this._chat.read(reader)); + if (session?.sessionId === lastSessionId && chat && lastChat && !isEqual(chat, lastChat)) { + this._workspaceControls.refresh(); + } + lastChat = chat; + })); const canSendRequest = derived(reader => { const session = this._session.read(reader); @@ -194,6 +235,13 @@ export class NewChatInSessionWidget extends Disposable { supportsBackground: true, voiceRoutesWhileSessionActive: true, useExperimentalLayout: this._useExperimentalComposerLayout, + sessionTypePickerOptions: { + telemetrySource: NEW_CHAT_IN_SESSION_HARNESS_PICKER_TELEMETRY_SOURCE, + peerChat: { + chat: derived(reader => getUnsentPeerChat(this._session.read(reader), reader, this._chat.read(reader))), + selectHarness: harness => this._selectHarness(harness), + }, + }, })); } @@ -209,8 +257,10 @@ export class NewChatInSessionWidget extends Disposable { const chatWidgetContent = dom.append(chatWidgetContainer, dom.$(`.new-chat-widget-content.${chatInputStackClass}`)); // The New Session options row: the folder picker, then the session options (isolation and - // branch) wherever the New Session composer shows its repository controls. Hidden when the - // host can't give a nested chat its own workspace: then the composer is unchanged. + // branch, and the harness picker where the session offers its chats more than one harness) + // wherever the New Session composer shows them. Hidden when the host can't give a nested chat + // its own workspace and there is no harness to choose: then the composer is unchanged. + const harnessPicker = this._newChatInput.sessionTypePicker; const workspacePickerContainer = dom.append(chatWidgetContent, dom.$('.new-session-workspace-picker-container')); const row = dom.append(workspacePickerContainer, dom.$('.sessions-workspace-category-picker')); this._workspaceControls.renderFolderPicker(row, this._newChatInput.pickerHoverDelegate); @@ -221,15 +271,25 @@ export class NewChatInSessionWidget extends Disposable { screenReaderOptimized: this._screenReaderOptimized, expanded: this._sessionOptionsExpanded, collapsedShowIcons: this._collapsedSessionOptionsShowIcons, - // Unlike New Session there is no harness picker: without a worktree to offer the tray is empty. - hasDetails: this._workspaceControls.offersWorktree, + // The harness picker shows only where the session offers its chats more than one harness: without + // it or a worktree to offer, the tray is empty. + hasDetails: derived(reader => this._workspaceControls.offersWorktree.read(reader) || harnessPicker.isVisible.read(reader)), setExpanded: expanded => { this._sessionOptionsExpanded.set(expanded, undefined); this.storageService.store(STORAGE_KEY_SESSION_OPTIONS_EXPANDED, expanded, StorageScope.PROFILE, StorageTarget.USER); }, })); + // Like New Session, the harness picker follows the repository controls in the session options. + harnessPicker.render(sessionOptionsRow.details, { + className: 'sessions-chat-session-type-picker sessions-workspace-category-picker-slot', + hoverDelegate: this._newChatInput.pickerHoverDelegate, + }); + const harnessSlot = sessionOptionsRow.details.lastElementChild; + if (harnessSlot) { + sessionOptionsRow.repositoryControlsHost.after(harnessSlot); + } this._register(autorun(reader => { - workspacePickerContainer.hidden = !this._workspaceSupport.read(reader); + workspacePickerContainer.hidden = !this._workspaceSupport.read(reader) && !harnessPicker.isVisible.read(reader); sessionOptionsRow.layout(); })); @@ -247,6 +307,41 @@ export class NewChatInSessionWidget extends Disposable { chatWidgetContainer.classList.add('revealed'); } + /** + * Switches the unsent peer chat to `harness`: opens (or reuses) a draft of that harness and + * deletes the empty draft it replaces, rather than leaving it behind as an empty tab. + */ + protected async _selectHarness(harness: string): Promise { + const session = this._session.get(); + const draft = session && getUnsentPeerChat(session, undefined, this._chat.get()); + // One switch at a time: a pick while one is pending is ignored. + if (!session || !draft || this._harnessSwitch || getChatHarness(session, draft) === harness) { + return; + } + const switched = this._switchHarness(session, draft, harness); + this._harnessSwitch = switched; + try { + await switched; + } finally { + this._harnessSwitch = undefined; + } + } + + private async _switchHarness(session: IActiveSession, draft: IChat, harness: string): Promise { + try { + // The typed text and attachments live in this composer, not in the draft, so they carry over. + await this.sessionsService.openNewChatInSession(session, { harness }); + // Only replace the draft while the user is still in this session's composer. + const current = this._session.get(); + const replacement = current?.sessionId === session.sessionId ? getUnsentPeerChat(current, undefined, this._chat.get()) : undefined; + if (replacement && !isEqual(replacement.resource, draft.resource) && draft.status.get() === SessionStatus.Untitled) { + await this.sessionsManagementService.deleteChat(session, draft.resource, { skipConfirmation: true }); + } + } catch (error) { + this.logService.error('[NewChatInSessionWidget] Failed to switch the new chat\'s harness', error); + } + } + private _renderSubSessionTip(): void { if (this.storageService.getBoolean(STORAGE_KEY_SUB_SESSION_TIP_DISMISSED, StorageScope.PROFILE, false)) { return; @@ -371,7 +466,12 @@ export class NewChatInSessionWidget extends Disposable { // --- Send --- - private async _send(query: string, attachedContext?: IChatRequestVariableEntry[], background?: boolean, userInteraction?: NewChatUserInteraction): Promise { + protected async _send(query: string, attachedContext?: IChatRequestVariableEntry[], background?: boolean, userInteraction?: NewChatUserInteraction): Promise { + // The picker already shows a picked harness while its chat is being opened: sending now would + // reach the draft being replaced (and then deleted), so wait until the picked chat is active. + if (this._harnessSwitch) { + await this._harnessSwitch; + } const activeSession = this._session.get(); if (!activeSession) { return false; diff --git a/src/vs/sessions/contrib/chat/browser/sessionModelSelection.ts b/src/vs/sessions/contrib/chat/browser/sessionModelSelection.ts index 961a3d5efb7d1..e505024a76b8f 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionModelSelection.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionModelSelection.ts @@ -199,7 +199,7 @@ export class SessionModelSelection extends Disposable implements ISessionModelSe } // Fresh snapshot: the pool the picker rendered from may already be stale. - const snapshot = provider.getModelsSnapshot(session.sessionId); + const snapshot = provider.getModelsSnapshot(session.sessionId, undefined, session.activeChat.get().resource); this._modelTarget = snapshot.modelTarget; this._models = snapshot.models; const model = snapshot.models.find(model => model.identifier === modelIdentifier); @@ -212,7 +212,7 @@ export class SessionModelSelection extends Disposable implements ISessionModelSe return false; } - const options = normalizeModelPickerOptions(provider.getModelPickerOptions(session.sessionId)); + const options = normalizeModelPickerOptions(provider.getModelPickerOptions(session.sessionId, session.activeChat.get().resource)); const providerModelBefore = session.modelId.get(); const storageKey = getSelectedModelStorageKey(ChatAgentLocation.Chat, snapshot.modelTarget); const conversation = this._conversation(); @@ -320,7 +320,7 @@ export class SessionModelSelection extends Disposable implements ISessionModelSe applyModel: model => this._pushModelToProvider(model), isModelAbsenceConclusive: modelId => { const session = this._activeSession; - return !!session && this._activeProvider?.getModelsSnapshot(session.sessionId, modelId).desiredModelResolution.kind === 'unavailable'; + return !!session && this._activeProvider?.getModelsSnapshot(session.sessionId, modelId, session.activeChat.get().resource).desiredModelResolution.kind === 'unavailable'; }, // The provider owns Automation model preferences; the controller only selects the model. }; @@ -376,7 +376,7 @@ export class SessionModelSelection extends Disposable implements ISessionModelSe const chatModelSource = chatModelId ? (chat.modelSource.get() ?? ChatModelSource.Chosen) : undefined; // Undefined only when the chat has no model, which is the one case with no authority at all. const chatModelReason = chatModelSource === undefined ? undefined : restoreReasonForSource(chatModelSource); - const baseSnapshot = provider.getModelsSnapshot(session.sessionId, chatModelId); + const baseSnapshot = provider.getModelsSnapshot(session.sessionId, chatModelId, chat.resource); const remembered = this._getRememberedModel(session, baseSnapshot.modelTarget); const rebound = session.sessionId !== this._boundSessionKey || conversationKey !== this._boundConversationKey; @@ -384,11 +384,11 @@ export class SessionModelSelection extends Disposable implements ISessionModelSe // that has yet to run on anything. Reading it per chat is what keeps one chat's choice out // of another's: the incoming chat brings its own model with it. const desiredModelId = chatModelId ?? remembered; - const snapshot = desiredModelId === chatModelId ? baseSnapshot : provider.getModelsSnapshot(session.sessionId, desiredModelId); + const snapshot = desiredModelId === chatModelId ? baseSnapshot : provider.getModelsSnapshot(session.sessionId, desiredModelId, chat.resource); this._models = snapshot.models; this._modelTarget = snapshot.modelTarget; - const options = normalizeModelPickerOptions(provider.getModelPickerOptions(session.sessionId)); + const options = normalizeModelPickerOptions(provider.getModelPickerOptions(session.sessionId, chat.resource)); // The provider resolves the desired model: a host republishes it under its own identifier, // so matching the raw one would miss it. const resolvedDesiredModel = snapshot.desiredModelResolution.kind === 'available' diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index d1c0daaa07e5c..06deb67326787 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -21,7 +21,7 @@ import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../ import { IProviderSessionType, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { autorun, IObservable, observableValue } from '../../../../base/common/observable.js'; -import { GITHUB_REMOTE_FILE_SCHEME, ISession, ISessionType, SessionStatus } from '../../../services/sessions/common/session.js'; +import { getChatHarness, GITHUB_REMOTE_FILE_SCHEME, IChat, ISession, ISessionType, SessionStatus } from '../../../services/sessions/common/session.js'; import { Emitter } from '../../../../base/common/event.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; @@ -120,6 +120,17 @@ export interface ISessionTypePickerOptions { }; /** Restricts new-session choices to the provider selected by the creation destination. */ readonly providerId?: IObservable; + /** + * Picks the harness of a new peer chat of the active session instead of a new session's type. + * While `chat` is set and the session offers more than one of its `supportsChatHarnesses`, the + * picker offers those harnesses' session types and shows `chat`'s harness; otherwise it stays + * hidden. A pick is never persisted; it is handed to `selectHarness`. + */ + readonly peerChat?: { + /** The session's unsent peer chat whose harness is picked, or `undefined` when there is none. */ + readonly chat: IObservable; + readonly selectHarness: (harness: string) => Promise; + }; } /** @@ -177,6 +188,11 @@ export class SessionTypePicker extends Disposable { /** Session types the active session's folder can be served by, across all providers. */ protected _folderSessionTypes: IProviderSessionType[] = []; + /** + * Peer-chat mode: the harness behind each offered session type. Filled whenever the offered types are + * resolved (see {@link _resolvePeerChatSessionTypes}), so readers run after a resolve, as `_folderSessionTypes`'. + */ + private readonly _peerChatHarnesses = new Map(); /** Folder that drives the available session types when set via {@link setFolderSource}; `undefined` keeps session-driven behavior. */ private _folderSource: IObservable | undefined; @@ -222,7 +238,11 @@ export class SessionTypePicker extends Disposable { this._picked = this._readStoredPick(); this._register(autorun(reader => { - this._session.read(reader); + const session = this._session.read(reader); + if (this._options?.peerChat) { + this._options.peerChat.chat.read(reader); + session?.capabilities.read(reader); + } this._options?.providerId?.read(reader); this._options?.allowedProviders?.read(reader); this._options?.presentation?.read(reader); @@ -276,6 +296,10 @@ export class SessionTypePicker extends Disposable { } private _resolveUnfilteredSessionTypes(): IProviderSessionType[] { + const peerChatTypes = this._resolvePeerChatSessionTypes(); + if (peerChatTypes) { + return peerChatTypes; + } const providerId = this._options?.providerId?.get(); if (providerId) { const provider = this.sessionsProvidersService.getProvider(providerId); @@ -314,9 +338,42 @@ export class SessionTypePicker extends Disposable { return session ? this._sessionTypesForSession(session) : []; } + /** + * Peer-chat mode: the session types of the harnesses the active session offers its peer chat, + * or `undefined` when choosing one does not apply (the picker then resolves as without the mode, hidden). + */ + private _resolvePeerChatSessionTypes(): IProviderSessionType[] | undefined { + this._peerChatHarnesses.clear(); + const session = this._session.get(); + const chat = this._options?.peerChat?.chat.get(); + if (!session || !chat) { + return undefined; + } + const provider = this.sessionsProvidersService.getProvider(session.providerId); + const types: IProviderSessionType[] = []; + for (const harness of session.capabilities.get().supportsChatHarnesses ?? []) { + const sessionType = provider?.getHarnessSessionType?.(harness); + if (sessionType && !this._peerChatHarnesses.has(sessionType.id)) { + this._peerChatHarnesses.set(sessionType.id, harness); + types.push({ providerId: session.providerId, sessionType }); + } + } + if (types.length < 2) { + this._peerChatHarnesses.clear(); + return undefined; + } + return types; + } + /** The pick to display for the current source: the active session's type, otherwise the folder or stored default. */ protected _computeCurrentPick(): IPreferredSessionType | undefined { const session = this._session.get(); + const peerChat = this._options?.peerChat?.chat.get(); + if (session && peerChat && this._peerChatHarnesses.size > 0) { + const harness = getChatHarness(session, peerChat); + const sessionTypeId = [...this._peerChatHarnesses].find(([, candidate]) => candidate === harness)?.[0]; + return sessionTypeId ? { providerId: session.providerId, sessionTypeId } : undefined; + } if (this._pendingExplicitPick) { if (!this._pickServedByFolder(this._pendingExplicitPick)) { this._pendingExplicitPick = undefined; @@ -587,7 +644,7 @@ export class SessionTypePicker extends Disposable { this._updateModelTargetChatSessionType(); const additionalAction = this._getVisibleAdditionalAction(); - if (folderTypes.length <= 1 && this._pickServedByFolder(this._picked) && !additionalAction) { + if (this._isHiddenPeerChatPicker() || (folderTypes.length <= 1 && this._pickServedByFolder(this._picked) && !additionalAction)) { return; } @@ -709,12 +766,17 @@ export class SessionTypePicker extends Disposable { getAriaLabel: (element) => element.item?.kind === 'sessionType' && element.item.groupLabel ? localize('sessionTypePicker.itemAriaLabel', "{0}, {1}", element.label ?? '', element.item.groupLabel) : (element.label ?? ''), - getWidgetAriaLabel: () => localize('sessionTypePicker.ariaLabel', "Session Type"), + getWidgetAriaLabel: () => this._options?.peerChat ? localize('sessionTypePicker.peerChatAriaLabel', "Harness for New Chat") : localize('sessionTypePicker.ariaLabel', "Session Type"), }, { className: 'sessions-new-chat-picker-list', minWidth: 200 }, ); } + /** Peer-chat mode while choosing a peer chat's harness does not apply: the picker stays hidden. */ + protected _isHiddenPeerChatPicker(): boolean { + return !!this._options?.peerChat && this._peerChatHarnesses.size === 0; + } + protected _getVisibleAdditionalAction(): NonNullable | undefined { const action = this._options?.additionalAction; return action?.isVisible() ? action : undefined; @@ -757,7 +819,8 @@ export class SessionTypePicker extends Disposable { return; } this._pendingInitialPick = undefined; - const stored = this._readStoredPick(); + const peerChatHarness = this._options?.peerChat ? this._peerChatHarnesses.get(pick.sessionTypeId) : undefined; + const stored = this._options?.peerChat ? undefined : this._readStoredPick(); const beforeId = stored?.sessionTypeId ?? this._picked?.sessionTypeId; const beforeLabel = this._folderSessionTypes.find(t => t.sessionType.id === beforeId)?.sessionType.label; const afterLabel = this._folderSessionTypes.find(t => t.providerId === pick.providerId && t.sessionType.id === pick.sessionTypeId)?.sessionType.label; @@ -788,7 +851,7 @@ export class SessionTypePicker extends Disposable { this._hasResolvedFolderPick = true; } this._updateModelTargetChatSessionType(); - if (this._options?.persistSelection !== false) { + if (this._options?.persistSelection !== false && !this._options?.peerChat) { if (isDefault) { this._clearStoredPick(); } else { @@ -802,6 +865,14 @@ export class SessionTypePicker extends Disposable { if (visiblePickChanged) { this._onDidSelectSessionType.fire(pick); this._onDidChangeSelectedPick.fire(this._picked); + if (peerChatHarness) { + // The peer chat's harness is the source of truth: show it again once it is switched (or not). + this._options!.peerChat!.selectHarness(peerChatHarness).finally(() => { + if (!this._store.isDisposed) { + this._recompute(); + } + }); + } } } @@ -872,7 +943,7 @@ export class SessionTypePicker extends Disposable { this._triggerSlot?.toggleAttribute('hidden', this._folderSessionTypes.length === 0); dom.clearNode(this._triggerElement); - if (this._folderSessionTypes.length === 0) { + if (this._folderSessionTypes.length === 0 || this._isHiddenPeerChatPicker()) { this._triggerElement.classList.add('hidden'); this._triggerSlot?.classList.remove('disabled'); this._isVisible.set(false, undefined); @@ -902,8 +973,10 @@ export class SessionTypePicker extends Disposable { chevron.classList.add('sessions-chat-dropdown-chevron'); } - this._triggerElement.ariaLabel = disabled - ? localize('sessionTypePicker.disabledTriggerAriaLabel', "Session Type, {0}", modeLabel) - : localize('sessionTypePicker.triggerAriaLabel', "Pick Session Type, {0}", modeLabel); + this._triggerElement.ariaLabel = this._options?.peerChat + ? localize('sessionTypePicker.peerChatTriggerAriaLabel', "Pick Harness for New Chat, {0}", modeLabel) + : disabled + ? localize('sessionTypePicker.disabledTriggerAriaLabel', "Session Type, {0}", modeLabel) + : localize('sessionTypePicker.triggerAriaLabel', "Pick Session Type, {0}", modeLabel); } } diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 139672a931ec9..1086533e15985 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -254,7 +254,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.pickFolderQuickPick', "To choose a folder from a searchable list instead, use the New Session in Folder command{0}.", '')); content.push(localize('sessionsChat.quickChat', "To start a workspace-less quick chat, use the New Quick Chat command{0} or the plus button on the Chats section in the sessions list. When consolidated remote workspaces are enabled, this opens the new-session composer, where you can choose Chat. Otherwise, it starts a workspace-less chat directly. When GitHub sandboxes are enabled, choose Cloud in the Environment picker, then Chat in the workspace picker, to chat without a repository or pull request. If the Environment picker is disabled, choose Cloud in the harness picker instead. The Toggle Side Panel command is disabled for quick chats.", '')); content.push(localize('sessionsChat.quickChatNestedChats', "Quick chats can have nested chats: use New Nested Session{0} on a quick chat, and each nested chat works in its own scratch folder. Chats move between quick chats and workspace sessions and keep their folder. A quick chat stays in the Chats section, and those of its chats that have a folder of their own announce it. A chat that shares its folder with another chat in its session can't be moved on its own.", '')); - content.push(localize('sessionsChat.nestedSessionWorkspace', "New Nested Session{0} opens a composer that uses the session's agent and host, so it has no agent or environment picker. Above the input, the folder picker preselects the session's folder, or No Workspace in a session without one. When the session's folder no longer exists, No Workspace is preselected and the picker says why. Pick a recent folder, browse or open a folder, or pick No Workspace to give the nested chat its own scratch folder. For a Git folder, check New Worktree to give the nested chat its own worktree and pick the branch it starts from. An agent that supports only one folder offers only the session's folder. The choice applies when you send the first request.", '')); + content.push(localize('sessionsChat.nestedSessionWorkspace', "New Nested Session{0} opens a composer that uses the session's host, so it has no environment picker. It uses the session's agent unless the session offers its chats more than one harness, which you then pick in its session options. Above the input, the folder picker preselects the session's folder, or No Workspace in a session without one. When the session's folder no longer exists, No Workspace is preselected and the picker says why. Pick a recent folder, browse or open a folder, or pick No Workspace to give the nested chat its own scratch folder. For a Git folder, check New Worktree to give the nested chat its own worktree and pick the branch it starts from. An agent that supports only one folder offers only the session's folder. The choice applies when you send the first request.", '')); content.push(getModelPickerConfigurationAccessibilityHelp()); content.push(localize('sessionsChat.mobileConfig', "On mobile, the mode and model pickers appear as tappable chips below the input. Tap a chip to open a bottom sheet where you can change the selection.")); content.push(localize('sessionsChat.history', "Use up and down arrows to navigate your request history in the input box.")); @@ -286,6 +286,10 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(chatTabsMode === SessionsChatTabsMode.Single ? localize('sessionsChat.sessionsListChatsAsSessionView', "Sessions with multiple user-facing chats show those chats nested beneath the session in the Sessions list. Use the arrow keys to navigate the list and Enter to show a chat as the session view. Side chats and subagent chats are omitted from this nested list: side chats are reachable from the Side Chats dropdown in the session's overflow menu, and subagent chats open from their pills in the chat transcript.") : localize('sessionsChat.sessionsListChatsAsTabs', "Sessions with multiple user-facing chats show those chats nested beneath the session in the Sessions list. Use the arrow keys to navigate the list and Enter to open a chat as a tab. Side chats and subagent chats are omitted from this nested list: side chats are reachable from the Side Chats dropdown in the session's overflow menu, and subagent chats open from their pills in the chat transcript.")); + // Only where it applies: a shown session lets its new chats choose among its harnesses. + if (accessor.get(ISessionsService).visibleSessions?.get().some(session => (session?.capabilities.get().supportsChatHarnesses?.length ?? 0) > 1)) { + content.push(localize('sessionsChat.newChatAgentPicker', "When a session offers more than one harness for its chats, the New Nested Session composer shows the Harness for New Chat picker in its session options, where New Session shows its harness picker, listing those harnesses. When the session options are collapsed, use the session options toggle to show them. Use Tab to reach the picker and Enter or Space to open it, then the arrow keys and Enter to choose the harness for the new chat; a harness that is not available is disabled and describes why. Choosing another harness replaces the unsent new chat. A chat keeps its harness; forks and side chats use the harness of the chat they come from. A chat tab whose harness differs from the session's names that harness, and a Sessions list row whose chats use different harnesses says so.")); + } const promoteNewChatAction = SessionsListPromoteNewChatActionContext.getValue(accessor.get(IContextKeyService)) ?? SESSIONS_LIST_PROMOTE_NEW_CHAT_ACTION_DEFAULT; content.push(promoteNewChatAction ? archiveActionWording === ChatSessionArchiveActionWording.MarkAsDone diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts index 2e230c89e45f1..aa0094c71d9fb 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -16,7 +16,9 @@ import { Emitter, Event as VSCodeEvent } from '../../../../../base/common/event. import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; -import { autorun, constObservable, derived, IObservable, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { autorun, constObservable, derived, IObservable, IReader, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; @@ -69,12 +71,11 @@ import { ISessionsService } from '../../../../services/sessions/browser/sessions import { SessionsChatBackgroundRenderer, SessionsChatBackgroundReplica } from '../../../../services/chatBackground/browser/chatBackgroundRenderer.js'; import { ISessionsChatBackground } from '../../../../services/chatBackground/browser/chatBackgroundService.js'; import { AGENTS_CENTERED_CONTENT_MAX_WIDTH } from '../../../../common/layoutConstants.js'; -import { ChatView, EXPERIMENTAL_SESSION_CHAT_INPUT_TRAILING_SPACE, findInitialTranscriptContextEntry, findTranscriptContextEntry, getSessionChatItemHorizontalPadding, getSessionComparisonRequestSummary, getTranscriptProgress, isFocusChatPillsKeyDown, NewChatView, shouldRenderRunningSessionSecondaryToolbar, shouldShowSessionChatTip, shouldShowTranscriptPreparationCompletion, shouldShowTranscriptPreparationProgress } from '../../browser/chatView.js'; +import { ChatView, EXPERIMENTAL_SESSION_CHAT_INPUT_TRAILING_SPACE, findInitialTranscriptContextEntry, getChatRespondent, findTranscriptContextEntry, getSessionChatItemHorizontalPadding, getSessionComparisonRequestSummary, getTranscriptProgress, isFocusChatPillsKeyDown, NewChatView, shouldRenderRunningSessionSecondaryToolbar, shouldShowSessionChatTip, shouldShowTranscriptPreparationCompletion, shouldShowTranscriptPreparationProgress } from '../../browser/chatView.js'; import { SessionsChatViewStateService } from '../../browser/chatViewStateService.js'; import { NewChatInSessionWidget } from '../../browser/newChatInSessionWidget.js'; import { NestedChatWorkspaceControls } from '../../browser/nestedChatWorkspacePicker.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; -import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsRecentWorkspacesService } from '../../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; @@ -103,6 +104,56 @@ suite('Sessions - Chat View', () => { teardown(() => sinon.restore()); + test('a chat answered by another harness than its session\'s presents that harness and selects from its models once resolved', () => { + const sessionResource = URI.parse('agent-host-copilotcli:/session'); + const chat = (fragment: string, harness?: string) => ({ resource: sessionResource.with({ fragment }), ...(harness ? { harness } : {}) } as IChat); + const main = chat('', 'copilot'); + const sameHarness = chat('same'); + const otherHarness = chat('other', 'claude'); + const session = { sessionId: 'session', providerId: 'test', harness: 'copilot', resource: sessionResource, chats: constObservable([main, sameHarness, otherHarness]) } as unknown as ISession; + const known = observableValue('known', false); + let otherTarget: string | undefined = undefined; + const onDidChangeModels = disposables.add(new Emitter()); + const provider = new class extends mock() { + override readonly onDidChangeModels = onDidChangeModels.event; + override readonly onDidChangeSessionTypes = VSCodeEvent.None; + override getModelsSnapshot(_sessionId: string, _desired?: string, chatResource?: URI) { + return { models: [], desiredModelResolution: { kind: 'notRequested' as const }, modelTarget: chatResource?.fragment === 'other' ? otherTarget : 'agent-host-copilotcli' }; + } + override getHarnessPresentation(harness: string, reader?: IReader) { + return known.read(reader) && harness === 'claude' ? { label: 'Claude', icon: Codicon.claude } : undefined; + } + }(); + const providers = new class extends mock() { + override getProvider(): T | undefined { + return provider as unknown as T; + } + }(); + const respondents: unknown[] = []; + disposables.add(autorun(reader => respondents.push(getChatRespondent(session, otherHarness.resource, providers, reader)))); + // Not resolved apart from the session's own target yet, then resolved, then the harness presentation loads. + otherTarget = 'agent-host-copilotcli'; + onDidChangeModels.fire(); + otherTarget = 'agent-host-claude'; + onDidChangeModels.fire(); + known.set(true, undefined); + + assert.deepStrictEqual({ + main: getChatRespondent(session, main.resource, providers, undefined), + sameHarness: getChatRespondent(session, sameHarness.resource, providers, undefined), + otherHarness: respondents, + }, { + main: undefined, + sameHarness: undefined, + otherHarness: [ + { label: 'Other Harness', icon: Codicon.agent, modelSessionType: undefined }, + { label: 'Other Harness', icon: Codicon.agent, modelSessionType: undefined }, + { label: 'Other Harness', icon: Codicon.agent, modelSessionType: 'agent-host-claude' }, + { label: 'Claude', icon: Codicon.claude, modelSessionType: 'agent-host-claude' }, + ], + }); + }); + test('keeps the scroll arrow clickable above the ruler timeline', () => { const workbench = dom.append(document.body, dom.$('.monaco-workbench.agent-sessions-workbench')); disposables.add(toDisposable(() => workbench.remove())); @@ -853,6 +904,7 @@ suite('Sessions - Chat View', () => { _currentChatResource: resource, _currentSessionObs: { get: () => undefined }, _modelRef: modelRef, + _respondentBinding: disposables.add(new MutableDisposable()), _preparationModel: { value: undefined }, _loadChat: (chatResource: URI) => loads.push(chatResource), }) as { @@ -892,6 +944,7 @@ suite('Sessions - Chat View', () => { _currentSessionObs: observableValue('session', undefined), _isPrimaryObs: constObservable(false), _modelRef: { value: undefined }, + _respondentBinding: disposables.add(new MutableDisposable()), _interactiveDisposable: disposables.add(new MutableDisposable()), _liveModelReady: liveModelReady, _modelReadOnly: modelReadOnly, @@ -991,6 +1044,7 @@ suite('Sessions - Chat View', () => { _currentChatResource: resource, _currentSessionObs: { get: () => undefined }, _modelRef: modelRef, + _respondentBinding: disposables.add(new MutableDisposable()), _preparationModel: { value: undefined }, _loadCts: loadCts, _openDiagnostics: disposables.add(new MutableDisposable()), @@ -1277,6 +1331,7 @@ suite('Sessions - Chat View', () => { _loadFailure: loadFailure, _preparationModel: { value: undefined }, _modelRef: modelRef, + _respondentBinding: disposables.add(new MutableDisposable()), _loadCts: loadCts, _openDiagnostics: disposables.add(new MutableDisposable()), chatOpenDiagnosticsService: disposables.add(new ChatOpenDiagnosticsService(new NullLogService())), @@ -1587,6 +1642,7 @@ suite('Sessions - Chat View', () => { _preparationModel: { value: undefined, clear: () => { } }, hasVisibleTranscriptContent: observableValue('transcript', false), _modelRef: modelRef, + _respondentBinding: disposables.add(new MutableDisposable()), _loadCts: loadCts, _openDiagnostics: disposables.add(new MutableDisposable()), element: dom.$('div'), @@ -2256,7 +2312,7 @@ suite('Sessions - Chat View', () => { // Another chat of the session is active, such as one open in another group. const activeChat = upcastPartial({ resource: URI.parse('chat:/active-elsewhere') }); const sessionsService = new class extends mock() { - override readonly activeSession = constObservable(upcastPartial({ sessionId: 'session', activeChat: constObservable(activeChat) })); + override readonly activeSession = constObservable(upcastPartial({ sessionId: 'session', activeChat: constObservable(activeChat), mainChat: constObservable(activeChat), isCreated: constObservable(false) })); }(); const widget = disposables.add(new NewChatInSessionWidget( {}, @@ -4385,6 +4441,7 @@ suite('Sessions - Chat View', () => { _openDiagnostics: viewStore.add(new MutableDisposable()), chatOpenDiagnosticsService: viewStore.add(new ChatOpenDiagnosticsService(new NullLogService())), _modelRef: viewStore.add(new MutableDisposable()), + _respondentBinding: viewStore.add(new MutableDisposable()), _preparationModel: { value: undefined }, isLoadingTranscript: observableValue('loading', false), element: dom.$('div'), diff --git a/src/vs/sessions/contrib/chat/test/browser/nestedChatComposer.test.ts b/src/vs/sessions/contrib/chat/test/browser/nestedChatComposer.test.ts index 20209d5545090..a0c193426bd93 100644 --- a/src/vs/sessions/contrib/chat/test/browser/nestedChatComposer.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/nestedChatComposer.test.ts @@ -366,10 +366,98 @@ suite('Sessions - New Nested Session composer', () => { assert.deepStrictEqual({ unsupported, resolves }, { unsupported: { resolves: 0, workspace: undefined }, resolves: 1 }); }); + test('refresh re-reads the chosen folder for the new agent, and falls back to the session folder when the choice is no longer offered', async () => { + const value = observableValue('support', support(repo)); + let resolves = 0; + let info: INestedChatFolderInfo = git; + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IActionWidgetService, { isVisible: false, hide: () => { } } as Partial); + instantiationService.stub(ILabelService, { getUriBasenameLabel: (uri: URI) => uri.path.split('/').pop() ?? '' } as Partial); + const controls = store.add(instantiationService.createInstance(NestedChatWorkspaceControls, { + support: value, + resolveFolder: async () => { resolves++; return info; }, + getBranches: async () => [], + getFolderSources: () => ({ recentFolders: [], canOpenLocalFolder: false }), + describeNoWorkspace: () => getNoWorkspaceChoicePresentation(false, undefined), + describeFolder: () => undefined, + folderExists: async () => true, + openLocalFolder: async () => undefined, + })); + const settle = () => new Promise(resolve => setTimeout(resolve, 0)); + const h = { controls, pick: (folder: URI) => Reflect.get(controls, '_select').call(controls, { kind: 'folder', folder }) }; + await settle(); + h.pick(other); + await settle(); + const resolvesBefore = resolves; + + // The new agent still offers other folders, but this one has no worktrees for it. + info = { supportsWorktree: false, defaultBranch: undefined }; + controls.refresh(); + await settle(); + const reread = { workspace: controls.getWorkspace(), resolves: resolves - resolvesBefore }; + + // The new agent works only in the session folder. + value.set(support(repo, false), undefined); + controls.refresh(); + await settle(); + + assert.deepStrictEqual({ reread, fallback: controls.getWorkspace() }, { + reread: { workspace: { kind: 'folder', folder: other, isolation: 'folder' }, resolves: 1 }, + // The default: the session folder without a worktree, which needs no request. + fallback: undefined, + }); + }); + + test('a chat sent right after a refresh waits for the folder to be re-read, so its kept worktree choice is requested', async () => { + const answers: (() => void)[] = []; + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IActionWidgetService, { isVisible: false, hide: () => { } } as Partial); + instantiationService.stub(ILabelService, { getUriBasenameLabel: (uri: URI) => uri.path.split('/').pop() ?? '' } as Partial); + const controls = store.add(instantiationService.createInstance(NestedChatWorkspaceControls, { + support: constObservable(support(repo)), + resolveFolder: () => new Promise(resolve => answers.push(() => resolve(git))), + getBranches: async () => [], + getFolderSources: () => ({ recentFolders: [], canOpenLocalFolder: false }), + describeNoWorkspace: () => getNoWorkspaceChoicePresentation(false, undefined), + describeFolder: () => undefined, + folderExists: async () => true, + openLocalFolder: async () => undefined, + })); + const settle = () => new Promise(resolve => setTimeout(resolve, 0)); + const answerAll = () => answers.splice(0).forEach(answer => answer()); + Reflect.get(controls, '_select').call(controls, { kind: 'folder', folder: other }); + answerAll(); + await settle(); + Reflect.get(controls, '_setWorktree').call(controls, true); + + // The new chat's agent changed: the folder is re-read, and the chat is sent before that finishes. + controls.refresh(); + const sent = controls.resolveWorkspace(); + const whileReading = controls.getWorkspace(); + answerAll(); + + assert.deepStrictEqual({ whileReading, sent: await sent }, { + whileReading: { kind: 'folder', folder: other, isolation: 'folder' }, + sent: { kind: 'folder', folder: other, isolation: 'worktree', baseBranch: 'main' }, + }); + }); + + /** The input's shared harness picker, rendering its slot like the real one. */ + function fakeHarnessPicker(isVisible: IObservable) { + return { + isVisible, + render: (container: HTMLElement, options?: { className?: string }) => { + container.appendChild(mainWindow.document.createElement('div')).className = `sessions-chat-picker-slot ${options?.className}`; + }, + }; + } + test('renders the New Session structure: the options row in the centered content, the repository controls where New Session has them', () => { const experimental = observableValue('experimental', false); const supported = observableValue('support', support(repo)); const offersWorktree = observableValue('offersWorktree', true); + // Hidden unless the session offers its chats more than one harness (and behind the setting). + const harnessVisible = observableValue('harnessVisible', false); const placements: (string | undefined)[] = []; const parent = mainWindow.document.createElement('div'); const owner = { @@ -387,6 +475,7 @@ suite('Sessions - New Nested Session composer', () => { render: (content: HTMLElement) => content.appendChild(mainWindow.document.createElement('div')).classList.add('new-chat-input-container'), placeRepositoryControls: (host?: HTMLElement) => placements.push(host?.className), hostNoticeContainerElement: undefined, + sessionTypePicker: fakeHarnessPicker(harnessVisible), }, storageService: { getBoolean: () => true }, _renderSubSessionTip: Reflect.get(NewChatInSessionWidget.prototype, '_renderSubSessionTip'), @@ -405,8 +494,17 @@ suite('Sessions - New Nested Session composer', () => { const noWorktree = structure()[0]; supported.set(undefined, undefined); const unsupported = structure()[0][0]; + // More than one harness: the harness picker follows the repository controls in the tray, which it fills even + // without a worktree to offer, and keeps the row shown on a host without nested chat workspaces. + harnessVisible.set(true, undefined); + const details = parent.querySelector('.new-chat-session-options-details')!; + const withHarness = { + row: structure()[0][0], + tray: structure()[0][2], + trayChildren: [...details.children].map(child => child.className), + }; - assert.deepStrictEqual({ standard, experimentalLayout, noWorktree, unsupported }, { + assert.deepStrictEqual({ standard, experimentalLayout, noWorktree, unsupported, withHarness }, { standard: { structure: [ ['new-session-workspace-picker-container', 'nested-chat-folder-picker', 'new-chat-session-options-details legacy-session-options-details', 'monaco-button new-chat-session-options-toggle codicon codicon-chevron-left-compact[hidden]'], @@ -425,6 +523,11 @@ suite('Sessions - New Nested Session composer', () => { }, noWorktree: ['new-session-workspace-picker-container', 'nested-chat-folder-picker', 'new-chat-session-options-details[hidden]', 'monaco-button new-chat-session-options-toggle codicon codicon-chevron-left-compact[hidden]'], unsupported: 'new-session-workspace-picker-container[hidden]', + withHarness: { + row: 'new-session-workspace-picker-container', + tray: 'new-chat-session-options-details', + trayChildren: ['new-chat-repository-controls-host', 'sessions-chat-picker-slot sessions-chat-session-type-picker sessions-workspace-category-picker-slot'], + }, }); }); @@ -440,7 +543,7 @@ suite('Sessions - New Nested Session composer', () => { _sessionOptionsExpanded: observableValue('expanded', Reflect.get(NewChatInSessionWidget.prototype, '_getInitialSessionOptionsExpanded').call({ storageService: storage })), _workspaceSupport: constObservable(support(repo)), _workspaceControls: { renderFolderPicker: () => { }, offersWorktree: constObservable(true) }, - _newChatInput: { render: () => { }, placeRepositoryControls: () => { }, hostNoticeContainerElement: undefined }, + _newChatInput: { render: () => { }, placeRepositoryControls: () => { }, hostNoticeContainerElement: undefined, sessionTypePicker: fakeHarnessPicker(constObservable(false)) }, storageService: storage, _renderSubSessionTip: () => { }, }; diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatInSessionWidget.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInSessionWidget.test.ts new file mode 100644 index 0000000000000..95475793e6b2a --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInSessionWidget.test.ts @@ -0,0 +1,228 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { autorun, constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { IChat, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { IActiveSession, ICreateNewChatInSessionOptions, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { NewChatInSessionWidget } from '../../browser/newChatInSessionWidget.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISessionsRecentWorkspacesService } from '../../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; +import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; +import { NestedChatWorkspaceControls } from '../../browser/nestedChatWorkspacePicker.js'; +import { SharedChatInputComposer } from '../../browser/sharedChatInputComposer.js'; +import { TestAccessibilityService } from '../../../../../platform/accessibility/test/common/testAccessibilityService.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; + +class TestNewChatInSessionWidget extends NewChatInSessionWidget { + send(query: string, background: boolean): Promise { + return this._send(query, undefined, background); + } + + selectHarness(harness: string): Promise { + return this._selectHarness(harness); + } +} + +suite('NewChatInSessionWidget', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('reads the nested chat workspace for the draft\'s own agent, and re-reads it when a harness switch replaces the draft', () => { + const store = new DisposableStore(); + const chat = (fragment: string, status: SessionStatus) => upcastPartial({ resource: URI.parse(`test:///session#${fragment}`), status: constObservable(status) }); + const main = chat('', SessionStatus.Completed); + const copilotDraft = chat('copilot-draft', SessionStatus.Untitled); + const claudeDraft = chat('claude-draft', SessionStatus.Untitled); + const activeChat = observableValue('activeChat', copilotDraft); + const session = upcastPartial({ + sessionId: 's', providerId: 'p', isCreated: constObservable(true), mainChat: constObservable(main), activeChat, + workspace: constObservable(undefined), capabilities: constObservable({ supportsMultipleChats: true }), + }); + const supportFor: string[] = []; + const controlsCalls: string[] = []; + const instantiationService = new class extends mock() { + override createInstance = ((ctor: unknown) => ctor === NestedChatWorkspaceControls + ? { reset: () => controlsCalls.push('reset'), refresh: () => controlsCalls.push('refresh'), dispose: () => { } } + : Object.assign(Object.create(SharedChatInputComposer.prototype), { dispose: () => { } })) as IInstantiationService['createInstance']; + }(); + const widget = store.add(new NewChatInSessionWidget( + {}, + new TestConfigurationService(), + instantiationService, + new NullLogService(), + new class extends mock() { }(), + new class extends mock() { + override getProvider(): T | undefined { + return upcastPartial({ + getNestedChatWorkspaceSupport: (_sessionId: string, chat: URI) => { supportFor.push(chat.fragment); return undefined; }, + }) as T; + } + }(), + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { override readonly activeSession = constObservable(session); }(), + store.add(new InMemoryStorageService()), + new TestAccessibilityService(), + new class extends mock() { }(), + )); + store.add(autorun(reader => { Reflect.get(widget, '_workspaceSupport').read(reader); })); + + activeChat.set(claudeDraft, undefined); + store.dispose(); + + assert.deepStrictEqual({ supportFor, controlsCalls }, { supportFor: ['copilot-draft', 'claude-draft'], controlsCalls: ['refresh'] }); + }); + + test('resets the composer to a chat of the main harness after a background send', async () => { + const draft = upcastPartial({ resource: URI.parse('test:///session#draft'), harness: 'other' }); + const session = upcastPartial({ harness: 'main', activeChat: constObservable(draft) }); + const resets: (ICreateNewChatInSessionOptions | undefined)[] = []; + const widget: TestNewChatInSessionWidget = Object.assign(Object.create(TestNewChatInSessionWidget.prototype), { + _session: constObservable(session), + _workspaceSupport: constObservable(undefined), + _chat: constObservable(undefined), + _store: { isDisposed: false }, + logService: new NullLogService(), + sessionsService: new class extends mock() { + override async openNewChatInSession(_session: IActiveSession, options?: ICreateNewChatInSessionOptions): Promise { + resets.push(options); + } + }(), + sessionsManagementService: new class extends mock() { + override async sendRequest(): Promise { } + }(), + }); + + const sent = await widget.send('hello', true); + + assert.deepStrictEqual({ sent, resets }, { sent: true, resets: [{ forceNew: true }] }); + }); + + test('picking another harness swaps the unsent peer chat for one of that harness and deletes the replaced draft', async () => { + const chat = (fragment: string, harness: string | undefined, status = SessionStatus.Untitled) => upcastPartial({ resource: URI.parse(`test:///session#${fragment}`), status: constObservable(status), ...(harness ? { harness } : {}) }); + const main = chat('', 'main', SessionStatus.Completed); + const draft = chat('draft', undefined); + const replacement = chat('replacement', 'other'); + const activeChat = observableValue('activeChat', draft); + const session = upcastPartial({ harness: 'main', isCreated: constObservable(true), mainChat: constObservable(main), activeChat }); + const opened: (ICreateNewChatInSessionOptions | undefined)[] = []; + const deleted: string[] = []; + const widget: TestNewChatInSessionWidget = Object.assign(Object.create(TestNewChatInSessionWidget.prototype), { + _session: constObservable(session), + _chat: constObservable(undefined), + logService: new NullLogService(), + sessionsService: new class extends mock() { + override async openNewChatInSession(_session: IActiveSession, options?: ICreateNewChatInSessionOptions): Promise { + opened.push(options); + activeChat.set(replacement, undefined); + } + }(), + sessionsManagementService: new class extends mock() { + override async deleteChat(_session: IActiveSession, chatUri: URI): Promise { + deleted.push(chatUri.fragment); + return true; + } + }(), + }); + + await widget.selectHarness('main'); + await widget.selectHarness('other'); + + assert.deepStrictEqual({ opened, deleted, active: activeChat.get().resource.fragment }, { + opened: [{ harness: 'other' }], + deleted: ['draft'], + active: 'replacement', + }); + }); + + test('ignores a harness pick while a switch is pending, and keeps the draft when the user left the session meanwhile', async () => { + const chat = (fragment: string, status = SessionStatus.Untitled) => upcastPartial({ resource: URI.parse(`test:///session#${fragment}`), status: constObservable(status) }); + const main = chat('', SessionStatus.Completed); + const activeChat = observableValue('activeChat', chat('draft')); + const session = upcastPartial({ sessionId: 'session', harness: 'main', isCreated: constObservable(true), mainChat: constObservable(main), activeChat }); + const activeSession = observableValue('activeSession', session); + const opened: (string | undefined)[] = []; + const deleted: string[] = []; + let finishOpen!: () => void; + const widget: TestNewChatInSessionWidget = Object.assign(Object.create(TestNewChatInSessionWidget.prototype), { + _session: activeSession, + _chat: constObservable(undefined), + logService: new NullLogService(), + sessionsService: new class extends mock() { + override openNewChatInSession(_session: IActiveSession, options?: ICreateNewChatInSessionOptions): Promise { + opened.push(options?.harness); + return new Promise(resolve => finishOpen = () => { activeChat.set(chat('replacement'), undefined); resolve(); }); + } + }(), + sessionsManagementService: new class extends mock() { + override async deleteChat(_session: IActiveSession, chatUri: URI): Promise { + deleted.push(chatUri.fragment); + return true; + } + }(), + }); + + const first = widget.selectHarness('other'); + await widget.selectHarness('third'); + activeSession.set(undefined, undefined); + finishOpen(); + await first; + + assert.deepStrictEqual({ opened, deleted }, { opened: ['other'], deleted: [] }); + }); + + test('a send while a harness switch is in flight waits for it and reaches the picked harness\'s chat', async () => { + const chat = (fragment: string, status = SessionStatus.Untitled) => upcastPartial({ resource: URI.parse(`test:///session#${fragment}`), status: constObservable(status) }); + const main = chat('', SessionStatus.Completed); + const draft = chat('draft'); + const replacement = chat('replacement'); + const activeChat = observableValue('activeChat', draft); + const session = upcastPartial({ sessionId: 'session', harness: 'main', isCreated: constObservable(true), mainChat: constObservable(main), activeChat }); + const events: string[] = []; + let finishOpen!: () => void; + const widget: TestNewChatInSessionWidget = Object.assign(Object.create(TestNewChatInSessionWidget.prototype), { + _session: constObservable(session), + _workspaceSupport: constObservable(undefined), + _chat: constObservable(undefined), + _store: { isDisposed: false }, + logService: new NullLogService(), + sessionsService: new class extends mock() { + override openNewChatInSession(): Promise { + return new Promise(resolve => finishOpen = () => { activeChat.set(replacement, undefined); resolve(); }); + } + }(), + sessionsManagementService: new class extends mock() { + override async deleteChat(_session: IActiveSession, chatUri: URI): Promise { + events.push(`delete:${chatUri.fragment}`); + return true; + } + override async sendRequest(_session: IActiveSession, target: IChat, options: { query: string }): Promise { + events.push(`send:${target.resource.fragment}:${options.query}`); + } + }(), + }); + + const switched = widget.selectHarness('other'); + // The user types and sends while the picked harness's chat is still being opened. + const sent = widget.send('hello', false); + finishOpen(); + await Promise.all([switched, sent]); + + assert.deepStrictEqual({ sent: await sent, events }, { sent: true, events: ['delete:draft', 'send:replacement:hello'] }); + }); +}); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts index edf55f1b9992d..4575459e4654f 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts @@ -19,9 +19,9 @@ import { IActionListDelegate, IActionListItem, ActionListItemKind } from '../../ import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { MockKeybindingService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; -import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; import { IChatSessionsService, ResolvedChatSessionsExtensionPoint, SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; @@ -30,7 +30,7 @@ import { ChatEntitlement, IChatEntitlementService } from '../../../../../workben import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IProviderSessionType, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -import { GITHUB_REMOTE_FILE_SCHEME, SessionTypeAuthRequirement, ISession, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { GITHUB_REMOTE_FILE_SCHEME, SessionTypeAuthRequirement, IChat, ISession, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { IPickedSessionType, IPreferredSessionType, ISessionTypePickerOptions, SessionTypePicker } from '../../browser/sessionTypePicker.js'; import '../../browser/media/chatWidget.css'; @@ -626,6 +626,126 @@ suite('SessionTypePicker', () => { }); }); + suite('peer-chat mode', () => { + const copilot = sessionType('agent-host', 'copilotcli', 'Copilot'); + const claude = sessionType('agent-host', 'claude', 'Claude', SessionType.AgentHostClaude); + const harnessTypes = new Map([['copilot', copilot.sessionType], ['claude', claude.sessionType]]); + const provider = upcastPartial({ id: 'agent-host', label: 'Agent Host', sessionTypes: [copilot.sessionType, claude.sessionType], getHarnessSessionType: harness => harnessTypes.get(harness) }); + + function peerSession(harnesses: readonly string[] | undefined): ISession { + return { + providerId: 'agent-host', + sessionType: 'copilotcli', + harness: 'copilot', + status: constObservable(SessionStatus.Completed), + workspace: constObservable(undefined), + capabilities: constObservable({ supportsMultipleChats: true, ...(harnesses ? { supportsChatHarnesses: harnesses } : {}) }), + } as unknown as ISession; + } + + function createPeerPicker(chat: ISettableObservable, selected: string[], actionWidgetService?: Partial, services: ITestPickerServices = {}) { + return createPicker(disposables, session, management, storage, { + peerChat: { chat, selectHarness: async harness => { selected.push(harness); } }, + }, actionWidgetService, [], { providers: [provider], ...services }); + } + + test('offers the session\'s chat harnesses, shows the chat\'s, and hands a pick to the composer without persisting it', () => { + management.setSessionTypesForFolder(folder, [sessionType('other', 'cloud', 'Cloud')]); + const draft = observableValue('draft', { harness: 'claude' } as IChat); + const selected: string[] = []; + session.set(peerSession(['copilot', 'claude']), undefined); + const picker = createPeerPicker(draft, selected); + const container = document.createElement('div'); + picker.render(container); + const offered = { types: picker.offeredSessionTypeIds, pick: picker.selectedPick, modelTarget: picker.modelTargetChatSessionType.get(), visible: picker.isVisible.get() }; + + picker.pick({ providerId: 'agent-host', sessionTypeId: 'copilotcli' }); + const stored = storage.get('sessions.userSelectedSessionType', StorageScope.PROFILE); + draft.set({} as IChat, undefined); + const afterSwitch = picker.selectedPick; + draft.set(undefined, undefined); + const sent = { visible: picker.isVisible.get(), hidden: container.querySelector('.action-label')?.classList.contains('hidden') }; + + assert.deepStrictEqual({ offered, selected, stored, afterSwitch, sent }, { + offered: { types: ['copilotcli', 'claude'], pick: { providerId: 'agent-host', sessionTypeId: 'claude' }, modelTarget: SessionType.AgentHostClaude, visible: true }, + selected: ['copilot'], + stored: undefined, + afterSwitch: { providerId: 'agent-host', sessionTypeId: 'copilotcli' }, + sent: { visible: false, hidden: true }, + }); + }); + + test('names itself the harness picker for a new chat, while the new-session view keeps its labels', () => { + const labels = (peer: boolean) => { + let widgetLabel: string | undefined; + const actionWidgetService = new class extends mock() { + override readonly isVisible = false; + override show(_user: string, _supportsPreview: boolean, _items: readonly IActionListItem[], _delegate: IActionListDelegate, _anchor: unknown, _container: unknown, _buttons: unknown, accessibilityProvider?: { getWidgetAriaLabel(): string }): void { + widgetLabel = accessibilityProvider?.getWidgetAriaLabel(); + } + }(); + let picker: TestSessionTypePicker; + if (peer) { + session.set(peerSession(['copilot', 'claude']), undefined); + picker = createPeerPicker(observableValue('draft', { harness: 'claude' } as IChat), [], actionWidgetService); + } else { + management.setSessionTypesForFolder(folder, [copilot, claude]); + const newSession = observableValue('newSession', createFakeSession('agent-host', 'claude', folder)); + picker = createPicker(disposables, newSession, management, storage, undefined, actionWidgetService); + } + const container = document.createElement('div'); + picker.render(container); + picker.showPicker(); + return { trigger: container.querySelector('.action-label')?.getAttribute('aria-label'), widget: widgetLabel }; + }; + assert.deepStrictEqual({ peer: labels(true), newSession: labels(false) }, { + peer: { trigger: 'Pick Harness for New Chat, Claude', widget: 'Harness for New Chat' }, + newSession: { trigger: 'Pick Session Type, Claude', widget: 'Session Type' }, + }); + }); + + test('stays hidden while the session offers no choice of harness', () => { + const draft = observableValue('draft', {} as IChat); + const visible = (harnesses: readonly string[] | undefined) => { + session.set(peerSession(harnesses), undefined); + const picker = createPeerPicker(draft, []); + picker.render(document.createElement('div')); + return picker.isVisible.get(); + }; + assert.deepStrictEqual([visible(undefined), visible(['copilot']), visible(['copilot', 'claude'])], [false, false, true]); + }); + + test('describes a harness that is not available', () => { + const chatSessionsService = new class extends mock() { + override getChatSessionContribution(type: string): ResolvedChatSessionsExtensionPoint | undefined { + return { type, name: type, displayName: type, description: '', icon: Codicon.copilot }; + } + override requiresCopilotSignInForSessionType(): boolean { return false; } + override supportsAutoModelForSessionType(): boolean { return false; } + override requiresCustomModelsForSessionType(): boolean { return true; } + }(); + let items: readonly IActionListItem[] = []; + const actionWidgetService = new class extends mock() { + override show(_user: string, _supportsPreview: boolean, shown: readonly IActionListItem[]): void { + items = shown; + } + }(); + session.set(peerSession(['copilot', 'claude']), undefined); + // Signed out without models: a harness that cannot set itself up is not available. + const chatEntitlementService = new class extends mock() { + override readonly entitlement = ChatEntitlement.Unknown; + override readonly anonymous = false; + override readonly clientByokEnabled = false; + }(); + const picker = createPeerPicker(observableValue('draft', {} as IChat), [], actionWidgetService, { chatSessionsService, chatEntitlementService }); + picker.render(document.createElement('div')); + picker.showPicker(); + const claudeItem = items.find(item => item.label === 'Claude'); + + assert.deepStrictEqual({ disabled: claudeItem?.disabled, hasDescription: !!claudeItem?.description }, { disabled: true, hasDescription: true }); + }); + }); + test('preferred session type is the first one and follows session-type changes', () => { management.setSessionTypes([ sessionType('local-1', 'local', 'Local'), diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostChatMoveErrors.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostChatMoveErrors.ts index 9221fa07468ae..5f4ff8be56c8a 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostChatMoveErrors.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostChatMoveErrors.ts @@ -28,6 +28,9 @@ const refusals: ReadonlySet = new Set([ AgentChatMoveRefusal.NotSingleChat, AgentChatMoveRefusal.NotStarted, AgentChatMoveRefusal.DifferentCredentialOwner, + AgentChatMoveRefusal.AgentUnavailable, + AgentChatMoveRefusal.AgentWithoutChats, + AgentChatMoveRefusal.AgentConfigUnavailable, ]); /** @@ -47,11 +50,24 @@ export function getChatMoveRefusal(error: unknown): AgentChatMoveRefusal | undef return undefined; } +/** What a refused move was, where its explanation depends on it. */ +export interface IChatMoveRefusalContext { + /** Whether the chat shares its folder with another chat of its session (see {@link AgentChatMoveRefusal.Workspaceless}). */ + readonly chatSharesFolder: boolean; + /** Whether the chat was moved into a new session. */ + readonly toNewSession?: boolean; +} + /** * A user-facing explanation of why a host refused a move. `chatSharesFolder` tells a {@link AgentChatMoveRefusal.Workspaceless} * refusal of a chat that shares its folder with another chat of its session from one that has no folder (or lost it). */ -export function getChatMoveRefusalMessage(reason: AgentChatMoveRefusal, chatSharesFolder: boolean): string { +export function getChatMoveRefusalMessage(reason: AgentChatMoveRefusal, context: IChatMoveRefusalContext): string { + const { chatSharesFolder } = context; + if (reason === AgentChatMoveRefusal.Unsupported && context.toNewSession) { + // Refusing a new session, the host means the chat has no conversation of its own to take into it. + return localize('chatMoveRefusal.noConversation', "This chat has no conversation of its own yet, so it can't be moved into a new session."); + } switch (reason) { case AgentChatMoveRefusal.Disabled: return localize('chatMoveRefusal.disabled', "Moving chats is turned off."); @@ -62,7 +78,7 @@ export function getChatMoveRefusalMessage(reason: AgentChatMoveRefusal, chatShar case AgentChatMoveRefusal.InvalidAnchor: return localize('chatMoveRefusal.invalidAnchor', "The chat can't be placed at that position."); case AgentChatMoveRefusal.DifferentProvider: - return localize('chatMoveRefusal.differentProvider', "Chats can only move between sessions of the same agent."); + return localize('chatMoveRefusal.differentProvider', "Chats can only move between sessions of the same agent. Pick a session that uses this chat's agent."); case AgentChatMoveRefusal.SessionArchived: return localize('chatMoveRefusal.sessionArchived', "Chats can't be moved into or out of an archived session."); case AgentChatMoveRefusal.SessionNotReady: @@ -93,6 +109,12 @@ export function getChatMoveRefusalMessage(reason: AgentChatMoveRefusal, chatShar return localize('chatMoveRefusal.notStarted', "Send a message in this chat before moving it."); case AgentChatMoveRefusal.DifferentCredentialOwner: return localize('chatMoveRefusal.differentCredentialOwner', "This chat uses a different account's credentials than the destination session."); + case AgentChatMoveRefusal.AgentUnavailable: + return localize('chatMoveRefusal.agentUnavailable', "This chat's agent is unavailable or disabled. Enable it to move the chat."); + case AgentChatMoveRefusal.AgentWithoutChats: + return localize('chatMoveRefusal.agentWithoutChats', "The destination session's agent can't host chats of other agents, or this chat's agent can't join one. Pick a session whose agent supports multiple chats, or one of this chat's agent."); + case AgentChatMoveRefusal.AgentConfigUnavailable: + return localize('chatMoveRefusal.agentConfigUnavailable', "This chat's agent couldn't load its settings for the move. Try again."); } } @@ -100,7 +122,7 @@ export function getChatMoveRefusalMessage(reason: AgentChatMoveRefusal, chatShar * Replaces a host refusal by a {@link SessionMoveRefusedError} explaining it; other errors are returned unchanged. * `chatSharesFolder`: whether the moved chat shares its folder with another chat of its session. */ -export function toChatMoveError(error: unknown, chatSharesFolder: boolean): unknown { +export function toChatMoveError(error: unknown, context: IChatMoveRefusalContext): unknown { const reason = getChatMoveRefusal(error); - return reason === undefined ? error : new SessionMoveRefusedError(getChatMoveRefusalMessage(reason, chatSharesFolder)); + return reason === undefined ? error : new SessionMoveRefusedError(getChatMoveRefusalMessage(reason, context)); } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 4847c2f6f9d40..217956016f1cf 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -25,7 +25,7 @@ import { getAgentHostChatId } from '../../../../../platform/agentHost/common/age import { agentModelConfigurationReportKey } from '../../../../../platform/agentHost/common/agentModelConfiguration.js'; import { isNativeAgentHost } from '../../../../../platform/agentHost/common/meta/agentHostSessionUrisMeta.js'; import { localize } from '../../../../../nls.js'; -import { AgentSession, AuthenticateParams, AuthenticateResult, CODEX_AGENT_PROVIDER_ID, type IAgentCreateChatWorkspace, type IAgentSessionChatMetadata, IAgentSessionMetadata, protectedResourcesRequireGitHubCopilotSignIn } from '../../../../../platform/agentHost/common/agent.js'; +import { AgentSession, AuthenticateParams, AuthenticateResult, CODEX_AGENT_PROVIDER_ID, type IAgentCreateChatWorkspace, type IAgentSessionChatMetadata, IAgentSessionMetadata, protectedResourcesRequireGitHubCopilotSignIn, type IAgentCreateChatRequestOptions } from '../../../../../platform/agentHost/common/agent.js'; import { AgentMergeSessionOverrides, AgentMergeSessionState, readAgentMergeFolderState, readAgentMergeFolderStates } from '../../../../../platform/agentHost/common/agentMerge.js'; import { readAgentSdkSetupInfos } from '../../../../../platform/agentHost/common/agentSdkSetup.js'; import { AgentHostPrewarmSessionsSettingId, type IAgentCanvasInfo, IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; @@ -46,10 +46,12 @@ import { buildOpenSessionLinkForChat } from '../../../../../platform/agentHost/c import { parseGitHubIssueUrl, parseGitHubPullRequestUrl } from '../../../../../platform/github/common/githubUrls.js'; import { getEffectiveAgents } from '../../../../../platform/agentHost/common/customAgents.js'; import { KNOWN_MODE_VALUES, omitAutomationSessionTemplateConfigValues, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; -import { filterSessionConfigValues, getChatConfigChain, getChatConfigWriteTarget, getEffectiveChatConfig, getEffectiveSessionApprovalValue, hasChatConfigProperties, isChatConfigProperty, mergeChatConfigChain, getSessionApprovalProperty, getSessionWorkspaceProperties, isSessionConfigWritable, readSessionIsolation, validateChatConfigWrite, validateSessionConfigWrite, writeSessionIsolation } from '../../../../../platform/agentHost/common/sessionConfigProperties.js'; +import { filterSessionConfigValues, getChatConfigChain, getChatConfigWriteTarget, getEffectiveSessionApprovalValue, hasChatConfigProperties, isChatConfigProperty, mergeChatConfigChain, getSessionApprovalProperty, getSessionWorkspaceProperties, isSessionConfigWritable, readSessionIsolation, validateChatConfigWrite, validateSessionConfigWrite, writeSessionIsolation } from '../../../../../platform/agentHost/common/sessionConfigProperties.js'; import { ChatAgentFilesLocationsSettingId, ChatHookFilesLocationsSettingId, getChatSensitiveEditLocations, mergeChatSensitiveEditLocations, type IChatSensitiveEditLocation } from '../../../../../platform/chat/common/chatSettings.js'; import { applyLegacyAutomationSessionConfig } from '../../../../../platform/agentHost/common/automationConfig.js'; import { migrateLegacyAutopilotConfig } from '../../../../../platform/agentHost/common/agentHostSchema.js'; +import { ChatProvidersConfigKey, ChatProvidersSettingId, toAgentHostChatHarness } from '../../../../../platform/agentHost/common/agentHostChatProviders.js'; +import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js'; import { readAgentDevContainerWorktreeMetadata, withAgentDevContainerWorktreeMetadata, type IAgentDevContainerWorktreeMetadata } from '../../../../../platform/agentHost/common/meta/agentDevContainerWorktreeMeta.js'; import { readAgentMessageDelegationMeta } from '../../../../../platform/agentHost/common/meta/agentMessageDelegationMeta.js'; import { readAgentHeadBranchPullRequest, toAgentHeadBranchPullRequestMeta } from '../../../../../platform/agentHost/common/meta/agentSessionGitHubReferences.js'; @@ -78,6 +80,7 @@ import { IWorkspaceTrustManagementService } from '../../../../../platform/worksp import { AgentHostDownloadProgress } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostDownloadProgress.js'; import { composeSessionActiveClient, IAgentCustomizationScope, IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IAgentHostMcpAppToolsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpAppToolsService.js'; +import { getAgentHostEffectiveChatConfig, hostAppliesSessionConfigToDefaultChat, hostPublishesCompleteChatConfig } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/applyAgentHostSessionConfig.js'; import { AgentHostBackgroundShellOutputs } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostBackgroundShells.js'; import type { IChatBackgroundShell } from '../../../../../workbench/contrib/chat/common/sessionChatPills.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; @@ -105,7 +108,7 @@ import { IArtifactModel } from '../../../../../platform/artifactIntegrations/com import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import type { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsRecentWorkspacesService } from '../../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; -import { IAutomationSessionConfiguration, IChatMovedEvent, ISessionChatMoveDestination, SessionMoveRefusedError, IDeleteChatOptions, type INestedChatFolderInfo, type INestedChatWorkspace, type INestedChatWorkspaceSupport, NestedChatWorkspaceError, type IForkChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionChangesetOperationInvokedEvent, ISessionConfigurationSnapshot, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionPermissionOption, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../../../services/sessions/common/sessionsProvider.js'; +import { IAutomationSessionConfiguration, IChatMovedEvent, ISessionChatMoveDestination, SessionMoveRefusedError, IDeleteChatOptions, type INestedChatFolderInfo, type INestedChatWorkspace, type INestedChatWorkspaceSupport, NestedChatWorkspaceError, type IForkChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionChangesetOperationInvokedEvent, ISessionConfigurationSnapshot, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionPermissionOption, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration, ICreateNewChatOptions, IHarnessPresentation } from '../../../../services/sessions/common/sessionsProvider.js'; import { AgentHostChatMoveTracker } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatMoveTracker.js'; import type { AgentChatMoveDestination } from '../../../../../platform/agentHost/common/agentHostChatMove.js'; import { toChatMoveError } from './agentHostChatMoveErrors.js'; @@ -279,6 +282,7 @@ interface ISerializedSessionMetadata { readonly summary?: string; readonly kind: 'default' | 'peer'; readonly origin?: ChatOrigin; + readonly provider?: string; readonly interactivity?: ProtocolChatInteractivity; readonly archived?: boolean; readonly isRead?: boolean; @@ -344,6 +348,7 @@ function serializeMetadata(meta: IAgentSessionMetadata, discovery?: IAgentHostSe summary: chat.summary, kind: chat.kind, origin: chat.origin, + ...(readChatMetadataProvider(chat) !== undefined ? { provider: readChatMetadataProvider(chat) } : {}), ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), ...(chat.archived === true ? { archived: true } : {}), isRead: chat.isRead, @@ -446,11 +451,12 @@ function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMeta status: deserializeStatus(raw), project: raw.project ? { uri: URI.parse(raw.project.uri), displayName: raw.project.displayName } : undefined, changes: raw.changes, - chats: raw.chats?.map(chat => ({ + chats: raw.chats?.map((chat): IAgentSessionChatMetadata => ({ chat: URI.parse(chat.chat), summary: chat.summary, kind: chat.kind, origin: chat.origin, + ...(typeof chat.provider === 'string' ? { provider: chat.provider } : {}), ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), ...(chat.archived === true ? { archived: true } : {}), isRead: chat.isRead, @@ -465,13 +471,18 @@ function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMeta } } +function readChatMetadataProvider(chat: IAgentSessionChatMetadata): string | undefined { + return chat.provider; +} + /** `defaultChat` is the session's advertised default chat (`SessionSummary.defaultChat`). */ function chatMetadataFromSummary(summary: Pick, defaultChat: string | undefined): readonly IAgentSessionChatMetadata[] | undefined { - return summary.chats?.map(chat => ({ + return summary.chats?.map((chat): IAgentSessionChatMetadata => ({ chat: URI.parse(chat.resource), summary: chat.title, kind: chat.resource === defaultChat ? 'default' : 'peer', origin: chat.origin, + ...(chat.provider !== undefined ? { provider: chat.provider } : {}), ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), ...(chat.status !== undefined ? { status: chat.status, @@ -890,6 +901,13 @@ export interface IAgentHostAdapterOptions { readonly getBackendChatResource?: (chat: URI) => URI | undefined; /** Agent capability lookup shared by every adapter owned by this provider. */ readonly agentCapabilities: IObservable | undefined>; + /** + * Agent providers a new peer chat may use on this connection, or `undefined` when the + * host does not advertise chats backed by different agents (or the feature is disabled). + */ + readonly chatProviders?: IObservable; + /** The resource scheme an agent's sessions and models are registered under on this host. */ + readonly resourceSchemeForAgent?: (agentProvider: string) => string; /** Whether this provider can present server-published canvas channels for the agent. */ readonly supportsCanvasPresentation?: (agentProvider: string) => boolean; /** @@ -1103,6 +1121,10 @@ class AdditionalChat extends Disposable { private readonly _isRead: ISettableObservable; private readonly _origin: ChatOrigin | undefined; private readonly _changesSummary: ISettableObservable; + /** The agent provider backing this chat; it never changes for the chat's lifetime. */ + readonly agentProvider: string; + /** Whether {@link agentProvider} was reported by the host rather than inferred from the session. */ + private readonly _reportedProvider: string | undefined; private _movable: boolean; /** Whether the host advertises this chat as a move source (`ChatSummary.movable`). */ @@ -1110,10 +1132,12 @@ class AdditionalChat extends Disposable { return this._movable; } - constructor(resource: URI, summary: AgentHostChatSummary, createdAtFallback: Date, changesets: IObservable, backgroundShells: IObservable, private readonly _acquireDetails: () => IDisposable, sessionWorkspace: IObservable, mapWorkingDirectoryUri: AgentHostUriMapper, isNew: boolean = false, parentChat?: URI, sessionIsArchived: IObservable = constObservable(false), canArchive: IObservable = constObservable(false), output?: IChatOutputObs, sessionIsReadOnly: IObservable = constObservable(false), connectionStatus?: IObservable, allowOfflineDrafts: IObservable = constObservable(false)) { + constructor(resource: URI, summary: AgentHostChatSummary, createdAtFallback: Date, changesets: IObservable, backgroundShells: IObservable, private readonly _acquireDetails: () => IDisposable, sessionWorkspace: IObservable, mapWorkingDirectoryUri: AgentHostUriMapper, isNew: boolean = false, parentChat?: URI, sessionIsArchived: IObservable = constObservable(false), canArchive: IObservable = constObservable(false), output?: IChatOutputObs, sessionIsReadOnly: IObservable = constObservable(false), connectionStatus?: IObservable, allowOfflineDrafts: IObservable = constObservable(false), sessionAgentProvider?: string) { super(); this.backendUri = URI.parse(summary.resource); this._origin = summary.origin; + this._reportedProvider = summary.provider; + this.agentProvider = summary.provider ?? sessionAgentProvider ?? ''; this._movable = summary.movable === true; const modifiedAt = summary.modifiedAt ? new Date(summary.modifiedAt) : undefined; this._title = observableValue('chatTitle', summary.title || localize('newChatTab', "New Chat")); @@ -1152,6 +1176,7 @@ class AdditionalChat extends Disposable { : derived(reader => ({ ...DEFAULT_CHAT_CAPABILITIES, canArchive: canArchive.read(reader) })); this.chat = { resource, + ...(this.agentProvider ? { harness: toAgentHostChatHarness(this.agentProvider) } : {}), createdAt: modifiedAt ?? createdAtFallback, workspace: this._withDetails(workspace), title: this._withDetails(this._title), @@ -1255,11 +1280,12 @@ class AdditionalChat extends Disposable { toMetadata(): IAgentSessionChatMetadata { const changes = this._changesSummary.get(); const workingDirectories = this._workingDirectories.get(); - return { + const metadata: IAgentSessionChatMetadata = { chat: this.backendUri, summary: this._title.get(), kind: 'peer', origin: this._origin, + ...(this._reportedProvider !== undefined ? { provider: this._reportedProvider } : {}), interactivity: toProtocolChatInteractivity(this._interactivity.get()), archived: this._isArchived.get(), isRead: this._isRead.get(), @@ -1273,6 +1299,7 @@ class AdditionalChat extends Disposable { modifiedTime: this._updatedAt.get()?.getTime(), ...(workingDirectories !== undefined ? { workingDirectories } : {}), }; + return metadata; } /** Keeps the last known counts when an update does not carry them. */ @@ -1599,8 +1626,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this.sessionId = toSessionId(providerId, this.resource); this.providerId = providerId; this.sessionType = logicalSessionType; - const harness = metadata.provider ?? agentProvider; - this.harness = harness === 'copilotcli' ? 'copilot' : harness; + this.harness = toAgentHostChatHarness(metadata.provider ?? agentProvider); this.environment = _options.environment; this._isQuickChat = observableValue('isQuickChat', readSessionWorkspaceless(metadata._meta)); this.icon = _options.icon; @@ -1779,6 +1805,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._defaultChatUpdatedAt = observableValueOpts({ owner: this, debugName: 'defaultChatUpdatedAt', equalsFn: dateEquals }, hasMultipleChats ? undefined : this.updatedAt.get()); const mainChat: IChat = { resource: this.resource, + harness: this.harness, createdAt: this.createdAt, workspace: defaultChatWorkspace, title: this._options.useSessionTitleForDefaultChat ? this.title : derived(this, reader => this._defaultChatTitleOverride.read(reader) ?? this.title.read(reader)), @@ -1834,12 +1861,15 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._options.connectionStatus?.read(reader); const connection = this._options.getConnection(); connection?.initializeResult.read(reader); + const supportsMultipleChats = agentCapabilities?.multipleChats !== undefined; + const chatProviders = supportsMultipleChats ? this._options.chatProviders?.read(reader) : undefined; return { supportsRemoveArtifacts: !!connection?.removeSessionArtifact && supportsAgentHostArtifactRemoval(connection.initializeResult.read(reader)), supportsImport: this.isExternal.read(reader) && !!connection?.importSession && supportsAgentHostSessionImport(connection.initializeResult.read(reader)), ...(connection?.artifactIntegrationTransport ? { supportsArtifactIntegrations: true } : {}), supportsCanvases: this._options.supportsCanvasPresentation?.(this.agentProvider) === true, - supportsMultipleChats: agentCapabilities?.multipleChats !== undefined, + supportsMultipleChats, + ...(chatProviders?.length ? { supportsChatHarnesses: chatProviders.map(toAgentHostChatHarness) } : {}), supportsIndependentChatReadState: this._supportsChatReadState.read(reader), supportsFork: agentCapabilities?.multipleChats?.fork ?? false, supportsSideChat: agentCapabilities?.multipleChats?.sideChat ?? false, @@ -1965,6 +1995,11 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { } const chatId = getAgentHostChatId(chat.chat); let entry = this._additionalChats.get(chatId); + if (entry && entry.agentProvider !== (readChatMetadataProvider(chat) ?? this.agentProvider)) { + // See `_applyChatCatalog`: the chat behind this id changed agent, so it is rebuilt. + this._additionalChats.deleteAndDispose(chatId); + entry = undefined; + } if (!entry) { entry = this._createAdditionalChat(chatId, { resource: chat.chat.toString(), @@ -1975,6 +2010,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { chat.isRead ?? supportsChatReadState, ), origin: chat.origin, + ...(readChatMetadataProvider(chat) !== undefined ? { provider: readChatMetadataProvider(chat) } : {}), interactivity: chat.interactivity, ...(chat.changes !== undefined ? { changes: chat.changes } : {}), ...(chat.modifiedTime !== undefined ? { modifiedAt: new Date(chat.modifiedTime).toISOString() } : {}), @@ -2175,6 +2211,12 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { } const chatId = getAgentHostChatId(summary.resource); let entry = this._additionalChats.get(chatId); + if (entry && entry.agentProvider !== (summary.provider ?? this.agentProvider)) { + // A chat's agent is fixed, so a different one means a different chat behind this id (e.g. a + // chat moved in from a session of another agent): rebuild it from what the catalog reports. + this._additionalChats.deleteAndDispose(chatId); + entry = undefined; + } if (!entry) { entry = this._createAdditionalChat(chatId, summary); this._additionalChats.set(chatId, entry); @@ -2237,6 +2279,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._options.readOnly, this._options.preserveStatusWhenDisconnected ? undefined : this._options.connectionStatus, this._options.allowOfflineDrafts, + this.agentProvider, ); const selection = this._chatModelSelections.get(chatId); if (selection) { @@ -2266,6 +2309,12 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { return URI.from({ scheme: this._resourceScheme, path: `/${this._rawId}`, fragment: getAgentHostChatId(parentUri) }); } + /** The agent provider backing a chat of this session: its own when the host reported one, else the session's. */ + getChatAgentProvider(chatResource: URI): string { + const entry = chatResource.fragment && !isEqual(chatResource, this.resource) ? this._additionalChats.get(chatResource.fragment) : undefined; + return entry?.agentProvider || this.agentProvider; + } + setChatRead(chatResource: URI, isRead: boolean, tx?: ITransaction): boolean { if (isEqual(chatResource, this._defaultChat.resource)) { if (this._hasMultipleChats.get()) { @@ -2544,7 +2593,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { getChatModelSelection(chatResource: URI): ModelSelection | undefined { const modelId = this.getChatModelId(chatResource); if (modelId) { - return this._toModelSelection(modelId); + return this._toModelSelection(modelId, chatResource); } return chatResource.fragment ? undefined : this.modelSelection; } @@ -2565,11 +2614,19 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._additionalChats.get(chatId)?.setTitle(title); } - private _toModelSelection(modelId: string): ModelSelection { - const prefix = `${this._resourceScheme}:`; + private _toModelSelection(modelId: string, chatResource?: URI): ModelSelection { + const prefix = `${chatResource ? this.getChatModelScheme(chatResource) : this._resourceScheme}:`; return { id: modelId.startsWith(prefix) ? modelId.substring(prefix.length) : modelId }; } + /** The scheme the models of a chat's agent are registered under; the session's own for its agent. */ + getChatModelScheme(chatResource: URI): string { + const agentProvider = this.getChatAgentProvider(chatResource); + return agentProvider === this.agentProvider + ? this._resourceScheme + : this._options.resourceSchemeForAgent?.(agentProvider) ?? this._resourceScheme; + } + private _getAdditionalChat(chatResource: URI): AdditionalChat | undefined { const byFragment = chatResource.fragment ? this._additionalChats.get(chatResource.fragment) : undefined; if (byFragment) { @@ -3515,7 +3572,7 @@ class NewSession extends Disposable { resource, providerId: ctx.providerId, sessionType: ctx.sessionType.id, - harness: ctx.sessionType.id === 'copilotcli' ? 'copilot' : ctx.sessionType.id, + harness: toAgentHostChatHarness(ctx.sessionType.id), environment: _options.environment, application: constObservable(getSessionApplication('vscode')), icon: ctx.icon, @@ -4618,6 +4675,37 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement */ protected readonly _sessionStateSubscriptions = this._register(new DisposableMap()); protected readonly _connectionChanged = observableSignal(this); + private _chatProvidersObs: IObservable | undefined; + /** + * Agent providers a new peer chat may use: those advertising multiple chats and not + * disabled by policy, only while the connection advertises chats backed by different + * agents, the host's root config still allows them (another window can turn them off + * after the handshake), and the user enabled the feature. + */ + protected get _chatProviders(): IObservable { + if (!this._chatProvidersObs) { + const enabled = observableConfigValue(ChatProvidersSettingId.Enabled, false, this._baseConfigurationService); + const rootConfigChanged = observableSignalFromEvent(this, this.onDidChangeRootConfig); + this._chatProvidersObs = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => { + if (!enabled.read(reader)) { + return undefined; + } + this._connectionChanged.read(reader); + if (!this.connection?.initializeResult.read(reader)?.multipleChatProviders) { + return undefined; + } + rootConfigChanged.read(reader); + if (this._rootConfig?.values[ChatProvidersConfigKey.Enabled] !== true) { + return undefined; + } + const providers = [...this._agentCapabilities.read(reader) ?? []] + .filter(([provider, capabilities]) => capabilities?.multipleChats !== undefined && this._shouldAdvertiseAgent(provider) && !this._isAgentDisabledByPolicy(provider)) + .map(([provider]) => provider); + return providers.length > 0 ? providers : undefined; + }); + } + return this._chatProvidersObs; + } private readonly _chatCatalogLoading = new Map>(); private readonly _agentMergeSessionStateSubscriptions = this._register(new DisposableMap()); private readonly _agentMergeSessionStateIdleTimers = this._register(new DisposableMap()); @@ -4900,6 +4988,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement }, getBackendChatResource: chat => this.getBackendChatResource(chat), agentCapabilities: this._agentCapabilities, + chatProviders: this._chatProviders, + resourceSchemeForAgent: agentProvider => this.resourceSchemeForProvider(agentProvider), backendSessionScheme: this._backendSessionScheme(provider), mapBackendSessionResource: resource => this._mapBackendSessionResource(resource), getBackendDefaultChat: (backendSession, reader) => this._backendDefaultChat(backendSession, reader), @@ -5093,6 +5183,26 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._onDidChangeSessionTypes.fire(); } + private _sessionTypesChangedSignal: IObservable | undefined; + + /** + * Presents a harness by the session type of the agent behind it, re-evaluating for `reader` + * as the host's advertised agents load or change. `undefined` until that agent is advertised. + */ + getHarnessPresentation(harness: string, reader?: IReader): IHarnessPresentation | undefined { + if (reader) { + this._sessionTypesChangedSignal ??= observableSignalFromEvent(this, this.onDidChangeSessionTypes); + this._sessionTypesChangedSignal.read(reader); + } + const sessionType = this.getHarnessSessionType(harness); + return sessionType ? { label: sessionType.label, icon: sessionType.icon } : undefined; + } + + /** The advertised session type of the agent behind a harness, `undefined` until the host advertises it. */ + getHarnessSessionType(harness: string): ISessionType | undefined { + return this._sessionTypes.find(type => toAgentHostChatHarness(type.id) === harness); + } + /** * Returns the {@link ThemeIcon} associated with a known agent provider, or * `undefined` when the provider is not recognised. @@ -6069,10 +6179,24 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } this._keepSessionStateAlive(sessionId); const config = this._runningSessionConfigs.get(sessionId); - // Only properties the session's schema lets a chat set apply, so a host without them reads as the session. - // Until the chat's own values arrive this is the session's config, and isSessionConfigResolving reports it. + const completeValues = hostPublishesCompleteChatConfig(this.connection?.rootState?.value); + // A chat whose agent differs from the session's has that agent's schema in its state; the session's config + // never applies to it, so it has none until its state arrives. + if (this._chatUsesOwnAgent(sessionId, chat)) { + const own = this._getChatOwnConfigValues(sessionId, chat); + return config && own?.schema ? getAgentHostEffectiveChatConfig(config, own, completeValues) : undefined; + } + // Only properties the session's schema lets a chat set are a chat's own, so a host without them reads as the session. + // Until the chat's values arrive this is the session's config, and isSessionConfigResolving reports it. const own = config && hasChatConfigProperties(config.schema) ? this._getChatOwnConfigValues(sessionId, chat) : undefined; - return config && own ? getEffectiveChatConfig(config, own) : config; + return config && own ? getAgentHostEffectiveChatConfig(config, own, completeValues) : config; + } + + /** Whether a running session's chat is backed by an agent other than the session's. */ + private _chatUsesOwnAgent(sessionId: string, chat: URI): boolean { + const rawId = this._sessionKeyFromChatId(sessionId); + const cached = rawId ? this._sessionCache.get(rawId) : undefined; + return !!cached && cached.getChatAgentProvider(chat) !== cached.agentProvider; } getSessionOwnConfig(sessionId: string): ResolveSessionConfigResult | undefined { @@ -6085,7 +6209,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } /** - * The own config values that apply to a chat: its own and, for a subagent, those of the chats that + * The config that applies to a chat: its own or, for a subagent (which has none), that of the chat that * spawned it, from their chat states when those are subscribed; reading never subscribes. */ private _getChatOwnConfigValues(sessionId: string, chat: URI): ChatConfigState | undefined { @@ -6215,11 +6339,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // before its state is subscribed, which happens once it is active in a visible session, and again // once the subscription is released (idle or reconnect) until it is acquired again. const config = this._runningSessionConfigs.get(sessionId); - const chain = config && hasChatConfigProperties(config.schema) ? this._getChatConfigChain(sessionId, chat) : []; + const ownAgent = this._chatUsesOwnAgent(sessionId, chat); + const chain = (config && hasChatConfigProperties(config.schema)) || ownAgent ? this._getChatConfigChain(sessionId, chat) : []; if (chain.length === 0) { return constObservable(false); } - // A subagent's values come from the chats that spawned it too. + // A subagent's config comes from the chats that spawned it. return derived(this, reader => { this._onDidChangeChatConfigSubscriptions.read(reader); return chain.some(backendChat => this._chatConfigSubscriptions.get(backendChat)?.resolving.read(reader) ?? !this._failedChatConfigs.has(backendChat)); @@ -6331,20 +6456,43 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return; } + // A chat on another agent than its session's owns its whole config; session-schema keys never apply to it. + if (this._chatUsesOwnAgent(sessionId, chat)) { + const ownSchemaConfig = this.getSessionConfig(sessionId, chat); + if (!ownSchemaConfig || !isChatConfigProperty(ownSchemaConfig.schema, property)) { + throw new Error(`Chat configuration '${property}' is not writable.`); + } + const writer = this.getBackendChatResource(chat)?.toString(); + const ownChat = writer !== undefined ? getChatConfigWriteTarget(this._lastSessionStates.get(sessionId)?.chats, writer) : undefined; + if (ownChat === undefined) { + return; + } + validateChatConfigWrite(ownSchemaConfig.schema, ownSchemaConfig.values, property, normalizedValue); + this._keepChatConfigAlive(sessionId, ownChat); + connection.dispatch(ownChat, { type: ActionType.ChatConfigChanged, config: { [property]: normalizedValue } }); + this._onDidChangeSessionConfig.fire(sessionId); + return; + } + const schema = runningConfig.schema.properties[property]; if (!isSessionConfigWritable(schema, false)) { throw new Error(`Session configuration '${property}' is not writable.`); } - // A property the chat may set for itself is the chat's own: the host and the chat's + // A property a peer chat may set for itself is the chat's own: the host and the chat's // subscription apply it optimistically and reconcile it, leaving the session's other chats. // A subagent's write changes the chat that spawned it, as the host does for a subagent's own writes. + // The session's config is its default chat's: a host that applies session writes to its default chat + // gets a property the session may set too on the session (writing it to the chat would stop it following + // the session's); an older host lets the chat's own value win, so it gets it on the default chat. const writer = isChatConfigProperty(runningConfig.schema, property) ? this.getBackendChatResource(chat)?.toString() : undefined; - const backendChat = writer !== undefined ? getChatConfigWriteTarget(this._lastSessionStates.get(sessionId)?.chats, writer) : undefined; + const state = this._lastSessionStates.get(sessionId); + const target = writer !== undefined ? getChatConfigWriteTarget(state?.chats, writer) : undefined; + const backendChat = target !== undefined && (target !== state?.defaultChat || !schema?.sessionMutable || !hostAppliesSessionConfigToDefaultChat(connection.rootState.value)) ? target : undefined; if (backendChat !== undefined) { validateChatConfigWrite(runningConfig.schema, this.getSessionConfig(sessionId, chat)?.values ?? runningConfig.values, property, normalizedValue); // A choice in a peer chat is that chat's own, not a default for new sessions. - if (this._lastSessionStates.get(sessionId)?.defaultChat === backendChat) { + if (backendChat === state?.defaultChat) { this._rememberSessionConfigValue(property, normalizedValue); } this._keepChatConfigAlive(sessionId, backendChat); @@ -6893,11 +7041,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // -- Model selection ------------------------------------------------------ - getModelsSnapshot(sessionId: string, desiredModelId?: string): ISessionModelsSnapshot { + getModelsSnapshot(sessionId: string, desiredModelId?: string, chat?: URI): ISessionModelsSnapshot { // Agent-host models are registered against the session's resource // scheme (the per-host/per-agent `targetChatSessionType`). Resolve the // scheme from the session and return the matching language models. - const resourceScheme = this._resolveSessionResourceScheme(sessionId); + const resourceScheme = this._resolveSessionResourceScheme(sessionId, chat); if (!resourceScheme) { return { models: [], @@ -6926,7 +7074,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement }; } - getModelPickerOptions(sessionId: string): ISessionModelPickerOptions { + getModelPickerOptions(sessionId: string, chat?: URI): ISessionModelPickerOptions { // A session type that requires an explicit model selection cannot fall // back to Auto. When it has no models (e.g. the Claude agent host for a // Copilot Free / Student user), the picker shows a "No models available" @@ -6935,7 +7083,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // contribution's declarative `showAutoModel` flag (keyed by the // session's resource scheme, which is the registered // `agent-host-` chat session type) rather than hardcoding names. - const resourceScheme = this._resolveSessionResourceScheme(sessionId); + const resourceScheme = this._resolveSessionResourceScheme(sessionId, chat); const showAutoModel = !resourceScheme || this._chatSessionsService.supportsAutoModelForSessionType(resourceScheme); return { showUnavailableFeatured: true, @@ -6950,16 +7098,16 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * (rather than `undefined`, which would leave an already-running chat pinned * to its stale backend model) so the request is explicitly reset to Auto. */ - private _resolveSendModelId(sessionId: string, selectedModelId: string | undefined): string | undefined { + private _resolveSendModelId(sessionId: string, selectedModelId: string | undefined, chat?: URI): string | undefined { if (!selectedModelId) { return selectedModelId; } - const snapshot = this.getModelsSnapshot(sessionId, selectedModelId); + const snapshot = this.getModelsSnapshot(sessionId, selectedModelId, chat); if (snapshot.desiredModelResolution.kind !== 'unavailable') { // Available, pending (list not yet populated) or not requested: keep the selection. return selectedModelId; } - const resourceScheme = this._resolveSessionResourceScheme(sessionId); + const resourceScheme = this._resolveSessionResourceScheme(sessionId, chat); const supportsAuto = !resourceScheme || this._chatSessionsService.supportsAutoModelForSessionType(resourceScheme); if (!supportsAuto) { return selectedModelId; @@ -6972,14 +7120,14 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return autoModelId; } - private _resolveSessionResourceScheme(sessionId: string): string | undefined { + private _resolveSessionResourceScheme(sessionId: string, chat?: URI): string | undefined { const newSession = this._getNewSession(sessionId); if (newSession) { return newSession.session.resource.scheme; } const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; - return cached?.resource.scheme; + return cached && chat ? cached.getChatModelScheme(chat) : cached?.resource.scheme; } setModel(sessionId: string, chatResource: URI, modelId: string, source: ChatModelSource): void { @@ -7255,7 +7403,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement try { await connection.moveSession(source.backendUri, destination.backendUri, backendAfter); } catch (error) { - throw toChatMoveError(error, false); + throw toChatMoveError(error, { chatSharesFolder: false }); } } @@ -7290,7 +7438,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement try { await connection.moveChat(backendChat, backendDestination); } catch (error) { - throw toChatMoveError(error, source.chatSharesScratchFolder(chatUri)); + throw toChatMoveError(error, { + chatSharesFolder: source.chatSharesScratchFolder(chatUri), + toNewSession: backendDestination.kind === 'newSession', + }); } } @@ -7782,7 +7933,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return true; } - async createNewChat(chatId: string): Promise { + async createNewChat(chatId: string, _prompt?: string, options?: ICreateNewChatOptions): Promise { this._assertSessionAvailable(chatId); const connection = this.connection; if (!connection) { @@ -7799,24 +7950,26 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // Otherwise this is an additional peer chat inside an existing running // session. Mint a client-chosen chat URI, ask the host to add it to the // session's catalog, and wait for the adapter to surface the new chat. - return this._createAdditionalChat(chatId, connection); + return this._createAdditionalChat(chatId, connection, options?.harness); } - getNestedChatWorkspaceSupport(sessionId: string, reader: IReader | undefined): INestedChatWorkspaceSupport | undefined { + getNestedChatWorkspaceSupport(sessionId: string, chat: URI, reader: IReader | undefined): INestedChatWorkspaceSupport | undefined { const cached = this._getNestedChatWorkspaceSession(sessionId, reader); - if (!cached) { + const provider = cached && this._getNestedChatAgent(cached, chat, reader); + if (!cached || !provider) { return undefined; } return { sessionFolder: cached.getNestedChatSessionFolder(reader), sessionFolderWorktree: cached.getNestedChatSessionFolderWorktree(reader), - multipleFolders: !!this._agentCapabilities.read(reader)?.get(cached.agentProvider)?.multipleWorkingDirectories, + // The chat's own agent decides whether it may work in folders of its own. + multipleFolders: !!this._agentCapabilities.read(reader)?.get(provider)?.multipleWorkingDirectories, browseActions: this.browseActions, }; } - async resolveNestedChatFolder(sessionId: string, folder: URI, token: CancellationToken): Promise { - const { schema, values } = await this._resolveNestedChatFolderConfig(sessionId, folder, token); + async resolveNestedChatFolder(sessionId: string, chat: URI, folder: URI, token: CancellationToken): Promise { + const { schema, values } = await this._resolveNestedChatFolderConfig(sessionId, chat, folder, token); const { isolation, baseBranch } = getSessionWorkspaceProperties(schema); const defaultBranch = baseBranch ? values[baseBranch.key] ?? baseBranch.schema.default : undefined; return { @@ -7825,8 +7978,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement }; } - async getNestedChatBranches(sessionId: string, folder: URI, query: string, token: CancellationToken): Promise { - const { connection, provider, schema, values } = await this._resolveNestedChatFolderConfig(sessionId, folder, token); + async getNestedChatBranches(sessionId: string, chat: URI, folder: URI, query: string, token: CancellationToken): Promise { + const { connection, provider, schema, values } = await this._resolveNestedChatFolderConfig(sessionId, chat, folder, token); const baseBranch = getSessionWorkspaceProperties(schema).baseBranch; if (!baseBranch) { return []; @@ -7847,7 +8000,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement async applyNestedChatWorkspace(sessionId: string, chat: URI, workspace: INestedChatWorkspace, prompt: string): Promise { this._assertSessionAvailable(sessionId); - const support = this.getNestedChatWorkspaceSupport(sessionId, undefined); + const support = this.getNestedChatWorkspaceSupport(sessionId, chat, undefined); const cached = this._getNestedChatWorkspaceSession(sessionId, undefined); const connection = this.connection; if (!support || !cached || !connection || !isNestedChatWorkspaceAllowed(support, workspace)) { @@ -7892,19 +8045,47 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return cached; } - /** Resolves the agent's default configuration for a new chat in `folder`, as a new session draft does. */ - private async _resolveNestedChatFolderConfig(sessionId: string, folder: URI, token: CancellationToken): Promise { + /** + * The agent backing `chat`, a new nested chat of `cached`: its own for a chat using another harness, otherwise + * the session's. `undefined` when enterprise policy disables that agent. + */ + private _getNestedChatAgent(cached: AgentHostSessionAdapter, chat: URI, reader: IReader | undefined): string | undefined { + // The chat's agent is known once the session's catalog lists it. + cached.chats.read(reader); + const provider = cached.getChatAgentProvider(chat); + return this._isAgentDisabledByPolicy(provider) ? undefined : provider; + } + + /** Resolves the default configuration of `chat`'s agent for a new chat in `folder`, as a new session draft does. */ + private async _resolveNestedChatFolderConfig(sessionId: string, chat: URI, folder: URI, token: CancellationToken): Promise { const cached = this._getNestedChatWorkspaceSession(sessionId, undefined); + const provider = cached && this._getNestedChatAgent(cached, chat, undefined); const connection = this.connection; - if (!cached || !connection) { + if (!cached || !provider || !connection) { throw NestedChatWorkspaceError.notSupported(); } - const provider = cached.agentProvider; const result = await raceCancellationError(connection.resolveSessionConfig({ provider, workingDirectory: folder, config: undefined }), token); return { ...result, connection, provider }; } - private async _createAdditionalChat(chatId: string, connection: IAgentConnection): Promise { + /** + * The agent provider to request for a new peer chat using `harness`, or `undefined` + * when the chat uses the session's agent (and the request must not name one). + */ + private _resolveNewChatProvider(cached: AgentHostSessionAdapter, harness: string | undefined): string | undefined { + if (harness === undefined || harness === cached.harness) { + return undefined; + } + const provider = cached.capabilities.get().supportsChatHarnesses?.includes(harness) + ? this._chatProviders.get()?.find(candidate => toAgentHostChatHarness(candidate) === harness) + : undefined; + if (!provider) { + throw new Error(`Session '${cached.sessionId}' does not support chats using harness '${harness}'`); + } + return provider; + } + + private async _createAdditionalChat(chatId: string, connection: IAgentConnection, harness?: string): Promise { const rawId = this._sessionKeyFromChatId(chatId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; if (!rawId || !cached) { @@ -7913,12 +8094,14 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!cached.capabilities.get().supportsMultipleChats) { throw new Error(`Session '${chatId}' does not support multiple chats`); } + const chatProvider = this._resolveNewChatProvider(cached, harness); const sessionUri = cached.backendUri; const chatUri = URI.parse(newAgentHostChatUri(sessionUri.toString(), this._backendDefaultChat(sessionUri, undefined), this.connection?.initializeResult.get())); const newChatId = getAgentHostChatId(chatUri.toString()); - const selectedModelId = cached.modelId.get() ?? (cached.modelSelection ? `${cached.resource.scheme}:${cached.modelSelection.id}` : undefined); - const selectedAgentUri = cached.mode.get()?.id; + // A chat backed by another agent starts with that agent's defaults rather than the session's selections. + const selectedModelId = chatProvider ? undefined : cached.modelId.get() ?? (cached.modelSelection ? `${cached.resource.scheme}:${cached.modelSelection.id}` : undefined); + const selectedAgentUri = chatProvider ? undefined : cached.mode.get()?.id; // Show as `Untitled` until the first request; the host commits it below. cached.markChatAsNew(newChatId); @@ -7926,9 +8109,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // Keep the session-state subscription alive so the `chatAdded` it emits // flows into `_applyChatCatalogFromState` and updates `cached.chats`. this._keepSessionStateAlive(cached.sessionId); - await connection.createChat(sessionUri, chatUri, { - model: cached.modelSelection, - }); + const createOptions: IAgentCreateChatRequestOptions = chatProvider + ? { provider: chatProvider } + : { model: cached.modelSelection }; + await connection.createChat(sessionUri, chatUri, createOptions); let chat: IChat; try { @@ -7951,6 +8135,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement throw error; } + if (chatProvider) { + await this._retainChatSessionModel(chat.resource); + return chat; + } + // The model comes from the chat this one was branched off, not from any choice made here. cached.setChatModelId(chat.resource, selectedModelId, ChatModelSource.CarriedOver); cached.setChatAgent(chat.resource, selectedAgentUri ? { uri: selectedAgentUri, name: '' } : undefined); @@ -7974,7 +8163,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!capabilities.supportsMultipleChats) { throw new Error(`Session '${sessionId}' does not support multiple chats`); } - if (!this._agentCapabilities.get()?.get(cached.agentProvider)?.multipleChats?.fork) { + // Fork support belongs to the agent backing the source chat, which the fork keeps. + if (!this._agentCapabilities.get()?.get(cached.getChatAgentProvider(sourceChat))?.multipleChats?.fork) { throw new Error(`Session '${sessionId}' does not support forking`); } @@ -8011,7 +8201,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // which after a reload would fork it onto a model it was never running. const selectedModel = cached.getChatModelSelection(sourceChat); const selectedModelId = cached.getChatModelId(sourceChat) - ?? (selectedModel ? `${cached.resource.scheme}:${selectedModel.id}` : undefined); + ?? (selectedModel ? `${cached.getChatModelScheme(sourceChat)}:${selectedModel.id}` : undefined); const selectedAgentUri = cached.getChatMode(sourceChat)?.id; // Keep the session-state subscription alive so the `chatAdded` it emits @@ -8062,7 +8252,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // model), fall back to what the host recorded the source chat running on. const selectedModel = cached.getChatModelSelection(sourceChat) ?? this._readRunningChatModel(connection, cached, sourceBackendUri); const selectedModelId = cached.getChatModelId(sourceChat) - ?? (selectedModel ? `${cached.resource.scheme}:${selectedModel.id}` : undefined); + ?? (selectedModel ? `${cached.getChatModelScheme(sourceChat)}:${selectedModel.id}` : undefined); const selectedAgentUri = cached.getChatMode(sourceChat)?.id; // Keep the session-state subscription alive so the `chatAdded` it emits @@ -8166,7 +8356,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const sessionType = chatResource.scheme; const contribution = this._chatSessionsService.getChatSessionContribution(sessionType); - const selectedModelId = this._resolveSendModelId(chatId, cached.getChatModelId(chatResource)); + const selectedModelId = this._resolveSendModelId(chatId, cached.getChatModelId(chatResource), chatResource); const carriedOverConfiguration = this._carriedOverChatModelConfigurations.get(chatResource.toString()); this._carriedOverChatModelConfigurations.delete(chatResource.toString()); const selectedModelConfiguration = carriedOverConfiguration && carriedOverConfiguration.modelId === selectedModelId @@ -8627,9 +8817,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement private _syncActiveChatConfigPins(reader: IReader, visible: readonly (IActiveSession | undefined)[]): void { const pinned = new Map(); for (const session of visible) { - // Only this provider's running sessions whose host lets a chat set values for itself. + // Only this provider's running sessions whose host lets a chat set values for itself, + // or whose active chat is backed by another agent and so has a config of its own. const config = session && this._runningSessionConfigs.get(session.sessionId); - if (!session || !config || !hasChatConfigProperties(config.schema)) { + if (!session || !config || !(hasChatConfigProperties(config.schema) || this._chatUsesOwnAgent(session.sessionId, session.activeChat.read(reader).resource))) { continue; } // The chat and, for a subagent, the chats that spawned it; only chats the session's catalog lists, diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostChatMoveErrors.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostChatMoveErrors.test.ts index ec0cb66fad3a5..3233142e1b199 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostChatMoveErrors.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostChatMoveErrors.test.ts @@ -24,9 +24,9 @@ suite('Agent Host Chat Move Errors', () => { assert.deepStrictEqual({ reasons: [archived, notStarted, credentials, unknownReason, failure].map(getChatMoveRefusal), - messages: [archived, notStarted, credentials, unknownReason, failure].map(error => message(toChatMoveError(error, false))), + messages: [archived, notStarted, credentials, unknownReason, failure].map(error => message(toChatMoveError(error, { chatSharesFolder: false }))), // Only refusals become errors the sessions list shows as they are. - refused: [archived, notStarted, credentials, unknownReason, failure].map(error => toChatMoveError(error, false) instanceof SessionMoveRefusedError), + refused: [archived, notStarted, credentials, unknownReason, failure].map(error => toChatMoveError(error, { chatSharesFolder: false }) instanceof SessionMoveRefusedError), }, { reasons: [AgentChatMoveRefusal.SessionArchived, AgentChatMoveRefusal.NotStarted, AgentChatMoveRefusal.DifferentCredentialOwner, undefined, undefined], messages: [ @@ -46,11 +46,33 @@ suite('Agent Host Chat Move Errors', () => { const message = (error: unknown) => error instanceof Error ? error.message : String(error); assert.deepStrictEqual({ - shared: message(toChatMoveError(refusal, true)), - noFolder: message(toChatMoveError(refusal, false)), + shared: message(toChatMoveError(refusal, { chatSharesFolder: true })), + noFolder: message(toChatMoveError(refusal, { chatSharesFolder: false })), }, { shared: 'This chat shares its folder with another chat in its session, so it can\'t be moved on its own.', noFolder: 'This chat\'s folder is missing, so it can\'t be moved.', }); }); + + test('explains refusals by agent, and an unsupported new session as the chat having no conversation of its own', () => { + const message = (error: unknown) => error instanceof Error ? error.message : String(error); + const refusal = (reason: AgentChatMoveRefusal) => new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Host details.', { reason }); + + assert.deepStrictEqual({ + // Only with chats on their own agents off. + differentProvider: message(toChatMoveError(refusal(AgentChatMoveRefusal.DifferentProvider), { chatSharesFolder: false })), + agentUnavailable: message(toChatMoveError(refusal(AgentChatMoveRefusal.AgentUnavailable), { chatSharesFolder: false })), + agentWithoutChats: message(toChatMoveError(refusal(AgentChatMoveRefusal.AgentWithoutChats), { chatSharesFolder: false })), + agentConfigUnavailable: message(toChatMoveError(refusal(AgentChatMoveRefusal.AgentConfigUnavailable), { chatSharesFolder: false, toNewSession: true })), + unsupportedNewSession: message(toChatMoveError(refusal(AgentChatMoveRefusal.Unsupported), { chatSharesFolder: false, toNewSession: true })), + unsupportedSession: message(toChatMoveError(refusal(AgentChatMoveRefusal.Unsupported), { chatSharesFolder: false })), + }, { + differentProvider: 'Chats can only move between sessions of the same agent. Pick a session that uses this chat\'s agent.', + agentUnavailable: 'This chat\'s agent is unavailable or disabled. Enable it to move the chat.', + agentWithoutChats: 'The destination session\'s agent can\'t host chats of other agents, or this chat\'s agent can\'t join one. Pick a session whose agent supports multiple chats, or one of this chat\'s agent.', + agentConfigUnavailable: 'This chat\'s agent couldn\'t load its settings for the move. Try again.', + unsupportedNewSession: 'This chat has no conversation of its own yet, so it can\'t be moved into a new session.', + unsupportedSession: 'This chat can\'t be moved there.', + }); + }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 28164c9a8cc79..98ebafa2a501f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -25,6 +25,7 @@ import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js' import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { AgentSession, CODEX_AGENT_PROVIDER_ID, type IAgentCanvasCloseRequest, type IAgentCanvasInfo, type IAgentCanvasOpenRequest, type IAgentCreateChatRequestOptions, type IAgentCreateChatWorkspace, type IAgentCreateSessionConfig, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agent.js'; +import { ChatProvidersConfigKey, ChatProvidersSettingId } from '../../../../../../platform/agentHost/common/agentHostChatProviders.js'; import { agentSdkSetupStatusKey } from '../../../../../../platform/agentHost/common/agentSdkSetup.js'; import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { AgentHostCodexAgentEnabledSettingId, AgentHostPrewarmSessionsSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; @@ -167,8 +168,8 @@ class MockAgentHostService extends mock() { public dispatchedActions: { channel: string; action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction; clientId: string; clientSeq: number }[] = []; public failResolveSessionConfig = false; public resolveSessionConfigResult: ResolveSessionConfigResult = { schema: createVSCodeSessionConfigSchema(), values: { isolation: 'worktree' } }; - public resolveSessionConfigRequests: { config?: Record; workingDirectory?: URI }[] = []; - public resolveSessionConfigHandler: ((request: { config?: Record }) => ResolveSessionConfigResult) | undefined; + public resolveSessionConfigRequests: { provider?: string; config?: Record; workingDirectory?: URI }[] = []; + public resolveSessionConfigHandler: ((request: { provider?: string; config?: Record }) => ResolveSessionConfigResult) | undefined; public resolveSessionConfigBarrier: DeferredPromise | undefined; public onResolveSessionConfig: ((request: { config?: Record }) => Promise) | undefined; public branchCompletionRequests: IAgentSessionConfigCompletionsParams[] = []; @@ -287,11 +288,15 @@ class MockAgentHostService extends mock() { const key = session.toString(); const existing = this._sessionStateValues.get(key) as SessionState | undefined; if (existing && Array.isArray(existing.chats)) { + const requestedProvider = (options as { readonly provider?: string } | undefined)?.provider; + const source = (options?.fork?.source ?? options?.sideChat?.source)?.toString(); + const provider = requestedProvider ?? existing.chats.find(summary => summary.resource === source)?.provider; const newChat: ChatSummary = { resource: chat.toString(), title: options?.title ?? '', status: ProtocolSessionStatus.Idle, modifiedAt: new Date(0).toISOString(), + ...(provider !== undefined ? { provider } : {}), }; this.setSessionState(AgentSession.id(session), AgentSession.provider(session)!, { ...existing, @@ -341,7 +346,7 @@ class MockAgentHostService extends mock() { override async deleteDetachedWorktree(handle: string): Promise { this.deletedDetachedWorktrees.push(handle); } override async claimDetachedWorktree(handle: string): Promise { this.claimedDetachedWorktrees.push(handle); } - override async resolveSessionConfig(request: { config?: Record; workingDirectory?: URI }): Promise { + override async resolveSessionConfig(request: { provider?: string; config?: Record; workingDirectory?: URI }): Promise { this.resolveSessionConfigRequests.push(request); if (this.onResolveSessionConfig) { return this.onResolveSessionConfig(request); @@ -12701,6 +12706,185 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + suite('chats backed by different agents', () => { + const otherAgent = { provider: 'otheragent', displayName: 'Other', description: '', models: [], capabilities: { multipleChats: { fork: true, sideChat: true } } } as AgentInfo; + const copilotAgent = { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true, sideChat: true } } } as AgentInfo; + + function setHostGate(enabled: boolean): void { + agentHost.setRootState({ + agents: [copilotAgent, otherAgent, { provider: 'singlechat', displayName: 'Single', description: '', models: [], capabilities: {} } as AgentInfo], + config: { schema: { type: 'object', properties: {} }, values: { [ChatProvidersConfigKey.Enabled]: enabled } }, + }); + } + + function enableChatProviders(options: { setting?: boolean; hostCapability?: boolean } = {}): TestConfigurationService { + setHostGate(true); + if (options.hostCapability !== false) { + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), multipleChatProviders: {} }, undefined); + } + return new TestConfigurationService({ [ChatProvidersSettingId.Enabled]: options.setting !== false }); + } + + function setupCatalog(rawId: string, chats: ChatSummary[]): string { + const defaultChat = buildDefaultChatUri(AgentSession.uri('copilotcli', rawId).toString()); + agentHost.setSessionState(rawId, 'copilotcli', makeState([makeChatSummary(defaultChat, ''), ...chats], { defaultChat })); + return defaultChat; + } + + test('subscribes no chat of a visible session\'s catalog with chats on their own agents on, as with them off, also across a reconnect', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const peerSubscribes = async (gate: boolean) => { + const rawId = `fanout-${gate}`; + const configurationService = gate ? enableChatProviders() : new TestConfigurationService(); + setHostGate(gate); + const visibleSessions = observableValue('visibleSessions', []); + const activeSession = observableValue('activeSession', undefined); + const provider = createProvider(disposables, agentHost, undefined, { configurationService, visibleSessions, activeSession }); + const session = setupMultiChatSession(provider, rawId); + const sessionUri = AgentSession.uri('copilotcli', rawId).toString(); + // Unopened chats, half of them archived, one on another agent. + setupCatalog(rawId, Array.from({ length: 6 }, (_, i) => ({ + ...makeChatSummary(buildChatUri(sessionUri, `peer-${i}`), `Peer ${i}`, i % 2 ? ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsArchived : ProtocolSessionStatus.Idle), + ...(i === 1 ? { provider: 'otheragent' } : {}), + }))); + await timeout(0); + const shown = upcastPartial({ ...session, activeChat: session.mainChat }); + visibleSessions.set([shown], undefined); + activeSession.set(shown, undefined); + await timeout(0); + agentHost.fireAgentHostExit(); + agentHost.fireAgentHostStart(); + await timeout(100); + return agentHost.wireOps.filter(op => op.startsWith('subscribe:') && op.includes(`${rawId}/peer-`)).length; + }; + + assert.deepStrictEqual({ off: await peerSubscribes(false), on: await peerSubscribes(true) }, { off: 0, on: 0 }); + })); + + test('stops advertising chat harnesses when the host turns its gate off after the handshake', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const provider = createProvider(disposables, agentHost, undefined, { configurationService: enableChatProviders() }); + const session = setupMultiChatSession(provider, 'harness-gate-flip'); + const before = session.capabilities.get().supportsChatHarnesses; + setHostGate(false); + const off = session.capabilities.get().supportsChatHarnesses; + setHostGate(true); + assert.deepStrictEqual({ before: !!before?.length, off, after: !!session.capabilities.get().supportsChatHarnesses?.length }, { before: true, off: undefined, after: true }); + })); + + test('advertises chat harnesses only with the setting and the host capability, including for a quick chat', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const harnesses = (configurationService: TestConfigurationService, rawId: string) => { + const provider = createProvider(disposables, agentHost, undefined, { configurationService }); + return setupMultiChatSession(provider, rawId).capabilities.get().supportsChatHarnesses; + }; + const enabled = harnesses(enableChatProviders(), 'harness-enabled'); + const settingOff = harnesses(enableChatProviders({ setting: false }), 'harness-setting-off'); + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), multipleChatProviders: undefined }, undefined); + const hostWithout = harnesses(enableChatProviders({ hostCapability: false }), 'harness-host-without'); + + agentHost.addSession(createSession('harness-quick', { summary: 'Quick Chat', workingDirectory: URI.file('/tmp/copilot-scratch/harness-quick'), quickChat: true })); + const quickProvider = createProvider(disposables, agentHost, undefined, { configurationService: enableChatProviders() }); + quickProvider.getSessions(); + await timeout(0); + const quick = quickProvider.getSessions().find(session => session.title.get() === 'Quick Chat'); + + assert.deepStrictEqual({ enabled, settingOff, hostWithout, quick: quick?.capabilities.get().supportsChatHarnesses }, { + enabled: ['copilot', 'otheragent'], + settingOff: undefined, + hostWithout: undefined, + quick: ['copilot', 'otheragent'], + }); + })); + + test('maps each chat\'s reported agent to its harness', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const provider = createProvider(disposables, agentHost, undefined, { configurationService: enableChatProviders() }); + const session = setupMultiChatSession(provider, 'harness-mapping'); + const sessionUri = AgentSession.uri('copilotcli', 'harness-mapping').toString(); + setupCatalog('harness-mapping', [ + { ...makeChatSummary(buildChatUri(sessionUri, 'same'), 'Same') }, + { ...makeChatSummary(buildChatUri(sessionUri, 'other'), 'Other'), provider: 'otheragent' }, + ]); + + assert.deepStrictEqual(session.chats.get().map(chat => [chat.resource.fragment, chat.harness]), [ + ['', 'copilot'], + ['same', 'copilot'], + ['other', 'otheragent'], + ]); + })); + + test('createNewChat names an agent only when the harness differs from the session\'s', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const provider = createProvider(disposables, agentHost, undefined, { configurationService: enableChatProviders() }); + const session = setupMultiChatSession(provider, 'harness-create'); + setupCatalog('harness-create', []); + + const sameHarness = await provider.createNewChat(session.sessionId, undefined, { harness: 'copilot' }); + const noHarness = await provider.createNewChat(session.sessionId); + const otherHarness = await provider.createNewChat(session.sessionId, undefined, { harness: 'otheragent' }); + + assert.deepStrictEqual({ + providers: agentHost.createdChats.map(call => (call.options as { readonly provider?: string } | undefined)?.provider), + harnesses: [sameHarness.harness, noHarness.harness, otherHarness.harness], + otherModel: agentHost.createdChats.at(-1)?.options?.model, + }, { + providers: [undefined, undefined, 'otheragent'], + harnesses: ['copilot', 'copilot', 'otheragent'], + otherModel: undefined, + }); + await assert.rejects(provider.createNewChat(session.sessionId, undefined, { harness: 'singlechat' }), /does not support chats using harness/); + })); + + test('createNewChat rejects another harness when the host does not advertise it', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), multipleChatProviders: undefined }, undefined); + const provider = createProvider(disposables, agentHost, undefined, { configurationService: enableChatProviders({ hostCapability: false }) }); + const session = setupMultiChatSession(provider, 'harness-old-host'); + setupCatalog('harness-old-host', []); + + await assert.rejects(provider.createNewChat(session.sessionId, undefined, { harness: 'otheragent' }), /does not support chats using harness/); + assert.deepStrictEqual(agentHost.createdChats, []); + })); + + test('fork and side chat never name an agent and keep the source chat\'s harness', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const provider = createProvider(disposables, agentHost, undefined, { configurationService: enableChatProviders() }); + const session = setupMultiChatSession(provider, 'harness-fork'); + const sessionUri = AgentSession.uri('copilotcli', 'harness-fork').toString(); + setupCatalog('harness-fork', [{ ...makeChatSummary(buildChatUri(sessionUri, 'other'), 'Other'), provider: 'otheragent' }]); + const source = session.chats.get().find(chat => chat.resource.fragment === 'other'); + assert.ok(source); + + const forked = await provider.forkChat(session.sessionId, source.resource, 'turn-1'); + const side = await provider.createSideChat(session.sessionId, source.resource, 'turn-1'); + + assert.deepStrictEqual({ + providers: agentHost.createdChats.map(call => (call.options as { readonly provider?: string } | undefined)?.provider), + harnesses: [forked.harness, side.harness], + }, { + providers: [undefined, undefined], + harnesses: ['otheragent', 'otheragent'], + }); + })); + + test('a chat whose reported agent changes (e.g. moved in from another session) is rebuilt with that harness', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const provider = createProvider(disposables, agentHost, undefined, { configurationService: enableChatProviders() }); + const session = setupMultiChatSession(provider, 'harness-moved'); + const peer = buildChatUri(AgentSession.uri('copilotcli', 'harness-moved').toString(), 'moved'); + setupCatalog('harness-moved', [makeChatSummary(peer, 'Moved')]); + const before = session.chats.get().find(chat => chat.resource.fragment === 'moved')?.harness; + setupCatalog('harness-moved', [{ ...makeChatSummary(peer, 'Moved'), provider: 'otheragent' }]); + + assert.deepStrictEqual({ before, after: session.chats.get().find(chat => chat.resource.fragment === 'moved')?.harness }, { before: 'copilot', after: 'otheragent' }); + })); + + test('harness presentation follows the agents a restored host advertises', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.setAgents([copilotAgent]); + const provider = createProvider(disposables, agentHost); + const observed: (string | undefined)[] = []; + disposables.add(autorun(reader => observed.push(provider.getHarnessPresentation('otheragent', reader)?.label))); + agentHost.setAgents([copilotAgent, otherAgent]); + await timeout(0); + + assert.deepStrictEqual(observed, [undefined, provider.sessionTypes.find(type => type.id === 'otheragent')?.label]); + assert.ok(observed[1]); + })); + }); + test('forkChat sends an optional title with only fork support and returns the host-published named peer chat', () => runWithFakedTimers({ useFakeTimers: true }, async () => { agentHost.setAgents([{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true } } } as AgentInfo]); const provider = createProvider(disposables, agentHost); @@ -13444,8 +13628,8 @@ suite('LocalAgentHostSessionsProvider', () => { return session; } - function describeSupport(provider: ReturnType, sessionId: string) { - const support = provider.getNestedChatWorkspaceSupport(sessionId, undefined); + function describeSupport(provider: ReturnType, session: ISession) { + const support = provider.getNestedChatWorkspaceSupport(session.sessionId, session.mainChat.get().resource, undefined); return support && { sessionFolder: support.sessionFolder?.toString(), sessionFolderWorktree: support.sessionFolderWorktree, @@ -13462,7 +13646,7 @@ suite('LocalAgentHostSessionsProvider', () => { const error = await provider.applyNestedChatWorkspace(session.sessionId, peer.resource, { kind: 'folder', folder: repo, isolation: 'folder' }, 'Fix the bug').then(() => undefined, e => e); assert.deepStrictEqual({ - support: provider.getNestedChatWorkspaceSupport(session.sessionId, undefined), + support: provider.getNestedChatWorkspaceSupport(session.sessionId, peer.resource, undefined), error: error instanceof NestedChatWorkspaceError ? error.kind : error, requests: agentHost.chatWorkspaceRequests.length, }, { @@ -13476,7 +13660,7 @@ suite('LocalAgentHostSessionsProvider', () => { const provider = createProvider(disposables, agentHost); const session = setupWorkspaceSession(provider, 'nested-reactive'); const support = derived(reader => { - const value = provider.getNestedChatWorkspaceSupport(session.sessionId, reader); + const value = provider.getNestedChatWorkspaceSupport(session.sessionId, session.mainChat.read(reader).resource, reader); return value && { multipleFolders: value.multipleFolders, sessionFolder: value.sessionFolder?.toString() }; }); const observed: unknown[] = []; @@ -13520,8 +13704,8 @@ suite('LocalAgentHostSessionsProvider', () => { fireSessionSummaryChanged(agentHost, 'nested-wl', { workingDirectories: quickDirectories, _meta: quickMeta }); assert.deepStrictEqual({ - workspace: describeSupport(provider, workspaceSession.sessionId), - workspaceless: describeSupport(provider, quickSession.sessionId), + workspace: describeSupport(provider, workspaceSession), + workspaceless: describeSupport(provider, quickSession), }, { workspace: { sessionFolder: repo.toString(), sessionFolderWorktree: undefined, multipleFolders: true, browseActions: true }, workspaceless: { sessionFolder: undefined, sessionFolderWorktree: undefined, multipleFolders: true, browseActions: true }, @@ -13548,7 +13732,7 @@ suite('LocalAgentHostSessionsProvider', () => { }); const describe = (session: ISession) => { - const support = provider.getNestedChatWorkspaceSupport(session.sessionId, undefined); + const support = provider.getNestedChatWorkspaceSupport(session.sessionId, session.mainChat.get().resource, undefined); return support && { sessionFolder: support.sessionFolder?.toString(), sessionFolderWorktree: support.sessionFolderWorktree }; }; assert.deepStrictEqual({ checkout: describe(checkout), worktree: describe(worktreeSession) }, { @@ -13574,7 +13758,7 @@ suite('LocalAgentHostSessionsProvider', () => { _meta: meta, }); - const support = provider.getNestedChatWorkspaceSupport(session.sessionId, undefined); + const support = provider.getNestedChatWorkspaceSupport(session.sessionId, session.mainChat.get().resource, undefined); assert.deepStrictEqual({ folders: session.workspace.get()?.folders.map(folder => folder.workingDirectory.toString()), sessionFolder: support?.sessionFolder?.toString(), @@ -13605,7 +13789,7 @@ suite('LocalAgentHostSessionsProvider', () => { ], { defaultChat: quickDefault, meta: quickMeta, workingDirectories: quickDirectories })); fireSessionSummaryChanged(agentHost, 'nested-wl-primary', { workingDirectories: quickDirectories, _meta: quickMeta }); - assert.deepStrictEqual(describeSupport(provider, quickSession.sessionId), { sessionFolder: undefined, sessionFolderWorktree: undefined, multipleFolders: true, browseActions: true }); + assert.deepStrictEqual(describeSupport(provider, quickSession), { sessionFolder: undefined, sessionFolderWorktree: undefined, multipleFolders: true, browseActions: true }); })); test('a single-folder agent may only give a nested chat the session folder', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -13623,7 +13807,7 @@ suite('LocalAgentHostSessionsProvider', () => { }; assert.deepStrictEqual({ - multipleFolders: provider.getNestedChatWorkspaceSupport(session.sessionId, undefined)?.multipleFolders, + multipleFolders: provider.getNestedChatWorkspaceSupport(session.sessionId, chat.resource, undefined)?.multipleFolders, sessionFolder: await outcome({ kind: 'folder', folder: repo, isolation: 'folder' }), sessionFolderWorktree: await outcome({ kind: 'folder', folder: repo, isolation: 'worktree', baseBranch: 'main' }), otherFolder: await outcome({ kind: 'folder', folder: other, isolation: 'folder' }), @@ -13723,10 +13907,11 @@ suite('LocalAgentHostSessionsProvider', () => { agentHost.resolveSessionConfigResult = { schema: createVSCodeSessionConfigSchema(), values: { isolation: 'worktree', branch: 'develop' } }; agentHost.branchCompletionItems = [{ value: 'feature/a', label: 'feature/a' }]; - const info = await provider.resolveNestedChatFolder(session.sessionId, other, CancellationToken.None); - const branches = await provider.getNestedChatBranches(session.sessionId, other, 'feat', CancellationToken.None); + const chat = session.mainChat.get().resource; + const info = await provider.resolveNestedChatFolder(session.sessionId, chat, other, CancellationToken.None); + const branches = await provider.getNestedChatBranches(session.sessionId, chat, other, 'feat', CancellationToken.None); agentHost.resolveSessionConfigResult = { schema: createVSCodeSessionConfigSchema({ isolation: { type: 'string', title: 'Isolation', enum: ['folder'], default: 'folder', sessionMutable: false } }), values: {} }; - const folderOnly = await provider.resolveNestedChatFolder(session.sessionId, other, CancellationToken.None); + const folderOnly = await provider.resolveNestedChatFolder(session.sessionId, chat, other, CancellationToken.None); assert.deepStrictEqual({ info, @@ -13742,6 +13927,78 @@ suite('LocalAgentHostSessionsProvider', () => { completion: [{ property: 'branch', query: 'feat', workingDirectory: other.toString() }], }); })); + + test('a draft using another harness gets that agent\'s folders, folder facts, and branches; a same-harness draft the session\'s', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), _meta: { ...agentHost.initializeResult.get()._meta, [AgentHostCreateChatWorkspaceCapabilityMetaKey]: true } }, undefined); + // The session's agent gives each chat its own folders; the other agent doesn't. + agentHost.setAgents([ + { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true }, multipleWorkingDirectories: {} } } as AgentInfo, + { provider: 'claude', displayName: 'Claude', description: '', models: [], capabilities: { multipleChats: {} } } as AgentInfo, + ]); + const provider = createProvider(disposables, agentHost); + const session = setupWorkspaceSession(provider, 'nested-harness'); + const sessionUri = AgentSession.uri('copilotcli', 'nested-harness').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const claudeDraft: ChatSummary = { ...makeChatSummary(buildChatUri(sessionUri, 'claude-draft'), '', ProtocolSessionStatus.Idle), provider: 'claude' }; + const sameDraft = makeChatSummary(buildChatUri(sessionUri, 'same-draft'), '', ProtocolSessionStatus.Idle); + agentHost.setSessionState('nested-harness', 'copilotcli', makeState([ + makeChatSummary(defaultChat, '', ProtocolSessionStatus.Idle, [repo.toString()]), claudeDraft, sameDraft, + ], { defaultChat, workingDirectories: [repo.toString()] })); + const claude = session.chats.get().find(chat => chat.resource.fragment === 'claude-draft')!.resource; + const same = session.chats.get().find(chat => chat.resource.fragment === 'same-draft')!.resource; + agentHost.resolveSessionConfigResult = { schema: createVSCodeSessionConfigSchema(), values: { isolation: 'worktree', branch: 'develop' } }; + agentHost.resolveSessionConfigRequests.length = 0; + + const claudeInfo = await provider.resolveNestedChatFolder(session.sessionId, claude, other, CancellationToken.None); + await provider.getNestedChatBranches(session.sessionId, claude, other, 'feat', CancellationToken.None); + const sameInfo = await provider.resolveNestedChatFolder(session.sessionId, same, other, CancellationToken.None); + await provider.getNestedChatBranches(session.sessionId, same, other, 'feat', CancellationToken.None); + + assert.deepStrictEqual({ + multipleFolders: { + claude: provider.getNestedChatWorkspaceSupport(session.sessionId, claude, undefined)?.multipleFolders, + same: provider.getNestedChatWorkspaceSupport(session.sessionId, same, undefined)?.multipleFolders, + }, + info: { claude: claudeInfo, same: sameInfo }, + resolvedProviders: agentHost.resolveSessionConfigRequests.map(request => request.provider), + completionProviders: agentHost.branchCompletionRequests.map(request => request.provider), + }, { + multipleFolders: { claude: false, same: true }, + info: { claude: { supportsWorktree: true, defaultBranch: 'develop' }, same: { supportsWorktree: true, defaultBranch: 'develop' } }, + resolvedProviders: ['claude', 'claude', 'copilotcli', 'copilotcli'], + completionProviders: ['claude', 'copilotcli'], + }); + })); + + test('a draft whose agent is disabled by policy is offered no workspace choices', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), _meta: { ...agentHost.initializeResult.get()._meta, [AgentHostCreateChatWorkspaceCapabilityMetaKey]: true } }, undefined); + agentHost.setAgents([ + { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true }, multipleWorkingDirectories: {} } } as AgentInfo, + { provider: 'claude', displayName: 'Claude', description: '', models: [], capabilities: { multipleChats: {}, multipleWorkingDirectories: {} } } as AgentInfo, + ]); + const setting = 'chat.agentHost.claudeAgent.enabled'; + const configurationService = new class extends TestConfigurationService { + override inspect(key: string) { + const value = super.inspect(key); + return { ...value, policyValue: key === setting ? value.value : undefined }; + } + }({ [setting]: false }); + const provider = createProvider(disposables, agentHost, undefined, { configurationService, isSessionsWindow: true }); + const session = setupWorkspaceSession(provider, 'nested-policy'); + const sessionUri = AgentSession.uri('copilotcli', 'nested-policy').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + agentHost.setSessionState('nested-policy', 'copilotcli', makeState([ + makeChatSummary(defaultChat, '', ProtocolSessionStatus.Idle, [repo.toString()]), + { ...makeChatSummary(buildChatUri(sessionUri, 'claude-draft'), '', ProtocolSessionStatus.Idle), provider: 'claude' }, + ], { defaultChat, workingDirectories: [repo.toString()] })); + const claude = session.chats.get().find(chat => chat.resource.fragment === 'claude-draft')!.resource; + + const error = await provider.resolveNestedChatFolder(session.sessionId, claude, other, CancellationToken.None).then(() => undefined, e => e); + assert.deepStrictEqual({ + support: provider.getNestedChatWorkspaceSupport(session.sessionId, claude, undefined), + error: error instanceof NestedChatWorkspaceError ? error.kind : error, + }, { support: undefined, error: 'notSupported' }); + })); }); test('sendRequest keeps a peer chat model loaded while dispatching', async () => { @@ -16138,6 +16395,82 @@ suite('LocalAgentHostSessionsProvider', () => { return { provider, chat, peer, summary, peerSummary, state, setChatState, show, visibleSessions, sessionUri: AgentSession.uri('copilotcli', rawId).toString() }; } + test('a chat backed by another agent renders and writes its config against its own schema from its state', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { provider, peer, peerSummary, state, show } = perChatSession('own-schema', true, { withoutChatState: true }); + const otherPeerSummary: ChatSummary = { ...peerSummary, provider: 'otheragent' }; + const sessionState = state(); + agentHost.setSessionState('own-schema', 'copilotcli', { ...sessionState, chats: sessionState.chats.map(summary => summary.resource === peer ? otherPeerSummary : summary) }); + // The host publishes the chat agent's chat-settable schema on the chat. + const ownSchema: SessionConfigState['schema'] = { + type: 'object', + properties: { + effort: { type: 'string', title: 'Effort', enum: ['low', 'medium', 'high'], default: 'medium', chatMutable: true }, + verbose: { type: 'boolean', title: 'Verbose', default: false, chatMutable: true }, + }, + }; + await timeout(0); + const session = provider.getSessions().find(s => s.title.get() === 'own-schema')!; + await waitForSessionConfig(provider, session, c => !!c); + const peerResource = session.chats.get().find(chat => chat.harness === 'otheragent')!.resource; + show(session, peerResource); + const requestsBefore = agentHost.resolveSessionConfigRequests.length; + const configBefore = provider.getSessionConfig(session.sessionId, peerResource); + const resolvingBefore = provider.isSessionConfigResolving(session.sessionId, peerResource).get(); + agentHost.setChatState(peer, { ...otherPeerSummary, turns: [], config: { schema: ownSchema, values: { effort: 'high' } } }); + await timeout(0); + + const config = provider.getSessionConfig(session.sessionId, peerResource); + await assert.rejects(provider.setSessionConfigValue(session.sessionId, peerResource, 'isolation', 'folder'), /not writable/); + agentHost.dispatchedActions.length = 0; + await provider.setSessionConfigValue(session.sessionId, peerResource, 'effort', 'low'); + + assert.deepStrictEqual({ + configBefore, + resolvingBefore, + resolvingAfter: provider.isSessionConfigResolving(session.sessionId, peerResource).get(), + resolveRequests: agentHost.resolveSessionConfigRequests.length - requestsBefore, + config, + dispatched: agentHost.dispatchedActions.map(({ channel, action }) => ({ channel, action })), + }, { + // Never the session agent's settings while the chat's state is unknown. + configBefore: undefined, + resolvingBefore: true, + resolvingAfter: false, + resolveRequests: 0, + config: { schema: ownSchema, values: { effort: 'high', verbose: false } }, + dispatched: [{ channel: peer, action: { type: ActionType.ChatConfigChanged, config: { effort: 'low' } } }], + }); + })); + + test('a chat on its session\'s agent has the session\'s session-wide values and its own values, or their defaults, for what it may set (chats with their own agents on)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { provider, peer, peerSummary, show } = perChatSession('same-agent', true); + // A host publishes complete chat values while its chats may use their own agents. + agentHost.setRootState({ agents: [{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true } } } as AgentInfo], config: { schema: { type: 'object', properties: { [ChatProvidersConfigKey.Enabled]: { type: 'boolean', title: 'Multiple Chat Providers' } } }, values: { [ChatProvidersConfigKey.Enabled]: true } } }); + await timeout(0); + const session = provider.getSessions().find(s => s.title.get() === 'same-agent')!; + await waitForSessionConfig(provider, session, c => !!c); + const peerResource = session.chats.get().find(chat => chat.resource.toString() !== session.mainChat.get().resource.toString())!.resource; + show(session, peerResource); + const requestsBefore = agentHost.resolveSessionConfigRequests.length; + // The peer's values set nothing: a property it may set falls back to its default, not the session's value. + agentHost.setChatState(peer, { ...peerSummary, turns: [], config: { values: {} } }); + await timeout(0); + const unset = provider.getSessionConfig(session.sessionId, peerResource)?.values; + agentHost.setChatState(peer, { ...peerSummary, turns: [], config: { values: { autoApprove: 'autoApprove', isolation: 'folder' } } }); + await timeout(0); + + assert.deepStrictEqual({ + unset, + set: provider.getSessionConfig(session.sessionId, peerResource)?.values, + resolveRequests: agentHost.resolveSessionConfigRequests.length - requestsBefore, + }, { + unset: { isolation: 'worktree' }, + // `isolation` is session-wide: the chat's value for it never applies. + set: { isolation: 'worktree', autoApprove: 'autoApprove' }, + resolveRequests: 0, + }); + })); + test('a visible session\'s active chat keeps its config subscription until it is no longer visible, and held resolving observables follow it', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const visibleSessions = observableValue('visibleSessions', []); agentHost.addSession(createSession('pinned', { summary: 'pinned' })); @@ -16249,40 +16582,51 @@ suite('LocalAgentHostSessionsProvider', () => { ]); })); - test('a chat reads and writes the properties it may set for itself, leaving the session\'s own config', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { provider, chat, summary, sessionUri, show } = perChatSession('per-chat', true); - await timeout(0); - const session = provider.getSessions().find(s => s.title.get() === 'per-chat')!; - const chatResource = session.mainChat.get().resource; - await waitForSessionConfig(provider, session, c => !!c); - show(session); - await waitForSessionConfig(provider, session, c => c?.values.autoApprove === 'autoApprove'); - const read = provider.getSessionConfig(session.sessionId, chatResource)?.values; - let changes = 0; - disposables.add(provider.onDidChangeSessionConfig(() => changes++)); - agentHost.setChatState(chat, { ...summary, turns: [], config: { values: { autoApprove: 'default' } } }); - const afterHostChange = { values: provider.getSessionConfig(session.sessionId, chatResource)?.values, changes }; - - agentHost.dispatchedActions.length = 0; - await provider.setSessionConfigValue(session.sessionId, chatResource, 'autoApprove', 'autoApprove'); - await provider.setSessionConfigValue(session.sessionId, chatResource, 'isolation', 'folder'); + for (const currentHost of [true, false]) { + test(`a peer chat writes what it may set to itself, while the main chat's writes go to the session on a host that applies them to the default chat, else to the default chat (current host: ${currentHost})`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // A host that applies session writes to its default chat always publishes this root config key. + const agents = (agentHost.rootState.value as RootState).agents; + // A current host with chats with their own agents on applies session writes to its default chat. + agentHost.setRootState(currentHost ? { agents, config: { schema: { type: 'object', properties: { [ChatProvidersConfigKey.Enabled]: { type: 'boolean', title: 'Multiple Chat Providers' } } }, values: { [ChatProvidersConfigKey.Enabled]: true } } } : { agents }); + const { provider, chat, peer, summary, sessionUri, show } = perChatSession(`per-chat-${currentHost}`, true); + await timeout(0); + const session = provider.getSessions().find(s => s.title.get() === `per-chat-${currentHost}`)!; + const chatResource = session.mainChat.get().resource; + const peerResource = session.resource.with({ fragment: peer }); + await waitForSessionConfig(provider, session, c => !!c); + show(session, chatResource, peerResource); + await waitForSessionConfig(provider, session, c => c?.values.autoApprove === 'autoApprove'); + const read = provider.getSessionConfig(session.sessionId, chatResource)?.values; + let changes = 0; + disposables.add(provider.onDidChangeSessionConfig(() => changes++)); + agentHost.setChatState(chat, { ...summary, turns: [], config: { values: { autoApprove: 'default' } } }); + const afterHostChange = { values: provider.getSessionConfig(session.sessionId, chatResource)?.values, changes }; + + agentHost.dispatchedActions.length = 0; + await provider.setSessionConfigValue(session.sessionId, chatResource, 'autoApprove', 'autoApprove'); + await provider.setSessionConfigValue(session.sessionId, chatResource, 'isolation', 'folder'); + await provider.setSessionConfigValue(session.sessionId, peerResource, 'autoApprove', 'autoApprove'); - assert.deepStrictEqual({ - read, - own: provider.getSessionOwnConfig(session.sessionId)?.values, - afterHostChange, - dispatched: agentHost.dispatchedActions.map(({ channel, action }) => ({ channel, action })), - }, { - read: { autoApprove: 'autoApprove', isolation: 'worktree' }, - // The session's own values change only through the session write. - own: { autoApprove: 'default', isolation: 'folder' }, - afterHostChange: { values: { autoApprove: 'default', isolation: 'worktree' }, changes: 1 }, - dispatched: [ - { channel: chat, action: { type: ActionType.ChatConfigChanged, config: { autoApprove: 'autoApprove' } } }, - { channel: sessionUri, action: { type: ActionType.SessionConfigChanged, config: { isolation: 'folder' } } }, - ], - }); - })); + assert.deepStrictEqual({ + read, + own: provider.getSessionOwnConfig(session.sessionId)?.values, + afterHostChange, + dispatched: agentHost.dispatchedActions.filter(({ action }) => action.type === ActionType.SessionConfigChanged || action.type === ActionType.ChatConfigChanged).map(({ channel, action }) => ({ channel, action })), + }, { + read: { autoApprove: 'autoApprove', isolation: 'worktree' }, + own: { autoApprove: currentHost ? 'autoApprove' : 'default', isolation: 'folder' }, + afterHostChange: { values: { autoApprove: 'default', isolation: 'worktree' }, changes: 1 }, + // Each change is written once: to the session on a current host, to the default chat on an older one. + dispatched: [ + currentHost + ? { channel: sessionUri, action: { type: ActionType.SessionConfigChanged, config: { autoApprove: 'autoApprove' } } } + : { channel: chat, action: { type: ActionType.ChatConfigChanged, config: { autoApprove: 'autoApprove' } } }, + { channel: sessionUri, action: { type: ActionType.SessionConfigChanged, config: { isolation: 'folder' } } }, + { channel: peer, action: { type: ActionType.ChatConfigChanged, config: { autoApprove: 'autoApprove' } } }, + ], + }); + })); + } test('a host that lets no chat set a property keeps it session-wide', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { provider, sessionUri } = perChatSession('older-host', false); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index bf0fe6f577170..c59afb0377068 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -33,7 +33,7 @@ import { linkKey } from '../../../../common/sessionLinks.js'; import { getSessionApplication } from '../../../../common/sessionApplication.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; -import { IAutomationSessionConfiguration, IChatMovedEvent, IDeleteChatOptions, IPreparedNewSession, ISessionChatMoveDestination, SessionMoveRefusedError, ISendRequestOptions, ISessionChangeEvent, ISessionConfigProvider, ISessionConfigurationSnapshot, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProvider, ISessionsProviderCreateSessionOptions, INestedChatFolderInfo, INestedChatWorkspace, INestedChatWorkspaceSupport, NestedChatWorkspaceError } from '../../../../services/sessions/common/sessionsProvider.js'; +import { IAutomationSessionConfiguration, IChatMovedEvent, IDeleteChatOptions, IPreparedNewSession, ISessionChatMoveDestination, SessionMoveRefusedError, ISendRequestOptions, ISessionChangeEvent, ISessionConfigProvider, ISessionConfigurationSnapshot, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProvider, ISessionsProviderCreateSessionOptions, INestedChatFolderInfo, INestedChatWorkspace, INestedChatWorkspaceSupport, NestedChatWorkspaceError, ICreateNewChatOptions } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionOptionGroup } from '../../../chat/browser/newSession.js'; import { UNIFIED_WORKSPACE_PICKER_SETTING } from '../../../chat/common/constants.js'; import { CancellationToken, CancellationTokenSource, cancelOnDispose } from '../../../../../base/common/cancellation.js'; @@ -2013,7 +2013,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } } - async createNewChat(sessionId: string, _prompt?: string): Promise { + async createNewChat(sessionId: string, _prompt?: string, _options?: ICreateNewChatOptions): Promise { const session = this._newSessions.get(sessionId); if (!session) { throw new Error(`[CopilotChatSessionsProvider] Session '${sessionId}' does not support multiple chats`); @@ -2026,15 +2026,15 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return newChat; } - getNestedChatWorkspaceSupport(_sessionId: string, _reader: IReader | undefined): INestedChatWorkspaceSupport | undefined { + getNestedChatWorkspaceSupport(_sessionId: string, _chat: URI, _reader: IReader | undefined): INestedChatWorkspaceSupport | undefined { return undefined; } - async resolveNestedChatFolder(_sessionId: string, _folder: URI, _token: CancellationToken): Promise { + async resolveNestedChatFolder(_sessionId: string, _chat: URI, _folder: URI, _token: CancellationToken): Promise { throw NestedChatWorkspaceError.notSupported(); } - async getNestedChatBranches(_sessionId: string, _folder: URI, _query: string, _token: CancellationToken): Promise { + async getNestedChatBranches(_sessionId: string, _chat: URI, _folder: URI, _query: string, _token: CancellationToken): Promise { throw NestedChatWorkspaceError.notSupported(); } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index bd64a76bcb6b3..0e8cd391f4d12 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -54,7 +54,7 @@ import { DevContainerAgentHostSessionsProvider } from '../../agentHost/browser/d import type { AgentHostSessionAdapter } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; import { mapProtocolStatus } from '../../agentHost/browser/agentHostDiffs.js'; import { ReconnectableAgentHostAutomationStore } from '../../agentHost/browser/reconnectableAgentHostAutomationStore.js'; -import type { INestedChatFolderInfo, INestedChatWorkspace, ISendRequestOptions, ISessionsProviderAutomations, SessionResourceResolveReason } from '../../../../services/sessions/common/sessionsProvider.js'; +import type { INestedChatFolderInfo, INestedChatWorkspace, ISendRequestOptions, ISessionsProviderAutomations, SessionResourceResolveReason, ICreateNewChatOptions } from '../../../../services/sessions/common/sessionsProvider.js'; import { remoteAgentHostSessionTypeAuthorityPrefix, remoteAgentHostSessionTypeId } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; import { readAgentDevContainerWorktreeMetadata } from '../../../../../platform/agentHost/common/meta/agentDevContainerWorktreeMeta.js'; import { INewSessionComposerService } from '../../../chat/browser/newSessionComposerService.js'; @@ -506,19 +506,19 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio } } - override async createNewChat(chatId: string): Promise { + override async createNewChat(chatId: string, prompt?: string, options?: ICreateNewChatOptions): Promise { await this._ensureDevContainerConnection(); - return super.createNewChat(chatId); + return super.createNewChat(chatId, prompt, options); } - override async resolveNestedChatFolder(sessionId: string, folder: URI, token: CancellationToken): Promise { + override async resolveNestedChatFolder(sessionId: string, chat: URI, folder: URI, token: CancellationToken): Promise { await this._ensureDevContainerConnection(); - return super.resolveNestedChatFolder(sessionId, folder, token); + return super.resolveNestedChatFolder(sessionId, chat, folder, token); } - override async getNestedChatBranches(sessionId: string, folder: URI, query: string, token: CancellationToken): Promise { + override async getNestedChatBranches(sessionId: string, chat: URI, folder: URI, query: string, token: CancellationToken): Promise { await this._ensureDevContainerConnection(); - return super.getNestedChatBranches(sessionId, folder, query, token); + return super.getNestedChatBranches(sessionId, chat, folder, query, token); } override async applyNestedChatWorkspace(sessionId: string, chat: URI, workspace: INestedChatWorkspace, prompt: string): Promise { diff --git a/src/vs/sessions/contrib/sessions/browser/sessionHoverContent.ts b/src/vs/sessions/contrib/sessions/browser/sessionHoverContent.ts index 9fc0fc6c39890..e6a893896248d 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionHoverContent.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionHoverContent.ts @@ -18,7 +18,8 @@ import { ISessionSummaryHoverData, ISessionSummaryHoverLocation, ISessionSummary import { SHOW_SESSION_APPLICATION_FILTERS_COMMAND_ID } from '../../../common/sessionCommands.js'; import { UNKNOWN_SESSION_APPLICATION_ID } from '../../../common/sessionApplication.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; -import { BRANCH_CHANGES_CHANGESET_ID, getSessionOwnedGitHubPullRequestRefs, getSessionWorkspaceKind, getUntitledSessionTitle, IChat, ISession, ISessionFolder, ISessionWorkspace, SessionWorkspaceKind } from '../../../services/sessions/common/session.js'; +import { getHarnessPresentationOrDefault } from '../../../services/sessions/common/sessionsProvider.js'; +import { BRANCH_CHANGES_CHANGESET_ID, getChatHarness, getSessionOwnedGitHubPullRequestRefs, getSessionWorkspaceKind, getUntitledSessionTitle, IChat, ISession, ISessionFolder, ISessionWorkspace, SessionWorkspaceKind } from '../../../services/sessions/common/session.js'; import { ISessionChangesStats, readChatChangesStats, readSessionChangesStats } from '../../../services/sessions/common/sessionChangesStatsCache.js'; /** Shared session diff counts, omitting entries without line changes. */ @@ -130,8 +131,11 @@ export function getChatSummaryHoverData( includeUpdatedAt = false, ): ISessionSummaryHoverData { const updatedAt = includeUpdatedAt ? chat.updatedAt.get() : undefined; + const chatHarnessLabel = getDistinctChatHarnessLabel(session, chat, sessionsProvidersService, undefined); return { ...getProviderDetails(session, sessionsProvidersService), + // A chat answered by another harness than its session's names that harness, as its tab does. + ...(chatHarnessLabel !== undefined ? { providerLabel: chatHarnessLabel } : {}), title: chat.title.get().trim() || localize('untitledChat', "Untitled Chat"), ...(updatedAt ? { updatedAt } : {}), location: getLocation( @@ -290,6 +294,15 @@ function getApplication(session: ISession, openerService: IOpenerService): ISess }; } +/** + * The label of a chat's harness when it differs from its session's (with the generic + * fallback while unknown), or `undefined` for a chat on the session's harness. + */ +export function getDistinctChatHarnessLabel(session: ISession, chat: IChat, sessionsProvidersService: ISessionsProvidersService, reader: IReader | undefined): string | undefined { + const harness = getChatHarness(session, chat); + return harness === session.harness ? undefined : getHarnessPresentationOrDefault(sessionsProvidersService.getProvider(session.providerId), harness, reader).label; +} + function getProviderDetails( session: ISession, sessionsProvidersService: ISessionsProvidersService, diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index 51fef1e66d403..e19e281f1c377 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -76,7 +76,7 @@ import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actio import { ChatMoveSettingId } from '../../../../platform/agentHost/common/agentHostChatMove.js'; import { IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; -import { getChatMoveDestinations, getSessionMoveDestinations, isChatMoveDndEnabled, ISessionsListMoveDestination, runSessionsListMove, SessionsListMove } from './views/sessionsListChatMove.js'; +import { getChatMoveDestinations, getSessionMoveDestinations, isChatMoveDndEnabled, isCrossAgentChatMoveEnabled, ISessionsListMoveDestination, runSessionsListMove, SessionsListMove } from './views/sessionsListChatMove.js'; export const NEW_SESSION_BUTTON_STYLE_SETTING = 'sessions.newSessionButton.style'; export const NEW_SESSION_BUTTON_STYLE_TREATMENT = 'agentSessionsNewSessionButtonStyle'; @@ -840,10 +840,12 @@ registerAction2(class MoveChatToSessionAction extends Action2 { const sessionsManagementService = accessor.get(ISessionsManagementService); const quickInputService = accessor.get(IQuickInputService); const runMove = getMoveRunner(accessor); - if (!context || !isChatMoveDndEnabled(accessor.get(IConfigurationService), accessor.get(IChatEntitlementService))) { + const configurationService = accessor.get(IConfigurationService); + if (!context || !isChatMoveDndEnabled(configurationService, accessor.get(IChatEntitlementService))) { return false; } - const destinations = getChatMoveDestinations(context.session, context.chat, sessionsManagementService.getSessions(), sessionsManagementService); + const crossAgent = isCrossAgentChatMoveEnabled(configurationService); + const destinations = getChatMoveDestinations(context.session, context.chat, sessionsManagementService.getSessions(), sessionsManagementService, crossAgent); if (!destinations.length) { return false; } @@ -855,7 +857,7 @@ registerAction2(class MoveChatToSessionAction extends Action2 { return false; } // The pick is asynchronous: check the destination still takes the chat. - const current = getChatMoveDestinations(context.session, context.chat, sessionsManagementService.getSessions(), sessionsManagementService).find(candidate => candidate.session === destination.session); + const current = getChatMoveDestinations(context.session, context.chat, sessionsManagementService.getSessions(), sessionsManagementService, crossAgent).find(candidate => candidate.session === destination.session); if (!current) { return false; } @@ -884,10 +886,12 @@ registerAction2(class MoveSessionIntoSessionAction extends Action2 { const quickInputService = accessor.get(IQuickInputService); const runMove = getMoveRunner(accessor); const session = Array.isArray(context) ? (context.length === 1 ? context[0] : undefined) : context; - if (!session || !isChatMoveDndEnabled(accessor.get(IConfigurationService), accessor.get(IChatEntitlementService))) { + const configurationService = accessor.get(IConfigurationService); + if (!session || !isChatMoveDndEnabled(configurationService, accessor.get(IChatEntitlementService))) { return false; } - const destinations = getSessionMoveDestinations(session, sessionsManagementService.getSessions(), sessionsManagementService); + const crossAgent = isCrossAgentChatMoveEnabled(configurationService); + const destinations = getSessionMoveDestinations(session, sessionsManagementService.getSessions(), sessionsManagementService, crossAgent); if (!destinations.length) { return false; } @@ -898,7 +902,7 @@ registerAction2(class MoveSessionIntoSessionAction extends Action2 { if (!destination) { return false; } - const current = getSessionMoveDestinations(session, sessionsManagementService.getSessions(), sessionsManagementService).find(candidate => candidate.session === destination.session); + const current = getSessionMoveDestinations(session, sessionsManagementService.getSessions(), sessionsManagementService, crossAgent).find(candidate => candidate.session === destination.session); if (!current) { return false; } diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 8ae9d917ee14c..73bbf6b6a41b5 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -61,7 +61,7 @@ import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/ import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ChatSessionArchiveActionWording, ChatSessionArchiveActionWordingSettingId, getChatSessionArchivedSectionLabel, getChatSessionArchiveActionWording } from '../../../../../platform/chat/common/sessionArchiveActions.js'; -import { BRANCH_CHANGES_CHANGESET_ID, ChatInteractivity, ChatOriginKind, getChatCapabilities, getGitHubPullRequestRefs, getGitHubRepositoryOfRemoteFile, getGitHubRepositoryOfUrl, getHighestPriorityPullRequestIcon, getSessionFolderGitHubRepository, getSessionStatusMessage, getSessionWorkspaceKind, getUntitledSessionTitle, GITHUB_REMOTE_FILE_SCHEME, IChat, isActiveSessionStatus, ISession, ISessionEnvironment, ISessionWorkspace, SessionArtifactKind, SessionStatus, SessionWorkspaceKind } from '../../../../services/sessions/common/session.js'; +import { BRANCH_CHANGES_CHANGESET_ID, ChatInteractivity, ChatOriginKind, getChatCapabilities, getGitHubPullRequestRefs, getGitHubRepositoryOfRemoteFile, getGitHubRepositoryOfUrl, getHighestPriorityPullRequestIcon, getSessionFolderGitHubRepository, getSessionStatusMessage, getSessionWorkspaceKind, getUntitledSessionTitle, GITHUB_REMOTE_FILE_SCHEME, IChat, isActiveSessionStatus, ISession, ISessionEnvironment, ISessionWorkspace, SessionArtifactKind, SessionStatus, SessionWorkspaceKind, hasMixedChatHarnesses } from '../../../../services/sessions/common/session.js'; import { ISessionChangesStats, readChatChangesStats } from '../../../../services/sessions/common/sessionChangesStatsCache.js'; import { AgentSessionApprovalModel, agentSessionApprovalId, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { IVoicePlaybackService } from '../../../../../workbench/contrib/chat/common/voicePlaybackService.js'; @@ -107,13 +107,13 @@ import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/ import { buildOpenSessionLinkUri } from '../../../../../platform/agentHost/common/openSessionLink.js'; import { LocalSelectionTransfer } from '../../../../../platform/dnd/browser/dnd.js'; import { DraggedSessionIdentifier, fillSessionChatDragData, SessionsDataTransfers } from '../../../../browser/dnd.js'; -import { getChatMoveDestinations, getSessionMoveDestinations, getSessionsListChatDrop, getSessionsListSessionDrop, isChatMoveDndEnabled, ISessionsListChatDropTarget, runSessionsListMove, SessionsListChatDrop, SessionsListMove, SessionsListMoveNotice, SessionsListSessionDrop } from './sessionsListChatMove.js'; +import { getChatMoveDestinations, getSessionMoveDestinations, getSessionsListChatDrop, getSessionsListSessionDrop, isChatMoveDndEnabled, isCrossAgentChatMoveEnabled, ISessionsListChatDropTarget, runSessionsListMove, SessionsListChatDrop, SessionsListMove, SessionsListMoveNotice, SessionsListSessionDrop } from './sessionsListChatMove.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IDragAndDropData } from '../../../../../base/browser/dnd.js'; import { ElementsDragAndDropData, ListViewTargetSector } from '../../../../../base/browser/ui/list/listView.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; -import { createSessionSummaryHover, getChatSummaryHoverData, getSessionDiffStats, getSessionSummaryHoverData } from '../sessionHoverContent.js'; +import { createSessionSummaryHover, getChatSummaryHoverData, getDistinctChatHarnessLabel, getSessionDiffStats, getSessionSummaryHoverData } from '../sessionHoverContent.js'; import { SessionStatusIcon } from '../../../../browser/sessionStatusIcon.js'; import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { AICustomizationManagementEditorInput } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; @@ -3162,6 +3162,8 @@ interface ISessionsAccessibilityProviderOptions extends ICompactInputNeededPrese readonly reportExternalApplicationBadgeModeExperimentTrigger?: () => void; readonly reportExternalApplicationBadgeShowFromExperimentTrigger?: () => void; readonly getCompletedStatusMessage?: (session: ISession, reader: IReader | undefined) => string | undefined; + /** The harness label of a nested chat whose harness differs from its session's. */ + readonly getChatHarnessLabel?: (session: ISession, chat: IChat, reader: IReader | undefined) => string | undefined; } class SessionsAccessibilityProvider { @@ -3201,6 +3203,10 @@ class SessionsAccessibilityProvider { if (diffStats) { label = localize('sessionChatItemChangesAria', "{0}, {1} lines added, {2} lines removed", label, diffStats.insertions, diffStats.deletions); } + const harnessLabel = this.options?.getChatHarnessLabel?.(element.session, element.chat, reader); + if (harnessLabel) { + label = localize('sessionChatItemHarnessAria', "{0}, uses harness {1}", label, harnessLabel); + } const isArchived = element.chat.isArchived.read(reader); const readLabel = !isArchived && !element.chat.isRead.read(reader) ? localize('sessionChatItemUnreadAria', "{0}, unread", label) @@ -3329,6 +3335,9 @@ class SessionsAccessibilityProvider { this.options?.reportExternalApplicationBadgeShowFromExperimentTrigger?.(); label = localize('sessionItemExternalAria', "{0}, created in {1}", label, element.application.read(reader).label); } + if (hasMixedChatHarnesses(element, reader)) { + label = localize('sessionItemMixedHarnessesAria', "{0}, chats use different harnesses", label); + } if (!element.isArchived.read(reader) && !getSessionRowIsRead(element, reader, !!this.options?.deriveStatusFromMainChat)) { label = localize('sessionItemUnreadAria', "{0}, unread", label); } @@ -3502,6 +3511,8 @@ interface ISessionsListDndDelegate { getMovableDraggedChat(item: ISessionChatItem): { readonly session: ISession; readonly chat: IChat } | undefined; /** Whether nested chat rows are shown in their session's chat order, so that a position among them is a position in that order. */ areChatPositionsShown(): boolean; + /** Whether chats may also move into sessions of other agents that offer their harness (see {@link isCrossAgentChatMoveEnabled}). */ + isCrossAgentChatMoveEnabled(): boolean; /** Whether the session can move under another session, when moving chats by drag and drop is on. */ canMoveSessionUnder(session: ISession): boolean; /** Move the chat of `session` as the drop says. */ @@ -3748,7 +3759,7 @@ class SessionsListDragAndDrop extends Disposable implements ITreeDragAndDrop this.delegate.isInManuallyOrderedSection(session), isLastInSection: () => this.delegate.isLastRowOfSection(chatItem ?? session), canPlace: position => !!this.resolveSessionDrop(sessions, session, undefined, position === 'before' ? ListViewTargetSector.TOP : ListViewTargetSector.BOTTOM), - }, this.delegate.areChatPositionsShown()); + }, this.delegate.areChatPositionsShown(), this.delegate.isCrossAgentChatMoveEnabled()); } private resolveSessionDrop(sessions: readonly ISession[], target: SessionListItem | undefined, targetIndex: number | undefined, sector: ListViewTargetSector | undefined): ISessionsListDropOperation | undefined { @@ -4657,6 +4668,7 @@ export class SessionsList extends Disposable implements ISessionsList { { accessibilityProvider: new SessionsAccessibilityProvider(sectionRenderer.automationStatus, { grouping: this.options.grouping, + getChatHarnessLabel: (session, chat, reader) => getDistinctChatHarnessLabel(session, chat, sessionsProvidersService, reader), isPinned: session => this.isSessionPinned(session), isRenderedInCustomGroup: session => this.isRenderedInCustomGroup(session), getComparisonAttemptLabel: session => this.renderedComparisonAttemptLabels.get(session.sessionId), @@ -4685,6 +4697,7 @@ export class SessionsList extends Disposable implements ISessionsList { moveHeader: (header, drop) => this.moveHeader(header, drop), getMovableDraggedChat: item => this.getMovableDraggedChat(item), areChatPositionsShown: () => this.areChatPositionsShown(), + isCrossAgentChatMoveEnabled: () => isCrossAgentChatMoveEnabled(this.configurationService), canMoveSessionUnder: session => this.isChatMoveDndEnabled() && this._sessionsManagementService.canMoveSessionUnder(session), moveChat: (session, chat, drop) => this.moveChatByDrop(session, chat, drop), moveSessionUnder: (session, target, after) => this.moveSessionUnderByDrop(session, target, after), @@ -6724,7 +6737,7 @@ export class SessionsList extends Disposable implements ISessionsList { const positionsShown = this.areChatPositionsShown(); const target = positionsShown ? this.getAdjacentChatRow(item, direction) : undefined; const movable = target && this.getMovableDraggedChat(item); - const drop = target && movable && getSessionsListChatDrop(movable.session, movable.chat, { session: target.session, chat: target.chat, position: direction === 'up' ? 'before' : 'after' }, positionsShown); + const drop = target && movable && getSessionsListChatDrop(movable.session, movable.chat, { session: target.session, chat: target.chat, position: direction === 'up' ? 'before' : 'after' }, positionsShown, isCrossAgentChatMoveEnabled(this.configurationService)); return movable && drop ? { session: movable.session, chat: movable.chat, drop } : undefined; } @@ -7578,7 +7591,7 @@ export class SessionsList extends Disposable implements ISessionsList { const contextOverlay: [string, boolean | string][] = [ [IsSessionPinnedContext.key, this.isSessionPinned(element)], [SessionItemIsMultiSelectionContext.key, selectedSessions.length > 1], - [SessionItemHasMoveDestinationContext.key, getSessionMoveDestinations(element, this._sessionsManagementService.getSessions(), this._sessionsManagementService).length > 0], + [SessionItemHasMoveDestinationContext.key, getSessionMoveDestinations(element, this._sessionsManagementService.getSessions(), this._sessionsManagementService, isCrossAgentChatMoveEnabled(this.configurationService)).length > 0], [SessionItemHasUnreadNestedSessionsContext.key, selectedSessions.some(session => !session.isArchived.get() && session.capabilities.get().supportsIndependentChatReadState === true @@ -7671,7 +7684,7 @@ export class SessionsList extends Disposable implements ISessionsList { [SessionChatItemCanMoveUpContext.key, this.getAdjacentChatMove(element, 'up') !== undefined], [SessionChatItemCanMoveDownContext.key, this.getAdjacentChatMove(element, 'down') !== undefined], [SessionChatItemCanMoveContext.key, this._sessionsManagementService.canMoveChat(element.session, element.chat)], - [SessionChatItemHasMoveDestinationContext.key, getChatMoveDestinations(element.session, element.chat, this._sessionsManagementService.getSessions(), this._sessionsManagementService).length > 0], + [SessionChatItemHasMoveDestinationContext.key, getChatMoveDestinations(element.session, element.chat, this._sessionsManagementService.getSessions(), this._sessionsManagementService, isCrossAgentChatMoveEnabled(this.configurationService)).length > 0], [SessionProviderIdContext.key, element.session.providerId], ]); const menu = this.menuService.createMenu(Menus.SessionChatItemContext, contextKeyService); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsListChatMove.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsListChatMove.ts index 10ac1f09d2251..3935425b899ce 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsListChatMove.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsListChatMove.ts @@ -11,11 +11,12 @@ import { isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { ChatMoveSettingId } from '../../../../../platform/agentHost/common/agentHostChatMove.js'; +import { ChatProvidersSettingId } from '../../../../../platform/agentHost/common/agentHostChatProviders.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { IChatEntitlementService } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; -import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, ChatOriginKind, getChatHarness, IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ISessionChatMoveTarget, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { IChatMoveEnd, SessionMoveRefusedError } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionGroupsService } from '../../../../services/sessions/browser/sessionGroupsService.js'; @@ -31,15 +32,26 @@ export function isChatMoveDndEnabled(configurationService: IConfigurationService && !chatEntitlementService.sentiment.hidden; } +/** + * Whether chats may move into sessions of another agent of the same provider, keeping their own agent: both Move Chats + * and chats with their own agents are enabled. + */ +export function isCrossAgentChatMoveEnabled(configurationService: IConfigurationService): boolean { + return configurationService.getValue(ChatMoveSettingId.Enabled) === true + && configurationService.getValue(ChatProvidersSettingId.Enabled) === true; +} + /** * Whether `target` can receive a chat (or a single-chat session) moved from `source`: another started, unarchived - * session of the same provider and agent (its session type: one provider can serve several agents, e.g. Copilot, - * Claude and Codex) that holds several chats. + * session of the same provider that holds several chats, and of the same agent (its session type: one provider can + * serve several agents, e.g. Copilot, Claude and Codex), or, when `harness` is given (cross-agent moves are on, see + * {@link isCrossAgentChatMoveEnabled}), of another agent whose session offers its chats `harness`: the harness of the + * moved chat, which keeps its agent. */ -export function canMoveIntoSession(source: ISession, target: ISession): boolean { +export function canMoveIntoSession(source: ISession, target: ISession, harness?: string): boolean { return target.sessionId !== source.sessionId && target.providerId === source.providerId - && target.sessionType === source.sessionType + && (target.sessionType === source.sessionType || (harness !== undefined && !!target.capabilities.get().supportsChatHarnesses?.includes(harness))) && !target.isArchived.get() && target.status.get() !== SessionStatus.Untitled && target.capabilities.get().supportsMultipleChats; @@ -67,27 +79,42 @@ export interface ISessionsListMoveDestination { /** * Where moving a chat or single-chat session of `source` to the end of `target` puts it, or `undefined` when `target` - * can't receive it (see {@link canMoveIntoSession}). `exclude` is the moved chat, when it is already in `target`. The - * sessions list's drops and the move commands share it. + * can't receive it (see {@link canMoveIntoSession}, which `harness` is passed to). `exclude` is the moved chat, when it + * is already in `target`. The sessions list's drops and the move commands share it. */ -export function getChatMoveAppendDestination(source: ISession, target: ISession, exclude?: IChat): ISessionsListMoveDestination | undefined { - return canMoveIntoSession(source, target) ? { session: target, after: getChatMoveAppendAnchor(target, exclude) } : undefined; +export function getChatMoveAppendDestination(source: ISession, target: ISession, exclude?: IChat, harness?: string): ISessionsListMoveDestination | undefined { + return canMoveIntoSession(source, target, harness) ? { session: target, after: getChatMoveAppendAnchor(target, exclude) } : undefined; } -/** The sessions the nested session `chat` of `source` can move into, each at its end, in the order of `sessions`: none when its provider can't move it. */ -export function getChatMoveDestinations(source: ISession, chat: IChat, sessions: readonly ISession[], sessionsManagementService: Pick): ISessionsListMoveDestination[] { +/** The harness a chat moved across agents keeps, or `undefined` while cross-agent moves are off (see {@link isCrossAgentChatMoveEnabled}). */ +function getMovedChatHarness(source: ISession, chat: IChat, crossAgent: boolean | undefined): string | undefined { + return crossAgent ? getChatHarness(source, chat) : undefined; +} + +/** + * The sessions the nested session `chat` of `source` can move into, each at its end, in the order of `sessions`: none + * when its provider can't move it. With `crossAgent` (see {@link isCrossAgentChatMoveEnabled}), also the sessions of + * other agents that offer the chat's harness. + */ +export function getChatMoveDestinations(source: ISession, chat: IChat, sessions: readonly ISession[], sessionsManagementService: Pick, crossAgent?: boolean): ISessionsListMoveDestination[] { if (!sessionsManagementService.canMoveChat(source, chat)) { return []; } - return sessions.flatMap(session => getChatMoveAppendDestination(source, session, chat) ?? []); + const harness = getMovedChatHarness(source, chat, crossAgent); + return sessions.flatMap(session => getChatMoveAppendDestination(source, session, chat, harness) ?? []); } -/** The sessions the single-chat session `source` can nest under, each at its end, in the order of `sessions`: none when its provider can't move it. */ -export function getSessionMoveDestinations(source: ISession, sessions: readonly ISession[], sessionsManagementService: Pick): ISessionsListMoveDestination[] { +/** + * The sessions the single-chat session `source` can nest under, each at its end, in the order of `sessions`: none + * when its provider can't move it. With `crossAgent`, also the sessions of other agents that offer the harness of its + * main chat. + */ +export function getSessionMoveDestinations(source: ISession, sessions: readonly ISession[], sessionsManagementService: Pick, crossAgent?: boolean): ISessionsListMoveDestination[] { if (!sessionsManagementService.canMoveSessionUnder(source)) { return []; } - return sessions.flatMap(session => getChatMoveAppendDestination(source, session) ?? []); + const harness = getMovedChatHarness(source, source.mainChat.get(), crossAgent); + return sessions.flatMap(session => getChatMoveAppendDestination(source, session, undefined, harness) ?? []); } /** @@ -154,8 +181,10 @@ export type SessionsListChatDrop = * Positions are anchored in the order of the session's chats, whatever order the list shows them in. * When the list doesn't show the session's chats in that order (`positionsShown` false), a chat can't be reordered in * its own session, and dropped on a nested chat row of another session it goes to that session's end. + * With `crossAgent` (see {@link isCrossAgentChatMoveEnabled}), it can also drop into sessions of other agents that + * offer its harness. */ -export function getSessionsListChatDrop(source: ISession, chat: IChat, target: ISessionsListChatDropTarget | undefined, positionsShown: boolean): SessionsListChatDrop | undefined { +export function getSessionsListChatDrop(source: ISession, chat: IChat, target: ISessionsListChatDropTarget | undefined, positionsShown: boolean, crossAgent?: boolean): SessionsListChatDrop | undefined { const session = target?.session; if (!target || !session) { return { kind: 'newSession' }; @@ -164,7 +193,7 @@ export function getSessionsListChatDrop(source: ISession, chat: IChat, target: I if (!positionsShown && isOwnSession) { return undefined; } - const append = isOwnSession ? { session, after: getChatMoveAppendAnchor(session, chat) } : getChatMoveAppendDestination(source, session, chat); + const append = isOwnSession ? { session, after: getChatMoveAppendAnchor(session, chat) } : getChatMoveAppendDestination(source, session, chat, getMovedChatHarness(source, chat, crossAgent)); if (!append) { return undefined; } @@ -224,8 +253,9 @@ export type SessionsListSessionDrop = * where the list accepts that placement; otherwise these quarters nest too. * When the list doesn't show the session's chats in that order (`positionsShown` false), a nested chat row is the * session's row: the session goes to its end. + * With `crossAgent`, it can also nest under sessions of other agents that offer the harness of its main chat. */ -export function getSessionsListSessionDrop(source: ISession, target: ISessionsListSessionDropTarget, positionsShown: boolean): SessionsListSessionDrop | undefined { +export function getSessionsListSessionDrop(source: ISession, target: ISessionsListSessionDropTarget, positionsShown: boolean, crossAgent?: boolean): SessionsListSessionDrop | undefined { const { session, quarter } = target; const chat = positionsShown ? target.chat : undefined; if (!target.chat && quarter === ListViewTargetSector.TOP && target.isManuallyOrdered() && target.canPlace('before')) { @@ -234,7 +264,7 @@ export function getSessionsListSessionDrop(source: ISession, target: ISessionsLi if (quarter === ListViewTargetSector.BOTTOM && target.isManuallyOrdered() && target.isLastInSection() && target.canPlace('after')) { return { kind: 'place', session, position: 'after' }; } - const append = getChatMoveAppendDestination(source, session); + const append = getChatMoveAppendDestination(source, session, undefined, getMovedChatHarness(source, source.mainChat.get(), crossAgent)); if (!append) { return undefined; } diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index 492698e71caf0..b10b061ef1edf 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -17,7 +17,7 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { ExtUri } from '../../../../../base/common/resources.js'; import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; -import { autorun, constObservable, derived, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; +import { autorun, constObservable, derived, IObservable, IReader, ISettableObservable, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { OS } from '../../../../../base/common/platform.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -81,7 +81,7 @@ import { ISessionsListModelService, SessionsListModelService } from '../../../.. import { ISessionGroup, ISessionGroupsChangeEvent, ISessionGroupsService, SessionGroupsService, SessionGroupTree } from '../../../../services/sessions/browser/sessionGroupsService.js'; import { ISessionSectionOrderService, SessionSectionOrderService } from '../../../../services/sessions/browser/sessionSectionOrderService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; -import { BRANCH_CHANGES_CHANGESET_ID, ChatInteractivity, ChatOriginKind, IChat, ISession, ISessionChangeset, ISessionChangesSummary, ISessionFileChange, ISessionFolder, ISessionType, SessionArtifactKind, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { BRANCH_CHANGES_CHANGESET_ID, ChatInteractivity, ChatOriginKind, IChat, ISession, ISessionChangeset, ISessionChangesSummary, ISessionFileChange, ISessionFolder, ISessionType, SessionArtifactKind, SessionStatus, SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js'; import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionChangeEvent, ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionComparison, ISessionComparisonService, SessionComparisonParticipantRole } from '../../../../services/sessions/common/sessionComparison.js'; @@ -95,7 +95,7 @@ import { ChatToolInvocation } from '../../../../../workbench/contrib/chat/common import { ChatAgentService, IChatAgentService } from '../../../../../workbench/contrib/chat/common/participants/chatAgents.js'; import { ToolDataSource } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { MockChatService } from '../../../../../workbench/contrib/chat/test/common/chatService/mockChatService.js'; -import { getChatSummaryHoverData, getSessionDiffStats, getSessionSummaryHoverData } from '../../browser/sessionHoverContent.js'; +import { getChatSummaryHoverData, getDistinctChatHarnessLabel, getSessionDiffStats, getSessionSummaryHoverData } from '../../browser/sessionHoverContent.js'; import { createListHarness, createTestSession, IListHarnessOptions, ISortChangeRecord, ITestSessionOptions, TestSessionsManagementService } from './sessionsListTestUtils.js'; import { SessionsArchiveActionsContribution } from '../../browser/views/sessionsViewActions.js'; import { renderSessionsHeader } from '../../browser/views/sessionsView.js'; @@ -4560,6 +4560,38 @@ suite('Sessions - SessionsList', () => { }); }); + suite('Mixed harnesses', () => { + test('a session whose chats use different harnesses says so in its label, without an icon', () => { + const base = createTestSession('Mixed').session; + const mainChat = base.mainChat.get(); + const chats = observableValue('chats', [mainChat, { ...mainChat, harness: 'claude' }]); + const session: ISession = { ...base, chats, mainChat: constObservable(mainChat) }; + const harness = createListHarness(disposables, [session]); + const container = harness.createContainer(); + const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { + grouping: () => SessionsGrouping.Date, + sorting: () => SessionsSorting.Created, + onSessionOpen: () => { }, + })); + list.layout(300, 400); + const snapshot = () => { + const row = container.querySelector('.session-item'); + return { + icon: !!row?.querySelector('.codicon-combine, .session-mixed-harness-indicator'), + mixedLabel: row?.closest('.monaco-list-row')?.getAttribute('aria-label')?.includes('chats use different harnesses') ?? false, + }; + }; + + const mixed = snapshot(); + chats.set([mainChat], undefined); + + assert.deepStrictEqual({ mixed, single: snapshot() }, { + mixed: { icon: false, mixedLabel: true }, + single: { icon: false, mixedLabel: false }, + }); + }); + }); + suite('Application session hover', () => { for (const flat of [false, true]) { test(`updates ${flat ? 'flat' : 'grouped'} application session hovers while visible and when reopened`, async () => { @@ -9030,6 +9062,42 @@ suite('Sessions - SessionsList', () => { }); }); + test('a peer chat on another harness than its session\'s names that harness in its hover and label', () => { + const first = { root: URI.file('/workspace/first'), workingDirectory: URI.file('/workspace/first'), name: 'first', description: undefined }; + const base = createMultiFolderSession([first], [undefined, [first]]); + const [main, peer] = base.chats.get(); + const known = observableValue('known', false); + const provider = upcastPartial({ + label: 'Agent Host', + sessionTypes: [{ id: base.sessionType, label: 'Copilot', icon: Codicon.copilot, authRequirement: SessionTypeAuthRequirement.GitHub }], + getHarnessPresentation: (harness: string, reader?: IReader) => known.read(reader) && harness === 'claude' ? { label: 'Claude', icon: Codicon.claude } : undefined, + }); + const services = [ + upcastPartial({ getProvider: () => provider as T }), + upcastPartial({ open: () => Promise.resolve(true) }), + upcastPartial({ getUriLabel: resource => resource.path }), + ] as const; + const otherHarness: IChat = { ...peer, harness: 'claude' }; + const session: ISession = { ...base, chats: constObservable([main, otherHarness]) }; + const label = (chat: IChat) => getDistinctChatHarnessLabel(session, chat, services[0], undefined); + const before = { hover: getChatSummaryHoverData(session, otherHarness, ...services).providerLabel, label: label(otherHarness) }; + known.set(true, undefined); + + assert.deepStrictEqual({ + main: getChatSummaryHoverData(session, main, ...services).providerLabel, + sameHarness: getChatSummaryHoverData(session, peer, ...services).providerLabel, + sameHarnessLabel: label(peer), + before, + after: { hover: getChatSummaryHoverData(session, otherHarness, ...services).providerLabel, label: label(otherHarness) }, + }, { + main: 'Copilot', + sameHarness: 'Copilot', + sameHarnessLabel: undefined, + before: { hover: 'Other Harness', label: 'Other Harness' }, + after: { hover: 'Claude', label: 'Claude' }, + }); + }); + test('peer chat hover ignores the session pending worktree flag', () => { const first = { root: URI.file('/workspace/first'), workingDirectory: URI.file('/workspace/first'), name: 'first', description: undefined }; const second: ISessionFolder = { diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListChatMoveDnd.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListChatMoveDnd.test.ts index 917e8671afaf6..82cf8a53d6693 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListChatMoveDnd.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListChatMoveDnd.test.ts @@ -20,7 +20,7 @@ import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionStatus } fro import { ISessionChatMoveTarget, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { IChatMovedEvent, ISessionChangeEvent, SessionMoveRefusedError } from '../../../../services/sessions/common/sessionsProvider.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; -import { canMoveIntoSession, getChatMoveAnchorBefore, getChatMoveAppendAnchor, getChatMoveDestinations, getSessionMoveDestinations, getSessionsListChatDrop, getSessionsListSessionDrop, ISessionsListChatDropTarget, ISessionsListMoveDestination, runSessionsListMove, SessionsListChatDrop, SessionsListMove, SessionsListMoveNotice } from '../../browser/views/sessionsListChatMove.js'; +import { canMoveIntoSession, getChatMoveAnchorBefore, isCrossAgentChatMoveEnabled, getChatMoveAppendAnchor, getChatMoveDestinations, getSessionMoveDestinations, getSessionsListChatDrop, getSessionsListSessionDrop, ISessionsListChatDropTarget, ISessionsListMoveDestination, runSessionsListMove, SessionsListChatDrop, SessionsListMove, SessionsListMoveNotice } from '../../browser/views/sessionsListChatMove.js'; import { createListHarness, createTestSession, ITestSession, TestSessionsManagementService } from './sessionsListTestUtils.js'; import { timeout } from '../../../../../base/common/async.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; @@ -46,6 +46,7 @@ import { CommandsRegistry } from '../../../../../platform/commands/common/comman import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; import { IView } from '../../../../../workbench/common/views.js'; import { SessionsListNotification } from '../../browser/views/sessionsListNotification.js'; +import { ChatProvidersSettingId } from '../../../../../platform/agentHost/common/agentHostChatProviders.js'; function multiChatSession(title: string, options?: { readonly providerId?: string; readonly status?: SessionStatus; readonly isArchived?: boolean; readonly chats?: readonly IChat[] }): ITestSession { @@ -287,6 +288,55 @@ suite('Move Chats Drag and Drop', () => { }); }); + test('with chats on their own agents and Move Chats both on, also lists sessions of another agent that offer the moved chat\'s harness', () => { + const moving = { ...chat('moving', ChatOriginKind.User), harness: 'claude' }; + const source = multiChatSession('Source', { chats: [chat('source-main'), moving] }).session; + // Another agent of the same provider, without and with the harnesses of the moved chat and the source's main chat. + const otherAgent = (title: string, harnesses: readonly string[]) => { + const test = multiChatSession(title, { chats: [chat(`${title}-main`)] }); + test.capabilities.set({ supportsMultipleChats: true, supportsChatHarnesses: harnesses }, undefined); + return { ...test.session, sessionType: 'claude', harness: 'claude' }; + }; + const sessions = [ + source, + otherAgent('Claude', ['claude', 'codex']), + otherAgent('Claude Copilot', ['claude', 'copilot']), + otherAgent('Codex', ['codex']), + { ...otherAgent('Other Provider', ['claude', 'copilot']), providerId: 'other' }, + ]; + const describeAll = (destinations: readonly ISessionsListMoveDestination[]) => destinations.map(destination => destination.session.title.get()); + const destinations = (crossAgent: boolean | undefined) => ({ + chat: describeAll(getChatMoveDestinations(source, moving, sessions, { canMoveChat: () => true }, crossAgent)), + chatDrop: sessions.flatMap(session => { + const drop = getSessionsListChatDrop(source, moving, { session, chat: undefined, position: 'after' }, true, crossAgent); + return drop?.kind === 'session' ? [drop.session.title.get()] : []; + }), + // A whole session moves with the harness of its main chat: the session's. + session: describeAll(getSessionMoveDestinations(source, sessions, { canMoveSessionUnder: () => true }, crossAgent)), + sessionDrop: sessions.flatMap(session => { + const drop = getSessionsListSessionDrop(source, { session, chat: undefined, quarter: ListViewTargetSector.CENTER_BOTTOM, isManuallyOrdered: () => false, isLastInSection: () => false, canPlace: () => false }, true, crossAgent); + return drop?.kind === 'nest' ? [drop.session.title.get()] : []; + }), + }); + const configured = (moveChats: boolean, chatProviders: boolean) => isCrossAgentChatMoveEnabled(new TestConfigurationService({ [ChatMoveSettingId.Enabled]: moveChats, [ChatProvidersSettingId.Enabled]: chatProviders })); + + assert.deepStrictEqual({ + gates: { both: configured(true, true), moveChatsOnly: configured(true, false), chatProvidersOnly: configured(false, true) }, + off: destinations(false), + on: destinations(true), + }, { + gates: { both: true, moveChatsOnly: false, chatProvidersOnly: false }, + // Either setting off: only sessions of the same agent, as without chats on their own agents. + off: { chat: [], chatDrop: [], session: [], sessionDrop: [] }, + on: { + chat: ['Claude', 'Claude Copilot'], + chatDrop: ['Claude', 'Claude Copilot'], + session: ['Claude Copilot'], + sessionDrop: ['Claude Copilot'], + }, + }); + }); + test('lists the sessions a nested session or a session can move into, each at its end, by the rules a drop uses', () => { const moving = chat('moving', ChatOriginKind.User); const source = multiChatSession('Source', { chats: [chat('source-main'), moving] }).session; diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 1131d0696d1c6..d37251f7546ac 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -28,7 +28,7 @@ import { getSessionReferenceResource } from './sessionReference.js'; import { IChatDeletedEvent, ISessionChatMoveTarget, ICreateNewChatInSessionOptions, ICreateNewSessionOptions, IDeferredNewSessionRequestOptions, IMarkSessionReadOptions, IProviderSessionType, ISendRequestOptions, ISendRequestSentEvent, ISessionsChangeEvent, ISessionsManagementService, NewSessionRequestOptions, WorkspaceNotTrustedError } from '../common/sessionsManagement.js'; import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from './sessionsProvidersService.js'; import { IChatMovedEvent, IDeleteChatOptions, type IForkChatOptions, type INestedChatWorkspace, IPreparedNewSession, NestedChatWorkspaceError, SessionMoveRefusedError, ISessionChangeEvent, ISessionChangesetOperationInvokedEvent, ISessionCreationTarget, ISessionsProvider, type ISessionsProviderCreateSessionOptions, type SessionResourceResolveReason } from '../common/sessionsProvider.js'; -import { ChatModelSource, IChat, ISession, ISessionCanvas, ISessionCanvasDefinition, ISessionWorkspace, ISideChatSelection, isActiveSessionStatus, SessionStatus, ISessionType, type SessionCanvasDefinitionSnapshot } from '../common/session.js'; +import { ChatModelSource, IChat, ISession, ISessionCanvas, ISessionCanvasDefinition, ISessionWorkspace, ISideChatSelection, isActiveSessionStatus, SessionStatus, ISessionType, type SessionCanvasDefinitionSnapshot, getChatHarness } from '../common/session.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; @@ -723,12 +723,14 @@ export class SessionsManagementService extends Disposable implements ISessionsMa // `forceNew` skips reuse so callers can reset the composer right after // sending a chat (which may still transiently report `Untitled`). if (!options?.forceNew) { - const existingUntitled = session.chats.get().find(c => c.status.get() === SessionStatus.Untitled); + // Reuse only a draft using the requested harness; a different harness needs a chat of its own. + const requestedHarness = options?.harness ?? session.harness; + const existingUntitled = session.chats.get().find(c => c.status.get() === SessionStatus.Untitled && getChatHarness(session, c) === requestedHarness); if (existingUntitled) { return existingUntitled; } } - const created = await provider.createNewChat(session.sessionId); + const created = await provider.createNewChat(session.sessionId, undefined, options); return created; } diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 105d1d258dd19..04a949f466a77 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -790,6 +790,11 @@ export const enum ChatModelSource { export interface IChat { /** Resource URI identifying this chat. */ readonly resource: URI; + /** + * The chat's harness identity, in the same identity space as {@link ISession.harness}. + * Absent means the chat uses the session's harness. + */ + readonly harness?: string; /** When the chat was created. */ readonly createdAt: Date; @@ -891,6 +896,17 @@ export function isSideChatOf(chat: IChat, parentChat: URI): boolean { && isEqual(chat.origin.parentChat, parentChat); } +/** The harness a chat of `session` uses: its own, or the session's when it reports none. */ +export function getChatHarness(session: Pick, chat: Pick): string { + return chat.harness ?? session.harness; +} + +/** Whether the chats of a session use more than one harness; the session's harness is its main chat's. */ +export function hasMixedChatHarnesses(session: ISession, reader: IReader | undefined): boolean { + const mainHarness = getChatHarness(session, session.mainChat.read(reader)); + return session.chats.read(reader).some(chat => getChatHarness(session, chat) !== mainHarness); +} + /** * Resolve a chat's effective capabilities. Combines the chat's own advertised * {@link IChat.capabilities} (falling back to {@link DEFAULT_CHAT_CAPABILITIES}) @@ -1066,6 +1082,12 @@ export interface ISessionCapabilities { readonly supportsCanvases?: boolean; /** Whether this session supports multiple chats. */ readonly supportsMultipleChats: boolean; + /** + * Harness ids a new peer chat may choose in this session, in the same identity + * space as {@link ISession.harness}. Absent or empty means choosing a harness + * for a new peer chat is unsupported. + */ + readonly supportsChatHarnesses?: readonly string[]; /** Whether chats in this session can update their read state independently. */ readonly supportsIndependentChatReadState?: boolean; /** diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index a33de217b39ef..56731740a43e4 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -13,7 +13,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IAutomationSessionTemplate } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { IChat, ISession, ISessionCanvas, ISessionCanvasDefinition, ISessionCreationReference, ISessionType, ISessionWorkspace, ISideChatSelection, type SessionCanvasDefinitionSnapshot } from './session.js'; -import { IAutomationSessionConfiguration, IChatMovedEvent, IDeleteChatOptions, type IForkChatOptions, type INestedChatWorkspace, ISessionChangesetOperationInvokedEvent, ISessionConfigurationSnapshot, ISessionCreationTarget, ISendRequestOptions as ISessionsProviderSendRequestOptions, type SessionResourceResolveReason } from './sessionsProvider.js'; +import { IAutomationSessionConfiguration, IChatMovedEvent, IDeleteChatOptions, type IForkChatOptions, type INestedChatWorkspace, ISessionChangesetOperationInvokedEvent, ISessionConfigurationSnapshot, ISessionCreationTarget, ISendRequestOptions as ISessionsProviderSendRequestOptions, type SessionResourceResolveReason, ICreateNewChatOptions } from './sessionsProvider.js'; /** Raised when unattended session creation targets a workspace that requires trust. */ export class WorkspaceNotTrustedError extends Error { @@ -143,7 +143,7 @@ export interface ICreateNewSessionOptions { /** * Options for {@link ISessionsManagementService.createNewChatInSession}. */ -export interface ICreateNewChatInSessionOptions { +export interface ICreateNewChatInSessionOptions extends ICreateNewChatOptions { /** * Always create a fresh chat instead of reusing an existing untitled one. * Used to reset the composer right after a background send, where the diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index 412eabf53fd12..2393f90bad664 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -8,6 +8,7 @@ import type { IAgentHostWorktreeCleanupStatus } from '../../../../platform/agent import { Event } from '../../../../base/common/event.js'; import { IDisposable, IReference } from '../../../../base/common/lifecycle.js'; import { IObservable, IReader } from '../../../../base/common/observable.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { IArtifactModel } from '../../../../platform/artifactIntegrations/common/artifactIntegration.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; @@ -296,6 +297,31 @@ export interface ISessionsProviderAutomations extends IAutomationStore { readonly unavailableMessage?: string; } +/** How a harness is shown to the user. */ +export interface IHarnessPresentation { + readonly label: string; + readonly icon: ThemeIcon; +} + +/** + * Presents `harness` through `provider`, falling back to a generic presentation + * while the provider does not know it yet; a raw harness id is never shown. + */ +export function getHarnessPresentationOrDefault(provider: Pick | undefined, harness: string, reader: IReader | undefined): IHarnessPresentation { + return provider?.getHarnessPresentation?.(harness, reader) ?? { label: localize('otherHarness', "Other Harness"), icon: Codicon.agent }; +} + +/** + * Options for {@link ISessionsProvider.createNewChat}. + */ +export interface ICreateNewChatOptions { + /** + * The harness the new peer chat should use, chosen among the session's + * `ISessionCapabilities.supportsChatHarnesses`. Absent means the session's harness. + */ + readonly harness?: string; +} + /** * Options controlling how a chat is deleted via {@link ISessionsProvider.deleteChat}. */ @@ -593,8 +619,9 @@ export interface ISessionsProvider { /** * Get selectable models and the current resolution of `desiredModelId`. * Callers wait for {@link onDidChangeModels} while the requested model is pending. + * @param chat The chat whose models to resolve, when its harness may differ from the session's ({@link IChat.harness}). */ - getModelsSnapshot(sessionId: string, desiredModelId?: string): ISessionModelsSnapshot; + getModelsSnapshot(sessionId: string, desiredModelId?: string, chat?: URI): ISessionModelsSnapshot; /** * Get selectable models before creating a session, with no workspace URI for a quick chat. @@ -608,8 +635,9 @@ export interface ISessionsProvider { * are presented (grouping, featured models, whether the manage-models action * is offered), so provider-specific behavior is not hardcoded in core. * @param sessionId The ID of the session. + * @param chat The chat the picker selects for, when its harness may differ from the session's. */ - getModelPickerOptions(sessionId: string): ISessionModelPickerOptions; + getModelPickerOptions(sessionId: string, chat?: URI): ISessionModelPickerOptions; /** * Event that fires when the snapshot returned by {@link getModelsSnapshot} @@ -750,13 +778,29 @@ export interface ISessionsProvider { */ deleteChat(sessionId: string, chatUri: URI, options?: IDeleteChatOptions): Promise; + /** + * Presentation of a harness this provider serves, in the identity space of + * {@link ISession.harness}, for surfaces that show or pick a chat's harness. + * Pass a `reader` to re-evaluate when the provider learns about its harnesses + * (e.g. once a restored session's host advertises its agents). + */ + getHarnessPresentation?(harness: string, reader?: IReader): IHarnessPresentation | undefined; + + /** + * The session type of the agent behind a harness this provider serves, in the identity space of + * {@link ISession.harness}, so a peer chat's harness can be picked like a new session's type. + * Not reactive: providers must fire {@link onDidChangeSessionTypes} when its result may change. + */ + getHarnessSessionType?(harness: string): ISessionType | undefined; + /** * Create a new chat in the given session and return it. * * @param sessionId The ID of the session to create the new chat in. * @param prompt Optional prompt to initialize the new chat with. + * @param options Optional creation options, e.g. the harness to use. */ - createNewChat(sessionId: string, prompt?: string): Promise; + createNewChat(sessionId: string, prompt?: string, options?: ICreateNewChatOptions): Promise; /** * What the composer may offer for a new nested chat in the session. Cheap and synchronous; tracked by `reader`, @@ -764,14 +808,16 @@ export interface ISessionsProvider { * `undefined` when the composer must not offer workspace choices (a host without nested chat workspaces, a * provider or session without multiple chats, an archived or unknown session, or a provider that is not an * agent host). + * `chat` is the new chat: what it may choose follows its own agent, which differs from the session's for a + * chat using another harness. */ - getNestedChatWorkspaceSupport(sessionId: string, reader: IReader | undefined): INestedChatWorkspaceSupport | undefined; + getNestedChatWorkspaceSupport(sessionId: string, chat: URI, reader: IReader | undefined): INestedChatWorkspaceSupport | undefined; - /** Resolves the isolation and branch facts of `folder` for a new nested chat in the session. */ - resolveNestedChatFolder(sessionId: string, folder: URI, token: CancellationToken): Promise; + /** Resolves the isolation and branch facts of `folder` for `chat`, a new nested chat in the session, with its own agent. */ + resolveNestedChatFolder(sessionId: string, chat: URI, folder: URI, token: CancellationToken): Promise; - /** Branch names of the repository of `folder` matching `query` (may be empty). */ - getNestedChatBranches(sessionId: string, folder: URI, query: string, token: CancellationToken): Promise; + /** Branch names of the repository of `folder` matching `query` (may be empty), as `chat`'s own agent lists them. */ + getNestedChatBranches(sessionId: string, chat: URI, folder: URI, query: string, token: CancellationToken): Promise; /** * Gives `chat`, an untitled peer chat of the session that has not sent a request, the chosen workspace; diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 2426c92d38af9..cc02f0bfdfcf4 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -52,7 +52,7 @@ import { TestLifecycleService } from '../../../../../workbench/test/common/workb import { SessionTypeAuthRequirement, ChatInteractivity, ChatOriginKind, IChat, ISession, ISessionType, ISessionWorkspace, ISideChatSelection, SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus, SessionStatus } from '../../common/session.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { IAutomationSessionTemplate } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; -import { IChatMovedEvent, ISessionChatMoveDestination, type IForkChatOptions, type INestedChatWorkspace, NestedChatWorkspaceError, ISessionChangeEvent, ISessionChangesetOperationInvokedEvent, ISendRequestOptions, ISessionModelsSnapshot, ISessionModelPickerOptions, ISessionsProvider, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../common/sessionsProvider.js'; +import { IChatMovedEvent, ISessionChatMoveDestination, type IForkChatOptions, type INestedChatWorkspace, NestedChatWorkspaceError, ISessionChangeEvent, ISessionChangesetOperationInvokedEvent, ISendRequestOptions, ISessionModelsSnapshot, ISessionModelPickerOptions, ISessionsProvider, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration, type ICreateNewChatOptions } from '../../common/sessionsProvider.js'; import { SessionsManagementService } from '../../browser/sessionsManagementService.js'; import { VisibleSessions } from '../../browser/visibleSessions.js'; import { ISessionsManagementService, IActiveSession, IChatDeletedEvent, ICreateNewSessionOptions, inheritableSessionTarget, ISendRequestSentEvent, WorkspaceNotTrustedError } from '../../common/sessionsManagement.js'; @@ -7561,6 +7561,63 @@ suite('SessionsManagementService', () => { }); }); + test('without a harness, reuses only a draft of the main harness and creates a chat of the main harness', async () => { + const otherDraft: IChat = { ...stubChat, resource: URI.parse('test:///other-draft'), harness: 'other', status: constObservable(SessionStatus.Untitled) }; + const createdChat: IChat = { ...stubChat, resource: URI.parse('test:///created') }; + const session = stubSession({ sessionId: 'main-harness-reuse', providerId: 'test', harness: 'main', chats: constObservable([otherDraft]) }); + const requests: (ICreateNewChatOptions | undefined)[] = []; + const provider = new class extends TestSessionsProvider { + constructor() { super(session); } + override async createNewChat(_sessionId?: string, _prompt?: string, options?: ICreateNewChatOptions): Promise { + requests.push(options); + return createdChat; + } + }; + const { service } = createSessionsManagementService(session, disposables, provider); + + // The New Chat action passes no options; the composer resets with `forceNew` after a background send. + const fromAction = await service.createNewChatInSession(session); + const afterSend = await service.createNewChatInSession(session, { forceNew: true }); + + assert.deepStrictEqual({ + fromAction: fromAction?.resource.toString(), + afterSend: afterSend?.resource.toString(), + requestedHarnesses: requests.map(options => options?.harness), + }, { + fromAction: createdChat.resource.toString(), + afterSend: createdChat.resource.toString(), + requestedHarnesses: [undefined, undefined], + }); + }); + + test('reuses an untitled chat only when it uses the requested harness', async () => { + const untitledChat: IChat = { ...stubChat, resource: URI.parse('test:///untitled'), status: constObservable(SessionStatus.Untitled) }; + const createdChat: IChat = { ...stubChat, resource: URI.parse('test:///created'), harness: 'other' }; + const session = stubSession({ sessionId: 'harness-reuse', providerId: 'test', harness: 'main', chats: constObservable([untitledChat]) }); + const requests: (string | undefined)[] = []; + const provider = new class extends TestSessionsProvider { + constructor() { super(session); } + override async createNewChat(_sessionId?: string, _prompt?: string, options?: ICreateNewChatOptions): Promise { + requests.push(options?.harness); + return createdChat; + } + }; + const { service } = createSessionsManagementService(session, disposables, provider); + + const sameHarness = await service.createNewChatInSession(session, { harness: 'main' }); + const otherHarness = await service.createNewChatInSession(session, { harness: 'other' }); + + assert.deepStrictEqual({ + sameHarnessReused: sameHarness === untitledChat, + otherHarness: otherHarness?.resource.toString(), + requests, + }, { + sameHarnessReused: true, + otherHarness: createdChat.resource.toString(), + requests: ['other'], + }); + }); + test('asks the provider to create a chat when none are untitled', async () => { const activeChat: IChat = { ...stubChat, resource: URI.parse('test:///active'), status: constObservable(SessionStatus.InProgress) }; const createdChat: IChat = { ...stubChat, resource: URI.parse('test:///created') }; diff --git a/src/vs/sessions/services/sessions/test/common/session.test.ts b/src/vs/sessions/services/sessions/test/common/session.test.ts index 0e4ed0be3c7c9..fd0c019013f8b 100644 --- a/src/vs/sessions/services/sessions/test/common/session.test.ts +++ b/src/vs/sessions/services/sessions/test/common/session.test.ts @@ -9,9 +9,10 @@ import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { constObservable, IObservable } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IChatSessionFileChange, IChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { getSessionFolderGitHubRepository, getSessionOwnedGitHubPullRequestRefs, getSessionStatusMessage, getSessionWorkspaceKind, getUntitledSessionTitle, IGitHubInfo, isActiveSessionStatus, ISessionFolder, ISessionTurnFileChange, ISessionWorkspace, sessionFileChangesEqual, sessionTurnFileChangesEqual, SessionStatus, SessionWorkspaceKind, sessionWorkspaceEqual } from '../../common/session.js'; +import { getChatHarness, getSessionFolderGitHubRepository, getSessionOwnedGitHubPullRequestRefs, hasMixedChatHarnesses, IChat, ISession, getSessionStatusMessage, getSessionWorkspaceKind, getUntitledSessionTitle, IGitHubInfo, isActiveSessionStatus, ISessionFolder, ISessionTurnFileChange, ISessionWorkspace, sessionFileChangesEqual, sessionTurnFileChangesEqual, SessionStatus, SessionWorkspaceKind, sessionWorkspaceEqual } from '../../common/session.js'; suite('getSessionFolderGitHubRepository', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -363,3 +364,19 @@ suite('getUntitledSessionTitle', () => { assert.strictEqual(getUntitledSessionTitle(false), 'New Session'); }); }); + +suite('hasMixedChatHarnesses', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('compares every chat with the main chat\'s harness, reading an absent harness as the session\'s', () => { + const chat = (harness?: string) => upcastPartial({ resource: URI.parse(`test:///${harness ?? 'none'}`), ...(harness ? { harness } : {}) }); + const session = (main: IChat, chats: IChat[]) => upcastPartial({ harness: 'copilot', mainChat: constObservable(main), chats: constObservable([main, ...chats]) }); + assert.deepStrictEqual([ + hasMixedChatHarnesses(session(chat(), [chat(), chat('copilot')]), undefined), + hasMixedChatHarnesses(session(chat('copilot'), [chat('claude')]), undefined), + hasMixedChatHarnesses(session(chat('claude'), [chat('claude')]), undefined), + getChatHarness({ harness: 'copilot' }, {}), + ], [false, true, false, 'copilot']); + }); +}); diff --git a/src/vs/sessions/test/browser/chatCompositeBar.test.ts b/src/vs/sessions/test/browser/chatCompositeBar.test.ts index 706c1e2eb820c..f94ca08143bb0 100644 --- a/src/vs/sessions/test/browser/chatCompositeBar.test.ts +++ b/src/vs/sessions/test/browser/chatCompositeBar.test.ts @@ -13,7 +13,7 @@ import { timeout } from '../../../base/common/async.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { ResolvedKeybinding } from '../../../base/common/keybindings.js'; import { DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; -import { constObservable, IObservable, ISettableObservable, observableValue, transaction } from '../../../base/common/observable.js'; +import { constObservable, IObservable, IReader, ISettableObservable, observableValue, transaction } from '../../../base/common/observable.js'; import { isLinux } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../base/test/common/mock.js'; @@ -131,10 +131,11 @@ class TestEditorGroupsService extends mock() { } } -function createChat(id: string, title: string, status: SessionStatus = SessionStatus.Completed, options?: { readonly isArchived?: boolean; readonly isRead?: boolean; readonly canArchive?: boolean }): IChat { +function createChat(id: string, title: string, status: SessionStatus = SessionStatus.Completed, options?: { readonly isArchived?: boolean; readonly isRead?: boolean; readonly canArchive?: boolean; readonly harness?: string }): IChat { const resource = URI.parse(`test-chat://${id}`); return new class extends mock() { override readonly resource = resource; + override readonly harness = options?.harness; override readonly title: IObservable = constObservable(title); override readonly status: IObservable = constObservable(status); override readonly isArchived: IObservable = constObservable(options?.isArchived ?? false); @@ -148,6 +149,7 @@ function createSession(chats: readonly IChat[], activeChat: IChat, isQuickChat = const resource = URI.parse('test-session://session'); return new class extends mock() { override readonly sessionId = 'session'; + override readonly harness = 'copilot'; override readonly resource = resource; override readonly providerId = 'test'; override readonly title = observableValue(this, 'Parent Session'); @@ -182,7 +184,7 @@ interface IChatCompositeBarHarness { readonly showSessionActions: ISettableObservable; } -function createHarness(disposables: Pick, options?: { readonly isAgentHost?: boolean; readonly isQuickChat?: boolean; readonly standaloneChat?: boolean; readonly resizeObserverCtor?: typeof ResizeObserver; readonly mainIsRead?: boolean; readonly secondaryIsRead?: boolean; readonly secondaryArchived?: boolean; readonly secondaryCanArchive?: boolean; readonly terminalControls?: boolean; readonly sessionSupportsRename?: boolean; readonly accessibilityService?: IAccessibilityService }): IChatCompositeBarHarness { +function createHarness(disposables: Pick, options?: { readonly isAgentHost?: boolean; readonly isQuickChat?: boolean; readonly standaloneChat?: boolean; readonly resizeObserverCtor?: typeof ResizeObserver; readonly mainIsRead?: boolean; readonly secondaryIsRead?: boolean; readonly secondaryArchived?: boolean; readonly secondaryCanArchive?: boolean; readonly terminalControls?: boolean; readonly sessionSupportsRename?: boolean; readonly secondaryHarness?: string; readonly knownHarnesses?: IObservable; readonly accessibilityService?: IAccessibilityService }): IChatCompositeBarHarness { const store = disposables.add(new DisposableStore()); const instantiationService = workbenchInstantiationService(undefined, store); const commandService = new TestCommandService(); @@ -190,7 +192,7 @@ function createHarness(disposables: Pick, options?: { re const sessionsService = new TestSessionsService(); const editorGroupsService = store.add(new TestEditorGroupsService()); const mainChat = createChat('main', 'Main Chat', SessionStatus.Completed, { isRead: options?.mainIsRead }); - const secondaryChat = createChat('secondary', 'Secondary Chat', SessionStatus.Completed, { isArchived: options?.secondaryArchived, isRead: options?.secondaryIsRead, canArchive: options?.secondaryCanArchive }); + const secondaryChat = createChat('secondary', 'Secondary Chat', SessionStatus.Completed, { isArchived: options?.secondaryArchived, isRead: options?.secondaryIsRead, canArchive: options?.secondaryCanArchive, harness: options?.secondaryHarness }); const baseSession = createSession([mainChat, secondaryChat], mainChat, options?.isQuickChat, options?.sessionSupportsRename); const session = options?.standaloneChat ? { ...baseSession, scopedChatResource: secondaryChat.resource } : baseSession; const chats = observableValue('test.chats', [mainChat, secondaryChat]); @@ -200,6 +202,10 @@ function createHarness(disposables: Pick, options?: { re const showSessionActions = observableValue('test.showSessionActions', true); const provider = options?.isAgentHost ? new class extends mock() { override readonly id = LOCAL_AGENT_HOST_PROVIDER_ID; + override getHarnessPresentation(harness: string, reader?: IReader) { + const known = options?.knownHarnesses?.read(reader) ?? ['claude']; + return harness === 'claude' && known.includes(harness) ? { label: 'Claude', icon: Codicon.claude } : undefined; + } }() : undefined; instantiationService.stub(ICommandService, commandService); @@ -977,6 +983,32 @@ suite('Sessions - ChatCompositeBar', () => { }); }); + test('names a chat\'s harness that differs from the main chat\'s in its label, without an icon', () => { + const { tabs } = createHarness(disposables, { isAgentHost: true, secondaryHarness: 'claude' }); + + assert.deepStrictEqual(tabs.map(tab => ({ + harnessIcon: !!tab.querySelector('.codicon-claude, .codicon-agent'), + ariaLabel: tab.getAttribute('aria-label'), + })), [ + { harnessIcon: false, ariaLabel: 'Main Chat, State: Completed' }, + { harnessIcon: false, ariaLabel: 'Parent Session / Secondary Chat, State: Completed, uses harness Claude' }, + ]); + }); + + test('presents a restored chat\'s harness generically until its provider learns it', () => { + const knownHarnesses = observableValue('knownHarnesses', []); + const { tabs } = createHarness(disposables, { isAgentHost: true, secondaryHarness: 'claude', knownHarnesses }); + const snapshot = () => tabs[1].getAttribute('aria-label'); + + const cold = snapshot(); + knownHarnesses.set(['claude'], undefined); + + assert.deepStrictEqual({ cold, loaded: snapshot() }, { + cold: 'Parent Session / Secondary Chat, State: Completed, uses harness Other Harness', + loaded: 'Parent Session / Secondary Chat, State: Completed, uses harness Claude', + }); + }); + test('renders unread state from each chat', () => { const { tabs, activeChatResource } = createHarness(disposables, { mainIsRead: false, secondaryIsRead: false }); const snapshot = () => tabs.map(tab => ({ diff --git a/src/vs/sessions/test/browser/chatGroupsView.test.ts b/src/vs/sessions/test/browser/chatGroupsView.test.ts index a992ef75fd434..2c9f633abefde 100644 --- a/src/vs/sessions/test/browser/chatGroupsView.test.ts +++ b/src/vs/sessions/test/browser/chatGroupsView.test.ts @@ -3597,6 +3597,38 @@ suite('Sessions - ChatGroupsView', () => { assert.strictEqual(chatView.boundChats.length, bindCount); }); + test('keeps the composer, and so its typed draft and attachments, when its unsent chat is replaced by one of another harness', () => { + const { view, chatViewFactory } = createHarness(disposables); + const main = createChat('main'); + const draft = createChat('draft', SessionStatus.Untitled); + const replacement = createChat('replacement', SessionStatus.Untitled); + const session = new TestActiveSession([main, draft], [main, draft]); + session.activeChat.set(draft, undefined); + view.setSession(session, options); + const composer = chatViewFactory.views.at(-1); + + // The harness picker opens the replacement draft, then deletes the one it replaces. + transaction(tx => { + session.allChats.set([main, draft, replacement], tx); + session.visibleChatTabs.set([main, draft, replacement], tx); + session.activeChat.set(replacement, tx); + }); + transaction(tx => { + session.allChats.set([main, replacement], tx); + session.visibleChatTabs.set([main, replacement], tx); + }); + + assert.deepStrictEqual({ + kind: composer?.kind, + reused: chatViewFactory.views.at(-1) === composer && !composer?.isDisposed, + bound: composer?.boundChats.map(binding => binding.chat).at(-1), + }, { + kind: 'newChatInSession', + reused: true, + bound: 'test-chat://replacement', + }); + }); + test('recreates the chat view when the next session shows a composer', () => { const { view, chatViewFactory } = createHarness(disposables); view.setSession(new TestActiveSession([createChat('a')]), options); diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatAccessibilityProvider.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatAccessibilityProvider.ts index de89ba26c4ee2..d22fbc845a1b9 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatAccessibilityProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatAccessibilityProvider.ts @@ -20,7 +20,7 @@ import { IChatResponseViewModel, isRequestVM, isResponseVM } from '../../common/ import { isToolResultInputOutputDetails, isToolResultOutputDetails, toolContentToA11yString } from '../../common/tools/languageModelToolsService.js'; import { CancelChatActionId } from '../actions/chatExecuteActions.js'; import { AcceptToolConfirmationActionId } from '../actions/chatToolActions.js'; -import { ChatTreeItem } from '../chat.js'; +import { ChatTreeItem, IChatResponseIdentity } from '../chat.js'; import { getChatRequestText } from '../chatRequestText.js'; export const getToolConfirmationAlert = (accessor: ServicesAccessor, toolInvocation: IChatToolInvocation[]) => { @@ -95,6 +95,7 @@ export const getToolConfirmationAlert = (accessor: ServicesAccessor, toolInvocat export class ChatAccessibilityProvider implements IListAccessibilityProvider { constructor( + private readonly _getResponseIdentity: () => IChatResponseIdentity | undefined = () => undefined, @IAccessibleViewService private readonly _accessibleViewService: IAccessibleViewService, @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { @@ -119,7 +120,10 @@ export class ChatAccessibilityProvider implements IListAccessibilityProvider chat?.chain.get(member)?.value); + const connection = this._subRef.value?.connection; + return getAgentHostChatConfig(state, chat?.resource, member => chat?.chain.get(member)?.value, hostPublishesCompleteChatConfig(connection?.rootState?.value)); } /** Confirms an elevated approval selection before writing through its host binding. */ @@ -1465,7 +1466,7 @@ export class AgentHostChatInputPicker extends Disposable { } const state = this._subRef.value?.sub.value; - dispatchAgentHostConfigChange(connection, backendSession, this._subRef.value?.getChat()?.resource, state && !(state instanceof Error) ? state : undefined, partial); + dispatchAgentHostConfigChange(connection, backendSession, this._subRef.value?.getChat()?.resource, state && !(state instanceof Error) ? state : undefined, partial, state && !(state instanceof Error) ? this._readChatConfig(state) : undefined); if (this._isCurrentTarget(context)) { await this._provisional.refreshResolvedConfig( sessionResource, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts index 2cb537b84c1d8..bca1aac7454a9 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts @@ -165,7 +165,11 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements private readonly _connection: IAgentConnection, private readonly _connectionAuthority: string, private readonly _resolveBackendSession: (sessionResource: URI) => URI | undefined, - private readonly _resolveBackendChat: ((sessionResource: URI) => URI | undefined) | undefined, + /** + * Resolves the backend chat of a chat resource, from `sessionState` when given. Without a resolver, a request + * is looked up across every chat of the session. + */ + private readonly _resolveBackendChat: ((sessionResource: URI, sessionState?: SessionState) => URI | undefined) | undefined, private readonly _logService: ILogService, ) { super(); @@ -181,7 +185,7 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements const key = `${backendSession.toString()}\0${backendChat?.toString() ?? ''}\0${requestId}`; let obs = this._perRequest.get(key); if (!obs) { - obs = this._createDiffsObservable(backendSession, backendChat, requestId); + obs = this._createDiffsObservable(sessionResource, backendSession, backendChat, requestId); this._perRequest.set(key, obs); } return obs; @@ -198,7 +202,7 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements let obs = this._perRequestFileEdits.get(key); if (!obs) { const source = this._getSessionSource(backendSession); - const turnSource = this._createTurnSourceObservable(source, backendChat, requestId); + const turnSource = this._createTurnSourceObservable(sessionResource, source, backendChat, requestId); const turn = turnSource.map(source => source.turn); const workspaceRoots = this._createWorkspaceRootsObservable(source, turnSource); const fileEdits = this._createFileEditDiffsObservable(workspaceRoots, turn); @@ -208,9 +212,9 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements return obs; } - private _createDiffsObservable(backendSession: URI, backendChat: URI | undefined, requestId: string): IObservable { + private _createDiffsObservable(sessionResource: URI, backendSession: URI, backendChat: URI | undefined, requestId: string): IObservable { const source = this._getSessionSource(backendSession); - const turnSource = this._createTurnSourceObservable(source, backendChat, requestId); + const turnSource = this._createTurnSourceObservable(sessionResource, source, backendChat, requestId); const turn = turnSource.map(source => source.turn); const workspaceRoots = this._createWorkspaceRootsObservable(source, turnSource); const hasChatWorkingDirectoriesObs = turnSource.map(source => source.workingDirectories !== undefined); @@ -412,10 +416,22 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements }; } - private _createTurnSourceObservable(source: IObservable, backendChat: URI | undefined, requestId: string): IObservable { + private _createTurnSourceObservable(sessionResource: URI, source: IObservable, backendChat: URI | undefined, requestId: string): IObservable { + const resolveBackendChat = this._resolveBackendChat; + // A chat resource whose chat can't be resolved yet (e.g. its session state isn't loaded) resolves it from this + // provider's session state rather than looking the request up in every chat of the session, which would + // subscribe every chat in the catalog, archived ones included. + const exactChat = !!backendChat || !!resolveBackendChat; const chats = backendChat ? constObservable([{ uri: backendChat, source: this._getChatSource(backendChat) }]) - : mapObservableArrayCached(this, derived(reader => source.read(reader).chatUris.read(reader)), uri => { + : mapObservableArrayCached(this, derivedOpts({ equalsFn: arrayEquals }, reader => { + if (!resolveBackendChat) { + return source.read(reader).chatUris.read(reader); + } + const state = source.read(reader).state.read(reader); + const chat = state && !(state instanceof Error) ? resolveBackendChat(sessionResource, state) : undefined; + return chat ? [chat.toString()] : []; + }), uri => { const chatUri = URI.parse(uri); return { uri: chatUri, source: this._getChatSource(chatUri) }; }); @@ -432,7 +448,7 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements if (turn) { return { turn, chatUri: uri, changesets: chat.changesets.read(reader), workingDirectories }; } - if (backendChat) { + if (exactChat) { return fallback; } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSandboxNotification.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSandboxNotification.ts index b35ae819329c9..ef61b82379ccc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSandboxNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSandboxNotification.ts @@ -25,7 +25,7 @@ import { SessionConfigKey } from '../../../../../../platform/agentHost/common/se import { isSessionConfigWritable, validateChatPickerConfigWrite } from '../../../../../../platform/agentHost/common/sessionConfigProperties.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { SessionState, StateComponents, type SessionConfigState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import { dispatchAgentHostConfigChange, getAgentHostBackendChat, getAgentHostChatConfig, readAgentHostChatSandboxState } from './applyAgentHostSessionConfig.js'; +import { dispatchAgentHostConfigChange, getAgentHostBackendChat, getAgentHostChatConfig, hostPublishesCompleteChatConfig, readAgentHostChatSandboxState } from './applyAgentHostSessionConfig.js'; import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; @@ -166,14 +166,14 @@ export class AgentHostSandboxNotification extends Disposable { } validateChatPickerConfigWrite(config.schema, config.values, SessionConfigKey.SandboxEnabled, AgentSandboxEnabledValue.Off, false); // Turns off this chat's sandboxing, which is its own when the host lets a chat set it. - dispatchAgentHostConfigChange(resolution.connection, resolution.backendSession, chat, state, { [SessionConfigKey.SandboxEnabled]: AgentSandboxEnabledValue.Off }); + dispatchAgentHostConfigChange(resolution.connection, resolution.backendSession, chat, state, { [SessionConfigKey.SandboxEnabled]: AgentSandboxEnabledValue.Off }, config); } /** This notification's chat in its session's catalog, and that chat's effective config. */ private _readChat(state: SessionState): { readonly chat: string | undefined; readonly config: SessionConfigState | undefined } { const chat = getAgentHostBackendChat(this._sessionResource, state); const connection = this._connectionsService.resolveSessionResource(this._sessionResource)?.connection; - return { chat, config: getAgentHostChatConfig(state, chat, chat => connection?.getSubscriptionUnmanaged(StateComponents.Chat, URI.parse(chat))?.value) }; + return { chat, config: getAgentHostChatConfig(state, chat, chat => connection?.getSubscriptionUnmanaged(StateComponents.Chat, URI.parse(chat))?.value, hostPublishesCompleteChatConfig(connection?.rootState?.value)) }; } private _canDisableSandboxing(state: SessionState | Error | undefined): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index c2e04964fbe61..f05d76d230112 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -1577,9 +1577,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC config.connection, config.connectionAuthority, sessionResource => this._resolveSessionUri(sessionResource), - sessionResource => { + (sessionResource, sessionState) => { const backendSession = this._resolveSessionUri(sessionResource); - const chat = backendSession ? this._getChatURIOrDefault(sessionResource, backendSession) : undefined; + const chat = backendSession ? this._getChatURIOrDefault(sessionResource, backendSession, sessionState) : undefined; return chat !== undefined ? URI.parse(chat) : undefined; }, this._logService, @@ -2731,7 +2731,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return chatURI; } - private _getChatURIOrDefault(sessionResource: URI, session: URI): string | undefined { + /** Resolves the chat of `sessionResource`, from `sessionState` when given, else from the session's current state. */ + private _getChatURIOrDefault(sessionResource: URI, session: URI, sessionState?: SessionState): string | undefined { const mapped = this._chatURIsBySessionResource.get(sessionResource); if (mapped) { return mapped; @@ -2740,7 +2741,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (explicitChat) { return explicitChat; } - const state = this._getRawSessionState(session.toString()); + const state = sessionState ?? this._getRawSessionState(session.toString()); return sessionResource.fragment ? state && getSessionChatResource(state, sessionResource.fragment, state.defaultChat) : state?.defaultChat; } @@ -7388,6 +7389,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return languageModelIdentifier.substring(prefix.length); } } + // A peer chat backed by another agent of this host selects from that agent's models. + const targetSessionType = this._languageModelsService.lookupLanguageModel(languageModelIdentifier)?.targetChatSessionType; + if (targetSessionType && languageModelIdentifier.startsWith(targetSessionType + ':')) { + return languageModelIdentifier.substring(targetSessionType.length + 1); + } if (languageModelIdentifier.includes('/')) { this._logService.warn(`[AgentHost] Dropping foreign model identifier '${languageModelIdentifier}' for session type '${this._config.sessionType}'; falling back to default model.`); return undefined; @@ -7399,10 +7405,31 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (!rawModelId) { return undefined; } - const prefix = `${getChatSessionType(sessionResource)}:`; + const prefix = `${this._getChatModelVendor(sessionResource)}:`; return rawModelId.startsWith(prefix) ? rawModelId : `${prefix}${rawModelId}`; } + /** + * The language model vendor of the chat `sessionResource` names: its session type, or for a chat backed by another + * agent than its session's (e.g. one moved in from a session of that agent), the session type of that agent, whose + * models the chat selects from. A chat's turns name raw model ids of its own agent. + */ + private _getChatModelVendor(sessionResource: URI): string { + const sessionType = getChatSessionType(sessionResource); + if (!sessionType.endsWith(this._config.provider)) { + return sessionType; + } + // A chat is addressed by its fragment or, for a subagent chat, its query parameter: match the backend chat it + // resolves to against the session's catalog. + const backendSession = this._resolveSessionUri(sessionResource); + const state = this._getRawSessionState(backendSession.toString()); + const chat = state && this._getChatURIOrDefault(sessionResource, backendSession, state); + const chatProvider = chat !== undefined ? state?.chats.find(summary => summary.resource === chat)?.provider : undefined; + return state && chatProvider && chatProvider !== state.provider + ? `${sessionType.slice(0, sessionType.length - this._config.provider.length)}${chatProvider}` + : sessionType; + } + private _getTurnResponseDetails(sessionResource: URI, backendSession: URI, turn: Turn | undefined): string | undefined { const fallbackRawModelId = turn?.message?.model?.id ?? lastTurnModelSelection(this._getSessionState(backendSession.toString()))?.id; return turnToResponseDetails(turn, this._createTurnModelLookup(sessionResource, fallbackRawModelId)); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/applyAgentHostSessionConfig.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/applyAgentHostSessionConfig.ts index 87d74f024c842..f60c5ed6bc50d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/applyAgentHostSessionConfig.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/applyAgentHostSessionConfig.ts @@ -9,10 +9,11 @@ import { IWorkspaceContextService } from '../../../../../../platform/workspace/c import { IAgentHostService, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; -import { getSessionChatResource, StateComponents, type ChatState, type SessionConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { getSessionChatResource, StateComponents, type ChatConfigState, type ChatState, type RootState, type SessionConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { readChatSandboxState, readSessionSandboxState, type ISessionSandboxState } from '../../../../../../platform/agentHost/common/meta/agentSandboxStateMeta.js'; -import { getChatConfigChain, getChatConfigWriteTarget, getEffectiveChatConfig, mergeChatConfigChain, splitChatConfigPatch } from '../../../../../../platform/agentHost/common/sessionConfigProperties.js'; +import { getChatConfigChain, getChatConfigWriteTarget, getEffectiveChatConfig, isChatConfigProperty, mergeChatConfigChain, splitChatConfigPatch } from '../../../../../../platform/agentHost/common/sessionConfigProperties.js'; +import { ChatProvidersConfigKey } from '../../../../../../platform/agentHost/common/agentHostChatProviders.js'; import { isAutoApprovePolicyRestricted, normalizeSessionConfigValue } from '../../../common/agentHostConfigPolicy.js'; import { isUntitledChatSession } from '../../../common/model/chatUri.js'; import { getLocalAgentHostSessionProvider, resolveAgentHostChatSession, toAgentHostBackendSessionUri } from './agentHostSessionUri.js'; @@ -41,24 +42,63 @@ export function getAgentHostBackendChat(sessionResource: URI, state: SessionStat } /** - * The backend chats whose own config values apply to `backendChat`: the chat - * and, for a subagent, the chats that spawned it, as recorded in its session's - * chat catalog. + * The backend chats whose config applies to `backendChat`: the chat and, for a + * subagent (which has no config of its own), the chats that spawned it, as + * recorded in its session's chat catalog. */ export function getAgentHostChatConfigChain(state: SessionState, backendChat: string | undefined): readonly string[] { return backendChat !== undefined ? getChatConfigChain(state.chats, backendChat) : []; } /** - * A chat's effective config: its session's, with the own values of the chat - * and, for a subagent, of the chats that spawned it applied for the properties - * the session's schema lets a chat set. `readChat` supplies each chat's state. + * The agent provider backing `backendChat` when it differs from its session's + * (a chat on its session's agent reads `undefined`). */ -export function getAgentHostChatConfig(state: SessionState, backendChat: string | undefined, readChat: (chat: string) => ChatState | Error | undefined): SessionConfigState | undefined { - return state.config && getEffectiveChatConfig(state.config, mergeChatConfigChain(getAgentHostChatConfigChain(state, backendChat).map(chat => { - const chatState = readChat(chat); +export function getAgentHostChatOwnAgent(state: SessionState, backendChat: string | undefined): string | undefined { + const provider = backendChat !== undefined ? state.chats?.find(chat => chat.resource.toString() === backendChat)?.provider : undefined; + return provider !== undefined && provider !== state.provider ? provider : undefined; +} + +/** + * Whether the host publishes each chat's complete config values: only while its + * chats may use their own agents (root `multipleChatProviders.enabled`). Older + * hosts, and hosts with the setting off, publish the values a chat set itself, as + * an overlay on its session's. The host must also declare the key in its root + * schema: an older host keeps the value this client mirrors to it without + * supporting the feature. + */ +export function hostPublishesCompleteChatConfig(rootState: RootState | Error | undefined): boolean { + const config = !!rootState && !(rootState instanceof Error) ? rootState.config : undefined; + return !!config?.schema.properties[ChatProvidersConfigKey.Enabled] && config.values[ChatProvidersConfigKey.Enabled] === true; +} + +/** + * A chat's effective config (see {@link getEffectiveChatConfig}), read as its + * host publishes it: complete values, or an overlay on its session's (see + * {@link hostPublishesCompleteChatConfig}). + */ +export function getAgentHostEffectiveChatConfig(session: SessionConfigState, chat: ChatConfigState | undefined, completeValues: boolean): SessionConfigState { + return getEffectiveChatConfig(session, chat, completeValues); +} + +/** + * A chat's effective config (see {@link getAgentHostEffectiveChatConfig}), from + * the config of the chat or, for a subagent, of the chat that spawned it. + * `readChat` supplies each chat's state. + * + * A chat whose agent differs from its session's has its own schema in its + * state and never uses the session's config: until its state is known it has + * none. + */ +export function getAgentHostChatConfig(state: SessionState, backendChat: string | undefined, readChat: (chat: string) => ChatState | Error | undefined, completeValues: boolean): SessionConfigState | undefined { + const chat = mergeChatConfigChain(getAgentHostChatConfigChain(state, backendChat).map(member => { + const chatState = readChat(member); return chatState && !(chatState instanceof Error) ? chatState.config : undefined; - }))); + })); + if (getAgentHostChatOwnAgent(state, backendChat) !== undefined && !chat?.schema) { + return undefined; + } + return state.config && getAgentHostEffectiveChatConfig(state.config, chat, completeValues); } /** @@ -70,10 +110,23 @@ export function readAgentHostChatSandboxState(state: SessionState, backendChat: return getAgentHostChatConfigChain(state, backendChat).map(chat => readChatSandboxState(state, chat)).find(chatState => chatState !== undefined) ?? readSessionSandboxState(state); } +/** + * Whether the host behind `rootState` applies a session config write to its session's default chat, replacing the + * value the default chat set itself: only while its chats may use their own agents. Otherwise, as on older hosts, the + * default chat's own value overrides the session's, so a client writes the default chat itself. + */ +export function hostAppliesSessionConfigToDefaultChat(rootState: RootState | Error | undefined): boolean { + return hostPublishesCompleteChatConfig(rootState); +} + /** * Sends a config change for a chat: the properties the session's schema lets a * chat set go to the chat (for a subagent, to its nearest spawning chat that is * not one, as the host does), everything else to its session, in that order. + * The session's config is its default chat's: on a host that applies session + * writes to its default chat (see {@link hostAppliesSessionConfigToDefaultChat}), + * a property the session may set too is written to the session for that chat; + * an older host gets it on the default chat, as before. * * A patch with both kinds becomes two actions, which the host applies or * rejects independently; each is reconciled on its own echo. That is safe @@ -81,9 +134,26 @@ export function readAgentHostChatSandboxState(state: SessionState, backendChat: * dependency between them (validation of either reads only the effective * values already applied), and pickers send one property at a time. */ -export function dispatchAgentHostConfigChange(connection: Pick, backendSession: URI, backendChat: string | undefined, state: SessionState | undefined, partial: Record): void { +export function dispatchAgentHostConfigChange(connection: Pick, backendSession: URI, backendChat: string | undefined, state: SessionState | undefined, partial: Record, chatConfig?: SessionConfigState): void { const target = backendChat !== undefined ? getChatConfigWriteTarget(state?.chats, backendChat) : undefined; + if (state && getAgentHostChatOwnAgent(state, backendChat) !== undefined) { + // A chat on another agent than its session's sets only its agent's chat-settable properties; nothing reaches the session. + const own = Object.fromEntries(Object.entries(partial).filter(([key]) => isChatConfigProperty(chatConfig?.schema, key))); + if (target !== undefined && Object.keys(own).length > 0) { + connection.dispatch(target, { type: ActionType.ChatConfigChanged, config: own }); + } + return; + } const { chat, session } = target !== undefined ? splitChatConfigPatch(state?.config?.schema, partial) : { chat: {}, session: partial }; + if (target !== undefined && target === state?.defaultChat && hostAppliesSessionConfigToDefaultChat(connection.rootState.value)) { + // What the session may set too goes to the session, which the host applies to its default chat. + for (const key of Object.keys(chat)) { + if (state.config?.schema.properties[key]?.sessionMutable) { + session[key] = chat[key]; + delete chat[key]; + } + } + } if (target !== undefined && Object.keys(chat).length > 0) { connection.dispatch(target, { type: ActionType.ChatConfigChanged, config: chat }); } @@ -104,7 +174,9 @@ export function getAgentHostSessionConfig( return provisionalService.getResolvedConfig(sessionResource); } const chat = getAgentHostBackendChat(sessionResource, snapshot); - return getAgentHostChatConfig(snapshot, chat, chat => resolution.connection.getSubscriptionUnmanaged(StateComponents.Chat, URI.parse(chat))?.value) ?? provisionalService.getResolvedConfig(sessionResource); + const config = getAgentHostChatConfig(snapshot, chat, chat => resolution.connection.getSubscriptionUnmanaged(StateComponents.Chat, URI.parse(chat))?.value, hostPublishesCompleteChatConfig(resolution.connection.rootState?.value)); + // Never the session's (or its provisional) config for a chat on another agent. + return getAgentHostChatOwnAgent(snapshot, chat) !== undefined ? config : config ?? provisionalService.getResolvedConfig(sessionResource); } /** @@ -137,7 +209,9 @@ export async function applyAgentHostSessionConfigChange( const subscribed = backendSession ? agentHostService.getSubscriptionUnmanaged(StateComponents.Session, backendSession)?.value : undefined; const state = subscribed && !(subscribed instanceof Error) ? subscribed : undefined; const backendChat = state && getAgentHostBackendChat(sessionResource, state); - const currentConfig = (state && getAgentHostChatConfig(state, backendChat, chat => agentHostService.getSubscriptionUnmanaged(StateComponents.Chat, URI.parse(chat))?.value)) ?? provisionalService.getResolvedConfig(sessionResource); + const chatOnOwnAgent = !!state && getAgentHostChatOwnAgent(state, backendChat) !== undefined; + const chatConfig = state && getAgentHostChatConfig(state, backendChat, chat => agentHostService.getSubscriptionUnmanaged(StateComponents.Chat, URI.parse(chat))?.value, hostPublishesCompleteChatConfig(agentHostService.rootState?.value)); + const currentConfig = chatOnOwnAgent ? chatConfig : chatConfig ?? provisionalService.getResolvedConfig(sessionResource); const policyRestricted = isAutoApprovePolicyRestricted(configurationService, currentConfig?.schema); const partial = Object.fromEntries(Object.entries(config).map(([key, value]) => [key, normalizeSessionConfigValue(key, value, policyRestricted)])); @@ -152,7 +226,7 @@ export async function applyAgentHostSessionConfigChange( if (!backendSession) { return false; } - dispatchAgentHostConfigChange(agentHostService, backendSession, backendChat, state, partial); + dispatchAgentHostConfigChange(agentHostService, backendSession, backendChat, state, partial, currentConfig); const nextConfig = { ...(state ? currentConfig?.values : undefined), ...partial }; void provisionalService.refreshResolvedConfig(sessionResource, provider, workingDirectory, nextConfig); return true; diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 97f9eee09d3e8..fd857d0defcb6 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -8,6 +8,7 @@ import { Event } from '../../../../base/common/event.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable } from '../../../../base/common/observable.js'; import { isMobile, isWeb } from '../../../../base/common/platform.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { IRange } from '../../../../editor/common/core/range.js'; @@ -256,6 +257,18 @@ export function unwrapChatContextMenuActionContext(context: unknown): unknown { return isChatContextMenuActionContext(context) ? context.item : context; } +/** Who answers a chat, when that is not the agent its session type serves. */ +export interface IChatResponseIdentity { + readonly label: string; + readonly icon: ThemeIcon; +} + +/** An agent answering a chat in place of the one its session type serves. */ +export interface IChatRespondent extends IChatResponseIdentity { + /** The session type whose models the chat selects from; `undefined` while not known, when none are offered. */ + readonly modelSessionType: string | undefined; +} + /** In-progress work for the transcript status, independent of input-pill filters. */ export interface IChatBackgroundActivityPillSections { readonly subagents?: IObservable; @@ -286,6 +299,8 @@ export interface IChatListItemRendererOptions { * so command/tool output pasted by the model wraps instead of overflowing). */ readonly codeBlockRenderOptions?: ICodeBlockRenderOptions; + /** Presents responses as coming from this identity instead of the responding agent's own. */ + readonly responseIdentity?: IChatResponseIdentity; } export interface IChatWidgetViewOptions { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index adee1aebc6c5f..573f501952915 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -73,7 +73,7 @@ import { ClickAnimation } from '../../../../../base/browser/ui/animations/animat import { ForkConversationActionId } from '../actions/chatForkActions.js'; import { MarkHelpfulActionId } from '../actions/chatTitleActions.js'; import { focusChatModelFeedbackSurveyAction } from '../actions/chatModelFeedbackSurveyActions.js'; -import { ChatTreeItem, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidget, IChatWidgetService } from '../chat.js'; +import { ChatTreeItem, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatResponseIdentity, IChatWidget, IChatWidgetService } from '../chat.js'; import { getCompactCodicon } from '../chatIcons.js'; import { stripSystemNotificationWrapper } from '../chatRequestText.js'; import { ChatModelFeedbackSurveyWidget } from '../feedbackSurvey/chatModelFeedbackSurveyWidget.js'; @@ -1017,6 +1017,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this._renderer.responseIdentity), keyboardNavigationLabelProvider: { getKeyboardNavigationLabel: (e: ChatTreeItem) => isRequestVM(e) ? e.message : isResponseVM(e) ? e.response.value : '' diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index c50acc270b19e..a9d6a6ba9fcef 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -91,7 +91,7 @@ import { ILanguageModelToolsService, isToolSet } from '../../common/tools/langua import { IHandOff, PromptHeader } from '../../common/promptSyntax/promptFileParser.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { GENERATE_AGENT_INSTRUCTIONS_COMMAND_ID, handleModeSwitch } from '../actions/chatActions.js'; -import { ChatTreeItem, ChatViewPaneTarget, IChatAcceptInputOptions, IChatAccessibilityService, IChatBackgroundActivityPillSections, IChatCodeBlockInfo, IChatFileTreeInfo, IChatFindController, IChatListItemRendererOptions, IChatPasteTargetService, IChatWidget, IChatWidgetService, IChatWidgetViewContext, IChatWidgetViewModelChangeEvent, IChatWidgetViewOptions, IChatWidgetViewState, isIChatResourceViewContext, isIChatViewViewContext } from '../chat.js'; +import { ChatTreeItem, ChatViewPaneTarget, IChatAcceptInputOptions, IChatAccessibilityService, IChatBackgroundActivityPillSections, IChatCodeBlockInfo, IChatFileTreeInfo, IChatFindController, IChatListItemRendererOptions, IChatPasteTargetService, IChatRespondent, IChatWidget, IChatWidgetService, IChatWidgetViewContext, IChatWidgetViewModelChangeEvent, IChatWidgetViewOptions, IChatWidgetViewState, isIChatResourceViewContext, isIChatViewViewContext } from '../chat.js'; import { ChatAttachmentModel } from '../attachments/chatAttachmentModel.js'; import { IChatAttachmentResolveService } from '../attachments/chatAttachmentResolveService.js'; import { ChatDynamicVariableModel } from '../attachments/chatDynamicVariables.js'; @@ -490,6 +490,9 @@ export class ChatWidget extends Disposable implements IChatWidget { private _pendingFirstRenderSessionResource: URI | undefined; // Coding agent locking state + private _respondent: { readonly resource: URI; readonly respondent: IChatRespondent } | undefined; + private _appliedInputPlaceholder = false; + private _hasResponseIdentity = false; private _lockedAgent?: { id: string; name: string; @@ -3080,6 +3083,10 @@ export class ChatWidget extends Disposable implements IChatWidget { } this.clearGettingStartedTip(); this.viewModel = undefined; + // No chat is bound, so nothing is revalidated: the outgoing chat keeps its own model. + this._respondent = undefined; + this.inputPart.setModelSessionTypeOverride(undefined, undefined, { revalidate: false }); + this._setResponseIdentity(undefined); this.updateWorkingProgressBorder(); this.onDidChangeItems(); this._hasPendingRequestsContextKey.set(false); @@ -3096,6 +3103,15 @@ export class ChatWidget extends Disposable implements IChatWidget { } logChangesToStateModel(model.inputModel, `ChatWidget.setModel new ${model.sessionResource.toString()}, old ${this.viewModel?.sessionResource.toString()}`, model.inputModel.state.get(), currentInputModel, this.logService); + // A respondent belongs to the chat it was set for: one set ahead for the incoming chat applies, + // any other is dropped (the host sets one again for the next chat). + const respondent = this._respondent && isEqual(this._respondent.resource, model.sessionResource) ? this._respondent.respondent : undefined; + if (!respondent && this._respondent) { + // The outgoing chat is still bound here: dropping its pool must not revalidate it against another. + this._respondent = undefined; + this.inputPart.setModelSessionTypeOverride(undefined, undefined, { revalidate: false }); + } + this._setResponseIdentity(respondent); if (this.viewModel?.editing) { this.finishedEditing(); @@ -3128,16 +3144,7 @@ export class ChatWidget extends Disposable implements IChatWidget { // outgoing model was torn down cannot consume it. this._pendingFirstRenderSessionResource = model.sessionResource; - if (this._lockedAgent) { - let placeholder = this.chatSessionsService.getChatSessionContribution(this._lockedAgent.id)?.inputPlaceholder; - if (!placeholder) { - placeholder = localize('chat.input.placeholder.lockedToAgent', "Chat with {0}", this._lockedAgent.displayName || this._lockedAgent.name); - } - this.viewModel.setInputPlaceholder(placeholder); - this._updateInputPlaceholder(placeholder); - } else if (this.viewModel.inputPlaceholder) { - this._updateInputPlaceholder(this.viewModel.inputPlaceholder); - } + this._applyInputPlaceholder(); this.viewModelDisposables.add(Event.runAndSubscribe(Event.accumulate(this.viewModel.onDidChange), (events => { if (!this.viewModel || this._store.isDisposed) { @@ -3300,6 +3307,75 @@ export class ChatWidget extends Disposable implements IChatWidget { this.viewModel?.setInputPlaceholder(placeholder); } + /** + * Presents the bound chat as answered by `respondent`, an agent other than the one its session + * type serves, or restores that agent's presentation. The input placeholder, response identity + * and selectable models follow the respondent; requests still go to the session type's agent. + */ + setRespondent(respondent: IChatRespondent | undefined, resource: URI | undefined = this.viewModel?.sessionResource): void { + const next = respondent && resource ? { resource, respondent } : undefined; + const previous = this._respondent; + if (!next && previous && resource && !isEqual(previous.resource, resource)) { + // Another chat having none says nothing about this one's; binding that chat drops it. + return; + } + if (!!previous === !!next && isEqual(previous?.resource, next?.resource) && previous?.respondent.label === next?.respondent.label + && previous?.respondent.modelSessionType === next?.respondent.modelSessionType && previous?.respondent.icon.id === next?.respondent.icon.id) { + return; + } + this._respondent = next; + const bound = this.viewModel?.sessionResource; + // Set ahead of binding (`resource` not yet bound), the pool applies from the chat's first model validation; + // only a change for the bound chat revalidates it, so replacing the outgoing chat's pool never does. + const forBound = !!bound && isEqual(next?.resource ?? previous?.resource, bound); + this.inputPart.setModelSessionTypeOverride(next ? { sessionType: next.respondent.modelSessionType } : undefined, next?.resource ?? previous?.resource, { revalidate: forBound }); + if (!isEqual(next?.resource, bound) && !isEqual(previous?.resource, bound)) { + return; + } + const boundRespondent = next && isEqual(next.resource, bound) ? next.respondent : undefined; + this._setResponseIdentity(boundRespondent); + if (previous && !boundRespondent) { + this.viewModel?.resetInputPlaceholder(); + } + this._applyInputPlaceholder(); + this.rerenderListIfConnected(); + } + + private _setResponseIdentity(respondent: IChatRespondent | undefined): void { + // Only a change reaches the list, so chats that never have a respondent never touch its options. + if (!respondent && !this._hasResponseIdentity) { + return; + } + this._hasResponseIdentity = !!respondent; + this.listWidget.updateRendererOptions({ responseIdentity: respondent ? { label: respondent.label, icon: respondent.icon } : undefined }); + } + + private _applyInputPlaceholder(): void { + if (!this.viewModel) { + return; + } + let placeholder: string | undefined; + if (this._respondent && isEqual(this._respondent.resource, this.viewModel.sessionResource)) { + placeholder = localize('chat.input.placeholder.respondent', "Chat with {0}", this._respondent.respondent.label); + } else if (this._lockedAgent) { + placeholder = this.chatSessionsService.getChatSessionContribution(this._lockedAgent.id)?.inputPlaceholder + || localize('chat.input.placeholder.lockedToAgent', "Chat with {0}", this._lockedAgent.displayName || this._lockedAgent.name); + } + // Routed through `_updateInputPlaceholder`, so a host-supplied placeholder still wins. + if (placeholder) { + this.viewModel.setInputPlaceholder(placeholder); + this._updateInputPlaceholder(placeholder); + this._appliedInputPlaceholder = true; + } else if (this.viewModel.inputPlaceholder) { + this._updateInputPlaceholder(this.viewModel.inputPlaceholder); + this._appliedInputPlaceholder = true; + } else if (this._appliedInputPlaceholder) { + // Clear only a placeholder this widget set, so a widget that never had one stays untouched. + this._updateInputPlaceholder(undefined); + this._appliedInputPlaceholder = false; + } + } + resetInputPlaceholder(): void { this.viewModel?.resetInputPlaceholder(); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 9c6dbc7306993..416bf4121fc7f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -31,7 +31,7 @@ import { ResourceSet } from '../../../../../../base/common/map.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { mixin } from '../../../../../../base/common/objects.js'; -import { autorun, constObservable, derived, derivedOpts, IObservable, ISettableObservable, ITransaction, observableFromEvent, observableSignalFromEvent, observableValue, transaction } from '../../../../../../base/common/observable.js'; +import { autorun, constObservable, derived, derivedOpts, IObservable, IReader, ISettableObservable, ITransaction, observableFromEvent, observableSignalFromEvent, observableValue, transaction } from '../../../../../../base/common/observable.js'; import { isMacintosh, isWeb, OS } from '../../../../../../base/common/platform.js'; import { isTerminalSandboxSupported } from '../../../../../../platform/sandbox/common/settings.js'; import { IRemoteAgentService } from '../../../../../services/remote/common/remoteAgentService.js'; @@ -1046,7 +1046,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private set _pendingDelegationTarget(value: AgentSessionTarget | undefined) { this._pendingDelegationTargetObservable.set(value, undefined); } private readonly _currentSessionTypeObservable = observableValue(this, undefined); - private get _currentSessionType(): string | undefined { return this._currentSessionTypeObservable.get(); } + private get _currentSessionType(): string | undefined { return this._readModelSessionTypeOverride() ?? this._currentSessionTypeObservable.get(); } + /** + * The session type whose models the bound chat selects from when it differs from its resource's + * (a chat answered by another agent of its session). Scoped to that chat so a rebound input drops it. + */ + private readonly _modelSessionTypeOverride = observableValue<{ readonly resource: URI; readonly sessionType: string | undefined } | undefined>(this, undefined); private set _currentSessionType(value: string | undefined) { this._currentSessionTypeObservable.set(value, undefined); } /** The session type (harness) whose models this input selects from. */ @@ -1073,6 +1078,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private readonly _notificationModelTargetChatSessionType = derived(this, reader => this._pendingDelegationTargetObservable.read(reader) + ?? this._readModelSessionTypeOverride(reader) ?? this._currentSessionTypeObservable.read(reader) ?? this.getCurrentSessionType() ); @@ -2251,6 +2257,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge * additionally requires an empty model list. */ private _showAutoModel(): boolean { + if (this._readBoundModelPoolOverride() && this._readModelSessionTypeOverride() === undefined) { + return false; + } const sessionType = this.getCurrentSessionType(); return !sessionType || this.chatSessionsService.supportsAutoModelForSessionType(sessionType); } @@ -2266,9 +2275,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } private getModelsForSessionType(sessionType: string | undefined): ILanguageModelChatMetadataAndIdentifier[] { + // A chat answered by another agent whose pool is not known yet offers nothing rather than its session type's models. + const poolOverride = this._readBoundModelPoolOverride(); + if (poolOverride && poolOverride.sessionType === undefined) { + return []; + } const allModels = this.getAllMergedModels(); // Session owns a pool but no targeted models registered yet: return empty so callers don't treat general-pool models as valid. + // Agent-host session types all require custom models, which is what keeps a chat answered by another agent of the host + // (see setModelSessionTypeOverride) on an empty pool, rather than general or another agent's models, while its models load. if (sessionType && this.chatSessionsService.requiresCustomModelsForSessionType(sessionType) && !hasModelsTargetingSession(allModels, sessionType)) { @@ -2295,11 +2311,46 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private getCurrentSessionType(): string | undefined { const sessionResource = this._widget?.viewModel?.model.sessionResource; if (sessionResource) { - return getChatSessionType(sessionResource); + return this._readModelSessionTypeOverride() ?? getChatSessionType(sessionResource); } return this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.(); } + private _boundModelResource(): URI | undefined { + return this._inputModelSessionResource ?? this._widget?.viewModel?.model.sessionResource; + } + + private _readBoundModelPoolOverride(reader?: IReader): { readonly sessionType: string | undefined } | undefined { + const override = this._modelSessionTypeOverride.read(reader); + return override && isEqual(override.resource, this._boundModelResource()) ? override : undefined; + } + + private _readModelSessionTypeOverride(reader?: IReader): string | undefined { + return this._readBoundModelPoolOverride(reader)?.sessionType; + } + + /** + * Selects models for the chat at `resource` (the bound one by default) from another session type's + * pool than its resource's, for a chat answered by another agent than its session type serves. + * `{ sessionType: undefined }` means that agent's pool is not known yet, so nothing is selectable; + * `undefined` restores the resource's own pool. Another pool's models are never offered. + * Set for a chat before it binds, the pool applies from its first model validation. + */ + setModelSessionTypeOverride(override: { readonly sessionType: string | undefined } | undefined, resource: URI | undefined = this._boundModelResource(), options?: { readonly revalidate?: boolean }): void { + const next = override && resource ? { resource, sessionType: override.sessionType } : undefined; + const current = this._modelSessionTypeOverride.get(); + if (!!current === !!next && current?.sessionType === next?.sessionType && isEqual(current?.resource, next?.resource)) { + return; + } + this._modelSessionTypeOverride.set(next, undefined); + // Only the chat bound to the view revalidates; an input still holding an outgoing chat's model, or none, keeps it. + const viewResource = this._widget?.viewModel?.model.sessionResource; + const affectsBound = !!viewResource && (isEqual(current?.resource, viewResource) || isEqual(next?.resource, viewResource)); + if (options?.revalidate !== false && affectsBound) { + this._modelSelectionController.revalidateForSessionType(() => this.initSelectedModel()); + } + } + /** * Reset the current mode when it is not valid for the current session type. */ diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityProvider.test.ts index 79e2d416cc1c3..9f0940595ec87 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityProvider.test.ts @@ -13,6 +13,8 @@ import { IAccessibleViewService } from '../../../../../../platform/accessibility import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ChatAccessibilityProvider } from '../../../browser/accessibility/chatAccessibilityProvider.js'; import { Response } from '../../../common/model/chatModel.js'; +import { IChatResponseIdentity } from '../../../browser/chat.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { IChatResponseViewModel } from '../../../common/model/chatViewModel.js'; import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; @@ -28,12 +30,22 @@ suite('ChatAccessibilityProvider', () => { const inline = '`value` and **emphasis** with [a link](https://example.com)'; let accessibleViewHint: string; let provider: ChatAccessibilityProvider; + let responseIdentity: IChatResponseIdentity | undefined; setup(() => { accessibleViewHint = ''; + responseIdentity = undefined; const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IAccessibleViewService, { getOpenAriaHint: () => accessibleViewHint }); - provider = instantiationService.createInstance(ChatAccessibilityProvider); + provider = instantiationService.createInstance(ChatAccessibilityProvider, () => responseIdentity); + }); + + test('names the harness answering a chat answered by another agent than its session type serves', () => { + const element = createViewModel(store.add(new Response(new MarkdownString('Hello')))); + const ownAgent = provider.getAriaLabel(element); + responseIdentity = { label: 'Claude', icon: Codicon.claude }; + + assert.deepStrictEqual([ownAgent, provider.getAriaLabel(element)], [' Hello', 'Claude: Hello']); }); teardown(() => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts index 94acd9f40bfd7..38cb3c73ab5cf 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts @@ -144,7 +144,7 @@ suite('AgentHostResponseFileChangesProvider', () => { function createProvider( conn: IAgentConnection, resolveBackendSession: () => URI | undefined = () => backendSession, - resolveBackendChat?: (sessionResource: URI) => URI | undefined, + resolveBackendChat?: (sessionResource: URI, sessionState?: SessionState) => URI | undefined, ): AgentHostResponseFileChangesProvider { return new AgentHostResponseFileChangesProvider(conn, authority, resolveBackendSession, resolveBackendChat, new NullLogService()); } @@ -233,6 +233,38 @@ suite('AgentHostResponseFileChangesProvider', () => { }); } + test('a chat resource whose chat is unresolved when asked subscribes only its own chat, not every chat in the catalog', () => { + const peers = Array.from({ length: 6 }, (_, index) => URI.parse(buildChatUri(backendSession.toString(), `peer-${index}`)).toString()); + const subscribedChats = (resolveAtRequest: boolean) => { + const conn = new FakeAgentConnection(); + conn.setState(backendSession.toString(), { + defaultChat: defaultChatUri.toString(), + // Unopened peers, half of them archived. + chats: peers.map((resource, index) => createChatState({ + resource, + title: `Peer ${index}`, + status: index % 2 ? SessionStatus.Idle | SessionStatus.IsArchived : SessionStatus.Idle, + modifiedAt: new Date(0).toISOString(), + })), + } as unknown as SessionState); + // As after a reconnect, the session handler can resolve the chat only once its session state loads; until + // then the provider resolves it from its own session state. + const provider = store.add(createProvider(conn, () => backendSession, (_resource, state) => resolveAtRequest || state ? defaultChatUri : undefined)); + const observed = observe(provider, store.add(new DisposableStore())); + conn.setState(defaultChatUri.toString(), { ...chatStateWithTurnSupport(), turns: [{ ...completedTurn('t1'), responseParts: [] }] }); + conn.setState(turnChangesetUri('t1'), { status: ChangesetStatus.Ready, files: [{ id: 'edited', edit: fileEdit('/repo/a.ts', 3) }] } satisfies ChangesetState); + return { + chats: [defaultChatUri.toString(), ...peers].filter(chat => conn.getSubscriptionCount(chat) > 0), + files: observed.latest().map(diff => fromAgentHostUri(diff.modifiedURI).path), + }; + }; + + assert.deepStrictEqual({ unresolved: subscribedChats(false), resolved: subscribedChats(true) }, { + unresolved: { chats: [defaultChatUri.toString()], files: ['/repo/a.ts'] }, + resolved: { chats: [defaultChatUri.toString()], files: ['/repo/a.ts'] }, + }); + }); + function fileEdit(path: string, added = 1, onRead?: () => void): ISessionFileDiff { const uri = URI.file(path).toString(); return { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index f9c44bbd5db1d..c7fb132f7c4d3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -2491,6 +2491,14 @@ suite('AgentHostChatContribution', () => { await finish(); assert.deepStrictEqual(sent.action.message.model, { id: 'auto' }); }); + test('a model of another agent on the same host is sent by its raw id', async () => { + const models = new Map([['agent-host-otheragent:other-model', upcastPartial({ id: 'other-model', name: 'Other', targetChatSessionType: 'agent-host-otheragent' })]]); + const { startRequest } = await openWireSession({ languageModels: models }); + const { sent, finish } = await startRequest({ userSelectedModelId: 'agent-host-otheragent:other-model' }); + assert.ok(sent.action.type === ActionType.ChatTurnStarted); + await finish(); + assert.deepStrictEqual(sent.action.message.model, { id: 'other-model' }); + }); }); suite('response resource links', () => { @@ -2647,6 +2655,48 @@ suite('AgentHostChatContribution', () => { }); }); + test('a chat on another agent than its session\'s, such as one moved in from a session of that agent, restores its models from that agent', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const backend = AgentSession.uri('copilot', 'moved-in'); + const defaultChat = buildDefaultChatUri(backend); + const ownAgentChat = buildChatUri(backend.toString(), 'own-agent'); + const sameAgentChat = buildChatUri(backend.toString(), 'same-agent'); + const summary: SessionSummary = { + resource: backend.toString(), provider: 'copilot', title: 'Moved', status: SessionStatus.Idle, + createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), + }; + const ownAgent = { ...createDefaultChatSummary(summary, ownAgentChat), provider: 'claude' }; + const sameAgent = createDefaultChatSummary(summary, sameAgentChat); + agentHostService.sessionStates.set(backend.toString(), { + ...createSessionState(summary), lifecycle: SessionLifecycle.Ready, defaultChat, + chats: [createDefaultChatSummary(summary, defaultChat), ownAgent, sameAgent], + }); + const withTurn = (chat: ReturnType, model: string): ChatState => ({ + ...createChatState(chat), + turns: [{ id: `${model}-turn`, state: TurnState.Complete, message: { text: 'Before the move', origin: { kind: MessageKind.User }, model: { id: model } }, responseParts: [], usage: undefined }], + }); + agentHostService.chatStates.set(ownAgentChat, withTurn(ownAgent, 'claude-haiku')); + agentHostService.chatStates.set(sameAgentChat, withTurn(sameAgent, 'gpt')); + const resource = URI.from({ scheme: 'agent-host-copilot', path: backend.path }); + const historyModels = async (chatResource: URI) => { + const session = disposables.add(await sessionHandler.provideChatSessionContent(chatResource, CancellationToken.None)); + return session.history.flatMap(item => item.type === 'request' ? [item.modelId] : []); + }; + const byFragment = (chat: string) => resource.with({ fragment: getAgentHostChatId(chat) }); + + assert.deepStrictEqual({ + ownAgent: await historyModels(byFragment(ownAgentChat)), + sameAgent: await historyModels(byFragment(sameAgentChat)), + // A chat can also be addressed only by its backend chat, without a fragment. + ownAgentByQuery: await historyModels(resource.with({ query: new URLSearchParams({ [CHAT_SUBAGENT_RESOURCE_QUERY_PARAM]: ownAgentChat }).toString() })), + }, { + // The chat's model selection stays its own agent's: it is sent as is, not dropped as another session type's. + ownAgent: ['agent-host-claude:claude-haiku'], + sameAgent: ['agent-host-copilot:gpt'], + ownAgentByQuery: ['agent-host-claude:claude-haiku'], + }); + }); + test('resolves tool-result image bytes only in the owning opaque chat', async () => { const { sessionHandler, agentHostService } = createContribution(disposables); const backend = AgentSession.uri('copilot', 'tool-images'); @@ -16816,6 +16866,14 @@ suite('AgentHostChatContribution', () => { }; } + test('every agent\'s session type owns its model pool, so a chat answered by it never falls back to another\'s models', async () => { + const { instantiationService, subscription, chatSessionContributions } = createRegistrationServices(); + disposables.add(instantiationService.createInstance(AgentHostContribution)); + subscription.handleSnapshot(rootState, 0); + await timeout(0); + assert.deepStrictEqual(chatSessionContributions.map(contribution => [contribution.type, contribution.requiresCustomModels]), [['agent-host-copilot', true], ['agent-host-claude', true]]); + }); + for (const kind of ['Error', 'undefined'] as const) { test(`configuration changes preserve executable registrations with an ${kind} root snapshot`, async () => { const { instantiationService, agentHostService, subscription, makeRootUnavailable, changePreference, chatSessionContributions, contentProviders, modelProviders } = createRegistrationServices(); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts index aae7ad63f5845..48cf2f81a4ed6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts @@ -233,6 +233,7 @@ suite('AgentHostChatInputPicker - combined mode and permissions', () => { override error(message: string | Error): void { logErrors.push(message); } }())); const connection = instantiationService.stub(IAgentHostService, { + initializeResult: constObservable(undefined), onAgentHostStart: onAgentHostStart.event, onAgentHostExit: Event.None, getNetworkDiagnosticsInfo: () => { @@ -902,6 +903,7 @@ suite('AgentHostChatInputPicker - combined mode and permissions', () => { function createRemoteConnection(getHostInfo: () => Promise) { return new class extends mock() { + override readonly initializeResult = constObservable(undefined); diagnosticsRequests = 0; readonly writes: { channel: string; action: Parameters[1] }[] = []; override getNetworkDiagnosticsInfo(): Promise { @@ -2004,6 +2006,7 @@ suite('AgentHostChatInputPicker - sandbox toggle', () => { override viewModel: IChatViewModel | undefined; }(); const connection = new class extends mock() { + override readonly initializeResult = constObservable(undefined); override readonly onAgentHostStart = Event.None; override readonly onAgentHostExit = Event.None; override async getNetworkDiagnosticsInfo(): Promise { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostGenericConfigChips.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostGenericConfigChips.test.ts index bac0454dea6e1..324101966e7d7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostGenericConfigChips.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostGenericConfigChips.test.ts @@ -75,6 +75,7 @@ suite('AgentHostGenericConfigChips', () => { const restarted = disposables.add(new Emitter()); const exited = disposables.add(new Emitter()); const agentHost = new class extends mock() { + override readonly initializeResult = constObservable(undefined); override readonly onAgentHostStart = restarted.event; override readonly onAgentHostExit = exited.event; override readonly onDidNotification = Event.None; @@ -282,6 +283,7 @@ suite('AgentHostGenericConfigChips', () => { }({ 'chat.agentHost.claudeAgent.enabled': false, 'chat.agentHost.codexAgent.enabled': false }); disposables.add(configuration.onDidChangeConfigurationEmitter); const agentHost = new class extends mock() { + override readonly initializeResult = constObservable(undefined); override readonly onAgentHostStart = Event.None; override readonly onAgentHostExit = Event.None; override readonly onDidNotification = Event.None; @@ -343,6 +345,7 @@ suite('AgentHostGenericConfigChips', () => { const acquired: string[] = []; const released: string[] = []; const agentHostService = new class extends mock() { + override readonly initializeResult = constObservable(undefined); declare readonly _serviceBrand: undefined; override readonly onDidNotification = Event.None; override readonly onAgentHostStart = Event.None; @@ -406,6 +409,7 @@ suite('AgentHostInitialSessionConfig', () => { const answers: DeferredPromise[] = []; const errors: unknown[] = []; const makeConnection = (name: string) => new class extends mock() { + override readonly initializeResult = constObservable(undefined); override resolveSessionConfig(): Promise { requests.push(name); const answer = new DeferredPromise(); @@ -487,6 +491,7 @@ suite('AgentHostGenericConfigChips - remote sessions', () => { }, }; const connection = new class extends mock() { + override readonly initializeResult = constObservable(undefined); override readonly onDidNotification = Event.None; acquired = 0; released = 0; @@ -531,6 +536,7 @@ suite('AgentHostGenericConfigChips - remote sessions', () => { } remoteConnections.set('host-two', secondHost.connection); const ambient = new class extends mock() { + override readonly initializeResult = constObservable(undefined); override readonly onAgentHostStart = Event.None; override readonly onAgentHostExit = Event.None; override readonly resourceUris = identityAgentHostResourceUriMapper; @@ -792,6 +798,7 @@ suite('AgentHostGenericConfigChips - remote sessions', () => { return subscribeCalls === 1 ? initial.p : { resource: resource.toString(), state, fromSeq: 0 }; }, () => { })); const connection = new class extends mock() { + override readonly initializeResult = constObservable(undefined); override readonly onDidNotification = notifications.event; override getSubscription(kind: T, resource: URI, owner: string): IReference> { return manager.getSubscription(kind, resource, owner); @@ -836,6 +843,7 @@ suite('AgentHostGenericConfigChips - remote sessions', () => { onDidError: errors.event, }; const connection = new class extends mock() { + override readonly initializeResult = constObservable(undefined); override readonly onDidNotification = notifications.event; }(); let retries = 0; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSandboxNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSandboxNotification.test.ts index 751a59f13dab5..b1b206535a02f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSandboxNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSandboxNotification.test.ts @@ -26,7 +26,7 @@ import { SessionConfigKey } from '../../../../../../platform/agentHost/common/se import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/common/commands.js'; -import { createSessionState, SessionState, SessionStatus, StateComponents, type ChatState, type ComponentToState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { createSessionState, SessionState, SessionStatus, StateComponents, type ChatState, type ComponentToState, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { CommandsRegistry, ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -46,6 +46,7 @@ import { IPickOptions, IQuickInputService, IQuickPickItem, QuickPickInput } from import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotificationService } from '../../../browser/widget/input/chatInputNotificationService.js'; import { ChatInputNotificationWidget } from '../../../browser/widget/input/chatInputNotificationWidget.js'; +import { ChatProvidersConfigKey } from '../../../../../../platform/agentHost/common/agentHostChatProviders.js'; suite('AgentHostSandboxNotification', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -100,6 +101,7 @@ suite('AgentHostSandboxNotification', () => { }); dispatched = []; localHost = new class extends mock() { + override readonly initializeResult = constObservable(undefined); override dispatch(...args: Parameters): void { dispatched.push(args); } @@ -111,6 +113,7 @@ suite('AgentHostSandboxNotification', () => { connectionAuthority: 'custom-host', backendSession: URI.parse('custom-host://opaque/session'), connection: new class extends mock() { + override readonly initializeResult = constObservable(undefined); override dispatch(...args: Parameters): void { dispatched.push(args); } @@ -263,6 +266,7 @@ suite('AgentHostSandboxNotification', () => { resolution = { ...resolution, connection: new class extends mock() { + override readonly initializeResult = constObservable(undefined); override readonly sandboxCertificateRemovalSupported = constObservable(supported); override readonly sandboxCertificateInstallationSupported = constObservable(true); override removeSandboxCertificate = remove; @@ -282,6 +286,7 @@ suite('AgentHostSandboxNotification', () => { resolution = { ...resolution, connection: new class extends mock() { + override readonly initializeResult = constObservable(undefined); override readonly sandboxCertificateRotationSupported = constObservable(supported); override readonly sandboxCertificateInstallationSupported = constObservable(true); override rotateSandboxCertificate = rotate; @@ -1549,7 +1554,7 @@ suite('AgentHostSandboxNotification', () => { }); } - test('turns off the sandboxing of its chat, which the chat set for itself', async () => { + test('turns off the sandboxing of the default chat through its session, never writing the default chat itself', async () => { await configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.Off); const chat = 'ahp-chat://default/opaque'; const subscription = store.add(new TestSubscription()); @@ -1567,6 +1572,13 @@ suite('AgentHostSandboxNotification', () => { resolution = { ...resolution!, connection: new class extends mock() { + override readonly initializeResult = constObservable(undefined); + // A host that applies session writes to its default chat. + override readonly rootState = (() => { + // A host whose chats may use their own agents applies session writes to its default chat. + const value: RootState = { agents: [], config: { schema: { type: 'object', properties: { [ChatProvidersConfigKey.Enabled]: { type: 'boolean', title: 'Multiple Chat Providers' } } }, values: { [ChatProvidersConfigKey.Enabled]: true } } }; + return { value, verifiedValue: value, onDidChange: Event.None, onWillApplyAction: Event.None, onDidApplyAction: Event.None }; + })(); override dispatch(...args: Parameters): void { dispatched.push(args); } @@ -1581,7 +1593,8 @@ suite('AgentHostSandboxNotification', () => { createNotification(resource, subscription, service); await instantiationService.invokeFunction(CommandsRegistry.getCommand(getDisableCommand(service))!.handler); - assert.deepStrictEqual(dispatched, [[chat, { type: ActionType.ChatConfigChanged, config: { [SessionConfigKey.SandboxEnabled]: AgentSandboxEnabledValue.Off } }]]); + // The session's config is its default chat's: the host applies the session write to that chat. + assert.deepStrictEqual(dispatched, [[resolution!.backendSession.toString(), { type: ActionType.SessionConfigChanged, config: { [SessionConfigKey.SandboxEnabled]: AgentSandboxEnabledValue.Off } }]]); }); test('does not send a redundant session update when sandboxing is already off', async () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSessionUri.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSessionUri.test.ts index fc94609cd7dc5..82402ccbc4e79 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSessionUri.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSessionUri.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { Event } from '../../../../../../base/common/event.js'; +import { constObservable } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -13,9 +14,9 @@ import { IAgentHostService } from '../../../../../../platform/agentHost/common/a import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; -import { applyAgentHostSessionConfigChange, getAgentHostSessionConfig, readAgentHostChatSandboxState } from '../../../browser/agentSessions/agentHost/applyAgentHostSessionConfig.js'; +import { applyAgentHostSessionConfigChange, dispatchAgentHostConfigChange, getAgentHostEffectiveChatConfig, getAgentHostSessionConfig, hostPublishesCompleteChatConfig, readAgentHostChatSandboxState } from '../../../browser/agentSessions/agentHost/applyAgentHostSessionConfig.js'; import { withChatSandboxState, withSessionSandboxState } from '../../../../../../platform/agentHost/common/meta/agentSandboxStateMeta.js'; -import { buildChatUri, buildDefaultChatUri, ChatOriginKind, SessionLifecycle, SessionStatus, StateComponents, type ChatState, type ChatSummary, type ComponentToState, type SessionState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, ChatOriginKind, SessionLifecycle, SessionStatus, StateComponents, type ChatState, type ChatSummary, type ComponentToState, type RootState, type SessionState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { toAgentHostBackendSessionUri } from '../../../browser/agentSessions/agentHost/agentHostSessionUri.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; @@ -24,6 +25,7 @@ import { applyAgentHostSubmitConfig } from '../../../browser/agentSessions/agent import { TestDialogService } from '../../../../../../platform/dialogs/test/common/testDialogService.js'; import { InMemoryStorageService } from '../../../../../../platform/storage/common/storage.js'; import { ActionType, StateAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { ChatProvidersConfigKey } from '../../../../../../platform/agentHost/common/agentHostChatProviders.js'; suite('Agent Host committed configuration identity', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -46,6 +48,8 @@ suite('Agent Host committed configuration identity', () => { const config = { schema: { type: 'object' as const, properties: { approvalMode: { type: 'string' as const, title: 'Approvals', enum: ['manual', 'assisted', 'allow-all'] } } }, values: { approvalMode: 'assisted' } }; const writes: { channel: string; action: StateAction }[] = []; const agentHostService = new class extends mock() { + override readonly initializeResult = constObservable(undefined); + override readonly rootState = { value: undefined, verifiedValue: undefined, onDidChange: Event.None, onWillApplyAction: Event.None, onDidApplyAction: Event.None }; override dispatch(channel: string, action: StateAction) { writes.push({ channel, action }); } override getSubscriptionUnmanaged() { return { value: undefined, verifiedValue: undefined, onDidChange: Event.None, onWillApplyAction: Event.None, onDidApplyAction: Event.None }; @@ -93,12 +97,16 @@ suite('Agent Host committed configuration identity', () => { [peer, { ...summary, turns: [], config: { values: { mode: 'plan' } } }], ]); const writes: { channel: string; action: StateAction }[] = []; + const resolvedProviders: (string | undefined)[] = []; const agentHostService = new class extends mock() { + override readonly initializeResult = constObservable(undefined); + override readonly rootState = { value: undefined, verifiedValue: undefined, onDidChange: Event.None, onWillApplyAction: Event.None, onDidApplyAction: Event.None }; override dispatch(channel: string, action: StateAction) { writes.push({ channel, action }); } override getSubscriptionUnmanaged(_kind: T, uri: URI): IAgentSubscription { const value = states.get(uri.toString()) as ComponentToState[T] | undefined; return { value, verifiedValue: value, onDidChange: Event.None, onWillApplyAction: Event.None, onDidApplyAction: Event.None }; } + override async resolveSessionConfig(params: { provider?: string }) { resolvedProviders.push(params.provider); return { schema: { type: 'object' as const, properties: {} }, values: {} }; } }(); const connectionsService = new class extends mock() { override resolveSessionResourceIdentity() { return { backendSession, connectionAuthority: AMBIENT_AGENT_HOST_AUTHORITY }; } @@ -120,8 +128,10 @@ suite('Agent Host committed configuration identity', () => { configurationService: new TestConfigurationService(), }); - assert.deepStrictEqual({ read, writes }, { + assert.deepStrictEqual({ read, writes, resolvedProviders }, { read: { mode: 'plan', isolation: 'folder' }, + // A chat on its session's agent uses the session's config: no agent config is resolved for it. + resolvedProviders: [], writes: [ { channel: peer, action: { type: ActionType.ChatConfigChanged, config: { mode: 'autopilot' } } }, { channel: backendSession.toString(), action: { type: ActionType.SessionConfigChanged, config: { isolation: 'worktree' } } }, @@ -129,6 +139,154 @@ suite('Agent Host committed configuration identity', () => { }); }); + test('a chat on another agent reads its own schema from its state, never the session\'s, and writes only its chat-settable properties', async () => { + const resource = URI.parse('agent-host-copilotcli:/mixed').with({ fragment: 'peer' }); + const backendSession = URI.parse('copilotcli:/mixed'); + const peer = buildChatUri(backendSession, 'peer'); + const summary: ChatSummary = { resource: peer, title: 'Peer', status: SessionStatus.Idle, modifiedAt: new Date(0).toISOString(), provider: 'claude' }; + const states = new Map([ + [backendSession.toString(), { + provider: 'copilotcli', title: 'Mixed', status: SessionStatus.Idle, lifecycle: SessionLifecycle.Ready, activeClients: [], + chats: [summary], defaultChat: buildDefaultChatUri(backendSession), workingDirectories: ['file:///repo'], + config: { + schema: { type: 'object', properties: { mode: { type: 'string', title: 'Mode', enum: ['interactive', 'plan'], sessionMutable: true, chatMutable: true }, isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'], sessionMutable: true } } }, + values: { mode: 'interactive', isolation: 'folder' }, + }, + }], + ]); + const effort = { type: 'string' as const, title: 'Effort', enum: ['low', 'medium', 'high'], default: 'medium', chatMutable: true }; + const verbose = { type: 'boolean' as const, title: 'Verbose', default: false, chatMutable: true }; + const writes: { channel: string; action: StateAction }[] = []; + const resolved: (string | undefined)[] = []; + const agentHostService = new class extends mock() { + override readonly initializeResult = constObservable(undefined); + override readonly rootState = { value: undefined, verifiedValue: undefined, onDidChange: Event.None, onWillApplyAction: Event.None, onDidApplyAction: Event.None }; + override dispatch(channel: string, action: StateAction) { writes.push({ channel, action }); } + override getSubscriptionUnmanaged(_kind: T, uri: URI): IAgentSubscription { + const value = states.get(uri.toString()) as ComponentToState[T] | undefined; + return { value, verifiedValue: value, onDidChange: Event.None, onWillApplyAction: Event.None, onDidApplyAction: Event.None }; + } + override async resolveSessionConfig(params: { provider?: string }) { + resolved.push(params.provider); + return { schema: { type: 'object' as const, properties: {} }, values: {} }; + } + }(); + const connectionsService = new class extends mock() { + override resolveSessionResourceIdentity() { return { backendSession, connectionAuthority: AMBIENT_AGENT_HOST_AUTHORITY }; } + override resolveSessionResource() { return { backendSession, connectionAuthority: AMBIENT_AGENT_HOST_AUTHORITY, connection: agentHostService }; } + }(); + const provisionalService = new class extends mock() { + override get() { return undefined; } + override getResolvedConfig() { return states.get(backendSession.toString()) && (states.get(backendSession.toString()) as SessionState).config; } + override async refreshResolvedConfig() { } + }(); + // Until the chat's state arrives it has no config: never the session's (or its provisional) config. + const pending = getAgentHostSessionConfig(resource, provisionalService, connectionsService); + states.set(peer, { ...summary, turns: [], config: { schema: { type: 'object', properties: { effort, verbose } }, values: { effort: 'high' } } }); + const read = getAgentHostSessionConfig(resource, provisionalService, connectionsService); + + await applyAgentHostSessionConfigChange(resource, { effort: 'low', mode: 'plan', isolation: 'worktree' }, { + agentHostService, + connectionsService, + provisionalService, + workingDirectoryResolver: new class extends mock() { override resolve() { return undefined; } }(), + workspaceContextService: new class extends mock() { override getWorkspace() { return { id: 'test', folders: [] }; } }(), + configurationService: new TestConfigurationService(), + }); + + assert.deepStrictEqual({ pending, read, resolved, writes }, { + pending: undefined, + read: { schema: { type: 'object', properties: { effort, verbose } }, values: { effort: 'high', verbose: false } }, + resolved: [], + writes: [{ channel: peer, action: { type: ActionType.ChatConfigChanged, config: { effort: 'low' } } }], + }); + }); + + test('a change from the default chat writes what the session may set to the session on a host that applies it to the default chat (chats with their own agents on), and to the default chat otherwise', () => { + const backendSession = URI.parse('copilotcli:/main'); + const main = buildDefaultChatUri(backendSession); + const summary: ChatSummary = { resource: main, title: 'Main', status: SessionStatus.Idle, modifiedAt: new Date(0).toISOString() }; + const state: SessionState = { + provider: 'copilotcli', title: 'Main', status: SessionStatus.Idle, lifecycle: SessionLifecycle.Ready, activeClients: [], chats: [summary], defaultChat: main, + config: { + schema: { + type: 'object', properties: { + mode: { type: 'string', title: 'Mode', enum: ['interactive', 'plan'], sessionMutable: true, chatMutable: true }, + effort: { type: 'string', title: 'Effort', enum: ['low', 'high'], chatMutable: true }, + isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'], sessionMutable: true }, + }, + }, + values: { mode: 'interactive', isolation: 'folder' }, + }, + }; + const write = (rootConfigKeys: readonly string[], values: Record = {}) => { + const writes: { channel: string; action: StateAction }[] = []; + const rootState: RootState = { agents: [], config: { schema: { type: 'object', properties: Object.fromEntries(rootConfigKeys.map(key => [key, { type: 'boolean', title: key }])) }, values } }; + dispatchAgentHostConfigChange({ + dispatch: (channel, action) => writes.push({ channel, action }), + rootState: { value: rootState, verifiedValue: rootState, onDidChange: Event.None, onWillApplyAction: Event.None, onDidApplyAction: Event.None }, + }, backendSession, main, state, { mode: 'plan', effort: 'high', isolation: 'worktree' }); + return writes; + }; + + const older = [ + { channel: main, action: { type: ActionType.ChatConfigChanged, config: { mode: 'plan', effort: 'high' } } }, + { channel: backendSession.toString(), action: { type: ActionType.SessionConfigChanged, config: { isolation: 'worktree' } } }, + ]; + // An older host keeps the setting's value this client mirrors to it, but doesn't declare it. + assert.deepStrictEqual(write([], { [ChatProvidersConfigKey.Enabled]: true }), older); + assert.deepStrictEqual({ current: write([ChatProvidersConfigKey.Enabled], { [ChatProvidersConfigKey.Enabled]: true }), gateOff: write([ChatProvidersConfigKey.Enabled], { [ChatProvidersConfigKey.Enabled]: false }), older: write([]) }, { + // Only what a chat alone may set reaches the default chat; each property is written once. + current: [ + { channel: main, action: { type: ActionType.ChatConfigChanged, config: { effort: 'high' } } }, + { channel: backendSession.toString(), action: { type: ActionType.SessionConfigChanged, config: { mode: 'plan', isolation: 'worktree' } } }, + ], + // With chats with their own agents off, a host behaves as an older one. + gateOff: [ + { channel: main, action: { type: ActionType.ChatConfigChanged, config: { mode: 'plan', effort: 'high' } } }, + { channel: backendSession.toString(), action: { type: ActionType.SessionConfigChanged, config: { isolation: 'worktree' } } }, + ], + // An older host lets the default chat's own value win over the session's. + older: [ + { channel: main, action: { type: ActionType.ChatConfigChanged, config: { mode: 'plan', effort: 'high' } } }, + { channel: backendSession.toString(), action: { type: ActionType.SessionConfigChanged, config: { isolation: 'worktree' } } }, + ], + }); + }); + + test('a host publishes complete chat values only while it declares and enables chats with their own agents', () => { + const publishes = (declared: boolean, value: unknown) => hostPublishesCompleteChatConfig({ + agents: [], + config: { schema: { type: 'object', properties: declared ? { [ChatProvidersConfigKey.Enabled]: { type: 'boolean', title: 'Enabled' } } : {} }, values: { [ChatProvidersConfigKey.Enabled]: value } }, + }); + assert.deepStrictEqual({ on: publishes(true, true), off: publishes(true, false), undeclaredButOn: publishes(false, true), noRoot: hostPublishesCompleteChatConfig(undefined) }, { + on: true, off: false, undeclaredButOn: false, noRoot: false, + }); + }); + + test('a chat\'s effective config is an overlay on its session\'s unless its host publishes complete values (chats with their own agents on)', () => { + const session = { + schema: { + type: 'object' as const, properties: { + mode: { type: 'string' as const, title: 'Mode', enum: ['interactive', 'plan', 'autopilot'], default: 'interactive', sessionMutable: true, chatMutable: true }, + isolation: { type: 'string' as const, title: 'Isolation', enum: ['folder', 'worktree'], sessionMutable: true }, + }, + }, + values: { mode: 'plan', isolation: 'folder' }, + }; + assert.deepStrictEqual({ + // An older host, or one with the setting off, publishes only what the chat set itself. + overlay: getAgentHostEffectiveChatConfig(session, { values: {} }, false).values, + // With the setting on, a host publishes the chat's values; one it omits is its default. + complete: getAgentHostEffectiveChatConfig(session, { values: {} }, true).values, + completeSet: getAgentHostEffectiveChatConfig(session, { values: { mode: 'autopilot', isolation: 'worktree' } }, true).values, + }, { + overlay: { mode: 'plan', isolation: 'folder' }, + complete: { mode: 'interactive', isolation: 'folder' }, + completeSet: { mode: 'autopilot', isolation: 'folder' }, + }); + }); + for (const provider of ['copilotcli', 'codex', 'claude']) { for (const backendResource of [`${provider}:/shared`, 'ahp-session:/shared', 'session-store://tenant/shared?revision%3D2']) { test(`${provider} config changes use the advertised backend ${backendResource}`, async () => { @@ -142,6 +300,7 @@ suite('Agent Host committed configuration identity', () => { } }(); const agentHostService = new class extends mock() { + override readonly initializeResult = constObservable(undefined); override dispatch(channel: string): void { dispatched.push(channel); } override getSubscriptionUnmanaged() { return { value: undefined, verifiedValue: undefined, onDidChange: Event.None, onWillApplyAction: Event.None, onDidApplyAction: Event.None }; diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts index 19ee9d649e70f..ffdd71ab4355d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts @@ -20,6 +20,8 @@ import { Range } from '../../../../../../editor/common/core/range.js'; import { AgentHostClientConnectionKind } from '../../../../../../platform/agentHost/common/agentHostTelemetry.js'; import { AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentEnabledSettingId } from '../../../../../../platform/agentHost/common/agentService.js'; import { IConfigurationChangeEvent } from '../../../../../../platform/configuration/common/configuration.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { CodeEditorWidget } from '../../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -142,6 +144,115 @@ suite('ChatWidget', () => { }, { whileHidden: 0, whenVisible: 1, privateData: false }); }); + function createRespondentWidget(lockedAgent: boolean) { + const resource = URI.parse('agent-host-copilotcli:/session#peer'); + let placeholder: string | undefined; + const viewModel = upcastPartial({ + sessionResource: resource, + get inputPlaceholder() { return placeholder; }, + setInputPlaceholder: (text: string) => { placeholder = text; }, + resetInputPlaceholder: () => { placeholder = undefined; }, + }); + const editorPlaceholders: (string | undefined)[] = []; + const rendererOptions: (IChatListItemRendererOptions['responseIdentity'])[] = []; + const modelPools: ({ pool: string | undefined; resource: string | undefined; revalidate?: boolean } | { cleared: true; revalidate?: boolean } | undefined)[] = []; + const inputPart = upcastPartial({ + inputEditor: upcastPartial({ updateOptions: (options: { placeholder?: string }) => { editorPlaceholders.push(options.placeholder); } }), + setModelSessionTypeOverride: (override: { readonly sessionType: string | undefined } | undefined, target?: URI, options?: { readonly revalidate?: boolean }) => { + modelPools.push(override ? { pool: override.sessionType, resource: target?.fragment, ...(options?.revalidate === false ? { revalidate: false } : {}) } : (options?.revalidate === false ? { cleared: true, revalidate: false } : undefined)); + }, + flushInputStateToModel: () => { }, + }); + const widget: ChatWidget = Object.assign(Object.create(ChatWidget.prototype), { + _viewModel: viewModel, + ...(lockedAgent ? { _lockedAgent: { id: 'agent-host-copilotcli', name: 'copilot', displayName: 'Copilot', prefix: '@copilot ' } } : {}), + _appliedInputPlaceholder: false, + inputPartDisposable: { value: inputPart }, + configurationService: new TestConfigurationService(), + chatSessionsService: upcastPartial({ getChatSessionContribution: () => undefined }), + listWidget: { updateRendererOptions: (options: IChatListItemRendererOptions) => { rendererOptions.push(options.responseIdentity); } }, + rerenderListIfConnected: () => { }, + container: dom.$('div'), + viewModelDisposables: store.add(new DisposableStore()), + logService: new NullLogService(), + _onDidChangeViewModel: store.add(new Emitter()), + updateSessionStateIndicator: () => { }, + clearGettingStartedTip: () => { }, + updateWorkingProgressBorder: () => { }, + onDidChangeItems: () => { }, + _hasPendingRequestsContextKey: { set: () => { } }, + _chatSessionSupportsRenameContextKey: { set: () => { } }, + viewOptions: { isSessionsWindow: true }, + }); + return { widget, resource, get placeholder() { return placeholder; }, editorPlaceholders, rendererOptions, modelPools }; + } + + test('presents a chat answered by another agent with that agent, and restores its session type\'s presentation', () => { + const harness = createRespondentWidget(true); + harness.widget.setRespondent({ label: 'Claude', icon: Codicon.claude, modelSessionType: 'agent-host-claude' }); + const answeredByClaude = harness.placeholder; + harness.widget.setRespondent({ label: 'Claude', icon: Codicon.claude, modelSessionType: 'agent-host-claude' }); + harness.widget.setRespondent(undefined); + + assert.deepStrictEqual({ answeredByClaude, restored: harness.placeholder, editorPlaceholders: harness.editorPlaceholders, rendererOptions: harness.rendererOptions, modelPools: harness.modelPools }, { + answeredByClaude: 'Chat with Claude', + restored: 'Chat with Copilot', + editorPlaceholders: ['Chat with Claude', 'Chat with Copilot'], + rendererOptions: [{ label: 'Claude', icon: Codicon.claude }, undefined], + modelPools: [{ pool: 'agent-host-claude', resource: 'peer' }, undefined], + }); + }); + + test('clears its own placeholder when a respondent chat gives way to a plain chat, and never touches one it did not set', () => { + const untouched = createRespondentWidget(false); + untouched.widget.setRespondent(undefined); + const harness = createRespondentWidget(false); + harness.widget.setRespondent({ label: 'Claude', icon: Codicon.claude, modelSessionType: undefined }); + harness.widget.setRespondent(undefined); + + assert.deepStrictEqual({ untouched: untouched.editorPlaceholders, editorPlaceholders: harness.editorPlaceholders, placeholder: harness.placeholder, modelPools: harness.modelPools }, { + untouched: [], + editorPlaceholders: ['Chat with Claude', undefined], + placeholder: undefined, + modelPools: [{ pool: undefined, resource: 'peer' }, undefined], + }); + }); + + test('switching from a respondent chat never revalidates its model against another pool, and unbinding drops the respondent', () => { + const harness = createRespondentWidget(true); + harness.widget.setRespondent({ label: 'Claude', icon: Codicon.claude, modelSessionType: 'agent-host-claude' }); + const incoming = harness.resource.with({ fragment: 'incoming' }); + // The incoming chat has none: the bound chat's pool stays until the incoming chat binds. + harness.widget.setRespondent(undefined, incoming); + const afterNoneAhead = [...harness.modelPools]; + // The incoming chat has its own: it replaces the bound chat's pool without revalidating it. + harness.widget.setRespondent({ label: 'Codex', icon: Codicon.openai, modelSessionType: 'agent-host-codex' }, incoming); + harness.widget.setModel(undefined); + // A stale respondent never survives an unbind, e.g. when the incoming chat then fails to load. + harness.widget.setRespondent(undefined, incoming); + + assert.deepStrictEqual({ afterNoneAhead, modelPools: harness.modelPools, rendererOptions: harness.rendererOptions }, { + afterNoneAhead: [{ pool: 'agent-host-claude', resource: 'peer' }], + modelPools: [ + { pool: 'agent-host-claude', resource: 'peer' }, + { pool: 'agent-host-codex', resource: 'incoming', revalidate: false }, + { cleared: true, revalidate: false }, + ], + rendererOptions: [{ label: 'Claude', icon: Codicon.claude }, undefined], + }); + }); + + test('a respondent set ahead for an incoming chat leaves the bound chat\'s presentation alone', () => { + const harness = createRespondentWidget(true); + harness.widget.setRespondent({ label: 'Claude', icon: Codicon.claude, modelSessionType: 'agent-host-claude' }, harness.resource.with({ fragment: 'incoming' })); + + assert.deepStrictEqual({ editorPlaceholders: harness.editorPlaceholders, rendererOptions: harness.rendererOptions, modelPools: harness.modelPools }, { + editorPlaceholders: [], + rendererOptions: [], + modelPools: [{ pool: 'agent-host-claude', resource: 'incoming', revalidate: false }], + }); + }); + function createRequestToolsWidget() { const sessionA = upcastPartial({ sessionResource: URI.parse('test:/a') }); const sessionB = upcastPartial({ sessionResource: URI.parse('test:/b') }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPart.test.ts index 925b4f9696b69..e10e0cd62e781 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPart.test.ts @@ -31,7 +31,8 @@ import { ChatInputPart } from '../../../../browser/widget/input/chatInputPart.js import { ChatSessionWorkingSetKeptChanges } from '../../../../browser/widget/input/chatSessionWorkingSet.js'; import { ChatSessionReviewPreferencesService, IChatSessionReviewPreferencesService } from '../../../../common/editing/chatSessionReviewPreferences.js'; import { CollapsibleListPool } from '../../../../browser/widget/chatContentParts/chatReferencesContentPart.js'; -import { ChatConfiguration } from '../../../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../../common/constants.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; import { IChatSessionFileChange2, IChatSessionsService, SessionType } from '../../../../common/chatSessionsService.js'; import { ChatContextKeys } from '../../../../common/actions/chatContextKeys.js'; import { emptySessionEntryDiff, hasUndecidedChatEditingResourceContextKey, IChatEditingSession, IEditSessionEntryDiff, IModifiedFileEntry, IModifiedFileEntryEditorIntegration, ModifiedFileEntryState } from '../../../../common/editing/chatEditingService.js'; @@ -192,6 +193,78 @@ suite('ChatInputPart', () => { assert.deepStrictEqual(states, [false, true, false, false]); }); + function createModelPoolInput(models: readonly ILanguageModelChatMetadataAndIdentifier[]) { + const sessionResource = URI.from({ scheme: 'agent-host-copilotcli', path: '/session', fragment: 'peer' }); + const revalidations: (string | undefined)[] = []; + const inputPart: ChatInputPart = Object.create(ChatInputPart.prototype); + Object.defineProperty(inputPart, 'currentModeKind', { value: ChatModeKind.Agent }); + Object.assign(inputPart, { + location: ChatAgentLocation.Chat, + _inputModelSessionResource: sessionResource, + _widget: { viewModel: { model: { sessionResource } } }, + _currentSessionTypeObservable: observableValue('currentSessionType', 'agent-host-copilotcli'), + _modelSessionTypeOverride: observableValue<{ readonly resource: URI; readonly sessionType: string } | undefined>('override', undefined), + _modelSelectionController: { revalidateForSessionType: () => revalidations.push(inputPart.modelTargetSessionType) }, + getAllMergedModels: () => [...models], + chatSessionsService: upcastPartial({ requiresCustomModelsForSessionType: () => true }), + languageModelsService: upcastPartial({ isModelHidden: () => false }), + }); + return { inputPart, revalidations }; + } + + function targetedModel(sessionType: string, id: string): ILanguageModelChatMetadataAndIdentifier { + return { identifier: `${sessionType}:${id}`, metadata: upcastPartial({ id, name: id, targetChatSessionType: sessionType, isUserSelectable: true }) }; + } + + test('selects a chat\'s models from the session type it is answered by, never another one\'s', () => { + const copilot = targetedModel('agent-host-copilotcli', 'gpt'); + const claude = targetedModel('agent-host-claude', 'claude-haiku'); + const read = (input: ChatInputPart) => ({ target: input.modelTargetSessionType, models: (input as unknown as { getModels(): ILanguageModelChatMetadataAndIdentifier[] }).getModels().map(model => model.identifier) }); + + const withClaude = createModelPoolInput([copilot, claude]); + const sessionDefault = read(withClaude.inputPart); + withClaude.inputPart.setModelSessionTypeOverride({ sessionType: 'agent-host-claude' }); + const overridden = read(withClaude.inputPart); + withClaude.inputPart.setModelSessionTypeOverride(undefined); + const restored = read(withClaude.inputPart); + + const emptyVendor = createModelPoolInput([copilot]); + emptyVendor.inputPart.setModelSessionTypeOverride({ sessionType: 'agent-host-claude' }); + const unresolved = createModelPoolInput([copilot, claude]); + unresolved.inputPart.setModelSessionTypeOverride({ sessionType: undefined }); + const whileUnresolved = read(unresolved.inputPart); + unresolved.inputPart.setModelSessionTypeOverride({ sessionType: 'agent-host-claude' }); + const onceResolved = read(unresolved.inputPart); + const otherChat = createModelPoolInput([copilot, claude]); + otherChat.inputPart.setModelSessionTypeOverride({ sessionType: 'agent-host-claude' }, URI.parse('agent-host-copilotcli:/session#incoming')); + + assert.deepStrictEqual({ sessionDefault, overridden, restored, revalidations: withClaude.revalidations, emptyVendor: read(emptyVendor.inputPart), whileUnresolved, onceResolved, otherChat: { ...read(otherChat.inputPart), revalidations: otherChat.revalidations } }, { + sessionDefault: { target: 'agent-host-copilotcli', models: [copilot.identifier] }, + overridden: { target: 'agent-host-claude', models: [claude.identifier] }, + restored: { target: 'agent-host-copilotcli', models: [copilot.identifier] }, + revalidations: ['agent-host-claude', 'agent-host-copilotcli'], + emptyVendor: { target: 'agent-host-claude', models: [] }, + whileUnresolved: { target: 'agent-host-copilotcli', models: [] }, + onceResolved: { target: 'agent-host-claude', models: [claude.identifier] }, + // Set ahead for a chat that is not bound yet: the bound chat is not revalidated against it. + otherChat: { target: 'agent-host-copilotcli', models: [copilot.identifier], revalidations: [] }, + }); + }); + + test('replacing or dropping the bound chat\'s pool while switching away never revalidates it against another pool', () => { + const claude = targetedModel('agent-host-claude', 'claude-haiku'); + const { inputPart, revalidations } = createModelPoolInput([targetedModel('agent-host-copilotcli', 'gpt'), claude]); + inputPart.setModelSessionTypeOverride({ sessionType: 'agent-host-claude' }); + const bound = revalidations.length; + inputPart.setModelSessionTypeOverride({ sessionType: 'agent-host-codex' }, URI.parse('agent-host-copilotcli:/session#incoming'), { revalidate: false }); + inputPart.setModelSessionTypeOverride(undefined, undefined, { revalidate: false }); + Object.assign(inputPart, { _widget: { viewModel: undefined } }); + inputPart.setModelSessionTypeOverride({ sessionType: 'agent-host-claude' }); + inputPart.setModelSessionTypeOverride(undefined); + + assert.deepStrictEqual({ bound, revalidations }, { bound: 1, revalidations: ['agent-host-claude'] }); + }); + test('lets a host that submits for itself decide whether its session options are valid', () => { const instantiationService = workbenchInstantiationService(undefined, store); const contextKeyService = store.add(instantiationService.createInstance(ContextKeyService)); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelConfigurationStore.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelConfigurationStore.test.ts index f3d9bfeab436f..04e6cc547ff47 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelConfigurationStore.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelConfigurationStore.test.ts @@ -1033,6 +1033,7 @@ suite('ChatModelConfigurationStore', () => { _currentChatModesObservable: observableValue('test.modes', modes), _currentSessionModelObservable: observableValue('test.model', undefined), _currentSessionTypeObservable: observableValue('test.sessionType', undefined), + _modelSessionTypeOverride: observableValue('test.modelSessionTypeOverride', undefined), _currentLanguageModel: observableValue('test.selectedModel', selectedModel), chatModeService: { createModes: () => modes }, selectedToolsModel: { resetSessionEnablementState() { } },