diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index d801e0ee76a..043e1463ea3 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -67,6 +67,11 @@ Then read the relevant spec for the area you are changing (see table below). If - **Service operations should return a result or throw, not `undefined` for unsupported cases**: capability-gated operations like `forkChatInSession` must throw when the provider/session cannot perform them. Keep fallback decisions in the caller before invoking the service instead of encoding fallback as an `undefined` service result. - **A provisional session abandoned during commit detection must not be returned as successful**: its status can remain `InProgress` after its lifecycle owner is disposed, so consumers waiting for a terminal status never settle. Clean up the provisional session and reject the send when commit detection times out or the connection is lost. - **Drop a fork when its turn point is unknown, don't forward it empty**: in `AgentService.createChat`/`createSession`, if the requested fork `turnId`/`turnIndex` resolves to no source turns, set `fork: undefined` and fall through to a fresh create. Forwarding the fork with an empty turn slice makes the Copilot provider call `sessions.fork` with no `toEventId`, inheriting the entire backend conversation while the new chat UI is seeded with zero turns — an inconsistent hidden-history chat. +- **Side-chat context belongs to the provider, not AgentService message mutation**: `AgentService` records and forwards the side-chat origin, but must not synthesize a first-turn `Chat` attachment or strip provider-added context on restore. Each supporting provider establishes hidden backend context and removes inherited/provider-added history from the turns it returns. +- **`MessageAttachmentKind.Chat` is generic and may reference unloaded chats**: resolve chat attachments through a generic async path, enforce same-session ownership before hydration, and restore the referenced session/chat when it is absent from the state manager. Do not name this logic after side chats or assume `/btw` is its only producer. +- **Agent capabilities are provider-specific**: do not implement side chats for an agent merely because the protocol supports them. Advertise `multipleChats.sideChat` and run shared side-chat tests only for providers with a complete provider-owned context/restore implementation. +- **Side chats are top-level editor tabs, not auxiliary-bar views**: the single-pane auxiliary bar is the docked Changes/Files detail, so registering Side Chat there nests it beside the active detail. Open a singleton Side Chat editor input through `IEditorService`; while active, hide the docked detail like Browser so the chat owns the full side pane. +- **`/btw` must bypass queue/steer and may anchor to `activeTurn`**: mark the silent slash command `executeDuringRequest` so the chat widget invokes it independently, and validate its anchor against completed turns or the current active turn. Provider side-chat creation must lock on the new chat, not the source send key. Wrap the first provider prompt with a succinct instruction to prefer explanation over action and avoid work unless explicitly requested; include bounded user-visible active-turn markdown when native forks omit it, then strip the private wrapper from reconstructed visible history. Never inject reasoning or tool payloads. - **A responsive-layout autorun must re-baseline (not react) to controller-driven restores, holding the flag across the async reveal**: the desktop [D7] responsive sidebar hides the sessions sidebar when small + editor + aux-bar are all open. Switching sessions restores layout via two async paths — the desktop aux-bar restore (`openView`/`openViewContainer`) **and** the base controller's editor working-set apply (`_applyWorkingSet`, which reveals the editor part *after* an `await` and runs on a `Sequencer` microtask). Both reveal parts in a *later* autorun run, so an inline "same-run session changed" check only absorbs the synchronous transition and the async reveal still auto-hid the sidebar on navigation. Fix: a shared base-controller `_withSessionLayoutRestore(work)` epoch wraps **both** restore paths (the working-set wrap is the critical one for non-modal editors); the D7 autorun re-baselines `_previousSpaceConstrained` while `_isRestoringSessionLayout` is true. Also gate the constrained derivation on `!multipleSessionsVisibleObs` so the feature is disabled with multiple sessions visible. Never use a `setTimeout` to bridge the async reveal — tie the flag to the actual promise. - **A promise-tied "epoch" helper must decrement synchronously for void/sync work, only deferring for real Promises**: `_withSessionLayoutRestore` increments a depth counter, runs `work()`, and decrements when done. If it *always* schedules the decrement on a microtask (`Promise.resolve(result).finally(...)`) — even when `work()` returns `undefined` (the common no-op restore, e.g. a session with no workspace) — the depth stays elevated for the entire synchronous caller/test body, so `_isRestoringSessionLayout` reads `true` forever and the consumer (D7) silently stops acting. Only defer the decrement when `work()` returns a thenable; for void/sync (or throwing) work, decrement in the `finally`. - **A quick-chat's workspace-less kind is fixed at adapter construction — every construction path must carry `_meta`**: `AgentHostSessionAdapter` resolves its session-kind (`QuickChatSessionKind` vs `WorkspaceSessionKind`) **once**, from `readSessionWorkspaceless(metadata._meta)` in the constructor; `_computeWorkspace()` and `isQuickChat` delegate to that fixed kind and **cannot be flipped by a later `update`/`setMeta`**. So the `_meta.workspaceless` tag must be present in the metadata passed to **every** adapter-construction path: `_refreshSessions()`/`listSessions` **and** the live `_handleSessionAdded(summary)` notification (carry `summary._meta`). Dropping `_meta` on either locks a committed quick chat into `WorkspaceSessionKind` and leaks its `/.copilot/chats/` scratch dir as a `workspace` (breaking the archive-on-delete fallback, list badges, changes/files). On the host, `CopilotAgent.listSessions()` must re-emit `_meta.workspaceless` from persisted `copilot.workspaceless` metadata (mirroring `getSessionMetadata`) so restored sessions carry the tag once the state-manager live summary is gone. The host still keeps the tag on both the summary `_meta` and `SessionState._meta` (`createSessionState(summary)` copies it) so the channels stay consistent. @@ -150,13 +155,13 @@ You **must** run these checks before declaring work complete: - **The width-based docked reveal-sync (`_syncEditorVisibility`) must bail while editor-part auto-visibility is suppressed**: `SinglePaneWorkbench._syncEditorVisibility` reveals/hides the docked editor purely from the node width (for user sash drags). A session-switch / reload layout restore holds `suppressEditorPartAutoVisibility` while it applies the working set, which can **widen the docked node** before the controller has set the target editor-part visibility. Because the width-sync ran regardless of suppression, restoring a **Detail-only** session (aux open, editor closed) flickered the editor open on switch (the working-set apply widened the node → reveal → the controller then re-hid it) and could persist it open on reload. Gate `_syncEditorVisibility` on `!this._isEditorPartAutoVisibilitySuppressed` (alongside the existing `_syncingEditorVisibility` reentrancy guard) so only a real user sash drag (unsuppressed) drives width-based visibility. Relatedly, `baseSessionLayoutController._applyWorkingSet`'s `isInitialRestore` branch must, for single-pane, apply `_shouldHideEditorPartOnApply(editorPartHidden)` after the working-set apply (a no-op for the classic layout) — otherwise a Detail-only session's persisted editor-hidden state is not re-applied on reload and the editor is left visible. -- **Single-pane detail (aux bar) ownership is split cleanly in two — visibility vs content — never three overlapping aux strategies**: in single-pane the auxiliary bar *is* the detail panel, so exactly two strategies touch it, with non-overlapping responsibilities. `SinglePaneDetailVisibilityStrategy` owns **only** per-session *shown/hidden* memory: it captures the user's choice ([D1]/[D2]), restores it on switch ([D3]) by revealing/hiding the aux **part** (`setPartHidden` / `hideAuxiliaryBarForRestore`), and handles the submit transition ([D4]). `SinglePaneDetailPanelStrategy` owns **everything about content**: which container (Changes/Files, mapped from the active editor), the transient browser-tab hide, editor-maximize → Changes, and the "nothing to show" hide (quick chat / no workspace / empty group → `Hidden`). Do NOT reintroduce a separate `EmptyAuxCleanup`/D10 strategy or desktop's saved-container machinery (`auxiliaryBarActiveViewContainerId` restore, `_openDefaultAuxiliaryBarContainer`, `_restoreSavedAuxiliaryBarContainerOnReveal`, pinned-container checks) into the visibility strategy — the container always follows the active editor, so a stored container preference is redundant and races the detail-panel mapping. Because the visibility strategy reveals the part and the detail-panel strategy fills it, the detail-panel strategy registers **immediately** in `_registerViewStateManagement` (not deferred to `Restored` like the managed tabs), so a reveal and its container open happen in the same turn. +- **Single-pane detail (aux bar) ownership is split cleanly in two — visibility vs content — never three overlapping aux strategies**: in single-pane the auxiliary bar *is* the detail panel, so exactly two strategies touch it, with non-overlapping responsibilities. `SinglePaneDetailVisibilityStrategy` owns **only** per-session *shown/hidden* memory: it captures the user's choice ([D1]/[D2]), restores it on switch ([D3]) by revealing/hiding the aux **part** (`setPartHidden` / `hideAuxiliaryBarForRestore`), and handles the submit transition ([D4]). `SinglePaneDetailPanelStrategy` owns **everything about content**: which container (Changes/Files, mapped from the active editor), transient hiding for full-width editors, editor-maximize → Changes, and the "nothing to show" hide (quick chat / no workspace / empty group → `Hidden`). Do NOT reintroduce a separate `EmptyAuxCleanup`/D10 strategy or desktop's saved-container machinery (`auxiliaryBarActiveViewContainerId` restore, `_openDefaultAuxiliaryBarContainer`, `_restoreSavedAuxiliaryBarContainerOnReveal`, pinned-container checks) into the visibility strategy — the container always follows the active editor, so a stored container preference is redundant and races the detail-panel mapping. Because the visibility strategy reveals the part and the detail-panel strategy fills it, the detail-panel strategy registers **immediately** in `_registerViewStateManagement` (not deferred to `Restored` like the managed tabs), so a reveal and its container open happen in the same turn. - **Single-pane is a *sibling* of the desktop controller and composes strategy objects — it does not extend `LayoutController`**: `SinglePaneLayoutController` (file `contrib/layout/browser/singlePaneLayoutController.ts`) extends `BaseLayoutController` directly, NOT the classic desktop `LayoutController`, so the desktop controller can be deprecated/deleted without touching single-pane. Its behaviour is composed from strategy objects under `contrib/layout/browser/singlePane/` (each a `Disposable`, created via `createInstance` with a leading `ISinglePaneLayoutContext` arg): `SinglePaneDetailVisibilityStrategy` (per-session detail shown/hidden: D1/D2/D3/D4) and `SinglePaneDetailPanelStrategy` (container + maximize + browser-hide + nothing-to-show hide) — the detail split above; `SinglePaneManagedTabsStrategy` + `SinglePaneEditorAreaCollapseStrategy` (share a `SinglePaneDockedTabsCoordinator` holding the tab `Sequencer`, `collapsedEditors`, and the `getChangesEditorResource` helper; docked (managed) tabs are identified by `instanceof DockedEditorInput`); `SinglePaneQuickChatEditorHideStrategy`; `SinglePaneResponsiveSidebarStrategy` (owns the Toggle Details action + sidebar auto-hide); `SinglePaneNewSessionRulesStrategy` (R1). Shared controller state (`isRestoringSessionLayout`, `withSessionLayoutRestore`, `togglingSidePane`, the obs, `viewStateBySession`, `hidingAuxiliaryBarForRestore`/`hideAuxiliaryBarForRestore`) is exposed to strategies through `ISinglePaneLayoutContext` (built lazily in the controller because base's constructor calls the `_registerViewStateManagement`/`_registerAuxiliaryControllers` hooks *before* subclass field initializers run). The detail-visibility/detail-panel/responsive/R1 strategies register in `_registerViewStateManagement`; the managed-tab/collapse/quick-chat strategies register in `_registerAuxiliaryControllers` deferred to `LifecyclePhase.Restored`. **Fresh storage**: single-pane persists to `sessions.singlePane.layoutState` + `sessions.singlePane.newSessionViewState` (base `_layoutStateStorageKey`/`_legacyWorkingSetsStorageKey` are overridable; single-pane skips legacy migration), so it never shares state with the classic desktop controller — the test harness seeds both keys. - **Single-pane detail/tab behaviour lives ON the layout controller (or its strategies), not in separate contribution controllers or a shared service**: `SinglePaneLayoutController` owns both the managed docked tabs (pinned Changes multi-diff + empty Files placeholder) and the detail-panel mapping (active editor → Changes/Files container, aux-bar reveal/hide). They were previously `ChangesTabController`/`DetailPanelController` (registered by a `SinglePaneModeController` contribution) coordinating via global `IAgentWorkbenchLayoutService` flags, then briefly via an `ISessionLayoutCoordinatorService`. Both were removed: "is a session-switch restore in progress?" is just the base protected getter `this._isRestoringSessionLayout` (set by `_withSessionLayoutRestore`) — surfaced to the strategies via `ISinglePaneLayoutContext.isRestoringSessionLayout` — so a restore-driven editor change never force-reveals the detail or dismisses a managed tab. The base controller has `IChangesViewService` + `IContextKeyService` deps and a protected `_editorGroupsService` (a subclass can't add DI ctor params without redeclaring all base params, so shared services live on the base). Tests: the layout harness got `activeGroupEditors`/`closeSuppressionFlags`, a real `mainPart.activeGroup`, an `activateAux` opt-in that resolves the lifecycle, and a `TestSinglePaneController.runWithRestore(...)` seam to hold `_isRestoringSessionLayout` across an async editor change; `changesTabController.test.ts` was deleted and its scenarios moved into `desktopSessionLayoutController.test.ts`. -- **Single-pane created-session default is Editor-only (Changes editor, detail closed) — the detail is not force-opened by editor activation**: a Changes/file editor becoming active must NOT auto-reveal the docked detail (aux bar). `SinglePaneDetailPanelStrategy._syncForcedDetailTarget` reveals a hidden detail ONLY when it was transiently hidden by a browser tab (`_hiddenByBrowser`), never when it is hidden by the per-session default or an explicit user hide; when the detail is visible it still switches the container (Changes/Files) to match the active editor. **Exception — opening the empty Files placeholder (`EmptyFileEditorInput`) reveals the Files detail** (its content, the Files tree, lives in the aux bar). This is a dedicated `onDidActiveEditorChange` listener in the strategy that reveals the aux bar when the placeholder *becomes the active editor* — NOT reactive logic inside the detail autorun (which re-reads `auxBarVisibleObs`, so it would re-reveal the instant the user hides the detail — bug: "can't hide the details view in the empty file editor at all"). Keying on **active editor** (not `onWillOpenEditor`) is deliberate: the managed auto-ensured Files tab is opened **inactive** as a background tab (`fileTabOptions`), so it never becomes active and never reveals — preserving the Editor-only default — while the `+` Files action and selecting the Files tab both make it active and reveal. Do NOT reveal from the `NewFileTabAction` instead: that misses tab-selection and other activation paths (tried and rejected — "does not work"). The listener is guarded by `isVisible(EDITOR_PART)` (don't reveal the detail alone while the whole side pane is closed, e.g. Scenario C reload) and `!ctx.isRestoringSessionLayout` (a restore-driven activation must not reveal). Because hiding the aux bar fires `onDidChangePartVisibility`, not `onDidActiveEditorChange`, the user's hide sticks while the placeholder stays active. Do NOT reintroduce a `DetailPanelTarget.FilesReveal` in the autorun or an `isEditorPartAutoVisibilitySuppressed()` layout-service API — the active-editor listener needs neither. The reopen default is layout-aware via the base `_defaultReopenSidePaneParts()` hook. When changing this, update the `[single-pane] reveals the Files detail when the empty Files placeholder becomes active` and `[Scenario C]` tests together. +- **Single-pane created-session default is Editor-only (Changes editor, detail closed) — the detail is not force-opened by editor activation**: a Changes/file editor becoming active must NOT auto-reveal the docked detail (aux bar). `SinglePaneDetailPanelStrategy._syncForcedDetailTarget` reveals a hidden detail ONLY when it was temporarily hidden by a full-width editor (`_hiddenByFullWidthEditor`), never when it is hidden by the per-session default or an explicit user hide; when the detail is visible it still switches the container (Changes/Files) to match the active editor. **Exception — opening the empty Files placeholder (`EmptyFileEditorInput`) reveals the Files detail** (its content, the Files tree, lives in the aux bar). This is a dedicated `onDidActiveEditorChange` listener in the strategy that reveals the aux bar when the placeholder *becomes the active editor* — NOT reactive logic inside the detail autorun (which re-reads `auxBarVisibleObs`, so it would re-reveal the instant the user hides the detail — bug: "can't hide the details view in the empty file editor at all"). Keying on **active editor** (not `onWillOpenEditor`) is deliberate: the managed auto-ensured Files tab is opened **inactive** as a background tab (`fileTabOptions`), so it never becomes active and never reveals — preserving the Editor-only default — while the `+` Files action and selecting the Files tab both make it active and reveal. Do NOT reveal from the `NewFileTabAction` instead: that misses tab-selection and other activation paths (tried and rejected — "does not work"). The listener is guarded by `isVisible(EDITOR_PART)` (don't reveal the detail alone while the whole side pane is closed, e.g. Scenario C reload) and `!ctx.isRestoringSessionLayout` (a restore-driven activation must not reveal). Because hiding the aux bar fires `onDidChangePartVisibility`, not `onDidActiveEditorChange`, the user's hide sticks while the placeholder stays active. Do NOT reintroduce a `DetailPanelTarget.FilesReveal` in the autorun or an `isEditorPartAutoVisibilitySuppressed()` layout-service API — the active-editor listener needs neither. The reopen default is layout-aware via the base `_defaultReopenSidePaneParts()` hook. When changing this, update the `[single-pane] reveals the Files detail when the empty Files placeholder becomes active` and `[Scenario C]` tests together. - **Editor-title actions that only make sense with a restorable editor must also gate on `EditorMaximizedContext.negate()`**: the single-pane "Hide Editor" action is meaningless while the editor area is maximized, so its `when` includes `EditorMaximizedContext.negate()` (in addition to `MainEditorAreaVisibleContext` + `HasDockedDetailsContext`). - **R1 (new-session editor hide) must be transition-triggered, not level-triggered on the active editor**: hiding the editor in the new-session view must fire only when the editor **just became visible** (visibility false→true) or when the view was **just entered** with the editor already visible (inherited-visible editor) — never merely because the active editor changed to a managed placeholder while the editor is already visible. A level-triggered rule ("hide whenever active editor is non-real content and editor visible") wrongly hides the editor when the user switches to the Files tab with a file already open (the reveal-sync suppression re-arm clears `isEditorRevealedExplicitly`, so the level rule then hides). Track `previousEditorVisible` + `previousInNewSessionView` in the autorun and hide only on `(editorJustRevealed || justEnteredNewSessionView) && !isEditorRevealedExplicitly()`. The two workbench methods `setSuppressDockedEditorRevealSync` (blocks width-based reveals at the source, avoiding flicker) and `isEditorRevealedExplicitly` (distinguishes an explicit toggle-details-off/file-open reveal that must stick) are still required by R1 — they are independent of the ChangesTab/DetailPanel controller merge. diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 143ef179904..3eb2e336ac1 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -32,6 +32,7 @@ import { type IVscodeUpgradeResult } from '../common/state/protocolUpgrade.js'; import { isClientTransport, type IProtocolTransport } from '../common/state/sessionTransport.js'; import { AhpErrorCodes } from '../common/state/protocol/errors.js'; import { ContentEncoding, ResourceRequestParams, type CompletionsParams, type CompletionsResult, type CreateTerminalParams, type ResolveSessionConfigResult, type SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; +import { ChatSourceKind } from '../common/state/protocol/channels-chat/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { encodeBase64 } from '../../../base/common/buffer.js'; import { ILoadEstimator, LoadEstimator } from '../../../base/parts/ipc/common/ipc.net.js'; @@ -966,7 +967,8 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC await this._sendRequest('createChat', { channel: session.toString(), chat: chat.toString(), - ...(options?.fork ? { source: { chat: options.fork.source.toString(), turnId: options.fork.turnId } } : {}), + ...(options?.fork ? { source: { kind: ChatSourceKind.Fork, chat: options.fork.source.toString(), turnId: options.fork.turnId } } : {}), + ...(options?.sideChat ? { source: { kind: ChatSourceKind.SideChat, chat: options.sideChat.source.toString(), turnId: options.sideChat.turnId } } : {}), }); } diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index d07b370326e..7733e2075f8 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -965,6 +965,12 @@ export interface IAgentCreateChatOptions { * is forked from the source so it can continue independently. */ readonly fork?: IAgentCreateChatForkSource; + /** + * Create this new chat as a side chat branching from a turn in an existing + * chat (via `/btw`). Unlike {@link fork}, inherited context is provider-owned + * and must not appear in the chat's visible history. + */ + readonly sideChat?: IAgentCreateChatSideChatSource; } /** Identifies a source chat and turn to fork a new chat from. */ @@ -981,6 +987,16 @@ export interface IAgentCreateChatForkSource { readonly turnIdMapping?: ReadonlyMap; } +/** Identifies a source chat and turn a side chat (`/btw`) branches from. */ +export interface IAgentCreateChatSideChatSource { + /** URI of the existing chat the side chat branches from. */ + readonly source: URI; + /** Turn ID in the source chat the side chat records as its provenance. */ + readonly turnId: string; + /** User-visible assistant text captured while the source turn was active. */ + readonly partialResponse?: string; +} + /** Result of {@link IAgentChats.createChat}: the opaque blob to persist for restore. */ export interface IAgentCreateChatResult { /** diff --git a/src/vs/platform/agentHost/common/state/chatAttachmentContext.ts b/src/vs/platform/agentHost/common/state/chatAttachmentContext.ts new file mode 100644 index 00000000000..c5cf6795746 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/chatAttachmentContext.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MessageAttachmentKind, ResponsePartKind, type MessageChatAttachment, type SimpleMessageAttachment, type Turn } from './protocol/state.js'; + +/** + * Model-facing preamble that frames the resolved transcript for the SDK. It is + * intentionally hard-coded English (like Claude's `` text): + * this string is consumed by the model, not shown in the UI, so it must not be + * localized. + */ +const CHAT_TRANSCRIPT_PREAMBLE = + 'The user referenced another chat in the same session. ' + + 'The transcript below is that chat up to the selected turn, provided as background context. ' + + 'Treat it as reference material that may or may not be relevant to the new question.'; + +/** + * Returns the referenced chat's turns bounded through {@link endTurn}, inclusive. + * When {@link endTurn} is not present (e.g. the source chat was pruned or + * truncated past the branch point) every retained turn is returned as a + * best-effort fallback. + */ +export function boundChatTranscriptTurns(turns: readonly Turn[], endTurn: string): readonly Turn[] { + const index = turns.findIndex(t => t.id === endTurn); + return index < 0 ? turns : turns.slice(0, index + 1); +} + +/** + * Formats bounded transcript turns into a plain-text conversation for the + * model. Only user message text and assistant markdown are rendered; tool + * calls, reasoning, and other parts are omitted to keep the context bounded. + * + * This never expands nested attachments, so a {@link MessageChatAttachment} + * referenced inside the source transcript is not recursively resolved. + */ +export function formatChatTranscript(turns: readonly Turn[]): string { + const blocks: string[] = []; + for (const turn of turns) { + const userText = turn.message?.text?.trim(); + if (userText) { + blocks.push(`User: ${userText}`); + } + const assistantText = turn.responseParts + .map(part => (part.kind === ResponsePartKind.Markdown ? part.content : '')) + .join('') + .trim(); + if (assistantText) { + blocks.push(`Assistant: ${assistantText}`); + } + } + return blocks.join('\n\n'); +} + +/** + * Resolves a {@link MessageChatAttachment} into an SDK-compatible + * {@link SimpleMessageAttachment}: the bounded transcript rendered as the + * attachment's {@link SimpleMessageAttachment.modelRepresentation}. Every + * provider adapter already inlines a `Simple` attachment's model + * representation, so this keeps transcript formatting in one place instead of + * duplicating it per agent. + * + * The resolution is non-recursive: it renders {@link sourceTurns} directly and + * never re-resolves chat attachments found within them. + */ +export function resolveChatAttachment(attachment: MessageChatAttachment, sourceTurns: readonly Turn[]): SimpleMessageAttachment { + const bounded = boundChatTranscriptTurns(sourceTurns, attachment.endTurn); + const transcript = formatChatTranscript(bounded); + const modelRepresentation = transcript + ? `${CHAT_TRANSCRIPT_PREAMBLE}\n\n${transcript}` + : CHAT_TRANSCRIPT_PREAMBLE; + return { + type: MessageAttachmentKind.Simple, + label: attachment.label, + modelRepresentation, + ...(attachment.range !== undefined ? { range: attachment.range } : {}), + }; +} 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 296f0efe8f0..b50db79727e 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 @@ -13,12 +13,29 @@ import type { Message } from './state.js'; // ─── createChat ────────────────────────────────────────────────────────────── /** - * Identifies a source chat and turn to fork from. + * How a new chat uses its source chat and turn. */ -export interface ChatForkSource { - /** URI of the existing chat to fork from */ +export const enum ChatSourceKind { + /** Copy source history through the referenced turn into the new chat. */ + Fork = 'fork', + /** Supply source context without copying it into the new chat's visible history. */ + SideChat = 'sideChat', +} + +/** + * Identifies a source chat and completed turn for a new chat. + * + */ +export interface ChatSource { + /** How the source is used. */ + kind: ChatSourceKind; + /** URI of the existing source chat. */ chat: URI; - /** Turn ID in the source chat; content up to and including this turn's response is copied */ + /** + * Completed turn in the source chat. For a fork, content through this turn is + * copied. For a side chat, that content is supplied as context but is not + * copied into the new chat's visible `turns`. + */ turnId: string; } @@ -38,14 +55,20 @@ export interface CreateChatParams extends BaseParams { chat: URI; /** Optional initial message for the new chat. */ initialMessage?: Message; - /** Optional source chat and turn to fork from. */ - source?: ChatForkSource; + /** + * Optional source chat and completed turn. + * + * The source chat MUST belong to this session. Clients MUST only request + * `kind: "sideChat"` when the selected agent advertises + * `capabilities.multipleChats.sideChat`. + */ + source?: ChatSource; /** * Initial working-directory subset for this chat. Every entry MUST be * present in the owning session's `workingDirectories`; the server MUST * reject any entry that is not. When absent, the chat inherits the full - * session set. Forked chats (`source`) inherit the source chat's - * `workingDirectories`; this field is ignored for forked chats. + * session set. Chats created from a source (`source`) inherit the source + * chat's `workingDirectories`; this field is ignored when `source` is set. * * A client MUST NOT supply this field unless the agent advertises * {@link AgentCapabilities.multipleWorkingDirectories}. @@ -59,8 +82,8 @@ export interface CreateChatParams extends BaseParams { * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY * reject creation that omits it, or fall back to the first of the chat's * directories. Fixed at creation and reported (read-only) on - * {@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork - * inherits the source chat's primary). + * {@link ChatState.primaryWorkingDirectory}. Ignored when `source` is set (the + * new chat inherits the source chat's primary). */ primaryWorkingDirectory?: URI; } diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts index 96594718600..febd6913ccf 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts @@ -167,6 +167,8 @@ export const enum ChatOriginKind { User = 'user', /** Forked from an existing chat at a specific turn. */ Fork = 'fork', + /** Created as an independent side conversation from a specific turn. */ + SideChat = 'sideChat', /** Spawned by a tool call running in another chat (e.g. a sub-agent delegation). */ Tool = 'tool', } @@ -186,6 +188,7 @@ export const enum ChatOriginKind { export type ChatOrigin = | { kind: ChatOriginKind.User } | { kind: ChatOriginKind.Fork; chat: URI; turnId: string } + | { kind: ChatOriginKind.SideChat; chat: URI; turnId: string } | { kind: ChatOriginKind.Tool; chat: URI; toolCallId: string }; /** @@ -505,6 +508,8 @@ export const enum MessageAttachmentKind { Resource = 'resource', /** An attachment that references annotations on an annotations channel. */ Annotations = 'annotations', + /** An attachment that references a bounded transcript from another chat. */ + Chat = 'chat', } /** @@ -767,6 +772,30 @@ export interface MessageAnnotationsAttachment extends MessageAttachmentBase { annotationIds?: string[]; } +/** + * An attachment that references a chat transcript through a fixed completed + * turn. + * + * The referenced chat MUST belong to the same session as the message's chat. + * The host resolves the transcript from its first retained turn through + * `endTurn`, inclusive, when accepting the message. Later turns do not + * change the context represented by an already-sent attachment. + * + * Hosts MUST NOT recursively expand chat attachments found inside the + * referenced transcript. Clients SHOULD keep rendering `label` if the + * referenced chat is later pruned, and treat opening `resource` as best-effort. + * + * @category Turn Types + */ +export interface MessageChatAttachment extends MessageAttachmentBase { + /** Discriminant */ + type: MessageAttachmentKind.Chat; + /** URI of the referenced chat. */ + resource: URI; + /** Last completed turn included in the referenced transcript. */ + endTurn: string; +} + /** * An attachment associated with a {@link Message}. * @@ -776,7 +805,8 @@ export type MessageAttachment = | SimpleMessageAttachment | MessageEmbeddedResourceAttachment | MessageResourceAttachment - | MessageAnnotationsAttachment; + | MessageAnnotationsAttachment + | MessageChatAttachment; // ─── Response Parts ────────────────────────────────────────────────────────── 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 f9aafad9a75..7b468829ce9 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 @@ -105,7 +105,8 @@ export interface AgentCapabilities { * The agent can host more than one concurrent chat per session. When absent, * clients MUST NOT call `createChat` to open chats beyond the default one the * session starts with. An empty object `{}` advertises multi-chat without - * forking; set {@link MultipleChatsCapability.fork} to also allow forking. + * source-based creation; set {@link MultipleChatsCapability.fork} or + * {@link MultipleChatsCapability.sideChat} to allow the corresponding mode. */ multipleChats?: MultipleChatsCapability; /** @@ -129,10 +130,21 @@ export interface AgentCapabilities { export interface MultipleChatsCapability { /** * The agent can fork a chat from a specific turn. When absent or `false`, - * clients MUST NOT pass a {@link ChatForkSource} (`source`) to `createChat`. + * clients MUST NOT pass a {@link ChatSource} with `kind: "fork"` to + * `createChat`. * Forking always implies multi-chat support. */ fork?: boolean; + /** + * The agent can create a side chat from a specific turn. When absent or + * `false`, clients MUST NOT pass a {@link ChatSource} with + * `kind: "sideChat"` to `createChat`. + * + * A side chat receives the source turn as context without copying the source + * transcript into its own visible history. Side-chat support always implies + * multi-chat support. + */ + sideChat?: boolean; } /** diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 32ea8523cb1..adfe0603487 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -749,7 +749,7 @@ export class AgentHostStateManager extends Disposable { * peer chat's opaque, agent-owned restore blob (see * {@link getChatProviderData}); the StateManager never parses it. */ - restoreChat(session: URI, chatUri: URI, options: { readonly title?: string; readonly turns: Turn[]; readonly draft?: Message; readonly providerData?: string }): void { + restoreChat(session: URI, chatUri: URI, options: { readonly title?: string; readonly turns: Turn[]; readonly draft?: Message; readonly providerData?: string; readonly origin?: ChatOrigin }): void { const entry = this._sessionStates.get(session); if (!entry) { this._logService.warn(`[AgentHostStateManager] restoreChat for unknown session: ${session}`); @@ -763,6 +763,7 @@ export class AgentHostStateManager extends Disposable { ...createDefaultChatSummary(this._toSummary(session, entry), chatUri), title: options.title ?? '', status: SessionStatus.Idle, + origin: options.origin, }; this._chatStates.set(chatUri, { ...createChatState(chatSummary), turns: options.turns, draft: options.draft }); if (options.providerData !== undefined) { diff --git a/src/vs/platform/agentHost/node/agentPeerChats.ts b/src/vs/platform/agentHost/node/agentPeerChats.ts index 8b1e88d5cd6..648d50e8877 100644 --- a/src/vs/platform/agentHost/node/agentPeerChats.ts +++ b/src/vs/platform/agentHost/node/agentPeerChats.ts @@ -4,7 +4,65 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableMap, IDisposable } from '../../../base/common/lifecycle.js'; -import { type ModelSelection } from '../common/state/protocol/state.js'; +import { renderResponseMarkdown } from '../common/agentHostConversationContext.js'; +import { type ModelSelection, type Turn } from '../common/state/protocol/state.js'; + +const SIDE_CHAT_CONTEXT_START = ''; +const SIDE_CHAT_CONTEXT_END = ''; +const SIDE_CHAT_GUIDANCE = 'This is a side conversation. Prefer explanation over action; do not make changes or carry out work unless the user explicitly asks.'; + +export interface IPersistedSideChat { + readonly source: string; + readonly turnId: string; + readonly inheritedTurnCount: number; + readonly partialResponse?: string; +} + +export function injectSideChatContext(prompt: string, partialResponse?: string): string { + const context = [SIDE_CHAT_GUIDANCE]; + if (partialResponse) { + context.push( + '', + 'The side chat was created while the source assistant was still responding.', + 'The user-visible response had produced the following text at that moment:', + '', + partialResponse, + ); + } + return [SIDE_CHAT_CONTEXT_START, ...context, SIDE_CHAT_CONTEXT_END, '', prompt].join('\n'); +} + +export function prepareSideChatPrompt(prompt: string, turns: readonly Turn[], sideChat: IPersistedSideChat | undefined): string { + if (!sideChat || turns.length > sideChat.inheritedTurnCount) { + return prompt; + } + let partialResponse = sideChat.partialResponse; + if (partialResponse) { + const sourceTurn = turns.find(turn => turn.id === sideChat.turnId); + const inheritedResponse = sourceTurn ? renderResponseMarkdown(sourceTurn.responseParts) : ''; + if (inheritedResponse.includes(partialResponse)) { + partialResponse = undefined; + } + } + return injectSideChatContext(prompt, partialResponse); +} + +export function stripSideChatContext(turns: readonly Turn[], sideChat: IPersistedSideChat | undefined): readonly Turn[] { + if (!sideChat || turns.length === 0) { + return turns; + } + const first = turns[0]; + const text = first.message.text; + if (!text.startsWith(SIDE_CHAT_CONTEXT_START)) { + return turns; + } + const endIndex = text.indexOf(SIDE_CHAT_CONTEXT_END); + if (endIndex < 0) { + return turns; + } + const userPrompt = text.slice(endIndex + SIDE_CHAT_CONTEXT_END.length).trimStart(); + return [{ ...first, message: { ...first.message, text: userPrompt } }, ...turns.slice(1)]; +} /** * In-memory backing for an additional (non-default) peer chat. Records the SDK @@ -16,6 +74,7 @@ import { type ModelSelection } from '../common/state/protocol/state.js'; export interface IPersistedChat { readonly sdkSessionId: string; readonly model?: ModelSelection; + readonly sideChat?: IPersistedSideChat; } export interface IResolvedAgentChat { @@ -39,7 +98,7 @@ export function encodeProviderData(backing: IPersistedChat): string { */ export function decodeProviderData(providerData: string): IPersistedChat | undefined { try { - const value = JSON.parse(providerData) as { sdkSessionId?: unknown; model?: unknown }; + const value = JSON.parse(providerData) as { sdkSessionId?: unknown; model?: unknown; sideChat?: unknown }; if (!value || typeof value !== 'object') { return undefined; } @@ -53,7 +112,15 @@ export function decodeProviderData(providerData: string): IPersistedChat | undef const validModel = model && typeof model === 'object' && typeof (model as { id?: unknown }).id === 'string' ? model as ModelSelection : undefined; - return { sdkSessionId, ...(validModel ? { model: validModel } : {}) }; + const sideChat = value.sideChat as { source?: unknown; turnId?: unknown; inheritedTurnCount?: unknown; partialResponse?: unknown } | undefined; + const validSideChat = sideChat + && typeof sideChat.source === 'string' + && typeof sideChat.turnId === 'string' + && typeof sideChat.inheritedTurnCount === 'number' + && (sideChat.partialResponse === undefined || typeof sideChat.partialResponse === 'string') + ? { source: sideChat.source, turnId: sideChat.turnId, inheritedTurnCount: sideChat.inheritedTurnCount, ...(sideChat.partialResponse ? { partialResponse: sideChat.partialResponse } : {}) } + : undefined; + return { sdkSessionId, ...(validModel ? { model: validModel } : {}), ...(validSideChat ? { sideChat: validSideChat } : {}) }; } catch { return undefined; } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 7c2fed5f6c0..81bed317dd9 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -22,7 +22,7 @@ import { FileChangeType, FileOperationResult, IFileChange, IFileService, toFileO import { InstantiationService } from '../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentProvider, AgentSession, AgentSignal, AgentHostSessionReleaseGraceMsEnvVar, IAgent, IAgentChatDataChange, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentHostAuthTokenRequest, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkEndpoint, IAgentHostNetworkFetchResult, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, IMcpNotification, IRestoredSubagentSession, SubagentChatSignal } from '../common/agentService.js'; +import { AgentProvider, AgentSession, AgentSignal, AgentHostSessionReleaseGraceMsEnvVar, IAgent, IAgentChatDataChange, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentHostAuthTokenRequest, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkEndpoint, IAgentHostNetworkFetchResult, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, IMcpNotification, IRestoredSubagentSession, SubagentChatSignal } from '../common/agentService.js'; import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import type { IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js'; @@ -31,11 +31,12 @@ import { ActionType, ActionEnvelope, AuthRequiredReason, INotification, type Cha import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult, SessionConfigPropertySchema } from '../common/state/protocol/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 { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; +import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type ChatOrigin, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction } from '../common/state/protocol/actions.js'; import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, readSessionSpawnDepth, withSessionSpawnDepth, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, readSessionWorkspaceless, withSessionGitHubState, withSessionGitState, withSessionWorkspaceless, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { IProductService } from '../../product/common/productService.js'; +import { renderResponseMarkdown, truncateMiddle } from '../common/agentHostConversationContext.js'; import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; import { AgentHostTerminalManager, IAgentHostTerminalManager } from './agentHostTerminalManager.js'; import { ISessionDbUriFields, parseSessionDbUri } from './shared/fileEditTracker.js'; @@ -47,6 +48,8 @@ import { AgentHostLocalTurns } from './agentHostLocalTurns.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; import { buildServerToolGroups } from './shared/serverToolGroups.js'; import { type IChatContextSnapshot, type ISessionServerToolAccessor } from './shared/sessionServerTools.js'; + +const MAX_SIDE_CHAT_PARTIAL_RESPONSE_CHARS = 20_000; import { WorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT, worktreeProjectFromRepositoryRoot } from './shared/worktreeIsolation.js'; import { AgentHostChangesetService } from './agentHostChangesetService.js'; import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; @@ -161,14 +164,16 @@ const PEER_CHAT_BACKING_METADATA_KEY = 'peerChatBacking'; * (see {@link IAgentCreateChatResult.providerData}) handed back to the agent on * restore — the orchestrator never parses it. `providerData` may be omitted, * in which case the agent recovers its backing from its own persistence on - * {@link IAgent.materializeChat}. + * {@link IAgent.materializeChat}. `origin` records the chat's provenance + * (currently only {@link ChatOriginKind.SideChat}, carrying the source chat and + * bounded turn) so it survives a restart; omitted for plain peer chats. */ interface IPersistedPeerChat { readonly uri: string; readonly providerData?: string; + readonly origin?: ChatOrigin; } - /** * The agent service implementation that runs inside the agent-host utility * process. Dispatches to registered {@link IAgent} instances based @@ -488,6 +493,7 @@ export class AgentService extends Disposable implements IAgentService { }); }, resolveWorkingDirectoryBeforeSend: params => this._resolveWorkingDirectoryBeforeSend(params), + resolveChatAttachmentTurns: resource => this._resolveChatAttachmentTurns(resource), onTurnComplete: async session => { // Refresh the git state for the session. const workingDirStr = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; @@ -554,6 +560,28 @@ export class AgentService extends Disposable implements IAgentService { return await this._resolveWorktreeBeforeSend({ ...params, sessionId, pickedFolderUri }) ?? pickedFolderUri; } + private async _resolveChatAttachmentTurns(resource: string): Promise { + const readTurns = () => { + const state = this._stateManager.getChatState(resource) ?? this._stateManager.getDefaultChatState(resource); + return state?.turns; + }; + const existing = readTurns(); + if (existing) { + return existing; + } + + const sessionUri = URI.parse(isAhpChatChannel(resource) ? parseRequiredSessionUriFromChatUri(resource) : resource); + if (!this._stateManager.getSessionState(sessionUri.toString())) { + await this.restoreSession(sessionUri); + } else { + const provider = this._findProviderForSession(sessionUri); + if (provider) { + await this._restorePeerChats(provider, sessionUri); + } + } + return readTurns() ?? []; + } + /** * Creates the session's isolated worktree on the first send (deferred so the * user's prompt can name the branch), surfaces the "Created isolated worktree" @@ -1156,6 +1184,25 @@ export class AgentService extends Disposable implements IAgentService { let forkedTitle: string | undefined; let forkedSourceTitle: string | undefined; let createOptions = options; + // Side chats validate and persist their provenance without seeding host-visible turns. + let sideChatOrigin: ChatOrigin | undefined; + if (options?.sideChat) { + const resolvedSideChat = this._resolveSideChatOrigin(session, options.sideChat); + sideChatOrigin = resolvedSideChat.origin; + createOptions = { + ...options, + sideChat: { + ...options.sideChat, + ...(resolvedSideChat.partialResponse ? { partialResponse: resolvedSideChat.partialResponse } : {}), + }, + }; + const sourceKey = options.sideChat.source.toString(); + const sourceChatUri = this._stateManager.getChatState(sourceKey) ? sourceKey : buildDefaultChatUri(sourceKey); + const concreteTurnId = this._localTurns.resolveConcreteTurnId(sourceChatUri, options.sideChat.turnId); + if (concreteTurnId !== undefined) { + createOptions = { ...createOptions, sideChat: { ...createOptions.sideChat!, turnId: concreteTurnId } }; + } + } if (options?.fork) { const sourceKey = options.fork.source.toString(); const peerState = this._stateManager.getChatState(sourceKey); @@ -1209,12 +1256,13 @@ export class AgentService extends Disposable implements IAgentService { ...(forkedTitle !== undefined ? { title: forkedTitle } : options?.title !== undefined ? { title: options.title } : {}), ...(forkedTurns !== undefined ? { turns: forkedTurns } : {}), ...(providerData !== undefined ? { providerData } : {}), + ...(sideChatOrigin !== undefined ? { origin: sideChatOrigin } : {}), }); // Persist the new peer chat into the orchestrator-owned catalog so it is // re-enumerated and re-materialized on the next restore without asking - // the agent. - void this._persistPeerChat(session, chat, providerData); + // the agent. Side-chat provenance is persisted alongside providerData. + void this._persistPeerChat(session, chat, providerData, sideChatOrigin); // When the agent backs this peer chat with its own separately-enumerable // SDK session (e.g. Claude), mark that session so it is filtered out of @@ -1232,6 +1280,38 @@ export class AgentService extends Disposable implements IAgentService { } } + /** + * Validates a side chat's source and returns its {@link ChatOriginKind.SideChat} + * origin. Throws when the source chat is not part of `session` or when the + * referenced completed or active turn is absent. + */ + private _resolveSideChatOrigin(session: URI, sideChat: IAgentCreateChatSideChatSource): { origin: ChatOrigin; partialResponse?: string } { + const sessionKey = session.toString(); + const sourceKey = sideChat.source.toString(); + // The source chat MUST belong to the target session. A source addressed + // by a peer-chat URI carries its session in the URI; otherwise it is the + // session URI itself (the default chat). + const sourceSessionKey = isAhpChatChannel(sourceKey) ? parseRequiredSessionUriFromChatUri(sourceKey) : sourceKey; + if (sourceSessionKey !== sessionKey) { + throw new Error(`[AgentService] createChat: side chat source ${sourceKey} does not belong to session ${sessionKey}`); + } + // The bounded turn must be a real completed or currently-active turn. + const peerState = this._stateManager.getChatState(sourceKey); + const sourceState = peerState ?? this._stateManager.getDefaultChatState(sourceKey); + const activeTurn = sourceState?.activeTurn?.id === sideChat.turnId ? sourceState.activeTurn : undefined; + if (!sourceState?.turns.some(t => t.id === sideChat.turnId) && !activeTurn) { + throw new Error(`[AgentService] createChat: side chat source turn ${sideChat.turnId} not found in ${sourceKey}`); + } + const responseMarkdown = activeTurn ? renderResponseMarkdown(activeTurn.responseParts) : ''; + const partialResponse = responseMarkdown + ? truncateMiddle(responseMarkdown, MAX_SIDE_CHAT_PARTIAL_RESPONSE_CHARS) + : undefined; + return { + origin: { kind: ChatOriginKind.SideChat, chat: sourceKey, turnId: sideChat.turnId }, + ...(partialResponse ? { partialResponse } : {}), + }; + } + async disposeChat(session: URI, chat: URI): Promise { const sessionKey = session.toString(); const provider = this._findProviderForSession(session); @@ -1371,8 +1451,12 @@ export class AgentService extends Disposable implements IAgentService { * the session), so no default-chat resolution is needed. */ private _createChat(provider: IAgent, chat: URI, options: IAgentCreateChatOptions | undefined): Promise { - const convOptions: IAgentCreateChatOptions | undefined = options && (options.title !== undefined || options.model !== undefined) - ? { ...(options.title !== undefined ? { title: options.title } : {}), ...(options.model !== undefined ? { model: options.model } : {}) } + const convOptions: IAgentCreateChatOptions | undefined = options && (options.title !== undefined || options.model !== undefined || options.sideChat !== undefined) + ? { + ...(options.title !== undefined ? { title: options.title } : {}), + ...(options.model !== undefined ? { model: options.model } : {}), + ...(options.sideChat !== undefined ? { sideChat: options.sideChat } : {}), + } : undefined; return options?.fork ? provider.chats.fork(chat, options.fork, convOptions) @@ -2601,26 +2685,26 @@ export class AgentService extends Disposable implements IAgentService { this._getChatDraft(session, chatUri), ]); const mergedTurns = await this._interleaveLocalTurns(session.toString(), chatUri.toString(), turns); - return { chatUri, title, turns: mergedTurns, draft, providerData: entry.providerData }; + return { chatUri, title, turns: mergedTurns, draft, providerData: entry.providerData, origin: entry.origin }; })); for (const item of restored) { if (!item) { continue; } - const { chatUri, title, turns, draft, providerData } = item; + const { chatUri, title, turns, draft, providerData, origin } = item; this._stateManager.restoreChat(session.toString(), chatUri.toString(), { title, - turns, + turns: [...turns], draft, ...(providerData !== undefined ? { providerData } : {}), + ...(origin !== undefined ? { origin } : {}), }); } } /** * Re-persists a peer chat's opaque `providerData` blob when the agent - * reports it changed (e.g. per-chat model switch, fork remap). The - * orchestrator never parses the blob; it stores whatever it is handed. + * reports it changed (e.g. per-chat model switch or fork remap). */ private _onChatDataChanged(e: IAgentChatDataChange): void { const sessionStr = parseDefaultChatUri(e.chat); @@ -2767,7 +2851,11 @@ export class AgentService extends Disposable implements IAgentService { } return parsed .filter((entry): entry is IPersistedPeerChat => typeof entry?.uri === 'string') - .map(entry => ({ uri: entry.uri, ...(typeof entry.providerData === 'string' ? { providerData: entry.providerData } : {}) })); + .map(entry => ({ + uri: entry.uri, + ...(typeof entry.providerData === 'string' ? { providerData: entry.providerData } : {}), + ...(entry.origin !== undefined ? { origin: entry.origin } : {}), + })); } catch (err) { this._logService.warn(`[AgentService] Failed to read peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`); return undefined; @@ -2800,13 +2888,23 @@ export class AgentService extends Disposable implements IAgentService { /** * Inserts or updates a single peer chat in the orchestrator's persisted * catalog, recording its opaque `providerData` verbatim (or clearing it when - * `undefined`). Serialized per session via {@link _enqueuePeerChatCatalogWrite}. + * `undefined`). When `origin` is supplied it is stored as the chat's + * provenance; when omitted (e.g. a provider-driven `providerData` refresh via + * {@link _onChatDataChanged}) any previously persisted origin is preserved so + * a data refresh never drops a side chat's source boundary. Serialized per + * session via {@link _enqueuePeerChatCatalogWrite}. */ - private _persistPeerChat(session: URI, chat: URI, providerData: string | undefined): Promise { + private _persistPeerChat(session: URI, chat: URI, providerData: string | undefined, origin?: ChatOrigin): Promise { const chatUri = chat.toString(); return this._enqueuePeerChatCatalogWrite(session, entries => { + const existing = entries.find(entry => entry.uri === chatUri); + const effectiveOrigin = origin ?? existing?.origin; const next = entries.filter(entry => entry.uri !== chatUri); - next.push({ uri: chatUri, ...(providerData !== undefined ? { providerData } : {}) }); + next.push({ + uri: chatUri, + ...(providerData !== undefined ? { providerData } : {}), + ...(effectiveOrigin !== undefined ? { origin: effectiveOrigin } : {}), + }); return next; }); } @@ -2851,7 +2949,13 @@ export class AgentService extends Disposable implements IAgentService { if (raw !== undefined) { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { - current = parsed.filter((entry): entry is IPersistedPeerChat => typeof entry?.uri === 'string'); + current = parsed + .filter((entry): entry is IPersistedPeerChat => typeof entry?.uri === 'string') + .map(entry => ({ + uri: entry.uri, + ...(typeof entry.providerData === 'string' ? { providerData: entry.providerData } : {}), + ...(entry.origin !== undefined ? { origin: entry.origin } : {}), + })); } } } catch (err) { diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 86ef43860dd..d78a1bd372b 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -23,6 +23,7 @@ import { readToolCallMeta, toToolCallMeta } from '../common/meta/agentToolCallMe import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; +import { resolveChatAttachment } from '../common/state/chatAttachmentContext.js'; import { SessionInputRequestKind, ToolCallContributorKind, type AgentInfo, type SessionInputRequest } from '../common/state/protocol/state.js'; import { ActionType, isChatAction, StateAction, type ChatAction, type ChatToolCallCompleteAction } from '../common/state/sessionActions.js'; import { @@ -33,6 +34,7 @@ import { isSubagentChatUri, isChatReadOnly, AH_META_IS_ARCHIVED_DB_KEY, + MessageAttachmentKind, MessageKind, parseChatUri, parseRequiredSessionUriFromChatUri, @@ -46,6 +48,7 @@ import { type ErrorInfo, type ISessionWithDefaultChat, type Message, + type MessageAttachment, type URI as ProtocolURI, type SessionState, type ToolCallState, @@ -93,6 +96,8 @@ export interface IAgentSideEffectsOptions { * {@link AgentService}. */ readonly resolveWorkingDirectoryBeforeSend?: (params: { session: ProtocolURI; chat: ProtocolURI; turnId: string; prompt: string }) => Promise; + /** Resolves a referenced chat's turns, hydrating its owning session when needed. */ + readonly resolveChatAttachmentTurns?: (resource: ProtocolURI) => Promise; /** * Called after each top-level session turn completes so git state can be * refreshed and published via `SessionMetaChanged`. Subagent turns are @@ -1593,7 +1598,8 @@ export class AgentSideEffects extends Disposable { await Promise.all(selectionUpdates); failureStage = 'sendMessage'; - await agent.chats.sendMessage(chatUri, message.text, resolvedWorkingDirectory, message.attachments, turnId, senderClientId); + const resolvedAttachments = await this._resolveChatAttachments(sessionChannel, message.attachments); + await agent.chats.sendMessage(chatUri, message.text, resolvedWorkingDirectory, resolvedAttachments, turnId, senderClientId); } catch (err) { const failure = buildTurnFailure(failureStage, err); const error = failure.error; @@ -1610,6 +1616,32 @@ export class AgentSideEffects extends Disposable { } } + private async _resolveChatAttachments(sessionChannel: ProtocolURI, attachments: readonly MessageAttachment[] | undefined): Promise { + if (!attachments?.some(attachment => attachment.type === MessageAttachmentKind.Chat)) { + return attachments; + } + return Promise.all(attachments.map(async attachment => { + if (attachment.type !== MessageAttachmentKind.Chat) { + return attachment; + } + const sourceSession = isAhpChatChannel(attachment.resource) + ? parseRequiredSessionUriFromChatUri(attachment.resource) + : URI.parse(attachment.resource).toString(); + if (sourceSession !== URI.parse(sessionChannel).toString()) { + throw new Error(`Chat attachment source must belong to the target session: ${attachment.resource}`); + } + const sourceTurns = await this._options.resolveChatAttachmentTurns?.(attachment.resource) + ?? this._resolveSourceChatTurns(attachment.resource); + return resolveChatAttachment(attachment, sourceTurns); + })); + } + + private _resolveSourceChatTurns(sourceUri: string): readonly Turn[] { + const peerState = this._stateManager.getChatState(sourceUri); + const state = peerState ?? this._stateManager.getDefaultChatState(sourceUri); + return state?.turns ?? []; + } + /** * Surfaces a failed first turn on a not-yet-materialized session as a * terminal creation failure. diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index ea9cb972d60..8839528bdc5 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -20,7 +20,7 @@ import { INativeEnvironmentService } from '../../../environment/common/environme import { ILogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSessionEntry, decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentPeerChats.js'; +import { AgentSessionEntry, decodeProviderData, encodeProviderData, prepareSideChatPrompt, stripSideChatContext, type IPersistedChat } from '../agentPeerChats.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; import { createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; import { ClaudePermissionMode, ClaudeSessionConfigKey, narrowClaudePermissionMode } from '../../common/claudeSessionConfigKeys.js'; @@ -505,7 +505,7 @@ export class ClaudeAgent extends Disposable implements IAgent { provider: this.id, displayName: localize('claudeAgent.displayName', "Claude"), description: localize('claudeAgent.description', "Claude agent backed by the Anthropic Claude Agent SDK"), - capabilities: { multipleChats: { fork: true } }, + capabilities: { multipleChats: { fork: true, sideChat: true } }, }; } @@ -1235,7 +1235,8 @@ export class ClaudeAgent extends Disposable implements IAgent { const chatKey = chat.toString(); const parentSessionId = AgentSession.id(session); let result: IAgentCreateChatResult | undefined; - await this._sessionSequencer.queue(parentSessionId, async () => { + const queueKey = options?.sideChat ? chatKey : parentSessionId; + await this._sessionSequencer.queue(queueKey, async () => { const existing = this._chatBackings.get(chatKey); if (existing) { // Idempotent re-create: hand back the existing backing so the @@ -1247,16 +1248,29 @@ export class ClaudeAgent extends Disposable implements IAgent { const model = options?.model ?? parentSession.model; let sdkSessionId: string | undefined; + let sideChat: IPersistedChat['sideChat']; if (options?.fork) { // If the fork point can't be resolved, fall through to a fresh // chat rather than inheriting the whole source backend. - sdkSessionId = await this._forkChat(session, options.fork); + sdkSessionId = (await this._forkChat(session, options.fork))?.sessionId; + } else if (options?.sideChat) { + const forked = await this._forkChat(session, options.sideChat); + sdkSessionId = forked?.sessionId; + if (!forked) { + throw new Error(`[Claude] createChat side chat: source turn ${options.sideChat.turnId} could not be forked`); + } + sideChat = { + source: options.sideChat.source.toString(), + turnId: options.sideChat.turnId, + inheritedTurnCount: forked.inheritedTurnCount, + ...(options.sideChat.partialResponse ? { partialResponse: options.sideChat.partialResponse } : {}), + }; } sdkSessionId ??= generateUuid(); // Record the live backing and hand the opaque blob back to the // orchestrator to persist. - const backing: IPersistedChat = { sdkSessionId, ...(model ? { model } : {}) }; + const backing: IPersistedChat = { sdkSessionId, ...(model ? { model } : {}), ...(sideChat ? { sideChat } : {}) }; this._chatBackings.set(chatKey, backing); result = { providerData: encodeProviderData(backing), backingSession: AgentSession.uri(this.id, sdkSessionId) }; @@ -1349,7 +1363,7 @@ export class ClaudeAgent extends Disposable implements IAgent { * caller creates a fresh chat instead) when the source chat or the * fork anchor cannot be resolved. */ - private async _forkChat(session: URI, fork: IAgentCreateChatOptions['fork'] & {}): Promise { + private async _forkChat(session: URI, fork: IAgentCreateChatOptions['fork'] & {}): Promise<{ sessionId: string; inheritedTurnCount: number } | undefined> { const sourceSdkId = await this._resolveChatSdkId(session, fork.source); if (!sourceSdkId) { this._logService.warn(`[Claude] createChat fork: source ${fork.source.toString()} has no SDK chat; creating fresh chat`); @@ -1362,7 +1376,9 @@ export class ClaudeAgent extends Disposable implements IAgent { return undefined; } const { sessionId } = await this._sdkService.forkSession(sourceSdkId, { upToMessageId }); - return sessionId; + const anchorIndex = messages.findIndex(message => message.uuid === upToMessageId); + const inheritedTurnCount = mapSessionMessagesToTurns(messages.slice(0, anchorIndex + 1), fork.source, this._logService).length; + return { sessionId, inheritedTurnCount }; } /** @@ -1507,7 +1523,7 @@ export class ClaudeAgent extends Disposable implements IAgent { if (!backing) { return; } - const updated: IPersistedChat = { sdkSessionId: backing.sdkSessionId, model }; + const updated: IPersistedChat = { ...backing, model }; this._chatBackings.set(chat.toString(), updated); this._onDidChangeChatData.fire({ chat: chat, providerData: encodeProviderData(updated) }); } @@ -1607,7 +1623,9 @@ export class ClaudeAgent extends Disposable implements IAgent { if (!sdkId) { return []; } - return this._reconstructTurns(sdkId, chat, context.target); + const turns = await this._reconstructTurns(sdkId, chat, context.target); + const sideChat = this._resolveChatBacking(chat)?.sideChat; + return stripSideChatContext(turns.slice(sideChat?.inheritedTurnCount ?? 0), sideChat); } const sess = context.target; @@ -1834,7 +1852,10 @@ export class ClaudeAgent extends Disposable implements IAgent { if (context.isPeerChat) { return this._sessionSequencer.queue(context.chatKey, async () => { const chatSession = await this._materializeChatLocked(context.session, chat); - await chatSession.send(this._buildSdkPrompt(chatSession.sessionId, prompt, attachments, effectiveTurnId), effectiveTurnId); + const sideChat = this._resolveChatBacking(chat)?.sideChat; + const turns = sideChat ? await this._reconstructTurns(chatSession.sessionId, chat, chatSession) : []; + const sdkPrompt = prepareSideChatPrompt(prompt, turns, sideChat); + await chatSession.send(this._buildSdkPrompt(chatSession.sessionId, sdkPrompt, attachments, effectiveTurnId), effectiveTurnId); }); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index ec8de32d973..d99d4741552 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -38,7 +38,7 @@ import { AgentHostConfigKey, agentHostCustomizationConfigSchema, DEFAULT_SESSION import { CopilotCliConfigKey, copilotCliConfigSchema, type CopilotSdkLogLevelSetting } from '../../common/copilotCliConfig.js'; import { AgentHostMcpServersConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AutoApproveLevel, SessionMode, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSessionEntry, decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentPeerChats.js'; +import { AgentSessionEntry, decodeProviderData, encodeProviderData, prepareSideChatPrompt, stripSideChatContext, type IPersistedChat } from '../agentPeerChats.js'; import { AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatDataChange, IAgentChats, IAgentLegacyChat, IAgentCreateChatForkSource, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, IRestoredSubagentSession, SubagentChatSignal } from '../../common/agentService.js'; import { getReasoningEffortDescription, getReasoningEffortLabel } from '../../common/reasoningEffort.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; @@ -561,7 +561,7 @@ export class CopilotAgent extends Disposable implements IAgent { provider: 'copilotcli', displayName: 'Copilot', description: localize('copilotAgent.description', "Copilot SDK agent running in the local agent host process"), - capabilities: { multipleChats: { fork: true } }, + capabilities: { multipleChats: { fork: true, sideChat: true } }, }; } @@ -1936,7 +1936,10 @@ export class CopilotAgent extends Disposable implements IAgent { if (turnId) { entry.resetTurnState(turnId, senderClientId); } - await entry.send(prompt, attachments, turnId, this._resolveSdkMode(context.session), senderClientId); + const sideChat = this._chatBackings.get(chat.toString())?.sideChat; + const existingTurns = sideChat ? await entry.getMessages() : []; + const sdkPrompt = prepareSideChatPrompt(prompt, existingTurns, sideChat); + await entry.send(sdkPrompt, attachments, turnId, this._resolveSdkMode(context.session), senderClientId); return; } await this._sessionSequencer.queue(context.sessionId, async () => { @@ -2080,7 +2083,9 @@ export class CopilotAgent extends Disposable implements IAgent { const context = this._getChatContext(chat); if (context.isPeerChat) { const entry = await this._ensureChatSession(context.session, chat); - return entry ? entry.getMessages() : []; + const turns = entry ? await entry.getMessages() : []; + const sideChat = this._chatBackings.get(chat.toString())?.sideChat; + return stripSideChatContext(turns.slice(sideChat?.inheritedTurnCount ?? 0), sideChat); } const sessionId = context.sessionId; @@ -2214,7 +2219,8 @@ export class CopilotAgent extends Disposable implements IAgent { } const sessionId = AgentSession.id(session); let result: IAgentCreateChatResult | undefined; - await this._sessionSequencer.queue(sessionId, async () => { + const queueKey = options?.sideChat ? chatKey : sessionId; + await this._sessionSequencer.queue(queueKey, async () => { // Re-check inside the per-session sequencer: the outer `has` check // above is only a fast early-out. If two `createChat` calls for the // same chat URI race, both can pass that outer check; the sequencer @@ -2250,6 +2256,7 @@ export class CopilotAgent extends Disposable implements IAgent { // spin up a fresh empty chat. let launchPlan: CopilotSessionLaunchPlan; let sdkSessionId: string; + let sideChat: IPersistedChat['sideChat']; if (options?.fork) { if (!workingDirectory) { throw new Error(`[Copilot] createChat fork: missing working directory for session ${session.toString()}`); @@ -2271,6 +2278,33 @@ export class CopilotAgent extends Disposable implements IAgent { githubToken: this._githubToken, fallback: { model, longContextWindow: this._longContextWindowFor(model?.id), freeLongContext: this._isFreeLongContext(model?.id) }, }; + } else if (options?.sideChat) { + if (!workingDirectory) { + throw new Error(`[Copilot] createChat side chat: missing working directory for session ${session.toString()}`); + } + const sourceEntry = await this._resolveChatEntry(session, options.sideChat.source); + if (!sourceEntry) { + throw new Error(`[Copilot] createChat side chat: source chat ${options.sideChat.source.toString()} not found`); + } + sdkSessionId = await this._forkSdkChat(client, sourceEntry, options.sideChat.turnId, this._sessionDataService.getSessionDataDir(chat)); + sideChat = { + source: options.sideChat.source.toString(), + turnId: options.sideChat.turnId, + inheritedTurnCount: 0, + ...(options.sideChat.partialResponse ? { partialResponse: options.sideChat.partialResponse } : {}), + }; + launchPlan = { + kind: 'resume', + client, + sessionId: sdkSessionId, + workingDirectory, + resolvedAgentName: undefined, + snapshot, + activeClientToolSet: activeClient.toolSet, + shellManager, + githubToken: this._githubToken, + fallback: { model, longContextWindow: this._longContextWindowFor(model?.id), freeLongContext: this._isFreeLongContext(model?.id) }, + }; } else { sdkSessionId = chatSdkId; launchPlan = { @@ -2292,6 +2326,9 @@ export class CopilotAgent extends Disposable implements IAgent { try { agentSession = this._createAgentSession(launchPlan, workingDirectory, activeClient, { sessionUri: session, chatChannelUri: chat }); await agentSession.initializeSession(); + if (sideChat) { + sideChat = { ...sideChat, inheritedTurnCount: (await agentSession.getMessages()).length }; + } if (options?.fork?.turnIdMapping) { await agentSession.remapTurnIds(options.fork.turnIdMapping); } @@ -2299,7 +2336,7 @@ export class CopilotAgent extends Disposable implements IAgent { // Record the live backing and hand the opaque blob back to the // orchestrator to persist. The agent no longer owns a durable // peer-chat catalog (`copilot.chats` is no longer written). - const backing: IPersistedChat = { sdkSessionId, ...(model ? { model } : {}) }; + const backing: IPersistedChat = { sdkSessionId, ...(model ? { model } : {}), ...(sideChat ? { sideChat } : {}) }; this._chatBackings.set(chatKey, backing); result = { providerData: encodeProviderData(backing), backingSession: AgentSession.uri(this.id, sdkSessionId) }; this._logService.info(`[Copilot] Created additional chat ${chatKey} in session ${session.toString()}${options?.fork ? ' (forked)' : ''}`); @@ -2590,7 +2627,7 @@ export class CopilotAgent extends Disposable implements IAgent { await context.target?.setModel(model.id, resolveCopilotReasoningEffort(model, this._configurationService, this._logService, context.sessionId), getCopilotContextTier(model, longContextWindow, freeLongContext)); const backing = this._chatBackings.get(context.chatKey); if (backing) { - const updated: IPersistedChat = { sdkSessionId: backing.sdkSessionId, model }; + const updated: IPersistedChat = { ...backing, model }; this._chatBackings.set(context.chatKey, updated); this._onDidChangeChatData.fire({ chat: chat, providerData: encodeProviderData(updated) }); } diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 8e9c02d3230..d6ce56420cb 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -54,6 +54,7 @@ import { type OtlpLogLevelName, } from '../common/otlp/otlpLogEmitter.js'; import { isFileResourceRead } from '../common/resourceReadLogging.js'; +import { ChatSourceKind } from '../common/state/protocol/channels-chat/commands.js'; /** Default capacity of the server-side action replay buffer. */ const REPLAY_BUFFER_CAPACITY = 1000; @@ -1215,7 +1216,8 @@ export class ProtocolServerHandler extends Disposable { URI.parse(params.channel), URI.parse(params.chat), { - ...(params.source ? { fork: { source: URI.parse(params.source.chat), turnId: params.source.turnId } } : {}), + ...(params.source?.kind === ChatSourceKind.Fork ? { fork: { source: URI.parse(params.source.chat), turnId: params.source.turnId } } : {}), + ...(params.source?.kind === ChatSourceKind.SideChat ? { sideChat: { source: URI.parse(params.source.chat), turnId: params.source.turnId } } : {}), }, ); return null; diff --git a/src/vs/platform/agentHost/test/common/state/chatAttachmentContext.test.ts b/src/vs/platform/agentHost/test/common/state/chatAttachmentContext.test.ts new file mode 100644 index 00000000000..4e3af3b93c5 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/state/chatAttachmentContext.test.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { boundChatTranscriptTurns, formatChatTranscript, resolveChatAttachment } from '../../../common/state/chatAttachmentContext.js'; +import { MessageAttachmentKind, MessageKind, ResponsePartKind, TurnState, type Turn } from '../../../common/state/sessionState.js'; +import { type MessageChatAttachment } from '../../../common/state/protocol/state.js'; + +/** Build a completed turn with a user message and a single assistant markdown reply. */ +function turn(id: string, userText: string, assistantText: string, extraAttachments?: Turn['message']['attachments']): Turn { + return { + id, + message: { text: userText, origin: { kind: MessageKind.User }, ...(extraAttachments ? { attachments: extraAttachments } : {}) }, + responseParts: [ + // A reasoning part must be ignored by the transcript formatter. + { kind: ResponsePartKind.Reasoning, id: `${id}-reason`, content: 'internal reasoning' }, + { kind: ResponsePartKind.Markdown, id: `${id}-md`, content: assistantText }, + ], + usage: undefined, + state: TurnState.Complete, + }; +} + +suite('chatAttachmentContext', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('boundChatTranscriptTurns bounds through endTurn (inclusive) and falls back to all when absent', () => { + const turns = [turn('t1', 'a', 'A'), turn('t2', 'b', 'B'), turn('t3', 'c', 'C')]; + assert.deepStrictEqual({ + bounded: boundChatTranscriptTurns(turns, 't2').map(t => t.id), + missing: boundChatTranscriptTurns(turns, 'nope').map(t => t.id), + }, { + bounded: ['t1', 't2'], + missing: ['t1', 't2', 't3'], + }); + }); + + test('formatChatTranscript renders user + assistant text only (ignores tool calls)', () => { + const text = formatChatTranscript([turn('t1', 'hi', 'hello'), turn('t2', 'more', 'sure')]); + assert.strictEqual(text, 'User: hi\n\nAssistant: hello\n\nUser: more\n\nAssistant: sure'); + }); + + test('resolveChatAttachment produces a Simple attachment carrying the bounded transcript', () => { + const turns = [turn('t1', 'first', 'reply one'), turn('t2', 'second', 'reply two'), turn('t3', 'later', 'reply three')]; + const attachment: MessageChatAttachment = { type: MessageAttachmentKind.Chat, resource: 'ahp-chat://c/src', endTurn: 't2', label: 'Conversation so far' }; + const resolved = resolveChatAttachment(attachment, turns); + assert.strictEqual(resolved.type, MessageAttachmentKind.Simple); + assert.strictEqual(resolved.label, 'Conversation so far'); + // Bounded through t2: t3 must be excluded. + assert.ok(resolved.modelRepresentation!.includes('User: first')); + assert.ok(resolved.modelRepresentation!.includes('Assistant: reply two')); + assert.ok(!resolved.modelRepresentation!.includes('reply three')); + }); + + test('resolveChatAttachment does not recursively expand chat attachments inside the source transcript', () => { + // A source turn whose message itself carries a Chat attachment: the + // resolver must not follow it (non-recursive expansion). + const nested: MessageChatAttachment = { type: MessageAttachmentKind.Chat, resource: 'ahp-chat://c/other', endTurn: 'x', label: 'Nested' }; + const turns = [turn('t1', 'ask', 'answer', [nested])]; + const resolved = resolveChatAttachment({ type: MessageAttachmentKind.Chat, resource: 'ahp-chat://c/src', endTurn: 't1', label: 'Conversation so far' }, turns); + assert.ok(!resolved.modelRepresentation!.includes('ahp-chat://c/other')); + assert.ok(!resolved.modelRepresentation!.includes('Nested')); + assert.ok(resolved.modelRepresentation!.includes('User: ask')); + }); +}); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index a31ea5d0d48..032c3298790 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -18,6 +18,7 @@ import { AgentHostClientState, RemoteAgentHostProtocolClient } from '../../brows import { AgentHostPermissionMode, AgentHostResourcePermissionError, IAgentHostResourceService } from '../../common/agentHostResourceService.js'; import { ConfigurationTarget, type IConfigurationValue } from '../../../configuration/common/configuration.js'; import { ContentEncoding, ReconnectResultType } from '../../common/state/protocol/commands.js'; +import { ChatSourceKind } from '../../common/state/protocol/channels-chat/commands.js'; import { AhpErrorCodes } from '../../common/state/protocol/errors.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; import { ActionType, type ChatTurnStartedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionTitleChangedAction } from '../../common/state/sessionActions.js'; @@ -407,26 +408,67 @@ suite('RemoteAgentHostProtocolClient', () => { assert.strictEqual(await creation, session); }); - test('maps protocol-supported create chat fork', async () => { - const { client, transport } = createClient(); - await connectClient(client, transport); - const session = URI.parse('ahp-session:/session'); - const chat = URI.parse('ahp-chat:/chat'); - const source = URI.parse('ahp-chat:/source'); - const creation = client.createChat(session, chat, { fork: { source, turnId: 'turn-1' } }); + suite('createChat', () => { + const sessionUri = URI.parse('ahp-session:/test'); + const chatUri = URI.parse('ahp-session:/test/chat-1'); + const sourceUri = URI.parse('ahp-session:/test/chat-0'); - const request = transport.sentMessages.find((message): message is JsonRpcRequest => - hasKey(message, { method: true }) && message.method === 'createChat'); - assert.deepStrictEqual(request?.params, { - channel: session.toString(), - chat: chat.toString(), - source: { chat: source.toString(), turnId: 'turn-1' }, + test('forwards a fork source tagged with kind "fork"', async () => { + const { client, transport } = createClient(); + + const resultPromise = client.createChat(sessionUri, chatUri, { fork: { source: sourceUri, turnId: 'turn-1' } }); + + assert.deepStrictEqual(transport.sentMessages[0], { + jsonrpc: '2.0', + id: 1, + method: 'createChat', + params: { + channel: sessionUri.toString(), + chat: chatUri.toString(), + source: { kind: ChatSourceKind.Fork, chat: sourceUri.toString(), turnId: 'turn-1' }, + }, + }); + + transport.fireMessage({ jsonrpc: '2.0', id: 1, result: null }); + await resultPromise; }); - assert.ok(request); - transport.fireMessage({ jsonrpc: '2.0', id: request.id, result: null }); - await creation; - }); + test('forwards a side chat (`/btw`) source tagged with kind "sideChat"', async () => { + const { client, transport } = createClient(); + + const resultPromise = client.createChat(sessionUri, chatUri, { sideChat: { source: sourceUri, turnId: 'turn-1' } }); + + assert.deepStrictEqual(transport.sentMessages[0], { + jsonrpc: '2.0', + id: 1, + method: 'createChat', + params: { + channel: sessionUri.toString(), + chat: chatUri.toString(), + source: { kind: ChatSourceKind.SideChat, chat: sourceUri.toString(), turnId: 'turn-1' }, + }, + }); + + transport.fireMessage({ jsonrpc: '2.0', id: 1, result: null }); + await resultPromise; + }); + + test('omits source entirely when neither fork nor sideChat is requested', async () => { + const { client, transport } = createClient(); + + const resultPromise = client.createChat(sessionUri, chatUri); + + assert.deepStrictEqual(transport.sentMessages[0], { + jsonrpc: '2.0', + id: 1, + method: 'createChat', + params: { channel: sessionUri.toString(), chat: chatUri.toString() }, + }); + + transport.fireMessage({ jsonrpc: '2.0', id: 1, result: null }); + await resultPromise; + }); + }); test('preserves JSON-RPC error code and data', async () => { const { client, transport } = createClient(); const resultPromise = client.resourceRead(URI.file('/missing')); diff --git a/src/vs/platform/agentHost/test/node/agentPeerChats.test.ts b/src/vs/platform/agentHost/test/node/agentPeerChats.test.ts new file mode 100644 index 00000000000..d5dfd147b18 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentPeerChats.test.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { MessageKind, TurnState, type Turn } from '../../common/state/sessionState.js'; +import { prepareSideChatPrompt, stripSideChatContext, type IPersistedSideChat } from '../../node/agentPeerChats.js'; + +suite('agentPeerChats', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const sourceTurn: Turn = { + id: 'source-turn', + state: TurnState.Complete, + message: { text: 'source question', origin: { kind: MessageKind.User } }, + responseParts: [], + usage: undefined, + }; + const sideChat: IPersistedSideChat = { + source: 'ahp-chat://default/source', + turnId: sourceTurn.id, + inheritedTurnCount: 1, + }; + + test('first prompt prefers explanation and remains hidden from visible history', () => { + const prepared = prepareSideChatPrompt('What is happening?', [sourceTurn], sideChat); + const visible = stripSideChatContext([{ + ...sourceTurn, + id: 'side-turn', + message: { ...sourceTurn.message, text: prepared }, + }], sideChat); + + assert.deepStrictEqual({ + hasGuidance: prepared.includes('Prefer explanation over action; do not make changes or carry out work unless the user explicitly asks.'), + visiblePrompt: visible[0]?.message.text, + }, { + hasGuidance: true, + visiblePrompt: 'What is happening?', + }); + }); + + test('later prompts are not wrapped again', () => { + const existingSideTurn: Turn = { + ...sourceTurn, + id: 'side-turn', + message: { ...sourceTurn.message, text: 'What is happening?' }, + }; + + assert.strictEqual(prepareSideChatPrompt('Follow up', [sourceTurn, existingSideTurn], sideChat), 'Follow up'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 75f19079dc6..ba2f9a3405b 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -3263,6 +3263,196 @@ suite('AgentService (node dispatcher)', () => { }); }); + suite('createChat side chats', () => { + + class SideChatAgent extends MockAgent { + lastCreateOptions: IAgentCreateChatOptions | undefined; + readonly chatMessages = new Map(); + override async createChat(_session: URI, _chat: URI, options?: IAgentCreateChatOptions): Promise { + this.lastCreateOptions = options; + } + override async getSessionMessages(chat: URI): Promise { + return this.chatMessages.get(chat.toString()) ?? super.getSessionMessages(chat); + } + } + + function completedTurn(id: string, userText = 'user text', assistantText = 'assistant text'): Turn { + return { + id, + state: TurnState.Complete, + message: { text: userText, origin: { kind: MessageKind.User } }, + responseParts: [{ kind: ResponsePartKind.Markdown, id: `${id}-md`, content: assistantText }], + usage: undefined, + }; + } + + test('rejects a side chat whose source turn does not exist', async () => { + const agent = disposables.add(new SideChatAgent('copilot')); + service.registerProvider(agent); + const session = await service.createSession({ provider: 'copilot' }); + const chatUri = URI.parse(buildChatUri(session, 'side-1')); + + await assert.rejects( + () => service.createChat(session, chatUri, { sideChat: { source: session, turnId: 'missing' } }), + /side chat source turn/, + ); + }); + + test('rejects a side chat whose source chat is in a different session', async () => { + const agent = disposables.add(new SideChatAgent('copilot')); + service.registerProvider(agent); + const sessionA = await service.createSession({ provider: 'copilot' }); + const sessionB = await service.createSession({ provider: 'copilot' }); + service.stateManager.seedDefaultChatTurns(sessionB.toString(), [completedTurn('t1')]); + const chatUri = URI.parse(buildChatUri(sessionA, 'side-1')); + + await assert.rejects( + () => service.createChat(sessionA, chatUri, { sideChat: { source: sessionB, turnId: 't1' } }), + /does not belong to session/, + ); + }); + + test('creates a fresh peer with a SideChat origin and no copied source turns', async () => { + const agent = disposables.add(new SideChatAgent('copilot')); + service.registerProvider(agent); + const session = await service.createSession({ provider: 'copilot' }); + service.stateManager.seedDefaultChatTurns(session.toString(), [completedTurn('t1'), completedTurn('t2')]); + const chatUri = URI.parse(buildChatUri(session, 'side-1')); + + await service.createChat(session, chatUri, { sideChat: { source: session, turnId: 't1' } }); + const state = service.stateManager.getChatState(chatUri.toString()); + + assert.deepStrictEqual({ + origin: state?.origin, + copiedTurns: state?.turns.length, + forkForwarded: agent.lastCreateOptions?.fork, + sideChatForwarded: agent.lastCreateOptions?.sideChat, + }, { + origin: { kind: ChatOriginKind.SideChat, chat: session.toString(), turnId: 't1' }, + copiedTurns: 0, + forkForwarded: undefined, + sideChatForwarded: { source: session, turnId: 't1' }, + }); + }); + + test('creates a side chat from the current active turn', async () => { + const agent = disposables.add(new SideChatAgent('copilot')); + service.registerProvider(agent); + const session = await service.createSession({ provider: 'copilot' }); + const sourceChat = buildDefaultChatUri(session); + service.dispatchAction(sourceChat, { + type: ActionType.ChatTurnStarted, + turnId: 'active-turn', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'still running', origin: { kind: MessageKind.User } }, + }, 'test-client', 1); + service.stateManager.dispatchServerAction(sourceChat, { + type: ActionType.ChatResponsePart, + turnId: 'active-turn', + part: { kind: ResponsePartKind.Markdown, id: 'partial', content: 'partial answer' }, + }); + const chatUri = URI.parse(buildChatUri(session, 'side-active')); + + await service.createChat(session, chatUri, { sideChat: { source: URI.parse(sourceChat), turnId: 'active-turn' } }); + + assert.deepStrictEqual({ + sourceActiveTurn: service.stateManager.getChatState(sourceChat)?.activeTurn?.id, + origin: service.stateManager.getChatState(chatUri.toString())?.origin, + sideChatForwarded: agent.lastCreateOptions?.sideChat + ? { + source: agent.lastCreateOptions.sideChat.source.toString(), + turnId: agent.lastCreateOptions.sideChat.turnId, + partialResponse: agent.lastCreateOptions.sideChat.partialResponse, + } + : undefined, + }, { + sourceActiveTurn: 'active-turn', + origin: { kind: ChatOriginKind.SideChat, chat: sourceChat, turnId: 'active-turn' }, + sideChatForwarded: { source: sourceChat, turnId: 'active-turn', partialResponse: 'partial answer' }, + }); + }); + + test('persists and restores the SideChat origin', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new SideChatAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + localService.stateManager.seedDefaultChatTurns(session.toString(), [completedTurn('t1')]); + const chatUri = URI.parse(buildChatUri(session, 'side-1')); + await localService.createChat(session, chatUri, { sideChat: { source: session, turnId: 't1' } }); + + let persistedOrigin: unknown; + for (let i = 0; i < 50; i++) { + const raw = await db.getMetadata('peerChats'); + if (raw !== undefined) { + const parsed = JSON.parse(raw) as { uri: string; origin?: unknown }[]; + persistedOrigin = parsed.find(entry => entry.uri === chatUri.toString())?.origin; + if (persistedOrigin) { + break; + } + } + await timeout(1); + } + + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + + assert.deepStrictEqual({ + persistedOrigin, + restoredOrigin: localService.stateManager.getChatState(chatUri.toString())?.origin, + }, { + persistedOrigin: { kind: ChatOriginKind.SideChat, chat: session.toString(), turnId: 't1' }, + restoredOrigin: { kind: ChatOriginKind.SideChat, chat: session.toString(), turnId: 't1' }, + }); + }); + + test('hydrates a missing peer chat when resolving a generic Chat attachment', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new SideChatAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + const peerChat = URI.parse(buildChatUri(session, 'peer-1')); + await localService.createChat(session, peerChat); + for (let i = 0; i < 50 && await db.getMetadata('peerChats') === undefined; i++) { + await timeout(1); + } + agent.chatMessages.set(peerChat.toString(), [completedTurn('peer-turn', 'Remember X', 'Remembered')]); + localService.stateManager.removeChat(session.toString(), peerChat.toString()); + + const sent = Event.toPromise(agent.onDidSendMessage); + localService.dispatchAction(buildDefaultChatUri(session), { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { + text: 'What was remembered?', + origin: { kind: MessageKind.User }, + attachments: [{ + type: MessageAttachmentKind.Chat, + resource: peerChat.toString(), + endTurn: 'peer-turn', + label: 'Earlier chat', + }], + }, + }, 'client-1', 1); + await sent; + + const attachment = agent.sendMessageCalls[0].attachments?.[0]; + assert.deepStrictEqual({ + peerHydrated: !!localService.stateManager.getChatState(peerChat.toString()), + type: attachment?.type, + hasTranscript: attachment?.type === MessageAttachmentKind.Simple && attachment.modelRepresentation?.includes('User: Remember X'), + }, { + peerHydrated: true, + type: MessageAttachmentKind.Simple, + hasTranscript: true, + }); + }); + + }); + // ---- chat surface routing (G-C1) ---------------------------- suite('chat surface routing', () => { diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 84cfbf2aaff..32cc3c23cc3 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -26,7 +26,8 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js'; import { ChangesSummary, ChatOriginKind, CustomizationType, McpAuthRequiredReason, SessionInputRequestKind } from '../../common/state/protocol/state.js'; import { ActionType, ActionEnvelope, type ChatAction, type INotification, type SessionAction } from '../../common/state/sessionActions.js'; -import { buildSubagentChatUri, buildChatUri, buildDefaultChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInteractivity, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, customizationId, type ClientPluginCustomization, type Customization, type PluginCustomization } from '../../common/state/sessionState.js'; +import { buildSubagentChatUri, buildChatUri, buildDefaultChatUri, ChatInteractivity, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, customizationId, type ClientPluginCustomization, type Customization, type PluginCustomization, type Turn } from '../../common/state/sessionState.js'; +import { buildSubagentChatUri, buildChatUri, buildDefaultChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInteractivity, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, customizationId, type ClientPluginCustomization, type Customization, type PluginCustomization, type Turn } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; @@ -403,6 +404,92 @@ suite('AgentSideEffects', () => { }]); }); + test('rejects chat attachments that reference another session', async () => { + setupSession(); + const otherSessionUri = AgentSession.uri('mock', 'session-2'); + stateManager.createSession({ + resource: otherSessionUri.toString(), + provider: 'mock', + title: 'Other', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + stateManager.dispatchServerAction(otherSessionUri.toString(), { type: ActionType.SessionReady }); + + const error = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, (envelope): envelope is ActionEnvelope => + envelope.action.type === ActionType.ChatError && envelope.channel === defaultChatUri)); + sideEffects.handleAction(defaultChatUri, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { + text: 'read another session', + origin: { kind: MessageKind.User }, + attachments: [{ + type: MessageAttachmentKind.Chat, + resource: otherSessionUri.toString(), + endTurn: 'other-turn', + label: 'Other session', + }], + }, + }); + + const envelope = await error; + assert.deepStrictEqual({ + sendMessageCalls: agent.sendMessageCalls.length, + errorType: envelope.action.type === ActionType.ChatError ? envelope.action.error.errorType : undefined, + }, { + sendMessageCalls: 0, + errorType: 'sendFailed', + }); + }); + + test('awaits hydrated turns when resolving a chat attachment', async () => { + setupSession(); + const sourceTurn: Turn = { + id: 'source-turn', + state: TurnState.Complete, + message: { text: 'Remember X', origin: { kind: MessageKind.User } }, + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'response', content: 'Remembered' }], + usage: undefined, + }; + const resolvingSideEffects = createTestSideEffects(disposables, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService: createNullSessionDataService(), + resolveChatAttachmentTurns: async () => [sourceTurn], + onTurnComplete: () => { }, + }); + resolvingSideEffects.handleAction(defaultChatUri, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { + text: 'What was remembered?', + origin: { kind: MessageKind.User }, + attachments: [{ + type: MessageAttachmentKind.Chat, + resource: sessionUri.toString(), + endTurn: sourceTurn.id, + label: 'Earlier chat', + }], + }, + }); + + await waitForSendMessageCalls(1); + const attachment = agent.sendMessageCalls[0].attachments?.[0]; + assert.deepStrictEqual({ + type: attachment?.type, + hasUser: attachment?.type === MessageAttachmentKind.Simple && attachment.modelRepresentation?.includes('User: Remember X'), + hasAssistant: attachment?.type === MessageAttachmentKind.Simple && attachment.modelRepresentation?.includes('Assistant: Remembered'), + }, { + type: MessageAttachmentKind.Simple, + hasUser: true, + hasAssistant: true, + }); + }); + test('dispatches session/error when no agent is found', async () => { setupSession(); const emptyAgents = observableValue('agents', []); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 8e44f96869e..85240d3ab4d 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -67,6 +67,7 @@ import { IClaudeProxyCreditsReport, IClaudeProxyHandle, IClaudeProxyService } fr import { resolvePromptToContentBlocks } from '../../node/claude/claudePromptResolver.js'; import { ICopilotApiService, type ICopilotApiServiceRequestOptions } from '../../node/shared/copilotApiService.js'; import { AgentService } from '../../node/agentService.js'; +import { injectSideChatContext } from '../../node/agentPeerChats.js'; import { createNoopGitService, createNullSessionDataService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; // #region Test fakes @@ -6747,6 +6748,68 @@ suite('ClaudeAgent — Phase 11 customizations', () => { }); }); + test('createChat({ sideChat }) forks hidden context and filters inherited turns', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + sdk.forkSessionResult = { sessionId: 'side-1' }; + sdk.sessionList = [{ sessionId: 'side-1', summary: 'side', lastModified: 1, cwd: URI.file('/work').fsPath }]; + const partialResponse = 'partial source answer'; + const injectedPrompt = injectSideChatContext('side question', partialResponse); + sdk.sessionMessagesById.set('side-1', forkSourceMessages('side-1').slice(0, 2)); + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-side')); + const internals = agent as unknown as { + _sessionSequencer: { queue(key: string, task: () => Promise): Promise }; + }; + const sourceLockEntered = new DeferredPromise(); + const releaseSourceLock = new DeferredPromise(); + const sourceLock = internals._sessionSequencer.queue(parentId, async () => { + sourceLockEntered.complete(); + await releaseSourceLock.p; + }); + await sourceLockEntered.p; + let result; + const createTimeout = timeout(5_000); + try { + result = await Promise.race([ + agent.chats.createChat(chatUri, { sideChat: { source: created.session, turnId: 'u1', partialResponse } }), + createTimeout.then(() => { throw new Error('Side chat creation waited for the source turn lock'); }), + ]); + } finally { + createTimeout.cancel(); + releaseSourceLock.complete(); + await sourceLock; + } + sdk.nextQueryMessages = [makeSystemInitMessage('side-1'), makeResultSuccess('side-1')]; + await agent.chats.sendMessage(chatUri, 'side question', undefined, undefined, 'turn-side'); + const sentContent = sdk.warmQueries.at(-1)?.produced?.drainedPrompts[0]?.message.content; + const sentPrompt = typeof sentContent === 'string' + ? sentContent + : sentContent?.filter(block => block.type === 'text').map(block => block.text).join('\n'); + sdk.sessionMessagesById.set('side-1', [ + ...forkSourceMessages('side-1').slice(0, 2), + { type: 'user', uuid: 'turn-side', session_id: 'side-1', parent_tool_use_id: null, message: { role: 'user', content: [{ type: 'text', text: injectedPrompt }] } }, + { type: 'assistant', uuid: 'a3', session_id: 'side-1', parent_tool_use_id: null, message: { id: 'msg_a3', role: 'assistant', content: [{ type: 'text', text: 'side answer' }] } }, + ]); + await agent.chats.changeModel(chatUri, { id: 'claude-opus-4-6' }); + const turns = await agent.chats.getMessages(chatUri); + + assert.deepStrictEqual({ + forkCall: sdk.forkSessionCalls[0], + sentPrompt, + turns: turns.map(turn => turn.message.text), + sideChat: result ? JSON.parse(result.providerData!).sideChat : undefined, + }, { + forkCall: { sessionId: parentId, options: { upToMessageId: 'a1' } }, + sentPrompt: injectedPrompt, + turns: ['side question'], + sideChat: { source: created.session.toString(), turnId: 'u1', inheritedTurnCount: 1, partialResponse }, + }); + }); + test('createChat({ fork }) with an unknown turn falls back to a fresh chat', async () => { const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index f52bdb4b6f7..abd535fcecb 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -38,6 +38,8 @@ import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type import { ISessionDataService } from '../../common/sessionDataService.js'; import { buildDefaultChatUri, buildChatUri, buildSubagentChatUri, parseRequiredSessionUriFromChatUri, CustomizationLoadStatus, ResponsePartKind, ToolResultContentType, customizationId, type ClientPluginCustomization, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; import { CustomizationType, SessionStatus, ToolCallContributorKind, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; +import { buildDefaultChatUri, buildChatUri, buildSubagentChatUri, parseRequiredSessionUriFromChatUri, CustomizationLoadStatus, MessageKind, ResponsePartKind, ToolResultContentType, TurnState, customizationId, type ClientPluginCustomization, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; +import { CustomizationType, SessionStatus, ToolCallContributorKind, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -63,6 +65,10 @@ import { ActiveClientToolSet } from '../../node/activeClientState.js'; import { ByokLmBridgeRegistry, IByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; import { ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest, type IRestrictedTelemetryContext } from '../../node/shared/copilotApiService.js'; import type { IAgentHostInternalTelemetryContext, IAgentHostRestrictedTelemetryContext } from '../../node/agentHostRestrictedTelemetry.js'; +import { injectSideChatContext } from '../../node/agentPeerChats.js'; +import { ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest, type IRestrictedTelemetryContext } from '../../node/shared/copilotApiService.js'; +import type { IAgentHostInternalTelemetryContext, IAgentHostRestrictedTelemetryContext } from '../../node/agentHostRestrictedTelemetry.js'; +import { injectSideChatContext } from '../../node/agentPeerChats.js'; /** * Test helpers for the single `_sessions` container. All chats (default + peers) @@ -861,7 +867,7 @@ suite('CopilotAgent', () => { provider: 'copilotcli', displayName: 'Copilot', description: 'Copilot SDK agent running in the local agent host process', - capabilities: { multipleChats: { fork: true } }, + capabilities: { multipleChats: { fork: true, sideChat: true } }, }); } finally { await disposeAgent(agent); @@ -3290,6 +3296,8 @@ suite('CopilotAgent', () => { _chatBackings: Map; _sessions: Map; _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: unknown, identity?: { sessionUri: URI; chatChannelUri: URI }) => CopilotAgentSession; + _sessionSequencer: { queue(key: string, task: () => Promise): Promise }; + _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: unknown, identity?: { sessionUri: URI; chatChannelUri: URI }) => CopilotAgentSession; _forkSdkChat: (client: unknown, sourceEntry: unknown, turnId: string, targetDbDir: URI) => Promise; _resolveAgentName: (snapshot: IActiveClientSnapshot, agent: AgentSelection) => string | undefined; }; @@ -3471,6 +3479,87 @@ suite('CopilotAgent', () => { } }); + test('createChat side chat forks hidden context and filters inherited turns', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'side-peer'); + await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + const sourceTurn: Turn = { + id: 't1', + state: TurnState.Complete, + message: { text: 'source', origin: { kind: MessageKind.User } }, + responseParts: [], + usage: undefined, + }; + const partialResponse = 'partial source answer'; + const injectedPrompt = injectSideChatContext('side', partialResponse); + const sideTurn: Turn = { + id: 't2', + state: TurnState.Complete, + message: { text: injectedPrompt, origin: { kind: MessageKind.User } }, + responseParts: [], + usage: undefined, + }; + const source = makeFakeChatSession(session, 'source-sdk', async () => [sourceTurn]); + setDefaultSessionStub(agent, AgentSession.id(session), source.fake); + const internals = agent as unknown as ChatInternals; + internals._forkSdkChat = async () => 'side-sdk-id'; + let messageReadCount = 0; + let sideRecorder: IFakeChatRecorder | undefined; + internals._createAgentSession = launchPlan => { + const side = makeFakeChatSession(session, launchPlan.sessionId, async () => { + messageReadCount++; + return messageReadCount <= 2 ? [sourceTurn] : [sourceTurn, sideTurn]; + }, launchPlan.shellManager); + sideRecorder = side.rec; + return side.fake; + }; + + const chatUri = URI.parse(buildChatUri(session, 'peer-side')); + const sourceLockEntered = new DeferredPromise(); + const releaseSourceLock = new DeferredPromise(); + const sourceLock = internals._sessionSequencer.queue(AgentSession.id(session), async () => { + sourceLockEntered.complete(); + await releaseSourceLock.p; + }); + await sourceLockEntered.p; + let result; + const createTimeout = timeout(5_000); + try { + result = await Promise.race([ + agent.chats.createChat(chatUri, { sideChat: { source: URI.parse(buildDefaultChatUri(session)), turnId: 't1', partialResponse } }), + createTimeout.then(() => { throw new Error('Side chat creation waited for the source turn lock'); }), + ]); + } finally { + createTimeout.cancel(); + releaseSourceLock.complete(); + await sourceLock; + } + await agent.chats.sendMessage(chatUri, 'side', undefined, undefined, 't2'); + await agent.chats.sendMessage(chatUri, 'follow-up', undefined, undefined, 't3'); + await agent.chats.changeModel(chatUri, { id: 'gpt-y' }); + const turns = await agent.chats.getMessages(chatUri); + + assert.deepStrictEqual({ + hasExplanationGuidance: sideRecorder?.sends[0]?.prompt.includes('Prefer explanation over action'), + sentPrompts: sideRecorder?.sends.map(send => send.prompt), + turns: turns.map(turn => turn.id), + visiblePrompt: turns[0]?.message.text, + sideChat: result ? JSON.parse(result.providerData!).sideChat : undefined, + }, { + hasExplanationGuidance: true, + sentPrompts: [injectedPrompt, 'follow-up'], + turns: ['t2'], + visiblePrompt: 'side', + sideChat: { source: buildDefaultChatUri(session), turnId: 't1', inheritedTurnCount: 1, partialResponse }, + }); + } finally { + await disposeAgent(agent); + } + }); + test('sendMessage routes a turn to the targeted peer chat only', async () => { const agent = createTestAgent(disposables); try { diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-side-chat-receives-bounded-source-context-without-copied-history.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-side-chat-receives-bounded-source-context-without-copied-history.yaml new file mode 100644 index 00000000000..6d184384600 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-side-chat-receives-bounded-source-context-without-copied-history.yaml @@ -0,0 +1,31 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Remember the exact token SIDECHAT42 for a later question. Reply with exactly "ready". + response: + content: ready + stopReason: end_turn + usage: + inputTokens: 2686 + outputTokens: 4 + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Remember the exact token SIDECHAT42 for a later question. Reply with exactly "ready". + - role: assistant + content: ready + - role: user + content: What exact token did I ask you to remember? Reply with only the token. + response: + content: SIDECHAT42 + stopReason: end_turn + usage: + inputTokens: 2 + outputTokens: 10 diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-side-chat-receives-bounded-source-context-without-copied-history.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-side-chat-receives-bounded-source-context-without-copied-history.yaml new file mode 100644 index 00000000000..2507af9915f --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-side-chat-receives-bounded-source-context-without-copied-history.yaml @@ -0,0 +1,31 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-haiku-4.5 + system: ${system} + messages: + - role: user + content: Remember the exact token SIDECHAT42 for a later question. Reply with exactly "ready". + response: + content: ready + stopReason: end_turn + usage: + inputTokens: 9 + outputTokens: 65 + - request: + model: claude-haiku-4.5 + system: ${system} + messages: + - role: user + content: Remember the exact token SIDECHAT42 for a later question. Reply with exactly "ready". + - role: assistant + content: ready + - role: user + content: What exact token did I ask you to remember? Reply with only the token. + response: + content: SIDECHAT42 + stopReason: end_turn + usage: + inputTokens: 9 + outputTokens: 61 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 7ed1d5cff4f..12f0c0e682f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts @@ -220,6 +220,8 @@ export interface IAgentHostE2EProviderConfig { * session. Claude has not landed subagents yet (Phase 12 in roadmap). */ readonly supportsSubagents: boolean; + /** Whether the provider supports creating side chats from a source turn. */ + readonly supportsSideChats?: boolean; /** * When set, shell-dependent replay tests are skipped on Linux because this * provider completes recorded shell-tool turns without emitting tool-call 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 2a7d8b0d716..a9f2b61267f 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 @@ -71,6 +71,7 @@ const CLAUDE_CONFIG: IAgentHostE2EProviderConfig = { // isolation via the resolved working directory alone. supportsHostTerminalTool: false, supportsSubagents: true, + supportsSideChats: true, // Claude rebuilds a reopened subagent transcript from the SDK's on-disk // `subagents/agent-*.jsonl`, not reliably visible on Windows (see PR #325284). subagentReplayUnstableOnWindows: true, diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts index 16cfe760306..b56c66bd11d 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts @@ -52,6 +52,7 @@ const COPILOT_CONFIG: IAgentHostE2EProviderConfig = { supportsWorktreeIsolation: true, supportsHostTerminalTool: true, supportsSubagents: true, + supportsSideChats: true, supportsPlanMode: true, supportsMultipleChats: true, supportsChatFork: 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 71319f64594..d1383278f1e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts @@ -9,10 +9,11 @@ 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 { CompletionItemKind, type CompletionsResult, type ListSessionsResult, type SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import { ChatSourceKind, CompletionItemKind, type CompletionsResult, type ListSessionsResult, type SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { buildChatUri, buildDefaultChatUri, + ChatOriginKind, isAhpChatChannel, MessageAttachmentKind, MessageKind, @@ -46,12 +47,12 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { return { sessionUri, defaultChatUri: buildDefaultChatUri(sessionUri), workspace }; } - async function createPeer(sessionUri: string, id: string, source?: { chat: string; turnId: string }): Promise { + async function createPeer(sessionUri: string, id: string, source?: { chat: string; turnId: string; kind?: ChatSourceKind }): Promise { const chat = buildChatUri(sessionUri, id); await context.client.call('createChat', { channel: sessionUri, chat, - ...(source ? { source } : {}), + ...(source ? { source: { kind: source.kind ?? ChatSourceKind.Fork, chat: source.chat, turnId: source.turnId } } : {}), }, 30_000); return chat; } @@ -623,6 +624,41 @@ export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { assert.strictEqual(messages.some(message => message.content.includes('DEFAULTSECRET')), false); }, config.supportsMultipleChats && config.provider !== 'claude'); + providerTest('side chat receives bounded source context without copied history', async function () { + const { sessionUri, defaultChatUri } = await createSession('side-context'); + await driveTurn(defaultChatUri, 'turn-source', 'Remember the exact token SIDECHAT42 for a later question. Reply with exactly "ready".', 1); + + const sideChatUri = await createPeer(sessionUri, 'side', { + kind: ChatSourceKind.SideChat, + chat: defaultChatUri, + turnId: 'turn-source', + }); + await context.client.call('subscribe', { channel: sideChatUri }); + + const response = await driveTurn(sideChatUri, 'turn-side', 'What exact token did I ask you to remember? Reply with only the token.', 2); + const [sourceState, sideState, session] = await Promise.all([ + chatState(defaultChatUri), + chatState(sideChatUri), + sessionState(sessionUri), + ]); + + assert.deepStrictEqual({ + responseIncludesCode: /SIDECHAT42/i.test(response), + sourceTurnCount: sourceState.turns.length, + sideTurnCount: sideState.turns.length, + origin: session.chats.find(chat => chat.resource === sideChatUri)?.origin, + firstMessage: sideState.turns[0]?.message.text, + firstAttachments: sideState.turns[0]?.message.attachments ?? [], + }, { + responseIncludesCode: true, + sourceTurnCount: 1, + sideTurnCount: 1, + origin: { kind: ChatOriginKind.SideChat, chat: defaultChatUri, turnId: 'turn-source' }, + firstMessage: 'What exact token did I ask you to remember? Reply with only the token.', + firstAttachments: [], + }); + }, config.supportsMultipleChats && !!config.supportsSideChats); + providerTest('two peer chats keep independent provider contexts', async function () { const { sessionUri } = await createSession('two-contexts'); const first = await createPeer(sessionUri, 'first'); diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 0c3aaacc066..ca16d4642bf 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -278,6 +278,7 @@ Contributions are registered via module imports in entry points (`sessions.commo Key UI surfaces: - **Sessions View** — sidebar, shows sessions grouped by workspace with pinned section - **Changes View** — auxiliary bar, shows file changes for the active session +- **Side Chat editor** — top-level editor tab beside Changes/Files, shows the active session's newest `/btw` side chat, see [§10](#side-chat-editor-tab) - **Chat / New Chat views** — hosted inside each `SessionView` in the Sessions Part, registered via `IChatViewFactory` from `contrib/chat/` All session-window contributions use `WindowVisibility.Sessions` to only appear in the Agents Window. @@ -320,6 +321,29 @@ The Changes view's body is a vertical `SplitView` of File Changes, Other Files, `setEditorMaximized` (in `browser/workbench.ts`) treats maximize as a fully reversible state: on entering it snapshots the editor part's size and the surrounding parts' visibility, and on exiting it restores the auxiliary bar to its pre-maximize visibility and resizes the editor part back to its captured width. Without this, the auxiliary bar that the controller forces visible while maximized would otherwise remain (and shrink the editor) after un-maximizing, so the editor would not return to its previous size. +### Side Chat Editor Tab + +The **Side Chat** surface is a singleton editor tab (`contrib/sideChat/`) in the +same top-level tab strip as Changes, Files, Browser, and Search. It is not an +auxiliary-bar view: registering it there nests the chat beside the active +Changes/Files detail instead of giving it a peer tab. `SideChatEditor` wraps a +normal `ChatView`/`ChatWidget` (created via +`IChatViewFactory.createChatView()`, the same factory used for chats hosted in +a `SessionView`), so side chats get the full chat UI (composer, tool calls, +model/agent pickers) for free. + +The view reactively shows the **active session's newest side chat**: it reads +`ISessionsService.activeSession`'s `chats`, filters to +`origin?.kind === ChatOriginKind.SideChat`, and binds the last (most recently +created) match via `ChatView.setChat(chat, sessionId)`. Because it always shows +the *newest* side chat for the currently active session, switching sessions or +creating another side chat (via another `/btw`) reactively swaps the hosted +chat — there is no per-side-chat tab strip. `/btw` opens the singleton +`SideChatEditorInput` through `IEditorService`, appending it to the active main +editor group when it is not already open. In single-pane mode the Side Chat tab, +like Browser, temporarily hides the docked Changes/Files detail so the chat owns +the full side pane; returning to a managed tab restores the contextual detail. + ### Panel The panel (terminal / debug output) is hidden by default for all sessions. Each session independently tracks the user's last explicit show/hide action, and that state is restored on session switch. diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index f93f3309ffe..221d4632f1e 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -506,6 +506,84 @@ context is bounded to the same character budget (middle-truncated) as first-turn refinement, so it costs at most one small-model call, and a concurrent manual `/rename` suppresses it. +#### Side chats (`/btw`) + +A **side chat** is a peer chat branched from an existing chat's latest turn +to ask an unrelated, "by the way" question without polluting the source +conversation. Unlike a fork (which continues the same line of work as a new +chat), a side chat is surfaced in a dedicated, provider-agnostic **Side Chat** +editor tab rather than the normal conversation tab strip — see +[LAYOUT.md](LAYOUT.md#side-chat-editor-tab). + +Capability: `ISessionCapabilities.supportsSideChat`, derived by the agent host +provider from `agentCapabilities.multipleChats.sideChat` (mirroring +`supportsMultipleChats`/`fork`). Only providers with a complete side-chat +context/restore implementation advertise it (currently Claude and Copilot). + +Origin: side chats carry `IChat.origin.kind === ChatOriginKind.SideChat`. Like +subagent (`Tool`) chats, side-chat peers are **excluded** from the normal +sessions surfaces that assume a user-facing conversation: the chat tab strip +(`shouldShowChatTabs`/`openChats`), the **Conversations** menu +(`SessionConversationsMenuContribution`), the active-chat fallback when closing +a chat, and `committedChatCount` (so a session with only a main chat plus side +chats does not appear to "support multiple chats" in the UI). They still +appear in `ISession.chats` so the Side Chat editor can find them. + +Creation: `ISessionsManagementService.createSideChatInSession(session, +sourceChat, turnId)` → `ISessionsProvider.createSideChat`. The service throws +if the provider or session doesn't support side chats (mirroring +`forkChatInSession`); it never returns `undefined`. On the agent host, +`createSideChat` mints a client-chosen chat URI and calls +`connection.createChat(sessionUri, chatUri, { model, sideChat: { source, +turnId } })` — analogous to `forkChat`'s `{ fork: { source, turnId } }`, but +the new chat inherits the **source chat's own** model/agent selection (not the +session-level default), read via `getChatModelId(sourceChat)`/ +`getChatMode(sourceChat)` and re-applied to the new chat once it appears in the +catalog (`setChatModelId`/`setChatAgent`/`_updateChatSessionState`), matching +the plan's "inherits model/agent" requirement for a side chat asking a +tangential question with the same context as the turn it branched from. +The host records the `SideChat` origin but does not mutate the first user +message. Claude and Copilot use their SDK fork primitives to inherit source +context privately, persist the inherited-prefix length in providerData, and +filter those inherited turns from `getMessages()` so the Side Chat editor only +shows turns authored in that side chat. + +Invocation: the `/btw` slash command (registered against the core +`IChatSlashCommandService`, in `contrib/chat/browser/btwSlashCommand.contribution.ts` +— a sessions-owned contribution, not a change to the core `chatSlashCommands.ts`) +is only offered in the Agents window, on created (non-`Untitled`), non-archived +sessions whose provider `supportsSideChat` (`when` gates the completion; the +callback re-checks all three at execution time, since `when` is not +re-evaluated when a command actually runs). It is `silent: true` (no +request/response row is added to the **source** chat) and +`executeDuringRequest: true`, so the chat widget invokes it directly instead of +queueing or steering it behind an active source turn. It anchors to the source +chat's latest request, including an in-progress turn; only a chat with no turns +shows a localized warning. +Each invocation creates a **fresh** side chat (there is no "reuse the last side +chat" behavior). After creating the chat, it opens/focuses the singleton Side +Chat editor tab (`IEditorService.openEditor(SideChatEditorInput)`) and, if the user typed +text after `/btw `, sends it as a background request +(`sessionsManagementService.sendRequest(session, sideChat, { query, background: +true })`) so the Side Chat editor's `ChatWidget` shows the request without +navigating away from the source chat. + +The agent host accepts the anchor when it is either in `turns` or +`activeTurn`. Claude and Copilot serialize side-chat creation on the new chat's +key rather than the source session's send key, allowing their native fork +primitive to snapshot all provider transcript/events written up to that moment +while the source turn continues. Native Copilot forks do not persist streamed +assistant deltas before the final assistant message, so AgentService separately +captures the active turn's user-visible markdown (bounded to 20,000 characters). +The provider wraps the first SDK prompt in a private `` +block. Every side chat receives the succinct instruction: "Prefer explanation +over action; do not make changes or carry out work unless the user explicitly +asks." When present, the partial-response snapshot follows that instruction. +Provider reconstruction strips the whole block from the first visible side-chat +turn, so the UI and restored transcript continue to show only the user's `/btw` +question. Reasoning, tool payloads, and other non-markdown response parts are +deliberately not injected. + The session handler (`agentHostSessionHandler.ts`) routes each chat widget to its own AHP chat channel. Session-scoped reads (`summary`/`config`/`activeClient`) stay on the session URI, while conversation reads/dispatches diff --git a/src/vs/sessions/SINGLE_PANE_SCENARIOS.md b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md index ff2c35b8850..8d7f0833fcd 100644 --- a/src/vs/sessions/SINGLE_PANE_SCENARIOS.md +++ b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md @@ -47,7 +47,7 @@ Let **E** = editor content visible, **D** = detail panel visible. The pane suppo | **Editor only** | ✅ | ❌ | Detail toggled off; editor content fills the pane; tab bar across the top. **This is the default state for a created session** — opening the side pane shows the Changes editor with the detail panel closed; the detail is opened only via **Toggle Details** (or restored per-session). | | **Side pane closed** | ❌ | ❌ | The whole third pane is closed (chat-only). Reached via **Toggle Side Panel** or when the last editor tab closes; never via the detail toggle. **Closing the whole side pane does NOT close editors** — only a *Detail-only* collapse (editor hidden while the detail stays open) closes them; when both parts hide the editors are left intact so they return when the side pane is reopened. | -A created session opens the side pane to **Editor only** (Changes editor, detail closed) by default; a Changes/file editor becoming active never force-opens the detail (the one exception is restoring the detail after a transient browser-tab hide). Opening the empty **Files placeholder** (making it the **active editor** — via the `+` Files entry or by selecting its tab) reveals the Files detail, because the placeholder's content (the Files tree) lives there. The detail-panel strategy keys this on the active-editor signal, so the managed auto-ensured Files tab (opened *inactive* as a background tab) never triggers it — the Editor-only default is preserved — and hiding the detail afterwards sticks (hiding does not change the active editor). It is also skipped while the whole side pane is closed (editor content hidden) or during a session-switch restore. A new-session view opens to the **Files detail** (its editor content stays hidden by R1). +A created session opens the side pane to **Editor only** (Changes editor, detail closed) by default; a Changes/file editor becoming active never force-opens the detail (the one exception is restoring the detail after a full-width Browser or Side Chat editor temporarily hides it). Opening the empty **Files placeholder** (making it the **active editor** — via the `+` Files entry or by selecting its tab) reveals the Files detail, because the placeholder's content (the Files tree) lives there. The detail-panel strategy keys this on the active-editor signal, so the managed auto-ensured Files tab (opened *inactive* as a background tab) never triggers it — the Editor-only default is preserved — and hiding the detail afterwards sticks (hiding does not change the active editor). It is also skipped while the whole side pane is closed (editor content hidden) or during a session-switch restore. A new-session view opens to the **Files detail** (its editor content stays hidden by R1). **Size distribution when opening the side pane.** Opening the side pane from *closed* (e.g. clicking **Changes** while the chat is full-width) gives it a comfortable width of **60% of the full window width** @@ -131,13 +131,14 @@ The **auto-managed** tabs (the pinned Changes tab and the default File tab) are ## 5. Detail panel content (driven by the active tab) -The single-pane layout controller (`SinglePaneLayoutController`) maps the active editor tab to the detail content. By default the detail panel is **closed** for a created session (Editor-only); it is opened via **Toggle Details** (or restored per-session), and while visible its container follows the active tab (the one exception is restoring the detail after a transient browser-tab hide): +The single-pane layout controller (`SinglePaneLayoutController`) maps the active editor tab to the detail content. By default the detail panel is **closed** for a created session (Editor-only); it is opened via **Toggle Details** (or restored per-session), and while visible its container follows the active tab (the one exception is restoring the detail after a full-width editor temporarily hides it): | Active tab | Detail panel | |-----------|--------------| | **Changes** | Branch Changes file list + Checks — shown (Changes container) while the detail is visible | | **File** (Explorer) | Files/Explorer tree — shown (Files container) while the detail is visible | | **Browser** | **Hidden** (transiently) while the Browser tab is active; restored when switching back | +| **Side Chat** | **Hidden** (transiently) while the Side Chat tab is active; restored when switching back | Rules: - **Reveal on activate, respect after.** Switching to a Changes/File tab reveals the detail with the diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index 02a5e5878f4..e69b6640739 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -28,6 +28,7 @@ export const SessionIsStickyContext = new RawContextKey('sessionIsStick export const SessionIsMaximizedContext = new RawContextKey('sessionIsMaximized', false, localize('sessionIsMaximized', "Whether the session view is currently maximized in the sessions part's grid")); export const SessionSupportsMultipleChatsContext = new RawContextKey('sessionSupportsMultipleChats', false, localize('sessionSupportsMultipleChats', "Whether the session view's session supports multiple chats")); export const SessionSupportsForkContext = new RawContextKey('sessionSupportsFork', false, localize('sessionSupportsFork', "Whether the session view's session supports forking a chat from a turn into a new peer chat")); +export const SessionSupportsSideChatContext = new RawContextKey('sessionSupportsSideChat', false, localize('sessionSupportsSideChat', "Whether the session view's session supports creating a side chat from a turn (via /btw)")); export const SessionHasMultipleCommittedChatsContext = new RawContextKey('sessionHasMultipleCommittedChats', false, localize('sessionHasMultipleCommittedChats', "Whether the session view's session has more than one committed (non-draft) chat, which drives the Conversations menu visibility")); export const SessionActiveChatHasSubagentsContext = new RawContextKey('sessionActiveChatHasSubagents', false, localize('sessionActiveChatHasSubagents', "Whether the session view's currently-active chat has spawned subagent (tool-origin) chats, which are listed as a separate group in the Conversations menu")); export const SessionShouldShowChatTabsContext = new RawContextKey('sessionShouldShowChatTabs', false, localize('sessionShouldShowChatTabs', "Whether the session view's chat tab strip is shown, i.e. the session has more than one chat actually showing as a tab. A single visible tab always hides the strip. Used to hide the header New Chat button, which the tab strip then offers instead")); diff --git a/src/vs/sessions/contrib/chat/browser/btwSlashCommand.contribution.ts b/src/vs/sessions/contrib/chat/browser/btwSlashCommand.contribution.ts new file mode 100644 index 00000000000..1d0e444a24a --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/btwSlashCommand.contribution.ts @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IWorkbenchEnvironmentService } from '../../../../workbench/services/environment/common/environmentService.js'; +import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { ChatAgentLocation } from '../../../../workbench/contrib/chat/common/constants.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatSlashCommandService } from '../../../../workbench/contrib/chat/common/participants/chatSlashCommands.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { SessionIsArchivedContext, SessionIsCreatedContext, SessionSupportsSideChatContext } from '../../../common/contextkeys.js'; +import { SessionStatus } from '../../../services/sessions/common/session.js'; +import { SideChatEditorInput } from '../../sideChat/browser/sideChatEditorInput.js'; + +class BtwSlashCommandContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.btwSlashCommand'; + + constructor( + @IChatSlashCommandService slashCommandService: IChatSlashCommandService, + @ISessionsManagementService sessionsManagementService: ISessionsManagementService, + @IChatService chatService: IChatService, + @IEditorService editorService: IEditorService, + @IEditorGroupsService editorGroupsService: IEditorGroupsService, + @IInstantiationService instantiationService: IInstantiationService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + @ILogService logService: ILogService, + @INotificationService notificationService: INotificationService, + ) { + super(); + + if (!environmentService.isSessionsWindow) { + return; + } + + this._register(slashCommandService.registerSlashCommand({ + command: 'btw', + detail: localize('btw', "Ask a side question without adding it to this conversation"), + sortText: 'z2_btw', + executeImmediately: false, + executeDuringRequest: true, + silent: true, + locations: [ChatAgentLocation.Chat], + when: ContextKeyExpr.and( + IsSessionsWindowContext, + SessionIsCreatedContext, + SessionIsArchivedContext.negate(), + SessionSupportsSideChatContext, + ), + }, async (prompt, _progress, _history, _location, sessionResource) => { + const remainder = prompt.trim(); + if (!remainder) { + notificationService.warn(localize('btw.missingPrompt', "Enter a question after `/btw`.")); + return; + } + const found = sessionsManagementService.getSessionForChatResource(sessionResource); + if (!found) { + notificationService.warn(localize('btw.sessionUnavailable', "A side chat cannot be created from this conversation.")); + return; + } + const { session, chat } = found; + if (session.status.get() === SessionStatus.Untitled || session.isArchived.get() || !session.capabilities.get().supportsSideChat) { + notificationService.warn(localize('btw.unsupported', "This conversation does not support side chats.")); + return; + } + + const sourceTurn = chatService.getSession(chat.resource)?.getRequests().at(-1); + if (!sourceTurn) { + logService.warn('[btw] No turn to branch a side chat from'); + notificationService.warn(localize('btw.noTurn', "Send a message in this conversation before starting a side chat.")); + return; + } + + let sideChat; + try { + sideChat = await sessionsManagementService.createSideChatInSession(session, chat.resource, sourceTurn.id); + } catch (err) { + logService.error('[btw] Failed to create side chat', err); + notificationService.error(localize('btw.createFailed', "The side chat could not be created.")); + return; + } + + const group = editorGroupsService.mainPart.activeGroup; + await editorService.openEditor(instantiationService.createInstance(SideChatEditorInput), { pinned: true, index: group.count }, group); + await sessionsManagementService.sendRequest(session, sideChat, { query: remainder, background: true }); + })); + } +} + +registerWorkbenchContribution2(BtwSlashCommandContribution.ID, BtwSlashCommandContribution, WorkbenchPhase.Eventually); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index c8a29279fdc..57a050f094e 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -122,6 +122,7 @@ function createMockProvider(id: string, opts?: { deleteChat: async () => true, createNewChat: async () => { throw new Error('Not implemented'); }, forkChat: async () => { throw new Error('Not implemented'); }, + createSideChat: async () => { throw new Error('Not implemented'); }, sendRequest: async (_sessionId: string, _chatResource: URI, _options: ISendRequestOptions) => { throw new Error('Not implemented'); }, }; if (opts?.connectionStatus) { diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDetailPanelStrategy.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDetailPanelStrategy.ts index 02215487346..75c96a48918 100644 --- a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDetailPanelStrategy.ts +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDetailPanelStrategy.ts @@ -25,11 +25,12 @@ import { CHANGES_VIEW_CONTAINER_ID } from '../../../changes/common/changes.js'; import { ISessionChangesService } from '../../../changes/browser/sessionChangesService.js'; import { EmptyFileEditorInput } from '../../../editor/browser/emptyFileEditorInput.js'; import { SESSIONS_FILES_CONTAINER_ID } from '../../../files/browser/files.contribution.js'; +import { SideChatEditorInput } from '../../../sideChat/browser/sideChatEditorInput.js'; import { ISinglePaneLayoutContext, SinglePaneLayoutStrategy } from './singlePaneLayoutStrategy.js'; const enum DetailPanelTarget { Hidden, - BrowserHidden, + FullWidthEditor, Changes, ChangesForced, Files, @@ -42,15 +43,15 @@ const enum DetailPanelTarget { * reveals/hides the auxiliary bar accordingly. A created single-pane session * defaults to the Changes editor with the detail closed; a Changes/file editor * becoming active never force-reveals a hidden detail (except restoring it after - * a transient browser-tab hide). Opening the empty Files placeholder (making it - * the active editor) reveals the Files detail, since its content lives there. + * a full-width editor temporarily hides it). Opening the empty Files placeholder + * (making it the active editor) reveals the Files detail, since its content lives there. */ export class SinglePaneDetailPanelStrategy extends SinglePaneLayoutStrategy { private _hasDockedDetailsContext: IContextKey | undefined; private readonly _detailSequencer = new Sequencer(); private _detailGeneration = 0; - private _hiddenByBrowser = false; + private _hiddenByFullWidthEditor = false; constructor( ctx: ISinglePaneLayoutContext, @@ -122,8 +123,8 @@ export class SinglePaneDetailPanelStrategy extends SinglePaneLayoutStrategy { return activeSession?.isCreated.read(reader) ? DetailPanelTarget.Changes : DetailPanelTarget.Files; } - if (activeEditor instanceof BrowserEditorInput) { - return DetailPanelTarget.BrowserHidden; + if (activeEditor instanceof BrowserEditorInput || activeEditor instanceof SideChatEditorInput) { + return DetailPanelTarget.FullWidthEditor; } if (this._isChangesEditor(activeEditor)) { @@ -157,16 +158,16 @@ export class SinglePaneDetailPanelStrategy extends SinglePaneLayoutStrategy { if (this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { this._layoutService.setPartHidden(true, Parts.AUXILIARYBAR_PART); } - this._hiddenByBrowser = false; + this._hiddenByFullWidthEditor = false; return; - case DetailPanelTarget.BrowserHidden: + case DetailPanelTarget.FullWidthEditor: if (this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { this._layoutService.setPartHidden(true, Parts.AUXILIARYBAR_PART); } - this._hiddenByBrowser = true; + this._hiddenByFullWidthEditor = true; return; case DetailPanelTarget.Changes: - if (!auxBarVisible && this._hiddenByBrowser) { + if (!auxBarVisible && this._hiddenByFullWidthEditor) { this._layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); auxBarVisible = true; } @@ -176,13 +177,13 @@ export class SinglePaneDetailPanelStrategy extends SinglePaneLayoutStrategy { return; } await this._viewsService.openViewContainer(CHANGES_VIEW_CONTAINER_ID, false); - this._hiddenByBrowser = false; + this._hiddenByFullWidthEditor = false; return; case DetailPanelTarget.ChangesForced: await this._syncForcedDetailTarget(CHANGES_VIEW_CONTAINER_ID, auxBarVisible); return; case DetailPanelTarget.Files: - if (!auxBarVisible && this._hiddenByBrowser) { + if (!auxBarVisible && this._hiddenByFullWidthEditor) { this._layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); auxBarVisible = true; } @@ -190,13 +191,13 @@ export class SinglePaneDetailPanelStrategy extends SinglePaneLayoutStrategy { return; } await this._viewsService.openViewContainer(SESSIONS_FILES_CONTAINER_ID, false); - this._hiddenByBrowser = false; + this._hiddenByFullWidthEditor = false; return; case DetailPanelTarget.FilesForced: await this._syncForcedDetailTarget(SESSIONS_FILES_CONTAINER_ID, auxBarVisible); return; case DetailPanelTarget.Preserve: - this._hiddenByBrowser = false; + this._hiddenByFullWidthEditor = false; return; } } @@ -207,10 +208,10 @@ export class SinglePaneDetailPanelStrategy extends SinglePaneLayoutStrategy { // editor with the detail closed, and an explicit / per-session hide is // respected — so a Changes/file editor becoming active never // force-reveals the detail. The one exception is restoring the detail - // after a *transient* browser-tab hide (`_hiddenByBrowser`). Never reveal + // after a full-width editor temporarily hides it. Never reveal // while the whole side pane is closed (the editor content is also hidden) // or during a session-switch layout restore. - if (!this._hiddenByBrowser + if (!this._hiddenByFullWidthEditor || !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow) || this._ctx.isRestoringSessionLayout) { return; @@ -218,7 +219,7 @@ export class SinglePaneDetailPanelStrategy extends SinglePaneLayoutStrategy { this._layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); } await this._viewsService.openViewContainer(viewContainerId, false); - this._hiddenByBrowser = false; + this._hiddenByFullWidthEditor = false; } private _isChangesEditor(editor: EditorInput): boolean { diff --git a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts index 5e0512c5c5a..2d4dba5772a 100644 --- a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts @@ -32,6 +32,7 @@ import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID } from '../../../changes/com import '../../../changes/browser/changesActions.js'; import { SESSIONS_FILES_CONTAINER_ID } from '../../../files/browser/files.contribution.js'; import { NewChangesTabAction, NewFileTabAction } from '../../../editor/browser/addTabActions.js'; +import { SideChatEditorInput } from '../../../sideChat/browser/sideChatEditorInput.js'; import { createTestHarness, ICreateOptions, ITestLayoutHarness, makeChange, makeSession, TestStubEditorInput } from './layoutControllerTestUtils.js'; suite('LayoutController (desktop)', () => { @@ -285,7 +286,7 @@ suite('LayoutController (desktop)', () => { ); }); - test('[single-pane] restores the detail panel after a browser tab hides it', async () => { + test('[single-pane] gives browser and side-chat tabs the full side pane and restores docked details afterward', async () => { createSinglePaneController({ activateAux: true }); await timeout(0); const hasDockedDetails = () => harness.contextKeyService.getContextKeyValue(HasDockedDetailsContext.key); @@ -325,6 +326,27 @@ suite('LayoutController (desktop)', () => { 'file tabs should reopen the Files container after browser hides it' ); + harness.setPartHiddenCalls = []; + harness.activeEditorInput = store.add(new SideChatEditorInput()); + harness.onDidActiveEditorChange.fire(); + assert.strictEqual(hasDockedDetails(), false, 'side-chat target should clear the editor chevron context'); + await timeout(0); + + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + 'side-chat tabs should hide the detail panel' + ); + + harness.setPartHiddenCalls = []; + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + await timeout(0); + + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false), + 'file tabs should restore the detail panel after side chat hides it' + ); + // A search tab (any non-changes/non-file editor) has no detail panel, so // the chevron context must clear just like the browser tab does. harness.activeEditorInput = store.add(new TestStubEditorInput(URI.parse('search-editor://test'))); 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 cbef6301c68..8117d981b1e 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 @@ -184,6 +184,7 @@ When restoring Copilot SDK history, `mapSessionEvents` best-effort reconstructs - `renameSession` — updates the session-level title. - `deleteChat` — no-op (agent host sessions don't model individually deletable chats). - `forkChat(sessionId, sourceChat, turnId)` — multi-chat only. Mints a peer chat URI and calls `connection.createChat(sessionUri, chatUri, { fork: { source, turnId } })`, where `source` is the backend chat URI (a `chatId` fragment addresses a peer chat, otherwise the session's default chat). The host seeds the new chat with the forked history; the provider waits for it to surface in `cached.chats` and returns it. Routed from the **Fork Conversation** gesture via `ISessionsManagementService.forkChatInSession`; single-chat sessions instead fork into a new session (the workbench `AgentHostSessionHandler.forkSession`). +- `createSideChat(sessionId, sourceChat, turnId)` — gated on `capabilities.supportsSideChat` (currently Claude and Copilot), mirroring `forkChat`'s multi-chat gating and backend-URI resolution. Calls `connection.createChat(sessionUri, chatUri, { model, sideChat: { source, turnId } })`. The anchor may be the source chat's completed or active turn. The node host validates and persists the `SideChat` origin, then passes the source handle to the provider. Claude/Copilot use their SDK fork primitives for hidden context, locking creation on the new chat so they can snapshot provider context accumulated during an active source turn, and filter the inherited prefix from restored turns. The provider wraps the first SDK prompt with a private instruction to prefer explanation over action and to avoid doing work unless explicitly requested. When the active turn has streamed user-visible markdown that the native fork has not persisted, a bounded snapshot is included in the same wrapper. Provider reconstruction strips the wrapper from visible history. The source chat's model/agent selection is re-applied to the new chat once it surfaces. ## Picker & Action Contributions diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index c08d6860399..f80d2b31be4 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -380,6 +380,8 @@ export function toSessionChatOriginKind(kind: string): ChatOriginKind { return ChatOriginKind.Tool; case ChatOriginKind.Fork: return ChatOriginKind.Fork; + case ChatOriginKind.SideChat: + return ChatOriginKind.SideChat; default: return ChatOriginKind.User; } @@ -732,6 +734,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { return { supportsMultipleChats: !this._kind.isQuickChat && (agentCapabilities?.multipleChats !== undefined), supportsFork: agentCapabilities?.multipleChats?.fork ?? false, + supportsSideChat: agentCapabilities?.multipleChats?.sideChat ?? false, supportsRename: true, supportsDelete: true, }; @@ -784,12 +787,15 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._defaultChatTitleOverride.set(defaultSummary?.title || undefined, undefined); this._defaultChatInteractivity.set(toChatInteractivity(defaultSummary?.interactivity), undefined); - // Subagent (tool-origin) chats always surface as read-only peers; other - // non-default chats surface only when the session supports multiple chats. + // Subagent (tool-origin) and side (`/btw`) chats always surface as + // read-only/peer entries; other non-default chats surface only when the + // session supports multiple chats. const surfacesAsPeer = (summary: ChatSummary): boolean => !isDefault(summary) && !!parseChatUri(summary.resource)?.chatId - && (this.capabilities.get().supportsMultipleChats || summary.origin?.kind === ProtocolChatOriginKind.Tool); + && (this.capabilities.get().supportsMultipleChats + || summary.origin?.kind === ProtocolChatOriginKind.Tool + || summary.origin?.kind === ProtocolChatOriginKind.SideChat); if (!state.chats.some(surfacesAsPeer)) { // Single visible chat: the default chat is the session, so let it @@ -858,7 +864,10 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { * resource; peer chats carry their chatId in the resource fragment. */ private _resolveParentChatResource(origin: ChatSummary['origin']): URI | undefined { - const parentUri = origin && (origin.kind === ProtocolChatOriginKind.Tool || origin.kind === ProtocolChatOriginKind.Fork) + const parentUri = origin && ( + origin.kind === ProtocolChatOriginKind.Tool + || origin.kind === ProtocolChatOriginKind.Fork + || origin.kind === ProtocolChatOriginKind.SideChat) ? origin.chat : undefined; if (!parentUri) { @@ -3331,6 +3340,55 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return chat; } + async createSideChat(sessionId: string, sourceChat: URI, turnId: string): Promise { + const connection = this.connection; + if (!connection) { + throw new Error(this._notConnectedSendErrorMessage()); + } + const rawId = this._rawIdFromChatId(sessionId); + const cached = rawId ? this._sessionCache.get(rawId) : undefined; + if (!rawId || !cached) { + throw new Error(`Session '${sessionId}' not found`); + } + if (!cached.capabilities.get().supportsSideChat) { + throw new Error(`Session '${sessionId}' does not support side chats`); + } + + const sessionUri = AgentSession.uri(cached.agentProvider, rawId); + const newChatId = generateUuid(); + const chatUri = URI.parse(buildChatUri(sessionUri, newChatId)); + // Map the UI source chat resource to its backend chat URI: a fragment + // addresses a peer chat, otherwise the session's default chat. + const sourceBackendUri = sourceChat.fragment + ? URI.parse(buildChatUri(sessionUri, sourceChat.fragment)) + : sessionUri; + + // Inherit the source chat's own model/agent selection (which may differ + // from the session's default), not the session-level fallback. + const selectedModelId = cached.getChatModelId(sourceChat); + const selectedAgentUri = cached.getChatMode(sourceChat)?.id; + + // Keep the session-state subscription alive so the `chatAdded` it emits + // flows into `_applyChatCatalogFromState` and updates `cached.chats`. + this._keepSessionStateAlive(cached.sessionId); + await connection.createChat(sessionUri, chatUri, { + model: cached.modelSelection, + sideChat: { source: sourceBackendUri, turnId }, + }); + + const chat = await waitForState( + cached.chats.map(chats => chats.find(c => c.resource.fragment === newChatId)), + c => !!c, + ); + + cached.setChatModelId(chat.resource, selectedModelId); + cached.setChatAgent(chat.resource, selectedAgentUri ? { uri: selectedAgentUri, name: '' } : undefined); + + await this._chatSessionsService.getOrCreateChatSession(chat.resource, CancellationToken.None); + await this._updateChatSessionState(chat.resource, selectedModelId, selectedAgentUri); + return chat; + } + async sendRequest(chatId: string, chatResource: URI, options: ISendRequestOptions): Promise { const newSession = this._getNewSession(chatId); if (newSession) { 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 e58121019cc..30a765b9b53 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 @@ -1974,7 +1974,7 @@ suite('LocalAgentHostSessionsProvider', () => { await timeout(0); const session = provider.getSessions()[0]; - assert.deepStrictEqual(session?.capabilities.get(), { supportsMultipleChats: false, supportsFork: true, supportsRename: true, supportsDelete: true }); + assert.deepStrictEqual(session?.capabilities.get(), { supportsMultipleChats: false, supportsFork: true, supportsSideChat: false, supportsRename: true, supportsDelete: true }); })); test('restored quick chat collapses to a single chat even when state advertises peer chats', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -3034,6 +3034,42 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('createSideChat forwards the source chat and turn to the host and inherits the source chat model/agent', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.setAgents([{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true, sideChat: true } } } as AgentInfo]); + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'multi-side-chat'); + const sessionUri = AgentSession.uri('copilotcli', 'multi-side-chat').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + + agentHost.setSessionState('multi-side-chat', 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + ], { defaultChat })); + + assert.strictEqual(session.capabilities.get().supportsSideChat, true); + + const sideChat = await provider.createSideChat(session.sessionId, session.resource, 'turn-1'); + + const call = agentHost.createdChats.at(-1); + assert.deepStrictEqual({ + sideChatSource: call?.options?.sideChat?.source.toString(), + sideChatTurnId: call?.options?.sideChat?.turnId, + sideChatIsPeer: !!sideChat.resource.fragment, + sideChatInCatalog: session.chats.get().some(c => c.resource.toString() === sideChat.resource.toString()), + }, { + sideChatSource: sessionUri, + sideChatTurnId: 'turn-1', + sideChatIsPeer: true, + sideChatInCatalog: true, + }); + })); + + test('createSideChat rejects when the session capability is not advertised', async () => { + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'multi-side-chat-unsupported'); + + await assert.rejects(() => provider.createSideChat(session.sessionId, session.resource, 'turn-1'), /does not support side chats/); + }); + test('createNewChat forwards the selected model to the host and seeds the chat input state', async () => { const inputStates: { resource: string; state: Partial }[] = []; const provider = createProvider(disposables, agentHost, undefined, { diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index f0fdcf81ff8..6dc392336f8 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -2112,6 +2112,10 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions throw new Error(`Session '${sessionId}' does not support forking into a chat`); } + async createSideChat(sessionId: string, _sourceChat: URI, _turnId: string): Promise { + throw new Error(`Session '${sessionId}' does not support side chats`); + } + async createNewChat(sessionId: string, prompt?: string): Promise { const currentNewSession = this._newSessions.get(sessionId); if (currentNewSession) { diff --git a/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessionsProvider.ts index 4b64789d05e..a8ecedbb7bf 100644 --- a/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessionsProvider.ts @@ -906,6 +906,10 @@ export class LocalChatSessionsProvider extends Disposable implements ISessionsPr throw new Error(`Session '${sessionId}' does not support forking into a chat`); } + async createSideChat(sessionId: string, _sourceChat: URI, _turnId: string): Promise { + throw new Error(`Session '${sessionId}' does not support side chats`); + } + async renameChat(_sessionId: string, chatUri: URI, title: string): Promise { this.chatService.setSessionTitle(chatUri, title); const session = this._findSessionByResource(chatUri); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index f63e6ed9bf4..80c453bf2a8 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -1295,6 +1295,11 @@ export class SessionConversationsMenuContribution extends Disposable implements if (chat.origin?.kind === ChatOriginKind.Tool) { return; } + // Side chats (via `/btw`) are excluded from conversation tabs; they + // are shown in the top-level Side Chat editor. + if (chat.origin?.kind === ChatOriginKind.SideChat) { + return; + } registerToggle(chat, '1_chats', index); }); diff --git a/src/vs/sessions/contrib/sideChat/browser/sideChat.contribution.ts b/src/vs/sessions/contrib/sideChat/browser/sideChat.contribution.ts new file mode 100644 index 00000000000..0d3bb4302d8 --- /dev/null +++ b/src/vs/sessions/contrib/sideChat/browser/sideChat.contribution.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../../workbench/browser/editor.js'; +import { EditorExtensions, IEditorFactoryRegistry } from '../../../../workbench/common/editor.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { SideChatEditor } from './sideChatEditor.js'; +import { SideChatEditorInput, SideChatEditorSerializer } from './sideChatEditorInput.js'; + +class SideChatEditorContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.sideChatEditor'; + + constructor() { + super(); + + this._register(Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create(SideChatEditor, SideChatEditor.ID, localize('sideChatEditor.label', "Side Chat")), + [new SyncDescriptor(SideChatEditorInput)] + )); + + this._register(Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer( + SideChatEditorInput.ID, + SideChatEditorSerializer + )); + } +} + +registerWorkbenchContribution2(SideChatEditorContribution.ID, SideChatEditorContribution, WorkbenchPhase.BlockStartup); diff --git a/src/vs/sessions/contrib/sideChat/browser/sideChatEditor.ts b/src/vs/sessions/contrib/sideChat/browser/sideChatEditor.ts new file mode 100644 index 00000000000..066ed03859f --- /dev/null +++ b/src/vs/sessions/contrib/sideChat/browser/sideChatEditor.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Dimension } from '../../../../base/browser/dom.js'; +import { autorun, derived } from '../../../../base/common/observable.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { EditorPane } from '../../../../workbench/browser/parts/editor/editorPane.js'; +import { IEditorGroup } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { AbstractChatView } from '../../../browser/parts/chatView.js'; +import { IChatViewFactory } from '../../../services/chatView/browser/chatViewFactory.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { ChatOriginKind, IChat } from '../../../services/sessions/common/session.js'; +import { SideChatEditorInput } from './sideChatEditorInput.js'; + +export class SideChatEditor extends EditorPane { + + static readonly ID = SideChatEditorInput.EDITOR_ID; + + private readonly _chatView: AbstractChatView; + + constructor( + group: IEditorGroup, + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IChatViewFactory chatViewFactory: IChatViewFactory, + @ISessionsService sessionsService: ISessionsService, + ) { + super(SideChatEditor.ID, group, telemetryService, themeService, storageService); + + this._chatView = this._register(chatViewFactory.createChatView()); + + const newestSideChat = derived(reader => { + const session = sessionsService.activeSession.read(reader); + if (!session) { + return undefined; + } + const sideChats = session.chats.read(reader).filter(chat => chat.origin?.kind === ChatOriginKind.SideChat); + return sideChats.length ? { sessionId: session.sessionId, chat: sideChats[sideChats.length - 1] } : undefined; + }); + this._register(autorun(reader => { + const result: { sessionId: string; chat: IChat } | undefined = newestSideChat.read(reader); + if (result) { + this._chatView.setChat(result.chat, result.sessionId); + } + })); + } + + protected override createEditor(parent: HTMLElement): void { + parent.appendChild(this._chatView.element); + } + + protected override setEditorVisible(visible: boolean): void { + this._chatView.setActive(visible); + } + + override focus(): void { + super.focus(); + this._chatView.focus(); + } + + override layout(dimension: Dimension): void { + this._chatView.layout(dimension.width, dimension.height, 0, 0); + } +} diff --git a/src/vs/sessions/contrib/sideChat/browser/sideChatEditorInput.ts b/src/vs/sessions/contrib/sideChat/browser/sideChatEditorInput.ts new file mode 100644 index 00000000000..2b04e5b81b1 --- /dev/null +++ b/src/vs/sessions/contrib/sideChat/browser/sideChatEditorInput.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { EditorInputCapabilities, IEditorSerializer, IUntypedEditorInput, Verbosity } from '../../../../workbench/common/editor.js'; +import { EditorInput } from '../../../../workbench/common/editor/editorInput.js'; + +export class SideChatEditorInput extends EditorInput { + + static readonly ID = 'workbench.input.agentSessions.sideChat'; + static readonly EDITOR_ID = 'workbench.editor.agentSessions.sideChat'; + + override get resource(): URI | undefined { + return undefined; + } + + override get typeId(): string { + return SideChatEditorInput.ID; + } + + override get editorId(): string { + return SideChatEditorInput.EDITOR_ID; + } + + override get capabilities(): EditorInputCapabilities { + return EditorInputCapabilities.Readonly | EditorInputCapabilities.Singleton; + } + + override getName(): string { + return localize('sideChatEditor.name', "Side Chat"); + } + + override getIcon(): ThemeIcon { + return Codicon.commentDiscussion; + } + + override getTitle(_verbosity?: Verbosity): string { + return this.getName(); + } + + override canReopen(): boolean { + return true; + } + + override matches(otherInput: EditorInput | IUntypedEditorInput): boolean { + return super.matches(otherInput) || otherInput instanceof SideChatEditorInput; + } +} + +export class SideChatEditorSerializer implements IEditorSerializer { + + canSerialize(editorInput: EditorInput): editorInput is SideChatEditorInput { + return editorInput instanceof SideChatEditorInput; + } + + serialize(editorInput: EditorInput): string | undefined { + return this.canSerialize(editorInput) ? '' : undefined; + } + + deserialize(instantiationService: IInstantiationService, _serializedEditor: string): EditorInput { + return instantiationService.createInstance(SideChatEditorInput); + } +} diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 60a9ebe622e..ca96f6f1a57 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -484,6 +484,17 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return provider.forkChat(session.sessionId, sourceChat, turnId); } + async createSideChatInSession(session: ISession, sourceChat: URI, turnId: string): Promise { + const provider = this._getProvider(session); + if (!provider) { + throw new Error(`Provider '${session.providerId}' not found for session '${session.sessionId}'`); + } + if (!session.capabilities.get().supportsSideChat) { + throw new Error(`Session '${session.sessionId}' does not support side chats`); + } + return provider.createSideChat(session.sessionId, sourceChat, turnId); + } + /** * For a `/troubleshoot` request, strip any `#session` marker attachments and * append a `Session log:` line with the resolved host-local `events.jsonl` diff --git a/src/vs/sessions/services/sessions/browser/sessionsService.ts b/src/vs/sessions/services/sessions/browser/sessionsService.ts index 60d4d3b3c48..74f33764d4a 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsService.ts @@ -536,7 +536,7 @@ export class SessionsService extends Disposable implements ISessionsService { store.add(autorun(reader => { const active = this._visibility.activeSession.read(reader); if (active && active.sessionId === followId) { - const chats = active.chats.read(reader); + const chats = active.openChats.read(reader); const lastChat = chats[chats.length - 1]; if (lastChat) { this._visibility.setActiveChat(active, lastChat); @@ -662,8 +662,9 @@ export class SessionsService extends Disposable implements ISessionsService { } // Subagent (tool-origin) chats are hidden by default and toggled via an // in-memory shown set, not the persisted closed set, so they never - // participate in closed-chat persistence. - if (chat.origin?.kind === ChatOriginKind.Tool) { + // participate in closed-chat persistence. Side chats (via `/btw`) never + // appear as tabs at all, so they never participate either. + if (chat.origin?.kind === ChatOriginKind.Tool || chat.origin?.kind === ChatOriginKind.SideChat) { return; } const existing = this._sessionStates.get(session.resource); diff --git a/src/vs/sessions/services/sessions/browser/visibleSessions.ts b/src/vs/sessions/services/sessions/browser/visibleSessions.ts index fb7e6ab3398..fe8c5dfa009 100644 --- a/src/vs/sessions/services/sessions/browser/visibleSessions.ts +++ b/src/vs/sessions/services/sessions/browser/visibleSessions.ts @@ -94,9 +94,11 @@ export class VisibleSession extends Disposable implements IActiveSession { const closed = this._closedChatUris.read(reader); const chats = this._session.chats.read(reader); // Hidden chats are internal workers that must never be surfaced in the - // tab strip; closed chats are user-dismissed. Both are excluded here. + // conversation tab strip; closed chats are user-dismissed; side chats + // are surfaced in the top-level Side Chat editor. All three are excluded here. return chats.filter(c => c.interactivity.read(reader) !== ChatInteractivity.Hidden && + c.origin?.kind !== ChatOriginKind.SideChat && !closed.has(c.resource.toString())); }); this.closedChats = derived(this, reader => { @@ -130,6 +132,11 @@ export class VisibleSession extends Disposable implements IActiveSession { } closeChat(chat: IChat): void { + // Side chats never appear as conversation tabs, so closing one from the + // top-level Side Chat editor must be a no-op here. + if (chat.origin?.kind === ChatOriginKind.SideChat) { + return; + } const chatUri = chat.resource.toString(); // The main chat represents the session itself and is never closed. if (chatUri === this._session.mainChat.get().resource.toString()) { @@ -170,6 +177,11 @@ export class VisibleSession extends Disposable implements IActiveSession { } openChat(chat: IChat): void { + // Side chats never appear as conversation tabs; the Side Chat editor reaches + // them directly, so there is no "reopen as conversation tab" affordance. + if (chat.origin?.kind === ChatOriginKind.SideChat) { + return; + } // Opening a subagent (tool-origin) chat surfaces it as a tab. if (chat.origin?.kind === ChatOriginKind.Tool) { const shown = this._shownSubagentUris.get(); @@ -202,6 +214,7 @@ export class VisibleSession extends Disposable implements IActiveSession { private _defaultActiveChat(closed: ReadonlySet, shownSubagents: ReadonlySet): IChat { const candidates = this._session.chats.get().filter(c => c.interactivity.get() !== ChatInteractivity.Hidden && + c.origin?.kind !== ChatOriginKind.SideChat && !closed.has(c.resource.toString()) && (c.origin?.kind !== ChatOriginKind.Tool || shownSubagents.has(c.resource.toString()))); return candidates[candidates.length - 1] ?? this._session.mainChat.get(); @@ -811,7 +824,8 @@ export class VisibleSessions extends Disposable { const chats = session.chats.read(reader); const activeChat = visibleSessionRef.activeChat.read(reader); if (activeChat && !chats.some(c => this._uriIdentityService.extUri.isEqual(c.resource, activeChat.resource))) { - const fallback = chats[chats.length - 1] ?? session.mainChat; + const openChats = visibleSessionRef.openChats.read(reader); + const fallback = openChats[openChats.length - 1] ?? session.mainChat.read(reader); if (fallback) { visibleSessionRef.setActiveChat(fallback); } diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 2b9b69231fc..3c8f3e27710 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -370,6 +370,7 @@ export const enum ChatOriginKind { Tool = 'tool', User = 'user', Fork = 'fork', + SideChat = 'sideChat', } export interface IChatOrigin { @@ -576,6 +577,13 @@ export interface ISessionCapabilities { * it. Defaults to falsy (no fork) when omitted. */ readonly supportsFork?: boolean; + /** + * Whether this session supports creating a side chat from a turn (via + * `/btw`). Side chats inherit the source chat's model/agent and are shown + * in a dedicated top-level Side Chat editor, never in the conversation tab + * strip. Defaults to falsy (no side chat) when omitted. + */ + readonly supportsSideChat?: boolean; /** * Whether this session's title can be renamed. The agents-window UI * (session header inline edit, sessions-list `Rename...` action) gates diff --git a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts index 5ca878b71b8..d1fd2219120 100644 --- a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts +++ b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts @@ -19,6 +19,7 @@ import { SessionSupportsDeleteContext, SessionSupportsMultipleChatsContext, SessionSupportsForkContext, + SessionSupportsSideChatContext, SessionSupportsRenameContext, SessionTypeContext, SessionWorkspaceIsVirtualContext, @@ -45,6 +46,7 @@ interface ISessionContextKeys { readonly isRead: IContextKey; readonly supportsMultipleChats: IContextKey; readonly supportsFork: IContextKey; + readonly supportsSideChat: IContextKey; readonly supportsRename: IContextKey; readonly supportsDelete: IContextKey; readonly workspaceIsVirtual: IContextKey; @@ -84,6 +86,7 @@ function getBoundKeys(contextKeyService: IContextKeyService): ISessionContextKey isRead: SessionIsReadContext.bindTo(contextKeyService), supportsMultipleChats: SessionSupportsMultipleChatsContext.bindTo(contextKeyService), supportsFork: SessionSupportsForkContext.bindTo(contextKeyService), + supportsSideChat: SessionSupportsSideChatContext.bindTo(contextKeyService), supportsRename: SessionSupportsRenameContext.bindTo(contextKeyService), supportsDelete: SessionSupportsDeleteContext.bindTo(contextKeyService), workspaceIsVirtual: SessionWorkspaceIsVirtualContext.bindTo(contextKeyService), @@ -130,6 +133,7 @@ export function setSessionContextKeys(session: ISession | undefined, contextKeyS const capabilities = session?.capabilities.read(reader); keys.supportsMultipleChats.set(capabilities?.supportsMultipleChats ?? false); keys.supportsFork.set(capabilities?.supportsFork ?? false); + keys.supportsSideChat.set(capabilities?.supportsSideChat ?? false); keys.supportsRename.set(capabilities?.supportsRename ?? false); keys.supportsDelete.set(capabilities?.supportsDelete ?? false); const workspace = session?.workspace.read(reader); @@ -155,6 +159,7 @@ export function setSessionContextKeys(session: ISession | undefined, contextKeyS // `workspace === undefined` (which is also transiently true for a // still-resolving workspace session). keys.isQuickChat.set(!!session && (session.isQuickChat?.read(reader) ?? false)); + } /** @@ -176,7 +181,7 @@ export function setActiveSessionContextKeys(session: IActiveSession | undefined, // real chat. Counts the whole chat list (open or closed) so a committed chat // that was closed still keeps the menu available to reopen it. const committedChatCount = session?.chats.read(reader) - .reduce((count, chat) => chat.status.read(reader) === SessionStatus.Untitled || chat.origin?.kind === ChatOriginKind.Tool ? count : count + 1, 0) ?? 0; + .reduce((count, chat) => chat.status.read(reader) === SessionStatus.Untitled || chat.origin?.kind === ChatOriginKind.Tool || chat.origin?.kind === ChatOriginKind.SideChat ? count : count + 1, 0) ?? 0; keys.hasMultipleCommittedChats.set(committedChatCount > 1); // The tab strip is shown when the session has more than one chat (counting diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index a8be3cd3f25..969149836c1 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -357,6 +357,18 @@ export interface ISessionsManagementService { */ forkChatInSession(session: ISession, sourceChat: URI, turnId: string): Promise; + /** + * Create a side chat from an existing chat's turn, inheriting the source + * chat's model/agent selection. Used by the `/btw` command. Throws if the + * session's provider does not support side chats + * ({@link ISessionCapabilities.supportsSideChat}). + * + * @param session The session containing the source chat. + * @param sourceChat The resource URI of the chat to branch from. + * @param turnId The ID of the turn to branch from. + */ + createSideChatInSession(session: ISession, sourceChat: URI, turnId: string): Promise; + /** * Discard the in-progress new session, disposing it through its provider to * release the eagerly-acquired backend session. diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index 60eb284dc5c..bb2b908c1a8 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -346,6 +346,17 @@ export interface ISessionsProvider { */ forkChat(sessionId: string, sourceChat: URI, turnId: string): Promise; + /** + * Create a side chat from an existing chat's turn, inheriting the source + * chat's model/agent selection. Unlike {@link forkChat}, a side chat is + * never shown as a tab in its session — it is only ever shown in the Side + * Chat auxiliary view. + * @param sessionId The ID of the session containing the source chat. + * @param sourceChat The resource URI of the chat to branch from. + * @param turnId The ID of the turn to branch from. + */ + createSideChat(sessionId: string, sourceChat: URI, turnId: string): Promise; + /** * Send a request for a chat within a session. * diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index 74d6030d0af..0dc635bdf69 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -208,6 +208,7 @@ class MockSessionStore implements ISessionsManagementService { createQuickChat(_options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } createNewChatInSession(_session: ISession): Promise { throw new Error('not implemented'); } forkChatInSession(_session: ISession, _sourceChat: URI, _turnId: string): Promise { throw new Error('not implemented'); } + createSideChatInSession(_session: ISession, _sourceChat: URI, _turnId: string): Promise { throw new Error('not implemented'); } discardNewSession(): void { throw new Error('not implemented'); } unsetNewSession(): void { throw new Error('not implemented'); } sendNewChatRequest(_session: ISession, _options: ISendRequestOptions): Promise { throw new Error('not implemented'); } 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 500791dd14c..a5d9920d14c 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -172,6 +172,7 @@ class TestSessionsProvider extends mock() { override async sendRequest(_sessionId: string, _chatResource: URI, _options: ISendRequestOptions): Promise { return this._session; } override async createNewChat(): Promise { return this._session.mainChat.get(); } override async forkChat(_sessionId: string, _sourceChat: URI, _turnId: string): Promise { throw new Error('not implemented'); } + override async createSideChat(_sessionId: string, _sourceChat: URI, _turnId: string): Promise { throw new Error('not implemented'); } } function createSessionsManagementService(session: ISession, disposables: ReturnType, provider: ISessionsProvider = new TestSessionsProvider(session)): { service: ISessionsManagementService; view: SessionsService; chatWidgetService: TestChatWidgetService; chatService: TestChatService } { @@ -1827,6 +1828,49 @@ suite('SessionsManagementService', () => { }); }); + suite('createSideChatInSession', () => { + + test('asks the provider to create the side chat when the session supports it', async () => { + const sourceChat = URI.parse('test:///source'); + const sideChat: IChat = { ...stubChat, resource: URI.parse('test:///side') }; + const session = stubSession({ sessionId: 'side', providerId: 'test', capabilities: constObservable({ supportsMultipleChats: true, supportsSideChat: true }) }); + let createSideChatArgs: readonly [string, URI, string] | undefined; + const provider = new class extends TestSessionsProvider { + constructor() { super(session); } + override async createSideChat(sessionId: string, sourceChat: URI, turnId: string): Promise { + createSideChatArgs = [sessionId, sourceChat, turnId]; + return sideChat; + } + }; + const { service } = createSessionsManagementService(session, disposables, provider); + + const result = await service.createSideChatInSession(session, sourceChat, 'turn-1'); + + assert.deepStrictEqual({ + result: result.resource.toString(), + args: createSideChatArgs?.map(arg => URI.isUri(arg) ? arg.toString() : arg), + }, { + result: sideChat.resource.toString(), + args: ['side', sourceChat.toString(), 'turn-1'], + }); + }); + + test('throws when the provider is not found', async () => { + const session = stubSession({ sessionId: 'orphan', providerId: 'missing-provider', capabilities: constObservable({ supportsMultipleChats: true, supportsSideChat: true }) }); + const provider = new TestSessionsProvider(stubSession({ sessionId: 'other', providerId: 'test' })); + const { service } = createSessionsManagementService(session, disposables, provider); + + await assert.rejects(() => service.createSideChatInSession(session, URI.parse('test:///source'), 'turn-1'), /Provider 'missing-provider' not found/); + }); + + test('throws when the session does not support side chats', async () => { + const session = stubSession({ sessionId: 'no-side-chat', providerId: 'test', capabilities: constObservable({ supportsMultipleChats: true, supportsSideChat: false }) }); + const { service } = createSessionsManagementService(session, disposables); + + await assert.rejects(() => service.createSideChatInSession(session, URI.parse('test:///source'), 'turn-1'), /does not support side chats/); + }); + }); + suite('closed chats persistence', () => { function chat(id: string, status: SessionStatus = SessionStatus.Completed): IChat { diff --git a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts index 682de76ab71..bfce21ea9ac 100644 --- a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts @@ -1167,6 +1167,16 @@ suite('VisibleSession - visibleChatTabs', () => { assert.deepStrictEqual(visible.closedChats.get().map(c => c.title.get()), []); }); + + test('excludes side-chat (`/btw`) origin chats from the tab strip', () => { + const visible = createSession([ + makeChat('main'), + makeChat('side', SessionStatus.Completed, ChatOriginKind.SideChat), + makeChat('second'), + ]); + + assert.deepStrictEqual(visible.visibleChatTabs.get().map(c => c.title.get()), ['main', 'second']); + }); }); suite('VisibleSession - shouldShowChatTabs', () => { @@ -1223,6 +1233,15 @@ suite('VisibleSession - shouldShowChatTabs', () => { assert.strictEqual(visible.shouldShowChatTabs.get(), true); }); + test('hidden for a single chat matching the session title even when a side chat exists', () => { + const visible = createSession('Title', [ + makeChat('main', 'Title'), + makeChat('side', 'side', ChatOriginKind.SideChat), + ]); + // Unlike a subagent, a side chat never forces the tab strip to show. + assert.strictEqual(visible.shouldShowChatTabs.get(), false); + }); + test('hidden when there are no tab chats', () => { const main = makeChat('main', 'Title'); const base = stubSession('S'); @@ -1251,6 +1270,59 @@ suite('VisibleSession - shouldShowChatTabs', () => { }); }); +suite('VisibleSession - side chat exclusion', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function makeChat(id: string, origin?: ChatOriginKind): IChat { + return { + ...stubChat, + resource: URI.parse(`test:///chat/${id}`), + title: constObservable(id), + status: constObservable(SessionStatus.Completed), + origin: origin ? { kind: origin } : undefined, + }; + } + + function createSession(chats: IChat[]) { + const base = stubSession('S'); + const session: ISession = { ...base, chats: constObservable(chats), mainChat: constObservable(chats[0]) }; + return disposables.add(new VisibleSession(session, chats[0])); + } + + test('openChat is a no-op for a side-chat origin chat', () => { + const chats = [makeChat('main'), makeChat('side', ChatOriginKind.SideChat)]; + const visible = createSession(chats); + + visible.openChat(chats[1]); + + assert.deepStrictEqual(visible.visibleChatTabs.get().map(c => c.title.get()), ['main']); + }); + + test('closeChat is a no-op for a side-chat origin chat', () => { + const chats = [makeChat('main'), makeChat('side', ChatOriginKind.SideChat)]; + const visible = createSession(chats); + + visible.closeChat(chats[1]); + + assert.deepStrictEqual(visible.closedChats.get().map(c => c.title.get()), []); + }); + + test('the active-chat fallback never selects a side chat', () => { + const main = makeChat('main'); + const second = makeChat('second'); + const side = makeChat('side', ChatOriginKind.SideChat); + const visible = createSession([main, second, side]); + + // Closing the active chat falls back to the last remaining eligible tab; + // even though `side` is last in the chat list, it must never be selected. + visible.setActiveChat(second); + visible.closeChat(second); + + assert.strictEqual(visible.activeChat.get(), main); + }); +}); + suite('VisibleSession - per-chat model/mode', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts b/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts index af6ce5d8647..e299aa88bfe 100644 --- a/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts +++ b/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts @@ -4,12 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; import { constObservable, observableValue, autorun, ISettableObservable } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; -import { SessionHasGitRepositoryContext } from '../../../../common/contextkeys.js'; -import { ISession } from '../../common/session.js'; +import { SessionHasGitRepositoryContext, SessionSupportsSideChatContext } from '../../../../common/contextkeys.js'; +import { ChatInteractivity, IChat, ISession } from '../../common/session.js'; import { setSessionContextKeys } from '../../common/sessionContextKeys.js'; function createSession(hasGitRepository: ISettableObservable): ISession { @@ -27,6 +29,50 @@ function createSession(hasGitRepository: ISettableObservable): ISession }); } +const stubChat: IChat = { + resource: URI.parse('test:///chat'), + createdAt: new Date(), + title: constObservable('Chat'), + updatedAt: constObservable(new Date()), + status: constObservable(0), + changes: constObservable([]), + checkpoints: constObservable(undefined), + modelId: constObservable(undefined), + mode: constObservable(undefined), + isArchived: constObservable(false), + isRead: constObservable(true), + interactivity: constObservable(ChatInteractivity.Full), + description: constObservable(undefined), + lastTurnEnd: constObservable(undefined), +}; + +function stubSession(overrides: Partial & Pick): ISession { + return { + providerId: 'test', + resource: URI.parse(`test:///${overrides.sessionId}`), + sessionType: 'test', + icon: Codicon.vm, + createdAt: new Date(), + workspace: constObservable(undefined), + title: constObservable('Test'), + updatedAt: constObservable(new Date()), + status: constObservable(0), + changesets: constObservable([]), + changes: constObservable([]), + modelId: constObservable(undefined), + mode: constObservable(undefined), + loading: constObservable(false), + isArchived: constObservable(false), + isRead: constObservable(true), + description: constObservable(undefined), + lastTurnEnd: constObservable(undefined), + chats: constObservable([stubChat]), + mainChat: constObservable(stubChat), + capabilities: constObservable({ supportsMultipleChats: false }), + ...overrides, + }; +} + suite('Session Context Keys', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -61,3 +107,36 @@ suite('Session Context Keys', () => { }); }); }); + +suite('setSessionContextKeys - side chat', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('supportsSideChat reflects the session capability', () => { + const contextKeyService = disposables.add(new MockContextKeyService()); + const session = stubSession({ sessionId: 'a', capabilities: constObservable({ supportsMultipleChats: true, supportsSideChat: true }) }); + + setSessionContextKeys(session, contextKeyService, undefined); + + assert.strictEqual(SessionSupportsSideChatContext.getValue(contextKeyService), true); + }); + + test('supportsSideChat defaults to false when the capability is omitted', () => { + const contextKeyService = disposables.add(new MockContextKeyService()); + const session = stubSession({ sessionId: 'a', capabilities: constObservable({ supportsMultipleChats: true }) }); + + setSessionContextKeys(session, contextKeyService, undefined); + + assert.strictEqual(SessionSupportsSideChatContext.getValue(contextKeyService), false); + }); + + test('supportsSideChat resets to false for an undefined session', () => { + const contextKeyService = disposables.add(new MockContextKeyService()); + const session = stubSession({ sessionId: 'a', capabilities: constObservable({ supportsMultipleChats: true, supportsSideChat: true }) }); + + setSessionContextKeys(session, contextKeyService, undefined); + assert.strictEqual(SessionSupportsSideChatContext.getValue(contextKeyService), true); + + setSessionContextKeys(undefined, contextKeyService, undefined); + assert.strictEqual(SessionSupportsSideChatContext.getValue(contextKeyService), false); + }); +}); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 646c8f725ed..c95ffacaa38 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -461,6 +461,7 @@ import './browser/layoutActions.js'; import './contrib/accountMenu/browser/account.contribution.js'; import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.js'; import './contrib/chat/browser/chat.contribution.js'; +import './contrib/chat/browser/btwSlashCommand.contribution.js'; import './contrib/promptTimeline/browser/promptTimeline.contribution.js'; import './contrib/providers/agentHost/browser/exportDebugLogsAction.js'; import './contrib/providers/agentHost/browser/agentHostSessionConfigPicker.js'; @@ -476,6 +477,7 @@ import './contrib/sessions/browser/customizationsToolbar.contribution.js'; import './contrib/changes/browser/changes.contribution.js'; import './contrib/codeReview/browser/codeReview.contributions.js'; import './contrib/files/browser/files.contribution.js'; +import './contrib/sideChat/browser/sideChat.contribution.js'; import './contrib/github/browser/github.contribution.js'; import './contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.js'; import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; // view registration disabled; filesystem provider still needed diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 14a350d846f..1ea9d823377 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -26,7 +26,7 @@ import { autorun, derived, observableFromEvent, observableValue } from '../../.. import { extUri, isEqual } from '../../../../../base/common/resources.js'; import { isDefined } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; -import { ChatPerfMark, markChat } from '../../common/chatPerf.js'; +import { ChatPerfMark, clearChatMarks, markChat } from '../../common/chatPerf.js'; import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; @@ -58,6 +58,7 @@ import { IChatModel, IChatModelInputState, IChatResponseModel, logChangesToState import { ChatMode, getModeNameForTelemetry, IChatMode } from '../../common/chatModes.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestToolPart, ChatRequestToolSetPart, chatSubcommandLeader, formatChatQuestion, IParsedChatRequest } from '../../common/requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../../common/requestParser/chatRequestParser.js'; +import { ChatMessageRole, IChatMessage } from '../../common/languageModels.js'; import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../attachments/chatVariables.js'; import { ChatRequestQueueKind, ChatSendResult, IChatLocationData, IChatSendRequestOptions, IChatService } from '../../common/chatService/chatService.js'; import { IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; @@ -2730,6 +2731,10 @@ export class ChatWidget extends Disposable implements IChatWidget { attachedContext: options?.enableImplicitContext === false ? this.input.getAttachedContext() : this.input.getAttachedAndImplicitContext(), }; + const isUserQuery = !query; + if (this.viewModel.model.requestInProgress.get() && await this._executeSlashCommandDuringRequest(requestInputs.input, isUserQuery, options.preserveFocus)) { + return; + } const isEditing = this.viewModel?.editing; const editedModelRequestOptions = isEditing && this.configurationService.getValue('chat.editRequests') !== 'input' ? this.getSelectedModelRequestOptions() @@ -2929,6 +2934,54 @@ export class ChatWidget extends Disposable implements IChatWidget { return sent.data.responseCreatedPromise; } + private async _executeSlashCommandDuringRequest(input: string, storeToHistory: boolean, preserveFocus: boolean | undefined): Promise { + const viewModel = this.viewModel; + if (!viewModel) { + return false; + } + const parsedRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest( + viewModel.sessionResource, + input, + this.location, + { + selectedAgent: this._lastSelectedAgent, + mode: this.input.currentModeKind, + attachmentCapabilities: this.attachmentCapabilities, + forcedAgent: this._lockedAgent?.id ? this.chatAgentService.getAgent(this._lockedAgent.id) : undefined, + }, + ); + const commandPart = parsedRequest.parts.find((part): part is ChatRequestSlashCommandPart => part instanceof ChatRequestSlashCommandPart); + if (!commandPart?.slashCommand.executeDuringRequest || commandPart.slashCommand.silent !== true) { + return false; + } + + const history: IChatMessage[] = []; + for (const request of viewModel.model.getRequests()) { + if (!request.response) { + continue; + } + history.push({ role: ChatMessageRole.User, content: [{ type: 'text', value: request.message.text }] }); + history.push({ role: ChatMessageRole.Assistant, content: [{ type: 'text', value: request.response.response.toString() }] }); + } + + this.input.acceptInput(storeToHistory, preserveFocus); + const prompt = parsedRequest.text.substring(commandPart.slashCommand.command.length + 1).trimStart(); + try { + await this.chatSlashCommandService.executeCommand( + commandPart.slashCommand.command, + prompt, + Progress.None, + history, + this.location, + viewModel.sessionResource, + CancellationToken.None, + ); + } finally { + clearChatMarks(viewModel.sessionResource); + } + return true; + } + // Resolve images from directory attachments to send as additional variables. private async _resolveDirectoryImageAttachments(attachments: IChatRequestVariableEntry[]): Promise { const imagePromises: Promise[] = []; diff --git a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts index a0f7a798d15..cb889ad422d 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts @@ -29,6 +29,11 @@ export interface IChatSlashData { */ executeImmediately?: boolean; + /** + * Whether a silent command can execute independently while the chat has a request in progress. + */ + executeDuringRequest?: boolean; + /** * Whether the command should be added as a request/response * turn to the chat history. Defaults to `false`.