From 6a14f618cc45060a4431b8df7092842f2c5f3b41 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Wed, 7 Oct 2026 03:28:49 +1100 Subject: [PATCH 01/15] agentHost: support named fork titles Carry optional fork titles through Agent Host transports and both VS Code session surfaces, advertise support explicitly, and preserve ordinary forks for older hosts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/AGENTS.md | 15 +- .../browser/agentHostProtocolClient.ts | 1 + src/vs/platform/agentHost/common/agent.ts | 6 +- .../platform/agentHost/common/agentService.ts | 2 +- .../platform/agentHost/common/state/AGENTS.md | 13 ++ .../state/protocol/channels-chat/commands.ts | 13 ++ .../state/protocol/channels-root/state.ts | 7 + .../electron-browser/localAgentHostService.ts | 2 +- .../platform/agentHost/node/agentService.ts | 26 ++- .../agentHost/node/claude/claudeAgent.ts | 2 +- .../agentHost/node/codex/codexAgent.ts | 2 +- .../agentHost/node/copilot/copilotAgent.ts | 2 +- .../agentHost/node/protocolServerHandler.ts | 12 +- .../agentHostProtocolClient.test.ts | 4 +- .../localAgentHostService.test.ts | 46 ++++- .../test/node/agentHostE2EServerLease.test.ts | 1 + .../agentHost/test/node/agentService.test.ts | 174 +++++++++++++++++- .../agentHost/test/node/claudeAgent.test.ts | 4 +- .../test/node/codex/codexCreateChat.test.ts | 2 +- .../agentHost/test/node/copilotAgent.test.ts | 29 ++- .../agentHostConformance.integrationTest.ts | 1 + .../e2e/harness/agentHostE2ETestHarness.ts | 2 + .../claudeAgentHostE2E.integrationTest.ts | 1 + .../e2e/providers/codexTestConfiguration.ts | 1 + .../e2e/providers/copilotTestConfiguration.ts | 1 + .../test/node/e2e/suites/multiChatSuite.ts | 46 ++++- .../platform/agentHost/test/node/mockAgent.ts | 4 +- .../test/node/protocolServerHandler.test.ts | 12 +- src/vs/sessions/SESSIONS.md | 4 +- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 2 + .../agentHost/browser/agentHostForkActions.ts | 4 +- .../browser/baseAgentHostSessionsProvider.ts | 9 +- .../test/browser/agentHostForkActions.test.ts | 21 ++- .../localAgentHostSessionsProvider.test.ts | 41 ++++- .../browser/sessionsManagementService.ts | 6 +- .../sessions/common/sessionsManagement.ts | 7 +- .../sessions/common/sessionsProvider.ts | 8 +- .../browser/sessionsManagementService.test.ts | 22 ++- .../browser/actions/chatAccessibilityHelp.ts | 3 + .../chat/browser/actions/chatForkActions.ts | 15 +- .../agentHost/agentHostSessionHandler.ts | 11 +- .../chatSessions/chatSessions.contribution.ts | 6 +- .../contrib/chat/browser/chatSlashCommands.ts | 5 +- .../chat/common/chatSessionsService.ts | 10 +- .../chatAccessibilityHelp.test.ts | 53 ++++++ .../browser/actions/chatForkActions.test.ts | 151 ++++++++++++++- .../agentHostChatContribution.test.ts | 114 +++++++++++- .../test/common/mockChatSessionsService.ts | 4 +- 48 files changed, 835 insertions(+), 92 deletions(-) diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index 396d0164bed902..4f28a4b130af95 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -14,7 +14,7 @@ > **Status: COMPLETE** (2026-07-01) All waves A–D and gates G-B1, G-C1, G-C2, G-D1 are done. Codex, Claude, and Copilot all use the unified orchestrator path. > -> Codex advertises `multipleChats: { fork: true, sideChat: true }`. Model-backed peer, fork, and side-chat parity scenarios run in deterministic replay. +> Codex advertises `multipleChats: { fork: true, sideChat: true, initialTitle: true }`. Model-backed peer, fork, and side-chat parity scenarios run in deterministic replay. > > The *operational* chat surface (send/abort/model/agent/history) is fully chat-addressed and uniform across harnesses. Session ownership lives in the orchestrator: it drives every harness through the chat-surface seam — see [§7 Session Ownership (T2/T4)](#7-session-ownership-t2t4--the-orchestrator-owns-the-session). @@ -152,7 +152,7 @@ Artifact tools read and mutate only the invoking chat's artifacts. The session d ### UI/provider layer (`sessions/services/sessions/common/session.ts:ISessionCapabilities`) -- Protocol `AgentCapabilities` (`multipleChats?: { fork?: boolean }`) flows from `AgentInfo.capabilities` (protocol) through the provider adapter into `ISession.capabilities` (`ISessionCapabilities`), whose `supportsMultipleChats`/`supportsFork` flags derive from the presence of `multipleChats` and `multipleChats.fork`, and from there into VS Code context keys (`sessionContextKeys.ts:SessionSupportsMultipleChatsContext`, `SessionSupportsForkContext`). +- Protocol `AgentCapabilities` (`multipleChats?: { fork?: boolean; initialTitle?: boolean }`) flows from `AgentInfo.capabilities` (protocol) through the provider adapter. `ISession.capabilities` (`ISessionCapabilities`) exposes `supportsMultipleChats` and `supportsFork`, derived from `multipleChats` and `multipleChats.fork`; both flow into VS Code context keys (`sessionContextKeys.ts:SessionSupportsMultipleChatsContext`, `SessionSupportsForkContext`). The Agent Host provider handles `multipleChats.initialTitle` privately when forwarding fork options. - UI actions read context keys — no provider-id switches. --- @@ -267,6 +267,7 @@ interface AgentCapabilities { multipleChats?: { fork?: boolean; // can fork a chat from a turn sideChat?: boolean; // can branch hidden context without copied visible history + initialTitle?: boolean; // accepts a client-selected title when creating a chat }; multipleWorkingDirectories?: { immutablePrimary?: boolean; // index 0 remains the fixed process root @@ -274,11 +275,11 @@ interface AgentCapabilities { } ``` -The agent declares these in `getDescriptor().capabilities` (`common/agent.ts:IAgentDescriptor`). They flow to the UI as `ISessionCapabilities` (`sessions/services/sessions/common/session.ts`) and are bound to context keys (`sessions/services/sessions/common/sessionContextKeys.ts:SessionSupportsMultipleChatsContext`, `SessionSupportsForkContext`). +The agent declares these in `getDescriptor().capabilities` (`common/agent.ts:IAgentDescriptor`). `multipleChats` and `multipleChats.fork` flow to the UI as `ISessionCapabilities.supportsMultipleChats` and `supportsFork` (`sessions/services/sessions/common/session.ts`) and are bound to context keys (`sessions/services/sessions/common/sessionContextKeys.ts:SessionSupportsMultipleChatsContext`, `SessionSupportsForkContext`). The Agent Host provider consumes `multipleChats.initialTitle` at its private AHP boundary instead of projecting a generic session capability. UI code gates "Add Chat" and "Fork" actions on those context keys. No code inside `AgentService` or `AgentHostStateManager` switches on provider id to gate features. `AgentService.createChat` throws synchronously when `!provider.chats` (the structural guard that replaces a capability check in the orchestrator). -Claude, Copilot, and Codex advertise `multipleChats: { fork: true, sideChat: true }`. Codex side-chat context and restore run in deterministic replay. Subagent E2E and native streaming file-creation coverage remain independently disabled and must not be inferred from its peer-chat/fork/side-chat support. +Claude, Copilot, and Codex advertise `multipleChats: { fork: true, sideChat: true, initialTitle: true }`. Codex side-chat context and restore run in deterministic replay. Subagent E2E and native streaming file-creation coverage remain independently disabled and must not be inferred from its peer-chat/fork/side-chat support. --- @@ -454,7 +455,7 @@ Claude deliberately has no AH-session container and no membership/role concept o - `_chatBackings: Map` maps each exact host-supplied chat URI to only its provider-owned `{ sdkSessionId, model?, sideChat? }` backing data. It deliberately does **not** retain the owning AH session or a storage URI: AH supplies the owning session and persistence/config resource transiently on every operation (`IAgentChatContext`). - `IClaudeChatBacking` is the source of truth for both live and released chats: releasing a chat drops its `_chatEntriesBySdkId` leaf but keeps the backing data so a later send can cold-resume the corresponding `ClaudeAgentSession`. -Every chat operation resolves exactly one backing and routes to exactly one live leaf; there is no default-vs-additional branch and no cascade between chats of the same session. An additional chat's send after restart resumes only that chat's `ClaudeAgentSession`. Capabilities remain `multipleChats: { fork: true }`. +Every chat operation resolves exactly one backing and routes to exactly one live leaf; there is no default-vs-additional branch and no cascade between chats of the same session. An additional chat's send after restart resumes only that chat's `ClaudeAgentSession`. Capabilities remain `multipleChats: { fork: true, sideChat: true, initialTitle: true }`. Each additional chat is backed by a fresh top-level SDK session (`sdkSessionId = generateUuid()`) minted in the same global Claude project store that `listSessions` enumerates. `_createChat` therefore returns `backingSession: AgentSession.uri(this.id, sdkSessionId)` so the orchestrator can suppress that backing from the top-level session list (invariant I7); without it the additional chat would leak as a phantom session. The SDK exposes no delete-chat RPC, so `disposeChat` leaves the backing transcript on disk — the orchestrator-owned catalog simply drops the entry so it is never resumed again. (Claude writes no legacy `claude.chats` blob and has no legacy migration: Claude multi-chat shipped only with the orchestrator-owned catalog, so there is nothing to drain. Copilot keeps its own `copilot.chats` migration because `copilot.chats` predates the catalog.) @@ -467,7 +468,7 @@ Copilot also has no AH-session container: - Fork/import provisioning binds the exact target chat inside `chats.createChat`, so a create result is never left waiting for a follow-up bind call. - The backing records preserve the existing `providerData` codec and one-time `copilot.chats` migration. -No `CopilotSessionEntry`, `AgentSessionEntry`, default-chat URI helper, or sibling cascade remains. Send/history/model/agent/abort/tool/config/dispose/release operations resolve one leaf. Active-client state remains keyed by the owning SDK session where it is genuinely shared, while each live leaf owns its own SDK and MCP lifecycle. Capabilities remain `multipleChats: { fork: true }`. +No `CopilotSessionEntry`, `AgentSessionEntry`, default-chat URI helper, or sibling cascade remains. Send/history/model/agent/abort/tool/config/dispose/release operations resolve one leaf. Active-client state remains keyed by the owning SDK session where it is genuinely shared, while each live leaf owns its own SDK and MCP lifecycle. Capabilities remain `multipleChats: { fork: true, sideChat: true, initialTitle: true }`. Steering received during turn preparation or configuration refresh is buffered for the addressed chat. A dispatched turn confirmed running by root SDK events accepts and flushes steering immediately, even if its admission RPC has not returned; successful admission also flushes it. Preparation or send failure retains buffered steering for a successful retry, rather than dispatching against an unready runtime. Pending updates replace or remove buffered steering; abort clears it and emits `steering_consumed` to clear the pending UI. The live SDK-session wrapper takes ownership synchronously before MCP reconciliation, so abort and disposal can clear steering throughout delivery. Chat disposal and host shutdown discard buffered delivery, and pending-message updates are ignored once host shutdown starts. @@ -520,7 +521,7 @@ When a restored Agent Host conversation's intended model is missing from the pic Metadata and history reads use `thread/read` and `thread/turns/list` without resuming the thread or claiming its writer lock, so a thread still open in another Codex client remains readable. Subscribing to an interactive chat invokes the optional provider-internal `IAgentChats.prepareChat` seam before returning the snapshot. Codex resumes an existing backing to acquire its writer lock without sending a turn; fresh chats remain lazy. The host publishes temporary input restrictions through existing read-only chat interactivity and validated `vscode.chatInputState` session metadata. These restrictions are not persisted as permanent read-only state. Clients retain history and the editable draft; Retry refreshes the existing subscription without sending a turn or discarding pending local actions. No additional AHP method or capability is required. Sends still apply pending launch configuration and replace a never-persisted backing only when the SDK reports that its rollout is missing. Reads still retry when their app-server connection is replaced. -An additional chat is backed by a **fresh top-level thread minted eagerly** in `chats.createChat` (via `thread/start` or `thread/fork` at the requested turn, reusing `_forkSession`). For these internal peer backings only, the backing entry and URI are keyed by the app-server-assigned thread id. This does not couple the parent AH session id to its default thread id; it gives the peer-chat-backing marker a stable `codex:/` database across restart. `_createChat`/`fork` therefore return `backingSession: AgentSession.uri(this.id, threadId)` so the orchestrator suppresses that backing from the top-level session list (invariant I7), plus an opaque `providerData` blob (the backing thread id + model) that `materializeChat` decodes on restore. The additional chat inherits the parent session's working directory, model, and permissions. Exact disposal/release affects only the addressed chat's own thread — there is no cascade between chats of the same session. The persisted `codex.threadId`, `codex.cwd`, and `codex.model` keys and app-server protocol are unchanged, and Codex still never recognizes or derives a default-chat URI. The orchestrator registry contains the parent AH session, not these chat backing URIs; provider-owned discovery pushes external chats through `onDidDiscoverChats`. Capabilities are `multipleChats: { fork: true }`. +An additional chat is backed by a **fresh top-level thread minted eagerly** in `chats.createChat` (via `thread/start` or `thread/fork` at the requested turn, reusing `_forkSession`). For these internal peer backings only, the backing entry and URI are keyed by the app-server-assigned thread id. This does not couple the parent AH session id to its default thread id; it gives the peer-chat-backing marker a stable `codex:/` database across restart. `_createChat`/`fork` therefore return `backingSession: AgentSession.uri(this.id, threadId)` so the orchestrator suppresses that backing from the top-level session list (invariant I7), plus an opaque `providerData` blob (the backing thread id + model) that `materializeChat` decodes on restore. The additional chat inherits the parent session's working directory, model, and permissions. Exact disposal/release affects only the addressed chat's own thread — there is no cascade between chats of the same session. The persisted `codex.threadId`, `codex.cwd`, and `codex.model` keys and app-server protocol are unchanged, and Codex still never recognizes or derives a default-chat URI. The orchestrator registry contains the parent AH session, not these chat backing URIs; provider-owned discovery pushes external chats through `onDidDiscoverChats`. Capabilities are `multipleChats: { fork: true, sideChat: true, initialTitle: true }`. --- diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 47a108d35b8fac..6e398be6586d47 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -1967,6 +1967,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect await this._sendRequest('createChat', { channel: session.toString(), chat: chat.toString(), + ...(options?.title !== undefined ? { title: options.title } : {}), ...(options?.workingDirectories !== undefined && !options.fork ? { workingDirectories: options.workingDirectories.map(directory => fromAgentHostUri(directory).toString()) } : {}), diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 88bb9dfc0abe0a..2281ee8e4f2a9a 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -731,7 +731,11 @@ export interface IAgentCreateChatOptions { * carries no destination the permission layer can check against the scope. */ readonly hasScopedEditSurface?: boolean; - /** Optional display title for the new chat. */ + /** + * Authoritative initial title supplied to host creation. When present, it + * takes precedence over a generated fork placeholder; explicit provenance + * determines whether automatic title replacement is allowed. + */ readonly title?: string; /** Optional model override; defaults to the session's model. */ readonly model?: ModelSelection; diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index a21452ce6a6e00..271837c0c4f8f1 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -856,7 +856,7 @@ export interface IAgentHostManagementService { createSessionWithExtensions(config: IAgentCreateSessionConfig): Promise; /** * Local-only compatibility path for chat fields not yet represented by AHP - * `createChat` (`title` and `model`). + * `createChat` (`model`). */ createChatWithExtensions(session: URI, chat: URI, options: IAgentCreateChatRequestOptions): Promise; createDetachedWorktree(session: URI, prompt: string): Promise<{ handle: string; worktree: URI }>; diff --git a/src/vs/platform/agentHost/common/state/AGENTS.md b/src/vs/platform/agentHost/common/state/AGENTS.md index 47ad30abde52ee..642395cc18e907 100644 --- a/src/vs/platform/agentHost/common/state/AGENTS.md +++ b/src/vs/platform/agentHost/common/state/AGENTS.md @@ -25,6 +25,19 @@ The generated protocol includes the Changesets model: 3. If VS Code consumers need short aliases or type guards, update the wrapper files in this directory after the sync. 4. Compile. The generated registry catches missing action/notification version map entries. +### Current targeted backport + +The generated copy intentionally carries `CreateChatParams.title` and +`MultipleChatsCapability.initialTitle` on top of the `.ahp-version` source +commit `6eec1b91`. Syncing that commit without the canonical fields removes +them and breaks current consumers. Until the recorded pin advances to an AHP +commit containing these fields, preserve or reapply this generated delta when +syncing. + +A reproducible sync from `6eec1b91` must also retain the existing VS Code-only +compatibility deltas: deprecated `SessionChatSummary.archived` and +`ChatIsReadChanged` introduced in `0.10.0`. + ## Adding optional fields to existing types Optional protocol fields are usually backwards-compatible. Add the field in the source protocol repo, sync `protocol/**`, and add wrapper exports only when VS Code code needs them. 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 a5187b06d3efc8..3964469be431f8 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 @@ -120,6 +120,19 @@ export interface CreateChatParams extends BaseParams { * {@link AgentCapabilities.multipleWorkingDirectories}. */ workingDirectories?: URI[]; + /** + * Client-selected initial display title for the new chat. + * + * Clients MUST NOT supply this field unless the selected agent advertises + * {@link MultipleChatsCapability.initialTitle}. + * + * A server honoring this field MUST trim leading and trailing whitespace. + * If the result is empty, it MUST treat the field as absent and choose its + * normal initial title. Otherwise, it MUST preserve the remaining text + * exactly: it MUST NOT collapse internal whitespace, strip quotes or + * punctuation, or truncate it. The protocol defines no maximum title length. + */ + title?: string; } // ─── moveChat ──────────────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index 219d0cd14b4e05..88c2a44dd761b6 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -150,6 +150,13 @@ export interface MultipleChatsCapability { * time. Side-chat support always implies multi-chat support. */ sideChat?: boolean; + /** + * The agent honors {@link CreateChatParams.title} as the chat's first + * published title and does not replace it through automatic title generation. + * A later explicit rename is still allowed. When absent or `false`, clients + * MUST omit `title` when calling `createChat`. + */ + initialTitle?: boolean; } /** diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index 8a00ec926dafe0..35813c707f9618 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -721,7 +721,7 @@ function hasSessionExtensions(config: IAgentCreateSessionConfig): boolean { } function hasChatExtensions(options: IAgentCreateChatRequestOptions): boolean { - return options.title !== undefined || options.model !== undefined; + return options.model !== undefined; } /** diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 5d93e836422c27..152684b94bf620 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -83,7 +83,7 @@ import { resolveChangesetOwnerScope } from './agentHostBranchChangesetScope.js'; import { IAgentHostSessionOpenTelemetry, type IAgentHostSessionOpenTelemetryScope } from './agentHostSessionOpenTelemetry.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; import { type IAddSessionWorkingDirectoryOptions, type IAgentServiceSessionServerToolAccessor, type IPreparedChatWorkingDirectory, type IChatContextSnapshot, type IRenameTitleResult, type ISessionCreationDefaults, validateRenameTitle } from './shared/sessionServerTools.js'; -import { AGENT_HOST_TITLE_SOURCE_AGENT, AGENT_HOST_TITLE_SOURCE_AUTO, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, parseSessionWorkingDirectories, persistSessionMetadataValues, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY, SESSION_WORKING_DIRECTORIES_KEY } from './shared/persistSessionMetadata.js'; +import { AGENT_HOST_TITLE_SOURCE_AGENT, AGENT_HOST_TITLE_SOURCE_AUTO, AGENT_HOST_TITLE_SOURCE_USER, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, parseSessionWorkingDirectories, persistSessionMetadataValues, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY, SESSION_WORKING_DIRECTORIES_KEY } from './shared/persistSessionMetadata.js'; import { type IArtifactServerToolAccessor } from './shared/artifactServerTools.js'; import { SessionArtifacts } from './shared/sessionArtifacts.js'; import { readSessionAdditionalWorktrees, writeSessionAdditionalWorktrees, type ISessionAdditionalWorktree } from './shared/sessionAdditionalWorktrees.js'; @@ -5442,8 +5442,13 @@ export class AgentService extends Disposable implements IAgentService { let forkedTurns: Turn[] | undefined; let forkedTitle: string | undefined; let forkedSourceTitle: string | undefined; - const { sideChat, ...providerOptions } = options ?? {}; - let createOptions: IAgentCreateChatOptions | undefined = providerOptions; + const { sideChat, title: requestedTitle, ...providerOptions } = options ?? {}; + const explicitTitle = requestedTitle?.trim() || undefined; + const normalizedProviderOptions: IAgentCreateChatOptions = { + ...providerOptions, + ...(explicitTitle !== undefined ? { title: explicitTitle } : {}), + }; + let createOptions: IAgentCreateChatOptions | undefined = normalizedProviderOptions; // Persist exhaustive provenance for peer chats. Fresh user-created chats // leave this undefined and default to `ChatOriginKind.User`. let peerChatOrigin: ChatOrigin | undefined; @@ -5451,7 +5456,7 @@ export class AgentService extends Disposable implements IAgentService { const resolvedSideChat = await this._resolveSideChatOrigin(session, sideChat); peerChatOrigin = resolvedSideChat.origin; createOptions = { - ...providerOptions, + ...normalizedProviderOptions, ...(resolvedSideChat.shouldFork ? { fork: { @@ -5548,7 +5553,10 @@ export class AgentService extends Disposable implements IAgentService { // subscribers only see a chat that can already receive messages. const createResult = await this._createChat(provider, chat, session, createOptions); const providerData = createResult?.providerData; - const title = forkedTitle ?? options?.title; + const title = explicitTitle ?? forkedTitle; + const selectedTitleSource = explicitTitle !== undefined + ? AGENT_HOST_TITLE_SOURCE_USER + : forkedTitle !== undefined ? AGENT_HOST_TITLE_SOURCE_AUTO : undefined; const sessionState = this._stateManager.getSessionState(sessionKey); if (!sessionState) { await provider.chats.disposeChat(chat, this._chatContext(session, chat)); @@ -5585,15 +5593,15 @@ export class AgentService extends Disposable implements IAgentService { if (title !== undefined) { await this._peerChatStore.persistMetadata(session, chat, { [SESSION_CUSTOM_TITLE_KEY]: title, - [SESSION_CUSTOM_TITLE_SOURCE_KEY]: AGENT_HOST_TITLE_SOURCE_AUTO, + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: selectedTitleSource ?? AGENT_HOST_TITLE_SOURCE_AUTO, }); } await this._persistOrderedListVisibleSessionState(session, title === undefined ? {} : { [customChatTitleMetadataKey(chat.toString())]: title, - [customChatTitleSourceMetadataKey(chat.toString())]: AGENT_HOST_TITLE_SOURCE_AUTO, + [customChatTitleSourceMetadataKey(chat.toString())]: selectedTitleSource ?? AGENT_HOST_TITLE_SOURCE_AUTO, }, catalogChats); this._stateManager.addChat(sessionKey, chat.toString(), { - ...(forkedTitle !== undefined ? { title: forkedTitle } : options?.title !== undefined ? { title: options.title } : {}), + ...(title !== undefined ? { title } : {}), ...(forkedTurns !== undefined ? { turns: forkedTurns } : {}), ...(providerData !== undefined ? { providerData } : {}), ...(peerChatOrigin !== undefined ? { origin: peerChatOrigin } : {}), @@ -5646,7 +5654,7 @@ export class AgentService extends Disposable implements IAgentService { // derived from the inherited chat. Forks seed pre-existing // turns, so the normal first-message/first-turn title generation never // fires for them — this is the fork-time equivalent. - if (forkedTurns && forkedTurns.length > 0 && forkedTitle !== undefined) { + if (explicitTitle === undefined && forkedTurns && forkedTurns.length > 0 && forkedTitle !== undefined) { this._sideEffects.generateForkedTitle(sessionKey, chat.toString(), forkedTurns, forkedTitle, forkedSourceTitle); } } diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index ba9ab668ee9387..153f1fa1f37efa 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -718,7 +718,7 @@ export class ClaudeAgent extends Disposable implements IAgent { displayName: localize('claudeAgent.displayName', "Claude"), description: localize('claudeAgent.description', "Claude agent backed by the Anthropic Claude Agent SDK"), capabilities: { - multipleChats: { fork: true, sideChat: true }, + multipleChats: { fork: true, sideChat: true, initialTitle: true }, ...(this._isMultiRootEnabled() ? { multipleWorkingDirectories: { immutablePrimary: true } } : {}), }, }; diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 840da2fd0eb9a5..28df8a742b37d3 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -4275,7 +4275,7 @@ export class CodexAgent extends Disposable implements IAgent { displayName: localize('codexAgent.displayName', "Codex"), description: localize('codexAgent.description', "Codex agent using session-selected model providers"), capabilities: { - multipleChats: { fork: true, sideChat: true }, + multipleChats: { fork: true, sideChat: true, initialTitle: true }, ...(this._isMultiRootEnabled() ? { multipleWorkingDirectories: { immutablePrimary: true } } : {}), }, }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 794144b96db592..7bae997512735d 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -1594,7 +1594,7 @@ export class CopilotAgent extends Disposable implements IAgent { displayName: 'Copilot', description: localize('copilotAgent.description', "Copilot SDK agent running in the local agent host process"), capabilities: { - multipleChats: { fork: true, sideChat: true }, + multipleChats: { fork: true, sideChat: true, initialTitle: true }, ...(this._isMultiRootEnabled() ? { multipleWorkingDirectories: { immutablePrimary: true } } : {}), }, }; diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 0eab839f9035da..12f0cc2e2785c1 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -1836,13 +1836,19 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien return null; } const source = params.source; - let options: IAgentCreateChatRequestOptions | undefined = params.workingDirectories !== undefined - ? { workingDirectories: params.workingDirectories.map(directory => URI.parse(directory)) } + let options: IAgentCreateChatRequestOptions | undefined = params.title !== undefined || params.workingDirectories !== undefined + ? { + ...(params.title !== undefined ? { title: params.title } : {}), + ...(params.workingDirectories !== undefined ? { workingDirectories: params.workingDirectories.map(directory => URI.parse(directory)) } : {}), + } : undefined; if (source) { switch (source.kind) { case ChatSourceKind.Fork: - options = { fork: { source: URI.parse(source.chat), turnId: source.turnId } }; + options = { + ...(params.title !== undefined ? { title: params.title } : {}), + fork: { source: URI.parse(source.chat), turnId: source.turnId }, + }; break; case ChatSourceKind.SideChat: options = { 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 c80f812ad49b57..0262909dbd0ba6 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -1477,11 +1477,12 @@ suite('AgentHostProtocolClient', () => { const sourceUri = URI.parse('ahp-session:/test/chat-0'); const workingDirectory = toAgentHostUri(URI.file('/workspace'), agentHostAuthority('test.example:1234')); - test('forwards a fork source and ignores its working directories', async () => { + test('forwards an exact title and fork source while ignoring working directories', async () => { const { client, transport } = createClient(); const resultPromise = client.createChat(sessionUri, chatUri, { fork: { source: sourceUri, turnId: 'turn-1' }, + title: ' Exact title ', workingDirectories: [workingDirectory], }); @@ -1492,6 +1493,7 @@ suite('AgentHostProtocolClient', () => { params: { channel: sessionUri.toString(), chat: chatUri.toString(), + title: ' Exact title ', source: { kind: ChatSourceKind.Fork, chat: sourceUri.toString(), turnId: 'turn-1' }, }, }); diff --git a/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts index 9c1383cd76cea4..043c108bbda6dd 100644 --- a/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts @@ -4,11 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import sinon from 'sinon'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { constObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { IChannelClient, IChannelServer, IServerChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { mock } from '../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ConfigurationTarget, IConfigurationService, IConfigurationValue } from '../../../configuration/common/configuration.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; @@ -27,7 +29,7 @@ import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, AgentHostClientByokLmChannel } from import { AgentHostClientType, editorWindowAgentHostClientInfo } from '../../common/agentHostClientInfo.js'; import { AgentHostStartupTelemetry } from '../../common/agentHostStartupTelemetry.js'; import { AgentHostClientConnectionKind } from '../../common/agentHostTelemetry.js'; -import { AgentHostOTelEnabledSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelPolicyState, IAgentHostOTelPolicyReadiness, IAgentHostOTelSettings } from '../../common/agentService.js'; +import { AgentHostOTelEnabledSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelPolicyState, IAgentCreateChatRequestOptions, IAgentHostManagementService, IAgentHostOTelPolicyReadiness, IAgentHostOTelSettings } from '../../common/agentService.js'; import { ActionEnvelope, ActionType, INotification, NotificationType } from '../../common/state/sessionActions.js'; import { ProtocolError } from '../../common/state/sessionProtocol.js'; import { ROOT_STATE_URI } from '../../common/state/sessionState.js'; @@ -73,6 +75,7 @@ suite('LocalAgentHostServiceClient events', () => { const onDidNotification = disposables.add(new Emitter()); const onMcpNotification = disposables.add(new Emitter()); const onDidChangeConnectionState = disposables.add(new Emitter()); + const createdChats: { session: URI; chat: URI; options?: IAgentCreateChatRequestOptions }[] = []; let connectCount = 0; const fire = () => { onDidAction.fire(action); @@ -87,6 +90,9 @@ suite('LocalAgentHostServiceClient events', () => { onDidChangeConnectionState: onDidChangeConnectionState.event, onDidFatalClose: Event.None, registerMcpAuthenticationHandler: () => Disposable.None, + createChat: async (session: URI, chat: URI, options?: IAgentCreateChatRequestOptions) => { + createdChats.push({ session, chat, options }); + }, dispose: () => { }, }; const instantiationService = disposables.add(new TestInstantiationService()); @@ -112,12 +118,14 @@ suite('LocalAgentHostServiceClient events', () => { return listeners; }; return { - service, received, listen, fire, onDidChangeConnectionState, + service, received, listen, fire, onDidChangeConnectionState, createdChats, connectCount: () => connectCount, listening: () => [onDidAction.hasListeners(), onDidNotification.hasListeners(), onMcpNotification.hasListeners()], }; } + teardown(() => sinon.restore()); + test('exposes the local connection kind before the host starts', () => { const { service } = createService(); const beforeStart = service.clientConnectionKind; @@ -125,6 +133,40 @@ suite('LocalAgentHostServiceClient events', () => { assert.deepStrictEqual([beforeStart, service.clientConnectionKind], [AgentHostClientConnectionKind.Local, AgentHostClientConnectionKind.Local]); }); + test('routes title-only through AHP and model with title through management once', async () => { + const { service, createdChats } = createService(); + const managementChats: { session: URI; chat: URI; options: IAgentCreateChatRequestOptions }[] = []; + const managementService = new class extends mock() { + override async createChatWithExtensions(session: URI, chat: URI, options: IAgentCreateChatRequestOptions): Promise { + managementChats.push({ session, chat, options }); + } + }(); + sinon.stub(Object.getPrototypeOf(service), '_getManagementService').returns(managementService); + service.startAgentHost(); + const session = URI.parse('agent-session://test/session'); + const titleOnlyChat = URI.parse('agent-chat://test/session/title-only'); + const modelChat = URI.parse('agent-chat://test/session/model'); + + await service.createChat(session, titleOnlyChat, { title: 'Title only' }); + await service.createChat(session, modelChat, { title: 'Model title', model: { id: 'model-1' } }); + + assert.deepStrictEqual({ + protocol: createdChats, + management: managementChats, + }, { + protocol: [{ + session, + chat: titleOnlyChat, + options: { title: 'Title only' }, + }], + management: [{ + session, + chat: modelChat, + options: { title: 'Model title', model: { id: 'model-1' } }, + }], + }); + }); + test('forwards readiness changes and keeps another window refresh from restarting the shared host', async () => { class PolicyConfigurationService extends TestConfigurationService { override inspect(key: string): IConfigurationValue { diff --git a/src/vs/platform/agentHost/test/node/agentHostE2EServerLease.test.ts b/src/vs/platform/agentHost/test/node/agentHostE2EServerLease.test.ts index 6ec9905da1cc54..9f5814dfda46d5 100644 --- a/src/vs/platform/agentHost/test/node/agentHostE2EServerLease.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostE2EServerLease.test.ts @@ -40,6 +40,7 @@ suite('Agent Host E2E server lease diagnostics', () => { supportsHostTerminalTool: false, supportsSubagents: false, supportsMultipleChats: false, + supportsInitialTitle: false, supportsChatFork: false, supportsChatForkE2E: false, }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 4473339dea580d..a43ec100ff4b67 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -76,7 +76,7 @@ import type { IAgentHostStorageService } from '../../node/agentHostStorageServic import { AGENT_HOST_CATALOG_VERIFICATION_VERSION_STORAGE_KEY, CATALOG_VERIFICATION_VERSION, type IAgentHostCatalogReconciliationReport } from '../../node/agentHostCatalogReconciliationService.js'; import { AgentSessionRegistry, type IRegisteredSession } from '../../node/agentSessionRegistry.js'; import { AgentHostManagementService } from '../../node/agentHostManagementService.js'; -import { AGENT_HOST_TITLE_SOURCE_AGENT, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY, SESSION_WORKING_DIRECTORIES_KEY } from '../../node/shared/persistSessionMetadata.js'; +import { AGENT_HOST_TITLE_SOURCE_AGENT, AGENT_HOST_TITLE_SOURCE_AUTO, AGENT_HOST_TITLE_SOURCE_USER, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY, SESSION_WORKING_DIRECTORIES_KEY } from '../../node/shared/persistSessionMetadata.js'; import { MockAgent, ScriptedMockAgent } from './mockAgent.js'; import type { IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import { readChatInputState } from '../../common/meta/agentHostChatInputState.js'; @@ -23317,6 +23317,147 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('fork publishes an explicit normalized title without automatic refinement', async () => { + const sessionData = createPerSessionDataService(); + const copilotApiService = new TestCopilotApiService(); + const localService = disposables.add(createTestAgentService( + new NullLogService(), + fileService, + sessionData.service, + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + copilotApiService, + )); + const forkedTitleCalls: unknown[][] = []; + (localService as unknown as { _sideEffects: { generateForkedTitle: (...args: unknown[]) => void } })._sideEffects.generateForkedTitle = (...args) => forkedTitleCalls.push(args); + let receivedOptions: IAgentCreateChatOptions | undefined; + class MultiChatAgent extends MockAgent { + override async createChat(_session: URI, _chat: URI, options?: IAgentCreateChatOptions): Promise { + receivedOptions = options; + } + } + const agent = disposables.add(new MultiChatAgent('copilot')); + registerTestAgentProvider(localService, agent); + await localService.authenticate({ + resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, + scopes: GITHUB_COPILOT_PROTECTED_RESOURCE.scopes_supported, + token: 'gh-token', + }); + const session = await localService.createSession({ provider: 'copilot' }); + const sourceTurns: Turn[] = [ + { id: 't1', state: TurnState.Complete, message: { text: 'first', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, + ]; + const stateManager = getStateManager(localService); + stateManager.seedDefaultChatTurns(session.toString(), sourceTurns); + stateManager.updateChatTitle(session.toString(), buildDefaultChatUri(session.toString()), 'My Session'); + const envelopes: ActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(envelope => envelopes.push(envelope))); + const chatUri = URI.parse(buildChatUri(session, 'named-fork')); + await localService.createChat(session, chatUri, { title: ' . ', fork: { source: session, turnId: 't1' } }); + + const publishedTitles = envelopes.flatMap(envelope => + envelope.action.type === ActionType.SessionChatAdded && envelope.action.summary.resource === chatUri.toString() + ? [envelope.action.summary.title] + : []); + const creationTitleUpdates = envelopes.flatMap(envelope => + envelope.action.type === ActionType.SessionChatUpdated && envelope.action.chat === chatUri.toString() && envelope.action.changes.title !== undefined + ? [envelope.action.changes.title] + : []); + const creationSessionTitleChanges = envelopes.flatMap(envelope => + envelope.action.type === ActionType.SessionTitleChanged ? [envelope.action.title] : []); + const chatState = stateManager.getChatState(chatUri.toString()); + const sessionChat = stateManager.getSessionState(session.toString())?.chats.find(chat => chat.resource === chatUri.toString()); + assert.deepStrictEqual({ + providerTitle: receivedOptions?.title, + publishedTitles, + creationTitleUpdates, + creationSessionTitleChanges, + chatStateTitle: chatState?.title, + sessionChatTitle: sessionChat?.title, + chatLocalTitle: await sessionData.database(chatUri).getMetadata(SESSION_CUSTOM_TITLE_KEY), + chatLocalSource: await sessionData.database(chatUri).getMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY), + parentTitle: await sessionData.database(session).getMetadata(customChatTitleMetadataKey(chatUri.toString())), + parentSource: await sessionData.database(session).getMetadata(customChatTitleSourceMetadataKey(chatUri.toString())), + origin: chatState?.origin, + forkSource: receivedOptions?.fork?.source.toString(), + forkTurnId: receivedOptions?.fork?.turnId, + mappingSize: receivedOptions?.fork?.turnIdMapping?.size, + seededTurnIsRemapped: chatState?.turns[0]?.id !== 't1', + forkedTitleCalls: forkedTitleCalls.length, + }, { + providerTitle: '.', + publishedTitles: ['.'], + creationTitleUpdates: [], + creationSessionTitleChanges: [], + chatStateTitle: '.', + sessionChatTitle: '.', + chatLocalTitle: '.', + chatLocalSource: AGENT_HOST_TITLE_SOURCE_USER, + parentTitle: '.', + parentSource: AGENT_HOST_TITLE_SOURCE_USER, + origin: { kind: ChatOriginKind.Fork, chat: buildDefaultChatUri(session), turnId: 't1' }, + forkSource: buildDefaultChatUri(session), + forkTurnId: 't1', + mappingSize: 1, + seededTurnIsRemapped: true, + forkedTitleCalls: 0, + }); + }); + + test('bare fork keeps its automatic placeholder and refinement scheduling', async () => { + const sessionData = createPerSessionDataService(); + const copilotApiService = new TestCopilotApiService(); + const localService = disposables.add(createTestAgentService( + new NullLogService(), + fileService, + sessionData.service, + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + copilotApiService, + )); + const forkedTitleCalls: unknown[][] = []; + (localService as unknown as { _sideEffects: { generateForkedTitle: (...args: unknown[]) => void } })._sideEffects.generateForkedTitle = (...args) => forkedTitleCalls.push(args); + class MultiChatAgent extends MockAgent { + override async createChat(): Promise { } + } + const agent = disposables.add(new MultiChatAgent('copilot')); + registerTestAgentProvider(localService, agent); + const session = await localService.createSession({ provider: 'copilot' }); + const stateManager = getStateManager(localService); + stateManager.seedDefaultChatTurns(session.toString(), [ + { id: 't1', state: TurnState.Complete, message: { text: 'first', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, + ]); + stateManager.updateChatTitle(session.toString(), buildDefaultChatUri(session.toString()), 'My Session'); + + const chatUri = URI.parse(buildChatUri(session, 'bare-fork')); + await localService.createChat(session, chatUri, { fork: { source: session, turnId: 't1' } }); + const chatState = stateManager.getChatState(chatUri.toString()); + + assert.deepStrictEqual({ + title: chatState?.title, + chatLocalSource: await sessionData.database(chatUri).getMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY), + parentSource: await sessionData.database(session).getMetadata(customChatTitleSourceMetadataKey(chatUri.toString())), + forkedTitleCalls, + }, { + title: 'Forked: My Session', + chatLocalSource: AGENT_HOST_TITLE_SOURCE_AUTO, + parentSource: AGENT_HOST_TITLE_SOURCE_AUTO, + forkedTitleCalls: [[ + session.toString(), + chatUri.toString(), + chatState?.turns, + 'Forked: My Session', + 'My Session', + ]], + }); + }); + test('fork records a Fork origin naming the exact source chat and host-visible turn', async () => { class MultiChatAgent extends MockAgent { override async createChat(): Promise { } @@ -23861,6 +24002,37 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('side chat publishes the normalized explicit title', async () => { + const agent = disposables.add(new SideChatAgent('copilot')); + registerTestAgentProvider(service, agent); + const session = await service.createSession({ provider: 'copilot' }); + const stateManager = getStateManager(service); + stateManager.seedDefaultChatTurns(session.toString(), [completedTurn('t1')]); + const chatUri = URI.parse(buildChatUri(session, 'named-side')); + const defaultChatUri = buildDefaultChatUri(session); + const envelopes: ActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(envelope => envelopes.push(envelope))); + + await service.createChat(session, chatUri, { + title: ' Side title ', + sideChat: { source: session, turnId: 't1' }, + }); + + const chatAdded = envelopes.find(envelope => envelope.action.type === ActionType.SessionChatAdded); + const chatState = stateManager.getChatState(chatUri.toString()); + assert.deepStrictEqual({ + providerTitle: agent.lastCreateOptions?.title, + firstPublishedTitle: chatAdded?.action.type === ActionType.SessionChatAdded ? chatAdded.action.summary.title : undefined, + immediateStateTitle: chatState?.title, + origin: chatState?.origin, + }, { + providerTitle: 'Side title', + firstPublishedTitle: 'Side title', + immediateStateTitle: 'Side title', + origin: { kind: ChatOriginKind.SideChat, chat: defaultChatUri, turnId: 't1' }, + }); + }); + test('creates a side chat with a selection larger than the catalog JSON string bound', async () => { const sessionData = createPerSessionDataService(); const catalogDatabase = disposables.add(new AgentHostDatabase(':memory:')); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 35cfee9f77f073..23a6e803dfa7ac 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -1423,8 +1423,8 @@ suite('ClaudeAgent', () => { const { agent } = createTestContext(disposables); const desc = agent.getDescriptor(); assert.deepStrictEqual( - { provider: desc.provider, displayName: desc.displayName, hasDescription: desc.description.length > 0, agentHostCapabilities: agent.agentHostCapabilities }, - { provider: 'claude', displayName: 'Claude', hasDescription: true, agentHostCapabilities: { workspaceConversion: false } }, + { provider: desc.provider, displayName: desc.displayName, hasDescription: desc.description.length > 0, capabilities: desc.capabilities, agentHostCapabilities: agent.agentHostCapabilities }, + { provider: 'claude', displayName: 'Claude', hasDescription: true, capabilities: { multipleChats: { fork: true, sideChat: true, initialTitle: true } }, agentHostCapabilities: { workspaceConversion: false } }, ); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts index b0e929900bec6c..cb73d32aacbd2c 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts @@ -444,7 +444,7 @@ suite('CodexAgent createChat', () => { multipleChats: agent.getDescriptor().capabilities?.multipleChats, agentHostCapabilities: agent.agentHostCapabilities, }, { - multipleChats: { fork: true, sideChat: true }, + multipleChats: { fork: true, sideChat: true, initialTitle: true }, agentHostCapabilities: { workspaceConversion: true }, }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 1cb13dc88008f3..8f7ae9f989cd8b 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -120,6 +120,7 @@ import { ByokLmBridgeRegistry, IByokLmBridgeRegistry } from '../../node/byokLmBr import { IByokLmProxyService, NullByokLmProxyService, type IByokLmToolsCappedEvent } from '../../node/copilot/byokLmProxyService.js'; import { CopilotApiError, CopilotApiService, ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest, type IRestrictedTelemetryContext } from '../../node/shared/copilotApiService.js'; import type { IAgentHostInternalTelemetryContext, IAgentHostRestrictedTelemetryContext } from '../../node/agentHostRestrictedTelemetry.js'; +import { MockAgent, ScriptedMockAgent } from './mockAgent.js'; const TEST_PRODUCT_SERVICE: IProductService = { _serviceBrand: undefined, @@ -3118,7 +3119,7 @@ suite('CopilotAgent', () => { provider: 'copilotcli', displayName: 'Copilot', description: 'Copilot SDK agent running in the local agent host process', - capabilities: { multipleChats: { fork: true, sideChat: true } }, + capabilities: { multipleChats: { fork: true, sideChat: true, initialTitle: true } }, }, agentHostCapabilities: { workspaceConversion: true }, }); @@ -3127,6 +3128,32 @@ suite('CopilotAgent', () => { } }); + test('mock defaults advertise initial title support while custom capabilities remain unchanged', () => { + const previousMultipleChats = process.env['VSCODE_AGENT_HOST_MOCK_MULTIPLE_CHATS']; + process.env['VSCODE_AGENT_HOST_MOCK_MULTIPLE_CHATS'] = '1'; + try { + const mockAgent = disposables.add(new MockAgent()); + const scriptedMockAgent = disposables.add(new ScriptedMockAgent()); + const oldHostAgent = disposables.add(new MockAgent('old-host', { multipleChats: { fork: true } })); + + assert.deepStrictEqual({ + mockDefault: mockAgent.getDescriptor().capabilities, + scriptedMockDefault: scriptedMockAgent.getDescriptor().capabilities, + explicitOldHost: oldHostAgent.getDescriptor().capabilities, + }, { + mockDefault: { multipleChats: { fork: true, initialTitle: true } }, + scriptedMockDefault: { multipleChats: { fork: true, initialTitle: true } }, + explicitOldHost: { multipleChats: { fork: true } }, + }); + } finally { + if (previousMultipleChats === undefined) { + delete process.env['VSCODE_AGENT_HOST_MOCK_MULTIPLE_CHATS']; + } else { + process.env['VSCODE_AGENT_HOST_MOCK_MULTIPLE_CHATS'] = previousMultipleChats; + } + } + }); + test('advertises multipleWorkingDirectories only when the hidden setting is enabled', async () => { const { agent, stateManager } = createTestAgentContext(disposables); try { diff --git a/src/vs/platform/agentHost/test/node/e2e/conformance/agentHostConformance.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/conformance/agentHostConformance.integrationTest.ts index f74ad87c511d33..8849287d844717 100644 --- a/src/vs/platform/agentHost/test/node/e2e/conformance/agentHostConformance.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/conformance/agentHostConformance.integrationTest.ts @@ -49,6 +49,7 @@ const REFERENCE_CONFIG: IAgentHostE2EProviderConfig = { supportsSubagents: true, planModeStyle: 'session-state', supportsMultipleChats: true, + supportsInitialTitle: true, supportsChatFork: true, supportsChatForkE2E: true, }; diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts index 608b15d1bb692a..91ec9f2d0dc8d0 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts @@ -401,6 +401,8 @@ export interface IAgentHostE2EProviderConfig { readonly planModeStyle?: 'session-state' | 'input-request'; /** Whether the provider supports additional peer chats and chat forks. */ readonly supportsMultipleChats: boolean; + /** Whether peer chats can be created with an explicit initial title. */ + readonly supportsInitialTitle: boolean; /** Whether model-backed multiple-chat parity scenarios have deterministic fixtures. */ readonly supportsMultipleChatsE2E?: boolean; readonly supportsChatFork: boolean; diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts index 45f4cca9d4edd2..b9172e4cdfe076 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts @@ -88,6 +88,7 @@ const CLAUDE_CONFIG: IAgentHostE2EProviderConfig = { // shared test's Copilot-flavoured prompt doesn't reliably drive Claude // to invoke it. TODO: rework the prompt for Claude conventions. supportsMultipleChats: true, + supportsInitialTitle: true, supportsChatFork: true, // Claude cannot resolve a client-assigned AHP turn id to an SDK message UUID, // so a provider fork silently starts with fresh context. diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts b/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts index 38c12bc3f2853f..4d02c570958fe2 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts @@ -41,6 +41,7 @@ export const CODEX_CONFIG: IAgentHostE2EProviderConfig = { supportsSubagents: false, planModeStyle: 'input-request', supportsMultipleChats: true, + supportsInitialTitle: true, supportsMultipleChatsE2E: true, supportsWorkspacelessE2E: true, supportsRuntimeSlashCommandsE2E: true, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts b/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts index 4ff1a655cbede0..7a723c7eadb4a2 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts @@ -41,6 +41,7 @@ export const COPILOT_CONFIG: IAgentHostE2EProviderConfig = { supportsSideChatsE2E: true, planModeStyle: 'session-state', supportsMultipleChats: true, + supportsInitialTitle: true, supportsChatFork: true, supportsChatForkE2E: true, }; diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts index 624c16c39ed58a..8c9269f9013394 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts @@ -8,7 +8,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { ActionType, type ChatErrorAction, type ChatToolCallReadyAction } from '../../../../common/state/sessionActions.js'; +import { ActionType, type ChatErrorAction, type ChatToolCallReadyAction, type SessionChatAddedAction } from '../../../../common/state/sessionActions.js'; import { ChatSourceKind, CompletionItemKind, type CompletionsResult, type ListSessionsResult, type SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { buildChatUri, @@ -281,10 +281,12 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { multipleChats: !!agent?.capabilities?.multipleChats, fork: agent?.capabilities?.multipleChats?.fork ?? false, sideChat: agent?.capabilities?.multipleChats?.sideChat ?? false, + initialTitle: agent?.capabilities?.multipleChats?.initialTitle ?? false, }, { multipleChats: config.supportsMultipleChats, fork: config.supportsChatFork, sideChat: config.supportsSideChats ?? false, + initialTitle: config.supportsInitialTitle, }); }); @@ -558,6 +560,48 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { }]); }, config.supportsMultipleChats); + conformanceTest(context, 'titled peer chat publishes its title in the first notification and snapshots', async function () { + const { sessionUri, defaultChatUri } = await createSession('titled-peer'); + const root = await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + const agent = (root.snapshot!.state as RootState).agents.find(agent => agent.provider === config.provider); + await driveTurn(defaultChatUri, 'titled-peer-source', '/rename Fork Source', 1); + await context.client.call('subscribe', { channel: sessionUri }); + context.client.clearReceived(); + + const peer = buildChatUri(sessionUri, 'named-peer'); + const chatAddedPromise = context.client.waitForNotification(notification => { + if (!isActionNotification(notification, 'session/chatAdded')) { + return false; + } + const envelope = getActionEnvelope(notification); + return envelope.channel === sessionUri + && (envelope.action as SessionChatAddedAction).summary.resource === peer; + }); + await context.client.call('createChat', { + channel: sessionUri, + chat: peer, + title: 'Named peer .', + source: { kind: ChatSourceKind.Fork, chat: defaultChatUri, turnId: 'titled-peer-source' }, + }, 30_000); + const chatAdded = (getActionEnvelope(await chatAddedPromise).action as SessionChatAddedAction).summary; + const initialSession = await context.client.call('subscribe', { channel: sessionUri }); + const initialChat = await context.client.call('subscribe', { channel: peer }); + const sessionChat = (initialSession.snapshot!.state as SessionState).chats.find(chat => chat.resource === peer); + const chat = initialChat.snapshot!.state as ChatState; + + assert.deepStrictEqual({ + initialTitleCapability: agent?.capabilities?.multipleChats?.initialTitle, + chatAdded: { resource: chatAdded.resource, title: chatAdded.title }, + sessionChat: sessionChat && { resource: sessionChat.resource, title: sessionChat.title }, + chatSnapshot: { snapshotResource: initialChat.snapshot!.resource, resource: chat.resource, title: chat.title }, + }, { + initialTitleCapability: true, + chatAdded: { resource: peer, title: 'Named peer .' }, + sessionChat: { resource: peer, title: 'Named peer .' }, + chatSnapshot: { snapshotResource: peer, resource: peer, title: 'Named peer .' }, + }); + }, config.supportsMultipleChats && config.supportsInitialTitle); + conformanceTest(context, 'first peer chat snapshots the session title onto the default chat', async function () { const { sessionUri, defaultChatUri } = await createSession('default-title'); await rename(sessionUri, 'Original Session'); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 7057dab0a78da1..1bbcd2322e9de8 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -131,7 +131,7 @@ export class MockAgent implements IAgent { constructor( readonly id: AgentProvider = 'mock', - private readonly _capabilities: IAgentCapabilities = { multipleChats: { fork: true } }, + private readonly _capabilities: IAgentCapabilities = { multipleChats: { fork: true, initialTitle: true } }, readonly agentHostCapabilities: IAgent['agentHostCapabilities'] = { workspaceConversion: false }, autoDiscover = true, ) { @@ -569,7 +569,7 @@ export class ScriptedMockAgent implements IAgent { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent', - capabilities: this._supportsMultipleChats ? { multipleChats: { fork: true } } : undefined, + capabilities: this._supportsMultipleChats ? { multipleChats: { fork: true, initialTitle: true } } : undefined, }; } diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 97b0b1ce555ac5..007cf0bd1f71aa 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -5266,7 +5266,7 @@ suite('ProtocolServerHandler', () => { }); }); - test('createChat for an additional chat forwards working directories and grows the catalog', async () => { + test('createChat for an additional chat forwards an exact title and working directories', async () => { stateManager.createSession(makeSessionSummary()); const transport = connectClient('client-cc'); transport.sent.length = 0; @@ -5275,6 +5275,7 @@ suite('ProtocolServerHandler', () => { transport.simulateMessage(request(2, 'createChat', { channel: sessionUri, chat: peerChat, + title: ' Exact fresh title ', workingDirectories: [URI.file('/workspace').toString()], })); const resp = await responsePromise; @@ -5288,13 +5289,16 @@ suite('ProtocolServerHandler', () => { created: [{ session: sessionUri, chat: peerChat, - options: { workingDirectories: [URI.file('/workspace')] }, + options: { + title: ' Exact fresh title ', + workingDirectories: [URI.file('/workspace')], + }, }], inCatalog: true, }); }); - test('createChat forwards a fork source and ignores its working directories', async () => { + test('createChat forwards an exact title and fork source while ignoring working directories', async () => { stateManager.createSession(makeSessionSummary()); const transport = connectClient('client-cc'); transport.sent.length = 0; @@ -5303,6 +5307,7 @@ suite('ProtocolServerHandler', () => { transport.simulateMessage(request(2, 'createChat', { channel: sessionUri, chat: peerChat, + title: ' Exact fork title ', source: { kind: ChatSourceKind.Fork, chat: buildDefaultChatUri(sessionUri), turnId: 'turn-1' }, workingDirectories: [URI.file('/workspace').toString()], })); @@ -5317,6 +5322,7 @@ suite('ProtocolServerHandler', () => { session: sessionUri, chat: peerChat, options: { + title: ' Exact fork title ', fork: { source: URI.parse(buildDefaultChatUri(sessionUri)), turnId: 'turn-1' }, }, }], diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 4eb3f47a4ec6a8..00617d2a324501 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -235,7 +235,9 @@ A chat view binds its transcript to the content provider serving the chat's sess ### Multiple chats -Creating or forking a chat is a capability-gated provider operation routed by the management service. 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. +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. + +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/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 68471d1474c4bd..3c5f7bc68c9a25 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -117,6 +117,8 @@ The provider cache owns adapter identity. Catalog notifications describe members Provider-specific metadata such as pull-request provenance, changesets, agent configuration, and external visibility is translated inside this provider. Shared Sessions code consumes only provider-neutral fields and capabilities. +At its private AHP boundary, the provider reads the host's `multipleChats.initialTitle` capability when forwarding provider-neutral fork options. When support is advertised, it passes the requested title to `createChat`; when the capability is absent, it omits the title and performs a title-less fork. This AHP-specific decision is not projected through generic `ISessionCapabilities`. In both cases, the returned and opened chat is derived from the first host-published catalog summary, without an optimistic client title. + Each chat's read state is projected from its chat summary independently of the session aggregate. In a multi-chat session, any unread normal user-visible chat forces the aggregate unread; Tool/subagent chats are excluded. When the last unread participating chat becomes read, the host promotes the aggregate to read; repeated read actions do not clear an independently unread session. When supported by the negotiated protocol version, activating a chat or invoking Mark as Read or Mark as Unread on a session row dispatches the chat-level read-state action for the represented main chat and updates that chat facade optimistically; the host remains responsible for aggregate synchronization, persistence, and fan-out. Selectable Agent Host changesets have one catalogue owner for each scope. The default chat exists from initial draft creation and owns repository-preparation changes before provider materialization. After materialization, the session continues publishing its cumulative Session Changes entry while each chat owns its repository and turn catalogue. Peer chats also own a Session Changes entry scoped to the edits made in that chat; because Git checkpoints capture the working tree shared by every chat, it is computed from the chat's tracked edits. The client keeps a chat-owned Session Changes entry and otherwise projects the session-owned entry into the chat catalogue, so the default chat shows the cumulative summary across all chats while every chat retains chat-scoped Branch, Uncommitted, This Turn, and Compare entries. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostForkActions.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostForkActions.ts index cdd9bb734f08c0..e9dbc85961d5dd 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostForkActions.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostForkActions.ts @@ -15,6 +15,7 @@ import { IChatSessionRequestHistoryItem } from '../../../../../workbench/contrib import { isAgentHostProviderId } from '../../../../common/agentHostSessionsProvider.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import type { IForkChatOptions } from '../../../../services/sessions/common/sessionsProvider.js'; registerAction2(class extends ForkConversationAction { protected override async _tryForkAsChat(instantiationService: IInstantiationService, sourceSessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, options?: IForkConversationOptions): Promise { @@ -45,7 +46,8 @@ registerAction2(class extends ForkConversationAction { return false; } - const newChat = await sessionsManagementService.forkChatInSession(session, sourceSessionResource, turnId); + const forkOptions: IForkChatOptions | undefined = options?.title !== undefined ? { title: options.title } : undefined; + const newChat = await sessionsManagementService.forkChatInSession(session, sourceSessionResource, turnId, forkOptions); if (options?.toSide) { await sessionsService.openChatToSide(session, newChat.resource, { referenceChatResource: sourceSessionResource }); } else { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index d8b0f0fc547a6b..e40f626ee5b719 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -90,7 +90,7 @@ import { dedupeLinks, partitionSessionArtifacts, type IRecordedGitHubReference } import { getWorktreeDiskUsage } from './worktreeDiskUsage.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ISessionsRecentWorkspacesService } from '../../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; -import { IAutomationSessionConfiguration, IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionConfigurationSnapshot, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionPermissionOption, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../../../services/sessions/common/sessionsProvider.js'; +import { IAutomationSessionConfiguration, IDeleteChatOptions, type IForkChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionConfigurationSnapshot, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionPermissionOption, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../../../services/sessions/common/sessionsProvider.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; import { computePullRequestRefPresentation } from '../../../github/browser/pullRequestIconStatus.js'; import { IPullRequestIconCache } from '../../../github/browser/pullRequestIconCache.js'; @@ -6696,7 +6696,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return chat; } - async forkChat(sessionId: string, sourceChat: URI, turnId: string): Promise { + async forkChat(sessionId: string, sourceChat: URI, turnId: string, options?: IForkChatOptions): Promise { const connection = this.connection; if (!connection) { throw new Error(this._notConnectedSendErrorMessage()); @@ -6706,9 +6706,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!rawId || !cached) { throw new Error(`Session '${sessionId}' not found`); } - if (!cached.capabilities.get().supportsMultipleChats) { + const capabilities = cached.capabilities.get(); + if (!capabilities.supportsMultipleChats) { throw new Error(`Session '${sessionId}' does not support multiple chats`); } + const canSetInitialTitle = this._agentCapabilities.get()?.get(cached.agentProvider)?.multipleChats?.initialTitle === true; const sessionUri = cached.backendUri; const newChatId = generateUuid(); @@ -6730,6 +6732,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement await connection.createChat(sessionUri, chatUri, { model: selectedModel, fork: { source: sourceBackendUri, turnId }, + ...(canSetInitialTitle && options?.title !== undefined ? { title: options.title } : {}), }); const chat = await waitForState( diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostForkActions.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostForkActions.test.ts index 772ee053bf1512..98420b3cfa4fa0 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostForkActions.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostForkActions.test.ts @@ -49,6 +49,7 @@ suite('Agent Host fork actions', () => { let forkAvailable = true; let forkGate: Promise | undefined; const forkCalls: { kind: string; source: URI; requestId: string | undefined }[] = []; + const forkOptions: { readonly title?: string }[] = []; const opens: { kind: string; resource: URI; options?: IOpenSessionOptions & { referenceChatResource?: URI; referenceSessionId?: string } }[] = []; instantiationService.stub(ILogService, new NullLogService()); @@ -62,8 +63,9 @@ suite('Agent Host fork actions', () => { getSession: resource => isEqual(resource, source.resource) ? source : forkAvailable && isEqual(resource, forkChat.resource) ? forkSession : undefined, getSessionForChatResource: resource => isEqual(resource, sourceChat.resource) ? { session: source, chat: sourceChat } : undefined, onDidChangeSessions: onDidChangeSessions.event, - forkChatInSession: async (_session, source, turnId) => { + forkChatInSession: async (_session, source, turnId, options?: { readonly title?: string }) => { forkCalls.push({ kind: 'chat', source, requestId: turnId }); + forkOptions.push(options ?? {}); started.complete(); await forkGate; return forkChat; @@ -95,7 +97,7 @@ suite('Agent Host fork actions', () => { message: { text: 'second', parts: [] }, }); return { - source, sourceChat, forkChat, forkCalls, opens, started, activeSession, onDidChangeSessions, + source, sourceChat, forkChat, forkCalls, forkOptions, opens, started, activeSession, onDidChangeSessions, setForkGate: (gate: Promise) => forkGate = gate, setForkAvailable: (available: boolean) => { forkAvailable = available; @@ -103,8 +105,12 @@ suite('Agent Host fork actions', () => { onDidChangeSessions.fire({ added: [forkSession], removed: [], changed: [] }); } }, - run: async (toSide: boolean, slashCommand = false) => { - await instantiationService.invokeFunction(forkCommand.handler, slashCommand ? sourceChat.resource : toSide ? { element: checkpoint, toSide } : checkpoint); + run: async (toSide: boolean, slashCommand = false, title?: string) => { + await instantiationService.invokeFunction( + forkCommand.handler, + slashCommand ? sourceChat.resource : toSide ? { element: checkpoint, toSide } : checkpoint, + slashCommand && title !== undefined ? { title } : undefined, + ); }, }; } @@ -176,6 +182,13 @@ suite('Agent Host fork actions', () => { }); } + test('forwards the exact requested title when forking as a chat', async () => { + const harness = createHarness(true, true); + await harness.run(false, true, ' Exact fork title '); + + assert.deepStrictEqual(harness.forkOptions, [{ title: ' Exact fork title ' }]); + }); + test('waits for a separately forked session to appear before opening beside the source', async () => { const harness = createHarness(false); harness.setForkAvailable(false); 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 1edc4d17cfd338..28a49d262b8236 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 @@ -244,7 +244,7 @@ class MockAgentHostService extends mock() { } public createdChats: { session: URI; chat: URI; options?: IAgentCreateChatRequestOptions }[] = []; - public onCreateChat: ((session: URI, chat: URI) => void) | undefined; + public onCreateChat: ((session: URI, chat: URI, options?: IAgentCreateChatRequestOptions) => void) | undefined; override async createChat(session: URI, chat: URI, options?: IAgentCreateChatRequestOptions): Promise { this.createdChats.push({ session, chat, options }); const key = session.toString(); @@ -261,7 +261,7 @@ class MockAgentHostService extends mock() { chats: [...existing.chats, newChat], }); } - this.onCreateChat?.(session, chat); + this.onCreateChat?.(session, chat, options); } public createdSessionUris: URI[] = []; @@ -10594,7 +10594,8 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); - test('forkChat forwards the source chat and turn to the host and surfaces a new peer chat', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + test('forkChat sends a supported initial title and returns the host-published named peer chat', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.setAgents([{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true, initialTitle: true } } } as AgentInfo]); const provider = createProvider(disposables, agentHost); const session = setupMultiChatSession(provider, 'multi-fork'); const sessionUri = AgentSession.uri('copilotcli', 'multi-fork').toString(); @@ -10604,22 +10605,54 @@ suite('LocalAgentHostSessionsProvider', () => { makeChatSummary(defaultChat, ''), ], { defaultChat })); - const forked = await provider.forkChat(session.sessionId, session.resource, 'turn-1'); + const forked = await provider.forkChat(session.sessionId, session.resource, 'turn-1', { title: 'Named fork' }); const call = agentHost.createdChats.at(-1); assert.deepStrictEqual({ + title: call?.options?.title, forkSource: call?.options?.fork?.source.toString(), forkTurnId: call?.options?.fork?.turnId, + forkedTitle: forked.title.get(), forkedIsPeer: !!forked.resource.fragment, forkedInCatalog: session.chats.get().some(c => c.resource.toString() === forked.resource.toString()), }, { + title: 'Named fork', forkSource: defaultChat, forkTurnId: 'turn-1', + forkedTitle: 'Named fork', forkedIsPeer: true, forkedInCatalog: true, }); })); + test('forkChat omits an unsupported initial title and returns the host-generated 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); + const session = setupMultiChatSession(provider, 'multi-fork-no-title'); + const sessionUri = AgentSession.uri('copilotcli', 'multi-fork-no-title').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + agentHost.setSessionState('multi-fork-no-title', 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + ], { defaultChat })); + agentHost.onCreateChat = (sessionUri, chatUri) => { + agentHost.setSessionState('multi-fork-no-title', 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + makeChatSummary(chatUri.toString(), 'Host-generated fork'), + ], { defaultChat: buildDefaultChatUri(sessionUri) })); + }; + + const forked = await provider.forkChat(session.sessionId, session.resource, 'turn-1', { title: 'Ignored title' }); + const call = agentHost.createdChats.at(-1); + + assert.deepStrictEqual({ + title: call?.options?.title, + forkedTitle: forked.title.get(), + }, { + title: undefined, + forkedTitle: 'Host-generated fork', + }); + })); + test('forkChat inherits the source peer chat\'s model, recorded as inherited', () => runWithFakedTimers({ useFakeTimers: true }, async () => { // A fork continues the chat it was taken from, so it starts on that chat's model rather // than the session-level default it used to take, and records it as carried over rather diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index c162ab464b6641..32d892407ba2e6 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -26,7 +26,7 @@ import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uri import { getSessionReferenceResource } from './sessionReference.js'; import { IChatDeletedEvent, ICreateNewChatInSessionOptions, ICreateNewSessionOptions, IDeferredNewSessionRequestOptions, IMarkSessionReadOptions, IProviderSessionType, ISendRequestOptions, ISendRequestSentEvent, ISessionsChangeEvent, ISessionsManagementService, NewSessionRequestOptions, WorkspaceNotTrustedError } from '../common/sessionsManagement.js'; import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from './sessionsProvidersService.js'; -import { IDeleteChatOptions, IPreparedNewSession, ISessionChangeEvent, ISessionsProvider, type ISessionsProviderCreateSessionOptions, type SessionResourceResolveReason } from '../common/sessionsProvider.js'; +import { IDeleteChatOptions, type IForkChatOptions, IPreparedNewSession, ISessionChangeEvent, ISessionsProvider, type ISessionsProviderCreateSessionOptions, type SessionResourceResolveReason } from '../common/sessionsProvider.js'; import { ChatModelSource, IChat, ISession, ISessionCanvasDefinition, ISessionWorkspace, ISideChatSelection, isActiveSessionStatus, SessionStatus, ISessionType } from '../common/session.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -719,7 +719,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return created; } - async forkChatInSession(session: ISession, sourceChat: URI, turnId: string): Promise { + async forkChatInSession(session: ISession, sourceChat: URI, turnId: string, options?: IForkChatOptions): Promise { const provider = this._getProvider(session); if (!provider) { throw new Error(`Provider '${session.providerId}' not found for session '${session.sessionId}'`); @@ -727,7 +727,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa if (!session.capabilities.get().supportsMultipleChats) { throw new Error(`Session '${session.sessionId}' does not support forking into a chat`); } - return provider.forkChat(session.sessionId, sourceChat, turnId); + return provider.forkChat(session.sessionId, sourceChat, turnId, options); } async createSideChatInSession(session: ISession, sourceChat: URI, turnId: string, selection?: ISideChatSelection): Promise { diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index 5a63c484119de8..69e58b6db4bd2e 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -10,7 +10,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, ISessionCanvasDefinition, ISessionCreationReference, ISessionType, ISessionWorkspace, ISideChatSelection } from './session.js'; -import { IAutomationSessionConfiguration, IDeleteChatOptions, ISessionConfigurationSnapshot, ISendRequestOptions as ISessionsProviderSendRequestOptions, type SessionResourceResolveReason } from './sessionsProvider.js'; +import { IAutomationSessionConfiguration, IDeleteChatOptions, type IForkChatOptions, ISessionConfigurationSnapshot, ISendRequestOptions as ISessionsProviderSendRequestOptions, type SessionResourceResolveReason } from './sessionsProvider.js'; /** Raised when unattended session creation targets a workspace that requires trust. */ export class WorkspaceNotTrustedError extends Error { @@ -474,13 +474,14 @@ export interface ISessionsManagementService { /** * Fork an existing chat into a new chat within the same session, seeded * with the source chat's history up to and including the given turn. Used - * for the fork gesture on sessions that support multiple chats. + * for the fork gesture on sessions that support forking. * * @param session The session containing the source chat. * @param sourceChat The resource URI of the chat to fork from. * @param turnId The ID of the last turn (request) to include in the fork. + * @param options Optional fork settings. */ - forkChatInSession(session: ISession, sourceChat: URI, turnId: string): Promise; + forkChatInSession(session: ISession, sourceChat: URI, turnId: string, options?: IForkChatOptions): Promise; /** * Create a side chat from an existing chat's turn, inheriting the source diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index e007b3a3e93c3a..2f89c677638e96 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -79,6 +79,11 @@ export interface ISendRequestOptions { readonly hideFromTranscript?: boolean; } +/** Options for forking a chat within a session. */ +export interface IForkChatOptions { + readonly title?: string; +} + /** Provider options applied when creating a new session draft. */ export interface ISessionsProviderCreateSessionOptions { /** Whether this draft configures an Automation rather than an interactive session. */ @@ -581,8 +586,9 @@ export interface ISessionsProvider { * @param sessionId The ID of the session containing the source chat. * @param sourceChat The resource URI of the chat to fork from. * @param turnId The ID of the last turn (request) to include in the fork. + * @param options Optional fork settings. */ - forkChat(sessionId: string, sourceChat: URI, turnId: string): Promise; + forkChat(sessionId: string, sourceChat: URI, turnId: string, options?: IForkChatOptions): Promise; /** * Create a side chat from an existing chat's turn, inheriting the source 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 e2c49db472e5f3..7c3d8dad92851f 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -43,7 +43,7 @@ import { TestPathService } from '../../../../../workbench/test/browser/workbench 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 { ISessionChangeEvent, ISendRequestOptions, ISessionModelsSnapshot, ISessionModelPickerOptions, ISessionsProvider, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../common/sessionsProvider.js'; +import { type IForkChatOptions, ISessionChangeEvent, ISendRequestOptions, ISessionModelsSnapshot, ISessionModelPickerOptions, ISessionsProvider, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../common/sessionsProvider.js'; import { SessionsManagementService } from '../../browser/sessionsManagementService.js'; import { ISessionsManagementService, IActiveSession, IChatDeletedEvent, ICreateNewSessionOptions, inheritableSessionTarget, ISendRequestSentEvent, WorkspaceNotTrustedError } from '../../common/sessionsManagement.js'; import { SessionsService } from '../../browser/sessionsService.js'; @@ -6167,28 +6167,33 @@ suite('SessionsManagementService', () => { suite('forkChatInSession', () => { - test('asks the provider to fork the chat when the session supports multiple chats', async () => { + test('asks the provider to fork the chat with options when the session supports multiple chats', async () => { const sourceChat = URI.parse('test:///source'); const forkedChat: IChat = { ...stubChat, resource: URI.parse('test:///forked') }; const session = stubSession({ sessionId: 'fork', providerId: 'test', capabilities: constObservable({ supportsMultipleChats: true }) }); - let forkChatArgs: readonly [string, URI, string] | undefined; + let forkChatArgs: readonly [string, URI, string, IForkChatOptions | undefined] | undefined; const provider = new class extends TestSessionsProvider { constructor() { super(session); } - override async forkChat(sessionId: string, sourceChat: URI, turnId: string): Promise { - forkChatArgs = [sessionId, sourceChat, turnId]; + override async forkChat(sessionId: string, sourceChat: URI, turnId: string, options?: IForkChatOptions): Promise { + forkChatArgs = [sessionId, sourceChat, turnId, options]; return forkedChat; } }; const { service } = createSessionsManagementService(session, disposables, provider); - const result = await service.forkChatInSession(session, sourceChat, 'turn-1'); + const result = await service.forkChatInSession(session, sourceChat, 'turn-1', { title: 'Named fork' }); assert.deepStrictEqual({ result: result.resource.toString(), - args: forkChatArgs?.map(arg => URI.isUri(arg) ? arg.toString() : arg), + args: forkChatArgs && [ + forkChatArgs[0], + forkChatArgs[1].toString(), + forkChatArgs[2], + forkChatArgs[3], + ], }, { result: forkedChat.resource.toString(), - args: ['fork', sourceChat.toString(), 'turn-1'], + args: ['fork', sourceChat.toString(), 'turn-1', { title: 'Named fork' }], }); }); @@ -6206,6 +6211,7 @@ suite('SessionsManagementService', () => { await assert.rejects(() => service.forkChatInSession(session, URI.parse('test:///source'), 'turn-1'), /does not support forking into a chat/); }); + }); suite('createSideChatInSession', () => { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index ac9344e0873395..5248a58fc8918e 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -263,6 +263,9 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui if (!isSessionsWindow && (type === 'panelChat' || type === 'editsView' || type === 'agentView')) { content.push(localize('chat.renameSession', 'To rename the current chat session when supported, invoke the Rename command{0}. Agent Host sessions can be renamed after sending the first request.', ``)); } + if (type === 'panelChat' || type === 'editsView' || type === 'agentView') { + content.push(localize('chat.forkSession', "In a fork-capable Agent Host chat, to create a new forked chat alongside the current chat in the same Agent Host session, type /fork followed by a title. When the connected Agent Host supports named forks, the new chat uses that title. Agent Hosts that support forking but not named titles use their normal title for the forked chat. Accepting /fork from slash-command completion creates a bare forked chat immediately. To add a title, type or insert the command instead.")); + } if (type !== 'inlineChat') { content.push(localize('chat.linkContextMenu', "Focus a link in the chat transcript and press Shift+F10 to open its context menu. Use Copy Link to copy its target. File links with additional editor choices also provide Open With.")); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts index d421d5a6330ec0..8912af24b3dd01 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts @@ -16,14 +16,14 @@ import { ChatContextKeyExprs, ChatContextKeys } from '../../common/actions/chatC import { IChatService, ResponseModelState } from '../../common/chatService/chatService.js'; import type { ISerializableChatData } from '../../common/model/chatModel.js'; import { isChatTreeItem, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; -import { IChatSessionRequestHistoryItem, IChatSessionsService } from '../../common/chatSessionsService.js'; +import { IChatSessionForkOptions, IChatSessionRequestHistoryItem, IChatSessionsService } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { CHAT_CATEGORY } from './chatActions.js'; import { ChatTreeItem, ChatViewPaneTarget, IChatWidgetService } from '../chat.js'; export const ForkConversationActionId = 'workbench.action.chat.forkConversation'; -export interface IForkConversationOptions { +export interface IForkConversationOptions extends IChatSessionForkOptions { readonly toSide?: boolean; } @@ -67,14 +67,15 @@ export class ForkConversationAction extends Action2 { // Fork at the last request in that session. if (URI.isUri(args[0])) { const sourceSessionResource = args[0]; + const options = typeof args[1] === 'object' && args[1] !== null ? args[1] as IForkConversationOptions : undefined; // Check if this is a contributed session that supports forking const contentProviderSchemes = chatSessionsService.getContentProviderSchemes(); if (contentProviderSchemes.includes(getChatSessionType(sourceSessionResource))) { - if (await this._tryForkAsChat(instantiationService, sourceSessionResource, undefined)) { + if (await this._tryForkAsChat(instantiationService, sourceSessionResource, undefined, options)) { return; } - return await this.forkContributedChatSession(sourceSessionResource, undefined, false, chatSessionsService, instantiationService); + return await this.forkContributedChatSession(sourceSessionResource, undefined, false, chatSessionsService, instantiationService, options); } const chatModel = chatService.getSession(sourceSessionResource); @@ -267,7 +268,9 @@ export class ForkConversationAction extends Action2 { private pendingFork = new Map>(); private async forkContributedChatSession(sourceSessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, openForkedSessionImmediately: boolean, chatSessionsService: IChatSessionsService, instantiationService: IInstantiationService, options?: IForkConversationOptions) { - const pendingKey = `${sourceSessionResource.toString()}@${request?.id ?? 'full'}`; + const title = options?.title?.trim() || undefined; + const forkOptions: IChatSessionForkOptions | undefined = title ? { title } : undefined; + const pendingKey = JSON.stringify([sourceSessionResource.toString(), request?.id, title]); const pending = this.pendingFork.get(pendingKey); if (pending) { return pending; @@ -276,7 +279,7 @@ export class ForkConversationAction extends Action2 { const forkPromise = (async () => { const cts = new CancellationTokenSource(); try { - const forkedItem = await chatSessionsService.forkChatSession(sourceSessionResource, request, cts.token); + const forkedItem = await chatSessionsService.forkChatSession(sourceSessionResource, request, cts.token, forkOptions); const open = () => this._openForkedSession(instantiationService, sourceSessionResource, forkedItem.resource, options); if (openForkedSessionImmediately) { await open(); 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 1bbfbad414d660..c5266cb2d423f6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -103,7 +103,7 @@ import { coerceImageBuffer } from '../../../common/chatImageExtraction.js'; import { ChatErrorLevel, ChatRequestQueueKind, ConfirmedReason, ElicitationState, IChatProgress, IChatQuestionAnswers, IChatService, IChatToolInvocation, IRemotePendingRequest, ToolConfirmKind, type IChatAutoModeResolutionPart, type IChatMcpAuthenticationRequired, type IChatMcpAuthenticationRequiredServer, type IChatMcpStartingServer, type IChatMultiSelectAnswer, type IChatPlanReviewResult, type IChatResponseErrorDetails, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData, type IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; import { isInConversationModelChoice } from '../../../common/modelSelection.js'; import { readAgentRuntimeModelConfiguration } from '../../../../../../platform/agentHost/common/meta/agentModelConfigurationMeta.js'; -import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, isTerminalCommandPrompt, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult, type IChatSessionServerRequest } from '../../../common/chatSessionsService.js'; +import { IChatSession, IChatSessionContentProvider, IChatSessionForkOptions, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, isTerminalCommandPrompt, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult, type IChatSessionServerRequest } from '../../../common/chatSessionsService.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IWorkingCopyService } from '../../../../../services/workingCopy/common/workingCopyService.js'; import { ChatMode } from '../../../common/chatModes.js'; @@ -799,7 +799,7 @@ class AgentHostChatSession extends Disposable implements IChatSession { canvasProvider: AgentHostCanvasCollection | undefined, private readonly _promptCacheNotification: AgentHostPromptCacheNotification | undefined, refreshChat: (() => Promise) | undefined, - private readonly _forkSession: ((request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken) => Promise), + private readonly _forkSession: NonNullable, private readonly _renameSession: ((title: string, token: CancellationToken) => Promise), inputState: ISerializableChatModelInputState | undefined, initialProgress: IChatProgress[] | undefined, @@ -1776,12 +1776,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._config.connection.refreshSubscription ? () => this._config.connection.refreshSubscription!(URI.parse(this._getChatURIOrDefault(sessionResource, resolvedSession))) : undefined, - (request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken) => { + (request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken, options?: IChatSessionForkOptions) => { if (!this._getSessionState(resolvedSession.toString())) { throw new Error('Cannot fork session before the initial request'); } - return this._forkSession(sessionResource, resolvedSession, request, token); + return this._forkSession(sessionResource, resolvedSession, request, token, options); }, (title: string, _token: CancellationToken) => { this._config.connection.dispatch(this._getChatURIOrDefault(sessionResource, resolvedSession), { @@ -5927,6 +5927,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC backendSession: URI, request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken, + options?: IChatSessionForkOptions, ): Promise { if (token.isCancellationRequested) { throw new Error('Cancelled'); @@ -5942,6 +5943,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (!rawSessionState) { throw new Error(`Cannot fork: session state is not hydrated for ${sessionUri}`); } + const supportsInitialTitle = agentInfo.capabilities.multipleChats.initialTitle === true; // Fork the chat the gesture came from — a peer chat when the resource // carries a chat fragment, else the session's default chat. const sourceChat = this._resolveChatUriFromState(sessionResource, rawSessionState); @@ -5978,6 +5980,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC await this._config.connection.createChat(backendSession, forkedChat, { model: lastTurnModelSelection(protocolState), fork: { source: URI.parse(sourceChat), turnId }, + ...(supportsInitialTitle && options?.title !== undefined ? { title: options.title } : {}), }); // The chat is only addressable once the host has published it in the diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index b1254bc49eba26..b8f14fc7e5e36c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -32,7 +32,7 @@ import { ExtensionsRegistry } from '../../../../services/extensions/common/exten import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentService } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { ChatSessionOptionsMap, ChatSessionStatus, ChatSessionsExtensions, IAsyncChatSessionActivationRegistry, IChatNewSessionRequest, IChatSession, IChatSessionCommitEvent, IChatSessionContentProvider, IChatSessionCustomizationItemGroup, IChatSessionCustomizationsProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsChangeEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, IChatInputCompletionsParams, IChatInputCompletionsResult, isSessionInProgressStatus, localChatSessionType, ReadonlyChatSessionOptionsMap, ResolvedChatSessionsExtensionPoint, SessionType } from '../../common/chatSessionsService.js'; +import { ChatSessionOptionsMap, ChatSessionStatus, ChatSessionsExtensions, IAsyncChatSessionActivationRegistry, IChatNewSessionRequest, IChatSession, IChatSessionCommitEvent, IChatSessionContentProvider, IChatSessionCustomizationItemGroup, IChatSessionCustomizationsProvider, IChatSessionForkOptions, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsChangeEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, IChatInputCompletionsParams, IChatInputCompletionsResult, isSessionInProgressStatus, localChatSessionType, ReadonlyChatSessionOptionsMap, ResolvedChatSessionsExtensionPoint, SessionType } from '../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; @@ -1645,14 +1645,14 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return !!session?.session.forkSession; } - public async forkChatSession(sessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken): Promise { + public async forkChatSession(sessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken, options?: IChatSessionForkOptions): Promise { const session = this._sessions.get(sessionResource) // Try to resolve in case an alias was used ?? this._sessions.get(this._resolveResource(sessionResource)); if (!session?.session.forkSession) { throw new Error(`Session ${sessionResource.toString()} does not support forking`); } - return session.session.forkSession(request, token); + return session.session.forkSession(request, token, options); } public sessionSupportsRename(sessionResource: URI): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 36d7211b43ec0c..a0070dba879e60 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -232,8 +232,9 @@ export class ChatSlashCommandsContribution extends Disposable { ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeys.chatSessionSupportsFork ), - }, async (_prompt, _progress, _history, _location, sessionResource) => { - await commandService.executeCommand('workbench.action.chat.forkConversation', sessionResource); + }, async (prompt, _progress, _history, _location, sessionResource) => { + const title = prompt.trim() || undefined; + await commandService.executeCommand('workbench.action.chat.forkConversation', sessionResource, title ? { title } : undefined); })); this._store.add(slashCommandService.registerSlashCommand({ command: 'rename', diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 0c678d574fa0ee..98af016ac0e17d 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -460,6 +460,10 @@ export function getAgentHostProviderForTelemetry(sessionType: string | undefined */ export const localChatSessionType = SessionType.Local; +export interface IChatSessionForkOptions { + readonly title?: string; +} + export interface IChatSession extends IDisposable { readonly onWillDispose: Event; @@ -520,9 +524,10 @@ export interface IChatSession extends IDisposable { * Forks the session from the given request point. * @param request The request history item to fork from, or undefined to fork from the end. * @param token Cancellation token. + * @param options Optional fork options. * @returns The forked session item. The promise is rejected if forking fails. */ - forkSession?: (request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken) => Promise; + forkSession?: (request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken, options?: IChatSessionForkOptions) => Promise; /** * Renames the session. @@ -1032,9 +1037,10 @@ export interface IChatSessionsService { * @param sessionResource The session resource to fork. * @param request The request history item to fork from, or undefined to fork from the end. * @param token Cancellation token. + * @param options Optional fork options. * @returns The forked session item, or undefined if forking failed. */ - forkChatSession(sessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken): Promise; + forkChatSession(sessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken, options?: IChatSessionForkOptions): Promise; /** * Returns whether the loaded session supports renaming. diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts index abccaba5f0fe2c..b7ba42f12998c4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts @@ -599,4 +599,57 @@ suite('Chat Accessibility Help', () => { sessionsWindow: false, }); }); + + test('documents qualified named fork behavior on persistent chat surfaces', () => { + const keybindingService = new MockKeybindingService(); + const getForkHelp = (type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView', isSessionsWindow = false) => { + const help = getAccessibilityHelpText(type, keybindingService, true, isSessionsWindow); + return { + forkCapableAgentHostChat: help.includes('In a fork-capable Agent Host chat'), + commandWithTitle: help.includes('type /fork followed by a title'), + sameSessionPeerChat: help.includes('new forked chat alongside the current chat in the same Agent Host session'), + supportedHost: help.includes('When the connected Agent Host supports named forks, the new chat uses that title'), + unnamedForkHostFallback: help.includes('Agent Hosts that support forking but not named titles use their normal title for the forked chat'), + immediateBareFork: help.includes('Accepting /fork from slash-command completion creates a bare forked chat immediately'), + insertToTitle: help.includes('To add a title, type or insert the command instead'), + doesNotDescribeNewSession: !help.includes('new session') && !help.includes('fork an Agent Host session'), + }; + }; + const documented = { + forkCapableAgentHostChat: true, + commandWithTitle: true, + sameSessionPeerChat: true, + supportedHost: true, + unnamedForkHostFallback: true, + immediateBareFork: true, + insertToTitle: true, + doesNotDescribeNewSession: true, + }; + const notDocumented = { + forkCapableAgentHostChat: false, + commandWithTitle: false, + sameSessionPeerChat: false, + supportedHost: false, + unnamedForkHostFallback: false, + immediateBareFork: false, + insertToTitle: false, + doesNotDescribeNewSession: true, + }; + + assert.deepStrictEqual({ + panelChat: getForkHelp('panelChat'), + agentView: getForkHelp('agentView'), + editsView: getForkHelp('editsView'), + quickChat: getForkHelp('quickChat'), + inlineChat: getForkHelp('inlineChat'), + sessionsWindow: getForkHelp('agentView', true), + }, { + panelChat: { ...documented }, + agentView: { ...documented }, + editsView: { ...documented }, + quickChat: { ...notDocumented }, + inlineChat: { ...notDocumented }, + sessionsWindow: { ...documented }, + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts index fd3d0608b133d5..51783854a72192 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts @@ -5,19 +5,33 @@ import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; +import { IAgentHostUntitledProvisionalSessionService } from '../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; +import { IAgentSessionsService } from '../../../browser/agentSessions/agentSessionsService.js'; import { ForkConversationAction, IForkConversationOptions } from '../../../browser/actions/chatForkActions.js'; import { ChatViewPaneTarget, IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; +import { IChatPetService } from '../../../browser/chatPetService.js'; +import { ChatSlashCommandsContribution } from '../../../browser/chatSlashCommands.js'; import { IChatEditorOptions } from '../../../browser/widgetHosts/editor/chatEditor.js'; import { IChatModelReference, IChatService } from '../../../common/chatService/chatService.js'; import { IChatSession, IChatSessionItem, IChatSessionRequestHistoryItem, IChatSessionsService } from '../../../common/chatSessionsService.js'; import { ChatAgentLocation, SessionTypeSelectionReason } from '../../../common/constants.js'; import { IChatModel, IChatRequestModel, ISerializableChatData } from '../../../common/model/chatModel.js'; import { IChatRequestViewModel, IChatViewModel } from '../../../common/model/chatViewModel.js'; +import { IChatAgentService } from '../../../common/participants/chatAgents.js'; +import { IChatSlashCallback, IChatSlashCommandService, IChatSlashData } from '../../../common/participants/chatSlashCommands.js'; class TestForkConversationAction extends ForkConversationAction { openForkedSession(instantiationService: TestInstantiationService, parentSessionResource: URI, forkedSessionResource: URI, options?: IForkConversationOptions): Promise { @@ -28,6 +42,132 @@ class TestForkConversationAction extends ForkConversationAction { suite('ForkConversationAction', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('trims only the fork slash command title', async () => { + let forkCommand: IChatSlashCallback | undefined; + let forkData: IChatSlashData | undefined; + const commandCalls: { readonly resource: URI; readonly options: object | undefined }[] = []; + const slashCommandService = new class extends mock() { + override registerSlashCommand(data: IChatSlashData, callback: IChatSlashCallback) { + if (data.command === 'fork') { + forkData = data; + forkCommand = callback; + } + return Disposable.None; + } + }(); + const commandService = upcastPartial({ + executeCommand: async (_commandId: string, resource: URI, options?: object) => { + commandCalls.push({ resource, options }); + return undefined; + }, + }); + const configurationService = new TestConfigurationService(); + disposables.add(new ChatSlashCommandsContribution( + slashCommandService, + commandService, + new class extends mock() { }(), + disposables.add(new TestInstantiationService()), + new class extends mock() { }(), + new class extends mock() { }(), + configurationService, + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { override readonly isSessionsWindow = false; }(), + )); + const resource = URI.parse('contributed:/source'); + + assert.ok(forkCommand); + for (const prompt of ['', ' ', ' My title. ']) { + await forkCommand(prompt, { report: () => { } }, [], ChatAgentLocation.Chat, resource, CancellationToken.None); + } + + assert.deepStrictEqual({ + executeImmediately: forkData?.executeImmediately, + detailIncludesNewChatSession: forkData?.detail.includes('new chat session'), + commandCalls, + }, { + executeImmediately: true, + detailIncludesNewChatSession: true, + commandCalls: [ + { resource, options: undefined }, + { resource, options: undefined }, + { resource, options: { title: 'My title.' } }, + ], + }); + }); + + test('forwards a title from a URI fork through contributed hooks', async () => { + const instantiationService = disposables.add(new TestInstantiationService()); + const source = URI.parse('contributed:/source'); + const fork = URI.parse('contributed:/fork'); + const calls: { resource: URI; options: object | undefined }[] = []; + const hooks: (IForkConversationOptions | undefined)[] = []; + instantiationService.stub(IChatService, upcastPartial({})); + instantiationService.stub(IChatWidgetService, upcastPartial({})); + instantiationService.stub(IChatSessionsService, upcastPartial({ + getContentProviderSchemes: () => [source.scheme], + sessionSupportsFork: () => true, + forkChatSession: async (resource, _request, _token, options?: object) => { + calls.push({ resource, options }); + return { resource: fork, label: 'Fork', timing: { created: 0, lastRequestStarted: undefined, lastRequestEnded: undefined } }; + }, + })); + const action = new class extends ForkConversationAction { + protected override async _tryForkAsChat(_instantiationService: IInstantiationService, _source: URI, _request: IChatSessionRequestHistoryItem | undefined, options?: IForkConversationOptions): Promise { + hooks.push(options); + return false; + } + protected override async _openForkedSession(): Promise { } + }; + + await instantiationService.invokeFunction(accessor => action.run(accessor, source, { title: 'My title' })); + + assert.deepStrictEqual({ calls, hooks }, { + calls: [{ resource: source, options: { title: 'My title' } }], + hooks: [{ title: 'My title' }], + }); + }); + + test('uses the requested title in contributed pending fork identity', async () => { + const instantiationService = disposables.add(new TestInstantiationService()); + const source = URI.parse('contributed:/source'); + const calls: (object | undefined)[] = []; + const gates: DeferredPromise[] = []; + const started = [new DeferredPromise(), new DeferredPromise()]; + instantiationService.stub(IChatService, upcastPartial({})); + instantiationService.stub(IChatWidgetService, upcastPartial({})); + instantiationService.stub(IChatSessionsService, upcastPartial({ + getContentProviderSchemes: () => [source.scheme], + sessionSupportsFork: () => true, + forkChatSession: async (_resource, _request, _token, options?: object) => { + calls.push(options); + const gate = new DeferredPromise(); + gates.push(gate); + started[calls.length - 1].complete(); + return gate.p; + }, + })); + const action = new class extends ForkConversationAction { + protected override async _openForkedSession(): Promise { } + }; + + const first = instantiationService.invokeFunction(accessor => action.run(accessor, source, { title: 'First' })); + await started[0].p; + const second = instantiationService.invokeFunction(accessor => action.run(accessor, source, { title: 'Second' })); + await started[1].p; + const duplicate = instantiationService.invokeFunction(accessor => action.run(accessor, source, { title: 'First' })); + await timeout(0); + gates[0].complete({ resource: URI.parse('contributed:/fork-1'), label: 'Fork 1', timing: { created: 0, lastRequestStarted: undefined, lastRequestEnded: undefined } }); + gates[1].complete({ resource: URI.parse('contributed:/fork-2'), label: 'Fork 2', timing: { created: 0, lastRequestStarted: undefined, lastRequestEnded: undefined } }); + await Promise.all([first, second, duplicate]); + + assert.deepStrictEqual(calls, [{ title: 'First' }, { title: 'Second' }]); + }); + test('opens a fork with the current session selection reason', async () => { const instantiationService = disposables.add(new TestInstantiationService()); const parentSessionResource = URI.parse('vscode-chat-session://parent'); @@ -134,16 +274,17 @@ suite('ForkConversationAction', () => { const request: IChatSessionRequestHistoryItem = { type: 'request', id: checkpoint.id, prompt: 'second', participant: '' }; const gate = new DeferredPromise(); const started = new DeferredPromise(); - const calls: { resource: URI; request: IChatSessionRequestHistoryItem | undefined }[] = []; + const calls: { resource: URI; request: IChatSessionRequestHistoryItem | undefined; options: object | undefined }[] = []; const hooks: { source: URI; request: IChatSessionRequestHistoryItem | undefined; options: IForkConversationOptions | undefined }[] = []; const opens: { source: URI; resource: URI; options: IForkConversationOptions | undefined }[] = []; instantiationService.stub(IChatService, upcastPartial({})); instantiationService.stub(IChatWidgetService, upcastPartial({ getWidgetBySessionResource: () => undefined })); instantiationService.stub(IChatSessionsService, upcastPartial({ getContentProviderSchemes: () => [source.scheme], + sessionSupportsFork: () => true, getOrCreateChatSession: async () => upcastPartial({ history: [request] }), - forkChatSession: async (resource, request) => { - calls.push({ resource, request }); + forkChatSession: async (resource, request, _token, options) => { + calls.push({ resource, request, options }); started.complete(); return gate.p; }, @@ -165,7 +306,7 @@ suite('ForkConversationAction', () => { await Promise.all([first, second]); assert.deepStrictEqual({ calls, hooks, opens }, { - calls: [{ resource: source, request }], + calls: [{ resource: source, request, options: undefined }], hooks: [{ source, request, options: { toSide: true } }, { source, request, options: undefined }], opens: [{ source, resource: fork, options: { toSide: true } }], }); 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 bee54f145b73cd..92c25f3ce99bd4 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 @@ -39,7 +39,7 @@ import { AgentHostProtocolClient } from '../../../../../../platform/agentHost/br import { IAgentHostResourceService } from '../../../../../../platform/agentHost/common/agentHostResourceService.js'; import { type IProtocolTransport } from '../../../../../../platform/agentHost/common/state/sessionTransport.js'; import { PROTOCOL_VERSION } from '../../../../../../platform/agentHost/common/state/protocol/version/registry.js'; -import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentHostMcpToolRoutingEnabledSettingId, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentCreateChatRequestOptions, IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentHostMcpToolRoutingEnabledSettingId, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; import { CanvasState } from '../../../../../../platform/agentHost/common/state/protocol/channels-canvas/state.js'; import { ICanvas } from '../../../../canvases/common/canvas.js'; import type { ChatInputRequestWithPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; @@ -267,6 +267,8 @@ class MockAgentHostService extends mock() { private _nextId = 1; private readonly _sessions = new Map(); public createSessionCalls: IAgentCreateSessionConfig[] = []; + public createChatCalls: { session: URI; chat: URI; options: IAgentCreateChatRequestOptions | undefined }[] = []; + public nextCreatedChatTitle: string | undefined; public defaultChatUri: string | undefined; public disposedChats: URI[] = []; public disposedSessions: URI[] = []; @@ -402,6 +404,25 @@ class MockAgentHostService extends mock() { return session; } + override async createChat(session: URI, chat: URI, options?: IAgentCreateChatRequestOptions): Promise { + this.createChatCalls.push({ session, chat, options }); + this.fireAction({ + channel: session.toString(), + action: { + type: ActionType.SessionChatAdded, + summary: { + resource: chat.toString(), + title: this.nextCreatedChatTitle ?? 'Generated fork title', + status: SessionStatus.Idle, + modifiedAt: new Date().toISOString(), + origin: { kind: ChatOriginKind.User }, + }, + }, + serverSeq: this._nextSeq++, + origin: undefined, + }); + } + override async disposeChat(chat: URI): Promise { this.disposedChats.push(chat); } override async disposeSession(session: URI): Promise { this.disposedSessions.push(session); } async shutdown(): Promise { } @@ -6183,6 +6204,97 @@ suite('AgentHostChatContribution', () => { }); }); + // ---- Forking --------------------------------------------------------- + + suite('forking', () => { + async function forkSession(initialTitle: boolean | undefined, requestedTitle: string, publishedTitle: string) { + const { sessionHandler, agentHostService } = createContribution(disposables); + const backendSession = AgentSession.uri('copilot', `fork-${initialTitle === true ? 'supported' : 'legacy'}`); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: backendSession.path }); + const sourceChat = buildDefaultChatUri(backendSession.toString()); + const summary: SessionSummary = { + resource: backendSession.toString(), + provider: 'copilot', + title: 'Source chat', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }; + agentHostService.setRootState({ + agents: [{ + provider: 'copilot', + displayName: 'Agent Host - Copilot', + description: 'test', + models: [], + capabilities: { multipleChats: { fork: true, initialTitle } }, + }], + activeSessions: 0, + }); + agentHostService.sessionStates.set(backendSession.toString(), { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + defaultChat: sourceChat, + chats: [createDefaultChatSummary(summary, sourceChat)], + turns: [{ + id: 'source-turn', + state: TurnState.Complete, + message: { text: 'Source request', origin: { kind: MessageKind.User } }, + responseParts: [], + usage: undefined, + }], + }); + agentHostService.nextCreatedChatTitle = publishedTitle; + + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(chatSession); + const forkedItem = await chatSession.forkSession?.(undefined, CancellationToken.None, { title: requestedTitle }); + + return { + call: agentHostService.createChatCalls[0], + forkedItem, + sourceChat, + }; + } + + test('forwards a requested title when the host advertises initial title support and returns the host summary label', async () => { + const requestedTitle = 'Requested fork title'; + const result = await forkSession(true, requestedTitle, requestedTitle); + + assert.deepStrictEqual({ + session: result.call.session.toString(), + options: result.call.options, + label: result.forkedItem?.label, + }, { + session: 'copilot:/fork-supported', + options: { + model: undefined, + fork: { source: URI.parse(result.sourceChat), turnId: 'source-turn' }, + title: requestedTitle, + }, + label: requestedTitle, + }); + }); + + test('omits a requested title for a capability-absent host and returns its generated summary label', async () => { + const result = await forkSession(undefined, 'Ignored fork title', 'Host-generated fork title'); + + assert.deepStrictEqual({ + session: result.call.session.toString(), + options: result.call.options, + label: result.forkedItem?.label, + }, { + session: 'copilot:/fork-legacy', + options: { + model: undefined, + fork: { source: URI.parse(result.sourceChat), turnId: 'source-turn' }, + }, + label: 'Host-generated fork title', + }); + }); + + + }); + // ---- Multi-root customization scope --------------------------------- suite('multi-root customization scope', () => { diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index b9331147ede382..76bf480092eb47 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -9,7 +9,7 @@ import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; -import { ReadonlyChatSessionOptionsMap, IChatNewSessionRequest, IChatSession, IChatSessionCommitEvent, IChatSessionContentProvider, IChatSessionCustomizationItemGroup, IChatSessionCustomizationsProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsChangeEvent, IChatSessionProviderOptionGroup, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, ResolvedChatSessionsExtensionPoint, ChatSessionOptionsMap, IChatInputCompletionsParams, IChatInputCompletionsResult } from '../../common/chatSessionsService.js'; +import { ReadonlyChatSessionOptionsMap, IChatNewSessionRequest, IChatSession, IChatSessionCommitEvent, IChatSessionContentProvider, IChatSessionCustomizationItemGroup, IChatSessionCustomizationsProvider, IChatSessionForkOptions, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsChangeEvent, IChatSessionProviderOptionGroup, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, ResolvedChatSessionsExtensionPoint, ChatSessionOptionsMap, IChatInputCompletionsParams, IChatInputCompletionsResult } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { IChatAgentAttachmentCapabilities } from '../../common/participants/chatAgents.js'; import { Target } from '../../common/promptSyntax/promptTypes.js'; @@ -307,7 +307,7 @@ export class MockChatSessionsService implements IChatSessionsService { return false; } - async forkChatSession(_sessionResource: URI, _request: IChatSessionRequestHistoryItem | undefined, _token: CancellationToken): Promise { + async forkChatSession(_sessionResource: URI, _request: IChatSessionRequestHistoryItem | undefined, _token: CancellationToken, _options?: IChatSessionForkOptions): Promise { throw new Error('Not implemented'); } From f2c356d9e67d5e9b1883075715a96bb3040684b2 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Wed, 7 Oct 2026 03:35:05 +1100 Subject: [PATCH 02/15] chat: update fork test for connections service Adapt the named-fork slash-command test to the new Agent Host connections service constructor dependency introduced upstream. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/test/browser/actions/chatForkActions.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts index 51783854a72192..d93410215670c1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts @@ -10,6 +10,7 @@ import { Disposable } from '../../../../../../base/common/lifecycle.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 { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -77,6 +78,7 @@ suite('ForkConversationAction', () => { new class extends mock() { }(), new class extends mock() { }(), new class extends mock() { override readonly isSessionsWindow = false; }(), + new class extends mock() { }(), )); const resource = URI.parse('contributed:/source'); From 6c903d89123a5576f089844dc064867aa04aa926 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 9 Oct 2026 06:21:36 +1100 Subject: [PATCH 03/15] agentHost: support prompted forks Add prompt-bearing Agent Host forks, preserve source-turn context, generate context-aware titles, and expose completion templates with argument hints and recovery behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/AGENTS.md | 12 +- .../browser/agentHostProtocolClient.ts | 6 + src/vs/platform/agentHost/common/agent.ts | 7 + .../platform/agentHost/common/agentService.ts | 16 +- .../platform/agentHost/common/state/AGENTS.md | 11 + .../state/protocol/channels-chat/commands.ts | 14 +- .../state/protocol/channels-root/state.ts | 13 + .../common/state/protocol/common/errors.ts | 41 ++- .../electron-browser/localAgentHostService.ts | 4 +- .../node/agentHostSessionTitleController.ts | 108 +++++-- .../agentHost/node/agentHostTurnService.ts | 30 +- .../agentHost/node/agentHostTurnStarter.ts | 4 + .../platform/agentHost/node/agentService.ts | 113 ++++++-- .../agentHost/node/agentSideEffects.ts | 4 +- .../agentHost/node/claude/claudeAgent.ts | 2 +- .../agentHost/node/codex/codexAgent.ts | 2 +- .../agentHost/node/copilot/copilotAgent.ts | 2 +- .../agentHost/node/protocolServerHandler.ts | 13 +- .../agentHostProtocolClient.test.ts | 92 +++++- .../localAgentHostService.test.ts | 28 +- .../test/node/agentHostE2EServerLease.test.ts | 1 + .../agentHostSessionTitleController.test.ts | 86 +++++- .../test/node/agentHostTurnService.test.ts | 60 +++- .../agentHost/test/node/agentService.test.ts | 256 ++++++++++++++++- .../agentHost/test/node/claudeAgent.test.ts | 2 +- .../test/node/codex/codexCreateChat.test.ts | 2 +- .../agentHost/test/node/copilotAgent.test.ts | 6 +- .../agentHostConformance.integrationTest.ts | 1 + .../e2e/harness/agentHostE2ETestHarness.ts | 5 + .../claudeAgentHostE2E.integrationTest.ts | 1 + .../e2e/providers/codexTestConfiguration.ts | 1 + .../e2e/providers/copilotTestConfiguration.ts | 1 + .../test/node/e2e/suites/multiChatSuite.ts | 100 ++++++- .../platform/agentHost/test/node/mockAgent.ts | 4 +- .../test/node/protocolServerHandler.test.ts | 64 ++++- src/vs/sessions/SESSIONS.md | 2 +- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 2 +- .../agentHost/browser/agentHostForkActions.ts | 37 ++- .../browser/baseAgentHostSessionsProvider.ts | 31 ++ .../test/browser/agentHostForkActions.test.ts | 59 +++- .../localAgentHostSessionsProvider.test.ts | 55 +++- .../sessions/common/sessionsProvider.ts | 3 + .../browser/actions/chatAccessibilityHelp.ts | 2 +- .../chat/browser/actions/chatForkActions.ts | 111 ++++++- .../agentHost/agentHostSessionHandler.ts | 167 ++++++++--- .../contrib/chat/browser/chatSlashCommands.ts | 43 ++- .../contrib/chat/browser/widget/chatWidget.ts | 92 +++++- .../editor/chatInputCommandArgumentHint.ts | 169 +++++++++-- .../input/editor/chatInputCompletions.ts | 79 ++++- .../chat/common/chatService/chatService.ts | 5 + .../common/chatService/chatServiceImpl.ts | 3 +- .../chat/common/chatSessionsService.ts | 92 +++++- .../common/participants/chatSlashCommands.ts | 37 ++- .../chatAccessibilityHelp.test.ts | 21 ++ .../browser/actions/chatForkActions.test.ts | 202 ++++++++++--- .../agentHostChatContribution.test.ts | 154 +++++++++- .../browser/chatWidgetSlashCommand.test.ts | 95 +++++- .../chatInputCommandArgumentHint.test.ts | 195 ++++++++++++- .../input/editor/chatInputCompletions.test.ts | 270 +++++++++++++++++- .../editor/chatInputEditorContrib.test.ts | 1 + .../common/chatService/chatService.test.ts | 62 +++- 61 files changed, 2833 insertions(+), 268 deletions(-) diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index 4f28a4b130af95..07665572d8de75 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -14,7 +14,7 @@ > **Status: COMPLETE** (2026-07-01) All waves A–D and gates G-B1, G-C1, G-C2, G-D1 are done. Codex, Claude, and Copilot all use the unified orchestrator path. > -> Codex advertises `multipleChats: { fork: true, sideChat: true, initialTitle: true }`. Model-backed peer, fork, and side-chat parity scenarios run in deterministic replay. +> Codex advertises `multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true }`. Model-backed peer, fork, and side-chat parity scenarios run in deterministic replay. > > The *operational* chat surface (send/abort/model/agent/history) is fully chat-addressed and uniform across harnesses. Session ownership lives in the orchestrator: it drives every harness through the chat-surface seam — see [§7 Session Ownership (T2/T4)](#7-session-ownership-t2t4--the-orchestrator-owns-the-session). @@ -152,7 +152,7 @@ Artifact tools read and mutate only the invoking chat's artifacts. The session d ### UI/provider layer (`sessions/services/sessions/common/session.ts:ISessionCapabilities`) -- Protocol `AgentCapabilities` (`multipleChats?: { fork?: boolean; initialTitle?: boolean }`) flows from `AgentInfo.capabilities` (protocol) through the provider adapter. `ISession.capabilities` (`ISessionCapabilities`) exposes `supportsMultipleChats` and `supportsFork`, derived from `multipleChats` and `multipleChats.fork`; both flow into VS Code context keys (`sessionContextKeys.ts:SessionSupportsMultipleChatsContext`, `SessionSupportsForkContext`). The Agent Host provider handles `multipleChats.initialTitle` privately when forwarding fork options. +- Protocol `AgentCapabilities` (`multipleChats?: { fork?: boolean; initialTitle?: boolean; forkWithInitialMessage?: boolean }`) flows from `AgentInfo.capabilities` (protocol) through the provider adapter. `ISession.capabilities` (`ISessionCapabilities`) exposes `supportsMultipleChats` and `supportsFork`, derived from `multipleChats` and `multipleChats.fork`; both flow into VS Code context keys (`sessionContextKeys.ts:SessionSupportsMultipleChatsContext`, `SessionSupportsForkContext`). The Agent Host provider handles `multipleChats.initialTitle` and the composed `multipleChats.forkWithInitialMessage` operation privately when forwarding fork options. The latter attests that the host admits the first user message with the fork; it does not gate the pre-existing `createChat.initialMessage` field. - UI actions read context keys — no provider-id switches. --- @@ -279,7 +279,7 @@ The agent declares these in `getDescriptor().capabilities` (`common/agent.ts:IAg UI code gates "Add Chat" and "Fork" actions on those context keys. No code inside `AgentService` or `AgentHostStateManager` switches on provider id to gate features. `AgentService.createChat` throws synchronously when `!provider.chats` (the structural guard that replaces a capability check in the orchestrator). -Claude, Copilot, and Codex advertise `multipleChats: { fork: true, sideChat: true, initialTitle: true }`. Codex side-chat context and restore run in deterministic replay. Subagent E2E and native streaming file-creation coverage remain independently disabled and must not be inferred from its peer-chat/fork/side-chat support. +Claude, Copilot, and Codex advertise `multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true }`. Codex side-chat context and restore run in deterministic replay. Subagent E2E and native streaming file-creation coverage remain independently disabled and must not be inferred from its peer-chat/fork/side-chat support. --- @@ -455,7 +455,7 @@ Claude deliberately has no AH-session container and no membership/role concept o - `_chatBackings: Map` maps each exact host-supplied chat URI to only its provider-owned `{ sdkSessionId, model?, sideChat? }` backing data. It deliberately does **not** retain the owning AH session or a storage URI: AH supplies the owning session and persistence/config resource transiently on every operation (`IAgentChatContext`). - `IClaudeChatBacking` is the source of truth for both live and released chats: releasing a chat drops its `_chatEntriesBySdkId` leaf but keeps the backing data so a later send can cold-resume the corresponding `ClaudeAgentSession`. -Every chat operation resolves exactly one backing and routes to exactly one live leaf; there is no default-vs-additional branch and no cascade between chats of the same session. An additional chat's send after restart resumes only that chat's `ClaudeAgentSession`. Capabilities remain `multipleChats: { fork: true, sideChat: true, initialTitle: true }`. +Every chat operation resolves exactly one backing and routes to exactly one live leaf; there is no default-vs-additional branch and no cascade between chats of the same session. An additional chat's send after restart resumes only that chat's `ClaudeAgentSession`. Capabilities remain `multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true }`. Each additional chat is backed by a fresh top-level SDK session (`sdkSessionId = generateUuid()`) minted in the same global Claude project store that `listSessions` enumerates. `_createChat` therefore returns `backingSession: AgentSession.uri(this.id, sdkSessionId)` so the orchestrator can suppress that backing from the top-level session list (invariant I7); without it the additional chat would leak as a phantom session. The SDK exposes no delete-chat RPC, so `disposeChat` leaves the backing transcript on disk — the orchestrator-owned catalog simply drops the entry so it is never resumed again. (Claude writes no legacy `claude.chats` blob and has no legacy migration: Claude multi-chat shipped only with the orchestrator-owned catalog, so there is nothing to drain. Copilot keeps its own `copilot.chats` migration because `copilot.chats` predates the catalog.) @@ -468,7 +468,7 @@ Copilot also has no AH-session container: - Fork/import provisioning binds the exact target chat inside `chats.createChat`, so a create result is never left waiting for a follow-up bind call. - The backing records preserve the existing `providerData` codec and one-time `copilot.chats` migration. -No `CopilotSessionEntry`, `AgentSessionEntry`, default-chat URI helper, or sibling cascade remains. Send/history/model/agent/abort/tool/config/dispose/release operations resolve one leaf. Active-client state remains keyed by the owning SDK session where it is genuinely shared, while each live leaf owns its own SDK and MCP lifecycle. Capabilities remain `multipleChats: { fork: true, sideChat: true, initialTitle: true }`. +No `CopilotSessionEntry`, `AgentSessionEntry`, default-chat URI helper, or sibling cascade remains. Send/history/model/agent/abort/tool/config/dispose/release operations resolve one leaf. Active-client state remains keyed by the owning SDK session where it is genuinely shared, while each live leaf owns its own SDK and MCP lifecycle. Capabilities remain `multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true }`. Steering received during turn preparation or configuration refresh is buffered for the addressed chat. A dispatched turn confirmed running by root SDK events accepts and flushes steering immediately, even if its admission RPC has not returned; successful admission also flushes it. Preparation or send failure retains buffered steering for a successful retry, rather than dispatching against an unready runtime. Pending updates replace or remove buffered steering; abort clears it and emits `steering_consumed` to clear the pending UI. The live SDK-session wrapper takes ownership synchronously before MCP reconciliation, so abort and disposal can clear steering throughout delivery. Chat disposal and host shutdown discard buffered delivery, and pending-message updates are ignored once host shutdown starts. @@ -521,7 +521,7 @@ When a restored Agent Host conversation's intended model is missing from the pic Metadata and history reads use `thread/read` and `thread/turns/list` without resuming the thread or claiming its writer lock, so a thread still open in another Codex client remains readable. Subscribing to an interactive chat invokes the optional provider-internal `IAgentChats.prepareChat` seam before returning the snapshot. Codex resumes an existing backing to acquire its writer lock without sending a turn; fresh chats remain lazy. The host publishes temporary input restrictions through existing read-only chat interactivity and validated `vscode.chatInputState` session metadata. These restrictions are not persisted as permanent read-only state. Clients retain history and the editable draft; Retry refreshes the existing subscription without sending a turn or discarding pending local actions. No additional AHP method or capability is required. Sends still apply pending launch configuration and replace a never-persisted backing only when the SDK reports that its rollout is missing. Reads still retry when their app-server connection is replaced. -An additional chat is backed by a **fresh top-level thread minted eagerly** in `chats.createChat` (via `thread/start` or `thread/fork` at the requested turn, reusing `_forkSession`). For these internal peer backings only, the backing entry and URI are keyed by the app-server-assigned thread id. This does not couple the parent AH session id to its default thread id; it gives the peer-chat-backing marker a stable `codex:/` database across restart. `_createChat`/`fork` therefore return `backingSession: AgentSession.uri(this.id, threadId)` so the orchestrator suppresses that backing from the top-level session list (invariant I7), plus an opaque `providerData` blob (the backing thread id + model) that `materializeChat` decodes on restore. The additional chat inherits the parent session's working directory, model, and permissions. Exact disposal/release affects only the addressed chat's own thread — there is no cascade between chats of the same session. The persisted `codex.threadId`, `codex.cwd`, and `codex.model` keys and app-server protocol are unchanged, and Codex still never recognizes or derives a default-chat URI. The orchestrator registry contains the parent AH session, not these chat backing URIs; provider-owned discovery pushes external chats through `onDidDiscoverChats`. Capabilities are `multipleChats: { fork: true, sideChat: true, initialTitle: true }`. +An additional chat is backed by a **fresh top-level thread minted eagerly** in `chats.createChat` (via `thread/start` or `thread/fork` at the requested turn, reusing `_forkSession`). For these internal peer backings only, the backing entry and URI are keyed by the app-server-assigned thread id. This does not couple the parent AH session id to its default thread id; it gives the peer-chat-backing marker a stable `codex:/` database across restart. `_createChat`/`fork` therefore return `backingSession: AgentSession.uri(this.id, threadId)` so the orchestrator suppresses that backing from the top-level session list (invariant I7), plus an opaque `providerData` blob (the backing thread id + model) that `materializeChat` decodes on restore. The additional chat inherits the parent session's working directory, model, and permissions. Exact disposal/release affects only the addressed chat's own thread — there is no cascade between chats of the same session. The persisted `codex.threadId`, `codex.cwd`, and `codex.model` keys and app-server protocol are unchanged, and Codex still never recognizes or derives a default-chat URI. The orchestrator registry contains the parent AH session, not these chat backing URIs; provider-owned discovery pushes external chats through `onDidDiscoverChats`. Capabilities are `multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true }`. --- diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 6e398be6586d47..43955ba36f089c 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -1964,10 +1964,16 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } async createChat(session: URI, chat: URI, options?: IAgentCreateChatRequestOptions): Promise { + const initialMessage = options?.initialMessage && { + ...options.initialMessage, + ...(options.initialMessage.model === undefined && options.model !== undefined ? { model: options.model } : {}), + ...(options.initialMessage.agent === undefined && options.agent !== undefined ? { agent: options.agent } : {}), + }; await this._sendRequest('createChat', { channel: session.toString(), chat: chat.toString(), ...(options?.title !== undefined ? { title: options.title } : {}), + ...(initialMessage ? { initialMessage } : {}), ...(options?.workingDirectories !== undefined && !options.fork ? { workingDirectories: options.workingDirectories.map(directory => fromAgentHostUri(directory).toString()) } : {}), diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 2281ee8e4f2a9a..71c3a2fe1a2049 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -767,6 +767,13 @@ export interface IAgentCreateChatOptions { * {@link IAgentCreateChatOptions} and never receive side-chat provenance. */ export interface IAgentCreateChatRequestOptions extends IAgentCreateChatOptions { + /** + * Host-owned first user message submitted after creating the chat. This is + * intentionally excluded from {@link IAgentCreateChatOptions}: providers + * create the chat before the host processes this message through the normal + * client-aware send path. + */ + readonly initialMessage?: Message; /** * Create this new chat as a side chat branching from a turn in an existing * chat (via `/btw`). The host resolves this into {@link fork} before calling diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 271837c0c4f8f1..42120821e52bb9 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -87,6 +87,18 @@ export interface IAgentHostIpcConnectionOptions { /** Configuration key that controls whether AHP JSONL logs are written for agent host transports. */ export const AgentHostAhpJsonlLoggingSettingId = 'chat.agentHost.ahpJsonlLoggingEnabled'; +/** + * Trusted connection facts retained by the protocol ingress while it asks the + * host to submit a create-chat initial message. This is deliberately not part + * of the protocol request or provider creation options. + */ +export interface IAgentCreateChatInitialMessageContext { + readonly clientId: string; + readonly telemetryContext: IAgentHostClientTelemetryContext; + /** The caller requested the capability-backed fork-and-message operation. */ + readonly capabilityBackedFork: boolean; +} + export type AgentHostDebugLogsArtifactKind = 'archive' | 'directory'; /** Maximum number of files in one Agent Host debug-log artifact. */ export const AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES = 1000; @@ -947,7 +959,7 @@ export interface IAgentService { * registers the chat in the session's catalog so subscribers observe a * `session/chatAdded` action. The `chat` URI is the client-chosen channel. */ - createChat(session: URI, chat: URI, options?: IAgentCreateChatRequestOptions): Promise; + createChat(session: URI, chat: URI, options?: IAgentCreateChatRequestOptions, initialMessageContext?: IAgentCreateChatInitialMessageContext): Promise; /** Dispose an additional chat created via {@link createChat}. */ disposeChat(session: URI, chat: URI): Promise; @@ -1335,7 +1347,7 @@ export interface IAgentConnection { * client-chosen chat URI (see {@link buildChatUri}). The host adds the * chat to the session's catalog and publishes `session/chatAdded`. */ - createChat(session: URI, chat: URI, options?: IAgentCreateChatRequestOptions): Promise; + createChat(session: URI, chat: URI, options?: IAgentCreateChatRequestOptions, initialMessageContext?: IAgentCreateChatInitialMessageContext): Promise; /** Dispose an additional chat created via {@link createChat}. */ disposeChat(chat: URI): Promise; diff --git a/src/vs/platform/agentHost/common/state/AGENTS.md b/src/vs/platform/agentHost/common/state/AGENTS.md index 642395cc18e907..510b2a121d1d00 100644 --- a/src/vs/platform/agentHost/common/state/AGENTS.md +++ b/src/vs/platform/agentHost/common/state/AGENTS.md @@ -38,6 +38,17 @@ A reproducible sync from `6eec1b91` must also retain the existing VS Code-only compatibility deltas: deprecated `SessionChatSummary.archived` and `ChatIsReadChanged` introduced in `0.10.0`. +### Initial-message transport delta + +The pinned generated protocol includes `CreateChatParams.initialMessage` and +the associated `InitialMessageRejected` typed error data. Host-facing +`IAgentCreateChatRequestOptions.initialMessage` is deliberately not part of +provider creation options: the host creates the provider chat first, then +processes the message through its normal client-aware admission path. Protocol +clients serialize a supplied initial message unchanged except that a missing +message `model` or `agent` inherits the corresponding request option. Do not +add top-level `model` or `agent` fields to `CreateChatParams`. + ## Adding optional fields to existing types Optional protocol fields are usually backwards-compatible. Add the field in the source protocol repo, sync `protocol/**`, and add wrapper exports only when VS Code code needs them. 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 3964469be431f8..f17922c34defa8 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 @@ -90,7 +90,19 @@ export interface CreateChatParams extends BaseParams { channel: URI; /** Chat URI (client-chosen, e.g. `ahp-chat:/`). */ chat: URI; - /** Optional initial message for the new chat. */ + /** + * Optional initial message for the new chat. + * + * This existing field remains ungated, including for legacy fork requests. + * A client that needs the stronger composed fork-and-message guarantees MUST + * require both {@link MultipleChatsCapability.fork} and + * {@link MultipleChatsCapability.forkWithInitialMessage}. For that + * capability-backed operation, a host that has created the fork but can + * definitely not accept this message MUST return `InitialMessageRejected` + * (-32012) with the created chat URI and underlying rejection cause. A + * successful `null` result confirms message acceptance, not completion of + * the message's model turn. + */ initialMessage?: Message; /** * Optional source chat and source turn. diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index 88c2a44dd761b6..63639c204d5efb 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -157,6 +157,19 @@ export interface MultipleChatsCapability { * MUST omit `title` when calling `createChat`. */ initialTitle?: boolean; + /** + * The agent supports the stronger composed fork-and-initial-message + * operation. Clients relying on its preserved client context, definite + * acceptance or rejection result, and prompt-aware naming context MUST + * require this and {@link MultipleChatsCapability.fork} to be `true`. + * The naming context is available to the host's normal strategy at its + * normal time; it does not guarantee title generation. + * + * This attestation applies only to the stronger combined operation. It does + * not reject or otherwise change existing ungated `initialMessage` requests, + * including legacy fork requests on third-party hosts. + */ + forkWithInitialMessage?: boolean; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/common/errors.ts b/src/vs/platform/agentHost/common/state/protocol/common/errors.ts index 0fdab1d1f5e9cb..9f52951a60b1e7 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/errors.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/errors.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import type { ProtectedResourceMetadata } from './state.js'; +import type { ProtectedResourceMetadata, URI } from './state.js'; import type { ResourceRequestParams } from './commands.js'; // ─── Standard JSON-RPC Codes ───────────────────────────────────────────────── @@ -92,6 +92,11 @@ export const AhpErrorCodes = { * fresh token or surface the conflict to the user. */ Conflict: -32011, + /** + * A fork chat was created, but its requested initial message was definitely + * rejected before it could be accepted for processing. + */ + InitialMessageRejected: -32012, } as const; /** Union type of all AHP application error codes. */ @@ -156,6 +161,39 @@ export interface UnsupportedProtocolVersionErrorData { supportedVersions: string[]; } +/** + * Details carried in the `data` field of an `InitialMessageRejected` (-32012) + * error. + * + * The fork chat identified by `chat` was created successfully. `cause` + * preserves the underlying rejection, including structured details such as an + * authentication challenge, so clients can inspect or surface it. + * + * @category Error Details + * @version 1 + */ +export interface InitialMessageRejectedErrorData { + /** URI of the successfully created fork chat. */ + chat: URI; + /** Underlying reason the initial message could not be accepted. */ + cause: InitialMessageRejectedCause; +} + +/** + * The preserved underlying error for an `InitialMessageRejected` error. + * + * @category Error Details + * @version 1 + */ +export interface InitialMessageRejectedCause { + /** Underlying JSON-RPC or application error code. */ + code: number; + /** Underlying human-readable error message. */ + message: string; + /** Underlying structured error detail, when supplied. */ + data?: unknown; +} + /** * Maps each AHP error code that carries structured `data` to the type of * that data. @@ -170,6 +208,7 @@ export interface AhpErrorDetailsMap { [AhpErrorCodes.AuthRequired]: AuthRequiredErrorData; [AhpErrorCodes.PermissionDenied]: PermissionDeniedErrorData; [AhpErrorCodes.UnsupportedProtocolVersion]: UnsupportedProtocolVersionErrorData; + [AhpErrorCodes.InitialMessageRejected]: InitialMessageRejectedErrorData; } /** AHP error codes that carry a structured `data` payload. */ diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index 35813c707f9618..dbd728570c64c1 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -721,7 +721,9 @@ function hasSessionExtensions(config: IAgentCreateSessionConfig): boolean { } function hasChatExtensions(options: IAgentCreateChatRequestOptions): boolean { - return options.model !== undefined; + // AHP carries the first message's model/agent selection and client identity. + // The management channel cannot preserve that admission path. + return options.model !== undefined && options.initialMessage === undefined; } /** diff --git a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts index d099576bce0474..82cea3a2be02cb 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts @@ -59,6 +59,8 @@ function chatTitleReviewReminder(currentTitle: string): string { interface IDeferredTitleSeed { readonly title: string; readonly turnIndex: number; + readonly sourceTitle?: string; + readonly initialPrompt?: string; } /** @@ -127,7 +129,7 @@ export interface IAgentHostSessionTitleController { seedTitleFromFirstMessage(channel: ProtocolURI, userPrompt: string, chatChannel?: ProtocolURI, model?: ModelSelection): void; seedProvisionalTitle(channel: ProtocolURI, suggestedTitle: string, chatChannel?: ProtocolURI): void; refineTitleFromFirstTurn(channel: ProtocolURI, chatChannel?: ProtocolURI, successful?: boolean): void; - generateForkedTitle(channel: ProtocolURI, chatChannel: ProtocolURI | undefined, turns: readonly Turn[], fallbackTitle: string, sourceTitle?: string): void; + generateForkedTitle(channel: ProtocolURI, chatChannel: ProtocolURI | undefined, turns: readonly Turn[], fallbackTitle: string, sourceTitle?: string, initialPrompt?: string): void; generateExternalSessionTitle(session: ProtocolURI, userPrompt: string): Promise; cancelTitleGeneration(session: ProtocolURI): void; clearSession(session: ProtocolURI, chatChannels: readonly ProtocolURI[]): void; @@ -141,6 +143,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen declare readonly _serviceBrand: undefined; private readonly _titleGenerationCancellationSources = new Map(); + private readonly _forkTitleContexts = new Map(); /** * The most recent title this controller applied for a given session/chat @@ -398,14 +401,16 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen return; } const turn = state.turns[inheritedTurnCount]; + const forkContext = this._forkTitleContexts.get(key); if (deferred) { this._deferredRefinementStarted.add(key); this._clearDeferredTitleSeed(channel, independentChat); + this._forkTitleContexts.delete(key); } if (!successful || turn.state !== TurnState.Complete) { return; } - const context = this._buildFirstTurnContext(turn); + const context = this._buildFirstTurnContext(turn, forkContext); if (!context) { return; } @@ -428,21 +433,36 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen } /** Titles a fork from bounded inherited context in utility mode; deferred mode waits for its first new response. */ - generateForkedTitle(channel: ProtocolURI, chatChannel: ProtocolURI | undefined, turns: readonly Turn[], fallbackTitle: string, sourceTitle?: string): void { + generateForkedTitle(channel: ProtocolURI, chatChannel: ProtocolURI | undefined, turns: readonly Turn[], fallbackTitle: string, sourceTitle?: string, initialPrompt?: string): void { if (this._isEphemeralSession(channel)) { return; } const strategy = this.getAutomaticTitleGenerationStrategy(channel); + if (initialPrompt !== undefined && !this._canGeneratePromptForkedTitle(channel, chatChannel, fallbackTitle)) { + return; + } if (strategy !== 'utility') { this.markTitleAuto(channel, chatChannel, fallbackTitle); + const independentChat = this._independentChatChannel(channel, chatChannel); + const key = independentChat ?? channel; + if (initialPrompt !== undefined) { + this._forkTitleContexts.set(key, { + ...(sourceTitle ? { sourceTitle: this._truncateForkTitleContext(sourceTitle) } : {}), + initialPrompt: this._truncateForkTitleContext(initialPrompt), + }); + } if (isDeferredTitleGenerationStrategy(strategy)) { - const independentChat = this._independentChatChannel(channel, chatChannel); - this._deferredFirstTurnIndices.set(independentChat ?? channel, turns.length); - this._persistDeferredTitleSeed(channel, independentChat, { title: fallbackTitle, turnIndex: turns.length }); + this._deferredFirstTurnIndices.set(key, turns.length); + this._persistDeferredTitleSeed(channel, independentChat, { + title: fallbackTitle, + turnIndex: turns.length, + ...(sourceTitle ? { sourceTitle: this._truncateForkTitleContext(sourceTitle) } : {}), + ...(initialPrompt !== undefined ? { initialPrompt: this._truncateForkTitleContext(initialPrompt) } : {}), + }); } return; } - const context = this._buildConversationContext(turns, sourceTitle); + const context = this._buildConversationContext(turns, sourceTitle, initialPrompt); if (!context) { return; } @@ -485,6 +505,13 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen dispatch(title); } + /** A prompt fork must not turn a rename made during chat creation back into an automatic title. */ + private _canGeneratePromptForkedTitle(channel: ProtocolURI, chatChannel: ProtocolURI | undefined, fallbackTitle: string): boolean { + const independentChat = this._independentChatChannel(channel, chatChannel); + const key = independentChat ?? channel; + return !this._renamedTitles.has(key) && this._currentSeedTitle(channel, independentChat) === fallbackTitle; + } + /** * Generates a title for an external session whose provider surfaced it * without one, from the user's first prompt. Such a session usually has no @@ -534,6 +561,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen this._deferredFirstTurnIndices.delete(key); this._titleReviewReminderCounts.delete(key); this._restoringDeferredSeeds.delete(key); + this._forkTitleContexts.delete(key); } this._titleGenerationStrategies.delete(session); this._unpersistedTitleStrategies.delete(session); @@ -556,6 +584,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen this._autoTitles.delete(key); this._provisionalTitles.delete(key); this._renamedTitles.add(key); + this._forkTitleContexts.delete(key); if (isDeferredTitleGenerationStrategy(this._titleGenerationStrategies.get(channel))) { this._clearDeferredTitleSeed(channel, independentChat); } @@ -603,7 +632,11 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen this._titleReviewReminderCounts.set(key, (this._titleReviewReminderCounts.get(key) ?? 0) + 1); return chatTitleReviewReminder(currentTitle); } - return CHAT_RENAME_REMINDER; + const forkContext = this._forkTitleContexts.get(key); + this._forkTitleContexts.delete(key); + return forkContext + ? `${CHAT_RENAME_REMINDER}\n\nThis fork continues "${forkContext.sourceTitle ?? 'an earlier chat'}". The user's new request is:\n${forkContext.initialPrompt}` + : CHAT_RENAME_REMINDER; } /** @@ -932,7 +965,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen * response worth refining from (the opening message already produced a * title in that case). */ - private _buildFirstTurnContext(turn: Turn): string | undefined { + private _buildFirstTurnContext(turn: Turn, forkContext?: { readonly sourceTitle?: string; readonly initialPrompt: string }): string | undefined { const response = renderResponseMarkdown(turn.responseParts); if (!response) { return undefined; @@ -949,7 +982,12 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen const responseBudget = Math.max(0, MAX_TITLE_CONTEXT_CHARS - userBlock.length - responseLabel.length); const trimmedResponse = response.length > responseBudget ? truncateMiddle(response, responseBudget) : response; - return trimmedResponse ? `${userBlock}${responseLabel}${trimmedResponse}` : userBlock; + const firstTurnContext = trimmedResponse ? `${userBlock}${responseLabel}${trimmedResponse}` : userBlock; + if (!forkContext) { + return firstTurnContext; + } + const prefix = this._buildPromptForkContextPrefix(forkContext.sourceTitle, forkContext.initialPrompt); + return `${prefix}${truncateMiddle(firstTurnContext, Math.max(0, MAX_TITLE_CONTEXT_CHARS - prefix.length))}`; } /** @@ -966,12 +1004,40 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen * @returns the context string, or `undefined` when no turn carries any * text worth titling from. */ - private _buildConversationContext(turns: readonly Turn[], sourceTitle?: string): string | undefined { - const framedTitle = sourceTitle?.trim(); - const framing = framedTitle - ? `This conversation was branched from an earlier chat titled "${framedTitle}". The turns below, oldest first, are the inherited history up to the branch point.\n\n` - : undefined; - return buildConversationContext(turns, { maxChars: MAX_TITLE_CONTEXT_CHARS, framing }); + private _buildConversationContext(turns: readonly Turn[], sourceTitle?: string, initialPrompt?: string): string | undefined { + // Preserve the established bare-fork utility input exactly. + if (initialPrompt === undefined) { + const framedTitle = sourceTitle?.trim(); + const framing = framedTitle + ? `This conversation was branched from an earlier chat titled "${framedTitle}". The turns below, oldest first, are the inherited history up to the branch point.\n\n` + : undefined; + return buildConversationContext(turns, { maxChars: MAX_TITLE_CONTEXT_CHARS, framing }); + } + const prefix = this._buildPromptForkContextPrefix(sourceTitle, initialPrompt); + const history = buildConversationContext(turns, { maxChars: Math.max(0, MAX_TITLE_CONTEXT_CHARS - prefix.length) }); + return history ? `${prefix}${history}` : prefix.trimEnd(); + } + + /** Builds a bounded prompt-fork prefix without allowing inherited title context to crowd out the new request. */ + private _buildPromptForkContextPrefix(sourceTitle: string | undefined, initialPrompt: string): string { + const sourcePrefix = 'This conversation was branched from an earlier chat titled "'; + const sourceSuffix = '". The turns below, oldest first, are the inherited history up to the branch point.\n\n'; + const promptPrefix = 'The new user request for this fork is:\n'; + const promptSuffix = '\n\n'; + const hasSource = !!sourceTitle?.trim(); + const fixedLength = promptPrefix.length + promptSuffix.length + (hasSource ? sourcePrefix.length + sourceSuffix.length : 0); + const contentBudget = Math.max(0, MAX_TITLE_CONTEXT_CHARS - fixedLength); + const sourceBudget = hasSource ? Math.floor(contentBudget / 2) : 0; + const promptBudget = contentBudget - sourceBudget; + const source = hasSource ? truncateMiddle(sourceTitle!.trim(), sourceBudget) : ''; + const prompt = truncateMiddle(initialPrompt, promptBudget); + return `${hasSource ? `${sourcePrefix}${source}${sourceSuffix}` : ''}${promptPrefix}${prompt}${promptSuffix}`; + } + + private _truncateForkTitleContext(value: string): string { + return value.length > Math.floor(MAX_TITLE_CONTEXT_CHARS / 2) + ? truncateMiddle(value, Math.floor(MAX_TITLE_CONTEXT_CHARS / 2)) + : value; } private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void { @@ -1087,6 +1153,12 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen if (typeof seed.title !== 'string' || typeof seed.turnIndex !== 'number' || !Number.isSafeInteger(seed.turnIndex) || seed.turnIndex < 0) { return; } + const sourceTitle = seed.sourceTitle; + const initialPrompt = seed.initialPrompt; + if ((sourceTitle !== undefined && typeof sourceTitle !== 'string') + || (initialPrompt !== undefined && typeof initialPrompt !== 'string')) { + throw new Error('Invalid deferred title seed fork context'); + } const normalized = await this._options.readNormalizedChat?.(URI.parse(channel), URI.parse(chatChannel)); if (normalized?.normalized && !normalized.chat) { throw new Error(`Missing normalized chat during deferred title restoration: ${chatChannel}`); @@ -1104,6 +1176,9 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen if (source === AGENT_HOST_TITLE_SOURCE_AUTO && title === seed.title) { this._lastAppliedTitle.set(key, seed.title); this._deferredFirstTurnIndices.set(key, seed.turnIndex); + if (initialPrompt !== undefined) { + this._forkTitleContexts.set(key, { ...(sourceTitle !== undefined ? { sourceTitle } : {}), initialPrompt }); + } } } finally { ref.dispose(); @@ -1163,6 +1238,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen this._deferredRefinementStarted.clear(); this._deferredFirstTurnIndices.clear(); this._titleReviewReminderCounts.clear(); + this._forkTitleContexts.clear(); super.dispose(); } } diff --git a/src/vs/platform/agentHost/node/agentHostTurnService.ts b/src/vs/platform/agentHost/node/agentHostTurnService.ts index 6042be45b381ae..f94d5a3d1dfa36 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnService.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnService.ts @@ -27,10 +27,16 @@ export interface IDeferredAgentHostTurn { export interface IAgentHostTurnService { readonly _serviceBrand: undefined; startTurnMessage(chat: URI, message: Message): void; + /** + * Starts a message prepared by a connected client without manufacturing a + * client action envelope or client sequence number. + */ + startClientTurnMessage(chat: URI, message: Message, clientId: string, clientContext: IAgentHostClientTelemetryContext): ErrorInfo | undefined; + startClientTurn(chat: URI, action: ChatTurnStartedAction, clientId: string, clientContext: IAgentHostClientTelemetryContext): ErrorInfo | undefined; beginDeferredTurnMessage(chat: URI, message: Message): IDeferredAgentHostTurn; continueDeferredTurnMessage(chat: URI, turn: IDeferredAgentHostTurn, message: Message): boolean; failDeferredTurnMessage(chat: URI, turn: IDeferredAgentHostTurn, error: ErrorInfo): boolean; - handleTurnStarted(channel: ProtocolURI, action: ChatTurnStartedAction, clientId?: string, clientContextOrType?: IAgentHostClientTelemetryContext | AgentHostClientType): void; + handleTurnStarted(channel: ProtocolURI, action: ChatTurnStartedAction, clientId?: string, clientContextOrType?: IAgentHostClientTelemetryContext | AgentHostClientType): ErrorInfo | undefined; } /** Standard turn admission and provider routing shared by client- and host-authored turns. */ @@ -52,6 +58,21 @@ export class AgentHostTurnService implements IAgentHostTurnService { this.handleTurnStarted(channel, action); } + startClientTurnMessage(chat: URI, message: Message, clientId: string, clientContext: IAgentHostClientTelemetryContext): ErrorInfo | undefined { + const action: ChatTurnStartedAction = { + type: ActionType.ChatTurnStarted, + turnId: generateUuid(), + startedAt: new Date().toISOString(), + message, + }; + return this.startClientTurn(chat, action, clientId, clientContext); + } + + startClientTurn(chat: URI, action: ChatTurnStartedAction, clientId: string, clientContext: IAgentHostClientTelemetryContext): ErrorInfo | undefined { + this._stateManager.dispatchServerAction(chat.toString(), action); + return this.handleTurnStarted(chat.toString(), action, clientId, clientContext); + } + beginDeferredTurnMessage(chat: URI, message: Message): IDeferredAgentHostTurn { const channel = chat.toString(); if (this._deferredTurns.has(channel) || this._stateManager.getActiveTurnId(channel) !== undefined) { @@ -103,7 +124,7 @@ export class AgentHostTurnService implements IAgentHostTurnService { return true; } - handleTurnStarted(channel: ProtocolURI, action: ChatTurnStartedAction, clientId?: string, clientContextOrType?: IAgentHostClientTelemetryContext | AgentHostClientType): void { + handleTurnStarted(channel: ProtocolURI, action: ChatTurnStartedAction, clientId?: string, clientContextOrType?: IAgentHostClientTelemetryContext | AgentHostClientType): ErrorInfo | undefined { const host = this._chatContributions.getHost(); if (!host) { throw new Error('Agent Host turn routing is unavailable.'); @@ -118,6 +139,7 @@ export class AgentHostTurnService implements IAgentHostTurnService { : typeof clientContextOrType === 'string' ? createUnknownAgentHostClientTelemetryContext(clientContextOrType) : clientContextOrType; + let rejection: ErrorInfo | undefined; const started = this._instantiationService.invokeFunction(startTurn, { session: sessionChannel, chat: channel, @@ -128,9 +150,10 @@ export class AgentHostTurnService implements IAgentHostTurnService { clientId, clientContext, turnStopWatch, + onRejected: error => rejection = error, }); if (!started) { - return; + return rejection; } host.sendTurnMessage({ agent: started.agent, @@ -143,6 +166,7 @@ export class AgentHostTurnService implements IAgentHostTurnService { clientContext, turnStopWatch, }); + return undefined; } private _dispatchTurnStarted(channel: ProtocolURI, message: Message): ChatTurnStartedAction { diff --git a/src/vs/platform/agentHost/node/agentHostTurnStarter.ts b/src/vs/platform/agentHost/node/agentHostTurnStarter.ts index 5b99271eefc9de..363f104504c66c 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnStarter.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnStarter.ts @@ -35,6 +35,8 @@ export interface ITurnStartRequest { readonly clientId: string | undefined; readonly clientContext: IAgentHostClientTelemetryContext; readonly turnStopWatch: StopWatch; + /** Receives a definite admission rejection without changing queued callers' result contract. */ + readonly onRejected?: (error: ErrorInfo) => void; } /** @@ -88,6 +90,7 @@ export function startTurn(accessor: ServicesAccessor, request: ITurnStartRequest reason: { kind: 'rejected', error: disposition.error }, clientContext: request.clientContext, }); + request.onRejected?.(disposition.error); return undefined; } @@ -113,6 +116,7 @@ export function startTurn(accessor: ServicesAccessor, request: ITurnStartRequest reason: { kind: 'rejected', error }, clientContext: request.clientContext, }); + request.onRejected?.(error); return undefined; } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 152684b94bf620..51cf574afc2eb0 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -27,7 +27,7 @@ import { parsePullRequestUrl } from '../../github/common/githubUrls.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; import { AgentChatMigrationDeferred, AgentProvider, AgentSession, CLAUDE_AGENT_PROVIDER_ID, COPILOT_CLI_AGENT_PROVIDER_ID, AgentSignal, CODEX_AGENT_PROVIDER_ID, IAgent, type IAgentAdoptedWorktree, type IAgentCanvasInfo, type IAgentCanvasOpenRequest, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatRequestOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentCustomizationInstallation, IAgentCustomizationInstallationRequest, IAgentCustomizationInstallationReview, IAgentCustomizationMarketplaceSearchRequest, IAgentCustomizationMarketplaceSearchResult, IAgentDiscoveredChat, IAgentLegacyChat, IAgentMaterializeChatEvent, IAgentModelInfo, type IAgentPluginInstallRequest, type IAgentPluginUninstallRequest, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, type AgentChatAdoptionReason, IAgentSessionConfigCompletionsParams, type IAgentSessionChatMetadata, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, SubagentChatSignal, subagentChatTitle } from '../common/agent.js'; -import { type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentService } from '../common/agentService.js'; +import { type AgentHostDebugLogsArtifactKind, type IAgentCreateChatInitialMessageContext, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentService } from '../common/agentService.js'; import { ISessionDatabase, ISessionDataService, ISessionStorageAccessCounts, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; import { IAgentEditAttributionService, ICancelEditAttributionFlushParams, ICommitEditAttributionFlushParams, IEditAttributionFlushResult, IPrepareEditAttributionFlushParams, IPreparedEditAttributionFlush, parseEditAttributionResource } from '../common/fileEditAttribution.js'; import { getPersistedSessionConfigValues, getSessionPullRequestUrl, omitTransientSessionConfigValues, SessionConfigKey } from '../common/sessionConfigKeys.js'; @@ -42,7 +42,7 @@ 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'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; -import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; +import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, JsonRpcErrorCodes, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, PendingMessageKind, TerminalClaimKind, TerminalLifecycleStatus, type Annotation, type AnnotationEntry, type AnnotationOrigin, type AnnotationsState, type ChatOrigin, type ChatState, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment, type TerminalState, type TextRange, type ToolResultTerminalContent } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_AUTO_ARCHIVED_AT_DB_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionComparisonMetadata, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, chatStorageUri, getErrorResponsePart, isAhpChatChannel, isChatInSessionReadAggregate, isChatReadOnly, isDefaultChatUri, isSessionChatArchived, isSessionStatusArchived, isSessionStatusRead, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionGitData, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitData, withSessionGitHubState, withSessionGitState, withSessionHasWorkspaceTransitions, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, AH_META_EHCLI_LAST_TURN_DB_KEY, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, getAllSessionRelatedPullRequestUrls, readSessionEhcliAdoptable, readSessionGitHubData, parseSessionGitHubData, parseSessionGitHubState, readSessionGitHubStateInput, withMigratedSessionGitHubState, withReplacedFolderGitHubState, withMostRecentRelatedSessionPullRequest, SESSION_META_GITHUB_DATA_KEY, withWorkingDirectoryKey, withWorkingDirectoryScopeId, type ISessionGitHubState, type ISessionSourceControlState, type ISessionWithDefaultChat, type SessionConfigState, type SessionSummary, type SessionSummaryMeta, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; @@ -5418,7 +5418,7 @@ export class AgentService extends Disposable implements IAgentService { return provider.recoverCustomizationInstallations(session); } - async createChat(session: URI, chat: URI, options?: IAgentCreateChatRequestOptions): Promise { + async createChat(session: URI, chat: URI, options?: IAgentCreateChatRequestOptions, initialMessageContext?: IAgentCreateChatInitialMessageContext): Promise { const sessionKey = session.toString(); const initialPrimaryDirectory = this._stateManager.getSessionSummary(sessionKey)?.workingDirectories?.[0]; if (this._workspaceConversionService.isPending(this._defaultChatUri(session), true)) { @@ -5432,9 +5432,6 @@ export class AgentService extends Disposable implements IAgentService { if (!provider) { throw new Error(`[AgentService] createChat: no provider for session ${sessionKey}`); } - if (!this._supportsChats(provider)) { - throw new Error(`[AgentService] createChat: provider ${provider.id} does not support multiple chats`); - } // 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 @@ -5442,7 +5439,33 @@ export class AgentService extends Disposable implements IAgentService { let forkedTurns: Turn[] | undefined; let forkedTitle: string | undefined; let forkedSourceTitle: string | undefined; - const { sideChat, title: requestedTitle, ...providerOptions } = options ?? {}; + let persistForkedLocalTurnsAfterDuplicateCheck: (() => void) | undefined; + const { sideChat, title: requestedTitle, initialMessage, ...providerOptions } = options ?? {}; + if (initialMessage && !initialMessageContext) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'An initial chat message requires trusted submitting-client context.'); + } + if (initialMessage && initialMessage.origin?.kind !== MessageKind.User) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'An initial chat message must have user origin.'); + } + // `initialMessage` remains a legacy, ungated protocol field. The + // capability only attests the stronger composed-operation guarantees, so + // derive those guarantees from the provider that actually owns this chat. + const capabilityBackedInitialFork = initialMessageContext?.capabilityBackedFork === true + && initialMessage !== undefined + && providerOptions.fork !== undefined + && provider.getDescriptor().capabilities?.multipleChats?.forkWithInitialMessage === true; + if (chat.toString() === this._defaultChatUri(session) && initialMessage && initialMessageContext) { + if (capabilityBackedInitialFork) { + await this._chatCatalogMutationSequencer.queue(sessionKey, async () => { + this._throwIfInitialForkTargetExists(sessionKey, chat); + }); + } + await this._acceptInitialMessage(sessionKey, chat, initialMessage, { ...initialMessageContext, capabilityBackedFork: capabilityBackedInitialFork }); + return; + } + if (!this._supportsChats(provider)) { + throw new Error(`[AgentService] createChat: provider ${provider.id} does not support multiple chats`); + } const explicitTitle = requestedTitle?.trim() || undefined; const normalizedProviderOptions: IAgentCreateChatOptions = { ...providerOptions, @@ -5519,8 +5542,15 @@ export class AgentService extends Disposable implements IAgentService { // Carry forked host-injected local turns (`/rename`, `!command`) // into the new chat so they survive reload and anchor future - // fork/truncate. - this._persistForkedLocalTurns(sessionKey, sourceChatKey, chat.toString(), slice, forkedTurns, turnIdMapping); + // fork/truncate. A composed fork first reserves its target in the + // serialized catalog path, so a rejected repeat cannot rewrite an + // existing target's local-turn metadata. + const persistForkedLocalTurns = () => this._persistForkedLocalTurns(sessionKey, sourceChatKey, chat.toString(), slice, forkedTurns!, turnIdMapping); + if (capabilityBackedInitialFork) { + persistForkedLocalTurnsAfterDuplicateCheck = persistForkedLocalTurns; + } else { + persistForkedLocalTurns(); + } const forkedTitlePrefix = localize('agentHost.forkedTitlePrefix', "Forked: "); forkedSourceTitle = sourceState?.title || this._stateManager.getSessionState(sourceSessionKey)?.title; @@ -5545,10 +5575,14 @@ export class AgentService extends Disposable implements IAgentService { } const createResult = await this._chatCatalogMutationSequencer.queue(sessionKey, async () => { + if (capabilityBackedInitialFork) { + this._throwIfInitialForkTargetExists(sessionKey, chat); + } if (this._workspaceConversionService.isPending(this._defaultChatUri(session), true) || this._stateManager.getSessionSummary(sessionKey)?.workingDirectories?.[0] !== initialPrimaryDirectory) { throw new Error('The session workspace changed while preparing the new chat. Try creating the chat again.'); } + 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); @@ -5649,13 +5683,53 @@ export class AgentService extends Disposable implements IAgentService { if (createResult?.backingSession) { await this._markChatBacking(createResult.backingSession, chat); } - - // Refine the forked chat's placeholder `Forked: …` title into one - // derived from the inherited chat. Forks seed pre-existing - // turns, so the normal first-message/first-turn title generation never - // fires for them — this is the fork-time equivalent. + // A prompt-bearing fork must establish its one strategy-owned title path + // before the first new turn is admitted. Explicit titles remain user-owned. if (explicitTitle === undefined && forkedTurns && forkedTurns.length > 0 && forkedTitle !== undefined) { - this._sideEffects.generateForkedTitle(sessionKey, chat.toString(), forkedTurns, forkedTitle, forkedSourceTitle); + this._sideEffects.generateForkedTitle( + sessionKey, + chat.toString(), + forkedTurns, + forkedTitle, + forkedSourceTitle, + initialMessage?.text, + ); + } + if (initialMessage && initialMessageContext) { + await this._acceptInitialMessage(sessionKey, chat, initialMessage, { ...initialMessageContext, capabilityBackedFork: capabilityBackedInitialFork }); + } + + } + + private _throwIfInitialForkTargetExists(session: string, chat: URI): void { + const state = this._stateManager.getSessionState(session); + const defaultChat = this._defaultChatUri(session); + if (chat.toString() === defaultChat || (state !== undefined && this._catalogChatsFromState(state, defaultChat).some(candidate => candidate.uri === chat.toString()))) { + throw new ProtocolError(AhpErrorCodes.AlreadyExists, `Chat already exists: ${chat.toString()}`); + } + } + + private async _acceptInitialMessage(session: string, chat: URI, message: Message, context: IAgentCreateChatInitialMessageContext): Promise { + const action: ChatTurnStartedAction = { + type: ActionType.ChatTurnStarted, + turnId: generateUuid(), + startedAt: new Date().toISOString(), + message, + }; + try { + const rewritten = await this._rewriteUserMessageAttachments(session, action, context.clientId, true); + const rejection = this._turnService.startClientTurn(chat, rewritten, context.clientId, context.telemetryContext); + if (rejection) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidRequest, rejection.message, rejection); + } + } catch (error) { + if (!context.capabilityBackedFork) { + throw error; + } + const cause = error instanceof ProtocolError + ? { code: error.code, message: error.message, ...(error.data === undefined ? {} : { data: error.data }) } + : { code: JSON_RPC_INTERNAL_ERROR, message: toErrorMessage(error) }; + throw new ProtocolError(AhpErrorCodes.InitialMessageRejected, cause.message, { chat: chat.toString(), cause }); } } @@ -8478,20 +8552,20 @@ export class AgentService extends Disposable implements IAgentService { * etc.) the original attachment is preserved so the agent still has a * chance to make use of it. */ - private async _rewriteUserMessageAttachments(channel: string, action: T, clientId: string): Promise { + private async _rewriteUserMessageAttachments(channel: string, action: T, clientId: string, throwOnFailure = false): Promise { const attachments = action.message.attachments; if (!attachments?.length) { return action; } const attachmentsRoot = this._attachmentsRoot(channel); - const rewritten = await Promise.all(attachments.map(a => this._rewriteSingleAttachment(a, attachmentsRoot, clientId))); + const rewritten = await Promise.all(attachments.map(a => this._rewriteSingleAttachment(a, attachmentsRoot, clientId, throwOnFailure))); return { ...action, message: { ...action.message, attachments: rewritten }, }; } - private async _rewriteSingleAttachment(attachment: MessageAttachment, attachmentsRoot: URI, clientId: string): Promise { + private async _rewriteSingleAttachment(attachment: MessageAttachment, attachmentsRoot: URI, clientId: string, throwOnFailure: boolean): Promise { try { if (attachment.type === MessageAttachmentKind.EmbeddedResource) { const bytes = decodeBase64(attachment.data).buffer; @@ -8521,6 +8595,9 @@ export class AgentService extends Disposable implements IAgentService { } } catch (err) { this._logService.warn(`[AgentService] Failed to rewrite attachment '${attachment.label}': ${toErrorMessage(err)}`); + if (throwOnFailure) { + throw err; + } } return attachment; } diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 50222cefc07c0d..a65c1ef2098a33 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -1968,8 +1968,8 @@ export class AgentSideEffects extends Disposable { * (`chatChannel` undefined) or peer chat from its inherited chat * turns, replacing the placeholder `Forked: …` title once ready. */ - generateForkedTitle(channel: ProtocolURI, chatChannel: ProtocolURI | undefined, turns: readonly Turn[], fallbackTitle: string, sourceTitle?: string): void { - this._titleController.generateForkedTitle(channel, chatChannel, turns, fallbackTitle, sourceTitle); + generateForkedTitle(channel: ProtocolURI, chatChannel: ProtocolURI | undefined, turns: readonly Turn[], fallbackTitle: string, sourceTitle?: string, initialPrompt?: string): void { + this._titleController.generateForkedTitle(channel, chatChannel, turns, fallbackTitle, sourceTitle, initialPrompt); } markTitleAuto(channel: ProtocolURI, chatChannel: ProtocolURI | undefined, title: string): void { diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index 153f1fa1f37efa..70e6a96fa4ce8b 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -718,7 +718,7 @@ export class ClaudeAgent extends Disposable implements IAgent { displayName: localize('claudeAgent.displayName', "Claude"), description: localize('claudeAgent.description', "Claude agent backed by the Anthropic Claude Agent SDK"), capabilities: { - multipleChats: { fork: true, sideChat: true, initialTitle: true }, + multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true }, ...(this._isMultiRootEnabled() ? { multipleWorkingDirectories: { immutablePrimary: true } } : {}), }, }; diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 28df8a742b37d3..16a86d576855e8 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -4275,7 +4275,7 @@ export class CodexAgent extends Disposable implements IAgent { displayName: localize('codexAgent.displayName', "Codex"), description: localize('codexAgent.description', "Codex agent using session-selected model providers"), capabilities: { - multipleChats: { fork: true, sideChat: true, initialTitle: true }, + multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true }, ...(this._isMultiRootEnabled() ? { multipleWorkingDirectories: { immutablePrimary: true } } : {}), }, }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 7bae997512735d..f0f87f44993d2d 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -1594,7 +1594,7 @@ export class CopilotAgent extends Disposable implements IAgent { displayName: 'Copilot', description: localize('copilotAgent.description', "Copilot SDK agent running in the local agent host process"), capabilities: { - multipleChats: { fork: true, sideChat: true, initialTitle: true }, + multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true }, ...(this._isMultiRootEnabled() ? { multipleWorkingDirectories: { immutablePrimary: true } } : {}), }, }; diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 12f0cc2e2785c1..3d903f9aac3806 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -1832,14 +1832,15 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien const defaultChat = state.defaultChat ?? buildDefaultChatUri(params.channel); // The default chat is created alongside its session; creating it // again is a no-op. Any other chat URI spins up an additional chat. - if (URI.parse(params.chat).toString() === URI.parse(defaultChat).toString()) { + if (URI.parse(params.chat).toString() === URI.parse(defaultChat).toString() && params.initialMessage === undefined) { return null; } const source = params.source; - let options: IAgentCreateChatRequestOptions | undefined = params.title !== undefined || params.workingDirectories !== undefined + let options: IAgentCreateChatRequestOptions | undefined = params.title !== undefined || params.workingDirectories !== undefined || params.initialMessage !== undefined ? { ...(params.title !== undefined ? { title: params.title } : {}), ...(params.workingDirectories !== undefined ? { workingDirectories: params.workingDirectories.map(directory => URI.parse(directory)) } : {}), + ...(params.initialMessage !== undefined ? { initialMessage: params.initialMessage } : {}), } : undefined; if (source) { @@ -1847,6 +1848,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien case ChatSourceKind.Fork: options = { ...(params.title !== undefined ? { title: params.title } : {}), + ...(params.initialMessage !== undefined ? { initialMessage: params.initialMessage } : {}), fork: { source: URI.parse(source.chat), turnId: source.turnId }, }; break; @@ -1868,6 +1870,13 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien URI.parse(params.channel), URI.parse(params.chat), options, + params.initialMessage === undefined + ? undefined + : { + clientId: _client.clientId, + telemetryContext: _client.telemetryContext, + capabilityBackedFork: source?.kind === ChatSourceKind.Fork, + }, ); return null; }, 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 0262909dbd0ba6..44582c3560fd57 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -40,7 +40,7 @@ import { chatReducer } from '../../common/state/sessionReducers.js'; import { ProtocolError, type AhpServerNotification, type JsonRpcNotification, type JsonRpcRequest, type JsonRpcResponse, type ProtocolMessage } from '../../common/state/sessionProtocol.js'; import { hasKey } from '../../../../base/common/types.js'; import { mainWindow } from '../../../../base/browser/window.js'; -import { AUTOMATION_CATALOG_URI, buildChatUri, buildDefaultChatUri, createChatState, createSessionState, CustomizationType, MessageAttachmentKind, MessageKind, PendingMessageKind, readSessionExternal, readSessionWorkspaceless, ROOT_STATE_URI, SessionStatus, StateComponents, TurnState, customizationId, withSessionExternal, withSessionWorkspaceless, type ChatState, type SessionState } from '../../common/state/sessionState.js'; +import { AUTOMATION_CATALOG_URI, buildChatUri, buildDefaultChatUri, createChatState, createSessionState, CustomizationType, MessageAttachmentKind, MessageKind, PendingMessageKind, readSessionExternal, readSessionWorkspaceless, ROOT_STATE_URI, SessionStatus, StateComponents, TurnState, customizationId, withSessionExternal, withSessionWorkspaceless, type ChatState, type Message, type SessionState } from '../../common/state/sessionState.js'; import { AgentHostTransportFailureReason, NonReconnectableTransportError, type IClientTransport, type IProtocolTransport, type ITransportCloseDetails } from '../../common/state/sessionTransport.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { ITelemetryService, TelemetryConfiguration, TelemetryLevel, TELEMETRY_SETTING_ID } from '../../../telemetry/common/telemetry.js'; @@ -1502,6 +1502,96 @@ suite('AgentHostProtocolClient', () => { await resultPromise; }); + test('forwards the initial message snapshot and inherits missing message selections', async () => { + const { client, transport } = createClient(); + const initialMessage: Message = { + text: 'Summarize the attached image', + origin: { kind: MessageKind.User }, + attachments: [{ + type: MessageAttachmentKind.Resource, + uri: 'file:///workspace/screenshot.png', + label: 'screenshot.png', + }], + model: { id: 'message-model' }, + }; + + const resultPromise = client.createChat(sessionUri, chatUri, { + fork: { source: sourceUri, turnId: 'turn-1' }, + title: 'Fork title', + model: { id: 'request-model' }, + agent: { uri: 'agent://test/reviewer' }, + initialMessage, + }); + + assert.deepStrictEqual(transport.sentMessages[0], { + jsonrpc: '2.0', + id: 1, + method: 'createChat', + params: { + channel: sessionUri.toString(), + chat: chatUri.toString(), + title: 'Fork title', + initialMessage: { + ...initialMessage, + agent: { uri: 'agent://test/reviewer' }, + }, + source: { kind: ChatSourceKind.Fork, chat: sourceUri.toString(), turnId: 'turn-1' }, + }, + }); + + transport.fireMessage({ jsonrpc: '2.0', id: 1, result: null }); + await resultPromise; + }); + + test('inherits a request model when the initial message has none', async () => { + const { client, transport } = createClient(); + const resultPromise = client.createChat(sessionUri, chatUri, { + model: { id: 'request-model' }, + initialMessage: { text: 'Continue', origin: { kind: MessageKind.User } }, + }); + + assert.deepStrictEqual(transport.sentMessages[0], { + jsonrpc: '2.0', + id: 1, + method: 'createChat', + params: { + channel: sessionUri.toString(), + chat: chatUri.toString(), + initialMessage: { + text: 'Continue', + origin: { kind: MessageKind.User }, + model: { id: 'request-model' }, + }, + }, + }); + + transport.fireMessage({ jsonrpc: '2.0', id: 1, result: null }); + await resultPromise; + }); + + test('preserves initial-message rejection cause data', async () => { + const { client, transport } = createClient(); + const resultPromise = client.createChat(sessionUri, chatUri, { + initialMessage: { text: 'Continue', origin: { kind: MessageKind.User } }, + }); + const data = { + chat: chatUri.toString(), + cause: { code: AhpErrorCodes.PermissionDenied, message: 'Attachment access denied', data: { uri: 'file:///workspace/private.png' } }, + }; + + transport.fireMessage({ + jsonrpc: '2.0', + id: 1, + error: { code: AhpErrorCodes.InitialMessageRejected, message: 'Initial message rejected', data }, + }); + + await assertRemoteProtocolError(resultPromise, { + code: AhpErrorCodes.InitialMessageRejected, + message: 'Initial message rejected', + data, + }); + }); + test('forwards a side chat source and maps its working directories', async () => { const { client, transport } = createClient(); diff --git a/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts index 043c108bbda6dd..59b91a6a033499 100644 --- a/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts @@ -32,7 +32,7 @@ import { AgentHostClientConnectionKind } from '../../common/agentHostTelemetry.j import { AgentHostOTelEnabledSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelPolicyState, IAgentCreateChatRequestOptions, IAgentHostManagementService, IAgentHostOTelPolicyReadiness, IAgentHostOTelSettings } from '../../common/agentService.js'; import { ActionEnvelope, ActionType, INotification, NotificationType } from '../../common/state/sessionActions.js'; import { ProtocolError } from '../../common/state/sessionProtocol.js'; -import { ROOT_STATE_URI } from '../../common/state/sessionState.js'; +import { MessageAttachmentKind, MessageKind, ROOT_STATE_URI } from '../../common/state/sessionState.js'; import { LocalAgentHostManagementConnection, LocalAgentHostServiceClient, registerAgentHostClientChannels } from '../../electron-browser/localAgentHostService.js'; class CapturingNotificationService extends TestNotificationService { @@ -133,7 +133,7 @@ suite('LocalAgentHostServiceClient events', () => { assert.deepStrictEqual([beforeStart, service.clientConnectionKind], [AgentHostClientConnectionKind.Local, AgentHostClientConnectionKind.Local]); }); - test('routes title-only through AHP and model with title through management once', async () => { + test('routes title-only and prompt-bearing creation through AHP, while retaining legacy model management routing', async () => { const { service, createdChats } = createService(); const managementChats: { session: URI; chat: URI; options: IAgentCreateChatRequestOptions }[] = []; const managementService = new class extends mock() { @@ -146,9 +146,20 @@ suite('LocalAgentHostServiceClient events', () => { const session = URI.parse('agent-session://test/session'); const titleOnlyChat = URI.parse('agent-chat://test/session/title-only'); const modelChat = URI.parse('agent-chat://test/session/model'); + const promptChat = URI.parse('agent-chat://test/session/prompt'); await service.createChat(session, titleOnlyChat, { title: 'Title only' }); await service.createChat(session, modelChat, { title: 'Model title', model: { id: 'model-1' } }); + await service.createChat(session, promptChat, { + title: 'Prompt title', + model: { id: 'model-2' }, + agent: { uri: 'agent://test/reviewer' }, + initialMessage: { + text: 'Review this image', + origin: { kind: MessageKind.User }, + attachments: [{ type: MessageAttachmentKind.Resource, uri: 'file:///workspace/image.png', label: 'image.png' }], + }, + }); assert.deepStrictEqual({ protocol: createdChats, @@ -158,6 +169,19 @@ suite('LocalAgentHostServiceClient events', () => { session, chat: titleOnlyChat, options: { title: 'Title only' }, + }, { + session, + chat: promptChat, + options: { + title: 'Prompt title', + model: { id: 'model-2' }, + agent: { uri: 'agent://test/reviewer' }, + initialMessage: { + text: 'Review this image', + origin: { kind: MessageKind.User }, + attachments: [{ type: MessageAttachmentKind.Resource, uri: 'file:///workspace/image.png', label: 'image.png' }], + }, + }, }], management: [{ session, diff --git a/src/vs/platform/agentHost/test/node/agentHostE2EServerLease.test.ts b/src/vs/platform/agentHost/test/node/agentHostE2EServerLease.test.ts index 9f5814dfda46d5..b740d8a45933fb 100644 --- a/src/vs/platform/agentHost/test/node/agentHostE2EServerLease.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostE2EServerLease.test.ts @@ -41,6 +41,7 @@ suite('Agent Host E2E server lease diagnostics', () => { supportsSubagents: false, supportsMultipleChats: false, supportsInitialTitle: false, + supportsForkWithInitialMessage: false, supportsChatFork: false, supportsChatForkE2E: false, }); diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts index 9538b7f0f6855e..2cb05498a1f451 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts @@ -718,7 +718,7 @@ suite('AgentHostSessionTitleController', () => { }, { calls: 1, title: 'Generated title', seed: '', legacyTitle: 'Stale legacy title' }); }); - for (const rawSeed of ['', 'null', 'true', '42', '"seed"', '[]', '{}', '{"title":42,"turnIndex":0}', '{"title":"Add dark mode","turnIndex":"0"}', '{"title":"Add dark mode","turnIndex":-1}', '{"title":"Add dark mode","turnIndex":0.5}']) { + for (const rawSeed of ['', 'null', 'true', '42', '"seed"', '[]', '{}', '{"title":42,"turnIndex":0}', '{"title":"Add dark mode","turnIndex":"0"}', '{"title":"Add dark mode","turnIndex":-1}', '{"title":"Add dark mode","turnIndex":0.5}', '{"title":"Add dark mode","turnIndex":0,"sourceTitle":null}', '{"title":"Add dark mode","turnIndex":0,"initialPrompt":{}}']) { test(`ignores invalid persisted deferred title seed ${JSON.stringify(rawSeed)}`, async () => { const { controller, stateManager, session, db, copilotApiService } = setupDeferred(); controller.seedTitleFromFirstMessage(session.toString(), 'Add dark mode'); @@ -737,6 +737,31 @@ suite('AgentHostSessionTitleController', () => { }); } + test('restores deferred fork title context when optional seed fields are valid strings', async () => { + const { controller, stateManager, session, db, copilotApiService } = setupDeferred(); + controller.seedTitleFromFirstMessage(session.toString(), 'Add dark mode'); + await db.setMetadata('deferredTitleSeed', JSON.stringify({ + title: 'Add dark mode', + turnIndex: 0, + sourceTitle: 'Source title', + initialPrompt: 'Continue with the requested change', + })); + const restored = disposables.add(new AgentHostSessionTitleController(stateManager, { + sessionDataService: createSessionDataService(db), + copilotApiService, + getGitHubCopilotToken: () => 'gh-token', + }, new NullLogService())); + + await restored.restoreTitleGenerationStrategy(session.toString(), buildDefaultChatUri(session)); + stateManager.seedDefaultChatTurns(session.toString(), [firstTurn('Continue with the requested change', [textPart('Done')])]); + restored.refineTitleFromFirstTurn(session.toString()); + await waitForCondition(() => copilotApiService.utilityCalls.length === 1, 'restored deferred fork should refine'); + + const prompt = copilotApiService.utilityCalls[0].request.messages.find(message => message.role === 'user')?.content ?? ''; + assert.ok(prompt.includes('Source title')); + assert.ok(prompt.includes('Continue with the requested change')); + }); + for (const source of [AGENT_HOST_TITLE_SOURCE_USER, AGENT_HOST_TITLE_SOURCE_AGENT]) { test(`restored deferred seed never overrides ${source} rename provenance`, async () => { const { controller, stateManager, session, db, copilotApiService } = setupDeferred(); @@ -1833,6 +1858,65 @@ suite('AgentHostSessionTitleController', () => { }); }); + test('fork utility title includes the source title and initial request exactly once', async () => { + const copilotApiService = new TestCopilotApiService(); + const { controller, stateManager, session } = setup(copilotApiService); + const inherited = firstTurn('Original request', [textPart('Original answer')]); + + stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionTitleChanged, title: 'Forked: Source title' }); + controller.generateForkedTitle(session.toString(), undefined, [inherited], 'Forked: Source title', 'Source title', 'Implement the follow-up request'); + await waitForCondition(() => copilotApiService.utilityCalls.length === 1, 'fork title generation should start'); + + const prompt = copilotApiService.utilityCalls[0].request.messages.find(message => message.role === 'user')?.content ?? ''; + assert.deepStrictEqual({ + calls: copilotApiService.utilityCalls.length, + hasSourceTitle: prompt.includes('Source title'), + hasInitialRequest: prompt.includes('Implement the follow-up request'), + }, { + calls: 1, + hasSourceTitle: true, + hasInitialRequest: true, + }); + }); + + test('prompt-aware fork naming does not overwrite a target renamed before generation starts', async () => { + const copilotApiService = new TestCopilotApiService(); + const { controller, stateManager, session, db } = setup(copilotApiService); + const chat = buildChatUri(session.toString(), 'renamed-fork'); + const inherited = firstTurn('Original request', [textPart('Original answer')]); + stateManager.addChat(session.toString(), chat, { title: 'Manual title', turns: [inherited] }); + controller.markTitleRenamed(session.toString(), chat, 'Manual title'); + + controller.generateForkedTitle(session.toString(), chat, [inherited], 'Forked: Source title', 'Source title', 'Continue the work'); + + assert.deepStrictEqual({ + utilityCalls: copilotApiService.utilityCalls.length, + title: stateManager.getChatState(chat)?.title, + autoSource: await db.getMetadata(customChatTitleSourceMetadataKey(chat)), + }, { + utilityCalls: 0, + title: 'Manual title', + autoSource: undefined, + }); + }); + + test('prompt-aware fork context preserves new request intent within the title context budget', async () => { + const copilotApiService = new TestCopilotApiService(); + const { controller, stateManager, session } = setup(copilotApiService); + const inherited = firstTurn('Original request', [textPart('Original answer')]); + const sourceTitle = `source-start-${'s'.repeat(30_000)}-source-end`; + const initialPrompt = `prompt-start-${'p'.repeat(30_000)}-prompt-end`; + + stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionTitleChanged, title: 'Forked: Source title' }); + controller.generateForkedTitle(session.toString(), undefined, [inherited], 'Forked: Source title', sourceTitle, initialPrompt); + await waitForCondition(() => copilotApiService.utilityCalls.length === 1, 'prompt-aware title generation should start'); + + const prompt = copilotApiService.utilityCalls[0].request.messages.find(message => message.role === 'user')?.content ?? ''; + assert.ok(prompt.includes('prompt-start-')); + assert.ok(prompt.includes('-prompt-end')); + assert.ok(prompt.length <= 20_000 + 'Please write a brief title for the following conversation:\n\n'.length); + }); + test('generateExternalSessionTitle titles a surfaced external session from its first prompt', async () => { const copilotApiService = new TestCopilotApiService(); copilotApiService.response = 'Flaky renderer test'; diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnService.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnService.test.ts index ce02eb9346ac84..722a726ba392da 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnService.test.ts @@ -7,9 +7,11 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { mock } from '../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import type { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +import type { IInstantiationService, ServicesAccessor } from '../../../instantiation/common/instantiation.js'; import { NullLogService } from '../../../log/common/log.js'; -import type { IAgentHostChatContributions, ITurnEnd } from '../../common/agentHostChatContributionsService.js'; +import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; +import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; +import type { IAgentHostChatContributionHost, IAgentHostChatContributions, ISendTurnMessageOptions, ITurnEnd } from '../../common/agentHostChatContributionsService.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { buildDefaultChatUri, MessageKind, SessionStatus, TurnState } from '../../common/state/sessionState.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; @@ -128,4 +130,58 @@ suite('AgentHostTurnService', () => { activeTurnId: replacement.turnId, }); }); + + test('returns a client initial-message admission rejection without sending to the provider', () => { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const rejection = { errorType: 'permissionDenied', message: 'Initial message is not permitted' }; + const sent: ISendTurnMessageOptions[] = []; + const host: IAgentHostChatContributionHost = { + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + sendTurnMessage(request): void { + sent.push(request); + }, + }; + const contributions = new class extends mock() { + override getHost(): IAgentHostChatContributionHost { + return host; + } + }(); + const instantiationService = new class extends mock() { + override invokeFunction(_fn: (accessor: ServicesAccessor, ...args: any[]) => R, ...args: any[]): R { + (args[0] as { onRejected?: (error: typeof rejection) => void }).onRejected?.(rejection); + return undefined as R; + } + }(); + const service = new AgentHostTurnService(stateManager, contributions, instantiationService); + const session = URI.parse('copilot:/client-initial-message'); + const chat = URI.parse(buildDefaultChatUri(session)); + stateManager.createSession({ + resource: session.toString(), + provider: 'copilot', + title: 'Initial message', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + }); + + const result = service.startClientTurn(chat, { + type: ActionType.ChatTurnStarted, + turnId: 'prepared-turn', + startedAt: new Date(0).toISOString(), + message: { text: 'Start from the fork', origin: { kind: MessageKind.User } }, + }, 'submitting-client', { + ...createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + }); + + assert.deepStrictEqual({ + result, + sent, + activeTurn: stateManager.getChatState(chat.toString())?.activeTurn?.id, + }, { + result: rejection, + sent: [], + activeTurn: 'prepared-turn', + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index a43ec100ff4b67..b7a6014b1ef341 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -32,7 +32,7 @@ import { NullTelemetryService, NullTelemetryServiceShape } from '../../../teleme import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { TestAgentHostStartupTelemetryService } from './testAgentHostStartupTelemetryService.js'; import { AgentHostStartupPerformance, type IAgentHostStartupPerformance, type IAgentHostStartupMetrics } from '../../node/agentHostStartupPerformance.js'; -import { AgentHostLaunchKind } from '../../common/agentHostTelemetry.js'; +import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { chatCatalogV2ToCatalogChats } from '../../node/agentHostCatalogSourceResolver.js'; import { getTelemetryMigrationSessionId } from '../../common/agentTelemetryCorrelation.js'; import { FileService } from '../../../files/common/fileService.js'; @@ -23317,6 +23317,260 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('fork initial message is published after inherited history and sent once as the submitting user', async () => { + class MultiChatAgent extends MockAgent { + override async createChat(): Promise { } + } + const agent = disposables.add(new MultiChatAgent('copilot', { + multipleChats: { fork: true, initialTitle: true, forkWithInitialMessage: true }, + })); + registerTestAgentProvider(service, agent); + const session = await service.createSession({ provider: 'copilot' }); + const stateManager = getStateManager(service); + stateManager.seedDefaultChatTurns(session.toString(), [ + { id: 'source-turn', state: TurnState.Complete, message: { text: 'history', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, + ]); + const peer = URI.parse(buildChatUri(session, 'initial-message-peer')); + const sentAfterPublication: { turns: number; activeText: string | undefined; sender: string | undefined }[] = []; + disposables.add(agent.onDidSendMessage(call => { + sentAfterPublication.push({ + turns: stateManager.getChatState(peer.toString())?.turns.length ?? 0, + activeText: stateManager.getChatState(peer.toString())?.activeTurn?.message.text, + sender: call.senderClientId, + }); + })); + + await service.createChat(session, peer, { + fork: { source: session, turnId: 'source-turn' }, + initialMessage: { text: 'continue from here', origin: { kind: MessageKind.User } }, + }, { + clientId: 'initial-message-client', + telemetryContext: createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), + capabilityBackedFork: true, + }); + await waitForCondition(() => agent.sendMessageCalls.length === 1, 'Expected exactly one initial fork message to be sent'); + + assert.deepStrictEqual({ + message: agent.sendMessageCalls[0]?.prompt, + sentAfterPublication, + seededTurns: stateManager.getChatState(peer.toString())?.turns.length, + }, { + message: 'continue from here', + sentAfterPublication: [{ turns: 1, activeText: 'continue from here', sender: 'initial-message-client' }], + seededTurns: 1, + }); + }); + + test('legacy fork initial message remains accepted without the composed-operation capability', async () => { + class MultiChatAgent extends MockAgent { + override async createChat(): Promise { } + } + const agent = disposables.add(new MultiChatAgent('copilot', { + multipleChats: { fork: true, initialTitle: true }, + })); + registerTestAgentProvider(service, agent); + const session = await service.createSession({ provider: 'copilot' }); + const stateManager = getStateManager(service); + stateManager.seedDefaultChatTurns(session.toString(), [ + { id: 'source-turn', state: TurnState.Complete, message: { text: 'history', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, + ]); + const peer = URI.parse(buildChatUri(session, 'legacy-initial-message-peer')); + + await service.createChat(session, peer, { + fork: { source: session, turnId: 'source-turn' }, + initialMessage: { text: 'continue from here', origin: { kind: MessageKind.User } }, + }, { + clientId: 'legacy-initial-message-client', + telemetryContext: createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), + capabilityBackedFork: true, + }); + await waitForCondition(() => agent.sendMessageCalls.length === 1, 'Expected the legacy initial fork message to be sent'); + + assert.deepStrictEqual({ + chatCreated: !!stateManager.getChatState(peer.toString()), + prompt: agent.sendMessageCalls[0]?.prompt, + sender: agent.sendMessageCalls[0]?.senderClientId, + }, { + chatCreated: true, + prompt: 'continue from here', + sender: 'legacy-initial-message-client', + }); + }); + + test('legacy fork initial message preserves an attachment rejection without composed correlation', async () => { + class MultiChatAgent extends MockAgent { + override async createChat(): Promise { } + } + const agent = disposables.add(new MultiChatAgent('copilot', { + multipleChats: { fork: true, initialTitle: true }, + })); + registerTestAgentProvider(service, agent); + const session = await service.createSession({ provider: 'copilot' }); + getStateManager(service).seedDefaultChatTurns(session.toString(), [ + { id: 'source-turn', state: TurnState.Complete, message: { text: 'history', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, + ]); + const cause = new ProtocolError(AhpErrorCodes.PermissionDenied, 'Legacy attachment access denied', { request: { uri: 'file:///legacy-missing.txt' } }); + const clientResourceReader = service as unknown as { + _readClientResource: AgentService['_readClientResource']; + }; + clientResourceReader._readClientResource = async () => { + throw cause; + }; + + await assert.rejects(() => service.createChat(session, URI.parse(buildChatUri(session, 'legacy-attachment-rejected-peer')), { + fork: { source: session, turnId: 'source-turn' }, + initialMessage: { + text: 'continue from here', + origin: { kind: MessageKind.User }, + attachments: [{ type: MessageAttachmentKind.Resource, label: 'legacy-missing.txt', uri: URI.file('/legacy-missing.txt').toString() }], + }, + }, { + clientId: 'legacy-initial-message-client', + telemetryContext: createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), + capabilityBackedFork: true, + }), error => error === cause); + + assert.strictEqual(agent.sendMessageCalls.length, 0); + }); + + test('capability-backed initial fork rejects existing default and catalog peer targets without sending', async () => { + class MultiChatAgent extends MockAgent { + override async createChat(): Promise { } + } + const agent = disposables.add(new MultiChatAgent('copilot', { + multipleChats: { fork: true, initialTitle: true, forkWithInitialMessage: true }, + })); + registerTestAgentProvider(service, agent); + const session = await service.createSession({ provider: 'copilot' }); + const stateManager = getStateManager(service); + stateManager.seedDefaultChatTurns(session.toString(), [ + { id: 'source-turn', state: TurnState.Complete, message: { text: 'history', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, + ]); + const lazyPeer = URI.parse(buildChatUri(session, 'catalog-only-peer')); + stateManager.addChat(session.toString(), lazyPeer.toString()); + const context = { + clientId: 'initial-message-client', + telemetryContext: createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), + capabilityBackedFork: true, + }; + const options = { + fork: { source: session, turnId: 'source-turn' }, + initialMessage: { text: 'continue from here', origin: { kind: MessageKind.User } }, + }; + + await assert.rejects(() => service.createChat(session, URI.parse(buildDefaultChatUri(session)), options, context), error => error instanceof ProtocolError && error.code === AhpErrorCodes.AlreadyExists); + await assert.rejects(() => service.createChat(session, lazyPeer, options, context), error => error instanceof ProtocolError && error.code === AhpErrorCodes.AlreadyExists); + assert.strictEqual(agent.sendMessageCalls.length, 0); + }); + + test('concurrent capability-backed initial forks persist local turns and send only for the winner', async () => { + let providerCreates = 0; + class MultiChatAgent extends MockAgent { + override async createChat(): Promise { + providerCreates++; + } + } + const agent = disposables.add(new MultiChatAgent('copilot', { + multipleChats: { fork: true, initialTitle: true, forkWithInitialMessage: true }, + })); + registerTestAgentProvider(service, agent); + const session = await service.createSession({ provider: 'copilot' }); + const stateManager = getStateManager(service); + stateManager.seedDefaultChatTurns(session.toString(), [ + { id: 'source-turn', state: TurnState.Complete, message: { text: 'history', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, + ]); + const localTurnPersistence = service as unknown as { + _persistForkedLocalTurns: AgentService['_persistForkedLocalTurns']; + }; + const originalPersist = localTurnPersistence._persistForkedLocalTurns; + let localTurnWrites = 0; + localTurnPersistence._persistForkedLocalTurns = (...args) => { + localTurnWrites++; + originalPersist.call(service, ...args); + }; + const peer = URI.parse(buildChatUri(session, 'concurrent-initial-message-peer')); + const options = { + fork: { source: session, turnId: 'source-turn' }, + initialMessage: { text: 'continue from here', origin: { kind: MessageKind.User } }, + }; + const context = { + clientId: 'initial-message-client', + telemetryContext: createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), + capabilityBackedFork: true, + }; + + const outcomes = await Promise.allSettled([ + service.createChat(session, peer, options, context), + service.createChat(session, peer, options, context), + ]); + await waitForCondition(() => agent.sendMessageCalls.length === 1, 'Expected the winning initial fork to send once'); + + assert.deepStrictEqual({ + fulfilled: outcomes.filter(outcome => outcome.status === 'fulfilled').length, + rejectedCode: outcomes.find(outcome => outcome.status === 'rejected')?.status === 'rejected' + ? (outcomes.find(outcome => outcome.status === 'rejected') as PromiseRejectedResult).reason.code + : undefined, + providerCreates, + localTurnWrites, + sends: agent.sendMessageCalls.length, + }, { + fulfilled: 1, + rejectedCode: AhpErrorCodes.AlreadyExists, + providerCreates: 1, + localTurnWrites: 1, + sends: 1, + }); + }); + + test('fork initial attachment preparation rejection preserves the protocol cause after chat publication', async () => { + class MultiChatAgent extends MockAgent { + override async createChat(): Promise { } + } + const agent = disposables.add(new MultiChatAgent('copilot', { + multipleChats: { fork: true, initialTitle: true, forkWithInitialMessage: true }, + })); + registerTestAgentProvider(service, agent); + const session = await service.createSession({ provider: 'copilot' }); + const stateManager = getStateManager(service); + stateManager.seedDefaultChatTurns(session.toString(), [ + { id: 'source-turn', state: TurnState.Complete, message: { text: 'history', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, + ]); + const cause = new ProtocolError(AhpErrorCodes.PermissionDenied, 'Client attachment access denied', { request: { uri: 'file:///missing.txt' } }); + const clientResourceReader = service as unknown as { + _readClientResource: AgentService['_readClientResource']; + }; + clientResourceReader._readClientResource = async () => { + throw cause; + }; + const peer = URI.parse(buildChatUri(session, 'attachment-rejected-peer')); + + await assert.rejects(() => service.createChat(session, peer, { + fork: { source: session, turnId: 'source-turn' }, + initialMessage: { + text: 'continue from here', + origin: { kind: MessageKind.User }, + attachments: [{ type: MessageAttachmentKind.Resource, label: 'missing.txt', uri: URI.file('/missing.txt').toString() }], + }, + }, { + clientId: 'initial-message-client', + telemetryContext: createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), + capabilityBackedFork: true, + }), error => error instanceof ProtocolError + && error.code === AhpErrorCodes.InitialMessageRejected + && (error.data as { chat?: string; cause?: { code?: number; message?: string; data?: unknown } }).chat === peer.toString() + && (error.data as { cause?: { code?: number; message?: string; data?: unknown } }).cause?.code === AhpErrorCodes.PermissionDenied + && (error.data as { cause?: { code?: number; message?: string; data?: unknown } }).cause?.message === cause.message + && (error.data as { cause?: { code?: number; message?: string; data?: unknown } }).cause?.data === cause.data); + + assert.deepStrictEqual({ + chatRetained: !!stateManager.getChatState(peer.toString()), + sends: agent.sendMessageCalls.length, + }, { + chatRetained: true, + sends: 0, + }); + }); + test('fork publishes an explicit normalized title without automatic refinement', async () => { const sessionData = createPerSessionDataService(); const copilotApiService = new TestCopilotApiService(); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 23a6e803dfa7ac..98cc1cd9d52be7 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -1424,7 +1424,7 @@ suite('ClaudeAgent', () => { const desc = agent.getDescriptor(); assert.deepStrictEqual( { provider: desc.provider, displayName: desc.displayName, hasDescription: desc.description.length > 0, capabilities: desc.capabilities, agentHostCapabilities: agent.agentHostCapabilities }, - { provider: 'claude', displayName: 'Claude', hasDescription: true, capabilities: { multipleChats: { fork: true, sideChat: true, initialTitle: true } }, agentHostCapabilities: { workspaceConversion: false } }, + { provider: 'claude', displayName: 'Claude', hasDescription: true, capabilities: { multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true } }, agentHostCapabilities: { workspaceConversion: false } }, ); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts index cb73d32aacbd2c..c0487be0383f26 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts @@ -444,7 +444,7 @@ suite('CodexAgent createChat', () => { multipleChats: agent.getDescriptor().capabilities?.multipleChats, agentHostCapabilities: agent.agentHostCapabilities, }, { - multipleChats: { fork: true, sideChat: true, initialTitle: true }, + multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true }, agentHostCapabilities: { workspaceConversion: true }, }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 8f7ae9f989cd8b..90ef641184ff06 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -3119,7 +3119,7 @@ suite('CopilotAgent', () => { provider: 'copilotcli', displayName: 'Copilot', description: 'Copilot SDK agent running in the local agent host process', - capabilities: { multipleChats: { fork: true, sideChat: true, initialTitle: true } }, + capabilities: { multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true } }, }, agentHostCapabilities: { workspaceConversion: true }, }); @@ -3141,8 +3141,8 @@ suite('CopilotAgent', () => { scriptedMockDefault: scriptedMockAgent.getDescriptor().capabilities, explicitOldHost: oldHostAgent.getDescriptor().capabilities, }, { - mockDefault: { multipleChats: { fork: true, initialTitle: true } }, - scriptedMockDefault: { multipleChats: { fork: true, initialTitle: true } }, + mockDefault: { multipleChats: { fork: true, initialTitle: true, forkWithInitialMessage: true } }, + scriptedMockDefault: { multipleChats: { fork: true, initialTitle: true, forkWithInitialMessage: true } }, explicitOldHost: { multipleChats: { fork: true } }, }); } finally { diff --git a/src/vs/platform/agentHost/test/node/e2e/conformance/agentHostConformance.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/conformance/agentHostConformance.integrationTest.ts index 8849287d844717..fe52967fbb8d98 100644 --- a/src/vs/platform/agentHost/test/node/e2e/conformance/agentHostConformance.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/conformance/agentHostConformance.integrationTest.ts @@ -50,6 +50,7 @@ const REFERENCE_CONFIG: IAgentHostE2EProviderConfig = { planModeStyle: 'session-state', supportsMultipleChats: true, supportsInitialTitle: true, + supportsForkWithInitialMessage: true, supportsChatFork: true, supportsChatForkE2E: true, }; diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts index 91ec9f2d0dc8d0..bd657138c7ab6a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts @@ -403,6 +403,11 @@ export interface IAgentHostE2EProviderConfig { readonly supportsMultipleChats: boolean; /** Whether peer chats can be created with an explicit initial title. */ readonly supportsInitialTitle: boolean; + /** + * Whether a fork can atomically accept its first user message through the + * host-owned create-chat operation. + */ + readonly supportsForkWithInitialMessage?: boolean; /** Whether model-backed multiple-chat parity scenarios have deterministic fixtures. */ readonly supportsMultipleChatsE2E?: boolean; readonly supportsChatFork: boolean; diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts index b9172e4cdfe076..56fd69d8e45264 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts @@ -89,6 +89,7 @@ const CLAUDE_CONFIG: IAgentHostE2EProviderConfig = { // to invoke it. TODO: rework the prompt for Claude conventions. supportsMultipleChats: true, supportsInitialTitle: true, + supportsForkWithInitialMessage: true, supportsChatFork: true, // Claude cannot resolve a client-assigned AHP turn id to an SDK message UUID, // so a provider fork silently starts with fresh context. diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts b/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts index 4d02c570958fe2..576f4396922697 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts @@ -42,6 +42,7 @@ export const CODEX_CONFIG: IAgentHostE2EProviderConfig = { planModeStyle: 'input-request', supportsMultipleChats: true, supportsInitialTitle: true, + supportsForkWithInitialMessage: true, supportsMultipleChatsE2E: true, supportsWorkspacelessE2E: true, supportsRuntimeSlashCommandsE2E: true, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts b/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts index 7a723c7eadb4a2..2ed596386439cd 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts @@ -42,6 +42,7 @@ export const COPILOT_CONFIG: IAgentHostE2EProviderConfig = { planModeStyle: 'session-state', supportsMultipleChats: true, supportsInitialTitle: true, + supportsForkWithInitialMessage: true, supportsChatFork: true, supportsChatForkE2E: true, }; diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts index 8c9269f9013394..2907339c8c91e4 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts @@ -10,6 +10,7 @@ import { join } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ActionType, type ChatErrorAction, type ChatToolCallReadyAction, type SessionChatAddedAction } from '../../../../common/state/sessionActions.js'; import { ChatSourceKind, CompletionItemKind, type CompletionsResult, type ListSessionsResult, type SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import { AhpErrorCodes } from '../../../../common/state/protocol/common/errors.js'; import { buildChatUri, buildDefaultChatUri, @@ -282,11 +283,13 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { fork: agent?.capabilities?.multipleChats?.fork ?? false, sideChat: agent?.capabilities?.multipleChats?.sideChat ?? false, initialTitle: agent?.capabilities?.multipleChats?.initialTitle ?? false, + forkWithInitialMessage: agent?.capabilities?.multipleChats?.forkWithInitialMessage ?? false, }, { multipleChats: config.supportsMultipleChats, fork: config.supportsChatFork, sideChat: config.supportsSideChats ?? false, initialTitle: config.supportsInitialTitle, + forkWithInitialMessage: config.supportsForkWithInitialMessage ?? false, }); }); @@ -629,6 +632,71 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { assert.deepStrictEqual((await chatState(peer)).turns, []); }, config.supportsMultipleChats); + conformanceTest(context, 'fork with an initial local message publishes before returning an accepted turn snapshot', async function () { + const { sessionUri, defaultChatUri } = await createSession('fork-initial-message'); + await driveTurn(defaultChatUri, 'fork-initial-message-source', '/rename Source Chat', 1); + await context.client.call('subscribe', { channel: sessionUri }); + context.client.clearReceived(); + + const peer = buildChatUri(sessionUri, 'fork-with-initial-message'); + const chatAddedPromise = context.client.waitForNotification(notification => + isActionNotification(notification, 'session/chatAdded') + && getActionEnvelope(notification).channel === sessionUri + && (getActionEnvelope(notification).action as SessionChatAddedAction).summary.resource === peer + ); + const initialMessage = { + text: '/rename Initial Message Title', + origin: { kind: MessageKind.User }, + attachments: [{ type: MessageAttachmentKind.Simple, label: 'Initial context', modelRepresentation: 'local initial context' }], + }; + const createChat = context.client.call('createChat', { + channel: sessionUri, + chat: peer, + title: 'Explicit Fork Title', + source: { kind: ChatSourceKind.Fork, chat: defaultChatUri, turnId: 'fork-initial-message-source' }, + initialMessage, + }, 30_000); + + const chatAddedEnvelope = getActionEnvelope(await chatAddedPromise); + await createChat; + const chatAdded = chatAddedEnvelope.action as SessionChatAddedAction; + const initialChat = await context.client.call('subscribe', { channel: peer }); + const state = initialChat.snapshot!.state as ChatState; + const initialTurnCount = state.turns.filter(turn => turn.message.text === '/rename Initial Message Title').length + + (state.activeTurn?.message.text === '/rename Initial Message Title' ? 1 : 0); + const initialTurnMessage = [...state.turns.map(turn => turn.message), ...(state.activeTurn ? [state.activeTurn.message] : [])] + .find(message => message.text === '/rename Initial Message Title'); + + assert.deepStrictEqual({ + capability: true, + snapshotAfterPublication: initialChat.snapshot!.fromSeq > chatAddedEnvelope.serverSeq, + firstPublishedTitle: chatAdded.summary.title, + initialTurnCount, + initialAttachment: initialTurnMessage?.attachments?.map(attachment => attachment.label), + }, { + capability: config.supportsForkWithInitialMessage ?? false, + snapshotAfterPublication: true, + firstPublishedTitle: 'Explicit Fork Title', + initialTurnCount: 1, + initialAttachment: ['Initial context'], + }); + + await assert.rejects(() => context.client.call('createChat', { + channel: sessionUri, + chat: peer, + title: 'Explicit Fork Title', + source: { kind: ChatSourceKind.Fork, chat: defaultChatUri, turnId: 'fork-initial-message-source' }, + initialMessage, + }, 30_000), error => (error as { code?: number }).code === AhpErrorCodes.AlreadyExists); + + const repeatedState = await chatState(peer); + assert.strictEqual( + repeatedState.turns.filter(turn => turn.message.text === '/rename Initial Message Title').length + + (repeatedState.activeTurn?.message.text === '/rename Initial Message Title' ? 1 : 0), + 1, + ); + }, config.supportsMultipleChats && config.supportsChatFork && (config.supportsForkWithInitialMessage ?? false)); + providerTest('peer chat completes a simple turn', async function () { const { sessionUri } = await createSession('peer-turn'); const peer = await createPeer(sessionUri, 'peer'); @@ -666,14 +734,38 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { const { sessionUri, defaultChatUri } = await createSession('fork-history'); const sourceResponse = await driveTurn(defaultChatUri, 'fork-source', 'Remember the code word FORKCODE. Reply exactly "ready".', 1); - const peer = await createPeer(sessionUri, 'fork', { kind: ChatSourceKind.Fork, chat: defaultChatUri, turnId: 'fork-source' }); - await context.client.call('subscribe', { channel: peer }); - const response = await driveTurn(peer, 'fork-turn', 'What code word did I ask you to remember? Reply with only the code word.', 2); + const peer = buildChatUri(sessionUri, 'fork'); + await context.client.call('createChat', { + channel: sessionUri, + chat: peer, + source: { kind: ChatSourceKind.Fork, chat: defaultChatUri, turnId: 'fork-source' }, + initialMessage: { + text: 'What code word did I ask you to remember? Reply with only the code word.', + origin: { kind: MessageKind.User }, + }, + }, 30_000); + let state = (await context.client.call('subscribe', { channel: peer })).snapshot!.state as ChatState; + if (!state.turns.some(turn => turn.message.text === 'What code word did I ask you to remember? Reply with only the code word.')) { + const completion = await context.client.waitForNotification(notification => + (isActionNotification(notification, 'chat/turnComplete') || isActionNotification(notification, 'chat/error')) + && getActionEnvelope(notification).channel === peer, + ); + if (isActionNotification(completion, 'chat/error')) { + const action = getActionEnvelope(completion).action as ChatErrorAction; + throw new Error(`Fork initial message error: ${JSON.stringify(action.part.error)}`); + } + state = await chatState(peer); + } + const forkTurn = state.turns.find(turn => turn.message.text === 'What code word did I ask you to remember? Reply with only the code word.'); + const response = forkTurn?.responseParts + .filter(part => part.kind === ResponsePartKind.Markdown) + .map(part => part.content) + .join('') ?? ''; const messages = observedModelMessages(context.observedModelRequestBodies.at(-1) ?? ''); const priorAssistantResponse = sourceResponse.trim(); assert.deepStrictEqual({ - seededMessages: (await chatState(peer)).turns.map(turn => turn.message.text), + seededMessages: state.turns.map(turn => turn.message.text), priorAssistantResponseIsNonEmpty: priorAssistantResponse.length > 0, responseHasCodeWord: /FORKCODE/i.test(response), requestHasPriorUserMessage: messages.some(message => message.role === 'user' && message.content.includes('Remember the code word FORKCODE')), diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 1bbcd2322e9de8..c697c24b9f37e5 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -131,7 +131,7 @@ export class MockAgent implements IAgent { constructor( readonly id: AgentProvider = 'mock', - private readonly _capabilities: IAgentCapabilities = { multipleChats: { fork: true, initialTitle: true } }, + private readonly _capabilities: IAgentCapabilities = { multipleChats: { fork: true, initialTitle: true, forkWithInitialMessage: true } }, readonly agentHostCapabilities: IAgent['agentHostCapabilities'] = { workspaceConversion: false }, autoDiscover = true, ) { @@ -569,7 +569,7 @@ export class ScriptedMockAgent implements IAgent { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent', - capabilities: this._supportsMultipleChats ? { multipleChats: { fork: true, initialTitle: true } } : undefined, + capabilities: this._supportsMultipleChats ? { multipleChats: { fork: true, initialTitle: true, forkWithInitialMessage: true } } : undefined, }; } diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 007cf0bd1f71aa..f77eb46c540c71 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -33,7 +33,7 @@ import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesy import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { type IAgent, type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agent.js'; -import { type IAgentHostManagedSettingsDiagnostics, type IAgentHostNetworkDiagnosticsInfo, type IAgentHostNetworkFetchResult, type IAgentService } from '../../common/agentService.js'; +import { type IAgentCreateChatInitialMessageContext, type IAgentHostManagedSettingsDiagnostics, type IAgentHostNetworkDiagnosticsInfo, type IAgentHostNetworkFetchResult, type IAgentService } from '../../common/agentService.js'; import { DevContainerConnectExtensionMethod, DevContainerDisconnectExtensionMethod, DevContainerIsDockerAvailableExtensionMethod, DevContainerOutputNotification, RemoveSessionArtifactExtensionMethod, RequestAgentHostMcpAuthenticationExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, StopBackgroundWorkExtensionMethod, supportsAgentHostArtifactRemoval, supportsAgentHostDevContainers, type IAgentHostMcpAuthenticationRequest } from '../../common/agentHostExtensionProtocol.js'; import { ChatSourceKind, CompletionsParams, CompletionsResult, ContentEncoding, CreateTerminalParams, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult, ResourceType } from '../../common/state/protocol/commands.js'; import type { AutomationCapabilities, Implementation } from '../../common/state/protocol/common/commands.js'; @@ -281,10 +281,10 @@ class MockAgentService implements IAgentService { async fetchAutomationRuns(_params: FetchAutomationRunsParams): Promise { return {}; } async getCompletionTriggerCharacters(): Promise { return []; } async disposeSession(_session: URI): Promise { } - readonly createdChats: { session: string; chat: string; options?: IAgentCreateChatRequestOptions }[] = []; + readonly createdChats: { session: string; chat: string; options?: IAgentCreateChatRequestOptions; initialMessageContext?: IAgentCreateChatInitialMessageContext }[] = []; readonly disposedChats: { session: string; chat: string }[] = []; - async createChat(session: URI, chat: URI, options?: IAgentCreateChatRequestOptions): Promise { - this.createdChats.push({ session: session.toString(), chat: chat.toString(), ...(options ? { options } : {}) }); + async createChat(session: URI, chat: URI, options?: IAgentCreateChatRequestOptions, initialMessageContext?: IAgentCreateChatInitialMessageContext): Promise { + this.createdChats.push({ session: session.toString(), chat: chat.toString(), ...(options ? { options } : {}), ...(initialMessageContext ? { initialMessageContext } : {}) }); this._stateManager.addChat(session.toString(), chat.toString()); } async disposeChat(session: URI, chat: URI): Promise { @@ -5298,6 +5298,62 @@ suite('ProtocolServerHandler', () => { }); }); + test('createChat forwards an initial message with the submitting client context', async () => { + stateManager.createSession(makeSessionSummary()); + const transport = connectClient('client-initial-message'); + transport.sent.length = 0; + const responsePromise = waitForResponse(transport, 2); + const message = { text: 'Start the fork', origin: { kind: MessageKind.User } }; + + transport.simulateMessage(request(2, 'createChat', { + channel: sessionUri, + chat: peerChat, + initialMessage: message, + })); + const resp = await responsePromise; + + assert.deepStrictEqual({ + result: (resp as { result: null }).result, + options: agentService.createdChats[0].options, + clientId: agentService.createdChats[0].initialMessageContext?.clientId, + capabilityBackedFork: agentService.createdChats[0].initialMessageContext?.capabilityBackedFork, + }, { + result: null, + options: { initialMessage: message }, + clientId: 'client-initial-message', + capabilityBackedFork: false, + }); + }); + + test('createChat keeps a legacy fork initial message ungated at protocol ingress', async () => { + stateManager.createSession(makeSessionSummary()); + const transport = connectClient('client-legacy-fork-initial-message'); + transport.sent.length = 0; + const responsePromise = waitForResponse(transport, 2); + const message = { text: 'Continue the legacy fork', origin: { kind: MessageKind.User } }; + + transport.simulateMessage(request(2, 'createChat', { + channel: sessionUri, + chat: peerChat, + source: { kind: ChatSourceKind.Fork, chat: buildDefaultChatUri(sessionUri), turnId: 'turn-1' }, + initialMessage: message, + })); + const resp = await responsePromise; + + assert.deepStrictEqual({ + result: (resp as { result: null }).result, + options: agentService.createdChats[0].options, + candidateComposedFork: agentService.createdChats[0].initialMessageContext?.capabilityBackedFork, + }, { + result: null, + options: { + initialMessage: message, + fork: { source: URI.parse(buildDefaultChatUri(sessionUri)), turnId: 'turn-1' }, + }, + candidateComposedFork: true, + }); + }); + test('createChat forwards an exact title and fork source while ignoring working directories', async () => { stateManager.createSession(makeSessionSummary()); const transport = connectClient('client-cc'); diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 00617d2a324501..179869435933f7 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -235,7 +235,7 @@ A chat view binds its transcript to the content provider serving the chat's sess ### Multiple chats -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. +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. 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. diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 3c5f7bc68c9a25..6560d071c61829 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -117,7 +117,7 @@ The provider cache owns adapter identity. Catalog notifications describe members Provider-specific metadata such as pull-request provenance, changesets, agent configuration, and external visibility is translated inside this provider. Shared Sessions code consumes only provider-neutral fields and capabilities. -At its private AHP boundary, the provider reads the host's `multipleChats.initialTitle` capability when forwarding provider-neutral fork options. When support is advertised, it passes the requested title to `createChat`; when the capability is absent, it omits the title and performs a title-less fork. This AHP-specific decision is not projected through generic `ISessionCapabilities`. In both cases, the returned and opened chat is derived from the first host-published catalog summary, without an optimistic client title. +At its private AHP boundary, the provider reads the host's `multipleChats.initialTitle` capability when forwarding provider-neutral fork options. When support is advertised, it passes the requested title to `createChat`; when the capability is absent, it omits the title and performs a title-less fork. A prompt-bearing fork additionally requires `multipleChats.forkWithInitialMessage`; when it is advertised, the provider sends the inherited source selection and first `Message` in that same `createChat` request. When it is absent, the provider does not create a chat and silently drop the message. These AHP-specific decisions are not projected through generic `ISessionCapabilities`. The pre-existing AHP `initialMessage` field remains independently available to callers that do not require the composed fork guarantee. In all cases, the returned and opened chat is derived from the first host-published catalog summary, without an optimistic client title. Each chat's read state is projected from its chat summary independently of the session aggregate. In a multi-chat session, any unread normal user-visible chat forces the aggregate unread; Tool/subagent chats are excluded. When the last unread participating chat becomes read, the host promotes the aggregate to read; repeated read actions do not clear an independently unread session. When supported by the negotiated protocol version, activating a chat or invoking Mark as Read or Mark as Unread on a session row dispatches the chat-level read-state action for the represented main chat and updates that chat facade optimistically; the host remains responsible for aggregate synchronization, persistence, and fan-out. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostForkActions.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostForkActions.ts index e9dbc85961d5dd..1a93029a4caafc 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostForkActions.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostForkActions.ts @@ -11,7 +11,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { ILogService } from '../../../../../platform/log/common/log.js'; import { ForkConversationAction, IForkConversationOptions } from '../../../../../workbench/contrib/chat/browser/actions/chatForkActions.js'; import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; -import { IChatSessionRequestHistoryItem } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ChatSessionForkError, IChatSessionRequestHistoryItem } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { isAgentHostProviderId } from '../../../../common/agentHostSessionsProvider.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -46,12 +46,21 @@ registerAction2(class extends ForkConversationAction { return false; } - const forkOptions: IForkChatOptions | undefined = options?.title !== undefined ? { title: options.title } : undefined; + const forkOptions: IForkChatOptions | undefined = options?.title !== undefined || options?.initialMessage !== undefined + ? { ...(options.title !== undefined ? { title: options.title } : {}), ...(options.initialMessage !== undefined ? { initialMessage: options.initialMessage } : {}) } + : undefined; const newChat = await sessionsManagementService.forkChatInSession(session, sourceSessionResource, turnId, forkOptions); - if (options?.toSide) { - await sessionsService.openChatToSide(session, newChat.resource, { referenceChatResource: sourceSessionResource }); - } else { - await sessionsService.openChat(session, newChat.resource); + try { + if (options?.toSide) { + await sessionsService.openChatToSide(session, newChat.resource, { referenceChatResource: sourceSessionResource }); + } else { + await sessionsService.openChat(session, newChat.resource); + } + } catch (error) { + if (options?.initialMessage) { + throw new ChatSessionForkError('unknown', newChat.resource, error); + } + throw error; } logService.trace(`[AgentHostSessions] Forked conversation into new chat ${newChat.resource.toString()} in session ${session.sessionId}`); return true; @@ -71,6 +80,22 @@ registerAction2(class extends ForkConversationAction { return super._openForkedSession(instantiationService, parentSessionResource, forkedSessionResource); } + // A rejected initial prompt is recovered through the base action after + // the host has already created a peer chat. It is not a top-level + // session, so opening it through the legacy session path would wait for + // a session row that will never appear. + const forkedChat = options?.initialMessage + ? sessionsManagementService.getSessionForChatResource(forkedSessionResource) + : undefined; + if (forkedChat?.session === parentSession) { + if (options?.toSide) { + await sessionsService.openChatToSide(parentSession, forkedSessionResource, { referenceChatResource: parentSessionResource }); + } else { + await sessionsService.openChat(parentSession, forkedSessionResource); + } + return; + } + if (!sessionsManagementService.getSession(forkedSessionResource)) { let listener: IDisposable | undefined; const appeared = await raceTimeout(new Promise(resolve => { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index e40f626ee5b719..ef67970e11a94b 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -6712,6 +6712,37 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } const canSetInitialTitle = this._agentCapabilities.get()?.get(cached.agentProvider)?.multipleChats?.initialTitle === true; + // Prompt-bearing forks must use the Chat Sessions provider's existing + // fork callback. It captures and prepares the source composer exactly + // once, then asks the host to create and accept the first message as one + // operation. The legacy bare/named path below intentionally remains on + // the Sessions provider's direct transport behavior. + if (options?.initialMessage) { + this._keepSessionStateAlive(cached.sessionId); + const forked = await this._chatSessionsService.forkChatSession( + sourceChat, + undefined, + CancellationToken.None, + { + ...(options.title !== undefined ? { title: options.title } : {}), + sourceRequestId: turnId, + initialMessage: options.initialMessage, + }, + ); + const chat = await waitForState( + cached.chats.map(chats => chats.find(candidate => isEqual(candidate.resource, forked.resource))), + (candidate): candidate is IChat => !!candidate, + ); + // The host-published initial message owns the new chat's model and + // mode. Do not replace either with a potentially stale source picker. + await this._retainChatSessionModel( + chat.resource, + cached.getChatModelId(chat.resource), + cached.getChatMode(chat.resource)?.id, + ); + return chat; + } + const sessionUri = cached.backendUri; const newChatId = generateUuid(); const chatUri = URI.parse(buildChatUri(sessionUri, newChatId)); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostForkActions.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostForkActions.test.ts index 98420b3cfa4fa0..d6e98d6847779f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostForkActions.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostForkActions.test.ts @@ -14,10 +14,12 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { TestNotificationService } from '../../../../../../platform/notification/test/common/testNotificationService.js'; import { ForkConversationActionId } from '../../../../../../workbench/contrib/chat/browser/actions/chatForkActions.js'; import { IChatWidgetService } from '../../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService } from '../../../../../../workbench/contrib/chat/common/chatService/chatService.js'; -import { IChatSession, IChatSessionsService } from '../../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ChatSessionForkError, IChatSession, IChatSessionsService } from '../../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IChatModel, IChatRequestModel } from '../../../../../../workbench/contrib/chat/common/model/chatModel.js'; import { IChatRequestViewModel } from '../../../../../../workbench/contrib/chat/common/model/chatViewModel.js'; import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../../common/agentHostSessionsProvider.js'; @@ -31,7 +33,7 @@ const forkCommand = CommandsRegistry.getCommand(ForkConversationActionId)!; suite('Agent Host fork actions', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function createHarness(multipleChats: boolean, peer = false) { + function createHarness(multipleChats: boolean, peer = false, requestIds = ['request-1', 'request-2']) { const instantiationService = store.add(new TestInstantiationService()); const sourceResource = URI.parse('agent-host-copilotcli:/source'); const sourceChat = upcastPartial({ resource: peer ? sourceResource.with({ fragment: 'peer' }) : sourceResource }); @@ -48,26 +50,31 @@ suite('Agent Host fork actions', () => { const started = new DeferredPromise(); let forkAvailable = true; let forkGate: Promise | undefined; + let forkError: Error | undefined; const forkCalls: { kind: string; source: URI; requestId: string | undefined }[] = []; const forkOptions: { readonly title?: string }[] = []; const opens: { kind: string; resource: URI; options?: IOpenSessionOptions & { referenceChatResource?: URI; referenceSessionId?: string } }[] = []; instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(INotificationService, new TestNotificationService()); instantiationService.stub(IChatWidgetService, upcastPartial({ getWidgetBySessionResource: () => undefined })); instantiationService.stub(IChatService, upcastPartial({ getSession: () => upcastPartial({ - getRequests: () => ['request-1', 'request-2'].map(id => upcastPartial({ id })), + getRequests: () => requestIds.map(id => upcastPartial({ id })), }), })); instantiationService.stub(ISessionsManagementService, upcastPartial({ getSession: resource => isEqual(resource, source.resource) ? source : forkAvailable && isEqual(resource, forkChat.resource) ? forkSession : undefined, - getSessionForChatResource: resource => isEqual(resource, sourceChat.resource) ? { session: source, chat: sourceChat } : undefined, + getSessionForChatResource: resource => isEqual(resource, sourceChat.resource) ? { session: source, chat: sourceChat } : isEqual(resource, forkChat.resource) ? { session: source, chat: forkChat } : undefined, onDidChangeSessions: onDidChangeSessions.event, forkChatInSession: async (_session, source, turnId, options?: { readonly title?: string }) => { forkCalls.push({ kind: 'chat', source, requestId: turnId }); forkOptions.push(options ?? {}); started.complete(); await forkGate; + if (forkError) { + throw forkError; + } return forkChat; }, })); @@ -105,11 +112,13 @@ suite('Agent Host fork actions', () => { onDidChangeSessions.fire({ added: [forkSession], removed: [], changed: [] }); } }, - run: async (toSide: boolean, slashCommand = false, title?: string) => { + setForkError: (error: Error | undefined) => forkError = error, + run: async (toSide: boolean, slashCommand = false, title?: string, initialMessage?: { readonly text: string }) => { + const options = title !== undefined || initialMessage !== undefined ? { ...(title !== undefined ? { title } : {}), ...(initialMessage ? { initialMessage } : {}) } : undefined; await instantiationService.invokeFunction( forkCommand.handler, slashCommand ? sourceChat.resource : toSide ? { element: checkpoint, toSide } : checkpoint, - slashCommand && title !== undefined ? { title } : undefined, + slashCommand ? options : undefined, ); }, }; @@ -157,11 +166,11 @@ suite('Agent Host fork actions', () => { }); }); - test(`does not navigate when ${multipleChats ? 'chat' : 'session'} forking fails`, async () => { + test(`retains the source and reports a generic initial-prompt failure from the ${multipleChats ? 'peer-chat' : 'contributed'} path`, async () => { const harness = createHarness(multipleChats); const gate = new DeferredPromise(); harness.setForkGate(gate.p); - const running = harness.run(true); + const running = harness.run(true, true, undefined, { text: 'keep this source draft' }); const rejected = assert.rejects(running, /Fork failed/); await harness.started.p; await gate.error(new Error('Fork failed')); @@ -189,6 +198,40 @@ suite('Agent Host fork actions', () => { assert.deepStrictEqual(harness.forkOptions, [{ title: ' Exact fork title ' }]); }); + test('opens a rejected initial-prompt peer through the parent session chat path', async () => { + const harness = createHarness(true, true); + harness.setForkError(new ChatSessionForkError('rejected', harness.forkChat.resource, new Error('rejected'))); + + await assert.rejects(harness.run(false, true, undefined, { text: 'recover this prompt' })); + + assert.deepStrictEqual(harness.opens, [{ + kind: 'chat', + resource: harness.forkChat.resource, + options: undefined, + }]); + }); + + test('uses the last reported request when /fork runs while the source is streaming', async () => { + const harness = createHarness(true, true, ['completed-turn', 'streaming-turn']); + await harness.run(false, true); + + assert.deepStrictEqual({ forks: harness.forkCalls, opens: harness.opens }, { + forks: [{ kind: 'chat', source: harness.sourceChat.resource, requestId: 'streaming-turn' }], + opens: [{ kind: 'chat', resource: harness.forkChat.resource, options: undefined }], + }); + }); + + test('falls back to the contributed fork when a multiple-chat source has no request history', async () => { + const harness = createHarness(true, true, []); + await harness.run(false, true); + await timeout(0); + + assert.deepStrictEqual({ forks: harness.forkCalls, opens: harness.opens }, { + forks: [{ kind: 'session', source: harness.sourceChat.resource, requestId: undefined }], + opens: [{ kind: 'session', resource: harness.forkChat.resource, options: { source: 'fork' } }], + }); + }); + test('waits for a separately forked session to appear before opening beside the source', async () => { const harness = createHarness(false); harness.setForkAvailable(false); 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 28a49d262b8236..10675c160e5ebd 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 @@ -633,7 +633,7 @@ class BackendSchemeTestProvider extends LocalAgentHostSessionsProvider { function createProvider(disposables: DisposableStore, agentHostService: MockAgentHostService, contributions = [ { type: 'agent-host-copilotcli', name: 'copilot', displayName: 'Copilot', description: 'test', icon: undefined }, -], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; acquireOrLoadSession?: (resource: URI) => Promise; languageModelsService?: Partial; languageModelIds?: string[]; lookupLanguageModel?: (modelId: string) => ILanguageModelChatMetadata | undefined; languageModelChanges?: Event; hiddenLanguageModelIds?: ReadonlySet; languageModelVisibilityChanges?: Event; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable; visibleSessions?: IObservable; activeClient?: Omit; activeClientAgents?: IObservable; activeClientScope?: (sessionType: string, roots: readonly URI[]) => IAgentCustomizationScope; storageService?: IStorageService; recentWorkspacesService?: ISessionsRecentWorkspacesService; isSessionsWindow?: boolean; confirmDelete?: boolean; workspaceTrusted?: boolean; requestWorkspaceTrust?: (uri: URI) => Promise; workspaceTrustBarrier?: DeferredPromise; workspaceTrustError?: Error; setUrisTrust?: (uris: URI[], trusted: boolean) => Promise; gitHubService?: IGitHubService; devContainerAgentHostService?: IDevContainerAgentHostService; sessionsProvidersService?: ISessionsProvidersService; pathService?: IPathService; labelService?: ILabelService; providerCtor?: typeof LocalAgentHostSessionsProvider }): LocalAgentHostSessionsProvider { +], options?: { chatSessionsService?: Partial; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; acquireOrLoadSession?: (resource: URI) => Promise; languageModelsService?: Partial; languageModelIds?: string[]; lookupLanguageModel?: (modelId: string) => ILanguageModelChatMetadata | undefined; languageModelChanges?: Event; hiddenLanguageModelIds?: ReadonlySet; languageModelVisibilityChanges?: Event; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable; visibleSessions?: IObservable; activeClient?: Omit; activeClientAgents?: IObservable; activeClientScope?: (sessionType: string, roots: readonly URI[]) => IAgentCustomizationScope; storageService?: IStorageService; recentWorkspacesService?: ISessionsRecentWorkspacesService; isSessionsWindow?: boolean; confirmDelete?: boolean; workspaceTrusted?: boolean; requestWorkspaceTrust?: (uri: URI) => Promise; workspaceTrustBarrier?: DeferredPromise; workspaceTrustError?: Error; setUrisTrust?: (uris: URI[], trusted: boolean) => Promise; gitHubService?: IGitHubService; devContainerAgentHostService?: IDevContainerAgentHostService; sessionsProvidersService?: ISessionsProvidersService; pathService?: IPathService; labelService?: ILabelService; providerCtor?: typeof LocalAgentHostSessionsProvider }): LocalAgentHostSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IAgentHostService, agentHostService); @@ -679,6 +679,7 @@ function createProvider(disposables: DisposableStore, agentHostService: MockAgen getChatSessionContribution: (chatSessionType: string) => contributions.find(c => c.type === chatSessionType), getAllChatSessionContributions: () => contributions, getOrCreateChatSession: async () => ({ onWillDispose: () => ({ dispose() { } }), sessionResource: URI.from({ scheme: 'test' }), history: [], dispose() { } }), + ...options?.chatSessionsService, }); instantiationService.stub(IChatService, { acquireOrLoadSession: options?.acquireOrLoadSession ?? (async () => undefined), @@ -10625,6 +10626,45 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('forkChat routes a prompt-bearing fork through Chat Sessions with the exact frontend source turn', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.setAgents([{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true } } } as AgentInfo]); + const calls: { resource: URI; requestId: string | undefined; text: string }[] = []; + const provider = createProvider(disposables, agentHost, undefined, { + chatSessionsService: { + forkChatSession: async (resource, _request, _token, options) => { + calls.push({ resource, requestId: options?.sourceRequestId, text: options?.initialMessage?.text ?? '' }); + return { resource: resource.with({ fragment: 'prompt-child' }), label: 'Prompt child', timing: { created: 0, lastRequestStarted: 0, lastRequestEnded: 0 } }; + }, + }, + }); + const session = setupMultiChatSession(provider, 'multi-fork-prompt'); + const sessionUri = AgentSession.uri('copilotcli', 'multi-fork-prompt').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const childChat = buildChatUri(sessionUri, 'prompt-child'); + agentHost.setSessionState('multi-fork-prompt', 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + makeChatSummary(childChat, 'Prompt child'), + ], { defaultChat })); + + // This is an unsent source picker selection. The prompt branch must + // not copy it onto the host-created target. + provider.setModel(session.sessionId, session.resource, 'agent-host-copilotcli:unsent-picker', ChatModelSource.Chosen); + const createCallsBefore = agentHost.createdChats.length; + const forked = await provider.forkChat(session.sessionId, session.resource, 'selected-turn', { + initialMessage: { text: 'Use the actual source turn' }, + }); + + assert.deepStrictEqual({ + calls: calls.map(call => ({ resource: call.resource.toString(), requestId: call.requestId, text: call.text })), + createCalls: agentHost.createdChats.length - createCallsBefore, + targetModel: forked.modelId.get(), + }, { + calls: [{ resource: session.resource.toString(), requestId: 'selected-turn', text: 'Use the actual source turn' }], + createCalls: 0, + targetModel: undefined, + }); + })); + test('forkChat omits an unsupported initial title and returns the host-generated 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); @@ -10653,7 +10693,7 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); - test('forkChat inherits the source peer chat\'s model, recorded as inherited', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + test('forkChat inherits the source peer model and agent instead of the default chat selection', () => runWithFakedTimers({ useFakeTimers: true }, async () => { // A fork continues the chat it was taken from, so it starts on that chat's model rather // than the session-level default it used to take, and records it as carried over rather // than chosen here. @@ -10693,7 +10733,9 @@ suite('LocalAgentHostSessionsProvider', () => { const peer = session.chats.get().find(c => c.resource.fragment === 'peer-1'); assert.ok(peer); activeSession.set({ sessionId: session.sessionId, activeChat: constObservable(peer!) } as IActiveSession, undefined); + provider.setModel(session.sessionId, session.resource, 'agent-host-copilotcli:default-model', ChatModelSource.Chosen); provider.setModel(session.sessionId, peer!.resource, 'agent-host-copilotcli:peer-model', ChatModelSource.Chosen); + provider.setAgent?.(session.sessionId, { uri: 'agent://peer', name: 'peer' }); const forked = await provider.forkChat(session.sessionId, peer!.resource, 'turn-1'); const call = agentHost.createdChats.at(-1); @@ -10701,7 +10743,7 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual({ forkSource: call?.options?.fork?.source.toString(), - // The source peer's model, not the session-level default the fork used to take. + defaultModelId: session.mainChat.get().modelId.get(), createdModel: call?.options?.model, forkedModelId: forkedChat?.modelId.get(), forkedModelSource: forkedChat?.modelSource.get(), @@ -10709,13 +10751,18 @@ suite('LocalAgentHostSessionsProvider', () => { .filter(entry => entry.resource === forked.resource.toString()) .map(entry => entry.state.selectedModel?.identifier) .filter((id): id is string => id !== undefined), + forkedInputModes: inputStates + .filter(entry => entry.resource === forked.resource.toString()) + .map(entry => entry.state.mode?.id) + .filter((id): id is string => id !== undefined), }, { forkSource: peerChat, + defaultModelId: 'agent-host-copilotcli:default-model', createdModel: { id: 'peer-model' }, forkedModelId: 'agent-host-copilotcli:peer-model', - // Inherited, not a choice, so `chat.defaultModel` may still seed the new chat. forkedModelSource: ChatModelSource.CarriedOver, forkedInputSelectedModels: ['agent-host-copilotcli:peer-model'], + forkedInputModes: ['agent://peer'], }); })); diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index 2f89c677638e96..fa6d029a85e070 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -12,6 +12,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatSessionInitialMessage } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ILanguageModelChatMetadataAndIdentifier, type IModelConfigurationAccess } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { ModelIdentifierResolution } from '../../../../workbench/contrib/chat/common/modelSelection.js'; import { IAutomationSessionTemplate } from '../../../../workbench/contrib/chat/common/automations/automation.js'; @@ -81,6 +82,8 @@ export interface ISendRequestOptions { /** Options for forking a chat within a session. */ export interface IForkChatOptions { + /** Optional captured prompt and context for a host-owned fork-and-send operation. */ + readonly initialMessage?: IChatSessionInitialMessage; readonly title?: string; } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 5248a58fc8918e..572dd3e397869e 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -264,7 +264,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('chat.renameSession', 'To rename the current chat session when supported, invoke the Rename command{0}. Agent Host sessions can be renamed after sending the first request.', ``)); } if (type === 'panelChat' || type === 'editsView' || type === 'agentView') { - content.push(localize('chat.forkSession', "In a fork-capable Agent Host chat, to create a new forked chat alongside the current chat in the same Agent Host session, type /fork followed by a title. When the connected Agent Host supports named forks, the new chat uses that title. Agent Hosts that support forking but not named titles use their normal title for the forked chat. Accepting /fork from slash-command completion creates a bare forked chat immediately. To add a title, type or insert the command instead.")); + content.push(localize('chat.forkSession', "In a fork-capable Agent Host chat, to create a new forked chat alongside the current chat in the same Agent Host session, type /fork followed by a title. When the connected Agent Host supports named forks, the new chat uses that title. Agent Hosts that support forking but not named titles use their normal title for the forked chat. Accepting /fork from slash-command completion creates a bare forked chat immediately. Select the /fork title template to insert /fork, or select /fork prompt to insert /fork prompt; selecting a template does not submit it. The title and prompt hints are suggestions and are not part of your input. Send /fork prompt followed by a prompt to create the fork and submit that prompt in the new chat. An empty prompt creates an ordinary fork. The prompt template is unavailable when the connected Agent Host does not support submitting the initial message with the fork. To add a title, type or insert the command instead.")); } if (type !== 'inlineChat') { content.push(localize('chat.linkContextMenu', "Focus a link in the chat transcript and press Shift+F10 to open its context menu. Use Copy Link to copy its target. File links with additional editor choices also provide Open With.")); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts index 8912af24b3dd01..9dd3538dd5acdc 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts @@ -5,6 +5,8 @@ import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; +import { isCancellationError } from '../../../../../base/common/errors.js'; import { revive } from '../../../../../base/common/marshalling.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; @@ -12,11 +14,13 @@ import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuId } from '../../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; import { ChatContextKeyExprs, ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatService, ResponseModelState } from '../../common/chatService/chatService.js'; import type { ISerializableChatData } from '../../common/model/chatModel.js'; import { isChatTreeItem, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; -import { IChatSessionForkOptions, IChatSessionRequestHistoryItem, IChatSessionsService } from '../../common/chatSessionsService.js'; +import { ChatSessionForkError, getChatSessionInitialMessageVariables, IChatSessionForkOptions, IChatSessionRequestHistoryItem, IChatSessionsService } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { CHAT_CATEGORY } from './chatActions.js'; import { ChatTreeItem, ChatViewPaneTarget, IChatWidgetService } from '../chat.js'; @@ -72,10 +76,15 @@ export class ForkConversationAction extends Action2 { // Check if this is a contributed session that supports forking const contentProviderSchemes = chatSessionsService.getContentProviderSchemes(); if (contentProviderSchemes.includes(getChatSessionType(sourceSessionResource))) { - if (await this._tryForkAsChat(instantiationService, sourceSessionResource, undefined, options)) { - return; + try { + if (await this._tryForkAsChat(instantiationService, sourceSessionResource, undefined, options)) { + return; + } + return await this.forkContributedChatSession(sourceSessionResource, undefined, false, chatSessionsService, instantiationService, options); + } catch (error) { + await this._handleForkPromptError(error, sourceSessionResource, instantiationService, options); + throw error; } - return await this.forkContributedChatSession(sourceSessionResource, undefined, false, chatSessionsService, instantiationService, options); } const chatModel = chatService.getSession(sourceSessionResource); @@ -175,10 +184,15 @@ export class ForkConversationAction extends Action2 { } } } - if (await this._tryForkAsChat(instantiationService, sessionResource, request, options)) { - return; + try { + if (await this._tryForkAsChat(instantiationService, sessionResource, request, options)) { + return; + } + return await this.forkContributedChatSession(sessionResource, request, true, chatSessionsService, instantiationService, options); + } catch (error) { + await this._handleForkPromptError(error, sessionResource, instantiationService, options); + throw error; } - return await this.forkContributedChatSession(sessionResource, request, true, chatSessionsService, instantiationService, options); } const chatModel = chatService.getSession(sessionResource); @@ -269,8 +283,12 @@ export class ForkConversationAction extends Action2 { private async forkContributedChatSession(sourceSessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, openForkedSessionImmediately: boolean, chatSessionsService: IChatSessionsService, instantiationService: IInstantiationService, options?: IForkConversationOptions) { const title = options?.title?.trim() || undefined; - const forkOptions: IChatSessionForkOptions | undefined = title ? { title } : undefined; - const pendingKey = JSON.stringify([sourceSessionResource.toString(), request?.id, title]); + const forkOptions: IChatSessionForkOptions | undefined = title || options?.initialMessage + ? { ...(title ? { title } : {}), ...(options?.initialMessage ? { initialMessage: options.initialMessage } : {}), ...(options?.sourceRequestId ? { sourceRequestId: options.sourceRequestId } : {}) } + : undefined; + // Message context is part of the operation identity: only an identical + // in-flight fork may coalesce. + const pendingKey = JSON.stringify([sourceSessionResource.toString(), request?.id, title, options?.initialMessage]); const pending = this.pendingFork.get(pendingKey); if (pending) { return pending; @@ -281,8 +299,15 @@ export class ForkConversationAction extends Action2 { try { const forkedItem = await chatSessionsService.forkChatSession(sourceSessionResource, request, cts.token, forkOptions); const open = () => this._openForkedSession(instantiationService, sourceSessionResource, forkedItem.resource, options); - if (openForkedSessionImmediately) { - await open(); + if (openForkedSessionImmediately || options?.initialMessage) { + try { + await open(); + } catch (error) { + if (options?.initialMessage) { + throw new ChatSessionForkError('unknown', forkedItem.resource, error); + } + throw error; + } } else { setTimeout(open, 0); } @@ -298,4 +323,68 @@ export class ForkConversationAction extends Action2 { this.pendingFork.delete(pendingKey); } } + + private async _handleForkPromptError(error: unknown, sourceSessionResource: URI, instantiationService: IInstantiationService, options: IForkConversationOptions | undefined): Promise { + if (!options?.initialMessage || isCancellationError(error)) { + return; + } + if (error instanceof ChatSessionForkError) { + if (error.kind === 'rejected') { + try { + await this._openForkedSession(instantiationService, sourceSessionResource, error.target, options); + } catch (openError) { + await instantiationService.invokeFunction(accessor => accessor.get(ILogService).error('Failed to open rejected fork target', openError)); + } + let restored = false; + await instantiationService.invokeFunction(async accessor => { + const widget = accessor.get(IChatWidgetService).getWidgetBySessionResource(error.target); + if (widget && widget.getInput() === '' && widget.input.attachmentModel.getAttachmentIDs().size === 0) { + widget.setInput(options.initialMessage!.text); + await widget.input.restoreAttachments(getChatSessionInitialMessageVariables(options.initialMessage!)); + restored = true; + } + }); + await instantiationService.invokeFunction(accessor => { + if (restored) { + accessor.get(INotificationService).error(localize('chat.forkInitialMessageRejected', "The fork was created, but its initial prompt was not accepted. It has been restored in the new chat.")); + return; + } + const initialMessage = options.initialMessage!; + const variables = getChatSessionInitialMessageVariables(initialMessage); + accessor.get(INotificationService).prompt( + Severity.Error, + localize('chat.forkInitialMessageRejectedKept', "The fork was created, but its initial prompt was not accepted."), + [{ + label: localize('chat.fork.restoreInitialPrompt', "Restore Prompt"), + keepOpen: true, + run: async () => { + await this._openForkedSession(instantiationService, sourceSessionResource, error.target, options); + await instantiationService.invokeFunction(async restoreAccessor => { + const targetWidget = restoreAccessor.get(IChatWidgetService).getWidgetBySessionResource(error.target); + if (targetWidget && targetWidget.getInput() === '' && targetWidget.input.attachmentModel.getAttachmentIDs().size === 0) { + targetWidget.setInput(initialMessage.text); + await targetWidget.input.restoreAttachments(variables); + } else { + restoreAccessor.get(INotificationService).warn(localize('chat.fork.restoreInitialPromptTargetBusy', "Clear the target chat draft before restoring this prompt.")); + } + }); + } + }] + ); + }); + } else { + await instantiationService.invokeFunction(accessor => { + accessor.get(INotificationService).prompt( + Severity.Warning, + localize('chat.forkInitialMessageUnknown', "The fork request outcome is unknown. Inspect the target before sending the prompt again."), + [{ label: localize('chat.fork.inspectTarget', "Inspect Target"), run: () => this._openForkedSession(instantiationService, sourceSessionResource, error.target, options) }] + ); + }); + } + return; + } + await instantiationService.invokeFunction(accessor => { + accessor.get(INotificationService).error(localize('chat.forkInitialMessageFailed', "The fork could not be created: {0}.", toErrorMessage(error))); + }); + } } 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 c5266cb2d423f6..0e7bf68010ca1d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -103,7 +103,7 @@ import { coerceImageBuffer } from '../../../common/chatImageExtraction.js'; import { ChatErrorLevel, ChatRequestQueueKind, ConfirmedReason, ElicitationState, IChatProgress, IChatQuestionAnswers, IChatService, IChatToolInvocation, IRemotePendingRequest, ToolConfirmKind, type IChatAutoModeResolutionPart, type IChatMcpAuthenticationRequired, type IChatMcpAuthenticationRequiredServer, type IChatMcpStartingServer, type IChatMultiSelectAnswer, type IChatPlanReviewResult, type IChatResponseErrorDetails, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData, type IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; import { isInConversationModelChoice } from '../../../common/modelSelection.js'; import { readAgentRuntimeModelConfiguration } from '../../../../../../platform/agentHost/common/meta/agentModelConfigurationMeta.js'; -import { IChatSession, IChatSessionContentProvider, IChatSessionForkOptions, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, isTerminalCommandPrompt, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult, type IChatSessionServerRequest } from '../../../common/chatSessionsService.js'; +import { ChatSessionForkError, getChatSessionInitialMessageVariables, IChatSession, IChatSessionContentProvider, IChatSessionForkOptions, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, isTerminalCommandPrompt, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult, type IChatSessionInitialMessage, type IChatSessionServerRequest } from '../../../common/chatSessionsService.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IWorkingCopyService } from '../../../../../services/workingCopy/common/workingCopyService.js'; import { ChatMode } from '../../../common/chatModes.js'; @@ -463,6 +463,7 @@ function emptyDraftFromLastTurn(state: ISessionWithDefaultChat): Message | undef if (!message?.model && !message?.agent) { return undefined; } + return { text: '', origin: { kind: MessageKind.User }, @@ -471,6 +472,12 @@ function emptyDraftFromLastTurn(state: ISessionWithDefaultChat): Message | undef }; } +type ForkInitialMessageSource = Pick; + +interface ICapturedForkInitialMessage { + readonly message: Message; +} + /** The draft the chat channel holds, so we only send real changes and can keep its model. */ export class DraftSyncState { private _synced: Message | undefined; @@ -785,6 +792,7 @@ class AgentHostChatSession extends Disposable implements IChatSession { readonly interruptActiveResponseCallback: IChatSession['interruptActiveResponseCallback']; readonly forkSession: IChatSession['forkSession']; + readonly supportsForkWithInitialMessage: NonNullable; readonly renameSession: IChatSession['renameSession']; readonly transferredState: IChatSession['transferredState']; prepareForClientTools: IChatSession['prepareForClientTools']; @@ -806,6 +814,7 @@ class AgentHostChatSession extends Disposable implements IChatSession { historySubagentObservations: IDisposable, onDispose: () => void, interruptActiveResponse: () => boolean, + private readonly _supportsForkWithInitialMessage: () => boolean, @ILogService private readonly _logService: ILogService, @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { @@ -861,6 +870,7 @@ class AgentHostChatSession extends Disposable implements IChatSession { this.interruptActiveResponseCallback = async () => interruptActiveResponse(); this.forkSession = this._forkSession; + this.supportsForkWithInitialMessage = this._supportsForkWithInitialMessage; this.renameSession = this._renameSession; } @@ -1835,6 +1845,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }); return true; }, + () => { + const agent = this._getRootState()?.agents.find(agent => agent.provider === this._config.provider); + return agent?.capabilities?.multipleChats?.fork === true + && agent.capabilities.multipleChats.forkWithInitialMessage === true; + }, ); } catch (err) { historySubagentObservations.dispose(); @@ -3306,7 +3321,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const turnId = request.requestId; const chatURI = this._getChatURI(request.sessionResource); const turnChannel = chatURI; - const messageAttachments = await this._convertVariablesToAttachments(request); + const messageAttachments = this._convertVariablesToAttachments(request.variables.variables, request.sessionResource, request.message); if (this._isInvocationAbandoned(cancellationToken)) { return; } @@ -5937,6 +5952,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (!agentInfo?.capabilities?.multipleChats?.fork) { throw new Error(`Provider ${this._config.provider} does not support forking`); } + if (options?.initialMessage && !agentInfo.capabilities.multipleChats.forkWithInitialMessage) { + // Do not fall back to a create-then-send sequence: that loses the + // host-owned acceptance and correlation guarantees for prompt forks. + throw new Error(localize('agentHost.forkInitialMessageUnsupported', "This Agent Host does not support starting a fork with a prompt.")); + } const sessionUri = backendSession.toString(); const rawSessionState = this._getRawSessionState(sessionUri); @@ -5948,19 +5968,16 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // carries a chat fragment, else the session's default chat. const sourceChat = this._resolveChatUriFromState(sessionResource, rawSessionState); - // Determine the turn to fork at. If a specific request is provided, - // fork BEFORE it (keeping turns up to the previous one). This matches - // the non-contributed path in ForkConversationAction which uses - // `requestIndex - 1`. If no request is provided, fork the whole chat. + // An explicit source is inclusive; a request-item gesture forks before that request. const protocolState = this._getSessionState(sessionUri, sourceChat); let turnIndex: number | undefined; - if (request) { - const requestIdx = protocolState?.turns.findIndex(t => t.id === request.id); + const sourceRequestId = options?.sourceRequestId ?? request?.id; + if (sourceRequestId) { + const requestIdx = protocolState?.turns.findIndex(t => t.id === sourceRequestId); if (requestIdx === undefined || requestIdx < 0) { - throw new Error(`Cannot fork: turn for request ${request.id} not found in protocol state`); + throw new Error(`Cannot fork: turn for request ${sourceRequestId} not found in protocol state`); } - // Fork before this request — keep turns [0..requestIdx-1] - turnIndex = requestIdx - 1; + turnIndex = options?.sourceRequestId !== undefined ? requestIdx : requestIdx - 1; if (turnIndex < 0) { throw new Error('Cannot fork: cannot fork before the first request'); } @@ -5974,28 +5991,69 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const turnId = protocolState!.turns[turnIndex].id; const chatModel = this._chatService.getSession(sessionResource); + // Snapshot source-owned context and selection before the preflight awaits + // below. They must not follow a newly focused widget or a changed picker. + const capturedInitialMessage = options?.initialMessage + ? this._captureForkInitialMessage(sessionResource, protocolState, options.initialMessage) + : undefined; const forkedChatId = generateUuid(); const forkedChat = URI.parse(buildChatUri(backendSession, forkedChatId)); - await this._config.connection.createChat(backendSession, forkedChat, { - model: lastTurnModelSelection(protocolState), - fork: { source: URI.parse(sourceChat), turnId }, - ...(supportsInitialTitle && options?.title !== undefined ? { title: options.title } : {}), - }); + const forkedResource = URI.from({ scheme: this._config.sessionType, path: sessionResource.path, fragment: forkedChatId }); + const initialMessage = capturedInitialMessage + ? await this._prepareForkInitialMessage(sessionResource, backendSession, capturedInitialMessage, token) + : undefined; + try { + await this._config.connection.createChat(backendSession, forkedChat, { + model: lastTurnModelSelection(protocolState), + fork: { source: URI.parse(sourceChat), turnId }, + ...(supportsInitialTitle && options?.title !== undefined ? { title: options.title } : {}), + ...(initialMessage ? { initialMessage } : {}), + }); + } catch (error) { + if (initialMessage && error instanceof ProtocolError) { + if (error.code === -32012) { + const data: { chat?: unknown; cause?: unknown } | undefined = typeof error.data === 'object' && error.data !== null ? error.data : undefined; + const cause: { code?: unknown; message?: unknown } | undefined = typeof data?.cause === 'object' && data.cause !== null ? data.cause : undefined; + if (typeof data?.chat === 'string' + && typeof cause?.code === 'number' + && typeof cause.message === 'string' + && data.chat === forkedChat.toString()) { + throw new ChatSessionForkError('rejected', forkedResource, error); + } + throw new ChatSessionForkError('unknown', forkedResource, error); + } + throw error; + } + if (initialMessage) { + throw new ChatSessionForkError('unknown', forkedResource, error); + } + throw error; + } // The chat is only addressable once the host has published it in the // session's chat catalog; hydrating the returned item before then // fails to resolve the fragment. - const sessionSubscription = this._ensureSessionSubscription(sessionUri); - const forkedSummary = await waitForState( - observableFromSubscription(this, sessionSubscription).map(state => - state?.chats.find(summary => parseChatUri(summary.resource)?.chatId === forkedChatId)), - (summary): summary is ChatSummary => !!summary, - undefined, - token, - ); + let forkedSummary: ChatSummary; + try { + const sessionSubscription = this._ensureSessionSubscription(sessionUri); + forkedSummary = await waitForState( + observableFromSubscription(this, sessionSubscription).map(state => + state?.chats.find(summary => parseChatUri(summary.resource)?.chatId === forkedChatId)), + (summary): summary is ChatSummary => !!summary, + undefined, + token, + ); + } catch (error) { + // createChat succeeded, so an initial-message fork must retain the + // frontend target even when catalog hydration/navigation cannot finish. + // The outcome is deliberately unknown: do not restore or resend it. + if (initialMessage) { + throw new ChatSessionForkError('unknown', forkedResource, error); + } + throw error; + } - const forkedResource = URI.from({ scheme: this._config.sessionType, path: sessionResource.path, fragment: forkedChatId }); const now = Date.now(); const forkedLabel = forkedSummary.title || chatModel?.title || localize('agentHost.forkedSessionLabel', "Forked Session"); @@ -6007,6 +6065,47 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }; } + /** + * Prepares a fork's initial message with the same attachment conversion and + * client admission preflight as an ordinary turn. The generic fork contract + * deliberately carries only workbench intent; the AHP Message remains + * provider-owned here. + */ + private async _prepareForkInitialMessage( + sessionResource: URI, + backendSession: URI, + captured: ICapturedForkInitialMessage, + token: CancellationToken, + ): Promise { + this._shellInitSynchronizer.reconcile(backendSession); + await this._workingDirectorySynchronizer.reconcile(backendSession, token); + if (this._isInvocationAbandoned(token)) { + throw new CancellationError(); + } + await this._ensureActiveClient(sessionResource, backendSession, token); + if (this._isInvocationAbandoned(token)) { + throw new CancellationError(); + } + + return captured.message; + } + + private _captureForkInitialMessage( + sessionResource: URI, + sourceState: ForkInitialMessageSource | undefined, + intent: IChatSessionInitialMessage, + ): ICapturedForkInitialMessage { + const attachments = this._convertVariablesToAttachments(getChatSessionInitialMessageVariables(intent), sessionResource, intent.text); + const sourceMessage = sourceState?.activeTurn?.message ?? sourceState?.turns.at(-1)?.message; + return { + message: { + ...requestMessage(intent.text, attachments, undefined, false), + ...(sourceMessage?.model ? { model: sourceMessage.model } : {}), + ...(sourceMessage?.agent ? { agent: sourceMessage.agent } : {}), + }, + }; + } + private async _ensureRequiredAuthentication(model: ModelSelection | undefined): Promise { const agentInfo = this._getRootState()?.agents.find(a => a.provider === this._config.provider); const protectedResources = agentInfo?.protectedResources ?? []; @@ -6721,10 +6820,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return true; } - private _convertVariablesToAttachments(request: IChatAgentRequest): MessageAttachment[] { - const attachments = this._variableEntriesToAttachments(request.variables.variables, request.sessionResource, request.message); + private _convertVariablesToAttachments(variables: readonly IChatRequestVariableEntry[], sessionResource: URI, message: string): MessageAttachment[] { + const attachments = this._variableEntriesToAttachments(variables, sessionResource, message); const explicitCount = attachments.length; - this._appendActiveEditorAttachments(attachments, request); + this._appendActiveEditorAttachments(attachments, variables, sessionResource, message); if (attachments.length !== explicitCount) { this._logService.trace(`[AgentHost] Forwarded ${attachments.length - explicitCount} active editor attachment(s); ${attachments.length} total`); } @@ -6737,18 +6836,18 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * {@link ChatConfiguration.ImplicitContextActiveEditor} (on by default, off in the Agents window). * Unsaved handling lives in {@link _convertVariableToAttachment}. */ - private _appendActiveEditorAttachments(attachments: MessageAttachment[], request: IChatAgentRequest): void { + private _appendActiveEditorAttachments(attachments: MessageAttachment[], variables: readonly IChatRequestVariableEntry[], sessionResource: URI, message: string): void { if (!this._configurationService.getValue(ChatConfiguration.ImplicitContextActiveEditor)) { return; } - const implicitContext = this._chatWidgetService.getWidgetBySessionResource(request.sessionResource)?.input.implicitContext; + const implicitContext = this._chatWidgetService.getWidgetBySessionResource(sessionResource)?.input.implicitContext; if (!implicitContext) { return; } // Key on source entries (not produced attachments) so inlined unsaved buffers (no URI) still dedupe. const existingKeys = new Set(); - for (const v of request.variables.variables) { - const key = this._fileEntryDedupeKey(v, request.sessionResource); + for (const v of variables) { + const key = this._fileEntryDedupeKey(v, sessionResource); if (key) { existingKeys.add(key); } @@ -6766,7 +6865,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (skipUntitled && entry.uri?.scheme === Schemas.untitled) { continue; } - const key = this._fileEntryDedupeKey(entry, request.sessionResource); + const key = this._fileEntryDedupeKey(entry, sessionResource); let forwardEntry = true; if (key) { if (existingKeys.has(key)) { @@ -6776,7 +6875,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } } if (forwardEntry) { - const attachment = this._convertVariableToAttachment(entry, request.sessionResource, request.message); + const attachment = this._convertVariableToAttachment(entry, sessionResource, message); if (!Array.isArray(attachment) && attachment) { attachments.push(attachment); } diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index a0070dba879e60..b21bba3f1a79de 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -21,7 +21,7 @@ import { IChatAgentService } from '../common/participants/chatAgents.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { IChatSlashCommandService } from '../common/participants/chatSlashCommands.js'; import { IChatService } from '../common/chatService/chatService.js'; -import { IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, SessionType } from '../common/chatSessionsService.js'; +import { IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, isAgentHostTarget, SessionType } from '../common/chatSessionsService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel } from '../common/constants.js'; import { getChatSessionType, isUntitledChatSession } from '../common/model/chatUri.js'; import { ACTION_ID_NEW_CHAT } from './actions/chatActions.js'; @@ -227,14 +227,53 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z2_fork', executeImmediately: true, silent: true, + completionVariants: [ + { label: '/fork', insertText: '', executeImmediately: true }, + { label: '/fork ', insertText: '/fork ', argumentHint: nls.localize('fork.titleArgumentHint', "title") }, + { label: '/fork prompt <prompt>', insertText: '/fork prompt ', argumentHint: nls.localize('fork.promptArgumentHint', "prompt") }, + ], locations: [ChatAgentLocation.Chat], when: ContextKeyExpr.or( ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeys.chatSessionSupportsFork ), - }, async (prompt, _progress, _history, _location, sessionResource) => { + }, async (prompt, _progress, _history, _location, sessionResource, _token, options, executionContext) => { + // The callback normally receives a trimmed remainder. Retain the original + // input for the offset used to rebase inline attachment references. + const input = executionContext?.input ?? `/fork${prompt ? ` ${prompt}` : ''}`; + const commandEnd = input.indexOf('/fork') + '/fork'.length; + const afterCommand = input.slice(commandEnd); + const tokenMatch = /^(\s*)prompt(?=\s|$)/.exec(afterCommand); + const isAgentHostSession = isAgentHostTarget(getChatSessionType(sessionResource)); + if (isAgentHostSession && tokenMatch) { + const afterToken = afterCommand.slice(tokenMatch[0].length); + if (afterToken.trim().length > 0) { + // Drop only the token separator. The remainder, including + // internal newlines and whitespace, is user-authored prompt text. + const separatorLength = /^\s/.test(afterToken) ? 1 : 0; + const textOffset = commandEnd + tokenMatch[0].length + separatorLength; + const text = afterToken.slice(separatorLength); + await commandService.executeCommand('workbench.action.chat.forkConversation', sessionResource, { + initialMessage: { + text, + textOffset, + requestOptions: options ? { + attachedContext: options.attachedContext, + resolvedVariables: options.resolvedVariables, + } : undefined, + }, + }); + return { followUp: [], preserveInput: true }; + } + // `prompt` is reserved, but an empty prompt is intentionally the + // ordinary bare-fork path rather than a title named "prompt". + await commandService.executeCommand('workbench.action.chat.forkConversation', sessionResource); + return; + } + const title = prompt.trim() || undefined; await commandService.executeCommand('workbench.action.chat.forkConversation', sessionResource, title ? { title } : undefined); + return undefined; })); this._store.add(slashCommandService.registerSlashCommand({ command: 'rename', diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 9aac6a229c5a9c..c623f92b78554f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -3316,6 +3316,42 @@ export class ChatWidget extends Disposable implements IChatWidget { } } + private _createForkPromptInputCleanup(query: string | undefined, options: IChatAcceptInputOptions | undefined): (() => void) | undefined { + const sourceSessionResource = this.viewModel?.sessionResource; + const ownedInput = this.getInput(); + const forkPart = this.parsedInput.parts.find((part): part is ChatRequestSlashCommandPart => + part instanceof ChatRequestSlashCommandPart && part.slashCommand.command === 'fork'); + const afterFork = forkPart ? ownedInput.slice(forkPart.range.endExclusive) : ''; + const promptToken = afterFork ? /^(\s*)prompt(?=\s|$)/.exec(afterFork) : undefined; + const promptText = promptToken ? afterFork.slice(promptToken[0].length).trim() : ''; + if (!sourceSessionResource + || !isAgentHostTarget(getChatSessionType(sourceSessionResource)) + || !!query + || options?.preserveInput + || !forkPart + || !promptToken + || !promptText) { + return undefined; + } + const sourceInputModel = this.viewModel?.model.inputModel; + const ownedAttachmentIds = this.input.attachmentModel.getAttachmentIDs(); + // Keep cleanup bound to the source model even after navigating to the fork. + return () => { + if (!sourceInputModel) { + return; + } + const state = sourceInputModel.state.get(); + if (state?.inputText !== ownedInput) { + return; + } + const attachmentIds = new Set((state.attachments ?? []).map(attachment => attachment.id)); + if (attachmentIds.size !== ownedAttachmentIds.size || [...attachmentIds].some(id => !ownedAttachmentIds.has(id))) { + return; + } + sourceInputModel.setState({ inputText: '', attachments: [] }); + }; + } + async acceptInput(query?: string, options?: IChatAcceptInputOptions): Promise<IChatResponseModel | undefined> { if (this._readOnly || this.viewModel?.model.isInputBlocked.get() || this.isTranscriptProgressActive || this.input.hasPendingProgrammaticModelSelection || this.isSubmissionBlockedByManagedSettingsRefresh()) { return undefined; @@ -3324,6 +3360,11 @@ export class ChatWidget extends Disposable implements IChatWidget { if (participant) { return this.acceptParticipantInputInLocalChat(participant, query, options); } + // A prompt-bearing fork is a host-owned create-and-send operation. Keep + // its captured source draft until its silent callback establishes whether + // creation/message acceptance succeeded; ordinary slash commands retain + // their existing eager-clear behavior. + let clearOwnedForkPromptInput: (() => void) | undefined; const sessionResource = this.viewModel?.sessionResource; const modeInfo = this.input.currentModeInfo; const interaction = this.instantiationService.createInstance(ChatUserInteraction, { @@ -3350,6 +3391,9 @@ export class ChatWidget extends Disposable implements IChatWidget { // unrelated dictation and flush its final transcript into that draft. await stopDictationForEditor(this.inputEditor); } + // Dictation may flush its final transcript while stopping. Snapshot the + // owned source only after that update, immediately before submission. + clearOwnedForkPromptInput = this._createForkPromptInputCleanup(query, options); if (this.viewModel) { markChat(this.viewModel.sessionResource, ChatPerfMark.RequestStart); @@ -3358,7 +3402,7 @@ export class ChatWidget extends Disposable implements IChatWidget { if (kind === 'queued') { interaction.cancel('queued'); } - }); + }, clearOwnedForkPromptInput); if (!response) { interaction.cancel('notDispatched'); return undefined; @@ -3535,7 +3579,7 @@ export class ChatWidget extends Disposable implements IChatWidget { return blocked; } - private async _acceptInput(query: { query: string } | undefined, options: IChatAcceptInputOptions = {}, onDidCreateResponse?: IChatSendRequestOptions['onDidCreateResponse']): Promise<IChatResponseModel | undefined> { + private async _acceptInput(query: { query: string } | undefined, options: IChatAcceptInputOptions = {}, onDidCreateResponse?: IChatSendRequestOptions['onDidCreateResponse'], clearOwnedForkPromptInput?: () => void): Promise<IChatResponseModel | undefined> { if (this.isTranscriptProgressActive || this.isSubmissionBlockedByManagedSettingsRefresh()) { return undefined; } @@ -3586,8 +3630,23 @@ export class ChatWidget extends Disposable implements IChatWidget { if (this.isSubmissionBlockedByManagedSettingsRefresh()) { return; } - if (this.viewModel.model.hasActiveRequest.get() && await this._tryExecuteImmediateSlashCommand(inputValue, isUserQuery ? this.parsedInput : undefined)) { - this.setInput(''); + // Immediate silent commands run before the ordinary request collection + // below. Capture the source composer's context while it still belongs to + // this input, so callbacks that create another session can retain it. + const immediateCommandOptions: IChatSendRequestOptions = { + attachedContext: options?.preserveInput + ? [] + : options?.enableImplicitContext === false ? this.input.getAttachedContext().asArray() : this.input.getAttachedAndImplicitContext().asArray(), + }; + const immediateCommandResult = this.viewModel.model.hasActiveRequest.get() + ? await this._tryExecuteImmediateSlashCommand(inputValue, isUserQuery ? this.parsedInput : undefined, immediateCommandOptions) + : undefined; + if (immediateCommandResult?.executed) { + if (clearOwnedForkPromptInput) { + clearOwnedForkPromptInput(); + } else if (!immediateCommandResult.preserveInput) { + this.setInput(''); + } return; } if (isUserQuery) { @@ -3624,7 +3683,7 @@ export class ChatWidget extends Disposable implements IChatWidget { }; const attachedContext = this._getAttachedContextForConcurrentSlashCommand(options.preserveInput); - if (await this._executeSlashCommandDuringRequest(requestInputs.input, { attachedContext }, isUserQuery, options.preserveFocus)) { + if (await this._executeSlashCommandDuringRequest(requestInputs.input, { attachedContext }, isUserQuery, options.preserveFocus, clearOwnedForkPromptInput)) { return; } const isEditing = this.viewModel?.editing; @@ -3781,6 +3840,7 @@ export class ChatWidget extends Disposable implements IChatWidget { try { result = await this.chatService.sendRequest(this.viewModel.sessionResource, requestInputs.input, { onDidCreateResponse, + onDidExecuteSlashCommand: clearOwnedForkPromptInput, ...selectedModelRequestOptions, location: this.location, locationData: this._location.resolveData?.(), @@ -3822,7 +3882,7 @@ export class ChatWidget extends Disposable implements IChatWidget { // visibility sync before firing events to hide the welcome view this.updateChatViewVisibility(); - this.input.acceptInput(options?.storeToHistory ?? isUserQuery, options?.preserveFocus, options?.preserveInput); + this.input.acceptInput(options?.storeToHistory ?? isUserQuery, options?.preserveFocus, clearOwnedForkPromptInput ? true : options?.preserveInput); if (!options.preserveInput) { // A maintenance command is not the user's goal. @@ -3886,7 +3946,7 @@ export class ChatWidget extends Disposable implements IChatWidget { return preserveInput ? [] : this.input.getAttachedContext().asArray(); } - private async _executeSlashCommandDuringRequest(input: string, requestOptions: IChatSendRequestOptions, storeToHistory: boolean, preserveFocus: boolean | undefined): Promise<boolean> { + private async _executeSlashCommandDuringRequest(input: string, requestOptions: IChatSendRequestOptions, storeToHistory: boolean, preserveFocus: boolean | undefined, clearOwnedForkPromptInput?: () => void): Promise<boolean> { const viewModel = this.viewModel; if (!viewModel?.model.hasActiveRequest.get()) { return false; @@ -3916,7 +3976,9 @@ export class ChatWidget extends Disposable implements IChatWidget { history.push({ role: ChatMessageRole.Assistant, content: [{ type: 'text', value: request.response.response.toString() }] }); } - this.input.acceptInput(storeToHistory, preserveFocus); + if (!clearOwnedForkPromptInput) { + this.input.acceptInput(storeToHistory, preserveFocus); + } const prompt = parsedRequest.text.slice(commandPart.range.endExclusive).trimStart(); try { await this.chatSlashCommandService.executeCommand( @@ -3928,7 +3990,9 @@ export class ChatWidget extends Disposable implements IChatWidget { viewModel.sessionResource, CancellationToken.None, requestOptions, + { input }, ); + clearOwnedForkPromptInput?.(); this.chatTipService.recordSlashCommandUsage(commandPart.slashCommand.command); } finally { clearChatMarks(viewModel.sessionResource); @@ -3956,10 +4020,10 @@ export class ChatWidget extends Disposable implements IChatWidget { return resolved.flat(); } - private async _tryExecuteImmediateSlashCommand(input: string, parsedInput: IParsedChatRequest | undefined): Promise<boolean> { + private async _tryExecuteImmediateSlashCommand(input: string, parsedInput: IParsedChatRequest | undefined, requestOptions: IChatSendRequestOptions): Promise<{ readonly executed: boolean; readonly preserveInput: boolean }> { const viewModel = this.viewModel; if (!viewModel) { - return false; + return { executed: false, preserveInput: false }; } const parsedRequest = parsedInput ?? this.instantiationService.createInstance(ChatRequestParser) .parseChatRequestWithReferences(getDynamicVariablesForWidget(this), getSelectedToolAndToolSetsForWidget(this), input, this.location, { @@ -3971,7 +4035,7 @@ export class ChatWidget extends Disposable implements IChatWidget { }); const commandPart = getImmediateSilentSlashCommandPart(parsedRequest); if (!commandPart) { - return false; + return { executed: false, preserveInput: false }; } const history: IChatMessage[] = []; @@ -3984,7 +4048,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } const command = commandPart.slashCommand.command; - await this.chatSlashCommandService.executeCommand( + const result = await this.chatSlashCommandService.executeCommand( command, input.slice(commandPart.range.endExclusive).trimStart(), new Progress(() => { }), @@ -3992,9 +4056,11 @@ export class ChatWidget extends Disposable implements IChatWidget { this.location, viewModel.sessionResource, CancellationToken.None, + requestOptions, + { input: parsedRequest.text }, ); this.chatTipService.recordSlashCommandUsage(command); - return true; + return { executed: true, preserveInput: result?.preserveInput === true }; } private async confirmPendingRequestsBeforeSend(model: IChatModel, options: IChatAcceptInputOptions): Promise<boolean> { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCommandArgumentHint.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCommandArgumentHint.ts index f12f0a64b313a1..3cf389b585c314 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCommandArgumentHint.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCommandArgumentHint.ts @@ -3,7 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, MutableDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { isEqual } from '../../../../../../../base/common/resources.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { ICodeEditor } from '../../../../../../../editor/browser/editorBrowser.js'; import { ICodeEditorService } from '../../../../../../../editor/browser/services/codeEditorService.js'; import { IRange, Range } from '../../../../../../../editor/common/core/range.js'; import { IDecorationOptions } from '../../../../../../../editor/common/editorCommon.js'; @@ -15,26 +18,37 @@ import { ChatDynamicVariableModel } from '../../../attachments/chatDynamicVariab import { IChatWidget } from '../../../chat.js'; import { ChatWidget } from '../../chatWidget.js'; import { getInputPlaceholderColor, getRangeForPlaceholder } from './chatInputPlaceholderDecoration.js'; +import { registerChatInputReferenceDecorationType } from './chatInputReferenceDecorations.js'; const decorationDescription = 'chat'; const commandArgumentHintDecorationType = 'chat-command-argument-hint'; +const localCommandPrefixDecorationType = 'chat-local-command-prefix'; + +interface ILocalCommandArgumentHint { + readonly sessionResource: URI; + readonly model: ITextModel; + readonly range: Range; + readonly prefix: string; + readonly argumentHint: string; +} /** - * Renders an inline placeholder (ghost text) argument hint after an accepted - * agent-host slash-command completion (e.g. `/rename `). The hint text is - * carried on the completion's dynamic-variable reference `_meta` (see - * {@link getCommandArgumentHint}); it is shown only while the command reference - * is the sole content followed by a single trailing space, i.e. before any - * argument has been typed. + * Renders argument hints and display-only styling for accepted local command prefixes. + * Agent Host completions carry the hint on their + * dynamic-variable reference; local completions retain presentation-only state. * * Reads the accepted references directly from {@link ChatDynamicVariableModel} * rather than the parsed input, because the chat request parser resolves a * leading `/command` as a slash-prompt part (which carries no `_meta`) before * dynamic-variable parsing runs. */ -class InputEditorCommandArgumentHint extends Disposable { +export class InputEditorCommandArgumentHint extends Disposable { + + public static readonly ID = 'inputEditorCommandArgumentHint'; + + public readonly id = InputEditorCommandArgumentHint.ID; - public readonly id = 'inputEditorCommandArgumentHint'; + private readonly _editorListeners = this._register(new MutableDisposable<DisposableStore>()); /** * Subscription to {@link ChatDynamicVariableModel.onDidChangeReferences}. @@ -45,6 +59,8 @@ class InputEditorCommandArgumentHint extends Disposable { * reference present — this event is what triggers the hint on accept. */ private readonly _referencesListener = this._register(new MutableDisposable()); + private editor: ICodeEditor | undefined; + private localHint: ILocalCommandArgumentHint | undefined; constructor( private readonly widget: IChatWidget, @@ -54,16 +70,83 @@ class InputEditorCommandArgumentHint extends Disposable { super(); this._register(this.codeEditorService.registerDecorationType(decorationDescription, commandArgumentHintDecorationType, {})); + this._register(registerChatInputReferenceDecorationType(this.codeEditorService, localCommandPrefixDecorationType)); + this._register(this.widget.onDidChangeParsedInput(() => this.update())); + this._register(this.widget.onDidChangeViewModel(() => this.clearLocalHint())); + this._register(this.widget.onDidChangeActiveInputEditor(() => { + this.localHint = undefined; + this.rebindEditor(); + })); + this.rebindEditor(); + } + + override dispose(): void { + this.clearDecorations(this.editor); + super.dispose(); + } + + showLocalHint(sessionResource: URI, model: ITextModel, range: IRange, prefix: string, argumentHint: string): void { + const editor = this.editor; + const widgetSessionResource = this.widget.viewModel?.model.sessionResource; + if (!editor || editor.getModel() !== model || !widgetSessionResource || !isEqual(widgetSessionResource, sessionResource)) { + return; + } + this.localHint = { + sessionResource, + model, + range: Range.lift(range), + prefix, + argumentHint, + }; + this.update(); + } + + private rebindEditor(): void { + this.clearDecorations(this.editor); + this.editor = this.widget.inputEditor; + const editorListeners = new DisposableStore(); + this._editorListeners.value = editorListeners; + editorListeners.add(this.editor.onDidChangeModelContent(e => { + if (e.isFlush || e.changes.some(change => this.doesChangeTouchLocalHint(change.range))) { + this.localHint = undefined; + } + this.update(); + })); + editorListeners.add(this.editor.onDidChangeModel(() => { + this.localHint = undefined; + this.update(); + })); + this.update(); + } + + private clearLocalHint(): void { + this.localHint = undefined; this.update(); - this._register(this.widget.onDidChangeParsedInput(() => this.update())); - this._register(this.widget.inputEditor.onDidChangeModelContent(() => this.update())); } private update(): void { this._ensureSubscribedToReferences(); - const decoration = this.getArgumentHintDecoration(); - this.widget.inputEditor.setDecorationsByType(decorationDescription, commandArgumentHintDecorationType, decoration ? [decoration] : []); + const editor = this.editor; + if (!editor || !editor.hasModel()) { + return; + } + + const decoration = this.getArgumentHintDecoration(editor.getModel()!); + editor.setDecorationsByType(decorationDescription, commandArgumentHintDecorationType, decoration ? [decoration] : []); + editor.setDecorationsByType(decorationDescription, localCommandPrefixDecorationType, this.localHint ? [{ range: this.localHint.range }] : []); + } + + private clearDecorations(editor: ICodeEditor | undefined): void { + if (editor?.hasModel()) { + editor.setDecorationsByType(decorationDescription, commandArgumentHintDecorationType, []); + editor.setDecorationsByType(decorationDescription, localCommandPrefixDecorationType, []); + } + } + + private doesChangeTouchLocalHint(range: IRange): boolean { + const localHint = this.localHint; + return !!localHint && Range.intersectRanges(range, localHint.range) !== null; } private _ensureSubscribedToReferences(): void { @@ -76,10 +159,16 @@ class InputEditorCommandArgumentHint extends Disposable { } } - private getArgumentHintDecoration(): IDecorationOptions | undefined { - const model = this.widget.inputEditor.getModel(); - if (!model) { - return undefined; + private getArgumentHintDecoration(model: ITextModel): IDecorationOptions | undefined { + const localHint = this.localHint; + if (localHint) { + const localDecoration = this.getLocalHintDecoration(model, localHint); + if (localDecoration === undefined && this.localHint === localHint) { + return undefined; + } + if (localDecoration) { + return localDecoration; + } } const dynamicVariableModel = this.widget.getContrib<ChatDynamicVariableModel>(ChatDynamicVariableModel.ID); @@ -103,20 +192,48 @@ class InputEditorCommandArgumentHint extends Disposable { return undefined; } - return { - range: getRangeForPlaceholder(ref.range), - renderOptions: { - after: { - contentText: argumentHint, - color: getInputPlaceholderColor(this.themeService), - } - } - }; + return this.createDecoration(ref.range, argumentHint); } return undefined; } + private getLocalHintDecoration(model: ITextModel, hint: ILocalCommandArgumentHint): IDecorationOptions | undefined { + const sessionResource = this.widget.viewModel?.model.sessionResource; + if (model !== hint.model || !sessionResource || !isEqual(sessionResource, hint.sessionResource) || model.getValueInRange(hint.range) !== hint.prefix) { + this.localHint = undefined; + return undefined; + } + + const beforeRange = new Range(1, 1, hint.range.startLineNumber, hint.range.startColumn); + if (model.getValueInRange(beforeRange).trim().length > 0) { + this.localHint = undefined; + return undefined; + } + + const fullRange = model.getFullModelRange(); + const afterRange = new Range(hint.range.endLineNumber, hint.range.endColumn, fullRange.endLineNumber, fullRange.endColumn); + const textAfterPrefix = model.getValueInRange(afterRange); + if (!textAfterPrefix.startsWith(' ')) { + this.localHint = undefined; + return undefined; + } + + return textAfterPrefix === ' ' ? this.createDecoration(hint.range, hint.argumentHint) : undefined; + } + + private createDecoration(range: IRange, argumentHint: string): IDecorationOptions { + return { + range: getRangeForPlaceholder(range), + renderOptions: { + after: { + contentText: argumentHint, + color: getInputPlaceholderColor(this.themeService), + } + } + }; + } + private isCommandOnlyContent(model: ITextModel, range: IRange): boolean { // Nothing meaningful before the command reference. const beforeRange = new Range(1, 1, range.startLineNumber, range.startColumn); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index 4b7d16a800bc9a..e692b383792d9d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -34,6 +34,7 @@ import { IConfigurationService } from '../../../../../../../platform/configurati import { FileKind, IFileService } from '../../../../../../../platform/files/common/files.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../../../../platform/label/common/label.js'; +import { ILogService } from '../../../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../../../platform/notification/common/notification.js'; import { Registry } from '../../../../../../../platform/registry/common/platform.js'; import { IWorkspaceContextService } from '../../../../../../../platform/workspace/common/workspace.js'; @@ -65,6 +66,7 @@ import { ChatDynamicVariableModel } from '../../../attachments/chatDynamicVariab import { IChatService } from '../../../../common/chatService/chatService.js'; import { getChatSessionType } from '../../../../common/model/chatUri.js'; import { attachedContextCompletionAdditionalTriggerCharacters, computeCompletionRanges, escapeForCharClass, getAttachedContextCompletionMatch, getAttachedContextCompletionSortText, getCompletionRangeWord, getPromptSlashCommandFilterText, IChatCompletionRangeResult, isEmptyUpToCompletionWord } from './chatInputCompletionUtils.js'; +import { InputEditorCommandArgumentHint } from './chatInputCommandArgumentHint.js'; import { getAgentSessionProviderIcon, AgentSessionProviders } from '../../../agentSessions/agentSessions.js'; /** @@ -77,6 +79,16 @@ const SlashCommandWord = /\/[\p{L}0-9_.:-]*/gu; * Regex matching an agent-or-slash command word (e.g. `@agent` or `/cmd`). */ const AgentOrSlashCommandWord = /(@|\/)[\p{L}0-9_.:-]*/gu; +const acceptCommandArgumentHintCommandId = '_chat.acceptCommandArgumentHint'; + +interface IAcceptCommandArgumentHintContext { + readonly widget: IChatWidget; + readonly sessionResource: URI; + readonly model: ITextModel; + readonly range: Range; + readonly prefix: string; + readonly argumentHint: string; +} /** * Returns `true` when the widget's chat session is backed by an agent @@ -89,18 +101,24 @@ function isAgentHostBackedWidget(widget: IChatWidget): boolean { return !!sessionResource && isAgentHostTarget(getChatSessionType(sessionResource)); } -class SlashCommandCompletions extends Disposable { +export class SlashCommandCompletions extends Disposable { constructor( @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IChatSlashCommandService private readonly chatSlashCommandService: IChatSlashCommandService, @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, @IChatService chatService: IChatService, - @IChatSessionsService chatSessionsService: IChatSessionsService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IMcpService mcpService: IMcpService, + @ILogService private readonly logService: ILogService, ) { super(); + this._register(CommandsRegistry.registerCommand(acceptCommandArgumentHintCommandId, (_accessor: ServicesAccessor, context: IAcceptCommandArgumentHintContext) => { + context.widget.getContrib<InputEditorCommandArgumentHint>(InputEditorCommandArgumentHint.ID) + ?.showLocalHint(context.sessionResource, context.model, context.range, context.prefix, context.argumentHint); + })); + this._register(this.languageFeaturesService.completionProvider.register({ scheme: Schemas.vscodeChatInput, hasAccessToAllModels: true }, { _debugDisplayName: 'globalSlashCommands', triggerCharacters: [chatSubcommandLeader], @@ -132,7 +150,17 @@ class SlashCommandCompletions extends Disposable { return null; } - const sessionType = getChatSessionType(widget.viewModel.model.sessionResource); + const sessionResource = widget.viewModel.model.sessionResource; + const sessionType = getChatSessionType(sessionResource); + let promptForkSupported = false; + if (isAgentHostBackedWidget(widget) && slashCommands.some(command => command.completionVariants?.some(variant => variant.label === '/fork prompt <prompt>'))) { + try { + promptForkSupported = (await this.chatSessionsService.getOrCreateChatSession(sessionResource, _token)) + .supportsForkWithInitialMessage?.() === true; + } catch (error) { + this.logService.error(error, 'Failed to determine whether the chat session supports forking with an initial message'); + } + } return { suggestions: slashCommands @@ -157,17 +185,42 @@ class SlashCommandCompletions extends Disposable { } return true; }) - .map((c, i): CompletionItem => { + .flatMap((c, i): CompletionItem[] => { const withSlash = `/${c.command}`; - return { - label: { label: withSlash, description: c.detail }, - insertText: c.executeImmediately ? '' : `${withSlash} `, - documentation: c.detail, - range, - sortText: c.sortText ?? 'a'.repeat(i + 1), - kind: CompletionItemKind.Text, // The icons are disabled here anyway, - command: c.executeImmediately ? { id: ChatSubmitAction.ID, title: withSlash, arguments: [{ widget, inputValue: `${withSlash} ` } satisfies IChatExecuteActionContext] } : undefined, - }; + const variants = isAgentHostBackedWidget(widget) + ? (c.completionVariants ?? [{ label: withSlash, insertText: c.executeImmediately ? '' : `${withSlash} `, executeImmediately: c.executeImmediately }]) + .filter(variant => variant.label !== '/fork prompt <prompt>' || promptForkSupported) + : [{ label: withSlash, insertText: c.executeImmediately ? '' : `${withSlash} `, executeImmediately: c.executeImmediately }]; + return variants.map((variant, variantIndex) => { + const prefix = variant.insertText.trimEnd(); + const prefixRange = new Range(range.replace.startLineNumber, range.replace.startColumn, range.replace.startLineNumber, range.replace.startColumn + prefix.length); + const command = variant.executeImmediately + ? { id: ChatSubmitAction.ID, title: variant.label, arguments: [{ widget, inputValue: `${withSlash} ` } satisfies IChatExecuteActionContext] } + : variant.argumentHint + ? { + id: acceptCommandArgumentHintCommandId, + title: variant.label, + arguments: [{ + widget, + sessionResource, + model, + range: prefixRange, + prefix, + argumentHint: variant.argumentHint, + } satisfies IAcceptCommandArgumentHintContext] + } + : undefined; + return { + label: { label: variant.label, description: variant.detail ?? c.detail }, + insertText: variant.insertText, + filterText: withSlash, + documentation: variant.detail ?? c.detail, + range, + sortText: `${c.sortText ?? 'a'.repeat(i + 1)}${variantIndex}`, + kind: CompletionItemKind.Text, + command, + }; + }); }) }; } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index fea67d93521494..1a3eb5ac42bb30 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -2013,6 +2013,11 @@ export interface IRemotePendingRequest { } export interface IChatSendRequestOptions { + /** + * Invoked after a silent slash command has completed successfully. This is + * intentionally not called when command execution throws. + */ + onDidExecuteSlashCommand?: () => void; /** UI-only observation of this send's response and dispatch outcome. Queued/rejected sends have no response. Not persisted or sent to an agent. */ onDidCreateResponse?: (response: IChatResponseModel | undefined, kind?: ChatSendResult['kind']) => void; modeInfo?: IChatRequestModeInfo; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 268df632cbc7a6..efd89a490889f1 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -2033,7 +2033,8 @@ export class ChatService extends Disposable implements IChatService { const message = parsedRequest.text; const commandResult = await this.chatSlashCommandService.executeCommand(commandPart.slashCommand.command, message.substring(commandPart.slashCommand.command.length + 1).trimStart(), new Progress<IChatProgress>(p => { progressCallback([p]); - }), history, location, model.sessionResource, token, options); + }), history, location, model.sessionResource, token, options, { input: message }); + options?.onDidExecuteSlashCommand?.(); agentOrCommandFollowups = Promise.resolve(commandResult?.followUp); rawResult = {}; diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 98af016ac0e17d..0db35765f920f6 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -17,10 +17,11 @@ import { createDecorator, ServicesAccessor } from '../../../../platform/instanti import { Registry } from '../../../../platform/registry/common/platform.js'; import { LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from './participants/chatAgents.js'; +import { IChatRequestVariableEntry } from './attachments/chatVariableEntries.js'; import { IChatEditingSession } from './editing/chatEditingService.js'; import { ChatRequestSource, IChatRequestModeInstructions, IChatRequestVariableData, ISerializableChatModelInputState } from './model/chatModel.js'; import { IChatRequestOrigin } from './chatRequestOrigin.js'; -import { IChatProgress, IChatResponseErrorDetails, IChatSessionTiming } from './chatService/chatService.js'; +import { IChatProgress, IChatResponseErrorDetails, IChatSendRequestOptions, IChatSessionTiming } from './chatService/chatService.js'; import { ChatAgentLocation } from './constants.js'; import { Target } from './promptSyntax/promptTypes.js'; import type { ICanvasContext } from '../../canvases/common/canvas.js'; @@ -460,8 +461,90 @@ export function getAgentHostProviderForTelemetry(sessionType: string | undefined */ export const localChatSessionType = SessionType.Local; +/** + * Provider-neutral intent for the first user message in a forked session. + * + * The session provider translates this into its own transport message only + * after it has performed its normal client-side message preparation. + */ +export interface IChatSessionInitialMessage { + readonly text: string; + /** + * The original input offset at which {@link text} starts. Providers use it + * solely to rebase attachment text-reference ranges after a command prefix + * has been removed; it never changes resource-selection coordinates. + */ + readonly textOffset?: number; + /** + * Context captured from the source composer before navigation or reset. + * Model and agent selection deliberately do not live here: providers inherit + * those from the source conversation's actual turns. + */ + readonly requestOptions?: Pick<IChatSendRequestOptions, 'attachedContext' | 'resolvedVariables'>; +} + +/** + * Returns the composer variables that belong to an initial fork message. + * Text references are rebased after the fork command prefix is removed; resource + * selections have no text coordinate and are retained. + */ +export function getChatSessionInitialMessageVariables(intent: IChatSessionInitialMessage): IChatRequestVariableEntry[] { + const entries = [...(intent.requestOptions?.attachedContext ?? []), ...(intent.requestOptions?.resolvedVariables ?? [])]; + const seenEntryIds = new Set<string>(); + const variables = entries.filter(entry => { + if (seenEntryIds.has(entry.id)) { + return false; + } + seenEntryIds.add(entry.id); + return true; + }); + const offset = intent.textOffset ?? 0; + if (offset === 0) { + return variables; + } + return variables.map(variable => { + const range = variable.range; + // A reference wholly in the removed command prefix has no equivalent + // position in the prompt. Keep its resource/selection attachment, but + // omit the textual reference rather than emitting invalid offsets. + if (!range || range.start < offset || range.endExclusive > offset + intent.text.length) { + return range ? { ...variable, range: undefined } : variable; + } + return { + ...variable, + range: { + start: range.start - offset, + endExclusive: range.endExclusive - offset, + }, + }; + }); +} + export interface IChatSessionForkOptions { readonly title?: string; + readonly initialMessage?: IChatSessionInitialMessage; + /** + * Exact last source turn to include, overriding the request-item's exclusive boundary. + * Providers must preserve this selection while the operation is being prepared. + */ + readonly sourceRequestId?: string; +} + +/** + * A provider-neutral fork failure with the client resource selected before the + * create request was issued. `rejected` proves that the target chat exists and + * its initial message was not accepted; `unknown` deliberately makes no such + * claim and must never be retried automatically. + */ +export class ChatSessionForkError extends Error { + constructor( + readonly kind: 'rejected' | 'unknown', + readonly target: URI, + override readonly cause: unknown, + ) { + super(cause instanceof Error ? cause.message : String(cause)); + this.name = 'ChatSessionForkError'; + } } export interface IChatSession extends IDisposable { @@ -528,6 +611,13 @@ export interface IChatSession extends IDisposable { * @returns The forked session item. The promise is rejected if forking fails. */ forkSession?: (request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken, options?: IChatSessionForkOptions) => Promise<IChatSessionItem>; + /** + * Whether the currently connected provider supports the composed, + * host-owned fork-and-initial-message operation. This is deliberately a + * getter so callers consult the current connection descriptor rather than + * caching capability state. + */ + supportsForkWithInitialMessage?: () => boolean; /** * Renames the session. diff --git a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts index cb889ad422d061..02a8f045f3c4b4 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts @@ -52,12 +52,41 @@ export interface IChatSlashData { * When set, the command is only shown if the expression evaluates to true. */ when?: ContextKeyExpression; + + /** + * Alternate presentations of this command in the leading slash completion + * list. These are UI templates, not separately executable commands. + */ + completionVariants?: readonly IChatSlashCommandCompletionVariant[]; +} + +export interface IChatSlashCommandCompletionVariant { + readonly label: string; + readonly insertText: string; + readonly detail?: string; + readonly argumentHint?: string; + readonly executeImmediately?: boolean; +} + +/** Original composer text captured before a slash command clears its input. */ +export interface IChatSlashCommandExecutionContext { + readonly input: string; } export interface IChatSlashFragment { content: string | { treeData: IChatResponseProgressFileTreeData }; } -export type IChatSlashCallback = { (prompt: string, progress: IProgress<IChatProgress>, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken, options?: IChatSendRequestOptions): Promise<{ followUp: IChatFollowup[] } | void> }; +/** + * A silent immediate command may retain ownership of its source draft while + * an asynchronous action completes. The action must restore or clear that + * draft itself without overwriting input typed after invocation. + */ +export interface IChatSlashCommandResult { + readonly followUp: IChatFollowup[]; + readonly preserveInput?: boolean; +} + +export type IChatSlashCallback = { (prompt: string, progress: IProgress<IChatProgress>, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken, options?: IChatSendRequestOptions, executionContext?: IChatSlashCommandExecutionContext): Promise<IChatSlashCommandResult | void> }; export const IChatSlashCommandService = createDecorator<IChatSlashCommandService>('chatSlashCommandService'); @@ -68,7 +97,7 @@ export interface IChatSlashCommandService { _serviceBrand: undefined; readonly onDidChangeCommands: Event<void>; registerSlashCommand(data: IChatSlashData, command: IChatSlashCallback): IDisposable; - executeCommand(id: string, prompt: string, progress: IProgress<IChatProgress>, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken, options?: IChatSendRequestOptions): Promise<{ followUp: IChatFollowup[] } | void>; + executeCommand(id: string, prompt: string, progress: IProgress<IChatProgress>, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken, options?: IChatSendRequestOptions, executionContext?: IChatSlashCommandExecutionContext): Promise<IChatSlashCommandResult | void>; getCommands(location: ChatAgentLocation, mode: ChatModeKind): Array<IChatSlashData>; hasCommand(id: string, sessionType: string): boolean; } @@ -151,7 +180,7 @@ export class ChatSlashCommandService extends Disposable implements IChatSlashCom return !!this.getCommand(id, sessionType); } - async executeCommand(id: string, prompt: string, progress: IProgress<IChatProgress>, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken, options?: IChatSendRequestOptions): Promise<{ followUp: IChatFollowup[] } | void> { + async executeCommand(id: string, prompt: string, progress: IProgress<IChatProgress>, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken, options?: IChatSendRequestOptions, executionContext?: IChatSlashCommandExecutionContext): Promise<IChatSlashCommandResult | void> { const data = this.getCommand(id, getChatSessionType(sessionResource)); if (!data) { throw new Error(`No command with id ${id} NOT registered`); @@ -163,6 +192,6 @@ export class ChatSlashCommandService extends Disposable implements IChatSlashCom throw new Error(`No command with id ${id} NOT resolved`); } - return await data.command(prompt, progress, history, location, sessionResource, token, options); + return await data.command(prompt, progress, history, location, sessionResource, token, options, executionContext); } } diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts index b7ba42f12998c4..9719680f09ded0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts @@ -611,6 +611,13 @@ suite('Chat Accessibility Help', () => { supportedHost: help.includes('When the connected Agent Host supports named forks, the new chat uses that title'), unnamedForkHostFallback: help.includes('Agent Hosts that support forking but not named titles use their normal title for the forked chat'), immediateBareFork: help.includes('Accepting /fork from slash-command completion creates a bare forked chat immediately'), + titleTemplate: help.includes('Select the /fork title template to insert /fork'), + promptTemplate: help.includes('select /fork prompt to insert /fork prompt'), + templateDoesNotSubmit: help.includes('selecting a template does not submit it'), + hintsAreNotInput: help.includes('The title and prompt hints are suggestions and are not part of your input.'), + submitPromptInFork: help.includes('Send /fork prompt followed by a prompt to create the fork and submit that prompt in the new chat'), + emptyPromptFallback: help.includes('An empty prompt creates an ordinary fork'), + unsupportedPrompt: help.includes('prompt template is unavailable when the connected Agent Host does not support submitting the initial message with the fork'), insertToTitle: help.includes('To add a title, type or insert the command instead'), doesNotDescribeNewSession: !help.includes('new session') && !help.includes('fork an Agent Host session'), }; @@ -622,6 +629,13 @@ suite('Chat Accessibility Help', () => { supportedHost: true, unnamedForkHostFallback: true, immediateBareFork: true, + titleTemplate: true, + promptTemplate: true, + templateDoesNotSubmit: true, + hintsAreNotInput: true, + submitPromptInFork: true, + emptyPromptFallback: true, + unsupportedPrompt: true, insertToTitle: true, doesNotDescribeNewSession: true, }; @@ -632,6 +646,13 @@ suite('Chat Accessibility Help', () => { supportedHost: false, unnamedForkHostFallback: false, immediateBareFork: false, + titleTemplate: false, + promptTemplate: false, + templateDoesNotSubmit: false, + hintsAreNotInput: false, + submitPromptInFork: false, + emptyPromptFallback: false, + unsupportedPrompt: false, insertToTitle: false, doesNotDescribeNewSession: true, }; diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts index d93410215670c1..a1608d2e1b51bd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts @@ -16,6 +16,8 @@ import { ICommandService } from '../../../../../../platform/commands/common/comm import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { TestNotificationService } from '../../../../../../platform/notification/test/common/testNotificationService.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; @@ -27,7 +29,7 @@ import { IChatPetService } from '../../../browser/chatPetService.js'; import { ChatSlashCommandsContribution } from '../../../browser/chatSlashCommands.js'; import { IChatEditorOptions } from '../../../browser/widgetHosts/editor/chatEditor.js'; import { IChatModelReference, IChatService } from '../../../common/chatService/chatService.js'; -import { IChatSession, IChatSessionItem, IChatSessionRequestHistoryItem, IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { ChatSessionForkError, getChatSessionInitialMessageVariables, IChatSession, IChatSessionItem, IChatSessionRequestHistoryItem, IChatSessionsService } from '../../../common/chatSessionsService.js'; import { ChatAgentLocation, SessionTypeSelectionReason } from '../../../common/constants.js'; import { IChatModel, IChatRequestModel, ISerializableChatData } from '../../../common/model/chatModel.js'; import { IChatRequestViewModel, IChatViewModel } from '../../../common/model/chatViewModel.js'; @@ -43,6 +45,127 @@ class TestForkConversationAction extends ForkConversationAction { suite('ForkConversationAction', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('rebases and deduplicates recovered fork prompt variables', () => { + const variables = getChatSessionInitialMessageVariables({ + text: 'prompt', + textOffset: 13, + requestOptions: { + attachedContext: [ + { id: 'text', name: 'text', kind: 'generic', value: URI.parse('file:///text'), range: { start: 13, endExclusive: 19 } }, + { id: 'prefix', name: 'prefix', kind: 'generic', value: URI.parse('file:///prefix'), range: { start: 2, endExclusive: 7 } }, + ], + resolvedVariables: [ + { id: 'text', name: 'text', kind: 'generic', value: URI.parse('file:///text'), range: { start: 13, endExclusive: 19 } }, + { id: 'image', name: 'image', kind: 'generic', value: URI.parse('file:///image') }, + ], + }, + }); + + assert.deepStrictEqual(variables.map(variable => ({ id: variable.id, range: variable.range })), [ + { id: 'text', range: { start: 0, endExclusive: 6 } }, + { id: 'prefix', range: undefined }, + { id: 'image', range: undefined }, + ]); + }); + + test('retains target identity and original cause for rejected and unknown prompt forks', () => { + const target = URI.parse('contributed:/source#fork'); + const cause = new Error('initial message rejected'); + const rejected = new ChatSessionForkError('rejected', target, cause); + const unknown = new ChatSessionForkError('unknown', target, cause); + assert.deepStrictEqual({ + target: rejected.target, + kind: rejected.kind, + cause: rejected.cause, + unknownKind: unknown.kind, + unknownCause: unknown.cause, + }, { target, kind: 'rejected', cause, unknownKind: 'unknown', unknownCause: cause }); + }); + + test('offers three fork presentations and preserves prompt syntax and offsets', async () => { + let forkCommand: IChatSlashCallback | undefined; + let forkData: IChatSlashData | undefined; + const commandCalls: { readonly options: unknown }[] = []; + const slashCommandService = new class extends mock<IChatSlashCommandService>() { + override registerSlashCommand(data: IChatSlashData, callback: IChatSlashCallback) { + if (data.command === 'fork') { + forkData = data; + forkCommand = callback; + } + return Disposable.None; + } + }(); + const commandService = upcastPartial<ICommandService>({ + executeCommand: async (_commandId: string, _resource: URI, options?: unknown) => { + commandCalls.push({ options }); + }, + }); + const configurationService = new TestConfigurationService(); + disposables.add(new ChatSlashCommandsContribution( + slashCommandService, commandService, new class extends mock<IChatAgentService>() { }(), + disposables.add(new TestInstantiationService()), new class extends mock<IAgentSessionsService>() { }(), + new class extends mock<IChatService>() { }(), configurationService, new class extends mock<IChatWidgetService>() { }(), + new class extends mock<IAgentHostService>() { }(), new class extends mock<IAgentHostUntitledProvisionalSessionService>() { }(), + new class extends mock<IAgentHostSessionWorkingDirectoryResolver>() { }(), new class extends mock<IWorkspaceContextService>() { }(), + new class extends mock<IChatPetService>() { }(), new class extends mock<IWorkbenchEnvironmentService>() { override readonly isSessionsWindow = false; }(), + new class extends mock<IAgentHostConnectionsService>() { }(), + )); + assert.deepStrictEqual(forkData?.completionVariants?.map(variant => [variant.label, variant.insertText, variant.argumentHint, variant.executeImmediately]), [ + ['/fork', '', undefined, true], + ['/fork <title>', '/fork ', 'title', undefined], + ['/fork prompt <prompt>', '/fork prompt ', 'prompt', undefined], + ]); + + const resource = URI.parse('agent-host-copilot:/source'); + assert.ok(forkCommand); + await forkCommand('prompt line one\nline two', { report: () => { } }, [], ChatAgentLocation.Chat, resource, CancellationToken.None, { + attachedContext: [], + }, { input: '/fork prompt line one\nline two' }); + await forkCommand('prompting title', { report: () => { } }, [], ChatAgentLocation.Chat, resource, CancellationToken.None, undefined, { input: '/fork prompting title' }); + await forkCommand('prompt ', { report: () => { } }, [], ChatAgentLocation.Chat, resource, CancellationToken.None, undefined, { input: '/fork prompt ' }); + await forkCommand('prompt legacy title', { report: () => { } }, [], ChatAgentLocation.Chat, URI.parse('contributed:/source'), CancellationToken.None, undefined, { input: '/fork prompt legacy title' }); + + assert.deepStrictEqual(commandCalls.map(call => call.options), [ + { initialMessage: { text: ' line one\nline two', textOffset: 15, requestOptions: { attachedContext: [], resolvedVariables: undefined } } }, + { title: 'prompting title' }, + undefined, + { title: 'prompt legacy title' }, + ]); + }); + + test('retains the created target when opening an initial-message fork fails', async () => { + const instantiationService = disposables.add(new TestInstantiationService()); + const source = URI.parse('agent-host-copilot:/source'); + const target = URI.parse('agent-host-copilot:/source#fork'); + const cause = new Error('Unable to open the new chat'); + let creations = 0; + let openings = 0; + instantiationService.stub(IChatService, upcastPartial<IChatService>({})); + instantiationService.stub(IChatWidgetService, upcastPartial<IChatWidgetService>({})); + instantiationService.stub(INotificationService, new TestNotificationService()); + instantiationService.stub(IChatSessionsService, upcastPartial<IChatSessionsService>({ + getContentProviderSchemes: () => [source.scheme], + forkChatSession: async () => { + creations++; + return { resource: target, label: 'Fork', timing: { created: 0, lastRequestStarted: undefined, lastRequestEnded: undefined } }; + }, + })); + const action = new class extends ForkConversationAction { + protected override async _openForkedSession(): Promise<void> { + openings++; + throw cause; + } + }; + await assert.rejects( + instantiationService.invokeFunction(accessor => action.run(accessor, source, { initialMessage: { text: 'new prompt' } })), + error => { + assert.ok(error instanceof ChatSessionForkError); + assert.deepStrictEqual({ kind: error.kind, target: error.target, cause: error.cause, creations, openings }, { kind: 'unknown', target, cause, creations: 1, openings: 1 }); + return true; + }, + ); + }); + test('trims only the fork slash command title', async () => { let forkCommand: IChatSlashCallback | undefined; let forkData: IChatSlashData | undefined; @@ -134,41 +257,52 @@ suite('ForkConversationAction', () => { }); }); - test('uses the requested title in contributed pending fork identity', async () => { - const instantiationService = disposables.add(new TestInstantiationService()); - const source = URI.parse('contributed:/source'); - const calls: (object | undefined)[] = []; - const gates: DeferredPromise<IChatSessionItem>[] = []; - const started = [new DeferredPromise<void>(), new DeferredPromise<void>()]; - instantiationService.stub(IChatService, upcastPartial<IChatService>({})); - instantiationService.stub(IChatWidgetService, upcastPartial<IChatWidgetService>({})); - instantiationService.stub(IChatSessionsService, upcastPartial<IChatSessionsService>({ - getContentProviderSchemes: () => [source.scheme], - sessionSupportsFork: () => true, - forkChatSession: async (_resource, _request, _token, options?: object) => { - calls.push(options); - const gate = new DeferredPromise<IChatSessionItem>(); - gates.push(gate); - started[calls.length - 1].complete(); - return gate.p; - }, - })); - const action = new class extends ForkConversationAction { - protected override async _openForkedSession(): Promise<void> { } - }; + const forkIdentityCases: { name: string; first: IForkConversationOptions; second: IForkConversationOptions }[] = [ + { name: 'requested title', first: { title: 'First' }, second: { title: 'Second' } }, + { name: 'initial prompt', first: { initialMessage: { text: 'First' } }, second: { initialMessage: { text: 'Second' } } }, + { + name: 'initial attachment payload', + first: { initialMessage: { text: 'Same prompt', requestOptions: { attachedContext: [{ id: 'context', name: 'context', kind: 'generic', value: 'first payload' }] } } }, + second: { initialMessage: { text: 'Same prompt', requestOptions: { attachedContext: [{ id: 'context', name: 'context', kind: 'generic', value: 'second payload' }] } } }, + }, + ]; + for (const scenario of forkIdentityCases) { + test(`uses the ${scenario.name} in contributed pending fork identity`, async () => { + const instantiationService = disposables.add(new TestInstantiationService()); + const source = URI.parse('contributed:/source'); + const calls: (object | undefined)[] = []; + const gates: DeferredPromise<IChatSessionItem>[] = []; + const started = [new DeferredPromise<void>(), new DeferredPromise<void>()]; + instantiationService.stub(IChatService, upcastPartial<IChatService>({})); + instantiationService.stub(IChatWidgetService, upcastPartial<IChatWidgetService>({})); + instantiationService.stub(IChatSessionsService, upcastPartial<IChatSessionsService>({ + getContentProviderSchemes: () => [source.scheme], + sessionSupportsFork: () => true, + forkChatSession: async (_resource, _request, _token, options?: object) => { + calls.push(options); + const gate = new DeferredPromise<IChatSessionItem>(); + gates.push(gate); + started[calls.length - 1].complete(); + return gate.p; + }, + })); + const action = new class extends ForkConversationAction { + protected override async _openForkedSession(): Promise<void> { } + }; - const first = instantiationService.invokeFunction(accessor => action.run(accessor, source, { title: 'First' })); - await started[0].p; - const second = instantiationService.invokeFunction(accessor => action.run(accessor, source, { title: 'Second' })); - await started[1].p; - const duplicate = instantiationService.invokeFunction(accessor => action.run(accessor, source, { title: 'First' })); - await timeout(0); - gates[0].complete({ resource: URI.parse('contributed:/fork-1'), label: 'Fork 1', timing: { created: 0, lastRequestStarted: undefined, lastRequestEnded: undefined } }); - gates[1].complete({ resource: URI.parse('contributed:/fork-2'), label: 'Fork 2', timing: { created: 0, lastRequestStarted: undefined, lastRequestEnded: undefined } }); - await Promise.all([first, second, duplicate]); + const first = instantiationService.invokeFunction(accessor => action.run(accessor, source, scenario.first)); + await started[0].p; + const second = instantiationService.invokeFunction(accessor => action.run(accessor, source, scenario.second)); + await started[1].p; + const duplicate = instantiationService.invokeFunction(accessor => action.run(accessor, source, scenario.first)); + await timeout(0); + gates[0].complete({ resource: URI.parse('contributed:/fork-1'), label: 'Fork 1', timing: { created: 0, lastRequestStarted: undefined, lastRequestEnded: undefined } }); + gates[1].complete({ resource: URI.parse('contributed:/fork-2'), label: 'Fork 2', timing: { created: 0, lastRequestStarted: undefined, lastRequestEnded: undefined } }); + await Promise.all([first, second, duplicate]); - assert.deepStrictEqual(calls, [{ title: 'First' }, { title: 'Second' }]); - }); + assert.deepStrictEqual(calls, [scenario.first, scenario.second]); + }); + } test('opens a fork with the current session selection reason', async () => { const instantiationService = disposables.add(new TestInstantiationService()); 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 92c25f3ce99bd4..bde38a70950b91 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 @@ -80,7 +80,7 @@ import { IChatDebugService } from '../../../common/chatDebugService.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { IChatResponseFileChangesService } from '../../../browser/chatResponseFileChangesService.js'; import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { IChatSessionsService, type IChatSession, type IChatSessionHistoryItem, type IChatSessionItemController, type IChatSessionRequestHistoryItem, type IChatSessionServerRequest, type IChatSessionsExtensionPoint } from '../../../common/chatSessionsService.js'; +import { IChatSessionsService, type IChatSession, type IChatSessionForkOptions, type IChatSessionHistoryItem, type IChatSessionItemController, type IChatSessionRequestHistoryItem, type IChatSessionServerRequest, type IChatSessionsExtensionPoint } from '../../../common/chatSessionsService.js'; import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../common/languageModels.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; @@ -6207,7 +6207,7 @@ suite('AgentHostChatContribution', () => { // ---- Forking --------------------------------------------------------- suite('forking', () => { - async function forkSession(initialTitle: boolean | undefined, requestedTitle: string, publishedTitle: string) { + async function forkSession(initialTitle: boolean | undefined, requestedTitle: string, publishedTitle: string, initialMessage?: IChatSessionForkOptions['initialMessage'], sourceSelection = false, boundary?: { turnCount?: number; sourceRequestId?: string; beforeRequestId?: string }) { const { sessionHandler, agentHostService } = createContribution(disposables); const backendSession = AgentSession.uri('copilot', `fork-${initialTitle === true ? 'supported' : 'legacy'}`); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: backendSession.path }); @@ -6226,7 +6226,7 @@ suite('AgentHostChatContribution', () => { displayName: 'Agent Host - Copilot', description: 'test', models: [], - capabilities: { multipleChats: { fork: true, initialTitle } }, + capabilities: { multipleChats: { fork: true, initialTitle, ...(initialMessage ? { forkWithInitialMessage: true } : {}) } }, }], activeSessions: 0, }); @@ -6235,19 +6235,44 @@ suite('AgentHostChatContribution', () => { lifecycle: SessionLifecycle.Ready, defaultChat: sourceChat, chats: [createDefaultChatSummary(summary, sourceChat)], - turns: [{ - id: 'source-turn', + turns: Array.from({ length: boundary?.turnCount ?? 1 }, (_, index) => ({ + id: index === 0 ? 'source-turn' : `source-turn-${index + 1}`, state: TurnState.Complete, - message: { text: 'Source request', origin: { kind: MessageKind.User } }, + message: { + text: 'Source request', + origin: { kind: MessageKind.User }, + ...(sourceSelection ? { + model: { id: 'source-model', config: { thinking: 'high' } }, + agent: { uri: 'agent://source' }, + } : {}), + }, responseParts: [], usage: undefined, - }], + })), + ...(sourceSelection ? { + draft: { + text: 'Unsaved composer draft', + origin: { kind: MessageKind.User }, + model: { id: 'unsent-picker-model', config: { thinking: 'low' } }, + agent: { uri: 'agent://unsent-picker' }, + }, + } : {}), }); agentHostService.nextCreatedChatTitle = publishedTitle; const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); disposables.add(chatSession); - const forkedItem = await chatSession.forkSession?.(undefined, CancellationToken.None, { title: requestedTitle }); + const request = boundary?.beforeRequestId + ? chatSession.history.find((item): item is IChatSessionRequestHistoryItem => item.type === 'request' && item.id === boundary.beforeRequestId) + : undefined; + if (boundary?.beforeRequestId) { + assert.ok(request); + } + const forkedItem = await chatSession.forkSession?.(request, CancellationToken.None, { + title: requestedTitle, + ...(initialMessage ? { initialMessage } : {}), + ...(boundary?.sourceRequestId !== undefined ? { sourceRequestId: boundary.sourceRequestId } : {}), + }); return { call: agentHostService.createChatCalls[0], @@ -6256,6 +6281,53 @@ suite('AgentHostChatContribution', () => { }; } + for (const boundary of [ + { turnCount: 1, sourceRequestId: 'source-turn' }, + { turnCount: 3, sourceRequestId: 'source-turn-2' }, + { turnCount: 3, sourceRequestId: 'source-turn-3' }, + ]) { + test(`fork prompt includes the exact preselected ${boundary.sourceRequestId} in a ${boundary.turnCount}-turn chat`, async () => { + const result = await forkSession(true, 'Fork', 'Fork', { text: 'what is 4+5' }, false, boundary); + assert.deepStrictEqual({ + source: result.call.options?.fork?.source.toString(), + turnId: result.call.options?.fork?.turnId, + prompt: result.call.options?.initialMessage?.text, + }, { + source: result.sourceChat, + turnId: boundary.sourceRequestId, + prompt: 'what is 4+5', + }); + }); + } + + test('a request-item fork still excludes the selected request', async () => { + const result = await forkSession(true, 'Fork', 'Fork', undefined, false, { turnCount: 3, beforeRequestId: 'source-turn-2' }); + assert.strictEqual(result.call.options?.fork?.turnId, 'source-turn'); + }); + + test('an exact source selection overrides the request-item boundary', async () => { + const result = await forkSession(true, 'Fork', 'Fork', { text: 'new prompt' }, false, { + turnCount: 3, + sourceRequestId: 'source-turn', + beforeRequestId: 'source-turn-3', + }); + assert.strictEqual(result.call.options?.fork?.turnId, 'source-turn'); + }); + + test('a request-item fork still rejects forking before the first request', async () => { + await assert.rejects( + forkSession(true, 'Fork', 'Fork', undefined, false, { beforeRequestId: 'source-turn' }), + /Cannot fork: cannot fork before the first request/, + ); + }); + + test('a preselected source request must exist', async () => { + await assert.rejects( + forkSession(true, 'Fork', 'Fork', { text: 'new prompt' }, false, { sourceRequestId: 'missing-turn' }), + /Cannot fork: turn for request missing-turn not found in protocol state/, + ); + }); + test('forwards a requested title when the host advertises initial title support and returns the host summary label', async () => { const requestedTitle = 'Requested fork title'; const result = await forkSession(true, requestedTitle, requestedTitle); @@ -6292,7 +6364,73 @@ suite('AgentHostChatContribution', () => { }); }); + test('captures a multiline initial message from the actual source turn rather than the unsent composer selection', async () => { + const prompt = 'first line\nsecond line'; + const prefix = '/fork prompt '; + const result = await forkSession(true, 'Ignored', 'Forked', { + text: prompt, + textOffset: prefix.length, + requestOptions: { + attachedContext: [upcastPartial<IChatRequestVariableEntry>({ + id: 'source-context', + kind: 'string', + name: 'Source context', + value: 'captured', + range: { start: prefix.length, endExclusive: prefix.length + 'first line'.length }, + })], + resolvedVariables: [upcastPartial<IChatRequestVariableEntry>({ + id: 'source-context', + kind: 'string', + name: 'Source context', + value: 'captured', + range: { start: prefix.length, endExclusive: prefix.length + 'first line'.length }, + })], + }, + }, true); + const message = result.call.options?.initialMessage; + assert.deepStrictEqual({ + text: message?.text, + model: message?.model, + agent: message?.agent, + attachments: message?.attachments, + }, { + text: prompt, + model: { id: 'source-model', config: { thinking: 'high' } }, + agent: { uri: 'agent://source' }, + attachments: [{ + type: MessageAttachmentKind.Simple, + label: 'Source context', + modelRepresentation: 'captured', + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 'first line'.length }, + }, + }], + }); + }); + + test('does not create a fork when initial-message preflight is already cancelled', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const backendSession = AgentSession.uri('copilot', 'fork-cancelled'); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: backendSession.path }); + const sourceChat = buildDefaultChatUri(backendSession.toString()); + agentHostService.setRootState({ agents: [{ provider: 'copilot', displayName: 'Agent Host - Copilot', description: 'test', models: [], capabilities: { multipleChats: { fork: true, initialTitle: true } } }], activeSessions: 0 }); + agentHostService.sessionStates.set(backendSession.toString(), { + ...createSessionState({ resource: backendSession.toString(), provider: 'copilot', title: 'Source', status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString() }), + lifecycle: SessionLifecycle.Ready, + defaultChat: sourceChat, + chats: [createDefaultChatSummary({ resource: backendSession.toString(), provider: 'copilot', title: 'Source', status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString() }, sourceChat)], + turns: [{ id: 'source-turn', state: TurnState.Complete, message: { text: 'Source', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }], + }); + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(chatSession); + const cts = disposables.add(new CancellationTokenSource()); + cts.cancel(); + + await assert.rejects(() => chatSession.forkSession!(undefined, cts.token, { initialMessage: { text: 'Prompt' } })); + assert.strictEqual(agentHostService.createChatCalls.length, 0); + }); }); // ---- Multi-root customization scope --------------------------------- diff --git a/src/vs/workbench/contrib/chat/test/browser/chatWidgetSlashCommand.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatWidgetSlashCommand.test.ts index d89da55d0f916e..a790ca8a0e01d6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatWidgetSlashCommand.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatWidgetSlashCommand.test.ts @@ -4,8 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { mockObject } from '../../../../../base/test/common/mock.js'; +import { mockObject, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; +import { Range } from '../../../../../editor/common/core/range.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; @@ -13,6 +17,7 @@ import { ILogService, NullLogService } from '../../../../../platform/log/common/ import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { TestExtensionService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { IChatAcceptInputOptions } from '../../browser/chat.js'; import { IChatTipService } from '../../browser/chatTipService.js'; import { ChatWidget } from '../../browser/widget/chatWidget.js'; import { toPasteVariableEntry } from '../../common/attachments/chatVariableEntries.js'; @@ -22,6 +27,7 @@ import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { LocalChatSessionUri } from '../../common/model/chatUri.js'; import { ChatAgentService, IChatAgentService } from '../../common/participants/chatAgents.js'; import { IChatSlashCommandService } from '../../common/participants/chatSlashCommands.js'; +import { ChatRequestSlashCommandPart, IParsedChatRequest } from '../../common/requestParser/chatParserTypes.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { MockChatService } from '../common/chatService/mockChatService.js'; import { MockChatVariablesService } from '../common/mockChatVariables.js'; @@ -31,7 +37,79 @@ suite('ChatWidget Slash Commands', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('during-request execution handles an active request waiting for input', async () => { + test('fork prompt cleanup clears only the unchanged agent-host source input model', () => { + const sourceState = observableValue('sourceInput', { + inputText: '/fork prompt preserve this', + attachments: [{ id: 'source-attachment' }], + }); + const targetState = observableValue('targetInput', { + inputText: 'new target typing', + attachments: [{ id: 'target-attachment' }], + }); + const inputModel = { + state: sourceState, + setState: (change: Partial<ReturnType<typeof sourceState.get>>) => sourceState.set({ ...sourceState.get(), ...change }, undefined), + }; + let input = sourceState.get().inputText; + let attachmentIds = new Set(['source-attachment']); + const widgetModel = { + sessionResource: URI.parse('agent-host-copilot:/source'), + model: { inputModel }, + }; + const widget = { + viewModel: widgetModel, + getInput: () => input, + input: { attachmentModel: { getAttachmentIDs: () => attachmentIds } }, + parsedInput: upcastPartial<IParsedChatRequest>({ + parts: [new ChatRequestSlashCommandPart(new OffsetRange(0, '/fork'.length), new Range(1, 1, 1, 6), { command: 'fork', detail: 'Fork', locations: [ChatAgentLocation.Chat] })], + }), + } as unknown as ChatWidget; + const privateMethods = ChatWidget.prototype as unknown as { + _createForkPromptInputCleanup(query: string | undefined, options: IChatAcceptInputOptions | undefined): (() => void) | undefined; + }; + + const cleanup = privateMethods._createForkPromptInputCleanup.call(widget, undefined, undefined); + assert.ok(cleanup); + cleanup(); + assert.deepStrictEqual(sourceState.get(), { inputText: '', attachments: [] }); + assert.deepStrictEqual(targetState.get(), { inputText: 'new target typing', attachments: [{ id: 'target-attachment' }] }); + + sourceState.set({ inputText: '/fork prompt preserve this', attachments: [{ id: 'source-attachment' }] }, undefined); + input = '/fork prompt preserve this'; + attachmentIds = new Set(['source-attachment']); + const editedCleanup = privateMethods._createForkPromptInputCleanup.call(widget, undefined, undefined)!; + sourceState.set({ ...sourceState.get(), inputText: 'new source typing' }, undefined); + editedCleanup(); + assert.strictEqual(sourceState.get().inputText, 'new source typing'); + + sourceState.set({ inputText: '/fork prompt preserve this', attachments: [{ id: 'source-attachment' }] }, undefined); + attachmentIds = new Set(['source-attachment']); + const attachmentCleanup = privateMethods._createForkPromptInputCleanup.call(widget, undefined, undefined)!; + sourceState.set({ ...sourceState.get(), attachments: [{ id: 'new-attachment' }] }, undefined); + attachmentIds = new Set(['new-attachment']); + attachmentCleanup(); + assert.deepStrictEqual(sourceState.get().attachments, [{ id: 'new-attachment' }]); + + widgetModel.sessionResource = URI.parse('local:/source'); + assert.strictEqual(privateMethods._createForkPromptInputCleanup.call(widget, undefined, undefined), undefined); + widgetModel.sessionResource = URI.parse('agent-host-copilot:/source'); + assert.strictEqual(privateMethods._createForkPromptInputCleanup.call(widget, 'programmatic', undefined), undefined); + assert.strictEqual(privateMethods._createForkPromptInputCleanup.call(widget, undefined, { preserveInput: true }), undefined); + assert.ok(privateMethods._createForkPromptInputCleanup.call(widget, '', undefined), 'an empty query submits the composer input'); + widgetModel.sessionResource = URI.parse('remote-example-copilot:/source'); + assert.ok(privateMethods._createForkPromptInputCleanup.call(widget, undefined, undefined), 'remote agent-host sessions own fork-prompt cleanup'); + + input = ' @agent /fork prompt prefixed'; + (widget as unknown as { parsedInput: IParsedChatRequest }).parsedInput = upcastPartial<IParsedChatRequest>({ + parts: [new ChatRequestSlashCommandPart(new OffsetRange(9, 14), new Range(1, 10, 1, 15), { command: 'fork', detail: 'Fork', locations: [ChatAgentLocation.Chat] })], + }); + assert.ok(privateMethods._createForkPromptInputCleanup.call(widget, undefined, undefined), 'a global fork after whitespace and an agent mention owns its prompt'); + input = 'plain text /fork prompt mention'; + (widget as unknown as { parsedInput: IParsedChatRequest }).parsedInput = upcastPartial<IParsedChatRequest>({ parts: [] }); + assert.strictEqual(privateMethods._createForkPromptInputCleanup.call(widget, undefined, undefined), undefined); + }); + + test('during-request execution retains attachments for a non-preserved slash command', async () => { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IStorageService, disposables.add(new TestStorageService())); instantiationService.stub(ILogService, new NullLogService()); @@ -89,7 +167,14 @@ suite('ChatWidget Slash Commands', () => { _getAttachedContextForConcurrentSlashCommand(preserveInput: boolean | undefined): NonNullable<IChatSendRequestOptions['attachedContext']>; _executeSlashCommandDuringRequest(input: string, requestOptions: IChatSendRequestOptions, storeToHistory: boolean, preserveFocus: boolean | undefined): Promise<boolean>; }; - const attachedContext = privateMethods._getAttachedContextForConcurrentSlashCommand.call(widget, false); + // Owned /fork prompt input deliberately does not use preserveInput: its + // source attachments remain part of the request both before and during an + // active request. A true preserveInput remains an unrelated-draft path. + const idleForkPromptContext = privateMethods._getAttachedContextForConcurrentSlashCommand.call(widget, false); + hasActiveRequest = true; + const activeForkPromptContext = privateMethods._getAttachedContextForConcurrentSlashCommand.call(widget, false); + hasActiveRequest = false; + const attachedContext = idleForkPromptContext; const preservedInputContext = privateMethods._getAttachedContextForConcurrentSlashCommand.call(widget, true); const handledWithoutActiveRequest = await privateMethods._executeSlashCommandDuringRequest.call(widget, ' /btw keep indentation', { attachedContext }, true, true); hasActiveRequest = true; @@ -101,6 +186,8 @@ suite('ChatWidget Slash Commands', () => { executedPrompt, acceptedInput, attachedContext: executedRequestOptions?.attachedContext, + idleForkPromptContext, + activeForkPromptContext, preservedInputContext, recordedSlashCommands, }, { @@ -109,6 +196,8 @@ suite('ChatWidget Slash Commands', () => { executedPrompt: 'keep indentation', acceptedInput: { storeToHistory: true, preserveFocus: true }, attachedContext: [pastedText], + idleForkPromptContext: [pastedText], + activeForkPromptContext: [pastedText], preservedInputContext: [], recordedSlashCommands: ['btw'], }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCommandArgumentHint.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCommandArgumentHint.test.ts index e7f482b21f6f0a..f84b0498f656b9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCommandArgumentHint.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCommandArgumentHint.test.ts @@ -6,26 +6,23 @@ import assert from 'assert'; import { DisposableStore } from '../../../../../../../../base/common/lifecycle.js'; import { Emitter } from '../../../../../../../../base/common/event.js'; +import { URI } from '../../../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../../base/test/common/utils.js'; import { Range } from '../../../../../../../../editor/common/core/range.js'; import { IDecorationOptions } from '../../../../../../../../editor/common/editorCommon.js'; import { withTestCodeEditor } from '../../../../../../../../editor/test/browser/testCodeEditor.js'; +import { createTextModel } from '../../../../../../../../editor/test/common/testTextModel.js'; import { IDynamicVariable } from '../../../../../common/attachments/chatVariables.js'; import { IChatWidget } from '../../../../../browser/chat.js'; import { ChatWidget } from '../../../../../browser/widget/chatWidget.js'; import { ChatDynamicVariableModel } from '../../../../../browser/attachments/chatDynamicVariables.js'; -import '../../../../../browser/widget/input/editor/chatInputCommandArgumentHint.js'; +import { InputEditorCommandArgumentHint } from '../../../../../browser/widget/input/editor/chatInputCommandArgumentHint.js'; +import '../../../../../browser/widget/input/editor/chatInputEditorContrib.js'; suite('InputEditorCommandArgumentHint', () => { ensureNoDisposablesAreLeakedInTestSuite(); - function getCtor() { - const ctor = ChatWidget.CONTRIBS.find(contrib => contrib.name === 'InputEditorCommandArgumentHint'); - assert.ok(ctor, 'InputEditorCommandArgumentHint should be registered as a chat widget contribution'); - return ctor!; - } - function commandReference(range: Range, argumentHint: string | undefined): IDynamicVariable { return { id: 'agent-host-command:plan', @@ -35,37 +32,94 @@ suite('InputEditorCommandArgumentHint', () => { }; } - function run(value: string, variables: IDynamicVariable[], trigger: 'parsedInput' | 'references' = 'parsedInput'): IDecorationOptions[] { + function run( + value: string, + variables: IDynamicVariable[], + trigger: 'parsedInput' | 'references' = 'parsedInput', + operation?: (editor: IChatWidget['inputEditor'], widget: IChatWidget, hintContribution: InputEditorCommandArgumentHint, sessionResource: URI, viewModelEmitter: Emitter<void>, store: DisposableStore, activeInputEditorEmitter: Emitter<void>) => void, + onDecorations?: (options: IDecorationOptions[]) => void, + decorationType = 'chat-command-argument-hint', + ): IDecorationOptions[] { let captured: IDecorationOptions[] = []; + let result: IDecorationOptions[] = []; withTestCodeEditor(value, {}, (editor, _vm, instantiationService) => { const store = new DisposableStore(); try { const realSet = editor.setDecorationsByType.bind(editor); editor.setDecorationsByType = ((desc: string, key: string, opts: IDecorationOptions[]) => { - if (key === 'chat-command-argument-hint') { + if (key === decorationType) { captured = opts; + onDecorations?.(opts); } return realSet(desc, key, opts); }) as typeof editor.setDecorationsByType; const parsedInputEmitter = store.add(new Emitter<void>()); const referencesEmitter = store.add(new Emitter<void>()); + const viewModelEmitter = store.add(new Emitter<void>()); + const activeInputEditorEmitter = store.add(new Emitter<void>()); + const sessionResource = URI.parse('test:session'); const dynamicVariableModel = { variables, onDidChangeReferences: referencesEmitter.event } as unknown as ChatDynamicVariableModel; const widget = { inputEditor: editor, onDidChangeParsedInput: parsedInputEmitter.event, - getContrib: (id: string) => id === ChatDynamicVariableModel.ID ? dynamicVariableModel : undefined, + onDidChangeViewModel: viewModelEmitter.event, + onDidChangeActiveInputEditor: activeInputEditorEmitter.event, + viewModel: { model: { sessionResource } }, + refreshParsedInput: () => undefined, + getContrib: (id: string) => id === InputEditorCommandArgumentHint.ID ? hintContribution : id === ChatDynamicVariableModel.ID ? dynamicVariableModel : undefined, } as unknown as IChatWidget; - store.add(instantiationService.createInstance(getCtor(), widget)); + assert.ok(ChatWidget.CONTRIBS.includes(InputEditorCommandArgumentHint), 'InputEditorCommandArgumentHint should be registered as a chat widget contribution'); + const hintContribution = store.add(instantiationService.createInstance(InputEditorCommandArgumentHint, widget)); (trigger === 'references' ? referencesEmitter : parsedInputEmitter).fire(); + operation?.(editor, widget, hintContribution, sessionResource, viewModelEmitter, store, activeInputEditorEmitter); + result = captured; } finally { store.dispose(); } }); - return captured; + return result; } + test('styles the full accepted command prefix but not the typed argument', () => { + for (const prefix of ['/fork', '/fork prompt']) { + for (const argument of ['', 'user text\nsecond line']) { + const range = new Range(1, 1, 1, prefix.length + 1); + const decorations = run(`${prefix} `, [], 'parsedInput', (editor, _widget, contribution, session) => { + contribution.showLocalHint(session, editor.getModel()!, range, prefix, 'hint'); + if (argument) { + editor.executeEdits('test', [{ range: new Range(1, prefix.length + 2, 1, prefix.length + 2), text: argument }]); + } + assert.strictEqual(editor.getValue(), `${prefix} ${argument}`); + }, undefined, 'chat-local-command-prefix'); + assert.deepStrictEqual(decorations, [{ range }]); + } + } + }); + + test('clears accepted prefix styling when its ownership is invalidated', () => { + const operations = [ + (editor: IChatWidget['inputEditor']) => editor.executeEdits('test', [{ range: new Range(1, 12, 1, 13), text: '' }]), + (editor: IChatWidget['inputEditor']) => editor.setValue(''), + ]; + for (const operation of operations) { + const decorations = run('/fork prompt ', [], 'parsedInput', (editor, _widget, contribution, session) => { + contribution.showLocalHint(session, editor.getModel()!, new Range(1, 1, 1, 13), '/fork prompt', 'prompt'); + operation(editor); + }, undefined, 'chat-local-command-prefix'); + assert.deepStrictEqual(decorations, []); + } + }); + + test('clears accepted prefix styling when the contribution is disposed', () => { + let finalDecorations: IDecorationOptions[] | undefined; + run('/fork prompt ', [], 'parsedInput', (editor, _widget, contribution, session) => { + contribution.showLocalHint(session, editor.getModel()!, new Range(1, 1, 1, 13), '/fork prompt', 'prompt'); + }, options => finalDecorations = options, 'chat-local-command-prefix'); + assert.deepStrictEqual(finalDecorations, []); + }); + test('renders ghost text after a command with a trailing space and an argument hint', () => { const decorations = run('/plan ', [commandReference(new Range(1, 1, 1, 6), 'task')]); assert.deepStrictEqual(decorations, [{ @@ -87,4 +141,121 @@ suite('InputEditorCommandArgumentHint', () => { assert.deepStrictEqual(run('/plan task', [commandReference(new Range(1, 1, 1, 6), 'task')]), []); assert.deepStrictEqual(run('hi /plan ', [commandReference(new Range(1, 4, 1, 9), 'task')]), []); }); + + test('restores a local hint after deleting an argument without creating a dynamic reference', () => { + const history: IDecorationOptions[][] = []; + const variables: IDynamicVariable[] = []; + let hiddenAfterArgument = false; + const decorations = run('/fork ', variables, 'parsedInput', (editor, _widget, hintContribution, sessionResource) => { + hintContribution.showLocalHint(sessionResource, editor.getModel()!, new Range(1, 1, 1, 6), '/fork', 'title'); + editor.executeEdits('test', [{ range: new Range(1, 7, 1, 7), text: 'My fork' }]); + const latestDecorations = history[history.length - 1]; + hiddenAfterArgument = latestDecorations?.length === 0; + editor.executeEdits('test', [{ range: new Range(1, 7, 1, 14), text: '' }]); + }, options => history.push(options)); + + assert.deepStrictEqual({ + hiddenAfterArgument, + decorations, + variables, + }, { + hiddenAfterArgument: true, + decorations: [{ + range: { startLineNumber: 1, endLineNumber: 1, startColumn: 7, endColumn: 1000 }, + renderOptions: { after: { contentText: 'title', color: undefined } } + }], + variables: [], + }); + }); + + test('clears a local hint after prefix edits and does not resurrect it when the prefix is restored', () => { + const decorations = run('/fork ', [], 'parsedInput', (editor, _widget, hintContribution, sessionResource) => { + hintContribution.showLocalHint(sessionResource, editor.getModel()!, new Range(1, 1, 1, 6), '/fork', 'title'); + editor.executeEdits('test', [{ range: new Range(1, 2, 1, 3), text: '' }]); + editor.executeEdits('test', [{ range: new Range(1, 2, 1, 2), text: 'f' }]); + }); + + assert.deepStrictEqual(decorations, []); + }); + + test('clears a local hint after replacing the whole input with the same prefix', () => { + const decorations = run('/fork ', [], 'parsedInput', (editor, _widget, hintContribution, sessionResource) => { + hintContribution.showLocalHint(sessionResource, editor.getModel()!, new Range(1, 1, 1, 6), '/fork', 'title'); + editor.setValue('/fork '); + }); + + assert.deepStrictEqual(decorations, []); + }); + + test('clears a local hint after a non-flush multiline replacement restores the prefix', () => { + const decorations = run('/fork ', [], 'parsedInput', (editor, _widget, hintContribution, sessionResource) => { + hintContribution.showLocalHint(sessionResource, editor.getModel()!, new Range(1, 1, 1, 6), '/fork', 'title'); + editor.executeEdits('test', [{ range: new Range(1, 7, 1, 7), text: 'argument\nnext line' }]); + editor.executeEdits('test', [{ range: editor.getModel()!.getFullModelRange(), text: '/fork ' }]); + }); + + assert.deepStrictEqual(decorations, []); + }); + + test('clears a local hint when leading whitespace becomes meaningful text', () => { + const decorations = run(' /fork ', [], 'parsedInput', (editor, _widget, hintContribution, sessionResource) => { + hintContribution.showLocalHint(sessionResource, editor.getModel()!, new Range(1, 3, 1, 8), '/fork', 'title'); + editor.executeEdits('test', [{ range: new Range(1, 1, 1, 3), text: 'a ' }]); + }); + + assert.deepStrictEqual(decorations, []); + }); + + test('clears a local hint when the input editor changes models and then restores the original model', () => { + const decorations = run('/fork ', [], 'parsedInput', (editor, _widget, hintContribution, sessionResource, _viewModelEmitter, store) => { + const originalModel = editor.getModel(); + assert.ok(originalModel); + hintContribution.showLocalHint(sessionResource, originalModel, new Range(1, 1, 1, 6), '/fork', 'title'); + const replacementModel = store.add(createTextModel('/fork ')); + editor.setModel(replacementModel); + editor.setModel(originalModel); + }); + + assert.deepStrictEqual(decorations, []); + }); + + test('keeps a hidden local hint ahead of a stale legacy reference fallback', () => { + const decorations = run('/fork title ', [commandReference(new Range(1, 1, 1, 12), 'host')], 'parsedInput', (editor, _widget, hintContribution, sessionResource) => { + hintContribution.showLocalHint(sessionResource, editor.getModel()!, new Range(1, 1, 1, 6), '/fork', 'title'); + }); + + assert.deepStrictEqual(decorations, []); + }); + + test('clears a local hint when immediate Backspace deletes the accepted command', () => { + const decorations = run('/', [], 'parsedInput', (editor, widget, hintContribution, sessionResource, _viewModelEmitter, store) => { + const tokenDeleterCtor = ChatWidget.CONTRIBS.find(contrib => contrib.name === 'ChatTokenDeleter'); + assert.ok(tokenDeleterCtor, 'ChatTokenDeleter should be registered as a chat widget contribution'); + store.add(new tokenDeleterCtor(widget)); + + editor.executeEdits('test', [{ range: new Range(1, 1, 1, 2), text: '/fork ' }]); + hintContribution.showLocalHint(sessionResource, editor.getModel()!, new Range(1, 1, 1, 6), '/fork', 'title'); + editor.executeEdits('test', [{ range: new Range(1, 6, 1, 7), text: '' }]); + }); + + assert.deepStrictEqual(decorations, []); + }); + + test('clears a local hint when the widget changes chat sessions', () => { + const decorations = run('/fork ', [], 'parsedInput', (editor, _widget, hintContribution, sessionResource, viewModelEmitter) => { + hintContribution.showLocalHint(sessionResource, editor.getModel()!, new Range(1, 1, 1, 6), '/fork', 'title'); + viewModelEmitter.fire(); + }); + + assert.deepStrictEqual(decorations, []); + }); + + test('clears a local hint when the active input editor changes', () => { + const decorations = run('/fork ', [], 'parsedInput', (editor, _widget, hintContribution, sessionResource, _viewModelEmitter, _store, activeInputEditorEmitter) => { + hintContribution.showLocalHint(sessionResource, editor.getModel()!, new Range(1, 1, 1, 6), '/fork', 'title'); + activeInputEditorEmitter.fire(); + }); + + assert.deepStrictEqual(decorations, []); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts index a1c8d6122ef055..0a9c659e3c2f35 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts @@ -4,41 +4,54 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { CancellationToken } from '../../../../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../../../base/common/cancellation.js'; +import { Emitter } from '../../../../../../../../base/common/event.js'; import { fuzzyScore, FuzzyScoreOptions } from '../../../../../../../../base/common/filters.js'; import { DisposableStore, IDisposable } from '../../../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../../../base/common/uri.js'; import { Schemas } from '../../../../../../../../base/common/network.js'; +import { upcastDeepPartial, upcastPartial } from '../../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../../base/test/common/utils.js'; import { EditorOptions } from '../../../../../../../../editor/common/config/editorOptions.js'; import { Position } from '../../../../../../../../editor/common/core/position.js'; import { Range } from '../../../../../../../../editor/common/core/range.js'; import { OffsetRange } from '../../../../../../../../editor/common/core/ranges/offsetRange.js'; +import { Selection } from '../../../../../../../../editor/common/core/selection.js'; +import { IDecorationOptions } from '../../../../../../../../editor/common/editorCommon.js'; import { CompletionItem, CompletionItemKind, CompletionTriggerKind } from '../../../../../../../../editor/common/languages.js'; import { ITextModel } from '../../../../../../../../editor/common/model.js'; import { LanguageFeaturesService } from '../../../../../../../../editor/common/services/languageFeaturesService.js'; import { CompletionModel } from '../../../../../../../../editor/contrib/suggest/browser/completionModel.js'; import { CompletionItem as SuggestCompletionItem } from '../../../../../../../../editor/contrib/suggest/browser/suggest.js'; import { WordDistance } from '../../../../../../../../editor/contrib/suggest/browser/wordDistance.js'; +import { withTestCodeEditor } from '../../../../../../../../editor/test/browser/testCodeEditor.js'; +import { CommandsRegistry } from '../../../../../../../../platform/commands/common/commands.js'; import { createTextModel } from '../../../../../../../../editor/test/common/testTextModel.js'; +import { TestConfigurationService } from '../../../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { NullLogService } from '../../../../../../../../platform/log/common/log.js'; +import { ChatSubmitAction } from '../../../../../browser/actions/chatExecuteActions.js'; +import { IChatWidget, IChatWidgetService } from '../../../../../browser/chat.js'; +import { ChatWidget } from '../../../../../browser/widget/chatWidget.js'; import { AgentHostInputCompletionsBase } from '../../../../../browser/widget/input/editor/agentHostInputCompletionsBase.js'; import { AgentHostInputCompletions } from '../../../../../browser/widget/input/editor/agentHostInputCompletions.js'; -import { AgentCompletions } from '../../../../../browser/widget/input/editor/chatInputCompletions.js'; +import { AgentCompletions, SlashCommandCompletions } from '../../../../../browser/widget/input/editor/chatInputCompletions.js'; +import { InputEditorCommandArgumentHint } from '../../../../../browser/widget/input/editor/chatInputCommandArgumentHint.js'; import { createChatReferenceVariableEntry } from '../../../../../common/attachments/chatVariableEntries.js'; import { attachedContextCompletionAdditionalTriggerCharacters, attachedContextCompletionSortText, computeCompletionRanges, escapeForCharClass, getAttachedContextCompletionMatch, getAttachedContextCompletionSortText, getCompletionRangeWord, getPromptSlashCommandFilterText, isAtTriggerCharacterToken } from '../../../../../browser/widget/input/editor/chatInputCompletionUtils.js'; -import { IChatInputCompletionItem, IChatInputCompletionsParams, IChatInputCompletionsResult, IChatSessionsService } from '../../../../../common/chatSessionsService.js'; +import { IChatInputCompletionItem, IChatInputCompletionsParams, IChatInputCompletionsResult, IChatSession, IChatSessionsService } from '../../../../../common/chatSessionsService.js'; +import { IChatService } from '../../../../../common/chatService/chatService.js'; +import { ICustomizationHarnessService } from '../../../../../common/customizationHarnessService.js'; +import { IChatSlashCommandService, IChatSlashData } from '../../../../../common/participants/chatSlashCommands.js'; +import { IMcpService } from '../../../../../../mcp/common/mcpTypes.js'; import { ChatRequestAgentPart, ChatRequestTextPart, chatAgentLeader, chatVariableLeader } from '../../../../../common/requestParser/chatParserTypes.js'; import { MockChatSessionsService } from '../../../../common/mockChatSessionsService.js'; import { MockChatWidgetService } from '../../../widget/mockChatWidget.js'; -import { IChatWidget } from '../../../../../browser/chat.js'; import { ChatAgentLocation, ChatModeKind } from '../../../../../common/constants.js'; import { IChatAgentData, IChatAgentNameService, IChatAgentService } from '../../../../../common/participants/chatAgents.js'; -import { ChatViewModel } from '../../../../../common/model/chatViewModel.js'; +import { ChatViewModel, IChatViewModel } from '../../../../../common/model/chatViewModel.js'; import { IChatModel } from '../../../../../common/model/chatModel.js'; import { ChatInputPart } from '../../../../../browser/widget/input/chatInputPart.js'; -import { TestConfigurationService } from '../../../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { MockContextKeyService } from '../../../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; -import { upcastPartial } from '../../../../../../../../base/test/common/mock.js'; import { IAgentHostConnectionsService } from '../../../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IAgentHostUntitledProvisionalSessionService } from '../../../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; import { IsSessionsWindowContext } from '../../../../../../../common/contextkeys.js'; @@ -484,6 +497,249 @@ suite('AgentHostInputCompletions plain text', () => { }); }); +suite('SlashCommandCompletions fork variants', () => { + + const store = new DisposableStore(); + + teardown(() => store.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + class ForkCompletionChatSessionsService extends MockChatSessionsService { + lookupCount = 0; + lastToken: CancellationToken | undefined; + + constructor( + private readonly supportsForkWithInitialMessage: boolean, + private readonly lookupError?: Error, + ) { + super(); + } + + override async getOrCreateChatSession(_sessionResource: URI, token: CancellationToken): Promise<IChatSession> { + this.lookupCount++; + this.lastToken = token; + if (token.isCancellationRequested) { + throw new Error('lookup cancelled'); + } + if (this.lookupError) { + throw this.lookupError; + } + return upcastPartial<IChatSession>({ + supportsForkWithInitialMessage: () => this.supportsForkWithInitialMessage, + }); + } + } + + const forkCommand: IChatSlashData = { + command: 'fork', + detail: 'Fork this chat', + executeImmediately: true, + locations: [ChatAgentLocation.Chat], + completionVariants: [ + { label: '/fork', insertText: '/fork ', executeImmediately: true }, + { label: '/fork <title>', insertText: '/fork ', argumentHint: 'title' }, + { label: '/fork prompt <prompt>', insertText: '/fork prompt ', argumentHint: 'prompt' }, + ], + }; + + function completionRanges(suggestion: CompletionItem): { readonly insert: Range; readonly replace: Range } { + if (Range.isIRange(suggestion.range)) { + return { insert: Range.lift(suggestion.range), replace: Range.lift(suggestion.range) }; + } + return { insert: Range.lift(suggestion.range.insert), replace: Range.lift(suggestion.range.replace) }; + } + + async function provideSuggestions(agentHost: boolean, commands: readonly IChatSlashData[], chatSessionsService: ForkCompletionChatSessionsService, token = CancellationToken.None, inputWidget?: IChatWidget, inputModel?: ITextModel): Promise<{ readonly suggestions: readonly CompletionItem[]; readonly model: ITextModel }> { + const languageFeaturesService = new LanguageFeaturesService(); + const inputUri = inputModel?.uri ?? URI.from({ scheme: Schemas.vscodeChatInput, path: '/fork-completions' }); + const sessionResource = URI.from({ scheme: agentHost ? 'agent-host-test' : 'test', path: '/session' }); + const widget = inputWidget ?? upcastDeepPartial<IChatWidget>({ + viewModel: upcastPartial<IChatViewModel>({ model: upcastPartial<IChatModel>({ sessionResource }) }), + parsedInput: { parts: [] }, + location: ChatAgentLocation.Chat, + input: { currentModeKind: ChatModeKind.Ask }, + attachmentCapabilities: { supportsPromptAttachments: true }, + scopedContextKeyService: { contextMatchesRules: () => true }, + }); + store.add(new SlashCommandCompletions( + languageFeaturesService, + upcastPartial<IChatWidgetService>({ getWidgetByInputUri: () => widget }), + upcastPartial<IChatSlashCommandService>({ getCommands: () => [...commands] }), + upcastPartial<ICustomizationHarnessService>({}), + upcastPartial<IChatService>({}), + chatSessionsService, + upcastPartial<IMcpService>({}), + new NullLogService(), + )); + const model = inputModel ?? store.add(createTextModel('/fo', null, undefined, inputUri)); + const provider = languageFeaturesService.completionProvider.ordered(model).find(provider => provider._debugDisplayName === 'globalSlashCommands'); + assert.ok(provider); + + const result = await provider.provideCompletionItems(model, new Position(1, 4), { triggerKind: CompletionTriggerKind.Invoke }, token); + assert.ok(result); + return { suggestions: result.suggestions, model }; + } + + test('offers all fork variants for agent host sessions that support an initial message', async () => { + const chatSessionsService = new ForkCompletionChatSessionsService(true); + const { suggestions, model } = await provideSuggestions(true, [forkCommand], chatSessionsService); + + assert.deepStrictEqual(suggestions.map(suggestion => ({ + label: suggestion.label, + insertText: suggestion.insertText, + submitsImmediately: suggestion.command?.id === ChatSubmitAction.ID, + range: completionRanges(suggestion), + filterText: suggestion.filterText, + })), [ + { label: { label: '/fork', description: 'Fork this chat' }, insertText: '/fork ', submitsImmediately: true, range: { insert: new Range(1, 1, 1, 4), replace: new Range(1, 1, 1, 4) }, filterText: '/fork' }, + { label: { label: '/fork <title>', description: 'Fork this chat' }, insertText: '/fork ', submitsImmediately: false, range: { insert: new Range(1, 1, 1, 4), replace: new Range(1, 1, 1, 4) }, filterText: '/fork' }, + { label: { label: '/fork prompt <prompt>', description: 'Fork this chat' }, insertText: '/fork prompt ', submitsImmediately: false, range: { insert: new Range(1, 1, 1, 4), replace: new Range(1, 1, 1, 4) }, filterText: '/fork' }, + ]); + for (const suggestion of suggestions) { + model.setValue('/fo'); + const range = completionRanges(suggestion).replace; + model.applyEdits([{ range, text: suggestion.insertText }]); + assert.strictEqual(model.getValue(), suggestion.insertText); + assert.strictEqual(range.startColumn + suggestion.insertText.length, model.getLineMaxColumn(1)); + } + }); + + test('accepts fork title and prompt completion commands through the input editor', async () => { + const cases = [ + { label: '/fork <title>', insertText: '/fork ', argumentHint: 'title', cursorColumn: 7 }, + { label: '/fork prompt <prompt>', insertText: '/fork prompt ', argumentHint: 'prompt', cursorColumn: 14 }, + ]; + + for (const testCase of cases) { + const parsedInputEmitter = store.add(new Emitter<void>()); + const viewModelEmitter = store.add(new Emitter<void>()); + const activeInputEditorEmitter = store.add(new Emitter<void>()); + let inputEditor: IChatWidget['inputEditor']; + let hintContribution: InputEditorCommandArgumentHint | undefined; + const sessionResource = URI.from({ scheme: 'agent-host-test', path: `/session-${testCase.argumentHint}` }); + const model = store.add(createTextModel('/fo', null, undefined, URI.from({ scheme: Schemas.vscodeChatInput, path: `/fork-completions-${testCase.argumentHint}` }))); + const widget = upcastDeepPartial<IChatWidget>({ + get inputEditor() { + return inputEditor; + }, + viewModel: upcastPartial<IChatViewModel>({ model: upcastPartial<IChatModel>({ sessionResource }) }), + parsedInput: { parts: [] }, + location: ChatAgentLocation.Chat, + input: { currentModeKind: ChatModeKind.Ask }, + attachmentCapabilities: { supportsPromptAttachments: true }, + scopedContextKeyService: { contextMatchesRules: () => true }, + onDidChangeParsedInput: parsedInputEmitter.event, + onDidChangeViewModel: viewModelEmitter.event, + onDidChangeActiveInputEditor: activeInputEditorEmitter.event, + getContrib: (id: string) => id === InputEditorCommandArgumentHint.ID ? hintContribution : undefined, + }); + const { suggestions } = await provideSuggestions(true, [forkCommand], new ForkCompletionChatSessionsService(true), CancellationToken.None, widget, model); + const suggestion = suggestions.find(candidate => typeof candidate.label !== 'string' && candidate.label.label === testCase.label); + assert.ok(suggestion, `Expected ${testCase.label} completion`); + const completion = suggestion; + const command = completion.command; + assert.ok(command, `Expected ${testCase.label} to register an accept command`); + const acceptCommand = CommandsRegistry.getCommand(command.id); + assert.ok(acceptCommand, `Expected ${command.id} accept command to be registered`); + + withTestCodeEditor(model, {}, (editor, _viewModel, instantiationService) => { + const editorStore = new DisposableStore(); + let decorations: readonly IDecorationOptions[] = []; + try { + inputEditor = editor; + assert.ok(ChatWidget.CONTRIBS.includes(InputEditorCommandArgumentHint), 'InputEditorCommandArgumentHint should be registered as a chat widget contribution'); + hintContribution = editorStore.add(instantiationService.createInstance(InputEditorCommandArgumentHint, widget)); + const setDecorationsByType = editor.setDecorationsByType.bind(editor); + editor.setDecorationsByType = (description, decorationTypeKey, options) => { + if (decorationTypeKey === 'chat-command-argument-hint') { + decorations = options; + } + return setDecorationsByType(description, decorationTypeKey, options); + }; + + editor.setPosition(new Position(1, 4)); + editor.executeEdits('test', [{ range: completionRanges(completion).replace, text: completion.insertText }]); + instantiationService.invokeFunction(accessor => acceptCommand.handler(accessor, ...(command.arguments ?? []))); + + assert.deepStrictEqual({ + value: editor.getValue(), + selection: editor.getSelection(), + decorations: decorations.map(decoration => ({ + range: decoration.range, + contentText: decoration.renderOptions?.after?.contentText, + })), + }, { + value: completion.insertText, + selection: new Selection(1, testCase.cursorColumn, 1, testCase.cursorColumn), + decorations: [{ + range: { startLineNumber: 1, endLineNumber: 1, startColumn: testCase.cursorColumn, endColumn: 1000 }, + contentText: testCase.argumentHint, + }], + }); + } finally { + editorStore.dispose(); + } + }); + } + }); + + test('offers only legacy fork variants when the agent host does not support an initial message', async () => { + const { suggestions } = await provideSuggestions(true, [forkCommand], new ForkCompletionChatSessionsService(false)); + + assert.deepStrictEqual(suggestions.map(suggestion => suggestion.label), [ + { label: '/fork', description: 'Fork this chat' }, + { label: '/fork <title>', description: 'Fork this chat' }, + ]); + }); + + test('uses the original slash command completion outside agent host sessions', async () => { + const chatSessionsService = new ForkCompletionChatSessionsService(true); + const { suggestions } = await provideSuggestions(false, [forkCommand], chatSessionsService); + + assert.deepStrictEqual(suggestions.map(suggestion => ({ label: suggestion.label, insertText: suggestion.insertText, command: suggestion.command?.id })), [ + { label: { label: '/fork', description: 'Fork this chat' }, insertText: '', command: ChatSubmitAction.ID }, + ]); + assert.strictEqual(chatSessionsService.lookupCount, 0); + }); + + test('does not query capabilities when no command has a fork prompt variant', async () => { + const chatSessionsService = new ForkCompletionChatSessionsService(true); + const { suggestions } = await provideSuggestions(true, [{ + command: 'review', + detail: 'Review the workspace', + locations: [ChatAgentLocation.Chat], + }], chatSessionsService); + + assert.strictEqual(chatSessionsService.lookupCount, 0); + assert.deepStrictEqual(suggestions.map(suggestion => suggestion.label), [ + { label: '/review', description: 'Review the workspace' }, + ]); + }); + + test('preserves legacy variants when the capability lookup is cancelled', async () => { + const chatSessionsService = new ForkCompletionChatSessionsService(false); + const cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.cancel(); + const { suggestions } = await provideSuggestions(true, [forkCommand], chatSessionsService, cancellationTokenSource.token); + + assert.strictEqual(chatSessionsService.lastToken, cancellationTokenSource.token); + assert.deepStrictEqual(suggestions.map(suggestion => suggestion.label), [ + { label: '/fork', description: 'Fork this chat' }, + { label: '/fork <title>', description: 'Fork this chat' }, + ]); + cancellationTokenSource.dispose(); + }); + + test('preserves legacy variants when the capability lookup fails', async () => { + const { suggestions } = await provideSuggestions(true, [forkCommand], new ForkCompletionChatSessionsService(false, new Error('lookup failed'))); + + assert.deepStrictEqual(suggestions.map(suggestion => suggestion.label), [ + { label: '/fork', description: 'Fork this chat' }, + { label: '/fork <title>', description: 'Fork this chat' }, + ]); + }); +}); + suite('AgentHostInputCompletions skills', () => { const store = new DisposableStore(); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputEditorContrib.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputEditorContrib.test.ts index 0c8929554891b3..9999d22b70a96c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputEditorContrib.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputEditorContrib.test.ts @@ -32,6 +32,7 @@ suite('ChatTokenDeleter', () => { test('deletes inserted slash, agent, and variable tokens on immediate backspace', () => { const testCases = [ { initialValue: '/', insertedText: '/fix ', deleteRange: new Range(1, 5, 1, 6) }, + { initialValue: '/', insertedText: '/fork ', deleteRange: new Range(1, 6, 1, 7) }, { initialValue: '@', insertedText: '@workspace ', deleteRange: new Range(1, 11, 1, 12) }, { initialValue: '#', insertedText: '#selection', deleteRange: new Range(1, 10, 1, 11) }, ]; diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 52c4925f700df7..1f0cf37259fed3 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -165,6 +165,7 @@ suite('ChatService', () => { let instantiationService: TestInstantiationService; let testFileService: InMemoryTestFileService; let editingSessionEntries: ISettableObservable<readonly IModifiedFileEntry[]>; + let slashCommandService: ChatSlashCommandService; let chatAgentService: IChatAgentService; const testServices: ChatService[] = []; @@ -211,7 +212,8 @@ suite('ChatService', () => { instantiationService.stub(IContextKeyService, contextKeyService); instantiationService.stub(IViewsService, new TestExtensionService()); instantiationService.stub(IWorkspaceContextService, new TestContextService()); - instantiationService.stub(IChatSlashCommandService, testDisposables.add(instantiationService.createInstance(ChatSlashCommandService))); + slashCommandService = testDisposables.add(instantiationService.createInstance(ChatSlashCommandService)); + instantiationService.stub(IChatSlashCommandService, slashCommandService); instantiationService.stub(IConfigurationService, new TestConfigurationService()); instantiationService.stub(IChatService, new MockChatService()); instantiationService.stub(IChatSessionsService, new MockChatSessionsService()); @@ -743,6 +745,64 @@ suite('ChatService', () => { assert.deepStrictEqual(executions, ['local', 'remote']); }); + test('runs silent slash completion only after fork command success', async () => { + const service = createChatService(); + const model = startSessionModel(service).object; + const started = new DeferredPromise<void>(); + const gate = new DeferredPromise<void>(); + let completed = 0; + + testDisposables.add(slashCommandService.registerSlashCommand({ + command: 'fork', + detail: 'Fork', + silent: true, + locations: [ChatAgentLocation.Chat], + }, async () => { + started.complete(); + await gate.p; + })); + + const sending = service.sendRequest(model.sessionResource, '/fork prompt source draft', { + onDidExecuteSlashCommand: () => completed++, + }); + await started.p; + assert.strictEqual(completed, 0, 'the source draft must remain while the fork command is unresolved'); + gate.complete(); + const result = await sending; + assert.ok(result?.kind === 'sent'); + await result.data.responseCompletePromise; + assert.strictEqual(completed, 1); + }); + + test('does not run silent slash completion when fork command rejects', async () => { + const service = createChatService(); + const model = startSessionModel(service).object; + const started = new DeferredPromise<void>(); + const gate = new DeferredPromise<void>(); + let completed = 0; + + testDisposables.add(slashCommandService.registerSlashCommand({ + command: 'fork', + detail: 'Fork', + silent: true, + locations: [ChatAgentLocation.Chat], + }, async () => { + started.complete(); + await gate.p; + })); + + const sending = service.sendRequest(model.sessionResource, '/fork prompt source draft', { + onDidExecuteSlashCommand: () => completed++, + }); + await started.p; + assert.strictEqual(completed, 0); + gate.error(new Error('fork rejected')); + const result = await sending; + assert.ok(result?.kind === 'sent'); + await result.data.responseCompletePromise; + assert.strictEqual(completed, 0, 'a rejected fork must retain the source draft'); + }); + test('slash commands reject overlapping session types for the same id', () => { const slashCommandService = testDisposables.add(instantiationService.createInstance(ChatSlashCommandService)); const command = async () => undefined; From bdae0fa66d6b723015ac01c24cf8a89893fff252 Mon Sep 17 00:00:00 2001 From: Don Jayamanne <don.jayamanne@outlook.com> Date: Tue, 13 Oct 2026 10:15:57 +1100 Subject: [PATCH 04/15] agentHost: adapt fork title test to utility model service Use the upstream utility-model test adapter after rebasing prompted forks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/node/agentHostSessionTitleController.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts index 2cb05498a1f451..f36ebe8e662637 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts @@ -748,8 +748,7 @@ suite('AgentHostSessionTitleController', () => { })); const restored = disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService: createSessionDataService(db), - copilotApiService, - getGitHubCopilotToken: () => 'gh-token', + utilityModelService: createUtilityModelService(copilotApiService, () => 'gh-token'), }, new NullLogService())); await restored.restoreTitleGenerationStrategy(session.toString(), buildDefaultChatUri(session)); From ebab00d6844c65589655820b3a76f506bd94b58d Mon Sep 17 00:00:00 2001 From: Don Jayamanne <don.jayamanne@outlook.com> Date: Tue, 13 Oct 2026 13:58:59 +1100 Subject: [PATCH 05/15] chat: distinguish fork completion descriptions Localize the named and prompted fork descriptions for #341229. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workbench/contrib/chat/browser/chatSlashCommands.ts | 4 ++-- .../chat/test/browser/actions/chatForkActions.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index b21bba3f1a79de..074525efad8b2d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -229,8 +229,8 @@ export class ChatSlashCommandsContribution extends Disposable { silent: true, completionVariants: [ { label: '/fork', insertText: '', executeImmediately: true }, - { label: '/fork <title>', insertText: '/fork ', argumentHint: nls.localize('fork.titleArgumentHint', "title") }, - { label: '/fork prompt <prompt>', insertText: '/fork prompt ', argumentHint: nls.localize('fork.promptArgumentHint', "prompt") }, + { label: '/fork <title>', insertText: '/fork ', detail: nls.localize('fork.titleDetail', "Fork conversation with a custom title"), argumentHint: nls.localize('fork.titleArgumentHint', "title") }, + { label: '/fork prompt <prompt>', insertText: '/fork prompt ', detail: nls.localize('fork.promptDetail', "Fork conversation and send a prompt"), argumentHint: nls.localize('fork.promptArgumentHint', "prompt") }, ], locations: [ChatAgentLocation.Chat], when: ContextKeyExpr.or( diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts index a1608d2e1b51bd..cbb9292a26f95b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts @@ -110,10 +110,10 @@ suite('ForkConversationAction', () => { new class extends mock<IChatPetService>() { }(), new class extends mock<IWorkbenchEnvironmentService>() { override readonly isSessionsWindow = false; }(), new class extends mock<IAgentHostConnectionsService>() { }(), )); - assert.deepStrictEqual(forkData?.completionVariants?.map(variant => [variant.label, variant.insertText, variant.argumentHint, variant.executeImmediately]), [ - ['/fork', '', undefined, true], - ['/fork <title>', '/fork ', 'title', undefined], - ['/fork prompt <prompt>', '/fork prompt ', 'prompt', undefined], + assert.deepStrictEqual(forkData?.completionVariants?.map(variant => [variant.label, variant.insertText, variant.argumentHint, variant.executeImmediately, variant.detail ?? forkData?.detail]), [ + ['/fork', '', undefined, true, 'Fork conversation into a new chat session'], + ['/fork <title>', '/fork ', 'title', undefined, 'Fork conversation with a custom title'], + ['/fork prompt <prompt>', '/fork prompt ', 'prompt', undefined, 'Fork conversation and send a prompt'], ]); const resource = URI.parse('agent-host-copilot:/source'); From 508c2b9695a80e594825c0fe8b24cda5f89913a7 Mon Sep 17 00:00:00 2001 From: Don Jayamanne <don.jayamanne@outlook.com> Date: Tue, 13 Oct 2026 14:21:08 +1100 Subject: [PATCH 06/15] chat: preserve fork context and drafts across asynchronous creation Address review feedback for #341230: resolve streaming fork boundaries, retain inherited agent selection, identify in-flight operations without serializing attachments, parse prefixed titles, and protect edited attachment drafts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/baseAgentHostSessionsProvider.ts | 25 ++-- .../localAgentHostSessionsProvider.test.ts | 119 ++++++++++++++++++ .../chat/browser/actions/chatForkActions.ts | 20 +-- .../agentHost/agentHostSessionHandler.ts | 7 +- .../contrib/chat/browser/chatSlashCommands.ts | 2 +- .../contrib/chat/browser/widget/chatWidget.ts | 6 +- .../chat/common/chatSessionsService.ts | 2 +- .../browser/actions/chatForkActions.test.ts | 114 +++++++++++++++-- .../agentHostChatContribution.test.ts | 39 +++++- .../browser/chatWidgetSlashCommand.test.ts | 33 ++++- 10 files changed, 326 insertions(+), 41 deletions(-) diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index a5f3f57c8c007f..0bea1c5f1e44fb 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -6699,7 +6699,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement cached.setChatModelId(chat.resource, selectedModelId, ChatModelSource.CarriedOver); cached.setChatAgent(chat.resource, selectedAgentUri ? { uri: selectedAgentUri, name: '' } : undefined); - await this._retainChatSessionModel(chat.resource, selectedModelId, selectedAgentUri); + await this._retainChatSessionModel(chat.resource, { modelId: selectedModelId, agentUri: selectedAgentUri }); return chat; } @@ -6740,13 +6740,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement cached.chats.map(chats => chats.find(candidate => isEqual(candidate.resource, forked.resource))), (candidate): candidate is IChat => !!candidate, ); - // The host-published initial message owns the new chat's model and - // mode. Do not replace either with a potentially stale source picker. - await this._retainChatSessionModel( - chat.resource, - cached.getChatModelId(chat.resource), - cached.getChatMode(chat.resource)?.id, - ); + // Retain the hydrated input state; the new peer's catalog may not yet carry its model or mode. + await this._retainChatSessionModel(chat.resource); return chat; } @@ -6782,7 +6777,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement cached.setChatModelId(chat.resource, selectedModelId, ChatModelSource.CarriedOver); cached.setChatAgent(chat.resource, selectedAgentUri ? { uri: selectedAgentUri, name: '' } : undefined); - await this._retainChatSessionModel(chat.resource, selectedModelId, selectedAgentUri); + await this._retainChatSessionModel(chat.resource, { modelId: selectedModelId, agentUri: selectedAgentUri }); return chat; } @@ -6840,7 +6835,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._carriedOverChatModelConfigurations.set(chat.resource.toString(), { modelId: selectedModelId, configuration: modelConfiguration }); } - await this._retainChatSessionModel(chat.resource, selectedModelId, selectedAgentUri, modelConfiguration); + await this._retainChatSessionModel(chat.resource, { modelId: selectedModelId, agentUri: selectedAgentUri, modelConfiguration }); return chat; } @@ -6982,13 +6977,19 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } - private async _retainChatSessionModel(chatResource: URI, modelId: string | undefined, agentUri: string | undefined, modelConfiguration?: IAutomationSessionTemplate['modelConfiguration']): Promise<void> { + private async _retainChatSessionModel(chatResource: URI, selection?: { + readonly modelId: string | undefined; + readonly agentUri: string | undefined; + readonly modelConfiguration?: IAutomationSessionTemplate['modelConfiguration']; + }): Promise<void> { const modelRef = await this._chatService.acquireOrLoadSession(chatResource, ChatAgentLocation.Chat, CancellationToken.None); if (!modelRef) { return; } - this._applyChatSessionState(modelRef, modelId, agentUri, { modelConfiguration }); + if (selection) { + this._applyChatSessionState(modelRef, selection.modelId, selection.agentUri, { modelConfiguration: selection.modelConfiguration }); + } const resourceKey = chatResource.toString(); const lease = new DisposableStore(); lease.add(modelRef); 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 387734e9159e2d..9acb58ce4121fd 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 @@ -10670,6 +10670,125 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('forkChat retains a prompt-bearing fork without overwriting its hydrated input state', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + const rawId = 'multi-fork-prompt-input-state'; + const sessionUri = AgentSession.uri('copilotcli', rawId).toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const childChat = buildChatUri(sessionUri, 'prompt-child'); + const sourceTurnInputState: IChatModelInputState = { + mode: { id: 'agent://source-custom-agent', kind: ChatModeKind.Agent }, + selectedModel: { identifier: 'agent-host-copilotcli:source-model', metadata: createTestLanguageModel('source-model') }, + modelConfiguration: { reasoningEffort: 'high' }, + inputText: 'Keep the target draft', + attachments: [{ kind: 'file', id: 'source-file', name: 'source.ts', value: URI.file('/workspace/source.ts') }], + selections: [{ selectionStartLineNumber: 1, selectionStartColumn: 2, positionLineNumber: 1, positionColumn: 4 }], + contrib: {}, + }; + const inputUpdates: Partial<IChatModelInputState>[] = []; + const inputModel = new class extends mock<IInputModel>() { + override readonly state = observableValue<IChatModelInputState>(this, sourceTurnInputState); + override setState(state: Partial<IChatModelInputState>): void { + inputUpdates.push(state); + this.state.set({ ...this.state.get(), ...state }, undefined); + } + }(); + const targetModel = new class extends mock<IChatModel>() { + override readonly inputModel = inputModel; + }(); + const acquiredResources: string[] = []; + const releasedResources: string[] = []; + const provider = createProvider(disposables, agentHost, undefined, { + lookupLanguageModel: createTestLanguageModel, + acquireOrLoadSession: async resource => { + if (!resource.fragment) { + return undefined; + } + acquiredResources.push(resource.toString()); + return { + object: targetModel, + dispose: () => { + releasedResources.push(resource.toString()); + }, + }; + }, + chatSessionsService: { + forkChatSession: async resource => { + agentHost.setSessionState(rawId, 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + makeChatSummary(childChat, 'Prompt child'), + ], { defaultChat })); + return { resource: resource.with({ fragment: 'prompt-child' }), label: 'Prompt child', timing: { created: 0, lastRequestStarted: 0, lastRequestEnded: 0 } }; + }, + }, + }); + const session = setupMultiChatSession(provider, rawId); + agentHost.setSessionState(rawId, 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + ], { defaultChat })); + provider.setModel(session.sessionId, session.resource, 'agent-host-copilotcli:unsent-picker', ChatModelSource.Chosen); + provider.setAgent(session.sessionId, { uri: 'agent://unsent-picker', name: 'Unsent picker' }); + + const createCallsBefore = agentHost.createdChats.length; + const forked = await provider.forkChat(session.sessionId, session.resource, 'selected-turn', { + initialMessage: { text: 'Continue with the source custom agent' }, + }); + const retained = { + inputState: inputModel.state.get(), + inputUpdates: [...inputUpdates], + sourceModel: session.mainChat.get().modelId.get(), + sourceMode: session.mainChat.get().mode.get(), + targetCatalogModel: forked.modelId.get(), + targetCatalogMode: forked.mode.get(), + acquiredResources: [...acquiredResources], + releasedResources: [...releasedResources], + createCalls: agentHost.createdChats.length - createCallsBefore, + }; + await timeout(10_000); + + assert.deepStrictEqual({ retained, releasedResources }, { + retained: { + inputState: sourceTurnInputState, + inputUpdates: [], + sourceModel: 'agent-host-copilotcli:unsent-picker', + sourceMode: { id: 'agent://unsent-picker', kind: ChatModeKind.Agent }, + targetCatalogModel: undefined, + targetCatalogMode: undefined, + acquiredResources: [forked.resource.toString()], + releasedResources: [], + createCalls: 0, + }, + releasedResources: [forked.resource.toString()], + }); + })); + + test('forkChat preserves the built-in agent default for bare and named forks', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + agentHost.setAgents([{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true, initialTitle: true } } } as AgentInfo]); + const inputStates: Partial<IChatModelInputState>[] = []; + const provider = createProvider(disposables, agentHost, undefined, { + acquireOrLoadSession: async () => new ImmortalReference(new class extends mock<IChatModel>() { + override readonly inputModel = new class extends mock<IInputModel>() { + override setState(state: Partial<IChatModelInputState>): void { + inputStates.push(state); + } + }(); + }()), + }); + const session = setupMultiChatSession(provider, 'multi-fork-default-agent'); + const sessionUri = AgentSession.uri('copilotcli', 'multi-fork-default-agent').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + agentHost.setSessionState('multi-fork-default-agent', 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + ], { defaultChat })); + + await provider.forkChat(session.sessionId, session.resource, 'turn-1'); + await provider.forkChat(session.sessionId, session.resource, 'turn-1', { title: 'Named fork' }); + + assert.deepStrictEqual(inputStates, [ + { mode: { id: 'agent', kind: ChatModeKind.Agent } }, + { mode: { id: 'agent', kind: ChatModeKind.Agent } }, + ]); + })); + test('forkChat omits an unsupported initial title and returns the host-generated peer chat', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { agentHost.setAgents([{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true } } } as AgentInfo]); const provider = createProvider(disposables, agentHost); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts index 9dd3538dd5acdc..490a9a7b55cbc0 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts @@ -279,19 +279,20 @@ export class ForkConversationAction extends Action2 { return false; } - private pendingFork = new Map<string, Promise<void>>(); + private readonly pendingForks = new Set<{ readonly key: string; readonly context: object | undefined; readonly promise: Promise<void> }>(); private async forkContributedChatSession(sourceSessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, openForkedSessionImmediately: boolean, chatSessionsService: IChatSessionsService, instantiationService: IInstantiationService, options?: IForkConversationOptions) { const title = options?.title?.trim() || undefined; const forkOptions: IChatSessionForkOptions | undefined = title || options?.initialMessage ? { ...(title ? { title } : {}), ...(options?.initialMessage ? { initialMessage: options.initialMessage } : {}), ...(options?.sourceRequestId ? { sourceRequestId: options.sourceRequestId } : {}) } : undefined; - // Message context is part of the operation identity: only an identical - // in-flight fork may coalesce. - const pendingKey = JSON.stringify([sourceSessionResource.toString(), request?.id, title, options?.initialMessage]); - const pending = this.pendingFork.get(pendingKey); - if (pending) { - return pending; + const initialMessage = options?.initialMessage; + const pendingKey = JSON.stringify([sourceSessionResource.toString(), request?.id, options?.sourceRequestId, title, initialMessage?.text, initialMessage?.textOffset]); + const context = initialMessage?.requestOptions ?? initialMessage; + for (const pending of this.pendingForks) { + if (pending.key === pendingKey && pending.context === context) { + return pending.promise; + } } const forkPromise = (async () => { @@ -316,11 +317,12 @@ export class ForkConversationAction extends Action2 { } })(); - this.pendingFork.set(pendingKey, forkPromise); + const pending = { key: pendingKey, context, promise: forkPromise }; + this.pendingForks.add(pending); try { await forkPromise; } finally { - this.pendingFork.delete(pendingKey); + this.pendingForks.delete(pending); } } 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 608d687df384ed..64ebf14e2dd7e1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -5967,7 +5967,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const protocolState = this._getSessionState(sessionUri, sourceChat); let turnIndex: number | undefined; const sourceRequestId = options?.sourceRequestId ?? request?.id; - if (sourceRequestId) { + if (options?.sourceRequestId !== undefined && protocolState && protocolState.activeTurn?.id === sourceRequestId) { + // Active requests are not forkable history; use the completed boundary already available. + if (protocolState.turns.length) { + turnIndex = protocolState.turns.length - 1; + } + } else if (sourceRequestId) { const requestIdx = protocolState?.turns.findIndex(t => t.id === sourceRequestId); if (requestIdx === undefined || requestIdx < 0) { throw new Error(`Cannot fork: turn for request ${sourceRequestId} not found in protocol state`); diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 074525efad8b2d..5420e3a707a4ba 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -271,7 +271,7 @@ export class ChatSlashCommandsContribution extends Disposable { return; } - const title = prompt.trim() || undefined; + const title = (isAgentHostSession ? afterCommand : prompt).trim() || undefined; await commandService.executeCommand('workbench.action.chat.forkConversation', sessionResource, title ? { title } : undefined); return undefined; })); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index c623f92b78554f..396df148e37c33 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -13,6 +13,7 @@ import { disposableTimeout, timeout } from '../../../../../base/common/async.js' import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; +import { structuralEquals } from '../../../../../base/common/equals.js'; import { CancellationError, ErrorNoTelemetry, isCancellationError } from '../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { hash } from '../../../../../base/common/hash.js'; @@ -3334,7 +3335,7 @@ export class ChatWidget extends Disposable implements IChatWidget { return undefined; } const sourceInputModel = this.viewModel?.model.inputModel; - const ownedAttachmentIds = this.input.attachmentModel.getAttachmentIDs(); + const ownedAttachments = [...this.input.attachmentModel.attachments]; // Keep cleanup bound to the source model even after navigating to the fork. return () => { if (!sourceInputModel) { @@ -3344,8 +3345,7 @@ export class ChatWidget extends Disposable implements IChatWidget { if (state?.inputText !== ownedInput) { return; } - const attachmentIds = new Set((state.attachments ?? []).map(attachment => attachment.id)); - if (attachmentIds.size !== ownedAttachmentIds.size || [...attachmentIds].some(id => !ownedAttachmentIds.has(id))) { + if (!structuralEquals(state.attachments ?? [], ownedAttachments)) { return; } sourceInputModel.setState({ inputText: '', attachments: [] }); diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 0db35765f920f6..c6dd28591196c1 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -525,7 +525,7 @@ export interface IChatSessionForkOptions { readonly initialMessage?: IChatSessionInitialMessage; /** * Exact last source turn to include, overriding the request-item's exclusive boundary. - * Providers must preserve this selection while the operation is being prepared. + * An active request resolves to the last completed turn; other explicit selections must be preserved. */ readonly sourceRequestId?: string; } diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts index cbb9292a26f95b..d6ee2ac52d96ee 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts @@ -204,11 +204,16 @@ suite('ForkConversationAction', () => { new class extends mock<IAgentHostConnectionsService>() { }(), )); const resource = URI.parse('contributed:/source'); + const agentHostResource = URI.parse('agent-host-copilot:/source'); assert.ok(forkCommand); for (const prompt of ['', ' ', ' My title. ']) { await forkCommand(prompt, { report: () => { } }, [], ChatAgentLocation.Chat, resource, CancellationToken.None); } + await forkCommand('icipant /fork Participant title ', { report: () => { } }, [], ChatAgentLocation.Chat, agentHostResource, CancellationToken.None, undefined, { input: '@participant /fork Participant title ' }); + await forkCommand('ork Leading title ', { report: () => { } }, [], ChatAgentLocation.Chat, agentHostResource, CancellationToken.None, undefined, { input: ' /fork Leading title ' }); + await forkCommand(' Fallback title. ', { report: () => { } }, [], ChatAgentLocation.Chat, agentHostResource, CancellationToken.None); + await forkCommand('Legacy title', { report: () => { } }, [], ChatAgentLocation.Chat, resource, CancellationToken.None, undefined, { input: '/fork Different title' }); assert.deepStrictEqual({ executeImmediately: forkData?.executeImmediately, @@ -221,6 +226,10 @@ suite('ForkConversationAction', () => { { resource, options: undefined }, { resource, options: undefined }, { resource, options: { title: 'My title.' } }, + { resource: agentHostResource, options: { title: 'Participant title' } }, + { resource: agentHostResource, options: { title: 'Leading title' } }, + { resource: agentHostResource, options: { title: 'Fallback title.' } }, + { resource, options: { title: 'Legacy title' } }, ], }); }); @@ -257,14 +266,43 @@ suite('ForkConversationAction', () => { }); }); + const circularMetadata: { self?: object } = {}; + circularMetadata.self = circularMetadata; + const sharedRequestOptions = { attachedContext: [] }; const forkIdentityCases: { name: string; first: IForkConversationOptions; second: IForkConversationOptions }[] = [ { name: 'requested title', first: { title: 'First' }, second: { title: 'Second' } }, { name: 'initial prompt', first: { initialMessage: { text: 'First' } }, second: { initialMessage: { text: 'Second' } } }, + { name: 'source request', first: { title: 'Same title', sourceRequestId: 'first' }, second: { title: 'Same title', sourceRequestId: 'second' } }, + { + name: 'initial prompt offset', + first: { initialMessage: { text: 'Same prompt', textOffset: 13, requestOptions: sharedRequestOptions } }, + second: { initialMessage: { text: 'Same prompt', textOffset: 20, requestOptions: sharedRequestOptions } }, + }, + { + name: 'initial prompt intent reference', + first: { initialMessage: { text: 'Same prompt' } }, + second: { initialMessage: { text: 'Same prompt' } }, + }, { name: 'initial attachment payload', first: { initialMessage: { text: 'Same prompt', requestOptions: { attachedContext: [{ id: 'context', name: 'context', kind: 'generic', value: 'first payload' }] } } }, second: { initialMessage: { text: 'Same prompt', requestOptions: { attachedContext: [{ id: 'context', name: 'context', kind: 'generic', value: 'second payload' }] } } }, }, + { + name: 'circular attachment value and metadata context', + first: { initialMessage: { text: 'Same prompt', requestOptions: { attachedContext: [{ id: 'context', name: 'context', kind: 'generic', value: circularMetadata, _meta: circularMetadata }] } } }, + second: { initialMessage: { text: 'Same prompt', requestOptions: { attachedContext: [{ id: 'context', name: 'context', kind: 'generic', value: circularMetadata, _meta: circularMetadata }] } } }, + }, + { + name: 'BigInt attachment context', + first: { initialMessage: { text: 'Same prompt', requestOptions: { attachedContext: [{ id: 'context', name: 'context', kind: 'generic', value: 1n }] } } }, + second: { initialMessage: { text: 'Same prompt', requestOptions: { attachedContext: [{ id: 'context', name: 'context', kind: 'generic', value: 2n }] } } }, + }, + { + name: 'function attachment context with colliding serialization', + first: { initialMessage: { text: 'Same prompt', requestOptions: { attachedContext: [{ id: 'context', name: 'context', kind: 'generic', value: () => 'first payload' }] } } }, + second: { initialMessage: { text: 'Same prompt', requestOptions: { attachedContext: [{ id: 'context', name: 'context', kind: 'generic', value: () => 'second payload' }] } } }, + }, ]; for (const scenario of forkIdentityCases) { test(`uses the ${scenario.name} in contributed pending fork identity`, async () => { @@ -272,9 +310,9 @@ suite('ForkConversationAction', () => { const source = URI.parse('contributed:/source'); const calls: (object | undefined)[] = []; const gates: DeferredPromise<IChatSessionItem>[] = []; - const started = [new DeferredPromise<void>(), new DeferredPromise<void>()]; instantiationService.stub(IChatService, upcastPartial<IChatService>({})); - instantiationService.stub(IChatWidgetService, upcastPartial<IChatWidgetService>({})); + instantiationService.stub(IChatWidgetService, upcastPartial<IChatWidgetService>({ openSession: async () => undefined })); + instantiationService.stub(INotificationService, new TestNotificationService()); instantiationService.stub(IChatSessionsService, upcastPartial<IChatSessionsService>({ getContentProviderSchemes: () => [source.scheme], sessionSupportsFork: () => true, @@ -282,28 +320,80 @@ suite('ForkConversationAction', () => { calls.push(options); const gate = new DeferredPromise<IChatSessionItem>(); gates.push(gate); - started[calls.length - 1].complete(); return gate.p; }, })); - const action = new class extends ForkConversationAction { - protected override async _openForkedSession(): Promise<void> { } - }; + const action = new ForkConversationAction(); const first = instantiationService.invokeFunction(accessor => action.run(accessor, source, scenario.first)); - await started[0].p; const second = instantiationService.invokeFunction(accessor => action.run(accessor, source, scenario.second)); - await started[1].p; const duplicate = instantiationService.invokeFunction(accessor => action.run(accessor, source, scenario.first)); + const repeatedContext = instantiationService.invokeFunction(accessor => action.run(accessor, source, { + ...scenario.first, + ...(scenario.first.initialMessage?.requestOptions ? { initialMessage: { ...scenario.first.initialMessage } } : {}), + })); + const outcomes = Promise.allSettled([first, second, duplicate, repeatedContext]); + await timeout(0); + for (const [index, gate] of gates.entries()) { + gate.complete({ resource: URI.parse(`contributed:/fork-${index + 1}`), label: 'Fork', timing: { created: 0, lastRequestStarted: undefined, lastRequestEnded: undefined } }); + } + const results = await outcomes; await timeout(0); - gates[0].complete({ resource: URI.parse('contributed:/fork-1'), label: 'Fork 1', timing: { created: 0, lastRequestStarted: undefined, lastRequestEnded: undefined } }); - gates[1].complete({ resource: URI.parse('contributed:/fork-2'), label: 'Fork 2', timing: { created: 0, lastRequestStarted: undefined, lastRequestEnded: undefined } }); - await Promise.all([first, second, duplicate]); - assert.deepStrictEqual(calls, [scenario.first, scenario.second]); + assert.deepStrictEqual({ calls, outcomes: results.map(result => result.status) }, { + calls: [scenario.first, scenario.second], + outcomes: ['fulfilled', 'fulfilled', 'fulfilled', 'fulfilled'], + }); }); } + test('clears a failed pending fork before retrying the same context', async () => { + const instantiationService = disposables.add(new TestInstantiationService()); + const source = URI.parse('contributed:/source'); + const target = URI.parse('contributed:/fork'); + const cause = new Error('Unable to create the fork'); + const gate = new DeferredPromise<IChatSessionItem>(); + const options: IForkConversationOptions = { + initialMessage: { text: 'Same prompt', requestOptions: { attachedContext: [{ id: 'context', name: 'context', kind: 'generic', value: 1n }] } }, + }; + let creations = 0; + let openings = 0; + let fail = true; + instantiationService.stub(IChatService, upcastPartial<IChatService>({})); + instantiationService.stub(IChatWidgetService, upcastPartial<IChatWidgetService>({ + openSession: async () => { + openings++; + return undefined; + }, + })); + instantiationService.stub(INotificationService, new TestNotificationService()); + instantiationService.stub(IChatSessionsService, upcastPartial<IChatSessionsService>({ + getContentProviderSchemes: () => [source.scheme], + forkChatSession: async () => { + creations++; + if (fail) { + return gate.p; + } + return { resource: target, label: 'Fork', timing: { created: 0, lastRequestStarted: undefined, lastRequestEnded: undefined } }; + }, + })); + const action = new ForkConversationAction(); + const first = instantiationService.invokeFunction(accessor => action.run(accessor, source, options)); + const duplicate = instantiationService.invokeFunction(accessor => action.run(accessor, source, options)); + const outcomes = Promise.allSettled([first, duplicate]); + await timeout(0); + gate.error(cause); + const results = await outcomes; + fail = false; + await instantiationService.invokeFunction(accessor => action.run(accessor, source, options)); + + assert.deepStrictEqual({ + errors: results.map(result => result.status === 'rejected' ? result.reason : undefined), + creations, + openings, + }, { errors: [cause, cause], creations: 2, openings: 1 }); + }); + test('opens a fork with the current session selection reason', async () => { const instantiationService = disposables.add(new TestInstantiationService()); const parentSessionResource = URI.parse('vscode-chat-session://parent'); 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 eb44a1faf3e869..882848309f70b3 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 @@ -6209,7 +6209,7 @@ suite('AgentHostChatContribution', () => { // ---- Forking --------------------------------------------------------- suite('forking', () => { - async function forkSession(initialTitle: boolean | undefined, requestedTitle: string, publishedTitle: string, initialMessage?: IChatSessionForkOptions['initialMessage'], sourceSelection = false, boundary?: { turnCount?: number; sourceRequestId?: string; beforeRequestId?: string }) { + async function forkSession(initialTitle: boolean | undefined, requestedTitle: string, publishedTitle: string, initialMessage?: IChatSessionForkOptions['initialMessage'], sourceSelection = false, boundary?: { turnCount?: number; sourceRequestId?: string; beforeRequestId?: string; activeTurnId?: string }) { const { sessionHandler, agentHostService } = createContribution(disposables); const backendSession = AgentSession.uri('copilot', `fork-${initialTitle === true ? 'supported' : 'legacy'}`); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: backendSession.path }); @@ -6251,6 +6251,15 @@ suite('AgentHostChatContribution', () => { responseParts: [], usage: undefined, })), + ...(boundary?.activeTurnId ? { + activeTurn: { + id: boundary.activeTurnId, + startedAt: new Date().toISOString(), + message: { text: 'Streaming request', origin: { kind: MessageKind.User } }, + responseParts: [], + usage: undefined, + }, + } : {}), ...(sourceSelection ? { draft: { text: 'Unsaved composer draft', @@ -6283,6 +6292,34 @@ suite('AgentHostChatContribution', () => { }; } + test('prompt fork from an active request keeps the last completed turn', async () => { + const result = await forkSession(true, 'Fork', 'Fork', { text: 'Continue in a fork' }, false, { + turnCount: 2, + sourceRequestId: 'streaming-turn', + activeTurnId: 'streaming-turn', + }); + assert.deepStrictEqual({ + source: result.call.options?.fork?.source.toString(), + turnId: result.call.options?.fork?.turnId, + prompt: result.call.options?.initialMessage?.text, + }, { + source: result.sourceChat, + turnId: 'source-turn-2', + prompt: 'Continue in a fork', + }); + }); + + test('prompt fork from an active first request cannot invent a completed boundary', async () => { + await assert.rejects( + forkSession(true, 'Fork', 'Fork', { text: 'Continue in a fork' }, false, { + turnCount: 0, + sourceRequestId: 'streaming-turn', + activeTurnId: 'streaming-turn', + }), + /Cannot fork: no turns to fork from/, + ); + }); + for (const boundary of [ { turnCount: 1, sourceRequestId: 'source-turn' }, { turnCount: 3, sourceRequestId: 'source-turn-2' }, diff --git a/src/vs/workbench/contrib/chat/test/browser/chatWidgetSlashCommand.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatWidgetSlashCommand.test.ts index a790ca8a0e01d6..aab0f1c6906f2a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatWidgetSlashCommand.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatWidgetSlashCommand.test.ts @@ -59,7 +59,7 @@ suite('ChatWidget Slash Commands', () => { const widget = { viewModel: widgetModel, getInput: () => input, - input: { attachmentModel: { getAttachmentIDs: () => attachmentIds } }, + input: { attachmentModel: { getAttachmentIDs: () => attachmentIds, get attachments() { return sourceState.get().attachments; } } }, parsedInput: upcastPartial<IParsedChatRequest>({ parts: [new ChatRequestSlashCommandPart(new OffsetRange(0, '/fork'.length), new Range(1, 1, 1, 6), { command: 'fork', detail: 'Fork', locations: [ChatAgentLocation.Chat] })], }), @@ -109,6 +109,37 @@ suite('ChatWidget Slash Commands', () => { assert.strictEqual(privateMethods._createForkPromptInputCleanup.call(widget, undefined, undefined), undefined); }); + test('fork prompt cleanup preserves changed attachment content with unchanged IDs', () => { + const original = toPasteVariableEntry('Pasted text', 'original content', { id: 'same-id' }); + const updated = toPasteVariableEntry('Pasted text', 'edited content', { id: 'same-id' }); + const inputText = '/fork prompt preserve this'; + const state = observableValue('sourceInput', { inputText, attachments: [original] }); + const inputModel = { + state, + setState: (change: Partial<ReturnType<typeof state.get>>) => state.set({ ...state.get(), ...change }, undefined), + }; + const widget = { + viewModel: { + sessionResource: URI.parse('agent-host-copilot:/source'), + model: { inputModel }, + }, + getInput: () => state.get().inputText, + input: { attachmentModel: { getAttachmentIDs: () => new Set(state.get().attachments.map(attachment => attachment.id)), get attachments() { return state.get().attachments; } } }, + parsedInput: upcastPartial<IParsedChatRequest>({ + parts: [new ChatRequestSlashCommandPart(new OffsetRange(0, 5), new Range(1, 1, 1, 6), { command: 'fork', detail: 'Fork', locations: [ChatAgentLocation.Chat] })], + }), + } as unknown as ChatWidget; + const privateMethods = ChatWidget.prototype as unknown as { + _createForkPromptInputCleanup(query: string | undefined, options: IChatAcceptInputOptions | undefined): (() => void) | undefined; + }; + const cleanup = privateMethods._createForkPromptInputCleanup.call(widget, undefined, undefined); + assert.ok(cleanup); + state.set({ inputText, attachments: [] }, undefined); + state.set({ inputText, attachments: [updated] }, undefined); + cleanup(); + assert.deepStrictEqual(state.get(), { inputText, attachments: [updated] }); + }); + test('during-request execution retains attachments for a non-preserved slash command', async () => { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IStorageService, disposables.add(new TestStorageService())); From 30baf6d8c854b2d1aeeeff6569ea64ff91bdfd2e Mon Sep 17 00:00:00 2001 From: Don Jayamanne <don.jayamanne@outlook.com> Date: Tue, 13 Oct 2026 15:22:14 +1100 Subject: [PATCH 07/15] Fix fork CI regressions after upstream sync Guard fork-only draft inspection before accessing the composer. Update title tests for normalized storage and explicit user title ownership, and correct test formatting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/test/node/agentService.test.ts | 29 ++++++++++--------- .../test/node/e2e/suites/multiChatSuite.ts | 2 +- .../contrib/chat/browser/widget/chatWidget.ts | 9 +++--- .../agentHostChatContribution.test.ts | 26 ++++++++--------- .../browser/chatWidgetSlashCommand.test.ts | 21 +++++++++++++- 5 files changed, 54 insertions(+), 33 deletions(-) diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 86b254c15aec43..3c0886cb2f6088 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -24213,6 +24213,7 @@ suite('AgentService (node dispatcher)', () => { envelope.action.type === ActionType.SessionTitleChanged ? [envelope.action.title] : []); const chatState = stateManager.getChatState(chatUri.toString()); const sessionChat = stateManager.getSessionState(session.toString())?.chats.find(chat => chat.resource === chatUri.toString()); + const persisted = await getTestChatPersistenceService(localService).readNormalizedChat(session, chatUri); assert.deepStrictEqual({ providerTitle: receivedOptions?.title, publishedTitles, @@ -24220,10 +24221,9 @@ suite('AgentService (node dispatcher)', () => { creationSessionTitleChanges, chatStateTitle: chatState?.title, sessionChatTitle: sessionChat?.title, - chatLocalTitle: await sessionData.database(chatUri).getMetadata(SESSION_CUSTOM_TITLE_KEY), - chatLocalSource: await sessionData.database(chatUri).getMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY), - parentTitle: await sessionData.database(session).getMetadata(customChatTitleMetadataKey(chatUri.toString())), - parentSource: await sessionData.database(session).getMetadata(customChatTitleSourceMetadataKey(chatUri.toString())), + normalized: persisted.normalized, + persistedTitle: persisted.chat?.metadata?.summary, + persistedSource: persisted.chat?.metadata?.titleSource, origin: chatState?.origin, forkSource: receivedOptions?.fork?.source.toString(), forkTurnId: receivedOptions?.fork?.turnId, @@ -24237,10 +24237,9 @@ suite('AgentService (node dispatcher)', () => { creationSessionTitleChanges: [], chatStateTitle: '.', sessionChatTitle: '.', - chatLocalTitle: '.', - chatLocalSource: AGENT_HOST_TITLE_SOURCE_USER, - parentTitle: '.', - parentSource: AGENT_HOST_TITLE_SOURCE_USER, + normalized: true, + persistedTitle: '.', + persistedSource: AGENT_HOST_TITLE_SOURCE_USER, origin: { kind: ChatOriginKind.Fork, chat: buildDefaultChatUri(session), turnId: 't1' }, forkSource: buildDefaultChatUri(session), forkTurnId: 't1', @@ -24281,22 +24280,26 @@ suite('AgentService (node dispatcher)', () => { const chatUri = URI.parse(buildChatUri(session, 'bare-fork')); await localService.createChat(session, chatUri, { fork: { source: session, turnId: 't1' } }); const chatState = stateManager.getChatState(chatUri.toString()); + const persisted = await getTestChatPersistenceService(localService).readNormalizedChat(session, chatUri); assert.deepStrictEqual({ title: chatState?.title, - chatLocalSource: await sessionData.database(chatUri).getMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY), - parentSource: await sessionData.database(session).getMetadata(customChatTitleSourceMetadataKey(chatUri.toString())), + normalized: persisted.normalized, + persistedTitle: persisted.chat?.metadata?.summary, + persistedSource: persisted.chat?.metadata?.titleSource, forkedTitleCalls, }, { title: 'Forked: My Session', - chatLocalSource: AGENT_HOST_TITLE_SOURCE_AUTO, - parentSource: AGENT_HOST_TITLE_SOURCE_AUTO, + normalized: true, + persistedTitle: 'Forked: My Session', + persistedSource: AGENT_HOST_TITLE_SOURCE_AUTO, forkedTitleCalls: [[ session.toString(), chatUri.toString(), chatState?.turns, 'Forked: My Session', 'My Session', + undefined, ]], }); }); @@ -29085,7 +29088,7 @@ suite('AgentService (node dispatcher)', () => { persisted: snapshot.chats.map(chat => ({ uri: chat.chat, title: chat.metadata?.summary, source: chat.metadata?.titleSource })), }, { live: [{ uri: defaultChat, title: 'Original default' }, { uri: peer.toString(), title: 'Original peer' }], - persisted: [{ uri: defaultChat, title: 'Original default', source: 'user' }, { uri: peer.toString(), title: 'Original peer', source: 'auto' }], + persisted: [{ uri: defaultChat, title: 'Original default', source: 'user' }, { uri: peer.toString(), title: 'Original peer', source: 'user' }], }); }); }); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts index 836b9003c806e8..66abf88595678a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts @@ -693,7 +693,7 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { const repeatedState = await chatState(peer); assert.strictEqual( repeatedState.turns.filter(turn => turn.message.text === '/rename Initial Message Title').length - + (repeatedState.activeTurn?.message.text === '/rename Initial Message Title' ? 1 : 0), + + (repeatedState.activeTurn?.message.text === '/rename Initial Message Title' ? 1 : 0), 1, ); }, config.supportsMultipleChats && config.supportsChatFork && (config.supportsForkWithInitialMessage ?? false)); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 396df148e37c33..cf55eeb7431195 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -3319,17 +3319,16 @@ export class ChatWidget extends Disposable implements IChatWidget { private _createForkPromptInputCleanup(query: string | undefined, options: IChatAcceptInputOptions | undefined): (() => void) | undefined { const sourceSessionResource = this.viewModel?.sessionResource; + if (!sourceSessionResource || !isAgentHostTarget(getChatSessionType(sourceSessionResource)) || !!query || options?.preserveInput) { + return undefined; + } const ownedInput = this.getInput(); const forkPart = this.parsedInput.parts.find((part): part is ChatRequestSlashCommandPart => part instanceof ChatRequestSlashCommandPart && part.slashCommand.command === 'fork'); const afterFork = forkPart ? ownedInput.slice(forkPart.range.endExclusive) : ''; const promptToken = afterFork ? /^(\s*)prompt(?=\s|$)/.exec(afterFork) : undefined; const promptText = promptToken ? afterFork.slice(promptToken[0].length).trim() : ''; - if (!sourceSessionResource - || !isAgentHostTarget(getChatSessionType(sourceSessionResource)) - || !!query - || options?.preserveInput - || !forkPart + if (!forkPart || !promptToken || !promptText) { return undefined; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 882848309f70b3..13ecb044a70c0d 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 @@ -6451,19 +6451,19 @@ suite('AgentHostChatContribution', () => { test('does not create a fork when initial-message preflight is already cancelled', async () => { const { sessionHandler, agentHostService } = createContribution(disposables); - const backendSession = AgentSession.uri('copilot', 'fork-cancelled'); - const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: backendSession.path }); - const sourceChat = buildDefaultChatUri(backendSession.toString()); - agentHostService.setRootState({ agents: [{ provider: 'copilot', displayName: 'Agent Host - Copilot', description: 'test', models: [], capabilities: { multipleChats: { fork: true, initialTitle: true } } }], activeSessions: 0 }); - agentHostService.sessionStates.set(backendSession.toString(), { - ...createSessionState({ resource: backendSession.toString(), provider: 'copilot', title: 'Source', status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString() }), - lifecycle: SessionLifecycle.Ready, - defaultChat: sourceChat, - chats: [createDefaultChatSummary({ resource: backendSession.toString(), provider: 'copilot', title: 'Source', status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString() }, sourceChat)], - turns: [{ id: 'source-turn', state: TurnState.Complete, message: { text: 'Source', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }], - }); - const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); - disposables.add(chatSession); + const backendSession = AgentSession.uri('copilot', 'fork-cancelled'); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: backendSession.path }); + const sourceChat = buildDefaultChatUri(backendSession.toString()); + agentHostService.setRootState({ agents: [{ provider: 'copilot', displayName: 'Agent Host - Copilot', description: 'test', models: [], capabilities: { multipleChats: { fork: true, initialTitle: true } } }], activeSessions: 0 }); + agentHostService.sessionStates.set(backendSession.toString(), { + ...createSessionState({ resource: backendSession.toString(), provider: 'copilot', title: 'Source', status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString() }), + lifecycle: SessionLifecycle.Ready, + defaultChat: sourceChat, + chats: [createDefaultChatSummary({ resource: backendSession.toString(), provider: 'copilot', title: 'Source', status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString() }, sourceChat)], + turns: [{ id: 'source-turn', state: TurnState.Complete, message: { text: 'Source', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }], + }); + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(chatSession); const cts = disposables.add(new CancellationTokenSource()); cts.cancel(); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatWidgetSlashCommand.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatWidgetSlashCommand.test.ts index aab0f1c6906f2a..bcf8c8a84a0535 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatWidgetSlashCommand.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatWidgetSlashCommand.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { mockObject, upcastPartial } from '../../../../../base/test/common/mock.js'; +import { mockObject, upcastDeepPartial, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -37,6 +37,25 @@ suite('ChatWidget Slash Commands', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('fork prompt cleanup does not inspect the composer for unrelated submissions', () => { + const scenarios = [ + { resource: URI.parse('agent-host-copilot:/source'), query: 'programmatic prompt', options: undefined }, + { resource: URI.parse('agent-host-copilot:/source'), query: undefined, options: { preserveInput: true } }, + { resource: URI.parse('local:/source'), query: undefined, options: undefined }, + ]; + const privateMethods = ChatWidget.prototype as unknown as { + _createForkPromptInputCleanup(query: string | undefined, options: IChatAcceptInputOptions | undefined): (() => void) | undefined; + }; + for (const { resource, query, options } of scenarios) { + const widget = upcastDeepPartial<ChatWidget>({ + viewModel: { sessionResource: resource }, + getInput: () => assert.fail('unrelated submissions must not inspect the draft'), + }); + const cleanup = privateMethods._createForkPromptInputCleanup.call(widget, query, options); + assert.strictEqual(cleanup, undefined); + } + }); + test('fork prompt cleanup clears only the unchanged agent-host source input model', () => { const sourceState = observableValue('sourceInput', { inputText: '/fork prompt preserve this', From 29e29e2634587cdd2f7ebca438ea5f20f5514bf2 Mon Sep 17 00:00:00 2001 From: Don Jayamanne <don.jayamanne@outlook.com> Date: Tue, 13 Oct 2026 16:29:51 +1100 Subject: [PATCH 08/15] agentHost: drain session metadata before idle eviction Wait for pending normalized metadata and migration writes before dropping resident state, scoped to the released session. Preserve write error reporting and cover command and client renames across restoration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentHostPeerChatStore.ts | 42 ++++++++++--- .../platform/agentHost/node/agentService.ts | 1 + .../test/node/agentHostPeerChatStore.test.ts | 49 +++++++++++++++ .../agentHost/test/node/agentService.test.ts | 61 ++++++++++++++++++- 4 files changed, 141 insertions(+), 12 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts index bb25f72777cc82..cf45b5af0a14c0 100644 --- a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts +++ b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts @@ -60,7 +60,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ declare readonly _serviceBrand: undefined; private readonly _writes = new Map<string, Promise<void>>(); - private readonly _metadataWrites = new Set<Promise<void>>(); + private readonly _metadataWrites = new Map<Promise<void>, string>(); private readonly _deletingSessions = new Map<string, number>(); private _metadataMigration: ((session: URI, resource: URI, values: Readonly<Record<string, string>>) => Promise<Readonly<Record<string, string>>>) | undefined; @@ -99,15 +99,37 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ return { mutation: target ? { chat: target.chat, expected: { ownershipRevision: 0, metadataRevision: 0 }, patch } : undefined, remaining }; } + /** Drains all writes and propagates failures to callers synchronizing catalog state. */ async whenIdle(): Promise<void> { while (this._writes.size || this._metadataWrites.size) { - await Promise.all([...this._writes.values(), ...this._metadataWrites]); + await Promise.all([...this._writes.values(), ...this._metadataWrites.keys()]); } } - private _trackMetadataWrite(operation: () => Promise<void>): Promise<void> { + /** Waits for one session's writes to settle before release; write callers report failures. */ + async whenSessionIdle(session: URI): Promise<void> { + const sessionKey = session.toString(); + while (true) { + const writes: Promise<void>[] = []; + const queuedWrite = this._writes.get(sessionKey); + if (queuedWrite) { + writes.push(queuedWrite); + } + for (const [write, key] of this._metadataWrites) { + if (key === sessionKey) { + writes.push(write); + } + } + if (writes.length === 0) { + return; + } + await Promise.allSettled(writes); + } + } + + private _trackMetadataWrite(session: URI, operation: () => Promise<void>): Promise<void> { const tracked = operation().finally(() => this._metadataWrites.delete(tracked)); - this._metadataWrites.add(tracked); + this._metadataWrites.set(tracked, session.toString()); return tracked; } @@ -196,11 +218,11 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ } persistMetadata(session: URI, resource: URI, values: Readonly<Record<string, string>>): Promise<void> { - return this._trackMetadataWrite(() => this._persistMetadata(session, resource, values)); + return this._trackMetadataWrite(session, () => this._persistMetadata(session, resource, values)); } persistDefaultChatTitleSnapshot(session: URI, chat: URI, title: string): Promise<void> { - return this._trackMetadataWrite(() => this._enqueue(session, async () => { + return this._trackMetadataWrite(session, () => this._enqueue(session, async () => { while (true) { const current = await this.readNormalizedChat(session, chat); if (!current.normalized) { @@ -580,7 +602,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ }); return next; }; - return this._trackMetadataWrite(async () => { + return this._trackMetadataWrite(session, async () => { const values = { [CHAT_PROVIDER_DATA_METADATA_KEY]: providerData ?? '', ...(origin !== undefined ? { [CHAT_ORIGIN_METADATA_KEY]: JSON.stringify(origin) } : {}), @@ -622,7 +644,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ }); return next; }; - return this._trackMetadataWrite(async () => { + return this._trackMetadataWrite(session, async () => { const values = { [CHAT_WORKING_DIRECTORIES_METADATA_KEY]: JSON.stringify(workingDirectories) }; if (this._metadataMigration && !Object.keys(await this._metadataMigration(session, chat, values)).length) { return; @@ -636,7 +658,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ } setArchived(session: URI, chat: URI, archived: boolean): Promise<void> { - return this._trackMetadataWrite(() => this._setArchived(session, chat, archived)); + return this._trackMetadataWrite(session, () => this._setArchived(session, chat, archived)); } private async _setArchived(session: URI, chat: URI, archived: boolean): Promise<void> { @@ -657,7 +679,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ } setRead(session: URI, chat: URI, isRead: boolean): Promise<void> { - return this._trackMetadataWrite(() => this._setRead(session, chat, isRead)); + return this._trackMetadataWrite(session, () => this._setRead(session, chat, isRead)); } private async _setRead(session: URI, chat: URI, isRead: boolean): Promise<void> { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 75fb2bdeb01819..cf55cbd8ef008f 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -7037,6 +7037,7 @@ export class AgentService extends Disposable implements IAgentService { } private async _whenSessionDataIdle(session: URI): Promise<void> { + await this._peerChatStore.whenSessionIdle(session); const ref = this._sessionDataService.openDatabase(session); try { await ref.object.whenIdle(); diff --git a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts index 74adfb7d8879cb..f9e00ee2f22754 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts @@ -5,6 +5,7 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; +import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; @@ -95,6 +96,54 @@ suite('AgentHostPeerChatStore', () => { return new AgentHostPeerChatStore(orchestrator, createSessionDataService(database), logService, createLiveChatOwnership(() => undefined)); } + test('session idle waits for metadata migration without waiting for another session', async () => { + const localGate = new DeferredPromise<void>(); + const otherGate = new DeferredPromise<void>(); + const otherSession = URI.parse('agenthost:other-peer-store'); + const store = createStore(new TestSessionDatabase()); + store.setMetadataMigration(async (owner, _resource, values) => { + await (owner.toString() === session.toString() ? localGate.p : otherGate.p); + return values; + }); + const localWrite = store.persistMetadata(session, first, { [SESSION_CUSTOM_TITLE_KEY]: 'Local title' }); + const otherWrite = store.persistMetadata(otherSession, otherSession, { [SESSION_CUSTOM_TITLE_KEY]: 'Other title' }); + let sessionIdle = false; + let allIdle = false; + const sessionDrain = store.whenSessionIdle(session).then(() => { sessionIdle = true; }); + const allDrain = store.whenIdle().then(() => { allIdle = true; }); + try { + await timeout(0); + assert.deepStrictEqual({ sessionIdle, allIdle }, { sessionIdle: false, allIdle: false }); + localGate.complete(); + await localWrite; + await timeout(0); + assert.deepStrictEqual({ sessionIdle, allIdle }, { sessionIdle: true, allIdle: false }); + } finally { + localGate.complete(); + otherGate.complete(); + await Promise.all([localWrite, otherWrite, sessionDrain, allDrain]); + } + assert.strictEqual(allIdle, true); + }); + + test('session idle settles a rejected write without hiding the error from its caller', async () => { + const gate = new DeferredPromise<void>(); + const store = createStore(new TestSessionDatabase()); + store.setMetadataMigration(async () => { + await gate.p; + throw new Error('metadata migration failed'); + }); + const write = assert.rejects(store.persistMetadata(session, first, { [SESSION_CUSTOM_TITLE_KEY]: 'New title' }), /metadata migration failed/); + const allDrain = assert.rejects(store.whenIdle(), /metadata migration failed/); + let sessionIdle = false; + const sessionDrain = store.whenSessionIdle(session).then(() => { sessionIdle = true; }); + await timeout(0); + assert.strictEqual(sessionIdle, false); + gate.complete(); + await Promise.all([write, allDrain, sessionDrain]); + assert.strictEqual(sessionIdle, true); + }); + test('normalized metadata batches title and source without touching legacy backing', async () => { const defaultChat = buildDefaultChatUri(session); await orchestrator.registerChatCatalogV2(session.toString(), { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 3c0886cb2f6088..364e66ab029052 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -29196,6 +29196,61 @@ suite('AgentService (node dispatcher)', () => { })); } + for (const rename of ['local command', 'client action'] as const) { + test(`idle release preserves a peer rename from a ${rename} whose normalized write is pending`, async () => { + const writeStarted = new DeferredPromise<void>(); + const finishWrite = new DeferredPromise<void>(); + let delayTitleWrite = false; + class GatedTitleDatabase extends AgentHostDatabase { + override async updateChatV2Metadata(...args: Parameters<AgentHostDatabase['updateChatV2Metadata']>) { + if (delayTitleWrite && args[2].metadata?.summary === 'Rehydrated Peer') { + delayTitleWrite = false; + writeStarted.complete(); + await finishWrite.p; + } + return super.updateChatV2Metadata(...args); + } + } + class MultiChatAgent extends MockAgent { + override async createChat(): Promise<IAgentCreateChatResult> { + return { providerData: 'peer-backing' }; + } + } + const database = disposables.add(new GatedTitleDatabase(':memory:')); + const residency = createResidencyTestService(0, 30_000, true, new MultiChatAgent('copilot'), createSessionDataService(), database); + const svc = residency.service; + const session = await createUsedSession(svc, residency.agent); + svc.addSubscriber(session, 'rename-client'); + const peer = URI.parse(buildChatUri(session, 'renamed-peer')); + await svc.createChat(session, peer, { title: 'Persisted Peer' }); + delayTitleWrite = true; + svc.dispatchAction(peer.toString(), rename === 'local command' ? { + type: ActionType.ChatTurnStarted, + turnId: 'local-rename', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: '/rename Rehydrated Peer', origin: { kind: MessageKind.User } }, + } : { type: ActionType.SessionTitleChanged, title: 'Rehydrated Peer' }, 'rename-client', 1); + try { + await writeStarted.p; + svc.unsubscribe(session, 'rename-client'); + await timeout(0); + assert.deepStrictEqual({ + resident: !!getStateManager(svc).getSessionState(session.toString()), + released: residency.agent.releaseSessionCalls.length, + }, { resident: true, released: 0 }); + } finally { + finishWrite.complete(); + await svc.whenCatalogReconciliationIdle(); + } + await waitForResidency(() => !getStateManager(svc).getSessionState(session.toString()), 'session was not released after persisting its rename'); + await svc.restoreSession(session); + assert.deepStrictEqual({ + title: getStateManager(svc).getSessionState(session.toString())?.chats.find(chat => chat.resource === peer.toString())?.title, + persistedTitle: (await getTestChatPersistenceService(svc).readNormalizedChat(session, peer)).chat?.metadata?.summary, + }, { title: 'Rehydrated Peer', persistedTitle: 'Rehydrated Peer' }); + }); + } + test('deleting an evicted session disposes every persisted peer chat', async () => { class MultiChatAgent extends MockAgent { override async createChat(_session: URI, _chat: URI): Promise<void> { } @@ -29380,6 +29435,7 @@ suite('AgentService (node dispatcher)', () => { getStateManager(localService).dispatchServerAction(defaultChat, { type: ActionType.ChatTurnComplete, turnId: 'initial-turn', duration: 1000 }); getStateManager(localService).addChat(sessionResource.toString(), peerChat.toString(), creationTimeChatStorageResource(sessionResource.toString(), peerChat.toString()), {}); localService.addSubscriber(sessionResource, 'client-1'); + const evicted = Event.toPromise(Event.filter(getStateManager(localService).onDidRemoveSession, session => session === sessionResource.toString()), disposables); localService.unsubscribe(sessionResource, 'client-1'); await whenIdleStarted.p; @@ -29395,7 +29451,7 @@ suite('AgentService (node dispatcher)', () => { { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }, 'client-1', 2, ); - await new Promise(resolve => setTimeout(resolve, 30_000)); + await evicted; assert.strictEqual(getStateManager(localService).getSessionState(sessionResource.toString()), undefined); }); @@ -29669,8 +29725,9 @@ suite('AgentService (node dispatcher)', () => { getStateManager(service).addChat(session.toString(), chat.toString(), creationTimeChatStorageResource(session.toString(), chat.toString()), {}); service.addSubscriber(session, 'client-1'); + const evicted = Event.toPromise(Event.filter(getStateManager(service).onDidRemoveSession, removed => removed === session.toString()), disposables); service.unsubscribe(session, 'client-1'); - await new Promise(resolve => setTimeout(resolve, 30_000)); + await evicted; assert.deepStrictEqual({ chatReleases, From d69e571ea350ab4fcde5b097a1fbb68e34a20929 Mon Sep 17 00:00:00 2001 From: Don Jayamanne <don.jayamanne@outlook.com> Date: Wed, 14 Oct 2026 04:47:51 +1100 Subject: [PATCH 09/15] agentHost: align fork titles and errors with protocol review Use the existing fork capability and optional title field. Report initial-message failures in the target turn and persist rejected turns across reload. Remove special rejection draft recovery while keeping unknown-outcome inspection and no-resend behavior. Replicates microsoft/agent-host-protocol#526 review changes for #341230. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/AGENTS.md | 15 +- .../platform/agentHost/common/agentService.ts | 2 - .../platform/agentHost/common/state/AGENTS.md | 16 +- .../state/protocol/channels-chat/commands.ts | 27 +-- .../state/protocol/channels-root/state.ts | 20 --- .../common/state/protocol/common/errors.ts | 41 +---- .../platform/agentHost/node/agentService.ts | 44 +++-- .../persistedFailedTurnsContribution.ts | 2 +- .../agentHost/node/claude/claudeAgent.ts | 2 +- .../agentHost/node/codex/codexAgent.ts | 2 +- .../agentHost/node/copilot/copilotAgent.ts | 2 +- .../agentHost/node/protocolServerHandler.ts | 1 - .../agentHostProtocolClient.test.ts | 9 +- .../test/node/agentHostE2EServerLease.test.ts | 1 - .../agentHost/test/node/agentService.test.ts | 162 ++++++++++++++---- .../agentHost/test/node/claudeAgent.test.ts | 2 +- .../test/node/codex/codexCreateChat.test.ts | 2 +- .../agentHost/test/node/copilotAgent.test.ts | 6 +- .../agentHostConformance.integrationTest.ts | 1 - .../e2e/harness/agentHostE2ETestHarness.ts | 5 - .../claudeAgentHostE2E.integrationTest.ts | 1 - .../e2e/providers/codexTestConfiguration.ts | 1 - .../e2e/providers/copilotTestConfiguration.ts | 1 - .../test/node/e2e/suites/multiChatSuite.ts | 12 +- .../platform/agentHost/test/node/mockAgent.ts | 4 +- .../test/node/protocolServerHandler.test.ts | 6 +- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 2 +- .../agentHost/browser/agentHostForkActions.ts | 7 +- .../browser/baseAgentHostSessionsProvider.ts | 12 +- .../test/browser/agentHostForkActions.test.ts | 36 ++-- .../localAgentHostSessionsProvider.test.ts | 28 ++- .../remoteAgentHostSessionsProvider.test.ts | 10 +- .../browser/actions/chatAccessibilityHelp.ts | 2 +- .../chat/browser/actions/chatForkActions.ts | 64 +------ .../agentHost/agentHostSessionHandler.ts | 50 ++---- .../input/editor/chatInputCompletions.ts | 13 +- .../chat/common/chatSessionsService.ts | 15 +- .../chatAccessibilityHelp.test.ts | 23 +-- .../browser/actions/chatForkActions.test.ts | 65 +++++-- .../agentHostChatContribution.test.ts | 155 ++++++++++++++--- .../input/editor/chatInputCompletions.test.ts | 29 ++-- 41 files changed, 482 insertions(+), 416 deletions(-) diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index dca2daa03bd115..f655a3faa5ef81 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -14,7 +14,7 @@ > **Status: COMPLETE** (2026-07-01) All waves A–D and gates G-B1, G-C1, G-C2, G-D1 are done. Codex, Claude, and Copilot all use the unified orchestrator path. > -> Codex advertises `multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true }`. Model-backed peer, fork, and side-chat parity scenarios run in deterministic replay. +> Codex advertises `multipleChats: { fork: true, sideChat: true }`. Model-backed peer, fork, and side-chat parity scenarios run in deterministic replay. > > The *operational* chat surface (send/abort/model/agent/history) is fully chat-addressed and uniform across harnesses. Session ownership lives in the orchestrator: it drives every harness through the chat-surface seam — see [§7 Session Ownership (T2/T4)](#7-session-ownership-t2t4--the-orchestrator-owns-the-session). @@ -152,7 +152,7 @@ Artifact tools read and mutate only the invoking chat's artifacts. The session d ### UI/provider layer (`sessions/services/sessions/common/session.ts:ISessionCapabilities`) -- Protocol `AgentCapabilities` (`multipleChats?: { fork?: boolean; initialTitle?: boolean; forkWithInitialMessage?: boolean }`) flows from `AgentInfo.capabilities` (protocol) through the provider adapter. `ISession.capabilities` (`ISessionCapabilities`) exposes `supportsMultipleChats` and `supportsFork`, derived from `multipleChats` and `multipleChats.fork`; both flow into VS Code context keys (`sessionContextKeys.ts:SessionSupportsMultipleChatsContext`, `SessionSupportsForkContext`). The Agent Host provider handles `multipleChats.initialTitle` and the composed `multipleChats.forkWithInitialMessage` operation privately when forwarding fork options. The latter attests that the host admits the first user message with the fork; it does not gate the pre-existing `createChat.initialMessage` field. +- Protocol `AgentCapabilities` (`multipleChats?: { fork?: boolean; sideChat?: boolean }`) flows from `AgentInfo.capabilities` (protocol) through the provider adapter. `ISession.capabilities` (`ISessionCapabilities`) exposes `supportsMultipleChats` and `supportsFork`, derived from `multipleChats` and `multipleChats.fork`; both flow into VS Code context keys (`sessionContextKeys.ts:SessionSupportsMultipleChatsContext`, `SessionSupportsForkContext`). Forks can carry the optional `createChat.title` and existing `createChat.initialMessage` without additional capabilities. VS Code preserves a supplied title on first publication and reports initial-message errors in the new chat. - UI actions read context keys — no provider-id switches. --- @@ -267,7 +267,6 @@ interface AgentCapabilities { multipleChats?: { fork?: boolean; // can fork a chat from a turn sideChat?: boolean; // can branch hidden context without copied visible history - initialTitle?: boolean; // accepts a client-selected title when creating a chat }; multipleWorkingDirectories?: { immutablePrimary?: boolean; // index 0 remains the fixed process root @@ -275,11 +274,11 @@ interface AgentCapabilities { } ``` -The agent declares these in `getDescriptor().capabilities` (`common/agent.ts:IAgentDescriptor`). `multipleChats` and `multipleChats.fork` flow to the UI as `ISessionCapabilities.supportsMultipleChats` and `supportsFork` (`sessions/services/sessions/common/session.ts`) and are bound to context keys (`sessions/services/sessions/common/sessionContextKeys.ts:SessionSupportsMultipleChatsContext`, `SessionSupportsForkContext`). The Agent Host provider consumes `multipleChats.initialTitle` at its private AHP boundary instead of projecting a generic session capability. +The agent declares these in `getDescriptor().capabilities` (`common/agent.ts:IAgentDescriptor`). `multipleChats` and `multipleChats.fork` flow to the UI as `ISessionCapabilities.supportsMultipleChats` and `supportsFork` (`sessions/services/sessions/common/session.ts`) and are bound to context keys (`sessions/services/sessions/common/sessionContextKeys.ts:SessionSupportsMultipleChatsContext`, `SessionSupportsForkContext`). Optional initial titles require no separate capability. UI code gates "Add Chat" and "Fork" actions on those context keys. No code inside `AgentService` or `AgentHostStateManager` switches on provider id to gate features. `AgentService.createChat` throws synchronously when `!provider.chats` (the structural guard that replaces a capability check in the orchestrator). -Claude, Copilot, and Codex advertise `multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true }`. Codex side-chat context and restore run in deterministic replay. Subagent E2E and native streaming file-creation coverage remain independently disabled and must not be inferred from its peer-chat/fork/side-chat support. +Claude, Copilot, and Codex advertise `multipleChats: { fork: true, sideChat: true }`. Codex side-chat context and restore run in deterministic replay. Subagent E2E and native streaming file-creation coverage remain independently disabled and must not be inferred from its peer-chat/fork/side-chat support. --- @@ -457,7 +456,7 @@ Claude deliberately has no AH-session container and no membership/role concept o - `_chatBackings: Map<string, IClaudeChatBacking>` maps each exact host-supplied chat URI to only its provider-owned `{ sdkSessionId, model?, sideChat? }` backing data. It deliberately does **not** retain the owning AH session or a storage URI: AH supplies the owning session and persistence/config resource transiently on every operation (`IAgentChatContext`). - `IClaudeChatBacking` is the source of truth for both live and released chats: releasing a chat drops its `_chatEntriesBySdkId` leaf but keeps the backing data so a later send can cold-resume the corresponding `ClaudeAgentSession`. -Every chat operation resolves exactly one backing and routes to exactly one live leaf; there is no default-vs-additional branch and no cascade between chats of the same session. An additional chat's send after restart resumes only that chat's `ClaudeAgentSession`. Capabilities remain `multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true }`. +Every chat operation resolves exactly one backing and routes to exactly one live leaf; there is no default-vs-additional branch and no cascade between chats of the same session. An additional chat's send after restart resumes only that chat's `ClaudeAgentSession`. Capabilities remain `multipleChats: { fork: true, sideChat: true }`. Each additional chat is backed by a fresh top-level SDK session (`sdkSessionId = generateUuid()`) minted in the same global Claude project store that `listSessions` enumerates. `_createChat` therefore returns `backingSession: AgentSession.uri(this.id, sdkSessionId)` so the orchestrator can suppress that backing from the top-level session list (invariant I7); without it the additional chat would leak as a phantom session. The SDK exposes no delete-chat RPC, so `disposeChat` leaves the backing transcript on disk — the orchestrator-owned catalog simply drops the entry so it is never resumed again. (Claude writes no legacy `claude.chats` blob and has no legacy migration: Claude multi-chat shipped only with the orchestrator-owned catalog, so there is nothing to drain. Copilot keeps its own `copilot.chats` migration because `copilot.chats` predates the catalog.) @@ -470,7 +469,7 @@ Copilot also has no AH-session container: - Fork/import provisioning binds the exact target chat inside `chats.createChat`, so a create result is never left waiting for a follow-up bind call. - The backing records preserve the existing `providerData` codec and one-time `copilot.chats` migration. -No `CopilotSessionEntry`, `AgentSessionEntry`, default-chat URI helper, or sibling cascade remains. Send/history/model/agent/abort/tool/config/dispose/release operations resolve one leaf. Active-client state remains keyed by the owning SDK session where it is genuinely shared, while each live leaf owns its own SDK and MCP lifecycle. Capabilities remain `multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true }`. +No `CopilotSessionEntry`, `AgentSessionEntry`, default-chat URI helper, or sibling cascade remains. Send/history/model/agent/abort/tool/config/dispose/release operations resolve one leaf. Active-client state remains keyed by the owning SDK session where it is genuinely shared, while each live leaf owns its own SDK and MCP lifecycle. Capabilities remain `multipleChats: { fork: true, sideChat: true }`. Steering received during turn preparation or configuration refresh is buffered for the addressed chat. A dispatched turn confirmed running by root SDK events accepts and flushes steering immediately, even if its admission RPC has not returned; successful admission also flushes it. Preparation or send failure retains buffered steering for a successful retry, rather than dispatching against an unready runtime. Pending updates replace or remove buffered steering; abort clears it and emits `steering_consumed` to clear the pending UI. The live SDK-session wrapper takes ownership synchronously before MCP reconciliation, so abort and disposal can clear steering throughout delivery. Chat disposal and host shutdown discard buffered delivery, and pending-message updates are ignored once host shutdown starts. @@ -523,7 +522,7 @@ When a restored Agent Host conversation's intended model is missing from the pic Metadata and history reads use `thread/read` and `thread/turns/list` without resuming the thread or claiming its writer lock, so a thread still open in another Codex client remains readable. Subscribing to an interactive chat invokes the optional provider-internal `IAgentChats.prepareChat` seam before returning the snapshot. Codex resumes an existing backing to acquire its writer lock without sending a turn; fresh chats remain lazy. The host publishes temporary input restrictions through existing read-only chat interactivity and validated `vscode.chatInputState` session metadata. These restrictions are not persisted as permanent read-only state. Clients retain history and the editable draft; Retry refreshes the existing subscription without sending a turn or discarding pending local actions. No additional AHP method or capability is required. Sends still apply pending launch configuration and replace a never-persisted backing only when the SDK reports that its rollout is missing. Reads still retry when their app-server connection is replaced. -An additional chat is backed by a **fresh top-level thread minted eagerly** in `chats.createChat` (via `thread/start` or `thread/fork` at the requested turn, reusing `_forkSession`). For these internal peer backings only, the backing entry and URI are keyed by the app-server-assigned thread id. This does not couple the parent AH session id to its default thread id; it gives the peer-chat-backing marker a stable `codex:/<threadId>` database across restart. `_createChat`/`fork` therefore return `backingSession: AgentSession.uri(this.id, threadId)` so the orchestrator suppresses that backing from the top-level session list (invariant I7), plus an opaque `providerData` blob (the backing thread id + model) that `materializeChat` decodes on restore. The additional chat inherits the parent session's working directory, model, and permissions. Exact disposal/release affects only the addressed chat's own thread — there is no cascade between chats of the same session. The persisted `codex.threadId`, `codex.cwd`, and `codex.model` keys and app-server protocol are unchanged, and Codex still never recognizes or derives a default-chat URI. The orchestrator registry contains the parent AH session, not these chat backing URIs; provider-owned discovery pushes external chats through `onDidDiscoverChats`. Capabilities are `multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true }`. +An additional chat is backed by a **fresh top-level thread minted eagerly** in `chats.createChat` (via `thread/start` or `thread/fork` at the requested turn, reusing `_forkSession`). For these internal peer backings only, the backing entry and URI are keyed by the app-server-assigned thread id. This does not couple the parent AH session id to its default thread id; it gives the peer-chat-backing marker a stable `codex:/<threadId>` database across restart. `_createChat`/`fork` therefore return `backingSession: AgentSession.uri(this.id, threadId)` so the orchestrator suppresses that backing from the top-level session list (invariant I7), plus an opaque `providerData` blob (the backing thread id + model) that `materializeChat` decodes on restore. The additional chat inherits the parent session's working directory, model, and permissions. Exact disposal/release affects only the addressed chat's own thread — there is no cascade between chats of the same session. The persisted `codex.threadId`, `codex.cwd`, and `codex.model` keys and app-server protocol are unchanged, and Codex still never recognizes or derives a default-chat URI. The orchestrator registry contains the parent AH session, not these chat backing URIs; provider-owned discovery pushes external chats through `onDidDiscoverChats`. Capabilities are `multipleChats: { fork: true, sideChat: true }`. --- diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 42120821e52bb9..608670d3fe88a4 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -95,8 +95,6 @@ export const AgentHostAhpJsonlLoggingSettingId = 'chat.agentHost.ahpJsonlLogging export interface IAgentCreateChatInitialMessageContext { readonly clientId: string; readonly telemetryContext: IAgentHostClientTelemetryContext; - /** The caller requested the capability-backed fork-and-message operation. */ - readonly capabilityBackedFork: boolean; } export type AgentHostDebugLogsArtifactKind = 'archive' | 'directory'; diff --git a/src/vs/platform/agentHost/common/state/AGENTS.md b/src/vs/platform/agentHost/common/state/AGENTS.md index 510b2a121d1d00..f391f465abc2cb 100644 --- a/src/vs/platform/agentHost/common/state/AGENTS.md +++ b/src/vs/platform/agentHost/common/state/AGENTS.md @@ -27,12 +27,7 @@ The generated protocol includes the Changesets model: ### Current targeted backport -The generated copy intentionally carries `CreateChatParams.title` and -`MultipleChatsCapability.initialTitle` on top of the `.ahp-version` source -commit `6eec1b91`. Syncing that commit without the canonical fields removes -them and breaks current consumers. Until the recorded pin advances to an AHP -commit containing these fields, preserve or reapply this generated delta when -syncing. +The generated copy intentionally carries the optional `CreateChatParams.title` on top of the `.ahp-version` source commit `6eec1b91`. Until the recorded pin advances to an AHP commit containing this field, preserve or reapply this generated delta when syncing. Neither titles nor initial messages require an additional capability; source-based creation still uses the existing `fork` and `sideChat` capabilities. A reproducible sync from `6eec1b91` must also retain the existing VS Code-only compatibility deltas: deprecated `SessionChatSummary.archived` and @@ -40,14 +35,7 @@ compatibility deltas: deprecated `SessionChatSummary.archived` and ### Initial-message transport delta -The pinned generated protocol includes `CreateChatParams.initialMessage` and -the associated `InitialMessageRejected` typed error data. Host-facing -`IAgentCreateChatRequestOptions.initialMessage` is deliberately not part of -provider creation options: the host creates the provider chat first, then -processes the message through its normal client-aware admission path. Protocol -clients serialize a supplied initial message unchanged except that a missing -message `model` or `agent` inherits the corresponding request option. Do not -add top-level `model` or `agent` fields to `CreateChatParams`. +Host-facing `IAgentCreateChatRequestOptions.initialMessage` uses the existing `CreateChatParams.initialMessage` field and is deliberately not part of provider creation options: the host creates the provider chat first, then processes the message through its normal client-aware admission path. Message failures appear in the target chat's turn state rather than a special creation RPC error. Protocol clients serialize a supplied initial message unchanged except that a missing message `model` or `agent` inherits the corresponding request option. Do not add top-level `model` or `agent` fields to `CreateChatParams`. ## Adding optional fields to existing types 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 f17922c34defa8..b95911db178628 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 @@ -90,19 +90,7 @@ export interface CreateChatParams extends BaseParams { channel: URI; /** Chat URI (client-chosen, e.g. `ahp-chat:/<uuid>`). */ chat: URI; - /** - * Optional initial message for the new chat. - * - * This existing field remains ungated, including for legacy fork requests. - * A client that needs the stronger composed fork-and-message guarantees MUST - * require both {@link MultipleChatsCapability.fork} and - * {@link MultipleChatsCapability.forkWithInitialMessage}. For that - * capability-backed operation, a host that has created the fork but can - * definitely not accept this message MUST return `InitialMessageRejected` - * (-32012) with the created chat URI and underlying rejection cause. A - * successful `null` result confirms message acceptance, not completion of - * the message's model turn. - */ + /** Optional initial message for the new chat. */ initialMessage?: Message; /** * Optional source chat and source turn. @@ -132,18 +120,7 @@ export interface CreateChatParams extends BaseParams { * {@link AgentCapabilities.multipleWorkingDirectories}. */ workingDirectories?: URI[]; - /** - * Client-selected initial display title for the new chat. - * - * Clients MUST NOT supply this field unless the selected agent advertises - * {@link MultipleChatsCapability.initialTitle}. - * - * A server honoring this field MUST trim leading and trailing whitespace. - * If the result is empty, it MUST treat the field as absent and choose its - * normal initial title. Otherwise, it MUST preserve the remaining text - * exactly: it MUST NOT collapse internal whitespace, strip quotes or - * punctuation, or truncate it. The protocol defines no maximum title length. - */ + /** Client-selected initial display title for the new chat. */ title?: string; } diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index 63639c204d5efb..219d0cd14b4e05 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -150,26 +150,6 @@ export interface MultipleChatsCapability { * time. Side-chat support always implies multi-chat support. */ sideChat?: boolean; - /** - * The agent honors {@link CreateChatParams.title} as the chat's first - * published title and does not replace it through automatic title generation. - * A later explicit rename is still allowed. When absent or `false`, clients - * MUST omit `title` when calling `createChat`. - */ - initialTitle?: boolean; - /** - * The agent supports the stronger composed fork-and-initial-message - * operation. Clients relying on its preserved client context, definite - * acceptance or rejection result, and prompt-aware naming context MUST - * require this and {@link MultipleChatsCapability.fork} to be `true`. - * The naming context is available to the host's normal strategy at its - * normal time; it does not guarantee title generation. - * - * This attestation applies only to the stronger combined operation. It does - * not reject or otherwise change existing ungated `initialMessage` requests, - * including legacy fork requests on third-party hosts. - */ - forkWithInitialMessage?: boolean; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/common/errors.ts b/src/vs/platform/agentHost/common/state/protocol/common/errors.ts index 9f52951a60b1e7..0fdab1d1f5e9cb 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/errors.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/errors.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import type { ProtectedResourceMetadata, URI } from './state.js'; +import type { ProtectedResourceMetadata } from './state.js'; import type { ResourceRequestParams } from './commands.js'; // ─── Standard JSON-RPC Codes ───────────────────────────────────────────────── @@ -92,11 +92,6 @@ export const AhpErrorCodes = { * fresh token or surface the conflict to the user. */ Conflict: -32011, - /** - * A fork chat was created, but its requested initial message was definitely - * rejected before it could be accepted for processing. - */ - InitialMessageRejected: -32012, } as const; /** Union type of all AHP application error codes. */ @@ -161,39 +156,6 @@ export interface UnsupportedProtocolVersionErrorData { supportedVersions: string[]; } -/** - * Details carried in the `data` field of an `InitialMessageRejected` (-32012) - * error. - * - * The fork chat identified by `chat` was created successfully. `cause` - * preserves the underlying rejection, including structured details such as an - * authentication challenge, so clients can inspect or surface it. - * - * @category Error Details - * @version 1 - */ -export interface InitialMessageRejectedErrorData { - /** URI of the successfully created fork chat. */ - chat: URI; - /** Underlying reason the initial message could not be accepted. */ - cause: InitialMessageRejectedCause; -} - -/** - * The preserved underlying error for an `InitialMessageRejected` error. - * - * @category Error Details - * @version 1 - */ -export interface InitialMessageRejectedCause { - /** Underlying JSON-RPC or application error code. */ - code: number; - /** Underlying human-readable error message. */ - message: string; - /** Underlying structured error detail, when supplied. */ - data?: unknown; -} - /** * Maps each AHP error code that carries structured `data` to the type of * that data. @@ -208,7 +170,6 @@ export interface AhpErrorDetailsMap { [AhpErrorCodes.AuthRequired]: AuthRequiredErrorData; [AhpErrorCodes.PermissionDenied]: PermissionDeniedErrorData; [AhpErrorCodes.UnsupportedProtocolVersion]: UnsupportedProtocolVersionErrorData; - [AhpErrorCodes.InitialMessageRejected]: InitialMessageRejectedErrorData; } /** AHP error codes that carry a structured `data` payload. */ diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index cf55cbd8ef008f..f256112bbba54b 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -134,7 +134,7 @@ import { AgentHostCatalogSourceResolver, CHAT_BACKING_METADATA_KEY, chatCatalogV import { AgentHostPeerChatStore, CHAT_PROVIDER_DATA_METADATA_KEY, CHAT_WORKING_DIRECTORIES_METADATA_KEY, IAgentHostPeerChatPersistenceService, IPersistedPeerChat } from './agentHostPeerChatStore.js'; import { migrateChatCatalogV2 } from './agentHostChatCatalogMigration.js'; import { IAgentHostChatContributions } from '../common/agentHostChatContributionsService.js'; -import { IAgentHostTurnService } from './agentHostTurnService.js'; +import { IAgentHostTurnService, type IDeferredAgentHostTurn } from './agentHostTurnService.js'; /** * Grace period before an empty, unsubscribed session is garbage-collected @@ -5484,20 +5484,14 @@ export class AgentService extends Disposable implements IAgentService { if (initialMessage && initialMessage.origin?.kind !== MessageKind.User) { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'An initial chat message must have user origin.'); } - // `initialMessage` remains a legacy, ungated protocol field. The - // capability only attests the stronger composed-operation guarantees, so - // derive those guarantees from the provider that actually owns this chat. - const capabilityBackedInitialFork = initialMessageContext?.capabilityBackedFork === true - && initialMessage !== undefined - && providerOptions.fork !== undefined - && provider.getDescriptor().capabilities?.multipleChats?.forkWithInitialMessage === true; + const initialFork = initialMessage !== undefined && providerOptions.fork !== undefined; if (chat.toString() === this._defaultChatUri(session) && initialMessage && initialMessageContext) { - if (capabilityBackedInitialFork) { + if (initialFork) { await this._chatCatalogMutationSequencer.queue(sessionKey, async () => { this._throwIfInitialForkTargetExists(sessionKey, chat); }); } - await this._acceptInitialMessage(sessionKey, chat, initialMessage, { ...initialMessageContext, capabilityBackedFork: capabilityBackedInitialFork }); + await this._acceptInitialMessage(sessionKey, chat, initialMessage, initialMessageContext); return; } if (!this._supportsChats(provider)) { @@ -5583,7 +5577,7 @@ export class AgentService extends Disposable implements IAgentService { // serialized catalog path, so a rejected repeat cannot rewrite an // existing target's local-turn metadata. const persistForkedLocalTurns = () => this._persistForkedLocalTurns(sessionKey, sourceChatKey, chat.toString(), slice, forkedTurns!, turnIdMapping); - if (capabilityBackedInitialFork) { + if (initialFork) { persistForkedLocalTurnsAfterDuplicateCheck = persistForkedLocalTurns; } else { persistForkedLocalTurns(); @@ -5612,7 +5606,7 @@ export class AgentService extends Disposable implements IAgentService { } const createResult = await this._chatCatalogMutationSequencer.queue(sessionKey, async () => { - if (capabilityBackedInitialFork) { + if (initialFork) { this._throwIfInitialForkTargetExists(sessionKey, chat); } if (this._workspaceConversionService.isPending(this._defaultChatUri(session), true) @@ -5733,7 +5727,7 @@ export class AgentService extends Disposable implements IAgentService { ); } if (initialMessage && initialMessageContext) { - await this._acceptInitialMessage(sessionKey, chat, initialMessage, { ...initialMessageContext, capabilityBackedFork: capabilityBackedInitialFork }); + await this._acceptInitialMessage(sessionKey, chat, initialMessage, initialMessageContext); } } @@ -5753,21 +5747,25 @@ export class AgentService extends Disposable implements IAgentService { startedAt: new Date().toISOString(), message, }; + let rewritten: ChatTurnStartedAction; try { - const rewritten = await this._rewriteUserMessageAttachments(session, action, context.clientId, true); - const rejection = this._turnService.startClientTurn(chat, rewritten, context.clientId, context.telemetryContext); - if (rejection) { - throw new ProtocolError(JsonRpcErrorCodes.InvalidRequest, rejection.message, rejection); - } + rewritten = await this._rewriteUserMessageAttachments(session, action, context.clientId, true); } catch (error) { - if (!context.capabilityBackedFork) { + let turn: IDeferredAgentHostTurn; + try { + turn = this._turnService.beginDeferredTurnMessage(chat, message); + } catch { throw error; } - const cause = error instanceof ProtocolError - ? { code: error.code, message: error.message, ...(error.data === undefined ? {} : { data: error.data }) } - : { code: JSON_RPC_INTERNAL_ERROR, message: toErrorMessage(error) }; - throw new ProtocolError(AhpErrorCodes.InitialMessageRejected, cause.message, { chat: chat.toString(), cause }); + if (!this._turnService.failDeferredTurnMessage(chat, turn, { + errorType: 'attachmentPreparationFailed', + message: toErrorMessage(error), + })) { + this._logService.info(`[AgentService] The initial turn for ${chat.toString()} ended before its attachment failure could be reported.`); + } + return; } + this._turnService.startClientTurn(chat, rewritten, context.clientId, context.telemetryContext); } /** diff --git a/src/vs/platform/agentHost/node/chatContributions/persistedFailedTurns/persistedFailedTurnsContribution.ts b/src/vs/platform/agentHost/node/chatContributions/persistedFailedTurns/persistedFailedTurnsContribution.ts index 6849aca6e567d4..aa3533c44bd237 100644 --- a/src/vs/platform/agentHost/node/chatContributions/persistedFailedTurns/persistedFailedTurnsContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/persistedFailedTurns/persistedFailedTurnsContribution.ts @@ -48,7 +48,7 @@ export class PersistedFailedTurnsContribution extends Disposable implements IAge } return; } - if (ended.reason.kind !== 'error' || ended.reason.resumable) { + if (ended.reason.kind !== 'rejected' && (ended.reason.kind !== 'error' || ended.reason.resumable)) { return; } diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index 0a2f63d5413908..ddb0c30f8eb500 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -718,7 +718,7 @@ export class ClaudeAgent extends Disposable implements IAgent { displayName: localize('claudeAgent.displayName', "Claude"), description: localize('claudeAgent.description', "Claude agent backed by the Anthropic Claude Agent SDK"), capabilities: { - multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true }, + multipleChats: { fork: true, sideChat: true }, ...(this._isMultiRootEnabled() ? { multipleWorkingDirectories: { immutablePrimary: true } } : {}), }, }; diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 51f77526b094dc..0b05301fec925c 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -4280,7 +4280,7 @@ export class CodexAgent extends Disposable implements IAgent { displayName: localize('codexAgent.displayName', "Codex"), description: localize('codexAgent.description', "Codex agent using session-selected model providers"), capabilities: { - multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true }, + multipleChats: { fork: true, sideChat: true }, ...(this._isMultiRootEnabled() ? { multipleWorkingDirectories: { immutablePrimary: true } } : {}), }, }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index eaeb9288feb639..56a70c530bfb53 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -1592,7 +1592,7 @@ export class CopilotAgent extends Disposable implements IAgent { displayName: 'Copilot', description: localize('copilotAgent.description', "Copilot SDK agent running in the local agent host process"), capabilities: { - multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true }, + multipleChats: { fork: true, sideChat: true }, ...(this._isMultiRootEnabled() ? { multipleWorkingDirectories: { immutablePrimary: true } } : {}), }, }; diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index f2905fc899753d..dc0c201fc85b8d 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -1880,7 +1880,6 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien : { clientId: _client.clientId, telemetryContext: _client.telemetryContext, - capabilityBackedFork: source?.kind === ChatSourceKind.Fork, }, ); return null; 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 44582c3560fd57..ecd1c97b8031f2 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -1569,25 +1569,24 @@ suite('AgentHostProtocolClient', () => { await resultPromise; }); - test('preserves initial-message rejection cause data', async () => { + test('preserves chat creation failure data', async () => { const { client, transport } = createClient(); const resultPromise = client.createChat(sessionUri, chatUri, { initialMessage: { text: 'Continue', origin: { kind: MessageKind.User } }, }); const data = { chat: chatUri.toString(), - cause: { code: AhpErrorCodes.PermissionDenied, message: 'Attachment access denied', data: { uri: 'file:///workspace/private.png' } }, }; transport.fireMessage({ jsonrpc: '2.0', id: 1, - error: { code: AhpErrorCodes.InitialMessageRejected, message: 'Initial message rejected', data }, + error: { code: AhpErrorCodes.AlreadyExists, message: 'Chat already exists', data }, }); await assertRemoteProtocolError(resultPromise, { - code: AhpErrorCodes.InitialMessageRejected, - message: 'Initial message rejected', + code: AhpErrorCodes.AlreadyExists, + message: 'Chat already exists', data, }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostE2EServerLease.test.ts b/src/vs/platform/agentHost/test/node/agentHostE2EServerLease.test.ts index b740d8a45933fb..9f5814dfda46d5 100644 --- a/src/vs/platform/agentHost/test/node/agentHostE2EServerLease.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostE2EServerLease.test.ts @@ -41,7 +41,6 @@ suite('Agent Host E2E server lease diagnostics', () => { supportsSubagents: false, supportsMultipleChats: false, supportsInitialTitle: false, - supportsForkWithInitialMessage: false, supportsChatFork: false, supportsChatForkE2E: false, }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 364e66ab029052..d0ea8a1391959e 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -9281,6 +9281,43 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('restores an initial message rejected by admission without resending it', async () => { + const sessionDatabase = new TestSessionDatabase(); + const sessionDataService = createSessionDataService(sessionDatabase); + const orchestratorDatabase = new TestAgentHostOrchestratorDatabase(); + const first = createCentralCatalogService(sessionDataService, orchestratorDatabase); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(first, agent); + const session = await first.createSession({ provider: 'copilot' }); + const chat = URI.parse(buildDefaultChatUri(session)); + first.addSubscriber(session, 'client'); + getStateManager(first).dispatchServerAction(session.toString(), { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + await first.createChat(session, chat, { + initialMessage: { text: 'rejected initial prompt', origin: { kind: MessageKind.User } }, + }, { + clientId: 'client', + telemetryContext: createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), + }); + await waitForCondition(async () => (await sessionDatabase.getPersistedTurns()).some(record => record.kind === 'failed'), 'rejected initial turn should be persisted'); + first.dispose(); + + const restarted = createCentralCatalogService(sessionDataService, orchestratorDatabase); + registerTestAgentProvider(restarted, agent); + await restarted.restoreSession(session); + const turns = getStateManager(restarted).getChatState(chat.toString())?.turns; + assert.deepStrictEqual({ + turns: turns?.map(turn => ({ + text: turn.message.text, + state: turn.state, + errors: turn.responseParts.filter(part => part.kind === ResponsePartKind.Error).map(part => part.error.errorType), + })), + sends: agent.sendMessageCalls.length, + }, { + turns: [{ text: 'rejected initial prompt', state: TurnState.Error, errors: ['archived'] }], + sends: 0, + }); + }); + function getStartupTestInternals(service: AgentService) { return service as unknown as { _migrateLegacyEnabledSnapshot: boolean | undefined; @@ -23911,7 +23948,7 @@ suite('AgentService (node dispatcher)', () => { override async createChat(): Promise<void> { } } const agent = disposables.add(new MultiChatAgent('copilot', { - multipleChats: { fork: true, initialTitle: true, forkWithInitialMessage: true }, + multipleChats: { fork: true }, })); registerTestAgentProvider(service, agent); const session = await service.createSession({ provider: 'copilot' }); @@ -23935,7 +23972,6 @@ suite('AgentService (node dispatcher)', () => { }, { clientId: 'initial-message-client', telemetryContext: createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), - capabilityBackedFork: true, }); await waitForCondition(() => agent.sendMessageCalls.length === 1, 'Expected exactly one initial fork message to be sent'); @@ -23950,12 +23986,12 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('legacy fork initial message remains accepted without the composed-operation capability', async () => { + test('fork initial message only requires ordinary fork support', async () => { class MultiChatAgent extends MockAgent { override async createChat(): Promise<void> { } } const agent = disposables.add(new MultiChatAgent('copilot', { - multipleChats: { fork: true, initialTitle: true }, + multipleChats: { fork: true }, })); registerTestAgentProvider(service, agent); const session = await service.createSession({ provider: 'copilot' }); @@ -23971,7 +24007,6 @@ suite('AgentService (node dispatcher)', () => { }, { clientId: 'legacy-initial-message-client', telemetryContext: createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), - capabilityBackedFork: true, }); await waitForCondition(() => agent.sendMessageCalls.length === 1, 'Expected the legacy initial fork message to be sent'); @@ -23986,48 +24021,56 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('legacy fork initial message preserves an attachment rejection without composed correlation', async () => { + test('fork initial message admission failure remains an error on the created turn', async () => { class MultiChatAgent extends MockAgent { override async createChat(): Promise<void> { } } const agent = disposables.add(new MultiChatAgent('copilot', { - multipleChats: { fork: true, initialTitle: true }, + multipleChats: { fork: true }, })); registerTestAgentProvider(service, agent); const session = await service.createSession({ provider: 'copilot' }); getStateManager(service).seedDefaultChatTurns(session.toString(), [ { id: 'source-turn', state: TurnState.Complete, message: { text: 'history', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, ]); - const cause = new ProtocolError(AhpErrorCodes.PermissionDenied, 'Legacy attachment access denied', { request: { uri: 'file:///legacy-missing.txt' } }); - const clientResourceReader = service as unknown as { - _readClientResource: AgentService['_readClientResource']; - }; - clientResourceReader._readClientResource = async () => { - throw cause; - }; + service.addSubscriber(session, 'initial-message-client'); + getStateManager(service).dispatchServerAction(session.toString(), { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + const peer = URI.parse(buildChatUri(session, 'admission-rejected-peer')); - await assert.rejects(() => service.createChat(session, URI.parse(buildChatUri(session, 'legacy-attachment-rejected-peer')), { + await service.createChat(session, peer, { fork: { source: session, turnId: 'source-turn' }, initialMessage: { text: 'continue from here', origin: { kind: MessageKind.User }, - attachments: [{ type: MessageAttachmentKind.Resource, label: 'legacy-missing.txt', uri: URI.file('/legacy-missing.txt').toString() }], }, }, { clientId: 'legacy-initial-message-client', telemetryContext: createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), - capabilityBackedFork: true, - }), error => error === cause); + }); - assert.strictEqual(agent.sendMessageCalls.length, 0); + const state = getStateManager(service).getChatState(peer.toString()); + const turn = state?.turns.at(-1); + assert.deepStrictEqual({ + messages: state?.turns.map(turn => turn.message.text), + state: turn?.state, + errors: turn?.responseParts.filter(part => part.kind === ResponsePartKind.Error).map(part => part.error.errorType), + activeTurn: state?.activeTurn, + sends: agent.sendMessageCalls.length, + }, { + messages: ['history', 'continue from here'], + state: TurnState.Error, + errors: ['archived'], + activeTurn: undefined, + sends: 0, + }); }); - test('capability-backed initial fork rejects existing default and catalog peer targets without sending', async () => { + test('initial fork rejects existing default and catalog peer targets without sending', async () => { class MultiChatAgent extends MockAgent { override async createChat(): Promise<void> { } } const agent = disposables.add(new MultiChatAgent('copilot', { - multipleChats: { fork: true, initialTitle: true, forkWithInitialMessage: true }, + multipleChats: { fork: true }, })); registerTestAgentProvider(service, agent); const session = await service.createSession({ provider: 'copilot' }); @@ -24040,7 +24083,6 @@ suite('AgentService (node dispatcher)', () => { const context = { clientId: 'initial-message-client', telemetryContext: createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), - capabilityBackedFork: true, }; const options = { fork: { source: session, turnId: 'source-turn' }, @@ -24052,7 +24094,7 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(agent.sendMessageCalls.length, 0); }); - test('concurrent capability-backed initial forks persist local turns and send only for the winner', async () => { + test('concurrent initial forks persist local turns and send only for the winner', async () => { let providerCreates = 0; class MultiChatAgent extends MockAgent { override async createChat(): Promise<void> { @@ -24060,7 +24102,7 @@ suite('AgentService (node dispatcher)', () => { } } const agent = disposables.add(new MultiChatAgent('copilot', { - multipleChats: { fork: true, initialTitle: true, forkWithInitialMessage: true }, + multipleChats: { fork: true }, })); registerTestAgentProvider(service, agent); const session = await service.createSession({ provider: 'copilot' }); @@ -24085,7 +24127,6 @@ suite('AgentService (node dispatcher)', () => { const context = { clientId: 'initial-message-client', telemetryContext: createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), - capabilityBackedFork: true, }; const outcomes = await Promise.allSettled([ @@ -24111,12 +24152,12 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('fork initial attachment preparation rejection preserves the protocol cause after chat publication', async () => { + test('fork initial attachment preparation failure creates an errored turn without rejecting chat creation', async () => { class MultiChatAgent extends MockAgent { override async createChat(): Promise<void> { } } const agent = disposables.add(new MultiChatAgent('copilot', { - multipleChats: { fork: true, initialTitle: true, forkWithInitialMessage: true }, + multipleChats: { fork: true }, })); registerTestAgentProvider(service, agent); const session = await service.createSession({ provider: 'copilot' }); @@ -24133,7 +24174,7 @@ suite('AgentService (node dispatcher)', () => { }; const peer = URI.parse(buildChatUri(session, 'attachment-rejected-peer')); - await assert.rejects(() => service.createChat(session, peer, { + await service.createChat(session, peer, { fork: { source: session, turnId: 'source-turn' }, initialMessage: { text: 'continue from here', @@ -24143,23 +24184,70 @@ suite('AgentService (node dispatcher)', () => { }, { clientId: 'initial-message-client', telemetryContext: createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), - capabilityBackedFork: true, - }), error => error instanceof ProtocolError - && error.code === AhpErrorCodes.InitialMessageRejected - && (error.data as { chat?: string; cause?: { code?: number; message?: string; data?: unknown } }).chat === peer.toString() - && (error.data as { cause?: { code?: number; message?: string; data?: unknown } }).cause?.code === AhpErrorCodes.PermissionDenied - && (error.data as { cause?: { code?: number; message?: string; data?: unknown } }).cause?.message === cause.message - && (error.data as { cause?: { code?: number; message?: string; data?: unknown } }).cause?.data === cause.data); + }); + const state = stateManager.getChatState(peer.toString()); + const turn = state?.turns.at(-1); assert.deepStrictEqual({ - chatRetained: !!stateManager.getChatState(peer.toString()), + messages: state?.turns.map(turn => turn.message.text), + state: turn?.state, + errors: turn?.responseParts.filter(part => part.kind === ResponsePartKind.Error).map(part => part.error), + activeTurn: state?.activeTurn, sends: agent.sendMessageCalls.length, }, { - chatRetained: true, + messages: ['history', 'continue from here'], + state: TurnState.Error, + errors: [{ errorType: 'attachmentPreparationFailed', message: cause.message }], + activeTurn: undefined, sends: 0, }); }); + for (const existingTurn of ['active', 'cancelled deferred'] as const) { + test(`initial attachment failure preserves its cause with an existing ${existingTurn} turn`, async () => { + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(service, agent); + const session = await service.createSession({ provider: 'copilot' }); + const chat = URI.parse(buildDefaultChatUri(session)); + const turnService = (service as unknown as { _turnService: AgentService['_turnService'] })._turnService; + let deferred: ReturnType<typeof turnService.beginDeferredTurnMessage> | undefined; + if (existingTurn === 'cancelled deferred') { + deferred = turnService.beginDeferredTurnMessage(chat, { text: 'existing request', origin: { kind: MessageKind.User } }); + getStateManager(service).dispatchServerAction(chat.toString(), { + type: ActionType.ChatTurnCancelled, turnId: deferred.turnId, duration: 0, + }); + } else { + getStateManager(service).dispatchServerAction(chat.toString(), { + type: ActionType.ChatTurnStarted, + turnId: 'already-running', + startedAt: new Date().toISOString(), + message: { text: 'existing request', origin: { kind: MessageKind.User } }, + }); + } + const cause = new Error('attachment could not be read'); + const clientResourceReader = service as unknown as { _readClientResource: AgentService['_readClientResource'] }; + clientResourceReader._readClientResource = async () => { throw cause; }; + + await assert.rejects(service.createChat(session, chat, { + initialMessage: { + text: 'new request', + origin: { kind: MessageKind.User }, + attachments: [{ type: MessageAttachmentKind.Resource, label: 'missing', uri: 'file:///missing.txt' }], + }, + }, { + clientId: 'client', + telemetryContext: createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), + }), error => error === cause); + assert.deepStrictEqual({ + active: getStateManager(service).getChatState(chat.toString())?.activeTurn?.id, + sends: agent.sendMessageCalls.length, + }, { active: existingTurn === 'active' ? 'already-running' : undefined, sends: 0 }); + if (deferred) { + turnService.failDeferredTurnMessage(chat, deferred, { errorType: 'cancelled', message: 'cancelled' }); + } + }); + } + test('fork publishes an explicit normalized title without automatic refinement', async () => { const sessionData = createPerSessionDataService(); const copilotApiService = new TestCopilotApiService(); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 954c040ea9778e..38b7782affee5e 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -1429,7 +1429,7 @@ suite('ClaudeAgent', () => { const desc = agent.getDescriptor(); assert.deepStrictEqual( { provider: desc.provider, displayName: desc.displayName, hasDescription: desc.description.length > 0, capabilities: desc.capabilities, agentHostCapabilities: agent.agentHostCapabilities }, - { provider: 'claude', displayName: 'Claude', hasDescription: true, capabilities: { multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true } }, agentHostCapabilities: { workspaceConversion: false } }, + { provider: 'claude', displayName: 'Claude', hasDescription: true, capabilities: { multipleChats: { fork: true, sideChat: true } }, agentHostCapabilities: { workspaceConversion: false } }, ); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts index 505bc884c2e442..0ea1b2c6f4780c 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts @@ -453,7 +453,7 @@ suite('CodexAgent createChat', () => { multipleChats: agent.getDescriptor().capabilities?.multipleChats, agentHostCapabilities: agent.agentHostCapabilities, }, { - multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true }, + multipleChats: { fork: true, sideChat: true }, agentHostCapabilities: { workspaceConversion: true }, }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index c2b72a5e1403bf..7f204077b9c192 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -3125,7 +3125,7 @@ suite('CopilotAgent', () => { provider: 'copilotcli', displayName: 'Copilot', description: 'Copilot SDK agent running in the local agent host process', - capabilities: { multipleChats: { fork: true, sideChat: true, initialTitle: true, forkWithInitialMessage: true } }, + capabilities: { multipleChats: { fork: true, sideChat: true } }, }, agentHostCapabilities: { workspaceConversion: true }, }); @@ -3147,8 +3147,8 @@ suite('CopilotAgent', () => { scriptedMockDefault: scriptedMockAgent.getDescriptor().capabilities, explicitOldHost: oldHostAgent.getDescriptor().capabilities, }, { - mockDefault: { multipleChats: { fork: true, initialTitle: true, forkWithInitialMessage: true } }, - scriptedMockDefault: { multipleChats: { fork: true, initialTitle: true, forkWithInitialMessage: true } }, + mockDefault: { multipleChats: { fork: true } }, + scriptedMockDefault: { multipleChats: { fork: true } }, explicitOldHost: { multipleChats: { fork: true } }, }); } finally { diff --git a/src/vs/platform/agentHost/test/node/e2e/conformance/agentHostConformance.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/conformance/agentHostConformance.integrationTest.ts index fe52967fbb8d98..8849287d844717 100644 --- a/src/vs/platform/agentHost/test/node/e2e/conformance/agentHostConformance.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/conformance/agentHostConformance.integrationTest.ts @@ -50,7 +50,6 @@ const REFERENCE_CONFIG: IAgentHostE2EProviderConfig = { planModeStyle: 'session-state', supportsMultipleChats: true, supportsInitialTitle: true, - supportsForkWithInitialMessage: true, supportsChatFork: true, supportsChatForkE2E: true, }; diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts index bd657138c7ab6a..91ec9f2d0dc8d0 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts @@ -403,11 +403,6 @@ export interface IAgentHostE2EProviderConfig { readonly supportsMultipleChats: boolean; /** Whether peer chats can be created with an explicit initial title. */ readonly supportsInitialTitle: boolean; - /** - * Whether a fork can atomically accept its first user message through the - * host-owned create-chat operation. - */ - readonly supportsForkWithInitialMessage?: boolean; /** Whether model-backed multiple-chat parity scenarios have deterministic fixtures. */ readonly supportsMultipleChatsE2E?: boolean; readonly supportsChatFork: boolean; diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts index 56fd69d8e45264..b9172e4cdfe076 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts @@ -89,7 +89,6 @@ const CLAUDE_CONFIG: IAgentHostE2EProviderConfig = { // to invoke it. TODO: rework the prompt for Claude conventions. supportsMultipleChats: true, supportsInitialTitle: true, - supportsForkWithInitialMessage: true, supportsChatFork: true, // Claude cannot resolve a client-assigned AHP turn id to an SDK message UUID, // so a provider fork silently starts with fresh context. diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts b/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts index 576f4396922697..4d02c570958fe2 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts @@ -42,7 +42,6 @@ export const CODEX_CONFIG: IAgentHostE2EProviderConfig = { planModeStyle: 'input-request', supportsMultipleChats: true, supportsInitialTitle: true, - supportsForkWithInitialMessage: true, supportsMultipleChatsE2E: true, supportsWorkspacelessE2E: true, supportsRuntimeSlashCommandsE2E: true, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts b/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts index 2ed596386439cd..7a723c7eadb4a2 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/copilotTestConfiguration.ts @@ -42,7 +42,6 @@ export const COPILOT_CONFIG: IAgentHostE2EProviderConfig = { planModeStyle: 'session-state', supportsMultipleChats: true, supportsInitialTitle: true, - supportsForkWithInitialMessage: true, supportsChatFork: true, supportsChatForkE2E: true, }; diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts index 66abf88595678a..acd534648fa734 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts @@ -283,14 +283,10 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { multipleChats: !!agent?.capabilities?.multipleChats, fork: agent?.capabilities?.multipleChats?.fork ?? false, sideChat: agent?.capabilities?.multipleChats?.sideChat ?? false, - initialTitle: agent?.capabilities?.multipleChats?.initialTitle ?? false, - forkWithInitialMessage: agent?.capabilities?.multipleChats?.forkWithInitialMessage ?? false, }, { multipleChats: config.supportsMultipleChats, fork: config.supportsChatFork, sideChat: config.supportsSideChats ?? false, - initialTitle: config.supportsInitialTitle, - forkWithInitialMessage: config.supportsForkWithInitialMessage ?? false, }); }); @@ -566,8 +562,6 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { conformanceTest(context, 'titled peer chat publishes its title in the first notification and snapshots', async function () { const { sessionUri, defaultChatUri } = await createSession('titled-peer'); - const root = await context.client.call<SubscribeResult>('subscribe', { channel: ROOT_STATE_URI }); - const agent = (root.snapshot!.state as RootState).agents.find(agent => agent.provider === config.provider); await driveTurn(defaultChatUri, 'titled-peer-source', '/rename Fork Source', 1); await context.client.call<SubscribeResult>('subscribe', { channel: sessionUri }); context.client.clearReceived(); @@ -594,12 +588,10 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { const chat = initialChat.snapshot!.state as ChatState; assert.deepStrictEqual({ - initialTitleCapability: agent?.capabilities?.multipleChats?.initialTitle, chatAdded: { resource: chatAdded.resource, title: chatAdded.title }, sessionChat: sessionChat && { resource: sessionChat.resource, title: sessionChat.title }, chatSnapshot: { snapshotResource: initialChat.snapshot!.resource, resource: chat.resource, title: chat.title }, }, { - initialTitleCapability: true, chatAdded: { resource: peer, title: 'Named peer .' }, sessionChat: { resource: peer, title: 'Named peer .' }, chatSnapshot: { snapshotResource: peer, resource: peer, title: 'Named peer .' }, @@ -669,13 +661,11 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { .find(message => message.text === '/rename Initial Message Title'); assert.deepStrictEqual({ - capability: true, snapshotAfterPublication: initialChat.snapshot!.fromSeq > chatAddedEnvelope.serverSeq, firstPublishedTitle: chatAdded.summary.title, initialTurnCount, initialAttachment: initialTurnMessage?.attachments?.map(attachment => attachment.label), }, { - capability: config.supportsForkWithInitialMessage ?? false, snapshotAfterPublication: true, firstPublishedTitle: 'Explicit Fork Title', initialTurnCount: 1, @@ -696,7 +686,7 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { + (repeatedState.activeTurn?.message.text === '/rename Initial Message Title' ? 1 : 0), 1, ); - }, config.supportsMultipleChats && config.supportsChatFork && (config.supportsForkWithInitialMessage ?? false)); + }, config.supportsMultipleChats && config.supportsChatFork); providerTest('peer chat completes a simple turn', async function () { const { sessionUri } = await createSession('peer-turn'); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index a37e0b8ba98277..04fdd11abfa5ba 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -132,7 +132,7 @@ export class MockAgent implements IAgent { constructor( readonly id: AgentProvider = 'mock', - private readonly _capabilities: IAgentCapabilities = { multipleChats: { fork: true, initialTitle: true, forkWithInitialMessage: true } }, + private readonly _capabilities: IAgentCapabilities = { multipleChats: { fork: true } }, readonly agentHostCapabilities: IAgent['agentHostCapabilities'] = { workspaceConversion: false }, autoDiscover = true, ) { @@ -570,7 +570,7 @@ export class ScriptedMockAgent implements IAgent { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent', - capabilities: this._supportsMultipleChats ? { multipleChats: { fork: true, initialTitle: true, forkWithInitialMessage: true } } : undefined, + capabilities: this._supportsMultipleChats ? { multipleChats: { fork: true } } : undefined, }; } diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index f76a0b583d1d17..611364afc05d2b 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -5339,16 +5339,14 @@ suite('ProtocolServerHandler', () => { result: (resp as { result: null }).result, options: agentService.createdChats[0].options, clientId: agentService.createdChats[0].initialMessageContext?.clientId, - capabilityBackedFork: agentService.createdChats[0].initialMessageContext?.capabilityBackedFork, }, { result: null, options: { initialMessage: message }, clientId: 'client-initial-message', - capabilityBackedFork: false, }); }); - test('createChat keeps a legacy fork initial message ungated at protocol ingress', async () => { + test('createChat accepts a fork initial message without a separate capability', async () => { stateManager.createSession(makeSessionSummary()); const transport = connectClient('client-legacy-fork-initial-message'); transport.sent.length = 0; @@ -5366,14 +5364,12 @@ suite('ProtocolServerHandler', () => { assert.deepStrictEqual({ result: (resp as { result: null }).result, options: agentService.createdChats[0].options, - candidateComposedFork: agentService.createdChats[0].initialMessageContext?.capabilityBackedFork, }, { result: null, options: { initialMessage: message, fork: { source: URI.parse(buildDefaultChatUri(sessionUri)), turnId: 'turn-1' }, }, - candidateComposedFork: true, }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 6560d071c61829..ce4c7bcc17e6c2 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -117,7 +117,7 @@ The provider cache owns adapter identity. Catalog notifications describe members Provider-specific metadata such as pull-request provenance, changesets, agent configuration, and external visibility is translated inside this provider. Shared Sessions code consumes only provider-neutral fields and capabilities. -At its private AHP boundary, the provider reads the host's `multipleChats.initialTitle` capability when forwarding provider-neutral fork options. When support is advertised, it passes the requested title to `createChat`; when the capability is absent, it omits the title and performs a title-less fork. A prompt-bearing fork additionally requires `multipleChats.forkWithInitialMessage`; when it is advertised, the provider sends the inherited source selection and first `Message` in that same `createChat` request. When it is absent, the provider does not create a chat and silently drop the message. These AHP-specific decisions are not projected through generic `ISessionCapabilities`. The pre-existing AHP `initialMessage` field remains independently available to callers that do not require the composed fork guarantee. In all cases, the returned and opened chat is derived from the first host-published catalog summary, without an optimistic client title. +At its private AHP boundary, the provider uses `multipleChats.fork` for all fork variants. It forwards an optional nonempty title to `createChat`; older hosts may ignore it. A prompt-bearing fork sends the inherited source selection and first `Message` in that same request, without a separate capability or client-side resend. Once the chat is created, initial-message failures appear as ordinary error turns in that chat and do not fail `createChat`. The target opens normally, and the source composer remains responsible for cleanup until the command finishes. Transport or navigation failures with an unknown outcome retain the target for inspection, without restoring or automatically resending the prompt. The returned and opened chat is derived from the first host-published catalog summary, without an optimistic client title. Each chat's read state is projected from its chat summary independently of the session aggregate. In a multi-chat session, any unread normal user-visible chat forces the aggregate unread; Tool/subagent chats are excluded. When the last unread participating chat becomes read, the host promotes the aggregate to read; repeated read actions do not clear an independently unread session. When supported by the negotiated protocol version, activating a chat or invoking Mark as Read or Mark as Unread on a session row dispatches the chat-level read-state action for the represented main chat and updates that chat facade optimistically; the host remains responsible for aggregate synchronization, persistence, and fan-out. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostForkActions.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostForkActions.ts index 1a93029a4caafc..601e3a1b805533 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostForkActions.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostForkActions.ts @@ -58,7 +58,7 @@ registerAction2(class extends ForkConversationAction { } } catch (error) { if (options?.initialMessage) { - throw new ChatSessionForkError('unknown', newChat.resource, error); + throw new ChatSessionForkError(newChat.resource, error); } throw error; } @@ -80,10 +80,7 @@ registerAction2(class extends ForkConversationAction { return super._openForkedSession(instantiationService, parentSessionResource, forkedSessionResource); } - // A rejected initial prompt is recovered through the base action after - // the host has already created a peer chat. It is not a top-level - // session, so opening it through the legacy session path would wait for - // a session row that will never appear. + // Inspect a prompt fork's peer directly instead of waiting for a top-level session row. const forkedChat = options?.initialMessage ? sessionsManagementService.getSessionForChatResource(forkedSessionResource) : undefined; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 0bea1c5f1e44fb..bc1868b840b5eb 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -6717,13 +6717,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!capabilities.supportsMultipleChats) { throw new Error(`Session '${sessionId}' does not support multiple chats`); } - const canSetInitialTitle = this._agentCapabilities.get()?.get(cached.agentProvider)?.multipleChats?.initialTitle === true; + if (!this._agentCapabilities.get()?.get(cached.agentProvider)?.multipleChats?.fork) { + throw new Error(`Session '${sessionId}' does not support forking`); + } - // Prompt-bearing forks must use the Chat Sessions provider's existing - // fork callback. It captures and prepares the source composer exactly - // once, then asks the host to create and accept the first message as one - // operation. The legacy bare/named path below intentionally remains on - // the Sessions provider's direct transport behavior. + // The Chat Sessions provider prepares the source composer and starts the fork's first turn in one request. if (options?.initialMessage) { this._keepSessionStateAlive(cached.sessionId); const forked = await this._chatSessionsService.forkChatSession( @@ -6765,7 +6763,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement await connection.createChat(sessionUri, chatUri, { model: selectedModel, fork: { source: sourceBackendUri, turnId }, - ...(canSetInitialTitle && options?.title !== undefined ? { title: options.title } : {}), + ...(options?.title?.trim() ? { title: options.title } : {}), }); const chat = await waitForState( diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostForkActions.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostForkActions.test.ts index d6e98d6847779f..2183d908a81bac 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostForkActions.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostForkActions.test.ts @@ -14,7 +14,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; -import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { INotificationService, IPromptChoice } from '../../../../../../platform/notification/common/notification.js'; import { TestNotificationService } from '../../../../../../platform/notification/test/common/testNotificationService.js'; import { ForkConversationActionId } from '../../../../../../workbench/contrib/chat/browser/actions/chatForkActions.js'; import { IChatWidgetService } from '../../../../../../workbench/contrib/chat/browser/chat.js'; @@ -54,9 +54,15 @@ suite('Agent Host fork actions', () => { const forkCalls: { kind: string; source: URI; requestId: string | undefined }[] = []; const forkOptions: { readonly title?: string }[] = []; const opens: { kind: string; resource: URI; options?: IOpenSessionOptions & { referenceChatResource?: URI; referenceSessionId?: string } }[] = []; + const recoveryChoices: IPromptChoice[] = []; instantiationService.stub(ILogService, new NullLogService()); - instantiationService.stub(INotificationService, new TestNotificationService()); + instantiationService.stub(INotificationService, new class extends TestNotificationService { + override prompt(...args: Parameters<INotificationService['prompt']>) { + recoveryChoices.push(...args[2]); + return super.prompt(...args); + } + }()); instantiationService.stub(IChatWidgetService, upcastPartial<IChatWidgetService>({ getWidgetBySessionResource: () => undefined })); instantiationService.stub(IChatService, upcastPartial<IChatService>({ getSession: () => upcastPartial<IChatModel>({ @@ -104,7 +110,7 @@ suite('Agent Host fork actions', () => { message: { text: 'second', parts: [] }, }); return { - source, sourceChat, forkChat, forkCalls, forkOptions, opens, started, activeSession, onDidChangeSessions, + source, sourceChat, forkChat, forkCalls, forkOptions, opens, recoveryChoices, started, activeSession, onDidChangeSessions, setForkGate: (gate: Promise<void>) => forkGate = gate, setForkAvailable: (available: boolean) => { forkAvailable = available; @@ -198,17 +204,25 @@ suite('Agent Host fork actions', () => { assert.deepStrictEqual(harness.forkOptions, [{ title: ' Exact fork title ' }]); }); - test('opens a rejected initial-prompt peer through the parent session chat path', async () => { + test('inspects an unknown prompt fork through the parent chat without automatically reopening or resending', async () => { const harness = createHarness(true, true); - harness.setForkError(new ChatSessionForkError('rejected', harness.forkChat.resource, new Error('rejected'))); + harness.setForkError(new ChatSessionForkError(harness.forkChat.resource, new Error('connection lost'))); - await assert.rejects(harness.run(false, true, undefined, { text: 'recover this prompt' })); + await assert.rejects(harness.run(false, true, undefined, { text: 'inspect before resending' })); + const beforeInspection = [...harness.opens]; + await harness.recoveryChoices[0]?.run(); - assert.deepStrictEqual(harness.opens, [{ - kind: 'chat', - resource: harness.forkChat.resource, - options: undefined, - }]); + assert.deepStrictEqual({ + beforeInspection, + choices: harness.recoveryChoices.map(choice => choice.label), + forks: harness.forkCalls, + opens: harness.opens, + }, { + beforeInspection: [], + choices: ['Inspect Target'], + forks: [{ kind: 'chat', source: harness.sourceChat.resource, requestId: 'request-2' }], + opens: [{ kind: 'chat', resource: harness.forkChat.resource, options: undefined }], + }); }); test('uses the last reported request when /fork runs while the source is streaming', async () => { 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 9acb58ce4121fd..180fc45455ef07 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 @@ -10600,8 +10600,8 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); - test('forkChat sends a supported initial title and returns the host-published named peer chat', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { - agentHost.setAgents([{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true, initialTitle: true } } } as AgentInfo]); + test('forkChat sends an optional title with only fork support and returns the host-published named peer chat', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + agentHost.setAgents([{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true } } } as AgentInfo]); const provider = createProvider(disposables, agentHost); const session = setupMultiChatSession(provider, 'multi-fork'); const sessionUri = AgentSession.uri('copilotcli', 'multi-fork').toString(); @@ -10670,6 +10670,24 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('forkChat requires the same fork capability for bare, named, and prompt forks', async () => { + agentHost.setAgents([{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: false } } }]); + let promptForks = 0; + const provider = createProvider(disposables, agentHost, undefined, { + chatSessionsService: { + forkChatSession: async () => { + promptForks++; + throw new Error('Unexpected prompt fork'); + }, + }, + }); + const session = setupMultiChatSession(provider, 'multi-fork-disabled'); + for (const options of [undefined, { title: 'Named fork' }, { initialMessage: { text: 'Prompt' } }]) { + await assert.rejects(provider.forkChat(session.sessionId, session.resource, 'turn-1', options), /does not support forking/); + } + assert.deepStrictEqual({ creates: agentHost.createdChats.length, promptForks }, { creates: 0, promptForks: 0 }); + }); + test('forkChat retains a prompt-bearing fork without overwriting its hydrated input state', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { const rawId = 'multi-fork-prompt-input-state'; const sessionUri = AgentSession.uri('copilotcli', rawId).toString(); @@ -10762,7 +10780,7 @@ suite('LocalAgentHostSessionsProvider', () => { })); test('forkChat preserves the built-in agent default for bare and named forks', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { - agentHost.setAgents([{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true, initialTitle: true } } } as AgentInfo]); + agentHost.setAgents([{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true } } } as AgentInfo]); const inputStates: Partial<IChatModelInputState>[] = []; const provider = createProvider(disposables, agentHost, undefined, { acquireOrLoadSession: async () => new ImmortalReference(new class extends mock<IChatModel>() { @@ -10789,7 +10807,7 @@ suite('LocalAgentHostSessionsProvider', () => { ]); })); - test('forkChat omits an unsupported initial title and returns the host-generated peer chat', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + test('forkChat returns the host-generated peer title when an older host ignores the requested title', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { agentHost.setAgents([{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true } } } as AgentInfo]); const provider = createProvider(disposables, agentHost); const session = setupMultiChatSession(provider, 'multi-fork-no-title'); @@ -10812,7 +10830,7 @@ suite('LocalAgentHostSessionsProvider', () => { title: call?.options?.title, forkedTitle: forked.title.get(), }, { - title: undefined, + title: 'Ignored title', forkedTitle: 'Host-generated fork', }); })); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 912f13747564f5..0703ef0fbc0fec 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -1463,7 +1463,7 @@ suite('RemoteAgentHostSessionsProvider', () => { test('opaque advertised default and peer chat mutations use host resources without native reconstruction', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { const disposed: string[] = []; - const created: { session: string; forkSource?: string; sideChatSource?: string }[] = []; + const created: { session: string; forkSource?: string; sideChatSource?: string; title?: string }[] = []; const backend = URI.parse('session-store://tenant/opaque/session?generation%3D2'); const defaultChat = URI.parse('conversation://tenant/default?revision%3D3#entry'); const peer = URI.parse('conversation://tenant/peer?revision%3D4#entry'); @@ -1476,7 +1476,7 @@ suite('RemoteAgentHostSessionsProvider', () => { override readonly initializeResult = constObservable<InitializeResult>({ protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [] }); override async disposeChat(resource: URI): Promise<void> { disposed.push(resource.toString()); } override async createChat(session: URI, chat: URI, options?: IAgentCreateChatRequestOptions): Promise<void> { - created.push({ session: session.toString(), forkSource: options?.fork?.source.toString(), sideChatSource: options?.sideChat?.source.toString() }); + created.push({ session: session.toString(), forkSource: options?.fork?.source.toString(), sideChatSource: options?.sideChat?.source.toString(), title: options?.title }); state = { ...state, chats: [...state.chats, createChatState({ resource: chat.toString(), title: '', status: ProtocolSessionStatus.Idle, modifiedAt: new Date(2000).toISOString() })] }; this.setChannelState(session, state); } @@ -1492,7 +1492,7 @@ suite('RemoteAgentHostSessionsProvider', () => { await timeout(0); const session = provider.getSessions()[0]; const peerResource = session.resource.with({ fragment: peer.toString() }); - await provider.forkChat(session.sessionId, session.resource, 'source-turn'); + await provider.forkChat(session.sessionId, session.resource, 'source-turn', { title: 'Optional fork title' }); await provider.createSideChat(session.sessionId, peerResource, 'source-turn'); await provider.renameChat(session.sessionId, session.resource, 'Renamed default'); await provider.renameChat(session.sessionId, peerResource, 'Renamed peer'); @@ -1511,8 +1511,8 @@ suite('RemoteAgentHostSessionsProvider', () => { archives: [peer.toString()], disposed: [peer.toString()], created: [ - { session: backend.toString(), forkSource: defaultChat.toString(), sideChatSource: undefined }, - { session: backend.toString(), forkSource: undefined, sideChatSource: peer.toString() }, + { session: backend.toString(), forkSource: defaultChat.toString(), sideChatSource: undefined, title: 'Optional fork title' }, + { session: backend.toString(), forkSource: undefined, sideChatSource: peer.toString(), title: undefined }, ], resource: backend.with({ scheme: 'remote-localhost__4321-copilot' }).toString(), }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 572dd3e397869e..b519a647aa92aa 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -264,7 +264,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('chat.renameSession', 'To rename the current chat session when supported, invoke the Rename command{0}. Agent Host sessions can be renamed after sending the first request.', `<keybinding:${AGENT_SESSION_RENAME_ACTION_ID}>`)); } if (type === 'panelChat' || type === 'editsView' || type === 'agentView') { - content.push(localize('chat.forkSession', "In a fork-capable Agent Host chat, to create a new forked chat alongside the current chat in the same Agent Host session, type /fork followed by a title. When the connected Agent Host supports named forks, the new chat uses that title. Agent Hosts that support forking but not named titles use their normal title for the forked chat. Accepting /fork from slash-command completion creates a bare forked chat immediately. Select the /fork title template to insert /fork, or select /fork prompt to insert /fork prompt; selecting a template does not submit it. The title and prompt hints are suggestions and are not part of your input. Send /fork prompt followed by a prompt to create the fork and submit that prompt in the new chat. An empty prompt creates an ordinary fork. The prompt template is unavailable when the connected Agent Host does not support submitting the initial message with the fork. To add a title, type or insert the command instead.")); + content.push(localize('chat.forkSession', "In a fork-capable Agent Host chat, to create a new forked chat alongside the current chat in the same Agent Host session, type /fork followed by a title. The requested title is sent to the host; older hosts may use their own title. Accepting /fork from slash-command completion creates a bare forked chat immediately. Select the /fork title template to insert /fork, or select /fork prompt to insert /fork prompt; selecting a template does not submit it. The title and prompt hints are suggestions and are not part of your input. Send /fork prompt followed by a prompt to create the fork and submit that prompt in the new chat. An empty prompt creates an ordinary fork. All fork variants require the connected Agent Host to support forking. If the initial prompt fails, the new chat opens with an ordinary error response. If the outcome is unknown, use Inspect Target in the notification before sending the prompt again; the prompt is not automatically restored or resent. To add a title, type or insert the command instead.")); } if (type !== 'inlineChat') { content.push(localize('chat.linkContextMenu', "Focus a link in the chat transcript and press Shift+F10 to open its context menu. Use Copy Link to copy its target. File links with additional editor choices also provide Open With.")); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts index 490a9a7b55cbc0..30425b0e20d23a 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts @@ -14,13 +14,12 @@ import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuId } from '../../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; import { ChatContextKeyExprs, ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatService, ResponseModelState } from '../../common/chatService/chatService.js'; import type { ISerializableChatData } from '../../common/model/chatModel.js'; import { isChatTreeItem, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; -import { ChatSessionForkError, getChatSessionInitialMessageVariables, IChatSessionForkOptions, IChatSessionRequestHistoryItem, IChatSessionsService } from '../../common/chatSessionsService.js'; +import { ChatSessionForkError, IChatSessionForkOptions, IChatSessionRequestHistoryItem, IChatSessionsService } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { CHAT_CATEGORY } from './chatActions.js'; import { ChatTreeItem, ChatViewPaneTarget, IChatWidgetService } from '../chat.js'; @@ -305,7 +304,7 @@ export class ForkConversationAction extends Action2 { await open(); } catch (error) { if (options?.initialMessage) { - throw new ChatSessionForkError('unknown', forkedItem.resource, error); + throw new ChatSessionForkError(forkedItem.resource, error); } throw error; } @@ -331,58 +330,13 @@ export class ForkConversationAction extends Action2 { return; } if (error instanceof ChatSessionForkError) { - if (error.kind === 'rejected') { - try { - await this._openForkedSession(instantiationService, sourceSessionResource, error.target, options); - } catch (openError) { - await instantiationService.invokeFunction(accessor => accessor.get(ILogService).error('Failed to open rejected fork target', openError)); - } - let restored = false; - await instantiationService.invokeFunction(async accessor => { - const widget = accessor.get(IChatWidgetService).getWidgetBySessionResource(error.target); - if (widget && widget.getInput() === '' && widget.input.attachmentModel.getAttachmentIDs().size === 0) { - widget.setInput(options.initialMessage!.text); - await widget.input.restoreAttachments(getChatSessionInitialMessageVariables(options.initialMessage!)); - restored = true; - } - }); - await instantiationService.invokeFunction(accessor => { - if (restored) { - accessor.get(INotificationService).error(localize('chat.forkInitialMessageRejected', "The fork was created, but its initial prompt was not accepted. It has been restored in the new chat.")); - return; - } - const initialMessage = options.initialMessage!; - const variables = getChatSessionInitialMessageVariables(initialMessage); - accessor.get(INotificationService).prompt( - Severity.Error, - localize('chat.forkInitialMessageRejectedKept', "The fork was created, but its initial prompt was not accepted."), - [{ - label: localize('chat.fork.restoreInitialPrompt', "Restore Prompt"), - keepOpen: true, - run: async () => { - await this._openForkedSession(instantiationService, sourceSessionResource, error.target, options); - await instantiationService.invokeFunction(async restoreAccessor => { - const targetWidget = restoreAccessor.get(IChatWidgetService).getWidgetBySessionResource(error.target); - if (targetWidget && targetWidget.getInput() === '' && targetWidget.input.attachmentModel.getAttachmentIDs().size === 0) { - targetWidget.setInput(initialMessage.text); - await targetWidget.input.restoreAttachments(variables); - } else { - restoreAccessor.get(INotificationService).warn(localize('chat.fork.restoreInitialPromptTargetBusy', "Clear the target chat draft before restoring this prompt.")); - } - }); - } - }] - ); - }); - } else { - await instantiationService.invokeFunction(accessor => { - accessor.get(INotificationService).prompt( - Severity.Warning, - localize('chat.forkInitialMessageUnknown', "The fork request outcome is unknown. Inspect the target before sending the prompt again."), - [{ label: localize('chat.fork.inspectTarget', "Inspect Target"), run: () => this._openForkedSession(instantiationService, sourceSessionResource, error.target, options) }] - ); - }); - } + await instantiationService.invokeFunction(accessor => { + accessor.get(INotificationService).prompt( + Severity.Warning, + localize('chat.forkInitialMessageUnknown', "The fork request outcome is unknown. Inspect the target before sending the prompt again."), + [{ label: localize('chat.fork.inspectTarget', "Inspect Target"), run: () => this._openForkedSession(instantiationService, sourceSessionResource, error.target, options) }] + ); + }); return; } await instantiationService.invokeFunction(accessor => { 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 64ebf14e2dd7e1..a512ecb154c1d4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -65,7 +65,7 @@ import { CompletionItemKind as AhpCompletionItemKind, ContentEncoding, type Comp import { BackgroundWorkKind, ConfirmationOptionKind, CustomizationType, JsonPrimitive, McpServerAuthRequiredState, McpServerStatus, SessionInputRequestKind, TerminalClaimKind, ToolCallContributorKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient, type SessionInputRequest, type SessionToolClientExecutionRequest } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { compareProtocolVersions } from '../../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import { ActionType, ChatTurnStartedAction, isChatAction, type ClientChatAction, type ClientSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import { AHP_AUTH_REQUIRED, AHP_NOT_FOUND, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import { AhpErrorCodes, AHP_AUTH_REQUIRED, AHP_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatOriginKind, getErrorResponsePart, getInlineToolInput, getTurnError, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readSessionWorkspaceless, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type ChatSummary, type SessionState, type StringOrMarkdown, type ToolCallPendingConfirmationState, type ToolCallResponsePart, type ToolCallRunningState, type ToolCallState, type ToolInput, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -787,8 +787,9 @@ class AgentHostChatSession extends Disposable implements IChatSession { } readonly interruptActiveResponseCallback: IChatSession['interruptActiveResponseCallback']; - readonly forkSession: IChatSession['forkSession']; - readonly supportsForkWithInitialMessage: NonNullable<IChatSession['supportsForkWithInitialMessage']>; + get forkSession(): IChatSession['forkSession'] { + return this._supportsFork() ? this._forkSession : undefined; + } readonly renameSession: IChatSession['renameSession']; readonly transferredState: IChatSession['transferredState']; prepareForClientTools: IChatSession['prepareForClientTools']; @@ -810,7 +811,7 @@ class AgentHostChatSession extends Disposable implements IChatSession { historySubagentObservations: IDisposable, onDispose: () => void, interruptActiveResponse: () => boolean, - private readonly _supportsForkWithInitialMessage: () => boolean, + private readonly _supportsFork: () => boolean, @ILogService private readonly _logService: ILogService, @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { @@ -865,8 +866,6 @@ class AgentHostChatSession extends Disposable implements IChatSession { // current active turn at call time and dispatches ChatTurnCancelled. this.interruptActiveResponseCallback = async () => interruptActiveResponse(); - this.forkSession = this._forkSession; - this.supportsForkWithInitialMessage = this._supportsForkWithInitialMessage; this.renameSession = this._renameSession; } @@ -1843,8 +1842,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }, () => { const agent = this._getRootState()?.agents.find(agent => agent.provider === this._config.provider); - return agent?.capabilities?.multipleChats?.fork === true - && agent.capabilities.multipleChats.forkWithInitialMessage === true; + return agent?.capabilities?.multipleChats?.fork === true; }, ); } catch (err) { @@ -5947,18 +5945,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (!agentInfo?.capabilities?.multipleChats?.fork) { throw new Error(`Provider ${this._config.provider} does not support forking`); } - if (options?.initialMessage && !agentInfo.capabilities.multipleChats.forkWithInitialMessage) { - // Do not fall back to a create-then-send sequence: that loses the - // host-owned acceptance and correlation guarantees for prompt forks. - throw new Error(localize('agentHost.forkInitialMessageUnsupported', "This Agent Host does not support starting a fork with a prompt.")); - } - const sessionUri = backendSession.toString(); const rawSessionState = this._getRawSessionState(sessionUri); if (!rawSessionState) { throw new Error(`Cannot fork: session state is not hydrated for ${sessionUri}`); } - const supportsInitialTitle = agentInfo.capabilities.multipleChats.initialTitle === true; // Fork the chat the gesture came from — a peer chat when the resource // carries a chat fragment, else the session's default chat. const sourceChat = this._resolveChatUriFromState(sessionResource, rawSessionState); @@ -6007,26 +5998,19 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC await this._config.connection.createChat(backendSession, forkedChat, { model: lastTurnModelSelection(protocolState), fork: { source: URI.parse(sourceChat), turnId }, - ...(supportsInitialTitle && options?.title !== undefined ? { title: options.title } : {}), + ...(options?.title?.trim() ? { title: options.title } : {}), ...(initialMessage ? { initialMessage } : {}), }); } catch (error) { - if (initialMessage && error instanceof ProtocolError) { - if (error.code === -32012) { - const data: { chat?: unknown; cause?: unknown } | undefined = typeof error.data === 'object' && error.data !== null ? error.data : undefined; - const cause: { code?: unknown; message?: unknown } | undefined = typeof data?.cause === 'object' && data.cause !== null ? data.cause : undefined; - if (typeof data?.chat === 'string' - && typeof cause?.code === 'number' - && typeof cause.message === 'string' - && data.chat === forkedChat.toString()) { - throw new ChatSessionForkError('rejected', forkedResource, error); - } - throw new ChatSessionForkError('unknown', forkedResource, error); - } - throw error; - } - if (initialMessage) { - throw new ChatSessionForkError('unknown', forkedResource, error); + const failedBeforeCreation = error instanceof ProtocolError && ( + error.code === AhpErrorCodes.AlreadyExists + || error.code === JsonRpcErrorCodes.InvalidParams + || error.code === AhpErrorCodes.SessionNotFound + || error.code === AhpErrorCodes.ProviderNotFound + ); + // Other RPC failures can arrive after the target was created. + if (initialMessage && !failedBeforeCreation) { + throw new ChatSessionForkError(forkedResource, error); } throw error; } @@ -6049,7 +6033,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // frontend target even when catalog hydration/navigation cannot finish. // The outcome is deliberately unknown: do not restore or resend it. if (initialMessage) { - throw new ChatSessionForkError('unknown', forkedResource, error); + throw new ChatSessionForkError(forkedResource, error); } throw error; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index e692b383792d9d..8572cd5417133b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -152,19 +152,21 @@ export class SlashCommandCompletions extends Disposable { const sessionResource = widget.viewModel.model.sessionResource; const sessionType = getChatSessionType(sessionResource); - let promptForkSupported = false; - if (isAgentHostBackedWidget(widget) && slashCommands.some(command => command.completionVariants?.some(variant => variant.label === '/fork prompt <prompt>'))) { + let forkSupported = false; + if (isAgentHostBackedWidget(widget) && slashCommands.some(command => command.command === 'fork')) { try { - promptForkSupported = (await this.chatSessionsService.getOrCreateChatSession(sessionResource, _token)) - .supportsForkWithInitialMessage?.() === true; + forkSupported = !!(await this.chatSessionsService.getOrCreateChatSession(sessionResource, _token)).forkSession; } catch (error) { - this.logService.error(error, 'Failed to determine whether the chat session supports forking with an initial message'); + this.logService.error(error, 'Failed to determine whether the chat session supports forking'); } } return { suggestions: slashCommands .filter(c => { + if (isAgentHostBackedWidget(widget) && c.command === 'fork' && !forkSupported) { + return false; + } // silent commands are client-side only... so they're not "attaching anything" // so this check can be scoped to when the command _does_ attach something before // checking if the widget supports attachments at all @@ -189,7 +191,6 @@ export class SlashCommandCompletions extends Disposable { const withSlash = `/${c.command}`; const variants = isAgentHostBackedWidget(widget) ? (c.completionVariants ?? [{ label: withSlash, insertText: c.executeImmediately ? '' : `${withSlash} `, executeImmediately: c.executeImmediately }]) - .filter(variant => variant.label !== '/fork prompt <prompt>' || promptForkSupported) : [{ label: withSlash, insertText: c.executeImmediately ? '' : `${withSlash} `, executeImmediately: c.executeImmediately }]; return variants.map((variant, variantIndex) => { const prefix = variant.insertText.trimEnd(); diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index c6dd28591196c1..94f7d09a75fed8 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -531,14 +531,11 @@ export interface IChatSessionForkOptions { } /** - * A provider-neutral fork failure with the client resource selected before the - * create request was issued. `rejected` proves that the target chat exists and - * its initial message was not accepted; `unknown` deliberately makes no such - * claim and must never be retried automatically. + * A fork with an unknown outcome and a client-selected target to inspect. + * The initial message must never be retried automatically. */ export class ChatSessionForkError extends Error { constructor( - readonly kind: 'rejected' | 'unknown', readonly target: URI, override readonly cause: unknown, ) { @@ -611,14 +608,6 @@ export interface IChatSession extends IDisposable { * @returns The forked session item. The promise is rejected if forking fails. */ forkSession?: (request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken, options?: IChatSessionForkOptions) => Promise<IChatSessionItem>; - /** - * Whether the currently connected provider supports the composed, - * host-owned fork-and-initial-message operation. This is deliberately a - * getter so callers consult the current connection descriptor rather than - * caching capability state. - */ - supportsForkWithInitialMessage?: () => boolean; - /** * Renames the session. * @param title The new title for the session. diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts index 9719680f09ded0..e63761736362b8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts @@ -600,7 +600,7 @@ suite('Chat Accessibility Help', () => { }); }); - test('documents qualified named fork behavior on persistent chat surfaces', () => { + test('documents optional fork titles and ordinary initial-prompt errors on persistent chat surfaces', () => { const keybindingService = new MockKeybindingService(); const getForkHelp = (type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView', isSessionsWindow = false) => { const help = getAccessibilityHelpText(type, keybindingService, true, isSessionsWindow); @@ -608,8 +608,7 @@ suite('Chat Accessibility Help', () => { forkCapableAgentHostChat: help.includes('In a fork-capable Agent Host chat'), commandWithTitle: help.includes('type /fork followed by a title'), sameSessionPeerChat: help.includes('new forked chat alongside the current chat in the same Agent Host session'), - supportedHost: help.includes('When the connected Agent Host supports named forks, the new chat uses that title'), - unnamedForkHostFallback: help.includes('Agent Hosts that support forking but not named titles use their normal title for the forked chat'), + optionalTitle: help.includes('The requested title is sent to the host; older hosts may use their own title'), immediateBareFork: help.includes('Accepting /fork from slash-command completion creates a bare forked chat immediately'), titleTemplate: help.includes('Select the /fork title template to insert /fork'), promptTemplate: help.includes('select /fork prompt to insert /fork prompt'), @@ -617,7 +616,9 @@ suite('Chat Accessibility Help', () => { hintsAreNotInput: help.includes('The title and prompt hints are suggestions and are not part of your input.'), submitPromptInFork: help.includes('Send /fork prompt followed by a prompt to create the fork and submit that prompt in the new chat'), emptyPromptFallback: help.includes('An empty prompt creates an ordinary fork'), - unsupportedPrompt: help.includes('prompt template is unavailable when the connected Agent Host does not support submitting the initial message with the fork'), + sharedForkCapability: help.includes('All fork variants require the connected Agent Host to support forking'), + initialPromptError: help.includes('If the initial prompt fails, the new chat opens with an ordinary error response'), + unknownOutcome: help.includes('use Inspect Target in the notification before sending the prompt again; the prompt is not automatically restored or resent'), insertToTitle: help.includes('To add a title, type or insert the command instead'), doesNotDescribeNewSession: !help.includes('new session') && !help.includes('fork an Agent Host session'), }; @@ -626,8 +627,7 @@ suite('Chat Accessibility Help', () => { forkCapableAgentHostChat: true, commandWithTitle: true, sameSessionPeerChat: true, - supportedHost: true, - unnamedForkHostFallback: true, + optionalTitle: true, immediateBareFork: true, titleTemplate: true, promptTemplate: true, @@ -635,7 +635,9 @@ suite('Chat Accessibility Help', () => { hintsAreNotInput: true, submitPromptInFork: true, emptyPromptFallback: true, - unsupportedPrompt: true, + sharedForkCapability: true, + initialPromptError: true, + unknownOutcome: true, insertToTitle: true, doesNotDescribeNewSession: true, }; @@ -643,8 +645,7 @@ suite('Chat Accessibility Help', () => { forkCapableAgentHostChat: false, commandWithTitle: false, sameSessionPeerChat: false, - supportedHost: false, - unnamedForkHostFallback: false, + optionalTitle: false, immediateBareFork: false, titleTemplate: false, promptTemplate: false, @@ -652,7 +653,9 @@ suite('Chat Accessibility Help', () => { hintsAreNotInput: false, submitPromptInFork: false, emptyPromptFallback: false, - unsupportedPrompt: false, + sharedForkCapability: false, + initialPromptError: false, + unknownOutcome: false, insertToTitle: false, doesNotDescribeNewSession: true, }; diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts index d6ee2ac52d96ee..76e405510b35e2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts @@ -16,7 +16,7 @@ import { ICommandService } from '../../../../../../platform/commands/common/comm import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { INotificationService, IPromptChoice } from '../../../../../../platform/notification/common/notification.js'; import { TestNotificationService } from '../../../../../../platform/notification/test/common/testNotificationService.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; @@ -45,7 +45,7 @@ class TestForkConversationAction extends ForkConversationAction { suite('ForkConversationAction', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('rebases and deduplicates recovered fork prompt variables', () => { + test('rebases and deduplicates fork prompt variables', () => { const variables = getChatSessionInitialMessageVariables({ text: 'prompt', textOffset: 13, @@ -68,18 +68,14 @@ suite('ForkConversationAction', () => { ]); }); - test('retains target identity and original cause for rejected and unknown prompt forks', () => { + test('retains target identity and original cause for unknown prompt forks', () => { const target = URI.parse('contributed:/source#fork'); - const cause = new Error('initial message rejected'); - const rejected = new ChatSessionForkError('rejected', target, cause); - const unknown = new ChatSessionForkError('unknown', target, cause); + const cause = new Error('connection lost'); + const error = new ChatSessionForkError(target, cause); assert.deepStrictEqual({ - target: rejected.target, - kind: rejected.kind, - cause: rejected.cause, - unknownKind: unknown.kind, - unknownCause: unknown.cause, - }, { target, kind: 'rejected', cause, unknownKind: 'unknown', unknownCause: cause }); + target: error.target, + cause: error.cause, + }, { target, cause }); }); test('offers three fork presentations and preserves prompt syntax and offsets', async () => { @@ -160,12 +156,55 @@ suite('ForkConversationAction', () => { instantiationService.invokeFunction(accessor => action.run(accessor, source, { initialMessage: { text: 'new prompt' } })), error => { assert.ok(error instanceof ChatSessionForkError); - assert.deepStrictEqual({ kind: error.kind, target: error.target, cause: error.cause, creations, openings }, { kind: 'unknown', target, cause, creations: 1, openings: 1 }); + assert.deepStrictEqual({ target: error.target, cause: error.cause, creations, openings }, { target, cause, creations: 1, openings: 1 }); return true; }, ); }); + test('offers target inspection for an unknown transport outcome without restoring or resending the prompt', async () => { + const instantiationService = disposables.add(new TestInstantiationService()); + const source = URI.parse('agent-host-copilot:/source'); + const target = source.with({ fragment: 'fork' }); + const failure = new ChatSessionForkError(target, new Error('connection lost')); + const choices: IPromptChoice[] = []; + const opens: URI[] = []; + let creations = 0; + instantiationService.stub(IChatService, upcastPartial<IChatService>({})); + instantiationService.stub(IChatWidgetService, upcastPartial<IChatWidgetService>({})); + instantiationService.stub(INotificationService, new class extends TestNotificationService { + override prompt(...args: Parameters<INotificationService['prompt']>) { + choices.push(...args[2]); + return super.prompt(...args); + } + }()); + instantiationService.stub(IChatSessionsService, upcastPartial<IChatSessionsService>({ + getContentProviderSchemes: () => [source.scheme], + forkChatSession: async () => { + creations++; + throw failure; + }, + })); + const action = new class extends ForkConversationAction { + protected override async _openForkedSession(_instantiationService: IInstantiationService, _source: URI, target: URI): Promise<void> { + opens.push(target); + } + }; + await assert.rejects( + instantiationService.invokeFunction(accessor => action.run(accessor, source, { initialMessage: { text: 'new prompt' } })), + error => error === failure, + ); + const beforeInspection = [...opens]; + await choices[0]?.run(); + + assert.deepStrictEqual({ beforeInspection, choices: choices.map(choice => choice.label), creations, opens }, { + beforeInspection: [], + choices: ['Inspect Target'], + creations: 1, + opens: [target], + }); + }); + test('trims only the fork slash command title', async () => { let forkCommand: IChatSlashCallback | undefined; let forkData: IChatSlashData | undefined; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 13ecb044a70c0d..e564ac7217f729 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 @@ -56,7 +56,7 @@ import { toAgentMergeMessageMeta } from '../../../../../../platform/agentHost/co import { toAgentMessageDelegationMeta } from '../../../../../../platform/agentHost/common/meta/agentMessageDelegationMeta.js'; import { toRemoteSessionMessageMetadata } from '../../../../../../platform/agentHost/common/meta/agentRemoteSessionMeta.js'; import { ActionType, AuthRequiredReason, isSessionAction, isChatAction, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type ChatAction as AgentHostChatAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import { AHP_AUTH_REQUIRED, AHP_NOT_FOUND, ProtocolError, type DispatchActionParams, type IStateSnapshot, type JsonRpcRequest, type ProtocolMessage } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import { AhpErrorCodes, AHP_AUTH_REQUIRED, AHP_NOT_FOUND, JsonRpcErrorCodes, ProtocolError, type DispatchActionParams, type IStateSnapshot, type JsonRpcRequest, type ProtocolMessage } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { BackgroundWorkKind, ChatInteractivity, ConfirmationOptionKind, CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerStatus, type AgentCustomization, type ClientPluginCustomization, type ProtectedResourceMetadata, type SessionActiveClient, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ChatOriginKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, createSessionState, createChatState, createDefaultChatSummary, createErrorResponsePart, buildChatUri, buildDefaultChatUri, parseChatUri, isAhpChatChannel, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, ROOT_STATE_URI, StateComponents, buildSubagentChatUri, ToolResultContentType, MessageAttachmentKind, MessageKind, PendingMessageKind, withMessageRequestHiddenFromTranscript, withSessionMultiRootMetadata, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, type SessionState, type SessionSummary, type ChatState, type ISessionWithDefaultChat, RootState, type ToolCallState, type AgentInfo, type MessageAttachment, type MessageChatAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { CompletionItemKind as AhpCompletionItemKind, type CompletionsParams, type CompletionsResult, type InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; @@ -80,7 +80,7 @@ import { IChatDebugService } from '../../../common/chatDebugService.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { IChatResponseFileChangesService } from '../../../browser/chatResponseFileChangesService.js'; import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { IChatSessionsService, type IChatSession, type IChatSessionForkOptions, type IChatSessionHistoryItem, type IChatSessionItemController, type IChatSessionRequestHistoryItem, type IChatSessionServerRequest, type IChatSessionsExtensionPoint } from '../../../common/chatSessionsService.js'; +import { ChatSessionForkError, IChatSessionsService, type IChatSession, type IChatSessionForkOptions, type IChatSessionHistoryItem, type IChatSessionItemController, type IChatSessionRequestHistoryItem, type IChatSessionServerRequest, type IChatSessionsExtensionPoint } from '../../../common/chatSessionsService.js'; import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../common/languageModels.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; @@ -6209,9 +6209,9 @@ suite('AgentHostChatContribution', () => { // ---- Forking --------------------------------------------------------- suite('forking', () => { - async function forkSession(initialTitle: boolean | undefined, requestedTitle: string, publishedTitle: string, initialMessage?: IChatSessionForkOptions['initialMessage'], sourceSelection = false, boundary?: { turnCount?: number; sourceRequestId?: string; beforeRequestId?: string; activeTurnId?: string }) { + async function forkSession(requestedTitle: string | undefined, publishedTitle: string, initialMessage?: IChatSessionForkOptions['initialMessage'], sourceSelection = false, boundary?: { turnCount?: number; sourceRequestId?: string; beforeRequestId?: string; activeTurnId?: string }) { const { sessionHandler, agentHostService } = createContribution(disposables); - const backendSession = AgentSession.uri('copilot', `fork-${initialTitle === true ? 'supported' : 'legacy'}`); + const backendSession = AgentSession.uri('copilot', 'fork-source'); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: backendSession.path }); const sourceChat = buildDefaultChatUri(backendSession.toString()); const summary: SessionSummary = { @@ -6228,7 +6228,7 @@ suite('AgentHostChatContribution', () => { displayName: 'Agent Host - Copilot', description: 'test', models: [], - capabilities: { multipleChats: { fork: true, initialTitle, ...(initialMessage ? { forkWithInitialMessage: true } : {}) } }, + capabilities: { multipleChats: { fork: true } }, }], activeSessions: 0, }); @@ -6289,11 +6289,14 @@ suite('AgentHostChatContribution', () => { call: agentHostService.createChatCalls[0], forkedItem, sourceChat, + chatSession, + sessionHandler, + agentHostService, }; } test('prompt fork from an active request keeps the last completed turn', async () => { - const result = await forkSession(true, 'Fork', 'Fork', { text: 'Continue in a fork' }, false, { + const result = await forkSession('Fork', 'Fork', { text: 'Continue in a fork' }, false, { turnCount: 2, sourceRequestId: 'streaming-turn', activeTurnId: 'streaming-turn', @@ -6311,7 +6314,7 @@ suite('AgentHostChatContribution', () => { test('prompt fork from an active first request cannot invent a completed boundary', async () => { await assert.rejects( - forkSession(true, 'Fork', 'Fork', { text: 'Continue in a fork' }, false, { + forkSession('Fork', 'Fork', { text: 'Continue in a fork' }, false, { turnCount: 0, sourceRequestId: 'streaming-turn', activeTurnId: 'streaming-turn', @@ -6326,7 +6329,7 @@ suite('AgentHostChatContribution', () => { { turnCount: 3, sourceRequestId: 'source-turn-3' }, ]) { test(`fork prompt includes the exact preselected ${boundary.sourceRequestId} in a ${boundary.turnCount}-turn chat`, async () => { - const result = await forkSession(true, 'Fork', 'Fork', { text: 'what is 4+5' }, false, boundary); + const result = await forkSession('Fork', 'Fork', { text: 'what is 4+5' }, false, boundary); assert.deepStrictEqual({ source: result.call.options?.fork?.source.toString(), turnId: result.call.options?.fork?.turnId, @@ -6340,12 +6343,12 @@ suite('AgentHostChatContribution', () => { } test('a request-item fork still excludes the selected request', async () => { - const result = await forkSession(true, 'Fork', 'Fork', undefined, false, { turnCount: 3, beforeRequestId: 'source-turn-2' }); + const result = await forkSession('Fork', 'Fork', undefined, false, { turnCount: 3, beforeRequestId: 'source-turn-2' }); assert.strictEqual(result.call.options?.fork?.turnId, 'source-turn'); }); test('an exact source selection overrides the request-item boundary', async () => { - const result = await forkSession(true, 'Fork', 'Fork', { text: 'new prompt' }, false, { + const result = await forkSession('Fork', 'Fork', { text: 'new prompt' }, false, { turnCount: 3, sourceRequestId: 'source-turn', beforeRequestId: 'source-turn-3', @@ -6355,28 +6358,28 @@ suite('AgentHostChatContribution', () => { test('a request-item fork still rejects forking before the first request', async () => { await assert.rejects( - forkSession(true, 'Fork', 'Fork', undefined, false, { beforeRequestId: 'source-turn' }), + forkSession('Fork', 'Fork', undefined, false, { beforeRequestId: 'source-turn' }), /Cannot fork: cannot fork before the first request/, ); }); test('a preselected source request must exist', async () => { await assert.rejects( - forkSession(true, 'Fork', 'Fork', { text: 'new prompt' }, false, { sourceRequestId: 'missing-turn' }), + forkSession('Fork', 'Fork', { text: 'new prompt' }, false, { sourceRequestId: 'missing-turn' }), /Cannot fork: turn for request missing-turn not found in protocol state/, ); }); - test('forwards a requested title when the host advertises initial title support and returns the host summary label', async () => { + test('forwards an optional title with only fork support and returns the host summary label', async () => { const requestedTitle = 'Requested fork title'; - const result = await forkSession(true, requestedTitle, requestedTitle); + const result = await forkSession(requestedTitle, requestedTitle); assert.deepStrictEqual({ session: result.call.session.toString(), options: result.call.options, label: result.forkedItem?.label, }, { - session: 'copilot:/fork-supported', + session: 'copilot:/fork-source', options: { model: undefined, fork: { source: URI.parse(result.sourceChat), turnId: 'source-turn' }, @@ -6386,27 +6389,139 @@ suite('AgentHostChatContribution', () => { }); }); - test('omits a requested title for a capability-absent host and returns its generated summary label', async () => { - const result = await forkSession(undefined, 'Ignored fork title', 'Host-generated fork title'); + test('returns the host summary label when an older host ignores the optional title', async () => { + const result = await forkSession('Ignored fork title', 'Host-generated fork title'); assert.deepStrictEqual({ session: result.call.session.toString(), options: result.call.options, label: result.forkedItem?.label, }, { - session: 'copilot:/fork-legacy', + session: 'copilot:/fork-source', options: { model: undefined, fork: { source: URI.parse(result.sourceChat), turnId: 'source-turn' }, + title: 'Ignored fork title', }, label: 'Host-generated fork title', }); }); + for (const title of [undefined, '', ' ']) { + test(`omits an absent or empty optional fork title (${JSON.stringify(title)})`, async () => { + const result = await forkSession(title, 'Host fork'); + assert.deepStrictEqual(result.call.options, { + model: undefined, + fork: { source: URI.parse(result.sourceChat), turnId: 'source-turn' }, + }); + }); + } + + test('supports prompt forks with only fork capability and projects a normal error turn in the created target', async () => { + const prompt = 'Continue in a fork'; + const result = await forkSession(undefined, 'Prompt fork', { text: prompt }); + assert.ok(result.forkedItem); + assert.ok(result.call.options?.initialMessage); + result.agentHostService.chatStates.set(result.call.chat.toString(), { + ...createChatState({ + resource: result.call.chat.toString(), + title: 'Prompt fork', + status: SessionStatus.Error, + modifiedAt: new Date().toISOString(), + }), + turns: [{ + id: 'initial-prompt', + state: TurnState.Error, + message: result.call.options.initialMessage, + responseParts: [createErrorResponsePart({ errorType: 'provider_error', message: 'Unable to start the prompt' })], + usage: undefined, + }], + }); + + const target = disposables.add(await result.sessionHandler.provideChatSessionContent(result.forkedItem.resource, CancellationToken.None)); + assert.deepStrictEqual({ + supportsFork: !!result.chatSession.forkSession, + creates: result.agentHostService.createChatCalls.length, + clientTurns: result.agentHostService.turnActions, + sourceRequests: result.chatSession.history.filter(item => item.type === 'request').map(item => item.prompt), + targetRequests: target.history.filter(item => item.type === 'request').map(item => item.prompt), + targetErrors: target.history.filter(item => item.type === 'response').map(item => item.errorDetails?.message), + }, { + supportsFork: true, + creates: 1, + clientTurns: [], + sourceRequests: ['Source request'], + targetRequests: [prompt], + targetErrors: ['Error: (provider_error) Unable to start the prompt'], + }); + }); + + test('all fork variants follow the current host fork capability', async () => { + const result = await forkSession(undefined, 'Fork'); + const setForkSupport = (fork: boolean) => result.agentHostService.setRootState({ + agents: [{ provider: 'copilot', displayName: 'Copilot', description: 'test', models: [], capabilities: { multipleChats: { fork } } }], + activeSessions: 1, + }); + const initiallySupported = !!result.chatSession.forkSession; + setForkSupport(false); + const afterDisabled = !!result.chatSession.forkSession; + setForkSupport(true); + + assert.deepStrictEqual({ initiallySupported, afterDisabled, afterEnabled: !!result.chatSession.forkSession }, { + initiallySupported: true, + afterDisabled: false, + afterEnabled: true, + }); + }); + + for (const code of [JsonRpcErrorCodes.InternalError, AhpErrorCodes.PermissionDenied, AhpErrorCodes.NotFound]) { + test(`retains the prompt fork target for an uncertain RPC error (${code}) without resending`, async () => { + const fixture = await forkSession(undefined, 'Fork'); + const cause = new ProtocolError(code, 'The createChat response could not confirm the outcome'); + const targets: URI[] = []; + fixture.agentHostService.createChat = async (_session, chat) => { + targets.push(chat); + throw cause; + }; + + await assert.rejects( + fixture.chatSession.forkSession!(undefined, CancellationToken.None, { initialMessage: { text: 'New prompt' } }), + error => { + assert.ok(error instanceof ChatSessionForkError); + assert.deepStrictEqual({ + target: error.target.toString(), + cause: error.cause, + creates: targets.length, + clientTurns: fixture.agentHostService.turnActions, + }, { + target: fixture.chatSession.sessionResource.with({ fragment: parseChatUri(targets[0].toString())!.chatId }).toString(), + cause, + creates: 1, + clientTurns: [], + }); + return true; + }, + ); + }); + } + + for (const code of [AhpErrorCodes.AlreadyExists, JsonRpcErrorCodes.InvalidParams, AhpErrorCodes.SessionNotFound, AhpErrorCodes.ProviderNotFound]) { + test(`preserves a definite pre-creation prompt fork error (${code})`, async () => { + const fixture = await forkSession(undefined, 'Fork'); + const cause = new ProtocolError(code, 'The fork could not be created'); + fixture.agentHostService.createChat = async () => { throw cause; }; + + await assert.rejects( + fixture.chatSession.forkSession!(undefined, CancellationToken.None, { initialMessage: { text: 'New prompt' } }), + error => error === cause, + ); + }); + } + test('captures a multiline initial message from the actual source turn rather than the unsent composer selection', async () => { const prompt = 'first line\nsecond line'; const prefix = '/fork prompt '; - const result = await forkSession(true, 'Ignored', 'Forked', { + const result = await forkSession('Ignored', 'Forked', { text: prompt, textOffset: prefix.length, requestOptions: { @@ -6454,7 +6569,7 @@ suite('AgentHostChatContribution', () => { const backendSession = AgentSession.uri('copilot', 'fork-cancelled'); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: backendSession.path }); const sourceChat = buildDefaultChatUri(backendSession.toString()); - agentHostService.setRootState({ agents: [{ provider: 'copilot', displayName: 'Agent Host - Copilot', description: 'test', models: [], capabilities: { multipleChats: { fork: true, initialTitle: true } } }], activeSessions: 0 }); + agentHostService.setRootState({ agents: [{ provider: 'copilot', displayName: 'Agent Host - Copilot', description: 'test', models: [], capabilities: { multipleChats: { fork: true } } }], activeSessions: 0 }); agentHostService.sessionStates.set(backendSession.toString(), { ...createSessionState({ resource: backendSession.toString(), provider: 'copilot', title: 'Source', status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString() }), lifecycle: SessionLifecycle.Ready, diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts index 0a9c659e3c2f35..dd30c6eca189cc 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts @@ -509,7 +509,7 @@ suite('SlashCommandCompletions fork variants', () => { lastToken: CancellationToken | undefined; constructor( - private readonly supportsForkWithInitialMessage: boolean, + private readonly supportsFork: boolean, private readonly lookupError?: Error, ) { super(); @@ -525,7 +525,7 @@ suite('SlashCommandCompletions fork variants', () => { throw this.lookupError; } return upcastPartial<IChatSession>({ - supportsForkWithInitialMessage: () => this.supportsForkWithInitialMessage, + forkSession: this.supportsFork ? async () => { throw new Error('Not implemented'); } : undefined, }); } } @@ -580,7 +580,7 @@ suite('SlashCommandCompletions fork variants', () => { return { suggestions: result.suggestions, model }; } - test('offers all fork variants for agent host sessions that support an initial message', async () => { + test('offers all fork variants for agent host sessions that support forking', async () => { const chatSessionsService = new ForkCompletionChatSessionsService(true); const { suggestions, model } = await provideSuggestions(true, [forkCommand], chatSessionsService); @@ -683,13 +683,10 @@ suite('SlashCommandCompletions fork variants', () => { } }); - test('offers only legacy fork variants when the agent host does not support an initial message', async () => { + test('offers no fork variants when the agent host does not support forking', async () => { const { suggestions } = await provideSuggestions(true, [forkCommand], new ForkCompletionChatSessionsService(false)); - assert.deepStrictEqual(suggestions.map(suggestion => suggestion.label), [ - { label: '/fork', description: 'Fork this chat' }, - { label: '/fork <title>', description: 'Fork this chat' }, - ]); + assert.deepStrictEqual(suggestions, []); }); test('uses the original slash command completion outside agent host sessions', async () => { @@ -702,7 +699,7 @@ suite('SlashCommandCompletions fork variants', () => { assert.strictEqual(chatSessionsService.lookupCount, 0); }); - test('does not query capabilities when no command has a fork prompt variant', async () => { + test('does not query capabilities when no fork command is available', async () => { const chatSessionsService = new ForkCompletionChatSessionsService(true); const { suggestions } = await provideSuggestions(true, [{ command: 'review', @@ -716,27 +713,21 @@ suite('SlashCommandCompletions fork variants', () => { ]); }); - test('preserves legacy variants when the capability lookup is cancelled', async () => { + test('offers no fork variants when the capability lookup is cancelled', async () => { const chatSessionsService = new ForkCompletionChatSessionsService(false); const cancellationTokenSource = new CancellationTokenSource(); cancellationTokenSource.cancel(); const { suggestions } = await provideSuggestions(true, [forkCommand], chatSessionsService, cancellationTokenSource.token); assert.strictEqual(chatSessionsService.lastToken, cancellationTokenSource.token); - assert.deepStrictEqual(suggestions.map(suggestion => suggestion.label), [ - { label: '/fork', description: 'Fork this chat' }, - { label: '/fork <title>', description: 'Fork this chat' }, - ]); + assert.deepStrictEqual(suggestions, []); cancellationTokenSource.dispose(); }); - test('preserves legacy variants when the capability lookup fails', async () => { + test('offers no fork variants when the capability lookup fails', async () => { const { suggestions } = await provideSuggestions(true, [forkCommand], new ForkCompletionChatSessionsService(false, new Error('lookup failed'))); - assert.deepStrictEqual(suggestions.map(suggestion => suggestion.label), [ - { label: '/fork', description: 'Fork this chat' }, - { label: '/fork <title>', description: 'Fork this chat' }, - ]); + assert.deepStrictEqual(suggestions, []); }); }); From 9d35ae79334983a4cc3a4cff623d5ce77be8e41e Mon Sep 17 00:00:00 2001 From: Don Jayamanne <don.jayamanne@outlook.com> Date: Wed, 14 Oct 2026 05:53:36 +1100 Subject: [PATCH 10/15] agentHost: drop changes unrelated to named and prompted forks Revert 29e29e26345 (session metadata drain before idle eviction). The race it fixes is reached through the pre-existing rename paths and is independent of forking; it is tracked separately. Also remove the unused startClientTurnMessage overload, collapse the single-field ICapturedForkInitialMessage wrapper, drop a stray blank line, and right-size the fork accessibility help to match the surrounding entries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentHostPeerChatStore.ts | 42 +++---------- .../agentHost/node/agentHostTurnService.ts | 15 ----- .../platform/agentHost/node/agentService.ts | 1 - .../test/node/agentHostPeerChatStore.test.ts | 49 --------------- .../agentHost/test/node/agentService.test.ts | 61 +------------------ .../browser/actions/chatAccessibilityHelp.ts | 3 +- .../agentHost/agentHostSessionHandler.ts | 19 ++---- .../chatAccessibilityHelp.test.ts | 43 +++---------- 8 files changed, 28 insertions(+), 205 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts index cf45b5af0a14c0..bb25f72777cc82 100644 --- a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts +++ b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts @@ -60,7 +60,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ declare readonly _serviceBrand: undefined; private readonly _writes = new Map<string, Promise<void>>(); - private readonly _metadataWrites = new Map<Promise<void>, string>(); + private readonly _metadataWrites = new Set<Promise<void>>(); private readonly _deletingSessions = new Map<string, number>(); private _metadataMigration: ((session: URI, resource: URI, values: Readonly<Record<string, string>>) => Promise<Readonly<Record<string, string>>>) | undefined; @@ -99,37 +99,15 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ return { mutation: target ? { chat: target.chat, expected: { ownershipRevision: 0, metadataRevision: 0 }, patch } : undefined, remaining }; } - /** Drains all writes and propagates failures to callers synchronizing catalog state. */ async whenIdle(): Promise<void> { while (this._writes.size || this._metadataWrites.size) { - await Promise.all([...this._writes.values(), ...this._metadataWrites.keys()]); + await Promise.all([...this._writes.values(), ...this._metadataWrites]); } } - /** Waits for one session's writes to settle before release; write callers report failures. */ - async whenSessionIdle(session: URI): Promise<void> { - const sessionKey = session.toString(); - while (true) { - const writes: Promise<void>[] = []; - const queuedWrite = this._writes.get(sessionKey); - if (queuedWrite) { - writes.push(queuedWrite); - } - for (const [write, key] of this._metadataWrites) { - if (key === sessionKey) { - writes.push(write); - } - } - if (writes.length === 0) { - return; - } - await Promise.allSettled(writes); - } - } - - private _trackMetadataWrite(session: URI, operation: () => Promise<void>): Promise<void> { + private _trackMetadataWrite(operation: () => Promise<void>): Promise<void> { const tracked = operation().finally(() => this._metadataWrites.delete(tracked)); - this._metadataWrites.set(tracked, session.toString()); + this._metadataWrites.add(tracked); return tracked; } @@ -218,11 +196,11 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ } persistMetadata(session: URI, resource: URI, values: Readonly<Record<string, string>>): Promise<void> { - return this._trackMetadataWrite(session, () => this._persistMetadata(session, resource, values)); + return this._trackMetadataWrite(() => this._persistMetadata(session, resource, values)); } persistDefaultChatTitleSnapshot(session: URI, chat: URI, title: string): Promise<void> { - return this._trackMetadataWrite(session, () => this._enqueue(session, async () => { + return this._trackMetadataWrite(() => this._enqueue(session, async () => { while (true) { const current = await this.readNormalizedChat(session, chat); if (!current.normalized) { @@ -602,7 +580,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ }); return next; }; - return this._trackMetadataWrite(session, async () => { + return this._trackMetadataWrite(async () => { const values = { [CHAT_PROVIDER_DATA_METADATA_KEY]: providerData ?? '', ...(origin !== undefined ? { [CHAT_ORIGIN_METADATA_KEY]: JSON.stringify(origin) } : {}), @@ -644,7 +622,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ }); return next; }; - return this._trackMetadataWrite(session, async () => { + return this._trackMetadataWrite(async () => { const values = { [CHAT_WORKING_DIRECTORIES_METADATA_KEY]: JSON.stringify(workingDirectories) }; if (this._metadataMigration && !Object.keys(await this._metadataMigration(session, chat, values)).length) { return; @@ -658,7 +636,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ } setArchived(session: URI, chat: URI, archived: boolean): Promise<void> { - return this._trackMetadataWrite(session, () => this._setArchived(session, chat, archived)); + return this._trackMetadataWrite(() => this._setArchived(session, chat, archived)); } private async _setArchived(session: URI, chat: URI, archived: boolean): Promise<void> { @@ -679,7 +657,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ } setRead(session: URI, chat: URI, isRead: boolean): Promise<void> { - return this._trackMetadataWrite(session, () => this._setRead(session, chat, isRead)); + return this._trackMetadataWrite(() => this._setRead(session, chat, isRead)); } private async _setRead(session: URI, chat: URI, isRead: boolean): Promise<void> { diff --git a/src/vs/platform/agentHost/node/agentHostTurnService.ts b/src/vs/platform/agentHost/node/agentHostTurnService.ts index 8f9cab3e57766f..7a924cc931e8c9 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnService.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnService.ts @@ -28,11 +28,6 @@ export interface IDeferredAgentHostTurn { export interface IAgentHostTurnService { readonly _serviceBrand: undefined; startTurnMessage(chat: URI, message: Message): void; - /** - * Starts a message prepared by a connected client without manufacturing a - * client action envelope or client sequence number. - */ - startClientTurnMessage(chat: URI, message: Message, clientId: string, clientContext: IAgentHostClientTelemetryContext): ErrorInfo | undefined; startClientTurn(chat: URI, action: ChatTurnStartedAction, clientId: string, clientContext: IAgentHostClientTelemetryContext): ErrorInfo | undefined; beginDeferredTurnMessage(chat: URI, message: Message): IDeferredAgentHostTurn; continueDeferredTurnMessage(chat: URI, turn: IDeferredAgentHostTurn, message: Message): boolean; @@ -60,16 +55,6 @@ export class AgentHostTurnService implements IAgentHostTurnService { this.handleTurnStarted(channel, action); } - startClientTurnMessage(chat: URI, message: Message, clientId: string, clientContext: IAgentHostClientTelemetryContext): ErrorInfo | undefined { - const action: ChatTurnStartedAction = { - type: ActionType.ChatTurnStarted, - turnId: generateUuid(), - startedAt: new Date().toISOString(), - message, - }; - return this.startClientTurn(chat, action, clientId, clientContext); - } - startClientTurn(chat: URI, action: ChatTurnStartedAction, clientId: string, clientContext: IAgentHostClientTelemetryContext): ErrorInfo | undefined { this._stateManager.dispatchServerAction(chat.toString(), action); return this.handleTurnStarted(chat.toString(), action, clientId, clientContext); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index f256112bbba54b..1674e0d42dba48 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -7035,7 +7035,6 @@ export class AgentService extends Disposable implements IAgentService { } private async _whenSessionDataIdle(session: URI): Promise<void> { - await this._peerChatStore.whenSessionIdle(session); const ref = this._sessionDataService.openDatabase(session); try { await ref.object.whenIdle(); diff --git a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts index f9e00ee2f22754..74adfb7d8879cb 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts @@ -5,7 +5,6 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; -import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; @@ -96,54 +95,6 @@ suite('AgentHostPeerChatStore', () => { return new AgentHostPeerChatStore(orchestrator, createSessionDataService(database), logService, createLiveChatOwnership(() => undefined)); } - test('session idle waits for metadata migration without waiting for another session', async () => { - const localGate = new DeferredPromise<void>(); - const otherGate = new DeferredPromise<void>(); - const otherSession = URI.parse('agenthost:other-peer-store'); - const store = createStore(new TestSessionDatabase()); - store.setMetadataMigration(async (owner, _resource, values) => { - await (owner.toString() === session.toString() ? localGate.p : otherGate.p); - return values; - }); - const localWrite = store.persistMetadata(session, first, { [SESSION_CUSTOM_TITLE_KEY]: 'Local title' }); - const otherWrite = store.persistMetadata(otherSession, otherSession, { [SESSION_CUSTOM_TITLE_KEY]: 'Other title' }); - let sessionIdle = false; - let allIdle = false; - const sessionDrain = store.whenSessionIdle(session).then(() => { sessionIdle = true; }); - const allDrain = store.whenIdle().then(() => { allIdle = true; }); - try { - await timeout(0); - assert.deepStrictEqual({ sessionIdle, allIdle }, { sessionIdle: false, allIdle: false }); - localGate.complete(); - await localWrite; - await timeout(0); - assert.deepStrictEqual({ sessionIdle, allIdle }, { sessionIdle: true, allIdle: false }); - } finally { - localGate.complete(); - otherGate.complete(); - await Promise.all([localWrite, otherWrite, sessionDrain, allDrain]); - } - assert.strictEqual(allIdle, true); - }); - - test('session idle settles a rejected write without hiding the error from its caller', async () => { - const gate = new DeferredPromise<void>(); - const store = createStore(new TestSessionDatabase()); - store.setMetadataMigration(async () => { - await gate.p; - throw new Error('metadata migration failed'); - }); - const write = assert.rejects(store.persistMetadata(session, first, { [SESSION_CUSTOM_TITLE_KEY]: 'New title' }), /metadata migration failed/); - const allDrain = assert.rejects(store.whenIdle(), /metadata migration failed/); - let sessionIdle = false; - const sessionDrain = store.whenSessionIdle(session).then(() => { sessionIdle = true; }); - await timeout(0); - assert.strictEqual(sessionIdle, false); - gate.complete(); - await Promise.all([write, allDrain, sessionDrain]); - assert.strictEqual(sessionIdle, true); - }); - test('normalized metadata batches title and source without touching legacy backing', async () => { const defaultChat = buildDefaultChatUri(session); await orchestrator.registerChatCatalogV2(session.toString(), { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index d0ea8a1391959e..7991149733f8c1 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -29284,61 +29284,6 @@ suite('AgentService (node dispatcher)', () => { })); } - for (const rename of ['local command', 'client action'] as const) { - test(`idle release preserves a peer rename from a ${rename} whose normalized write is pending`, async () => { - const writeStarted = new DeferredPromise<void>(); - const finishWrite = new DeferredPromise<void>(); - let delayTitleWrite = false; - class GatedTitleDatabase extends AgentHostDatabase { - override async updateChatV2Metadata(...args: Parameters<AgentHostDatabase['updateChatV2Metadata']>) { - if (delayTitleWrite && args[2].metadata?.summary === 'Rehydrated Peer') { - delayTitleWrite = false; - writeStarted.complete(); - await finishWrite.p; - } - return super.updateChatV2Metadata(...args); - } - } - class MultiChatAgent extends MockAgent { - override async createChat(): Promise<IAgentCreateChatResult> { - return { providerData: 'peer-backing' }; - } - } - const database = disposables.add(new GatedTitleDatabase(':memory:')); - const residency = createResidencyTestService(0, 30_000, true, new MultiChatAgent('copilot'), createSessionDataService(), database); - const svc = residency.service; - const session = await createUsedSession(svc, residency.agent); - svc.addSubscriber(session, 'rename-client'); - const peer = URI.parse(buildChatUri(session, 'renamed-peer')); - await svc.createChat(session, peer, { title: 'Persisted Peer' }); - delayTitleWrite = true; - svc.dispatchAction(peer.toString(), rename === 'local command' ? { - type: ActionType.ChatTurnStarted, - turnId: 'local-rename', - startedAt: '2025-01-01T00:00:00.000Z', - message: { text: '/rename Rehydrated Peer', origin: { kind: MessageKind.User } }, - } : { type: ActionType.SessionTitleChanged, title: 'Rehydrated Peer' }, 'rename-client', 1); - try { - await writeStarted.p; - svc.unsubscribe(session, 'rename-client'); - await timeout(0); - assert.deepStrictEqual({ - resident: !!getStateManager(svc).getSessionState(session.toString()), - released: residency.agent.releaseSessionCalls.length, - }, { resident: true, released: 0 }); - } finally { - finishWrite.complete(); - await svc.whenCatalogReconciliationIdle(); - } - await waitForResidency(() => !getStateManager(svc).getSessionState(session.toString()), 'session was not released after persisting its rename'); - await svc.restoreSession(session); - assert.deepStrictEqual({ - title: getStateManager(svc).getSessionState(session.toString())?.chats.find(chat => chat.resource === peer.toString())?.title, - persistedTitle: (await getTestChatPersistenceService(svc).readNormalizedChat(session, peer)).chat?.metadata?.summary, - }, { title: 'Rehydrated Peer', persistedTitle: 'Rehydrated Peer' }); - }); - } - test('deleting an evicted session disposes every persisted peer chat', async () => { class MultiChatAgent extends MockAgent { override async createChat(_session: URI, _chat: URI): Promise<void> { } @@ -29523,7 +29468,6 @@ suite('AgentService (node dispatcher)', () => { getStateManager(localService).dispatchServerAction(defaultChat, { type: ActionType.ChatTurnComplete, turnId: 'initial-turn', duration: 1000 }); getStateManager(localService).addChat(sessionResource.toString(), peerChat.toString(), creationTimeChatStorageResource(sessionResource.toString(), peerChat.toString()), {}); localService.addSubscriber(sessionResource, 'client-1'); - const evicted = Event.toPromise(Event.filter(getStateManager(localService).onDidRemoveSession, session => session === sessionResource.toString()), disposables); localService.unsubscribe(sessionResource, 'client-1'); await whenIdleStarted.p; @@ -29539,7 +29483,7 @@ suite('AgentService (node dispatcher)', () => { { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }, 'client-1', 2, ); - await evicted; + await new Promise(resolve => setTimeout(resolve, 30_000)); assert.strictEqual(getStateManager(localService).getSessionState(sessionResource.toString()), undefined); }); @@ -29813,9 +29757,8 @@ suite('AgentService (node dispatcher)', () => { getStateManager(service).addChat(session.toString(), chat.toString(), creationTimeChatStorageResource(session.toString(), chat.toString()), {}); service.addSubscriber(session, 'client-1'); - const evicted = Event.toPromise(Event.filter(getStateManager(service).onDidRemoveSession, removed => removed === session.toString()), disposables); service.unsubscribe(session, 'client-1'); - await evicted; + await new Promise(resolve => setTimeout(resolve, 30_000)); assert.deepStrictEqual({ chatReleases, diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index b519a647aa92aa..bb23434f02f818 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -264,7 +264,8 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('chat.renameSession', 'To rename the current chat session when supported, invoke the Rename command{0}. Agent Host sessions can be renamed after sending the first request.', `<keybinding:${AGENT_SESSION_RENAME_ACTION_ID}>`)); } if (type === 'panelChat' || type === 'editsView' || type === 'agentView') { - content.push(localize('chat.forkSession', "In a fork-capable Agent Host chat, to create a new forked chat alongside the current chat in the same Agent Host session, type /fork followed by a title. The requested title is sent to the host; older hosts may use their own title. Accepting /fork from slash-command completion creates a bare forked chat immediately. Select the /fork title template to insert /fork, or select /fork prompt to insert /fork prompt; selecting a template does not submit it. The title and prompt hints are suggestions and are not part of your input. Send /fork prompt followed by a prompt to create the fork and submit that prompt in the new chat. An empty prompt creates an ordinary fork. All fork variants require the connected Agent Host to support forking. If the initial prompt fails, the new chat opens with an ordinary error response. If the outcome is unknown, use Inspect Target in the notification before sending the prompt again; the prompt is not automatically restored or resent. To add a title, type or insert the command instead.")); + content.push(localize('chat.forkSession', "In an Agent Host chat that supports forking, type /fork to create a forked chat beside the current one. Add a title after /fork to name it, or type /fork prompt followed by a prompt to also send that prompt in the new chat.")); + content.push(localize('chat.forkSession.completions', "Selecting the /fork title or /fork prompt completion inserts the command without submitting it. The title and prompt hints shown after the command are suggestions and are not part of your input.")); } if (type !== 'inlineChat') { content.push(localize('chat.linkContextMenu', "Focus a link in the chat transcript and press Shift+F10 to open its context menu. Use Copy Link to copy its target. File links with additional editor choices also provide Open With.")); 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 a512ecb154c1d4..4360175c9152db 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -459,7 +459,6 @@ function emptyDraftFromLastTurn(state: ISessionWithDefaultChat): Message | undef if (!message?.model && !message?.agent) { return undefined; } - return { text: '', origin: { kind: MessageKind.User }, @@ -470,10 +469,6 @@ function emptyDraftFromLastTurn(state: ISessionWithDefaultChat): Message | undef type ForkInitialMessageSource = Pick<ChatState, 'activeTurn' | 'turns'>; -interface ICapturedForkInitialMessage { - readonly message: Message; -} - /** The draft the chat channel holds, so we only send real changes and can keep its model. */ export class DraftSyncState { private _synced: Message | undefined; @@ -6058,7 +6053,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC private async _prepareForkInitialMessage( sessionResource: URI, backendSession: URI, - captured: ICapturedForkInitialMessage, + captured: Message, token: CancellationToken, ): Promise<Message> { this._shellInitSynchronizer.reconcile(backendSession); @@ -6071,22 +6066,20 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC throw new CancellationError(); } - return captured.message; + return captured; } private _captureForkInitialMessage( sessionResource: URI, sourceState: ForkInitialMessageSource | undefined, intent: IChatSessionInitialMessage, - ): ICapturedForkInitialMessage { + ): Message { const attachments = this._convertVariablesToAttachments(getChatSessionInitialMessageVariables(intent), sessionResource, intent.text); const sourceMessage = sourceState?.activeTurn?.message ?? sourceState?.turns.at(-1)?.message; return { - message: { - ...requestMessage(intent.text, attachments, undefined, false), - ...(sourceMessage?.model ? { model: sourceMessage.model } : {}), - ...(sourceMessage?.agent ? { agent: sourceMessage.agent } : {}), - }, + ...requestMessage(intent.text, attachments, undefined, false), + ...(sourceMessage?.model ? { model: sourceMessage.model } : {}), + ...(sourceMessage?.agent ? { agent: sourceMessage.agent } : {}), }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts index e63761736362b8..5bf8e62433cb52 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts @@ -605,58 +605,31 @@ suite('Chat Accessibility Help', () => { const getForkHelp = (type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView', isSessionsWindow = false) => { const help = getAccessibilityHelpText(type, keybindingService, true, isSessionsWindow); return { - forkCapableAgentHostChat: help.includes('In a fork-capable Agent Host chat'), - commandWithTitle: help.includes('type /fork followed by a title'), - sameSessionPeerChat: help.includes('new forked chat alongside the current chat in the same Agent Host session'), - optionalTitle: help.includes('The requested title is sent to the host; older hosts may use their own title'), - immediateBareFork: help.includes('Accepting /fork from slash-command completion creates a bare forked chat immediately'), - titleTemplate: help.includes('Select the /fork title template to insert /fork'), - promptTemplate: help.includes('select /fork prompt to insert /fork prompt'), - templateDoesNotSubmit: help.includes('selecting a template does not submit it'), - hintsAreNotInput: help.includes('The title and prompt hints are suggestions and are not part of your input.'), - submitPromptInFork: help.includes('Send /fork prompt followed by a prompt to create the fork and submit that prompt in the new chat'), - emptyPromptFallback: help.includes('An empty prompt creates an ordinary fork'), - sharedForkCapability: help.includes('All fork variants require the connected Agent Host to support forking'), - initialPromptError: help.includes('If the initial prompt fails, the new chat opens with an ordinary error response'), - unknownOutcome: help.includes('use Inspect Target in the notification before sending the prompt again; the prompt is not automatically restored or resent'), - insertToTitle: help.includes('To add a title, type or insert the command instead'), + forkCapableAgentHostChat: help.includes('In an Agent Host chat that supports forking'), + sameSessionPeerChat: help.includes('type /fork to create a forked chat beside the current one'), + optionalTitle: help.includes('Add a title after /fork to name it'), + submitPromptInFork: help.includes('type /fork prompt followed by a prompt to also send that prompt in the new chat'), + templateDoesNotSubmit: help.includes('Selecting the /fork title or /fork prompt completion inserts the command without submitting it.'), + hintsAreNotInput: help.includes('The title and prompt hints shown after the command are suggestions and are not part of your input.'), doesNotDescribeNewSession: !help.includes('new session') && !help.includes('fork an Agent Host session'), }; }; const documented = { forkCapableAgentHostChat: true, - commandWithTitle: true, sameSessionPeerChat: true, optionalTitle: true, - immediateBareFork: true, - titleTemplate: true, - promptTemplate: true, + submitPromptInFork: true, templateDoesNotSubmit: true, hintsAreNotInput: true, - submitPromptInFork: true, - emptyPromptFallback: true, - sharedForkCapability: true, - initialPromptError: true, - unknownOutcome: true, - insertToTitle: true, doesNotDescribeNewSession: true, }; const notDocumented = { forkCapableAgentHostChat: false, - commandWithTitle: false, sameSessionPeerChat: false, optionalTitle: false, - immediateBareFork: false, - titleTemplate: false, - promptTemplate: false, + submitPromptInFork: false, templateDoesNotSubmit: false, hintsAreNotInput: false, - submitPromptInFork: false, - emptyPromptFallback: false, - sharedForkCapability: false, - initialPromptError: false, - unknownOutcome: false, - insertToTitle: false, doesNotDescribeNewSession: true, }; From 19b175d28a59590df24b73cc97572a69bdc0e443 Mon Sep 17 00:00:00 2001 From: Don Jayamanne <don.jayamanne@outlook.com> Date: Wed, 14 Oct 2026 06:28:09 +1100 Subject: [PATCH 11/15] chat: surface the real cause when a prompt fork fails The unknown-outcome notification hid the underlying error, so definite pre-creation failures (for example "Wait for workspace setup to finish") were reported only as an unknown outcome. Include the cause in the message. Inspect Target waited for a chat that may never appear and then returned silently. Report that failure instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/browser/agentHostForkActions.ts | 3 +- .../chat/browser/actions/chatForkActions.ts | 16 ++++-- .../browser/actions/chatForkActions.test.ts | 52 ++++++++++++++++++- 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostForkActions.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostForkActions.ts index 601e3a1b805533..c0c1f78bed0e5f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostForkActions.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostForkActions.ts @@ -6,6 +6,7 @@ import { raceTimeout } from '../../../../../base/common/async.js'; import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; import { registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -106,7 +107,7 @@ registerAction2(class extends ForkConversationAction { if (!appeared) { logService.error(`Forked session ${forkedSessionResource.toString()} did not appear within timeout`); - return; + throw new Error(localize('chat.fork.targetDidNotAppear', "The forked chat did not appear, so it was most likely never created.")); } } if (options?.toSide) { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts index 30425b0e20d23a..546da364e7afbf 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts @@ -331,10 +331,20 @@ export class ForkConversationAction extends Action2 { } if (error instanceof ChatSessionForkError) { await instantiationService.invokeFunction(accessor => { - accessor.get(INotificationService).prompt( + const notificationService = accessor.get(INotificationService); + notificationService.prompt( Severity.Warning, - localize('chat.forkInitialMessageUnknown', "The fork request outcome is unknown. Inspect the target before sending the prompt again."), - [{ label: localize('chat.fork.inspectTarget', "Inspect Target"), run: () => this._openForkedSession(instantiationService, sourceSessionResource, error.target, options) }] + localize('chat.forkInitialMessageUnknown', "The fork request outcome is unknown: {0}. Inspect the target before sending the prompt again.", toErrorMessage(error.cause)), + [{ + label: localize('chat.fork.inspectTarget', "Inspect Target"), + run: async () => { + try { + await this._openForkedSession(instantiationService, sourceSessionResource, error.target, options); + } catch (openError) { + notificationService.error(localize('chat.fork.inspectTargetFailed', "The forked chat could not be opened: {0}.", toErrorMessage(openError))); + } + } + }] ); }); return; diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts index 76e405510b35e2..aac6046f21fcd8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatForkActions.test.ts @@ -168,15 +168,22 @@ suite('ForkConversationAction', () => { const target = source.with({ fragment: 'fork' }); const failure = new ChatSessionForkError(target, new Error('connection lost')); const choices: IPromptChoice[] = []; + const prompts: string[] = []; + const errors: string[] = []; const opens: URI[] = []; let creations = 0; instantiationService.stub(IChatService, upcastPartial<IChatService>({})); instantiationService.stub(IChatWidgetService, upcastPartial<IChatWidgetService>({})); instantiationService.stub(INotificationService, new class extends TestNotificationService { override prompt(...args: Parameters<INotificationService['prompt']>) { + prompts.push(String(args[1])); choices.push(...args[2]); return super.prompt(...args); } + override error(...args: Parameters<TestNotificationService['error']>) { + errors.push(String(args[0])); + return super.error(...args); + } }()); instantiationService.stub(IChatSessionsService, upcastPartial<IChatSessionsService>({ getContentProviderSchemes: () => [source.scheme], @@ -197,14 +204,57 @@ suite('ForkConversationAction', () => { const beforeInspection = [...opens]; await choices[0]?.run(); - assert.deepStrictEqual({ beforeInspection, choices: choices.map(choice => choice.label), creations, opens }, { + assert.deepStrictEqual({ beforeInspection, choices: choices.map(choice => choice.label), creations, opens, prompts, errors }, { beforeInspection: [], choices: ['Inspect Target'], creations: 1, opens: [target], + prompts: ['The fork request outcome is unknown: connection lost. Inspect the target before sending the prompt again.'], + errors: [], }); }); + test('reports a failure to inspect the target instead of silently doing nothing', async () => { + const instantiationService = disposables.add(new TestInstantiationService()); + const source = URI.parse('agent-host-copilot:/source'); + const target = source.with({ fragment: 'fork' }); + const failure = new ChatSessionForkError(target, new Error('connection lost')); + const choices: IPromptChoice[] = []; + const errors: string[] = []; + instantiationService.stub(IChatService, upcastPartial<IChatService>({})); + instantiationService.stub(IChatWidgetService, upcastPartial<IChatWidgetService>({})); + instantiationService.stub(INotificationService, new class extends TestNotificationService { + override prompt(...args: Parameters<INotificationService['prompt']>) { + choices.push(...args[2]); + return super.prompt(...args); + } + override error(...args: Parameters<TestNotificationService['error']>) { + errors.push(String(args[0])); + return super.error(...args); + } + }()); + instantiationService.stub(IChatSessionsService, upcastPartial<IChatSessionsService>({ + getContentProviderSchemes: () => [source.scheme], + forkChatSession: async () => { + throw failure; + }, + })); + const action = new class extends ForkConversationAction { + protected override async _openForkedSession(): Promise<void> { + throw new Error('The forked chat did not appear, so it was most likely never created.'); + } + }; + await assert.rejects( + instantiationService.invokeFunction(accessor => action.run(accessor, source, { initialMessage: { text: 'new prompt' } })), + error => error === failure, + ); + await choices[0]?.run(); + + assert.deepStrictEqual(errors, [ + 'The forked chat could not be opened: The forked chat did not appear, so it was most likely never created..', + ]); + }); + test('trims only the fork slash command title', async () => { let forkCommand: IChatSlashCallback | undefined; let forkData: IChatSlashData | undefined; From 4aeb97e86e41b384db079e675afd78d4cb1f42c0 Mon Sep 17 00:00:00 2001 From: Don Jayamanne <don.jayamanne@outlook.com> Date: Wed, 14 Oct 2026 06:46:34 +1100 Subject: [PATCH 12/15] agentHost: persist only rejections the submitter cannot recover Rejected turns were made durable for every chat so a rejected fork prompt would survive a reload. Ordinary rejections leave the message in the client's composer, so persisting them kept records nothing needs. Follow the resumable precedent on the error variant: the rejected variant now carries recoverable, producers default to recoverable, and only the create-chat initial message opts out. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostChatContributionsService.ts | 8 ++++- .../agentHost/node/agentHostTurnService.ts | 21 ++++++++--- .../agentHost/node/agentHostTurnStarter.ts | 10 ++++-- .../platform/agentHost/node/agentService.ts | 4 ++- .../persistedFailedTurnsContribution.ts | 4 ++- .../agentHost/test/node/agentService.test.ts | 36 +++++++++++++++++++ .../test/node/chatContributions.test.ts | 8 +++-- 7 files changed, 78 insertions(+), 13 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts b/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts index 054320dd7b0eec..8921627d00e3c7 100644 --- a/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts +++ b/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts @@ -26,7 +26,13 @@ export type TurnEndReason = | { readonly kind: 'success' } | { readonly kind: 'cancelled' } | { readonly kind: 'error'; readonly error: ErrorInfo; readonly resumable: boolean } - | { readonly kind: 'rejected'; readonly error: ErrorInfo } + /** + * `recoverable` reports whether the submitter still holds the message, as an + * ordinary client does in its composer draft. Only an unrecoverable + * rejection needs to outlive the session in state, because nothing else + * retains what the user asked for. + */ + | { readonly kind: 'rejected'; readonly error: ErrorInfo; readonly recoverable: boolean } | { readonly kind: 'localCommand' }; /** A terminal turn outcome offered to contributions after a turn ended. */ diff --git a/src/vs/platform/agentHost/node/agentHostTurnService.ts b/src/vs/platform/agentHost/node/agentHostTurnService.ts index 7a924cc931e8c9..8de3f6baa10394 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnService.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnService.ts @@ -28,11 +28,21 @@ export interface IDeferredAgentHostTurn { export interface IAgentHostTurnService { readonly _serviceBrand: undefined; startTurnMessage(chat: URI, message: Message): void; - startClientTurn(chat: URI, action: ChatTurnStartedAction, clientId: string, clientContext: IAgentHostClientTelemetryContext): ErrorInfo | undefined; + /** + * @param options.recoverableRejection `false` when the submitting client has + * already released this message, so an admission rejection is the only + * surviving record of it. + */ + startClientTurn(chat: URI, action: ChatTurnStartedAction, clientId: string, clientContext: IAgentHostClientTelemetryContext, options?: IClientTurnOptions): ErrorInfo | undefined; beginDeferredTurnMessage(chat: URI, message: Message): IDeferredAgentHostTurn; continueDeferredTurnMessage(chat: URI, turn: IDeferredAgentHostTurn, message: Message): boolean; failDeferredTurnMessage(chat: URI, turn: IDeferredAgentHostTurn, error: ErrorInfo): boolean; - handleTurnStarted(channel: ProtocolURI, action: ChatTurnStartedAction, clientId?: string, clientContextOrType?: IAgentHostClientTelemetryContext | AgentHostClientType): ErrorInfo | undefined; + handleTurnStarted(channel: ProtocolURI, action: ChatTurnStartedAction, clientId?: string, clientContextOrType?: IAgentHostClientTelemetryContext | AgentHostClientType, options?: IClientTurnOptions): ErrorInfo | undefined; +} + +/** Admission details a caller may override when it does not match the ordinary client send. */ +export interface IClientTurnOptions { + readonly recoverableRejection?: boolean; } /** Standard turn admission and provider routing shared by client- and host-authored turns. */ @@ -55,9 +65,9 @@ export class AgentHostTurnService implements IAgentHostTurnService { this.handleTurnStarted(channel, action); } - startClientTurn(chat: URI, action: ChatTurnStartedAction, clientId: string, clientContext: IAgentHostClientTelemetryContext): ErrorInfo | undefined { + startClientTurn(chat: URI, action: ChatTurnStartedAction, clientId: string, clientContext: IAgentHostClientTelemetryContext, options?: IClientTurnOptions): ErrorInfo | undefined { this._stateManager.dispatchServerAction(chat.toString(), action); - return this.handleTurnStarted(chat.toString(), action, clientId, clientContext); + return this.handleTurnStarted(chat.toString(), action, clientId, clientContext, options); } beginDeferredTurnMessage(chat: URI, message: Message): IDeferredAgentHostTurn { @@ -111,7 +121,7 @@ export class AgentHostTurnService implements IAgentHostTurnService { return true; } - handleTurnStarted(channel: ProtocolURI, action: ChatTurnStartedAction, clientId?: string, clientContextOrType?: IAgentHostClientTelemetryContext | AgentHostClientType): ErrorInfo | undefined { + handleTurnStarted(channel: ProtocolURI, action: ChatTurnStartedAction, clientId?: string, clientContextOrType?: IAgentHostClientTelemetryContext | AgentHostClientType, options?: IClientTurnOptions): ErrorInfo | undefined { const host = this._chatContributions.getHost(); if (!host) { throw new Error('Agent Host turn routing is unavailable.'); @@ -137,6 +147,7 @@ export class AgentHostTurnService implements IAgentHostTurnService { clientId, clientContext, turnStopWatch, + ...(options?.recoverableRejection !== undefined ? { recoverableRejection: options.recoverableRejection } : {}), onRejected: error => rejection = error, }); if (!started) { diff --git a/src/vs/platform/agentHost/node/agentHostTurnStarter.ts b/src/vs/platform/agentHost/node/agentHostTurnStarter.ts index a175cc21d46150..c0b8f5c10cff3b 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnStarter.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnStarter.ts @@ -35,6 +35,12 @@ export interface ITurnStartRequest { readonly clientId: string | undefined; readonly clientContext: IAgentHostClientTelemetryContext; readonly turnStopWatch: StopWatch; + /** + * Whether the submitter still holds this message if admission rejects it. + * Clients keep their composer draft, so this defaults to `true`; the host + * must pass `false` when it submits a message the client has already let go. + */ + readonly recoverableRejection?: boolean; /** Receives a definite admission rejection without changing queued callers' result contract. */ readonly onRejected?: (error: ErrorInfo) => void; } @@ -87,7 +93,7 @@ export function startTurn(accessor: ServicesAccessor, request: ITurnStartRequest session: request.session, channel: request.turnChannel, turnId: request.turnId, - reason: { kind: 'rejected', error: disposition.error }, + reason: { kind: 'rejected', error: disposition.error, recoverable: request.recoverableRejection !== false }, clientContext: request.clientContext, }); request.onRejected?.(disposition.error); @@ -113,7 +119,7 @@ export function startTurn(accessor: ServicesAccessor, request: ITurnStartRequest session: request.session, channel: request.turnChannel, turnId: request.turnId, - reason: { kind: 'rejected', error }, + reason: { kind: 'rejected', error, recoverable: request.recoverableRejection !== false }, clientContext: request.clientContext, }); request.onRejected?.(error); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index b78a16fd5ca385..588cefdd8f9ab0 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -5852,7 +5852,9 @@ export class AgentService extends Disposable implements IAgentService { } return; } - this._turnService.startClientTurn(chat, rewritten, context.clientId, context.telemetryContext); + // The submitting client cleared its composer once `createChat` resolved, + // so a rejection here is the only surviving record of the prompt. + this._turnService.startClientTurn(chat, rewritten, context.clientId, context.telemetryContext, { recoverableRejection: false }); } /** diff --git a/src/vs/platform/agentHost/node/chatContributions/persistedFailedTurns/persistedFailedTurnsContribution.ts b/src/vs/platform/agentHost/node/chatContributions/persistedFailedTurns/persistedFailedTurnsContribution.ts index aa3533c44bd237..3c24833601d8ae 100644 --- a/src/vs/platform/agentHost/node/chatContributions/persistedFailedTurns/persistedFailedTurnsContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/persistedFailedTurns/persistedFailedTurnsContribution.ts @@ -48,7 +48,9 @@ export class PersistedFailedTurnsContribution extends Disposable implements IAge } return; } - if (ended.reason.kind !== 'rejected' && (ended.reason.kind !== 'error' || ended.reason.resumable)) { + if (ended.reason.kind === 'rejected' + ? ended.reason.recoverable + : (ended.reason.kind !== 'error' || ended.reason.resumable)) { return; } diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 1a024f905249fa..169a09aaf791cf 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -9380,6 +9380,42 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('an ordinary rejected turn stays in the session it was refused in', async () => { + const sessionDatabase = new TestSessionDatabase(); + const sessionDataService = createSessionDataService(sessionDatabase); + const orchestratorDatabase = new TestAgentHostOrchestratorDatabase(); + const first = createCentralCatalogService(sessionDataService, orchestratorDatabase); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(first, agent); + const session = await first.createSession({ provider: 'copilot' }); + const chat = buildDefaultChatUri(session.toString()); + first.addSubscriber(session, 'client'); + getStateManager(first).dispatchServerAction(session.toString(), { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + first.dispatchAction(chat, { + type: ActionType.ChatTurnStarted, + turnId: 'rejected-turn', + startedAt: '2026-09-29T17:00:00.000Z', + message: { text: 'refused prompt', origin: { kind: MessageKind.User } }, + }, 'client', 1, AgentHostClientType.EditorWindow); + await waitForCondition( + () => getStateManager(first).getChatState(chat)?.turns[0]?.responseParts.some(part => part.kind === ResponsePartKind.Error) === true, + 'the turn should be rejected before the service is restarted', + ); + first.dispose(); + + const restarted = createCentralCatalogService(sessionDataService, orchestratorDatabase); + registerTestAgentProvider(restarted, agent); + await restarted.restoreSession(session); + + assert.deepStrictEqual({ + persisted: (await sessionDatabase.getPersistedTurns()).map(record => record.kind), + restoredTurns: getStateManager(restarted).getChatState(chat)?.turns.map(turn => turn.message.text), + }, { + persisted: [], + restoredTurns: [], + }); + }); + function getStartupTestInternals(service: AgentService) { return service as unknown as { _migrateLegacyEnabledSnapshot: boolean | undefined; diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index 36f3c618f9afc7..2d7c61b97734b9 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -1281,7 +1281,7 @@ suite('AgentHostChatContributions', () => { contributions.turnEnd({ session, channel: firstPeer, turnId: 'first', reason: { kind: 'success' } }); contributions.turnEnd({ session, channel: secondPeer, turnId: 'second', reason: { kind: 'success' } }); contributions.turnEnd({ session, channel: buildDefaultChatUri(session), turnId: 'default', reason: { kind: 'success' } }); - contributions.turnEnd({ session, channel: firstPeer, turnId: undefined, reason: { kind: 'rejected', error: { errorType: 'requestFailed', message: 'rejected' } } }); + contributions.turnEnd({ session, channel: firstPeer, turnId: undefined, reason: { kind: 'rejected', error: { errorType: 'requestFailed', message: 'rejected' }, recoverable: true } }); assert.deepStrictEqual({ chatReadChanges, @@ -1951,7 +1951,7 @@ suite('AgentHostChatContributions', () => { { kind: 'cancelled' }, { kind: 'error', error, resumable: false }, { kind: 'error', error, resumable: true }, - { kind: 'rejected', error }, + { kind: 'rejected', error, recoverable: true }, { kind: 'localCommand' }, ] satisfies ITurnEnd['reason'][]) { contributions.service.turnEnd({ ...turnEnd('root-refresh', reason), channel: `agent-host-session://${reason.kind}-${reason.kind === 'error' ? reason.resumable : false}` }); @@ -2151,6 +2151,7 @@ suite('AgentHostChatContributions', () => { contributions.service.turnEnd(turnEnd('rejected', { kind: 'rejected', error: { errorType: 'noAgent', message: 'No agent found for session' }, + recoverable: true, })); const rejectedReadChanges = [...readChanges]; contributions.service.turnEnd(turnEnd('error', { @@ -2174,6 +2175,7 @@ suite('AgentHostChatContributions', () => { contributions.service.turnEnd(turnEnd('rejected', { kind: 'rejected', error: { errorType: 'noAgent', message: 'No agent found for session' }, + recoverable: true, })); assert.deepStrictEqual({ @@ -2414,7 +2416,7 @@ suite('AgentHostChatContributions', () => { titles.service.turnEnd({ ...turn, reason: { kind: 'cancelled' } }); titles.service.turnEnd({ ...turn, reason: { kind: 'localCommand' } }); titles.service.turnEnd({ ...turn, reason: { kind: 'error', error: { errorType: 'test', message: 'Failed' }, resumable: false } }); - titles.service.turnEnd({ ...turn, reason: { kind: 'rejected', error: { errorType: 'test', message: 'Rejected' } } }); + titles.service.turnEnd({ ...turn, reason: { kind: 'rejected', error: { errorType: 'test', message: 'Rejected' }, recoverable: true } }); titles.service.turnEnd({ ...turn, reason: { kind: 'success' } }); assert.deepStrictEqual(titles.titleController.refinedTitles, [false, false, false, true].map(successful => ({ channel: titles.session, chatChannel: titles.defaultChat, successful, From b7e3b6277c772e43a0d0a952570f3bad626d1e1d Mon Sep 17 00:00:00 2001 From: Don Jayamanne <don.jayamanne@outlook.com> Date: Wed, 14 Oct 2026 07:25:42 +1100 Subject: [PATCH 13/15] Revert "agentHost: drop the session metadata drain" Restores 29e29e26345. CI proves the drain is load-bearing for this branch: runs 508c2b9695a and 30baf6d8c85 (no drain) failed `peer chat catalog and transcript survive a host restart`, 29e29e26345 (drain) was fully green, and 4aeb97e86e4 (drain reverted) failed again on Linux and Windows. The same test passes on contemporaneous unrelated pull requests. The underlying race is pre-existing and remains tracked separately, but this branch triggers it reliably, so the ordering fix stays here. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentHostPeerChatStore.ts | 42 ++++++++++--- .../platform/agentHost/node/agentService.ts | 1 + .../test/node/agentHostPeerChatStore.test.ts | 49 +++++++++++++++ .../agentHost/test/node/agentService.test.ts | 61 ++++++++++++++++++- 4 files changed, 141 insertions(+), 12 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts index a54bf19a4db31e..e3308778b3f6ec 100644 --- a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts +++ b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts @@ -61,7 +61,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ declare readonly _serviceBrand: undefined; private readonly _writes = new Map<string, Promise<void>>(); - private readonly _metadataWrites = new Set<Promise<void>>(); + private readonly _metadataWrites = new Map<Promise<void>, string>(); private readonly _deletingSessions = new Map<string, number>(); private _metadataMigration: ((session: URI, resource: URI, values: Readonly<Record<string, string>>) => Promise<Readonly<Record<string, string>>>) | undefined; @@ -100,15 +100,37 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ return { mutation: target ? { chat: target.chat, expected: { ownershipRevision: 0, metadataRevision: 0 }, patch } : undefined, remaining }; } + /** Drains all writes and propagates failures to callers synchronizing catalog state. */ async whenIdle(): Promise<void> { while (this._writes.size || this._metadataWrites.size) { - await Promise.all([...this._writes.values(), ...this._metadataWrites]); + await Promise.all([...this._writes.values(), ...this._metadataWrites.keys()]); } } - private _trackMetadataWrite(operation: () => Promise<void>): Promise<void> { + /** Waits for one session's writes to settle before release; write callers report failures. */ + async whenSessionIdle(session: URI): Promise<void> { + const sessionKey = session.toString(); + while (true) { + const writes: Promise<void>[] = []; + const queuedWrite = this._writes.get(sessionKey); + if (queuedWrite) { + writes.push(queuedWrite); + } + for (const [write, key] of this._metadataWrites) { + if (key === sessionKey) { + writes.push(write); + } + } + if (writes.length === 0) { + return; + } + await Promise.allSettled(writes); + } + } + + private _trackMetadataWrite(session: URI, operation: () => Promise<void>): Promise<void> { const tracked = operation().finally(() => this._metadataWrites.delete(tracked)); - this._metadataWrites.add(tracked); + this._metadataWrites.set(tracked, session.toString()); return tracked; } @@ -197,11 +219,11 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ } persistMetadata(session: URI, resource: URI, values: Readonly<Record<string, string>>): Promise<void> { - return this._trackMetadataWrite(() => this._persistMetadata(session, resource, values)); + return this._trackMetadataWrite(session, () => this._persistMetadata(session, resource, values)); } persistDefaultChatTitleSnapshot(session: URI, chat: URI, title: string): Promise<void> { - return this._trackMetadataWrite(() => this._enqueue(session, async () => { + return this._trackMetadataWrite(session, () => this._enqueue(session, async () => { while (true) { const current = await this.readNormalizedChat(session, chat); if (!current.normalized) { @@ -581,7 +603,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ }); return next; }; - return this._trackMetadataWrite(async () => { + return this._trackMetadataWrite(session, async () => { const values = { [CHAT_PROVIDER_DATA_METADATA_KEY]: providerData ?? '', ...(origin !== undefined ? { [CHAT_ORIGIN_METADATA_KEY]: JSON.stringify(origin) } : {}), @@ -623,7 +645,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ }); return next; }; - return this._trackMetadataWrite(async () => { + return this._trackMetadataWrite(session, async () => { const values = { [CHAT_WORKING_DIRECTORIES_METADATA_KEY]: JSON.stringify(workingDirectories) }; if (this._metadataMigration && !Object.keys(await this._metadataMigration(session, chat, values)).length) { return; @@ -637,7 +659,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ } setArchived(session: URI, chat: URI, archived: boolean): Promise<void> { - return this._trackMetadataWrite(() => this._setArchived(session, chat, archived)); + return this._trackMetadataWrite(session, () => this._setArchived(session, chat, archived)); } private async _setArchived(session: URI, chat: URI, archived: boolean): Promise<void> { @@ -658,7 +680,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ } setRead(session: URI, chat: URI, isRead: boolean): Promise<void> { - return this._trackMetadataWrite(() => this._setRead(session, chat, isRead)); + return this._trackMetadataWrite(session, () => this._setRead(session, chat, isRead)); } private async _setRead(session: URI, chat: URI, isRead: boolean): Promise<void> { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 588cefdd8f9ab0..a209232a994ad7 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -7132,6 +7132,7 @@ export class AgentService extends Disposable implements IAgentService { } private async _whenSessionDataIdle(session: URI): Promise<void> { + await this._peerChatStore.whenSessionIdle(session); const ref = this._sessionDataService.openDatabase(session); try { await ref.object.whenIdle(); diff --git a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts index c6676ab3b52a7f..82b712b780fa0b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts @@ -5,6 +5,7 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; +import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; @@ -95,6 +96,54 @@ suite('AgentHostPeerChatStore', () => { return new AgentHostPeerChatStore(orchestrator, createSessionDataService(database), logService, createLiveChatOwnership(() => undefined, () => undefined)); } + test('session idle waits for metadata migration without waiting for another session', async () => { + const localGate = new DeferredPromise<void>(); + const otherGate = new DeferredPromise<void>(); + const otherSession = URI.parse('agenthost:other-peer-store'); + const store = createStore(new TestSessionDatabase()); + store.setMetadataMigration(async (owner, _resource, values) => { + await (owner.toString() === session.toString() ? localGate.p : otherGate.p); + return values; + }); + const localWrite = store.persistMetadata(session, first, { [SESSION_CUSTOM_TITLE_KEY]: 'Local title' }); + const otherWrite = store.persistMetadata(otherSession, otherSession, { [SESSION_CUSTOM_TITLE_KEY]: 'Other title' }); + let sessionIdle = false; + let allIdle = false; + const sessionDrain = store.whenSessionIdle(session).then(() => { sessionIdle = true; }); + const allDrain = store.whenIdle().then(() => { allIdle = true; }); + try { + await timeout(0); + assert.deepStrictEqual({ sessionIdle, allIdle }, { sessionIdle: false, allIdle: false }); + localGate.complete(); + await localWrite; + await timeout(0); + assert.deepStrictEqual({ sessionIdle, allIdle }, { sessionIdle: true, allIdle: false }); + } finally { + localGate.complete(); + otherGate.complete(); + await Promise.all([localWrite, otherWrite, sessionDrain, allDrain]); + } + assert.strictEqual(allIdle, true); + }); + + test('session idle settles a rejected write without hiding the error from its caller', async () => { + const gate = new DeferredPromise<void>(); + const store = createStore(new TestSessionDatabase()); + store.setMetadataMigration(async () => { + await gate.p; + throw new Error('metadata migration failed'); + }); + const write = assert.rejects(store.persistMetadata(session, first, { [SESSION_CUSTOM_TITLE_KEY]: 'New title' }), /metadata migration failed/); + const allDrain = assert.rejects(store.whenIdle(), /metadata migration failed/); + let sessionIdle = false; + const sessionDrain = store.whenSessionIdle(session).then(() => { sessionIdle = true; }); + await timeout(0); + assert.strictEqual(sessionIdle, false); + gate.complete(); + await Promise.all([write, allDrain, sessionDrain]); + assert.strictEqual(sessionIdle, true); + }); + test('normalized metadata batches title and source without touching legacy backing', async () => { const defaultChat = buildDefaultChatUri(session); await orchestrator.registerChatCatalogV2(session.toString(), { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 169a09aaf791cf..3b25cbb9f8e109 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -29529,6 +29529,61 @@ suite('AgentService (node dispatcher)', () => { })); } + for (const rename of ['local command', 'client action'] as const) { + test(`idle release preserves a peer rename from a ${rename} whose normalized write is pending`, async () => { + const writeStarted = new DeferredPromise<void>(); + const finishWrite = new DeferredPromise<void>(); + let delayTitleWrite = false; + class GatedTitleDatabase extends AgentHostDatabase { + override async updateChatV2Metadata(...args: Parameters<AgentHostDatabase['updateChatV2Metadata']>) { + if (delayTitleWrite && args[2].metadata?.summary === 'Rehydrated Peer') { + delayTitleWrite = false; + writeStarted.complete(); + await finishWrite.p; + } + return super.updateChatV2Metadata(...args); + } + } + class MultiChatAgent extends MockAgent { + override async createChat(): Promise<IAgentCreateChatResult> { + return { providerData: 'peer-backing' }; + } + } + const database = disposables.add(new GatedTitleDatabase(':memory:')); + const residency = createResidencyTestService(0, 30_000, true, new MultiChatAgent('copilot'), createSessionDataService(), database); + const svc = residency.service; + const session = await createUsedSession(svc, residency.agent); + svc.addSubscriber(session, 'rename-client'); + const peer = URI.parse(buildChatUri(session, 'renamed-peer')); + await svc.createChat(session, peer, { title: 'Persisted Peer' }); + delayTitleWrite = true; + svc.dispatchAction(peer.toString(), rename === 'local command' ? { + type: ActionType.ChatTurnStarted, + turnId: 'local-rename', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: '/rename Rehydrated Peer', origin: { kind: MessageKind.User } }, + } : { type: ActionType.SessionTitleChanged, title: 'Rehydrated Peer' }, 'rename-client', 1); + try { + await writeStarted.p; + svc.unsubscribe(session, 'rename-client'); + await timeout(0); + assert.deepStrictEqual({ + resident: !!getStateManager(svc).getSessionState(session.toString()), + released: residency.agent.releaseSessionCalls.length, + }, { resident: true, released: 0 }); + } finally { + finishWrite.complete(); + await svc.whenCatalogReconciliationIdle(); + } + await waitForResidency(() => !getStateManager(svc).getSessionState(session.toString()), 'session was not released after persisting its rename'); + await svc.restoreSession(session); + assert.deepStrictEqual({ + title: getStateManager(svc).getSessionState(session.toString())?.chats.find(chat => chat.resource === peer.toString())?.title, + persistedTitle: (await getTestChatPersistenceService(svc).readNormalizedChat(session, peer)).chat?.metadata?.summary, + }, { title: 'Rehydrated Peer', persistedTitle: 'Rehydrated Peer' }); + }); + } + test('deleting an evicted session disposes every persisted peer chat', async () => { class MultiChatAgent extends MockAgent { override async createChat(_session: URI, _chat: URI): Promise<void> { } @@ -29713,6 +29768,7 @@ suite('AgentService (node dispatcher)', () => { getStateManager(localService).dispatchServerAction(defaultChat, { type: ActionType.ChatTurnComplete, turnId: 'initial-turn', duration: 1000 }); getStateManager(localService).addChat(sessionResource.toString(), peerChat.toString(), creationTimeChatStorageResource(sessionResource.toString(), peerChat.toString(), buildDefaultChatUri(sessionResource.toString())), {}); localService.addSubscriber(sessionResource, 'client-1'); + const evicted = Event.toPromise(Event.filter(getStateManager(localService).onDidRemoveSession, session => session === sessionResource.toString()), disposables); localService.unsubscribe(sessionResource, 'client-1'); await whenIdleStarted.p; @@ -29728,7 +29784,7 @@ suite('AgentService (node dispatcher)', () => { { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }, 'client-1', 2, ); - await new Promise(resolve => setTimeout(resolve, 30_000)); + await evicted; assert.strictEqual(getStateManager(localService).getSessionState(sessionResource.toString()), undefined); }); @@ -30002,8 +30058,9 @@ suite('AgentService (node dispatcher)', () => { getStateManager(service).addChat(session.toString(), chat.toString(), creationTimeChatStorageResource(session.toString(), chat.toString(), buildDefaultChatUri(session.toString())), {}); service.addSubscriber(session, 'client-1'); + const evicted = Event.toPromise(Event.filter(getStateManager(service).onDidRemoveSession, removed => removed === session.toString()), disposables); service.unsubscribe(session, 'client-1'); - await new Promise(resolve => setTimeout(resolve, 30_000)); + await evicted; assert.deepStrictEqual({ chatReleases, From b2840e57181f61f7a4cea2e3239aee9fae9a1e2a Mon Sep 17 00:00:00 2001 From: Don Jayamanne <don.jayamanne@outlook.com> Date: Wed, 14 Oct 2026 18:27:19 +1100 Subject: [PATCH 14/15] agentHost: drain session catalog writes before idle release Keep legacy chat restoration from reading an older catalog while an accepted rename is still being synchronized. Drain the session's catalog and passive metadata writes before releasing it, and scope their rejecting peer-write barriers to that session. Cover compatibility-storage renames and scoped write errors. Keep the existing restart assertion unchanged and synchronize the ordered-catalog test on its actual summary notification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentHostPeerChatStore.ts | 41 +++-- .../platform/agentHost/node/agentService.ts | 5 +- .../test/node/agentHostPeerChatStore.test.ts | 9 +- .../agentHost/test/node/agentService.test.ts | 166 +++++++++++++++++- 4 files changed, 198 insertions(+), 23 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts index e3308778b3f6ec..4f9377db91061a 100644 --- a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts +++ b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts @@ -100,10 +100,15 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ return { mutation: target ? { chat: target.chat, expected: { ownershipRevision: 0, metadataRevision: 0 }, patch } : undefined, remaining }; } - /** Drains all writes and propagates failures to callers synchronizing catalog state. */ - async whenIdle(): Promise<void> { - while (this._writes.size || this._metadataWrites.size) { - await Promise.all([...this._writes.values(), ...this._metadataWrites.keys()]); + /** Drains all writes, or one session's writes, and propagates failures to callers synchronizing catalog state. */ + async whenIdle(session?: URI): Promise<void> { + const sessionKey = session?.toString(); + while (true) { + const writes = this._pendingWrites(sessionKey); + if (writes.length === 0) { + return; + } + await Promise.all(writes); } } @@ -111,16 +116,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ async whenSessionIdle(session: URI): Promise<void> { const sessionKey = session.toString(); while (true) { - const writes: Promise<void>[] = []; - const queuedWrite = this._writes.get(sessionKey); - if (queuedWrite) { - writes.push(queuedWrite); - } - for (const [write, key] of this._metadataWrites) { - if (key === sessionKey) { - writes.push(write); - } - } + const writes = this._pendingWrites(sessionKey); if (writes.length === 0) { return; } @@ -128,6 +124,23 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ } } + private _pendingWrites(sessionKey?: string): Promise<void>[] { + if (sessionKey === undefined) { + return [...this._writes.values(), ...this._metadataWrites.keys()]; + } + const writes: Promise<void>[] = []; + const queuedWrite = this._writes.get(sessionKey); + if (queuedWrite) { + writes.push(queuedWrite); + } + for (const [write, key] of this._metadataWrites) { + if (key === sessionKey) { + writes.push(write); + } + } + return writes; + } + private _trackMetadataWrite(session: URI, operation: () => Promise<void>): Promise<void> { const tracked = operation().finally(() => this._metadataWrites.delete(tracked)); this._metadataWrites.set(tracked, session.toString()); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index a209232a994ad7..8b6cbf2fb554ce 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -2548,7 +2548,7 @@ export class AgentService extends Disposable implements IAgentService { await this._peerChatStore.setRead(session, URI.parse(readable[0].resource), readValue === 'true'); } } - await this._peerChatStore.whenIdle(); + await this._peerChatStore.whenIdle(session); const result = await this._catalogSyncService.synchronizeWithFactory(session, async database => { const [snapshot] = await this._orchestratorDatabase.readCatalogSnapshot([sessionKey]); const normalized = snapshot?.authorityVersion === 2 ? snapshot : undefined; @@ -7133,6 +7133,7 @@ export class AgentService extends Disposable implements IAgentService { private async _whenSessionDataIdle(session: URI): Promise<void> { await this._peerChatStore.whenSessionIdle(session); + await this._whenBackgroundCatalogStateWritesIdle(session.toString()); const ref = this._sessionDataService.openDatabase(session); try { await ref.object.whenIdle(); @@ -7784,7 +7785,7 @@ export class AgentService extends Disposable implements IAgentService { private async _synchronizePassiveSessionMetadata(session: URI, updates: readonly IPassiveSessionMetadataUpdate[]): Promise<void> { let requestUnavailable = false; try { - await this._peerChatStore.whenIdle(); + await this._peerChatStore.whenIdle(session); const result = await this._catalogSyncService.synchronizeWithFactory(session, async database => { const sessionKey = session.toString(); const catalog = await this._orchestratorDatabase.getSessionV2(sessionKey); diff --git a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts index 82b712b780fa0b..4e6875df3891c7 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts @@ -96,7 +96,7 @@ suite('AgentHostPeerChatStore', () => { return new AgentHostPeerChatStore(orchestrator, createSessionDataService(database), logService, createLiveChatOwnership(() => undefined, () => undefined)); } - test('session idle waits for metadata migration without waiting for another session', async () => { + test('session catalog idle waits for local metadata migration without waiting for another session', async () => { const localGate = new DeferredPromise<void>(); const otherGate = new DeferredPromise<void>(); const otherSession = URI.parse('agenthost:other-peer-store'); @@ -109,7 +109,7 @@ suite('AgentHostPeerChatStore', () => { const otherWrite = store.persistMetadata(otherSession, otherSession, { [SESSION_CUSTOM_TITLE_KEY]: 'Other title' }); let sessionIdle = false; let allIdle = false; - const sessionDrain = store.whenSessionIdle(session).then(() => { sessionIdle = true; }); + const sessionDrain = store.whenIdle(session).then(() => { sessionIdle = true; }); const allDrain = store.whenIdle().then(() => { allIdle = true; }); try { await timeout(0); @@ -126,7 +126,7 @@ suite('AgentHostPeerChatStore', () => { assert.strictEqual(allIdle, true); }); - test('session idle settles a rejected write without hiding the error from its caller', async () => { + test('session catalog idle propagates rejected writes while session data idle settles', async () => { const gate = new DeferredPromise<void>(); const store = createStore(new TestSessionDatabase()); store.setMetadataMigration(async () => { @@ -134,13 +134,14 @@ suite('AgentHostPeerChatStore', () => { throw new Error('metadata migration failed'); }); const write = assert.rejects(store.persistMetadata(session, first, { [SESSION_CUSTOM_TITLE_KEY]: 'New title' }), /metadata migration failed/); + const sessionCatalogDrain = assert.rejects(store.whenIdle(session), /metadata migration failed/); const allDrain = assert.rejects(store.whenIdle(), /metadata migration failed/); let sessionIdle = false; const sessionDrain = store.whenSessionIdle(session).then(() => { sessionIdle = true; }); await timeout(0); assert.strictEqual(sessionIdle, false); gate.complete(); - await Promise.all([write, allDrain, sessionDrain]); + await Promise.all([write, sessionCatalogDrain, allDrain, sessionDrain]); assert.strictEqual(sessionIdle, true); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 3b25cbb9f8e109..2efaf612dac9bb 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -70,6 +70,7 @@ import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; import { buildNonPtyShellTerminalUri } from '../../common/nonPtyShellTerminalUri.js'; import { AgentHostDatabase, AgentHostDatabaseSessionChatCatalogReplaceResult, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionChat, IAgentHostDatabaseSessionChatCatalog, IAgentHostDatabaseSessionListCatalog, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionsV2ExclusionExpectation, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Envelope, IAgentHostDatabaseSessionV2Receipt } from '../../node/agentHostDatabase.js'; +import { CompatibleAgentHostDatabase } from '../../node/compatibleAgentHostDatabase.js'; import { CHAT_INHERITED_TURN_METADATA_KEY, CHAT_ORIGIN_METADATA_KEY, CHAT_PROVIDER_DATA_METADATA_KEY, CHAT_WORKING_DIRECTORIES_METADATA_KEY, type IPersistedPeerChat } from '../../node/agentHostPeerChatStore.js'; import { AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT, AGENT_HOST_CATALOG_PAYLOAD_VERSION, AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT, decodeAgentHostCatalogPayload, encodeAgentHostCatalogPayload, type AgentHostCatalogData } from '../../node/agentHostCatalogProjection.js'; import type { IAgentHostStorageService } from '../../node/agentHostStorageService.js'; @@ -163,14 +164,14 @@ function discoveredChat(session: URI, external = true, modifiedTime = Date.now() }; } -function createPerSessionDataService(): { readonly service: ISessionDataService; readonly database: (session: URI) => TestSessionDatabase; readonly databaseOpens: string[]; readonly databaseIds: () => readonly string[] } { +function createPerSessionDataService(createDatabase: () => TestSessionDatabase = () => new TestSessionDatabase()): { readonly service: ISessionDataService; readonly database: (session: URI) => TestSessionDatabase; readonly databaseOpens: string[]; readonly databaseIds: () => readonly string[] } { const databases = new Map<string, TestSessionDatabase>(); const databaseOpens: string[] = []; const database = (session: URI): TestSessionDatabase => { const key = session.toString(); let result = databases.get(key); if (!result) { - result = new TestSessionDatabase(); + result = createDatabase(); databases.set(key, result); } return result; @@ -8814,6 +8815,7 @@ suite('AgentService (node dispatcher)', () => { )); registerTestAgentProvider(svc, copilotAgent); const session = await svc.createSession({ provider: 'copilot' }); + svc.addSubscriber(session, 'ordered-catalog-client'); await svc.whenCatalogReconciliationIdle(); const sessionKey = session.toString(); const initialApplied = catalogDatabase.applied.length; @@ -8843,9 +8845,18 @@ suite('AgentService (node dispatcher)', () => { link: 'https://example.com', }]), }); + const newerSummary = Event.toPromise(Event.filter( + getStateManager(svc).onDidChangeSessionSummary, + event => event.session === sessionKey && event.changes.title === 'Newer title' + && event.changes.status !== undefined && (event.changes.status & SessionStatus.IsArchived) !== 0, + ), disposables); getStateManager(svc).dispatchServerAction(sessionKey, { type: ActionType.SessionTitleChanged, title: 'Newer title' }); getStateManager(svc).dispatchServerAction(sessionKey, { type: ActionType.SessionIsArchivedChanged, isArchived: true }); - releaseBlocker.complete(); + try { + await newerSummary; + } finally { + releaseBlocker.complete(); + } await Promise.all([blocker, ordered]); await svc.whenCatalogReconciliationIdle(); @@ -29584,6 +29595,155 @@ suite('AgentService (node dispatcher)', () => { }); } + for (const rename of ['local command', 'client action'] as const) { + test(`idle release waits for a legacy peer rename catalog write from a ${rename}`, async () => { + const renamedTitle = 'Rehydrated Peer'; + type GatedCatalogWrite = { + readonly started: DeferredPromise<void>; + readonly release: DeferredPromise<void>; + readonly peerTitle: DeferredPromise<string | undefined>; + }; + class TrackingSessionDatabase extends TestSessionDatabase { + private _trackedTitle: { readonly key: string; readonly value: string; readonly persisted: DeferredPromise<void> } | undefined; + + override async setMetadata(key: string, value: string): Promise<void> { + await super.setMetadata(key, value); + if (this._trackedTitle?.key === key && this._trackedTitle.value === value) { + this._trackedTitle.persisted.complete(); + } + } + + waitForMetadata(key: string, value: string): Promise<void> { + const persisted = new DeferredPromise<void>(); + this._trackedTitle = { key, value, persisted }; + return persisted.p; + } + } + class GatedCompatibleDatabase extends CompatibleAgentHostDatabase { + private _nextSessionCatalogWrite: GatedCatalogWrite | undefined; + + blockNextSessionCatalogWrite(): GatedCatalogWrite { + const gate = { + started: new DeferredPromise<void>(), + release: new DeferredPromise<void>(), + peerTitle: new DeferredPromise<string | undefined>(), + }; + this._nextSessionCatalogWrite = gate; + return gate; + } + + override async upsertSessionV2(...args: Parameters<IAgentHostDatabase['upsertSessionV2']>): ReturnType<IAgentHostDatabase['upsertSessionV2']> { + const gate = this._nextSessionCatalogWrite; + if (gate) { + this._nextSessionCatalogWrite = undefined; + gate.peerTitle.complete(catalogDataOf(args[0])?.chats.find(chat => chat.kind === 'peer')?.summary); + gate.started.complete(); + await gate.release.p; + } + return super.upsertSessionV2(...args); + } + } + class NativeChatAgent extends MockAgent { + override async createChat(): Promise<void> { } + override async getChatMetadata(chat: URI): Promise<IAgentChatMetadata> { + return { chat, startTime: 1, modifiedTime: 1 }; + } + } + const backing = createPerSessionDataService(() => new TrackingSessionDatabase()); + const database = disposables.add(new GatedCompatibleDatabase(':memory:')); + await database.initialize(); + const residency = createResidencyTestService(0, 30_000, true, new NativeChatAgent('claude'), backing.service, database); + const svc = residency.service; + const session = await createUsedSession(svc, residency.agent); + const defaultChat = URI.parse(buildDefaultChatUri(session)); + const peer = URI.parse(buildChatUri(session, 'legacy-local-rename')); + svc.addSubscriber(session, 'evicting-client'); + svc.addSubscriber(defaultChat, 'evicting-client'); + await svc.createChat(session, peer, { title: 'Persisted Peer' }); + svc.addSubscriber(peer, 'evicting-client'); + await svc.whenCatalogReconciliationIdle(); + assert.strictEqual(database.requiresLegacyChatCatalog(session.toString()), true); + const peerDatabase = backing.database(peer); + const sessionDatabase = backing.database(session); + if (!(peerDatabase instanceof TrackingSessionDatabase) || !(sessionDatabase instanceof TrackingSessionDatabase)) { + throw new Error('Expected peer and session databases to track title persistence'); + } + + const peerTitlePersisted = peerDatabase.waitForMetadata(SESSION_CUSTOM_TITLE_KEY, renamedTitle); + const sessionTitlePersisted = sessionDatabase.waitForMetadata(customChatTitleMetadataKey(peer.toString()), renamedTitle); + const staleWrite = database.blockNextSessionCatalogWrite(); + let newerWrite: GatedCatalogWrite | undefined; + try { + svc.dispatchAction(session.toString(), { type: ActionType.SessionIsReadChanged, isRead: true }, 'rename-client', 1); + await staleWrite.started.p; + assert.strictEqual(await staleWrite.peerTitle.p, 'Persisted Peer'); + const gate = database.blockNextSessionCatalogWrite(); + newerWrite = gate; + svc.dispatchAction(peer.toString(), rename === 'local command' ? { + type: ActionType.ChatTurnStarted, + turnId: 'rename-legacy-peer', + startedAt: '2026-01-01T00:00:00Z', + message: { text: `/rename ${renamedTitle}`, origin: { kind: MessageKind.User } }, + } : { type: ActionType.SessionTitleChanged, title: renamedTitle }, 'rename-client', 1); + await Promise.all([ + peerTitlePersisted, + sessionTitlePersisted, + ]); + assert.deepStrictEqual({ + peerTitle: await peerDatabase.getMetadata(SESSION_CUSTOM_TITLE_KEY), + sessionMirrorTitle: await sessionDatabase.getMetadata(customChatTitleMetadataKey(peer.toString())), + }, { + peerTitle: renamedTitle, + sessionMirrorTitle: renamedTitle, + }); + staleWrite.release.complete(); + await gate.started.p; + + const evicted = Event.toPromise(Event.filter(getStateManager(svc).onDidRemoveSession, removed => removed === session.toString()), disposables); + try { + svc.unsubscribe(peer, 'evicting-client'); + svc.unsubscribe(defaultChat, 'evicting-client'); + svc.unsubscribe(session, 'evicting-client'); + await timeout(0); + assert.deepStrictEqual({ + resident: getStateManager(svc).getSessionState(session.toString()) !== undefined, + releases: residency.agent.releaseSessionCalls.length, + }, { + resident: true, + releases: 0, + }); + } finally { + gate.release.complete(); + await evicted; + } + } finally { + staleWrite.release.complete(); + newerWrite?.release.complete(); + await svc.whenCatalogReconciliationIdle(); + } + + await svc.restoreSession(session); + await svc.subscribe(peer, 'restored-peer'); + const [snapshot] = await database.readCatalogSnapshot([session.toString()]); + const cachedPeerTitle = catalogDataOf(await database.getSessionV2(session.toString()))?.chats.find(chat => chat.uri === peer.toString())?.summary; + assert.deepStrictEqual({ + authorityVersion: snapshot?.authorityVersion, + cachedPeerTitle, + persistedPeerTitle: await peerDatabase.getMetadata(SESSION_CUSTOM_TITLE_KEY), + persistedSessionMirrorTitle: await sessionDatabase.getMetadata(customChatTitleMetadataKey(peer.toString())), + restoredCatalogPeerTitle: getStateManager(svc).getSessionState(session.toString())?.chats.find(chat => chat.resource === peer.toString())?.title, + restoredPeerTitle: getStateManager(svc).getChatState(peer.toString())?.title, + }, { + authorityVersion: 1, + cachedPeerTitle: renamedTitle, + persistedPeerTitle: renamedTitle, + persistedSessionMirrorTitle: renamedTitle, + restoredCatalogPeerTitle: renamedTitle, + restoredPeerTitle: renamedTitle, + }); + }); + } + test('deleting an evicted session disposes every persisted peer chat', async () => { class MultiChatAgent extends MockAgent { override async createChat(_session: URI, _chat: URI): Promise<void> { } From 7b3265896c4c9f03c74750e444790bc87f0327d3 Mon Sep 17 00:00:00 2001 From: Don Jayamanne <don.jayamanne@outlook.com> Date: Sat, 17 Oct 2026 11:22:57 +1100 Subject: [PATCH 15/15] test: include Claude default multi-root capability Update the complete descriptor expectation retained by the fork feature to match main's intentional multi-root default. Keep the strict capability assertion and runtime behavior unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/test/node/claudeAgent.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index a55e8f86bf2759..bfb252a8f03db0 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -1436,7 +1436,7 @@ suite('ClaudeAgent', () => { const desc = agent.getDescriptor(); assert.deepStrictEqual( { provider: desc.provider, displayName: desc.displayName, hasDescription: desc.description.length > 0, capabilities: desc.capabilities, agentHostCapabilities: agent.agentHostCapabilities }, - { provider: 'claude', displayName: 'Claude', hasDescription: true, capabilities: { multipleChats: { fork: true, sideChat: true } }, agentHostCapabilities: { workspaceConversion: false } }, + { provider: 'claude', displayName: 'Claude', hasDescription: true, capabilities: { multipleChats: { fork: true, sideChat: true }, multipleWorkingDirectories: { immutablePrimary: true } }, agentHostCapabilities: { workspaceConversion: false } }, ); });