diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 8a43ae5f3c9f6..2336664d17b00 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -853,7 +853,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC workingDirectory: typeof s.workingDirectory === 'string' ? toAgentHostUri(URI.parse(s.workingDirectory), this._connectionAuthority) : undefined, isRead: !!(s.status & SessionStatus.IsRead), isArchived: !!(s.status & SessionStatus.IsArchived), - changesets: s.changesets, + changes: s.changes })); } diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 3600cf82f0f5a..e90191114e93f 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -17,7 +17,7 @@ import type { IAgentSubscription } from './state/agentSubscription.js'; import type { IRemoteWatchHandle } from './agentHostFileSystemProvider.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from './state/protocol/channels-changeset/commands.js'; -import { ProtectedResourceMetadata, type ChangesetSummary, type ConfigSchema, type MessageAttachment, type ModelSelection, type AgentSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition } from './state/protocol/state.js'; +import { ProtectedResourceMetadata, type Changeset, type ChangesSummary, type ConfigSchema, type MessageAttachment, type ModelSelection, type AgentSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition } from './state/protocol/state.js'; import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from './state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWatchState, ResourceWriteParams, ResourceWriteResult, CreateResourceWatchParams, CreateResourceWatchResult, IStateSnapshot } from './state/sessionProtocol.js'; import { ComponentToState, SessionInputResponseKind, SessionStatus, StateComponents, type ClientPluginCustomization, type Customization, type PendingMessage, type RootState, type SessionInputAnswer, type SessionMeta, type ToolCallResult, type Turn, type PolicyState } from './state/sessionState.js'; @@ -278,15 +278,22 @@ export interface IAgentSessionMetadata { readonly customizationDirectory?: URI; readonly isRead?: boolean; readonly isArchived?: boolean; + /** + * Aggregate counts (additions / deletions / files) describing the + * `changeKind: 'session'` changeset for this session — the chip + * aggregate previously embedded in the catalogue entry. Mirrors + * `SessionSummary.changes`. + */ + readonly changes?: ChangesSummary; /** * Catalogue of changesets the agent can produce for this session — the - * {@link ChangesetSummary | catalogue} that travels on - * `SessionSummary.changesets`. Lightweight summary entries (id / label / - * URI template / aggregate counts) without per-file detail; clients - * subscribe to a specific expanded changeset URI when they need the full - * file list. + * {@link Changeset | catalogue} that travels on `SessionState.changesets`. + * Lightweight catalogue entries (label / URI template / `changeKind`) + * without per-file detail or aggregate counts; clients subscribe to a + * specific expanded changeset URI when they need the full file list, + * and consult {@link changes} for the chip-level aggregate. */ - readonly changesets?: readonly ChangesetSummary[]; + readonly changesets?: readonly Changeset[]; /** * Side-channel metadata mirroring {@link SessionState._meta}, propagated * to clients via per-session state subscriptions. diff --git a/src/vs/platform/agentHost/common/changesetUri.ts b/src/vs/platform/agentHost/common/changesetUri.ts index bbf622c54d813..4a0a31b48bedb 100644 --- a/src/vs/platform/agentHost/common/changesetUri.ts +++ b/src/vs/platform/agentHost/common/changesetUri.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../nls.js'; -import type { ChangesetSummary, URI } from './state/sessionState.js'; +import type { Changeset, URI } from './state/sessionState.js'; /** * Helpers for building / parsing the URI clients subscribe to in order to @@ -281,9 +281,18 @@ export function parseCompareTurnsChangesetUri(uri: URI): { sessionUri: URI; orig * compare-turns diffs construct the URI themselves from two known * turn ids and subscribe directly. */ -export function buildDefaultChangesetCatalogue(sessionUri: URI): ChangesetSummary[] { +export function buildDefaultChangesetCatalogue(sessionUri: URI): Changeset[] { return [ - { label: sessionChangesetLabel(), uriTemplate: buildSessionChangesetUri(sessionUri) }, - { label: uncommittedChangesetLabel(), uriTemplate: buildUncommittedChangesetUri(sessionUri), description: uncommittedChangesetDescription() } - ]; + { + label: sessionChangesetLabel(), + uriTemplate: buildSessionChangesetUri(sessionUri), + changeKind: 'session' + }, + { + label: uncommittedChangesetLabel(), + uriTemplate: buildUncommittedChangesetUri(sessionUri), + description: uncommittedChangesetDescription(), + changeKind: 'uncommitted' + } + ] satisfies Changeset[]; } diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 97efb749b1e40..b02dfddb0ebf3 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -740f6cf +0ce713b diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-changeset/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-changeset/state.ts index f22feab892bba..8cd120ab23d95 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-changeset/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-changeset/state.ts @@ -21,7 +21,7 @@ import type { StringOrMarkdown, FileEdit, ErrorInfo } from '../common/state.js'; * * @category Changesets */ -export interface ChangesetSummary { +export interface Changeset { /** Human-readable label, e.g. `"Uncommitted Changes"`. */ label: string; /** @@ -44,12 +44,26 @@ export interface ChangesetSummary { uriTemplate: string; /** Optional longer description. */ description?: string; - /** Aggregate line additions across the changeset, when known. */ - additions?: number; - /** Aggregate line deletions across the changeset, when known. */ - deletions?: number; - /** Number of files in the changeset, when known. */ - files?: number; + /** + * Advisory hint describing what kind of changeset this is, so clients can + * group, sort, or render an appropriate icon without parsing + * {@link uriTemplate}. Recognized values include: + * + * - `'session'`: a static, session-wide changeset covering all changes the + * agent has produced in this session. + * - `'branch'`: changes relative to a base branch (e.g. a feature branch + * diffed against `main`). + * - `'uncommitted'`: the workspace's current uncommitted changes. + * - `'turn'`: changes produced by a single turn. Typically paired with a + * `{turnId}` variable in {@link uriTemplate}. + * - `'compare-turns'`: a diff between two turns. Typically paired with + * `{originalTurnId}` and `{modifiedTurnId}` variables in + * {@link uriTemplate}. + * + * Implementations MAY provide additional values; clients SHOULD fall back + * to a reasonable default when an unknown value is encountered. + */ + changeKind: string; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts index b578be490d881..5b5271dd8924c 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts @@ -104,8 +104,7 @@ export interface SessionRemovedParams { * {@link SessionSummary | `SessionSummary`} changes for a session the * server has surfaced via `listSessions()` or `root/sessionAdded`. * Servers MAY coalesce or debounce updates for noisy fields (for example, - * `modifiedAt` bumps while a turn is streaming, or rapidly changing - * `changesets`) at their discretion. + * `modifiedAt` bumps while a turn is streaming) at their discretion. * - Clients that have no cached entry for `session` MAY ignore the * notification; it is not a substitute for `root/sessionAdded`. * diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts index 08e3d4bd18bf9..50a2cc41ad707 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts @@ -10,7 +10,7 @@ import { ActionType } from '../common/actions.js'; import type { StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo, URI } from '../common/state.js'; import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type Message, type ResponsePart, type ToolCallResult, type ToolResultContent, type ToolDefinition, type SessionActiveClient, type Customization, type McpServerState, type SessionInputAnswer, type SessionInputRequest, type SessionInputResponseKind, type ConfirmationOption, type AgentSelection, type ToolCallContributor } from './state.js'; import type { ModelSelection } from '../channels-root/state.js'; -import type { ChangesetSummary } from '../channels-changeset/state.js'; +import type { Changeset } from '../channels-changeset/state.js'; // ─── Tool Call Action Base ─────────────────────────────────────────────────── @@ -478,14 +478,14 @@ export interface SessionActivityChangedAction { } /** - * The {@link ChangesetSummary | catalogue of changesets} the agent host + * The {@link Changeset | catalogue of changesets} the agent host * advertises for this session changed. Replaces - * `state.summary.changesets` entirely (full-replacement semantics) — set - * to `undefined` to clear the catalogue. + * {@link SessionState.changesets | `state.changesets`} entirely + * (full-replacement semantics) — set to `undefined` to clear the + * catalogue. * - * Producers dispatch this whenever entries are added, removed, or have - * their aggregate counts (`additions` / `deletions` / `files`) refreshed. - * The fan-out happens through this action so observers see catalogue + * Producers dispatch this whenever entries are added or removed. The + * fan-out happens through this action so observers see catalogue * mutations in the same {@link ChangesetAction | per-changeset} action * stream they already follow for file-level updates. * @@ -495,7 +495,7 @@ export interface SessionActivityChangedAction { export interface SessionChangesetsChangedAction { type: ActionType.SessionChangesetsChanged; /** New catalogue, or `undefined` to clear it */ - changesets: ChangesetSummary[] | undefined; + changesets: Changeset[] | undefined; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts index 32053159ad8b1..742cd05590faa 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts @@ -549,11 +549,10 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: }; case ActionType.SessionChangesetsChanged: { - const { changesets: _omit, ...summaryWithoutChangesets } = state.summary; - const newSummary = action.changesets - ? { ...summaryWithoutChangesets, changesets: action.changesets } - : summaryWithoutChangesets; - return { ...state, summary: newSummary }; + const { changesets: _omit, ...stateWithoutChangesets } = state; + return action.changesets + ? { ...stateWithoutChangesets, changesets: action.changesets } + : stateWithoutChangesets; } case ActionType.SessionConfigChanged: diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index 1e4d9ee71761a..0a9afa2e14a60 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import type { ChangesetSummary } from '../channels-changeset/state.js'; +import type { Changeset } from '../channels-changeset/state.js'; import type { ModelSelection } from '../channels-root/state.js'; import type { ConfigPropertySchema, ContentRef, ErrorInfo, FileEdit, Icon, ProtectedResourceMetadata, StringOrMarkdown, TextRange, TextSelection, URI, UsageInfo } from '../common/state.js'; @@ -127,6 +127,14 @@ export interface SessionState { * are server-originated. */ customizations?: Customization[]; + /** + * Catalogue of changesets the server can produce for this session. Each + * entry advertises a subscribable view of file changes (uncommitted, + * session-wide, per-turn, etc.) and the URI template the client expands + * before subscribing. See {@link Changeset} for the full shape and + * {@link /guide/changesets | Changesets} for an overview of the model. + */ + changesets?: Changeset[]; /** * Additional provider-specific metadata for this session. * @@ -207,13 +215,29 @@ export interface SessionSummary { /** The working directory URI for this session */ workingDirectory?: URI; /** - * Catalogue of changesets the server can produce for this session. Each - * entry advertises a subscribable view of file changes (uncommitted, - * session-wide, per-turn, etc.) and the URI template the client expands - * before subscribing. See {@link ChangesetSummary} for the full shape and - * {@link /guide/changesets | Changesets} for an overview of the model. - */ - changesets?: ChangesetSummary[]; + * Aggregate summary of file changes associated with this session. Servers + * may populate this to give clients a quick at-a-glance view of the + * session's footprint (e.g., for list rendering) without requiring the + * client to subscribe to a changeset. + */ + changes?: ChangesSummary; +} + +/** + * Aggregate counts describing the file changes associated with a session. + * + * All fields are optional so servers can populate only the metrics they + * cheaply have available. + * + * @category Session State + */ +export interface ChangesSummary { + /** Total number of inserted lines across all changed files. */ + additions?: number; + /** Total number of deleted lines across all changed files. */ + deletions?: number; + /** Number of files that have changes. */ + files?: number; } // ─── Model Selection ───────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/common/state.ts b/src/vs/platform/agentHost/common/state/protocol/common/state.ts index 2f4876cb66721..ebca2ffc25298 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/state.ts @@ -10,7 +10,6 @@ import type { RootState } from '../channels-root/state.js'; import type { SessionState } from '../channels-session/state.js'; import type { TerminalState } from '../channels-terminal/state.js'; import type { ChangesetState } from '../channels-changeset/state.js'; -import type { ResourceWatchState } from '../channels-resource-watch/state.js'; // ─── Type Aliases ──────────────────────────────────────────────────────────── @@ -323,7 +322,7 @@ export interface Snapshot { /** The subscribed channel URI (e.g. `ahp-root://` or `ahp-session:/`) */ resource: URI; /** The current state of the resource */ - state: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState; + state: RootState | SessionState | TerminalState | ChangesetState; /** The `serverSeq` at which this snapshot was taken. Subsequent actions will have `serverSeq > fromSeq`. */ fromSeq: number; } diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 7cd72764c78e4..4377b3dbdb858 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -38,7 +38,7 @@ import { // Re-export everything from the protocol state module export { ChangesetOperationScope, ChangesetOperationStatus, ChangesetStatus, CustomizationLoadStatus, - CustomizationType, MessageAttachmentKind, MessageKind, + CustomizationType, McpServerStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, PolicyState, ResponsePartKind, @@ -50,7 +50,7 @@ export { SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, type ActiveTurn, type AgentCustomization, type AgentInfo, type AgentSelection, type ChangesetFile, - type ChangesetOperation, type ChangesetState, type ChangesetSummary, type ChildCustomization, type ClientPluginCustomization, type ConfigPropertySchema, + type Changeset, type ChangesetOperation, type ChangesetState, type ChildCustomization, type ClientPluginCustomization, type ConfigPropertySchema, type ConfigSchema, type ContentRef, type Customization, type CustomizationDegradedState, type CustomizationErrorState, type CustomizationLoadedState, type CustomizationLoadingState, type CustomizationLoadState, type DirectoryCustomization, type ErrorInfo, type HookCustomization, type FileEdit as ISessionFileDiff, type ToolResultEmbeddedResourceContent as IToolResultBinaryContent, type MarkdownResponsePart, type McpServerCustomization, type MessageAttachment, diff --git a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts index ae70dd72b1ba1..3d552aaf5eabb 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts @@ -8,7 +8,6 @@ import { URI } from '../../../base/common/uri.js'; import { IAgentSessionMetadata } from '../common/agentService.js'; import { buildSessionChangesetUri, - buildUncommittedChangesetUri, ChangesetKind, parseChangesetUri, } from '../common/changesetUri.js'; @@ -20,13 +19,15 @@ import { IAgentHostGitService } from './agentHostGitService.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { ILogService } from '../../log/common/log.js'; import { - buildCatalogueFromLiveState, - buildCatalogueFromPersistedDiffs, + computeChangesSummaryFromLiveState, + computeChangesSummaryFromPersistedDiffs, IAgentHostChangesetService, + META_CHANGES_SUMMARY, META_CHANGESET_SESSION, META_CHANGESET_UNCOMMITTED, META_LEGACY_DIFFS, } from './agentHostChangesetService.js'; +import { ChangesSummary } from '../common/state/protocol/channels-session/state.js'; /** * Raw metadata blob values for the session DB, batch-read by the caller. @@ -46,6 +47,7 @@ export type IChangesetSessionMetadata = Record; export const CHANGESET_DB_METADATA_KEYS: Record = { [META_CHANGESET_UNCOMMITTED]: true, [META_CHANGESET_SESSION]: true, + [META_CHANGES_SUMMARY]: true, [META_LEGACY_DIFFS]: true, }; @@ -120,8 +122,8 @@ export class ChangesetSessionCoordinator extends Disposable { * on the state manager so client subscriptions resolve to a * `status: computing` snapshot before the first compute pass. * - * The catalogue summary (`summary.changesets`) is seeded synchronously - * by `_buildInitialSummary` in {@link AgentService} via + * The catalogue (`state.changesets`) is seeded synchronously by + * `_buildInitialSummary` in {@link AgentService} via * {@link buildDefaultChangesetCatalogue}; this method only registers * the backing per-changeset state. Both halves run before * `SessionReady` is dispatched. @@ -137,13 +139,9 @@ export class ChangesetSessionCoordinator extends Disposable { * `AgentService` already issues for title / read / archive / config * keys. */ - onSessionRestored(sessionStr: string, metadata: IChangesetSessionMetadata): void { + onSessionRestored(sessionStr: string): void { this._changesets.registerStaticChangesets(sessionStr); - this._changesets.restorePersistedStaticChangesets(sessionStr, { - uncommittedRaw: metadata[META_CHANGESET_UNCOMMITTED], - sessionRaw: metadata[META_CHANGESET_SESSION], - legacyRaw: metadata[META_LEGACY_DIFFS], - }); + // `addSubscriber`'s 0→1 trigger may have fired before the session // state existed; now that `summary.workingDirectory` is populated, // drain the deferred refresh. Idempotent — the per-session @@ -333,90 +331,99 @@ export class ChangesetSessionCoordinator extends Disposable { /** * Returns the session-DB metadata keys to merge into a batched read * for `sessionStr`, OR `undefined` when live state already answers - * the catalogue question (so the caller can skip loading the - * potentially-large persisted blobs). + * the aggregate-counts question (so the caller can skip loading the + * potentially-large persisted diff blobs). * - * Returning `undefined` is the fast path: live `summary.changesets` - * (loaded session) or a ready live changeset state (registered but - * not-yet-restored session) is authoritative. + * Returning `undefined` is the fast path: a live `summary.changes` + * (loaded session) or a ready live `changeKind: 'session'` changeset + * state (registered but not-yet-restored session) is authoritative. */ getListMetadataKeys(sessionStr: string): Record | undefined { - if (this._readyLiveCatalogueExists(sessionStr)) { + const liveSummaryChanges = this._stateManager.getSessionState(sessionStr)?.summary.changes; + if (liveSummaryChanges) { return undefined; } - const liveSessionState = this._stateManager.getSessionState(sessionStr); - if (liveSessionState?.summary.changesets) { + const liveSession = this._stateManager.getChangesetState(buildSessionChangesetUri(sessionStr)); + if (liveSession?.status === ChangesetStatus.Ready) { return undefined; } return CHANGESET_DB_METADATA_KEYS; } /** - * Decorates a single listSessions entry with the catalogue overlay. + * Decorates a single listSessions entry with the `changes` aggregate + * (additions / deletions / files for the session-wide changeset). * `metadata` is the already-batched DB read; if it lacks the * changeset keys (because {@link getListMetadataKeys} returned * `undefined`), this method falls through to synthesising the - * catalogue from live state. + * aggregate from live state. * - * Precedence: live `summary.changesets` > ready live changeset state - * > parsed persisted blobs > undefined (no catalogue advertised). - * This mirrors the inline pre-coordinator logic. + * Precedence: live `summary.changes` (already projected onto `entry` + * by the caller for loaded sessions) > ready live + * `changeKind: 'session'` changeset state > parsed persisted + * session-wide diff blob > undefined (no aggregate advertised). + * The catalogue itself is uniform across sessions and is not part of + * the listSessions overlay — it is seeded on `state.changesets` once + * at session creation. */ decorateListEntry(entry: IAgentSessionMetadata, metadata: IChangesetSessionMetadata): IAgentSessionMetadata { const sessionStr = entry.session.toString(); - const liveSessionState = this._stateManager.getSessionState(sessionStr); - const liveUncommitted = this._stateManager.getChangesetState(buildUncommittedChangesetUri(sessionStr)); - const liveSession = this._stateManager.getChangesetState(buildSessionChangesetUri(sessionStr)); - const hasReadyLiveCatalogue = liveUncommitted?.status === ChangesetStatus.Ready - || liveSession?.status === ChangesetStatus.Ready; - - // Ready live state for an unopened session: synthesise the catalogue - // from that live state. Counts stay in lockstep with the actual - // changeset state for the session-list chip. - if (!liveSessionState && hasReadyLiveCatalogue) { - const catalogue = buildCatalogueFromLiveState(sessionStr, liveUncommitted, liveSession); - if (catalogue) { - return { ...entry, changesets: catalogue }; - } + + // Loaded session: the caller has already projected + // `state.summary.changes` onto the entry. Nothing to overlay. + if (this._stateManager.getSessionState(sessionStr)) { return entry; } + // Check if the metadata contains the changes summary. In the past we + // used to store the changesets in the session database but we have + // since moved to a more efficient storage mechanism by only storing + // the changes summary. + const changesSummary = metadata[META_CHANGES_SUMMARY]; + if (changesSummary !== undefined) { + return { ...entry, changes: JSON.parse(changesSummary) as ChangesSummary }; + } + + // Ready live state for an unopened session: synthesise the aggregate + // from the live `changeKind: 'session'` changeset state. Counts stay + // in lockstep with the actual changeset state for the session-list + // chip. + const liveSession = this._stateManager.getChangesetState(buildSessionChangesetUri(sessionStr)); + const liveChanges = computeChangesSummaryFromLiveState(liveSession); + if (liveChanges) { + // Migrate the changes summary to the new storage mechanism. + this._changesets.persistChangesSummary(sessionStr, liveChanges); + return { ...entry, changes: liveChanges }; + } + // No live source — try persisted blobs (if the caller batched them). - const uncommittedRaw = metadata[META_CHANGESET_UNCOMMITTED]; const sessionRaw = metadata[META_CHANGESET_SESSION]; const legacyRaw = metadata[META_LEGACY_DIFFS]; - if (uncommittedRaw === undefined && sessionRaw === undefined && legacyRaw === undefined) { + if (sessionRaw === undefined && legacyRaw === undefined) { return entry; } + + // Extract the changes summary from the persisted static changesets const restored = this._changesets.parsePersistedStaticChangesets(sessionStr, { - uncommittedRaw, sessionRaw, legacyRaw, }); + // `listSessions` must not seed full changeset state for every row; - // it only parses persisted blobs enough to render catalogue counts. + // it only parses persisted blobs enough to render the chip aggregate. // Once the session is opened via `restoreSession`, the live overlay in - // `AgentService.listSessions` replaces this parse-only catalogue. - if (!liveSessionState) { - const catalogue = buildCatalogueFromPersistedDiffs(sessionStr, restored.uncommitted, restored.session); - if (catalogue) { - return { ...entry, changesets: catalogue }; - } + // `AgentService.listSessions` replaces this parse-only aggregate. + const persistedChanges = computeChangesSummaryFromPersistedDiffs(restored.session); + if (persistedChanges) { + // Migrate the changes summary to the new storage mechanism. + this._changesets.persistChangesSummary(sessionStr, persistedChanges); + return { ...entry, changes: persistedChanges }; } return entry; } // ---- Internal ----------------------------------------------------------- - private _readyLiveCatalogueExists(sessionStr: string): boolean { - const uncommitted = this._stateManager.getChangesetState(buildUncommittedChangesetUri(sessionStr)); - if (uncommitted?.status === ChangesetStatus.Ready) { - return true; - } - const session = this._stateManager.getChangesetState(buildSessionChangesetUri(sessionStr)); - return session?.status === ChangesetStatus.Ready; - } - /** * Triggers the first uncommitted refresh for `sessionStr`, deferring * it until materialization when the working directory is not yet diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index e5c4237487db9..ef82e5ac55b6b 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -15,13 +15,10 @@ import { buildSessionChangesetUri, buildTurnChangesetUri, buildUncommittedChangesetUri, - sessionChangesetLabel, - uncommittedChangesetLabel, - uncommittedChangesetDescription, } from '../common/changesetUri.js'; import { IDiffComputeService } from '../common/diffComputeService.js'; import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js'; -import type { ChangesetState, ChangesetSummary } from '../common/state/protocol/state.js'; +import type { ChangesetState, ChangesSummary } from '../common/state/protocol/state.js'; import { ActionType } from '../common/state/sessionActions.js'; import { ChangesetStatus, @@ -48,6 +45,9 @@ export const META_CHANGESET_UNCOMMITTED = 'agentHost.changeset.uncommitted'; /** Metadata key under which the session-wide changeset's diff list is persisted. */ export const META_CHANGESET_SESSION = 'agentHost.changeset.session'; +/** Metadata key under which the changes summary is persisted. */ +export const META_CHANGES_SUMMARY = 'agentHost.changes'; + /** * Legacy metadata key used by older builds to persist the session-wide * changeset's diff list. Read-only fallback for {@link META_CHANGESET_SESSION}. @@ -61,20 +61,14 @@ function staticChangesetUri(session: ProtocolURI, kind: StaticChangesetKind): Pr return kind === 'uncommitted' ? buildUncommittedChangesetUri(session) : buildSessionChangesetUri(session); } -function persistKeyFor(kind: StaticChangesetKind): string { - return kind === 'uncommitted' ? META_CHANGESET_UNCOMMITTED : META_CHANGESET_SESSION; -} - /** - * Builds a single static {@link ChangesetSummary} catalogue entry from a - * persisted (or live-state-derived) file list. Returns the bare entry - * (no counts) when `diffs` is undefined. Optional `description` is - * threaded through when provided. + * Sums the per-file diff counts into the {@link ChangesSummary} shape + * that lives on `summary.changes`. Returns `undefined` for an undefined + * input so callers can distinguish "no data yet" from "data, zero changes". */ -function buildStaticCatalogueEntry(label: string, uri: string, diffs: readonly ISessionFileDiff[] | undefined, description?: string): ChangesetSummary { - const base: ChangesetSummary = description ? { label, uriTemplate: uri, description } : { label, uriTemplate: uri }; +function summariseDiffs(diffs: readonly ISessionFileDiff[] | undefined): ChangesSummary | undefined { if (!diffs) { - return base; + return undefined; } let additions = 0; let deletions = 0; @@ -82,70 +76,44 @@ function buildStaticCatalogueEntry(label: string, uri: string, diffs: readonly I additions += d.diff?.added ?? 0; deletions += d.diff?.removed ?? 0; } - return { ...base, additions, deletions, files: diffs.length }; -} - -function defaultCatalogueWithCounts( - sessionUri: string, - uncommittedDiffs: readonly ISessionFileDiff[] | undefined, - sessionDiffs: readonly ISessionFileDiff[] | undefined, -): ChangesetSummary[] { - return [ - buildStaticCatalogueEntry(sessionChangesetLabel(), buildSessionChangesetUri(sessionUri), sessionDiffs), - buildStaticCatalogueEntry(uncommittedChangesetLabel(), buildUncommittedChangesetUri(sessionUri), uncommittedDiffs, uncommittedChangesetDescription()) - ]; + return { additions, deletions, files: diffs.length }; } /** - * Build the default ordered changeset catalogue (`Branch Changes`, - * `Uncommitted Changes`, `This Turn`) seeded from the live - * {@link ChangesetState} for an unopened session that has no live - * `SessionState` but already has ready changeset states (e.g. from a - * prior `restoreStaticChangeset` call). - * - * Returns `undefined` when no live state is ready, so `listSessions` - * naturally leaves the `changesets` field undefined for sessions that - * have no usable counts yet — preserving the long-standing contract that - * unopened sessions without persisted or live data advertise no catalogue. + * Derives the `summary.changes` aggregate for an unopened session from + * the ready live {@link ChangesetState} of the catalogue entry whose + * `changeKind === 'session'` — typically because a previous + * `restoreStaticChangeset` warmed the cache before the session itself + * was attached. * - * The two static entries (`Branch Changes`, `Uncommitted Changes`) are - * git-only — `AgentService._attachGitState` strips them from the live - * `summary.changesets` for non-git working directories. The synthesised - * catalogue here mirrors the live-state shape so list overlays stay - * consistent with the per-session catalogue clients subscribe to. + * Returns `undefined` when no live session-wide state is ready, so + * `listSessions` leaves the `changes` field unset for sessions without + * usable counts — preserving the long-standing contract that unopened + * sessions without live or persisted data advertise no aggregate. * - * The compare-turns changeset is intentionally NOT advertised in the - * catalogue — it is subscribe-only (see - * {@link buildDefaultChangesetCatalogue}). + * Only the `changeKind: 'session'` entry feeds the summary; other kinds + * (`'uncommitted'`, `'turn'`, `'compare-turns'`) describe slices, not + * the session-level footprint. The static catalogue itself (built by + * {@link buildDefaultChangesetCatalogue}) is independent of counts and + * is seeded once at session creation. */ -export function buildCatalogueFromLiveState( - sessionUri: string, - uncommitted: ChangesetState | undefined, +export function computeChangesSummaryFromLiveState( session: ChangesetState | undefined, -): ChangesetSummary[] | undefined { - const uncommittedDiffs = uncommitted?.status === ChangesetStatus.Ready ? uncommitted.files.map(f => f.edit) : undefined; +): ChangesSummary | undefined { const sessionDiffs = session?.status === ChangesetStatus.Ready ? session.files.map(f => f.edit) : undefined; - if (!uncommittedDiffs && !sessionDiffs) { - return undefined; - } - return defaultCatalogueWithCounts(sessionUri, uncommittedDiffs, sessionDiffs); + return summariseDiffs(sessionDiffs); } /** - * Build the default ordered changeset catalogue from parsed persisted - * diffs. Returns `undefined` when both inputs are absent so unopened - * sessions with no usable data leave `changesets` undefined — preserving - * the existing `listSessions` behaviour for malformed metadata cases. + * Derives the `summary.changes` aggregate for an unopened session from + * parsed persisted diffs for the `changeKind: 'session'` catalogue + * entry. Returns `undefined` when the session-wide blob is absent so + * malformed metadata leaves `summary.changes` unset. */ -export function buildCatalogueFromPersistedDiffs( - sessionUri: string, - uncommittedDiffs: readonly ISessionFileDiff[] | undefined, +export function computeChangesSummaryFromPersistedDiffs( sessionDiffs: readonly ISessionFileDiff[] | undefined, -): ChangesetSummary[] | undefined { - if (!uncommittedDiffs && !sessionDiffs) { - return undefined; - } - return defaultCatalogueWithCounts(sessionUri, uncommittedDiffs, sessionDiffs); +): ChangesSummary | undefined { + return summariseDiffs(sessionDiffs); } /** @@ -181,8 +149,9 @@ export interface IPersistedChangesetMetadata { /** * The parsed diffs returned from {@link IAgentHostChangesetService.restorePersistedStaticChangesets}, - * suitable for passing into {@link buildCatalogueFromPersistedDiffs} when - * the caller needs to synthesise a catalogue for the session-list overlay. + * suitable for passing into {@link computeChangesSummaryFromPersistedDiffs} + * when the caller needs to synthesise a `summary.changes` aggregate for + * the session-list overlay. */ export interface IRestoredChangesetDiffs { readonly uncommitted?: readonly ISessionFileDiff[]; @@ -213,7 +182,7 @@ export interface IAgentHostChangesetService { * Registers the two static changeset URIs (`uncommitted`, `session`) * on the state manager so client subscriptions resolve to a * `status: computing` snapshot before the first compute pass - * completes. The catalogue itself (`summary.changesets`) is seeded + * completes. The catalogue itself (`state.changesets`) is seeded * upstream by `_buildInitialSummary` / `restoreSession` — this only * deals with the state-manager-side per-changeset entries. * @@ -264,6 +233,16 @@ export interface IAgentHostChangesetService { */ restorePersistedStaticChangesets(sessionUri: ProtocolURI, metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs; + /** + * Fire-and-forget persistence of the `summary.changes` aggregate to the + * session DB under {@link META_CHANGES_SUMMARY}. Used both by the + * happy-path turn-complete write and by the {@link ChangesetSessionCoordinator} + * one-shot migration that reads the old `META_CHANGESET_SESSION` / + * `META_LEGACY_DIFFS` blobs and projects them into the new key on + * sessions written by older builds. Errors are logged, not thrown. + */ + persistChangesSummary(sessionUri: ProtocolURI, summary: ChangesSummary): void; + /** * Returns true when the static changeset identified by `changesetUri` is * currently being recomputed. Used by cache eviction to avoid dropping a @@ -418,6 +397,10 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC return parsed; } + persistChangesSummary(sessionUri: ProtocolURI, summary: ChangesSummary): void { + this._persistSessionFlag(sessionUri, META_CHANGES_SUMMARY, JSON.stringify(summary)); + } + isStaticChangesetComputeActive(changesetUri: ProtocolURI): boolean { return this._activeStaticComputes.has(changesetUri); } @@ -739,15 +722,14 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } this._publishChangesetDiffs(session, changesetUri, diffs); - // Persist the file list so a subsequent `listSessions` / - // `restoreSession` can reseed the changeset before the first - // post-restart compute completes. - this._persistSessionFlag(session, persistKeyFor(kind), JSON.stringify(diffs)); - // Migration: also overwrite the legacy `'diffs'` key with the - // session-changeset payload so older readers stay correct - // during the rollout window. + + // Persist the small aggregate so a subsequent `listSessions` + // can render the chip without recomputing or reading the + // full diff list. Only the session-wide changeset feeds the + // chip — uncommitted/turn/compare slots describe slices, not + // the session-level footprint. if (kind === 'session') { - this._persistSessionFlag(session, META_LEGACY_DIFFS, JSON.stringify(diffs)); + this.persistChangesSummary(session, summariseDiffs(diffs) ?? { additions: 0, deletions: 0, files: 0 }); } } catch (err) { this._logService.warn(`[AgentHostChangesetService] Failed to compute ${kind} diffs`, err); @@ -778,14 +760,19 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC /** * Translates the new file list into a sequence of changeset/* actions - * (fileSet, fileRemoved) and updates the matching catalogue entry's - * aggregate counts via {@link AgentHostStateManager.setSessionChangesets}. + * (fileSet, fileRemoved) and — when the changeset being published is + * the catalogue's `changeKind: 'session'` entry — refreshes + * `summary.changes` via + * {@link AgentHostStateManager.setSessionSummaryChanges} so the session + * list chip stays in sync without subscribers having to attach to the + * changeset. * - * The catalogue entry is matched by URI: for static changesets the - * `uriTemplate` is the concrete URI; for the per-turn template it - * contains `{turnId}` and never matches a concrete turn URI, so per- - * turn computations don't update catalogue counts (intended — the - * template entry advertises the shape, not aggregates). + * Counts for non-`'session'` changesets (`'uncommitted'`, `'turn'`, + * `'compare-turns'`) are intentionally NOT propagated to + * `summary.changes`: that field is a single session-level aggregate, + * and the chip the UI renders is the session-wide footprint. Per-turn + * diffs are also computed off concrete URIs that never appear as + * catalogue entries, so the lookup naturally skips them. */ private _publishChangesetDiffs(session: ProtocolURI, changesetUri: ProtocolURI, diffs: readonly ISessionFileDiff[]): void { const previous = this._stateManager.getChangesetState(changesetUri); @@ -829,26 +816,29 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC }); } - // Refresh the catalogue's aggregate counts so chip rendering stays - // in sync without subscribers having to attach to the changeset. - const sessionState = this._stateManager.getSessionState(session); - if (!sessionState) { + // Identify the session-wide changeset by its catalogue entry's + // `changeKind: 'session'` rather than by URI shape, so producers + // that register an alternate session-wide URI still feed the chip. + const catalogue = this._stateManager.getSessionState(session)?.changesets; + const entry = catalogue?.find(c => c.uriTemplate === changesetUri); + if (entry?.changeKind !== 'session') { return; } - const totals = Array.from(nextFilesById.values()).reduce( - (acc, d) => { - acc.additions += d.diff?.added ?? 0; - acc.deletions += d.diff?.removed ?? 0; - return acc; - }, - { additions: 0, deletions: 0 }, - ); - const existing = sessionState.summary.changesets ?? []; - const next = existing.map(c => c.uriTemplate === changesetUri - ? { ...c, additions: totals.additions, deletions: totals.deletions, files: nextFilesById.size } - : c, - ); - this._stateManager.setSessionChangesets(session, next); + + // Refresh `summary.changes` so the session list chip reflects the + // latest session-wide footprint without subscribers having to attach + // to the changeset. + let additions = 0; + let deletions = 0; + for (const d of nextFilesById.values()) { + additions += d.diff?.added ?? 0; + deletions += d.diff?.removed ?? 0; + } + this._stateManager.setSessionSummaryChanges(session, { + additions, + deletions, + files: nextFilesById.size, + }); } /** diff --git a/src/vs/platform/agentHost/node/agentHostSessionGitStateService.ts b/src/vs/platform/agentHost/node/agentHostSessionGitStateService.ts index ce1485b7ebec9..40d2e0acc1cad 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionGitStateService.ts @@ -71,7 +71,7 @@ export class AgentHostSessionGitStateService extends Disposable { private _stripGitOnlyChangesetEntries(sessionKey: string): void { const state = this._stateManager.getSessionState(sessionKey); - const current = state?.summary.changesets; + const current = state?.changesets; if (!current || current.length === 0) { return; } @@ -87,7 +87,7 @@ export class AgentHostSessionGitStateService extends Disposable { private _updateBranchChangesetDescription(sessionKey: string, gitState: { branchName?: string; baseBranchName?: string }): void { const description = formatSessionChangesetDescription(gitState.branchName, gitState.baseBranchName); const state = this._stateManager.getSessionState(sessionKey); - const current = state?.summary.changesets; + const current = state?.changesets; if (!current || current.length === 0) { return; } diff --git a/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts b/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts index a8092f0b9bb46..ccaaaf69de969 100644 --- a/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts @@ -80,7 +80,10 @@ export class AgentHostSkillCompletionProvider extends Disposable implements IAge const customizations = await agent.getSessionCustomizations(session); const result: SkillCustomization[] = []; for (const c of customizations) { - if (c.type === CustomizationType.McpServer || !c.enabled || !c.children) { + if (c.type === CustomizationType.McpServer) { + continue; + } + if (!c.enabled || !c.children) { continue; } for (const child of c.children) { diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index de577ea5672e1..43063d1cc0db4 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -12,40 +12,18 @@ import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, RootAction, StateAction, TerminalAction, ChangesetAction, isRootAction, isSessionAction, isChangesetAction } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer, changesetReducer } from '../common/state/sessionReducers.js'; -import { createRootState, createSessionState, isAhpRootChannel, SessionLifecycle, type ChangesetState, type ChangesetSummary, type RootState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus } from '../common/state/sessionState.js'; +import { createRootState, createSessionState, isAhpRootChannel, SessionLifecycle, type ChangesetState, type Changeset, type RootState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus } from '../common/state/sessionState.js'; import { AgentHostTelemetryLevelConfigKey, IPermissionsValue, platformRootSchema, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { parseChangesetUri } from '../common/changesetUri.js'; import { AgentHostChangesetStateCache, type IAgentHostChangesetStateRetentionOptions } from './agentHostChangesetStateCache.js'; +import { arrayEquals, structuralEquals } from '../../../base/common/equals.js'; +import { ChangesSummary } from '../common/state/protocol/state.js'; export interface IAgentHostStateManagerOptions { readonly changesetStateRetention?: IAgentHostChangesetStateRetentionOptions; } -/** - * Field-level equality for two changeset catalogue arrays. Used by - * {@link AgentHostStateManager.setSessionChangesets} to skip a redundant - * dispatch when the catalogue has not changed in any user-visible way. - */ -function changesetCataloguesEqual(a: readonly ChangesetSummary[] | undefined, b: readonly ChangesetSummary[] | undefined): boolean { - if (a === b) { return true; } - if (!a || !b) { return false; } - if (a.length !== b.length) { return false; } - for (let i = 0; i < a.length; i++) { - const x = a[i]; - const y = b[i]; - if (x.label !== y.label - || x.uriTemplate !== y.uriTemplate - || x.description !== y.description - || x.additions !== y.additions - || x.deletions !== y.deletions - || x.files !== y.files) { - return false; - } - } - return true; -} - /** * Server-side state manager for the sessions process protocol. * @@ -431,34 +409,65 @@ export class AgentHostStateManager extends Disposable { } /** - * Replaces the catalogue entries on `summary.changesets` for `session` - * by dispatching a {@link ActionType.SessionChangesetsChanged} action. - * The change is applied through the session reducer so subscribers see - * the mutation in the standard action stream alongside the regular - * `notify/sessionSummaryChanged` notification — the catalogue is not - * its own subscribable resource. + * Updates the aggregate `summary.changes` for a session. + * + * There is no dedicated action for this field: the value is purely + * informational (chip rendering on the session list), so the write + * piggybacks on the existing `sessionSummaryChanged` notification + * path. We mutate `state.summary` in place, mark the session dirty, + * and let {@link _flushSummaryNotificationFor} pick the new value up + * via its `current.changes !== lastNotified.changes` diff. + */ + setSessionSummaryChanges(session: URI, changes: ChangesSummary | undefined): void { + const state = this._sessionStates.get(session); + if (!state) { + this._logService.warn(`[AgentHostStateManager] setSessionSummaryChanges: unknown session ${session}`); + return; + } + if (structuralEquals(state.summary.changes, changes)) { + return; + } + + const newState = { + ...state, + summary: { ...state.summary, changes }, + }; + + this._sessionStates.set(session, newState); + + this._dirtySummaries.add(session); + this._summaryNotifyScheduler.schedule(); + } + + /** + * Replaces the catalogue entries on `state.changesets` for `session` by + * dispatching a {@link ActionType.SessionChangesetsChanged} action. + * Subscribers see the mutation in the standard session action stream — + * the catalogue lives on session state and is not its own subscribable + * resource. Aggregate `summary.changes` counts (additions / deletions / + * files) are propagated separately via {@link setSessionSummaryChanges}. * - * Producers call this after each compute pass to keep the lightweight - * chip-row counts (`additions`, `deletions`, `files`) in sync without - * forcing every observer to subscribe to the full changeset. + * Producers call this after each compute pass to keep the list of + * available changesets (with their `changeKind`) in sync so observers + * can render the correct entries without subscribing to each one. */ - setSessionChangesets(session: URI, changesets: readonly ChangesetSummary[] | undefined): void { + setSessionChangesets(session: URI, changesets: readonly Changeset[] | undefined): void { const state = this._sessionStates.get(session); if (!state) { this._logService.warn(`[AgentHostStateManager] setSessionChangesets: unknown session ${session}`); return; } - // Skip dispatch when the catalogue is field-equal to the existing - // one. The reducer would otherwise allocate a new summary on every - // call, dirtying `_dirtySummaries` and broadcasting a redundant - // envelope. Producers call this after every compute pass, so - // duplicate calls are common. - if (changesetCataloguesEqual(state.summary.changesets, changesets)) { + // Skip dispatch when the catalogue is field-equal to the existing one. + // Producers call this after every compute pass, so duplicate calls + // are common and would otherwise broadcast a redundant envelope to + // every subscriber. + if (arrayEquals(state.changesets ?? [], changesets ?? [], structuralEquals)) { return; } + // Take a defensive copy so callers can't mutate the catalogue array // after dispatch; the reducer otherwise stores the reference as-is. - const next: ChangesetSummary[] | undefined = changesets ? [...changesets] : undefined; + const next = changesets ? changesets.slice() : undefined; this.dispatchServerAction(session, { type: ActionType.SessionChangesetsChanged, changesets: next, @@ -673,8 +682,8 @@ export class AgentHostStateManager extends Disposable { if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; } if (current.project !== lastNotified.project) { changes.project = current.project; } if (current.model !== lastNotified.model) { changes.model = current.model; } + if (current.changes !== lastNotified.changes) { changes.changes = current.changes; } if (current.workingDirectory !== lastNotified.workingDirectory) { changes.workingDirectory = current.workingDirectory; } - if (current.changesets !== lastNotified.changesets) { changes.changesets = current.changesets; } this._lastNotifiedSummaries.set(session, current); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 01d764352f619..28cb5e1992ad1 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -27,7 +27,7 @@ import { ActionType, ActionEnvelope, INotification, type IRootConfigChangedActio import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } 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 { MessageAttachmentKind, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; +import { ChangesSummary, MessageAttachmentKind, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; import type { SessionPendingMessageSetAction, SessionTurnStartedAction } from '../common/state/protocol/actions.js'; import { ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType, buildResourceWatchChannelUri, buildSubagentSessionUriPrefix, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { IProductService } from '../../product/common/productService.js'; @@ -38,7 +38,7 @@ import { IGitBlobUriFields, parseGitBlobUri } from './gitDiffContent.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostGitService } from './agentHostGitService.js'; import { AgentSideEffects } from './agentSideEffects.js'; -import { AgentHostChangesetService, IAgentHostChangesetService } from './agentHostChangesetService.js'; +import { AgentHostChangesetService, IAgentHostChangesetService, META_CHANGES_SUMMARY } from './agentHostChangesetService.js'; import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../common/agentHostCheckpointService.js'; import { CHANGESET_DB_METADATA_KEYS, ChangesetSessionCoordinator } from './agentHostChangesetCoordinator.js'; @@ -392,11 +392,12 @@ export class AgentService extends Disposable implements IAgentService { // Overlay live session state from the state manager. // For the title, prefer the state manager's value when it is // non-empty, so SDK-sourced titles are not overwritten by the - // initial empty placeholder. The default `session` changeset - // catalogue entry lives on `state.summary.changesets` (published - // at session-ready/restore time and refreshed after each compute - // pass) and must be surfaced here so a fresh `listSessions` call - // returns the same catalogue subscribers see via + // initial empty placeholder. The default changeset catalogue lives + // on `state.changesets` (seeded after `createSession` / + // `restoreSession` and refreshed after each compute pass) and the + // chip aggregate on `state.summary.changes`; both must be surfaced + // here so a fresh `listSessions` call returns the same values + // subscribers see via the per-session action stream and // `notify/sessionSummaryChanged`. const withStatus = result.map(s => { const liveState = this._stateManager.getSessionState(s.session.toString()); @@ -408,7 +409,8 @@ export class AgentService extends Disposable implements IAgentService { activity: liveState.summary.activity, model: liveState.summary.model ?? s.model, agent: liveState.summary.agent ?? s.agent, - changesets: liveState.summary.changesets ?? s.changesets, + changesets: liveState.changesets ?? s.changesets, + changes: liveState.summary.changes ?? s.changes, }; } return s; @@ -543,9 +545,9 @@ export class AgentService extends Disposable implements IAgentService { } // Initial changeset state is established as part of session creation, - // never deferred to materialization. Two halves: (1) the summary - // catalogue is seeded by `buildDefaultChangesetCatalogue` inside - // `_buildInitialSummary`; (2) the backing per-changeset states are + // never deferred to materialization. Two halves: (1) the catalogue + // is seeded on `state.changesets` via `setSessionChangesets` right + // after `createSession`; (2) the backing per-changeset states are // registered by `_changesetCoordinator.onSessionCreated` here. Both // run before `SessionReady` is dispatched. Any future change must // keep both halves at create time so client subscriptions resolve @@ -556,6 +558,7 @@ export class AgentService extends Disposable implements IAgentService { // `_attachGitState` strips them once the git probe confirms the // resolved working directory is not a git repo. Pinned by item-2 // regression tests in `agentService.test.ts`. + this._stateManager.setSessionChangesets(session.toString(), buildDefaultChangesetCatalogue(session.toString())); this._changesetCoordinator.onSessionCreated(session.toString()); if (!created.provisional) { @@ -592,7 +595,6 @@ export class AgentService extends Disposable implements IAgentService { model: config?.model, agent: config?.agent, workingDirectory: (created.workingDirectory ?? config?.workingDirectory)?.toString(), - changesets: buildDefaultChangesetCatalogue(session.toString()), }; } @@ -1265,6 +1267,7 @@ export class AgentService extends Disposable implements IAgentService { let title = meta.summary ?? 'Session'; let isRead: boolean | undefined; let isArchived: boolean | undefined; + let changes: ChangesSummary | undefined; let persistedConfigValues: Record | undefined; let changesetMetadata: Record | undefined; const ref = this._sessionDataService.tryOpenDatabase?.(session); @@ -1292,10 +1295,16 @@ export class AgentService extends Disposable implements IAgentService { } else if (m.isDone !== undefined) { isArchived = m.isDone === 'true'; } - // Capture the batched changeset blobs verbatim — the - // coordinator parses, validates, and applies them - // after `restoreSession` registers the static states. + changesetMetadata = m as Record; + if (changesetMetadata[META_CHANGES_SUMMARY]) { + try { + changes = JSON.parse(changesetMetadata[META_CHANGES_SUMMARY]); + } catch (err) { + this._logService.warn(`[AgentService] Failed to parse changes summary for ${sessionStr}: ${toErrorMessage(err)}`); + } + } + if (m.configValues) { try { persistedConfigValues = JSON.parse(m.configValues); @@ -1332,22 +1341,23 @@ export class AgentService extends Disposable implements IAgentService { model: meta.model, agent: meta.agent, workingDirectory: meta.workingDirectory?.toString(), - changesets: buildDefaultChangesetCatalogue(sessionStr), + changes: meta.changes ?? changes, }; this._stateManager.restoreSession(summary, [...turns]); + this._stateManager.setSessionChangesets(sessionStr, buildDefaultChangesetCatalogue(sessionStr)); // Register the static changeset URIs and reseed them from any // persisted file lists in the batched metadata read. The catalogue - // summary is already on the summary above (seeded synchronously by - // `buildDefaultChangesetCatalogue`). The coordinator drains any + // itself is seeded on `state.changesets` synchronously by the + // `setSessionChangesets` call above. The coordinator drains any // uncommitted refresh deferred by an earlier `addSubscriber` — // `addSubscriber`'s 0→1 trigger may have fired for // `/changeset/uncommitted` before this restore ran (e.g. // active-session autorun subscribing in parallel with the // chat-view); now that `summary.workingDirectory` is populated, // re-triggering the refresh dispatches to the compute path. - this._changesetCoordinator.onSessionRestored(sessionStr, changesetMetadata ?? {}); + this._changesetCoordinator.onSessionRestored(sessionStr); // Restore persisted `_meta` (e.g. git state) onto the new session // state. This dispatches a SessionMetaChanged action. diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 29e3e9c62d4c9..a5bfe8c280566 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -29,9 +29,9 @@ import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import type { LanguageModelToolInvokedClassification, LanguageModelToolInvokedEvent } from '../../../telemetry/common/languageModelToolTelemetry.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ISessionDatabase, ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../../common/sessionDataService.js'; -import { MessageAttachmentKind, ToolCallContributorKind, type FileEdit, type MessageAttachment, type ToolDefinition } from '../../common/state/protocol/state.js'; +import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; -import { MessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type PendingMessage, type SessionInputAnswer, type SessionInputOption, type SessionInputQuestion, type SessionInputRequest, type ToolCallResult, type ToolResultContent, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { MessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, type PendingMessage, type SessionInputAnswer, type SessionInputOption, type SessionInputQuestion, type SessionInputRequest, type ToolCallResult, type ToolResultContent, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import type { ICopilotPluginInfo, IExitPlanModeResponse } from './copilotAgent.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; @@ -1755,7 +1755,6 @@ export class CopilotAgentSession extends Disposable { const toolKind = getToolKind(e.data.toolName); const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(parameters) : undefined; const toolClientId = this._clientToolNames.has(e.data.toolName) ? this._appliedSnapshot.clientId : undefined; - const contributor = toolClientId ? { kind: ToolCallContributorKind.Client, clientId: toolClientId } as const : undefined; // A new tool call invalidates the current markdown and reasoning // parts so the next text/reasoning delta after the tool call @@ -1788,7 +1787,7 @@ export class CopilotAgentSession extends Disposable { toolCallId: e.data.toolCallId, toolName: e.data.toolName, displayName, - contributor, + contributor: toolClientId ? { kind: ToolCallContributorKind.Client, clientId: toolClientId } : undefined, _meta: meta, }, parentToolCallId); diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 96d98ce07a581..8f29af8a8740f 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -696,8 +696,7 @@ export class ProtocolServerHandler extends Disposable { continue; } const toolCall = part.toolCall; - const toolContributor = toolCall.contributor; - if (toolContributor?.kind === ToolCallContributorKind.Client && toolContributor.clientId === clientId && (toolCall.status === ToolCallStatus.Streaming || toolCall.status === ToolCallStatus.Running || toolCall.status === ToolCallStatus.PendingConfirmation)) { + if (toolCall.contributor?.kind === ToolCallContributorKind.Client && toolCall.contributor.clientId === clientId && (toolCall.status === ToolCallStatus.Streaming || toolCall.status === ToolCallStatus.Running || toolCall.status === ToolCallStatus.PendingConfirmation)) { const mayRetryWithReplacementClient = this._hasReplacementActiveClientTool(state, clientId, toolCall.toolName); if (toolCall.status === ToolCallStatus.Streaming) { this._stateManager.dispatchServerAction(session, { @@ -752,13 +751,14 @@ export class ProtocolServerHandler extends Disposable { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Resource watch not found: ${params.channel}`); } client.subscriptions.set(classified.uri, classified); - return { - snapshot: { - resource: classified.uri, - state: descriptor, - fromSeq: this._stateManager.serverSeq, - }, + // ResourceWatchState is not part of Snapshot.state in the upstream protocol yet. + const resourceWatchSnapshot = { + resource: classified.uri, + state: descriptor, + fromSeq: this._stateManager.serverSeq, }; + const snapshot = resourceWatchSnapshot as unknown as IStateSnapshot; + return { snapshot }; } try { const snapshot = await this._agentService.subscribe(URI.parse(params.channel), client.clientId); @@ -849,7 +849,7 @@ export class ProtocolServerHandler extends Disposable { ...(s.project ? { project: { uri: s.project.uri.toString(), displayName: s.project.displayName } } : {}), model: s.model, workingDirectory: s.workingDirectory?.toString(), - changesets: s.changesets ? [...s.changesets] : undefined, + changes: s.changes, }; }); return { items }; diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts index 5579b628aaa25..2ce3e013323f9 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts @@ -11,6 +11,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { AgentSession, IAgentSessionMetadata } from '../../common/agentService.js'; import { buildDefaultChangesetCatalogue, buildSessionChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; +import { ChangesSummary } from '../../common/state/protocol/state.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { buildSubagentSessionUri, SessionStatus, type ISessionFileDiff } from '../../common/state/sessionState.js'; import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -35,8 +36,8 @@ suite('ChangesetSessionCoordinator', () => { modifiedAt: Date.now(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, workingDirectory, - changesets: buildDefaultChangesetCatalogue(session), }, { emitNotification }); + stateManager.setSessionChangesets(session, buildDefaultChangesetCatalogue(session)); stateManager.dispatchServerAction(session, { type: ActionType.SessionReady }); } @@ -382,6 +383,7 @@ class TestChangesetService implements IAgentHostChangesetService { parsePersistedStaticChangesets(_sessionUri: string, _metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs { return {}; } applyPersistedStaticChangesets(_sessionUri: string, _diffs: IRestoredChangesetDiffs): void { } restorePersistedStaticChangesets(_sessionUri: string, _metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs { return {}; } + persistChangesSummary(_sessionUri: string, _summary: ChangesSummary): void { } isStaticChangesetComputeActive(_changesetUri: string): boolean { return false; } refreshUncommittedChangeset(session: string): void { this.uncommittedRefreshes.push(session); diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index f46a4ec660776..e5067521dc743 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -40,8 +40,8 @@ suite('AgentHostChangesetService', () => { modifiedAt: Date.now(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, workingDirectory, - changesets: buildDefaultChangesetCatalogue(sessionUri.toString()), }); + stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalogue(sessionUri.toString())); stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, }); } @@ -67,9 +67,9 @@ suite('AgentHostChangesetService', () => { // Catalogue is seeded by setupSession (mirrors what `_buildInitialSummary` // does in production) — sanity check before exercising registration. - assert.deepStrictEqual(stateManager.getSessionState(sessionStr)?.summary.changesets, [ - { label: 'Branch Changes', uriTemplate: `${sessionStr}/changeset/session` }, - { label: 'Uncommitted Changes', uriTemplate: `${sessionStr}/changeset/uncommitted`, description: 'Show uncommitted changes in this session' }, + assert.deepStrictEqual(stateManager.getSessionState(sessionStr)?.changesets, [ + { label: 'Branch Changes', uriTemplate: `${sessionStr}/changeset/session`, changeKind: 'session' }, + { label: 'Uncommitted Changes', uriTemplate: `${sessionStr}/changeset/uncommitted`, description: 'Show uncommitted changes in this session', changeKind: 'uncommitted' }, ]); changesetService.registerStaticChangesets(sessionStr); @@ -84,9 +84,9 @@ suite('AgentHostChangesetService', () => { } // Registration must not mutate the seeded catalogue. - assert.deepStrictEqual(stateManager.getSessionState(sessionStr)?.summary.changesets, [ - { label: 'Branch Changes', uriTemplate: `${sessionStr}/changeset/session` }, - { label: 'Uncommitted Changes', uriTemplate: `${sessionStr}/changeset/uncommitted`, description: 'Show uncommitted changes in this session' }, + assert.deepStrictEqual(stateManager.getSessionState(sessionStr)?.changesets, [ + { label: 'Branch Changes', uriTemplate: `${sessionStr}/changeset/session`, changeKind: 'session' }, + { label: 'Uncommitted Changes', uriTemplate: `${sessionStr}/changeset/uncommitted`, description: 'Show uncommitted changes in this session', changeKind: 'uncommitted' }, ]); }); @@ -98,7 +98,7 @@ suite('AgentHostChangesetService', () => { changesetService.registerStaticChangesets(sessionStr); changesetService.registerStaticChangesets(sessionStr); - const changesets = stateManager.getSessionState(sessionStr)?.summary.changesets; + const changesets = stateManager.getSessionState(sessionStr)?.changesets; assert.strictEqual(changesets?.length, 2, 'expected the two default catalogue entries'); }); @@ -126,21 +126,25 @@ suite('AgentHostChangesetService', () => { assert.strictEqual(state.status, 'ready'); assert.deepStrictEqual(state.files.map(f => f.id), ['file:///wd/a.ts', 'file:///wd/b.ts']); - const catalogue = stateManager.getSessionState(sessionStr)?.summary.changesets; - assert.deepStrictEqual(catalogue, [ + const sessionState = stateManager.getSessionState(sessionStr); + assert.deepStrictEqual(sessionState?.changesets, [ { label: 'Branch Changes', uriTemplate: changesetUri, - additions: 6, - deletions: 2, - files: 2, + changeKind: 'session', }, { label: 'Uncommitted Changes', uriTemplate: `${sessionStr}/changeset/uncommitted`, description: 'Show uncommitted changes in this session', + changeKind: 'uncommitted', }, ]); + assert.deepStrictEqual(sessionState?.summary.changes, { + additions: 6, + deletions: 2, + files: 2, + }); }); test('restoreStaticChangeset catalogue counts only emitted unique files', () => { @@ -170,10 +174,11 @@ suite('AgentHostChangesetService', () => { const changesetUri = `${sessionStr}/changeset/session`; const snapshot = stateManager.getSnapshot(changesetUri); const state = snapshot?.state as { files: Array<{ id: string; edit: { diff?: { added?: number; removed?: number } } }> } | undefined; - const catalogue = stateManager.getSessionState(sessionStr)?.summary.changesets; + const sessionState = stateManager.getSessionState(sessionStr); assert.deepStrictEqual({ files: state?.files.map(f => ({ id: f.id, diff: f.edit.diff })), - catalogue, + catalogue: sessionState?.changesets, + changes: sessionState?.summary.changes, }, { files: [ { id: 'file:///wd/a.ts', diff: { added: 3, removed: 1 } }, @@ -183,16 +188,20 @@ suite('AgentHostChangesetService', () => { { label: 'Branch Changes', uriTemplate: changesetUri, - additions: 4, - deletions: 1, - files: 2, + changeKind: 'session', }, { label: 'Uncommitted Changes', uriTemplate: `${sessionStr}/changeset/uncommitted`, description: 'Show uncommitted changes in this session', + changeKind: 'uncommitted', }, ], + changes: { + additions: 4, + deletions: 1, + files: 2, + }, }); }); @@ -288,17 +297,20 @@ suite('AgentHostChangesetService', () => { assert.deepStrictEqual(sessionFileSets.map(e => e.action.file.edit), gitDiffs); assert.deepStrictEqual(uncommittedFileSets.map(e => e.action.file.edit), gitDiffs); - // The compute pass also persists the file list under the - // legacy `'diffs'` slot so it survives restarts. The write - // is fire-and-forget through the metadata sequencer; poll + // The compute pass also persists a small `ChangesSummary` + // aggregate under the `META_CHANGES_SUMMARY` slot so a + // subsequent `listSessions` can render the chip without + // recomputing. Only the session-wide kind contributes; + // uncommitted recomputes never touch the DB. The write is + // fire-and-forget through the metadata sequencer; poll // briefly until it lands. let persisted: string | undefined; for (let i = 0; i < 50 && !persisted; i++) { await timeout(2); - persisted = await sessionDb.getMetadata('diffs'); + persisted = await sessionDb.getMetadata('agentHost.changes'); } - assert.ok(persisted, 'expected the compute pass to persist diffs to the session DB'); - assert.deepStrictEqual(JSON.parse(persisted), gitDiffs); + assert.ok(persisted, 'expected the session compute pass to persist a changes summary'); + assert.deepStrictEqual(JSON.parse(persisted), { additions: 1, deletions: 0, files: 1 }); }); test('falls back to the edit-tracker aggregator when the git service returns undefined', async () => { @@ -413,9 +425,10 @@ suite('AgentHostChangesetService', () => { .filter(e => e.action.type === ActionType.ChangesetFileRemoved && e.channel === uncommittedUri); assert.deepStrictEqual(removed, [], 'no files should be removed when the git path is unavailable'); - // 2) The persisted DB blob is unchanged (compute did not overwrite it). - const persistedAfter = await sessionDb.getMetadata('agentHost.changeset.uncommitted'); - assert.strictEqual(persistedAfter, undefined, 'compute must not persist anything when git is unavailable'); + // 2) The uncommitted recompute must never touch the DB — + // only the session-wide kind persists a summary. + const persistedAfter = await sessionDb.getMetadata('agentHost.changes'); + assert.strictEqual(persistedAfter, undefined, 'uncommitted compute must not persist a changes summary'); // 3) Live state still reports the 3 seeded files. const snapshot = localStateManager.getSnapshot(uncommittedUri); @@ -561,15 +574,23 @@ suite('AgentHostChangesetService', () => { sessionRaw: JSON.stringify([aDiff, bDiff]), }); - const catalogue = stateManager.getSessionState(sessionStr)?.summary.changesets; - const sessionEntry = catalogue?.find(c => c.uriTemplate === `${sessionStr}/changeset/session`); - assert.deepStrictEqual(sessionEntry, { - label: 'Branch Changes', - uriTemplate: `${sessionStr}/changeset/session`, - additions: 3, - deletions: 0, - files: 2, - }, 'catalogue counts must reflect restored files'); + const sessionState = stateManager.getSessionState(sessionStr); + const sessionEntry = sessionState?.changesets?.find(c => c.uriTemplate === `${sessionStr}/changeset/session`); + assert.deepStrictEqual({ + sessionEntry, + changes: sessionState?.summary.changes, + }, { + sessionEntry: { + label: 'Branch Changes', + uriTemplate: `${sessionStr}/changeset/session`, + changeKind: 'session', + }, + changes: { + additions: 3, + deletions: 0, + files: 2, + }, + }, 'restored diffs must update the chip aggregate'); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts index d39120ad6ca89..a348559ad7c6a 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts @@ -13,6 +13,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import { SessionStatus, withSessionGitState, type ISessionFileDiff } from '../../common/state/sessionState.js'; +import type { ChangesSummary } from '../../common/state/protocol/state.js'; import type { IAgentHostGitService } from '../../node/agentHostGitService.js'; import { AgentHostCommitOperationHandler } from '../../node/agentHostCommitOperationHandler.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; @@ -104,6 +105,7 @@ class TestChangesetService implements IAgentHostChangesetService { parsePersistedStaticChangesets(_sessionUri: string, _metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs { return {}; } applyPersistedStaticChangesets(_sessionUri: string, _diffs: IRestoredChangesetDiffs): void { } restorePersistedStaticChangesets(_sessionUri: string, _metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs { return {}; } + persistChangesSummary(_sessionUri: string, _summary: ChangesSummary): void { } isStaticChangesetComputeActive(): boolean { return false; } refreshUncommittedChangeset(session: string): void { this.calls.push(`refreshUncommitted:${session}`); } refreshSessionChangeset(session: string): void { this.calls.push(`refreshSession:${session}`); } diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 4a9a1af776109..b3a8b5c5c6d9c 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -579,20 +579,25 @@ suite('AgentService (node dispatcher)', () => { const sessions = await svc.listSessions(); assert.strictEqual(sessions.length, 1); - assert.deepStrictEqual(sessions[0].changesets, [ - { - label: 'Branch Changes', - uriTemplate: `${sessionUri.toString()}/changeset/session`, - additions: 8, - deletions: 2, - files: 2, - }, - { - label: 'Uncommitted Changes', - uriTemplate: `${sessionUri.toString()}/changeset/uncommitted`, - description: 'Show uncommitted changes in this session', - }, - ]); + assert.deepStrictEqual({ + changesets: sessions[0].changesets, + changes: sessions[0].changes, + }, { + changesets: [ + { + label: 'Branch Changes', + uriTemplate: `${sessionUri.toString()}/changeset/session`, + changeKind: 'session', + }, + { + label: 'Uncommitted Changes', + uriTemplate: `${sessionUri.toString()}/changeset/uncommitted`, + description: 'Show uncommitted changes in this session', + changeKind: 'uncommitted', + }, + ], + changes: { additions: 8, deletions: 2, files: 2 }, + }); }); test('listSessions silently ignores malformed persisted diffs', async () => { @@ -661,15 +666,15 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ listCatalogueEntry: sessions[0].changesets?.find(c => c.uriTemplate === changesetUri), + listChanges: sessions[0].changes, listSeededSnapshot: svc.stateManager.getSnapshot(changesetUri), }, { listCatalogueEntry: { label: 'Branch Changes', uriTemplate: changesetUri, - additions: 5, - deletions: 2, - files: 1, + changeKind: 'session', }, + listChanges: { additions: 5, deletions: 2, files: 1 }, listSeededSnapshot: undefined, }); @@ -727,20 +732,25 @@ suite('AgentService (node dispatcher)', () => { }); const sessions = await svc.listSessions(); - assert.deepStrictEqual(sessions[0].changesets, [ - { - label: 'Branch Changes', - uriTemplate: changesetUri, - additions: 1, - deletions: 0, - files: 1, - }, - { - label: 'Uncommitted Changes', - uriTemplate: `${sessionUri.toString()}/changeset/uncommitted`, - description: 'Show uncommitted changes in this session', - }, - ]); + assert.deepStrictEqual({ + changesets: sessions[0].changesets, + changes: sessions[0].changes, + }, { + changesets: [ + { + label: 'Branch Changes', + uriTemplate: changesetUri, + changeKind: 'session', + }, + { + label: 'Uncommitted Changes', + uriTemplate: `${sessionUri.toString()}/changeset/uncommitted`, + description: 'Show uncommitted changes in this session', + changeKind: 'uncommitted', + }, + ], + changes: { additions: 1, deletions: 0, files: 1 }, + }); }); test('listSessions does not request the diffs metadata key when a live source can answer', async () => { @@ -828,20 +838,25 @@ suite('AgentService (node dispatcher)', () => { svc.stateManager.registerChangeset(buildSessionChangesetUri(sessionUri.toString())); const sessions = await svc.listSessions(); - assert.deepStrictEqual(sessions[0].changesets, [ - { - label: 'Branch Changes', - uriTemplate: `${sessionUri.toString()}/changeset/session`, - additions: 7, - deletions: 1, - files: 1, - }, - { - label: 'Uncommitted Changes', - uriTemplate: `${sessionUri.toString()}/changeset/uncommitted`, - description: 'Show uncommitted changes in this session', - }, - ]); + assert.deepStrictEqual({ + changesets: sessions[0].changesets, + changes: sessions[0].changes, + }, { + changesets: [ + { + label: 'Branch Changes', + uriTemplate: `${sessionUri.toString()}/changeset/session`, + changeKind: 'session', + }, + { + label: 'Uncommitted Changes', + uriTemplate: `${sessionUri.toString()}/changeset/uncommitted`, + description: 'Show uncommitted changes in this session', + changeKind: 'uncommitted', + }, + ], + changes: { additions: 7, deletions: 1, files: 1 }, + }); }); test('listSessions overlays live state manager title over SDK title', async () => { @@ -985,7 +1000,7 @@ suite('AgentService (node dispatcher)', () => { const state = localService.stateManager.getSessionState(session.toString()); assert.ok(state); - assert.deepStrictEqual(state!.summary.changesets?.length, 0); + assert.deepStrictEqual(state!.changesets?.length, 0); }); test('createSession keeps git-only catalogue entries for a git working directory', async () => { @@ -1016,9 +1031,9 @@ suite('AgentService (node dispatcher)', () => { const state = localService.stateManager.getSessionState(session.toString()); assert.ok(state); - assert.deepStrictEqual(state!.summary.changesets, [ - { label: 'Branch Changes', uriTemplate: `${session.toString()}/changeset/session`, description: 'main' }, - { label: 'Uncommitted Changes', uriTemplate: `${session.toString()}/changeset/uncommitted`, description: 'Show uncommitted changes in this session' }, + assert.deepStrictEqual(state!.changesets, [ + { label: 'Branch Changes', uriTemplate: `${session.toString()}/changeset/session`, description: 'main', changeKind: 'session' }, + { label: 'Uncommitted Changes', uriTemplate: `${session.toString()}/changeset/uncommitted`, description: 'Show uncommitted changes in this session', changeKind: 'uncommitted' }, ]); }); @@ -1050,9 +1065,9 @@ suite('AgentService (node dispatcher)', () => { const state = localService.stateManager.getSessionState(session.toString()); assert.ok(state); - assert.deepStrictEqual(state!.summary.changesets, [ - { label: 'Branch Changes', uriTemplate: `${session.toString()}/changeset/session`, description: 'feature/x → main' }, - { label: 'Uncommitted Changes', uriTemplate: `${session.toString()}/changeset/uncommitted`, description: 'Show uncommitted changes in this session' }, + assert.deepStrictEqual(state!.changesets, [ + { label: 'Branch Changes', uriTemplate: `${session.toString()}/changeset/session`, description: 'feature/x → main', changeKind: 'session' }, + { label: 'Uncommitted Changes', uriTemplate: `${session.toString()}/changeset/uncommitted`, description: 'Show uncommitted changes in this session', changeKind: 'uncommitted' }, ]); }); @@ -2109,22 +2124,28 @@ suite('AgentService (node dispatcher)', () => { assert.ok(state); // The session has no working directory, so `_attachGitState` // treats it as transient and does NOT strip the two git-only - // catalogue entries. The Branch Changes entry receives the - // persisted diff counts seeded by the changeset coordinator. - assert.deepStrictEqual(state!.summary.changesets, [ - { - additions: 5, - deletions: 2, - files: 1, - label: 'Branch Changes', - uriTemplate: `${sessionResource.toString()}/changeset/session`, - }, - { - description: 'Show uncommitted changes in this session', - label: 'Uncommitted Changes', - uriTemplate: `${sessionResource.toString()}/changeset/uncommitted`, - }, - ]); + // catalogue entries. The chip aggregate on `summary.changes` + // receives the persisted diff counts seeded by the changeset + // coordinator. + assert.deepStrictEqual({ + changesets: state!.changesets, + changes: state!.summary.changes, + }, { + changesets: [ + { + label: 'Branch Changes', + uriTemplate: `${sessionResource.toString()}/changeset/session`, + changeKind: 'session', + }, + { + description: 'Show uncommitted changes in this session', + label: 'Uncommitted Changes', + uriTemplate: `${sessionResource.toString()}/changeset/uncommitted`, + changeKind: 'uncommitted', + }, + ], + changes: { additions: 5, deletions: 2, files: 1 }, + }); const changesetSnapshot = localService.stateManager.getSnapshot(`${sessionResource.toString()}/changeset/session`); assert.ok(changesetSnapshot); @@ -2158,17 +2179,19 @@ suite('AgentService (node dispatcher)', () => { assert.ok(state); // Catalogue is seeded by `_buildInitialSummary` / `restoreSession`. // The session has no working directory, so `_attachGitState` does - // NOT strip the git-only entries — they remain advertised but - // without counts until a real compute lands. - assert.deepStrictEqual(state!.summary.changesets, [ + // NOT strip the git-only entries — they remain advertised but the + // chip aggregate stays unset until a real compute lands. + assert.deepStrictEqual(state!.changesets, [ { label: 'Branch Changes', uriTemplate: `${sessionResource.toString()}/changeset/session`, + changeKind: 'session', }, { description: 'Show uncommitted changes in this session', label: 'Uncommitted Changes', uriTemplate: `${sessionResource.toString()}/changeset/uncommitted`, + changeKind: 'uncommitted', }, ]); @@ -2338,8 +2361,8 @@ suite('AgentService (node dispatcher)', () => { // the two git-only entries. All three default entries are // advertised (without counts) until a real compute lands. return [ - { label: 'Branch Changes', uriTemplate: `${sessionStr}/changeset/session` }, - { label: 'Uncommitted Changes', uriTemplate: `${sessionStr}/changeset/uncommitted`, description: 'Show uncommitted changes in this session' }, + { label: 'Branch Changes', uriTemplate: `${sessionStr}/changeset/session`, changeKind: 'session' }, + { label: 'Uncommitted Changes', uriTemplate: `${sessionStr}/changeset/uncommitted`, description: 'Show uncommitted changes in this session', changeKind: 'uncommitted' }, ]; } @@ -2351,7 +2374,7 @@ suite('AgentService (node dispatcher)', () => { const state = service.stateManager.getSessionState(sessionStr); assert.ok(state); - assert.deepStrictEqual(state!.summary.changesets, defaultCatalogue(sessionStr)); + assert.deepStrictEqual(state!.changesets, defaultCatalogue(sessionStr)); assertBackingChangesetsComputing(service.stateManager, sessionStr); }); @@ -2383,7 +2406,7 @@ suite('AgentService (node dispatcher)', () => { const forkedState = service.stateManager.getSessionState(forkedStr); assert.ok(forkedState); - assert.deepStrictEqual(forkedState!.summary.changesets, defaultCatalogue(forkedStr)); + assert.deepStrictEqual(forkedState!.changesets, defaultCatalogue(forkedStr)); // Note: source-session turn was seeded directly on state, so the // reducer never saw a SessionTurnStarted/Complete pair for it; // the fork branch (agentService.ts:548 path) is still exercised @@ -2419,7 +2442,7 @@ suite('AgentService (node dispatcher)', () => { // Snapshot the create-time state BEFORE materialization. const stateBefore = service.stateManager.getSessionState(sessionStr); assert.ok(stateBefore, 'provisional session should already have state'); - assert.deepStrictEqual(stateBefore!.summary.changesets, defaultCatalogue(sessionStr)); + assert.deepStrictEqual(stateBefore!.changesets, defaultCatalogue(sessionStr)); assertBackingChangesetsComputing(service.stateManager, sessionStr); // `markSessionPersisted` (called from `_onDidMaterializeSession`) @@ -2430,7 +2453,7 @@ suite('AgentService (node dispatcher)', () => { const stateAfter = service.stateManager.getSessionState(sessionStr); assert.ok(stateAfter, 'materialized session should still have state'); - assert.deepStrictEqual(stateAfter!.summary.changesets, defaultCatalogue(sessionStr)); + assert.deepStrictEqual(stateAfter!.changesets, defaultCatalogue(sessionStr)); assertBackingChangesetsComputing(service.stateManager, sessionStr); }); @@ -2456,7 +2479,7 @@ suite('AgentService (node dispatcher)', () => { const state = localService.stateManager.getSessionState(sessionStr); assert.ok(state); - assert.deepStrictEqual(state!.summary.changesets, defaultCatalogue(sessionStr)); + assert.deepStrictEqual(state!.changesets, defaultCatalogue(sessionStr)); assertBackingChangesetsComputing(localService.stateManager, sessionStr); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 66e0342075bfe..2b00c4ecc40e8 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -21,9 +21,9 @@ import { AgentSession, IAgent } from '../../common/agentService.js'; import { buildDefaultChangesetCatalogue } from '../../common/changesetUri.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js'; -import { CustomizationType } from '../../common/state/protocol/state.js'; +import { CustomizationType, ToolCallContributorKind } from '../../common/state/protocol/state.js'; import { ActionType, ActionEnvelope, SessionAction } from '../../common/state/sessionActions.js'; -import { buildSubagentSessionUri, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, customizationId, type ClientPluginCustomization, type Customization, type PluginCustomization } from '../../common/state/sessionState.js'; +import { buildSubagentSessionUri, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, customizationId, type ClientPluginCustomization, type Customization, type PluginCustomization } 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'; @@ -55,6 +55,7 @@ class FakeChangesetService implements IAgentHostChangesetService { parsePersistedStaticChangesets(): { uncommitted?: undefined; session?: undefined } { return {}; } applyPersistedStaticChangesets(): void { /* no-op */ } restorePersistedStaticChangesets(): { uncommitted?: undefined; session?: undefined } { return {}; } + persistChangesSummary(): void { /* no-op */ } isStaticChangesetComputeActive(): boolean { return false; } refreshUncommittedChangeset(): void { /* no-op */ } refreshSessionChangeset(): void { /* no-op */ } @@ -147,8 +148,8 @@ suite('AgentSideEffects', () => { modifiedAt: Date.now(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, workingDirectory, - changesets: buildDefaultChangesetCatalogue(sessionUri.toString()), }); + stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalogue(sessionUri.toString())); stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, }); } @@ -1267,7 +1268,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-conf-1', toolName: 'read', displayName: 'Read File', contributor: undefined, + toolCallId: 'tc-conf-1', toolName: 'read', displayName: 'Read File', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -1315,7 +1316,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-deny-1', toolName: 'shell', displayName: 'Shell', contributor: undefined, + toolCallId: 'tc-deny-1', toolName: 'shell', displayName: 'Shell', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -1435,7 +1436,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-parent', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, + toolCallId: 'tc-parent', toolName: 'runSubagent', displayName: 'Run Subagent', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -1534,7 +1535,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-bypass-1', toolName: 'write', displayName: 'Write', contributor: undefined, + toolCallId: 'tc-bypass-1', toolName: 'write', displayName: 'Write', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -1573,7 +1574,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-ap-shell-1', toolName: 'shell', displayName: 'Shell', contributor: undefined, + toolCallId: 'tc-ap-shell-1', toolName: 'shell', displayName: 'Shell', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -1612,7 +1613,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-default-1', toolName: 'write', displayName: 'Write', contributor: undefined, + toolCallId: 'tc-default-1', toolName: 'write', displayName: 'Write', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -1655,7 +1656,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-mid-1', toolName: 'write', displayName: 'Write', contributor: undefined, + toolCallId: 'tc-mid-1', toolName: 'write', displayName: 'Write', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -1699,7 +1700,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-auto-1', toolName: 'write', displayName: 'Write', contributor: undefined, + toolCallId: 'tc-auto-1', toolName: 'write', displayName: 'Write', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -1741,7 +1742,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-env-1', toolName: 'write', displayName: 'Write', contributor: undefined, + toolCallId: 'tc-env-1', toolName: 'write', displayName: 'Write', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -1782,7 +1783,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-pkg-1', toolName: 'write', displayName: 'Write', contributor: undefined, + toolCallId: 'tc-pkg-1', toolName: 'write', displayName: 'Write', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -1818,7 +1819,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-lock-1', toolName: 'write', displayName: 'Write', contributor: undefined, + toolCallId: 'tc-lock-1', toolName: 'write', displayName: 'Write', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -1854,7 +1855,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-git-1', toolName: 'write', displayName: 'Write', contributor: undefined, + toolCallId: 'tc-git-1', toolName: 'write', displayName: 'Write', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -1895,7 +1896,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-read-1', toolName: 'read', displayName: 'Read', contributor: undefined, + toolCallId: 'tc-read-1', toolName: 'read', displayName: 'Read', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -1936,7 +1937,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-read-2', toolName: 'read', displayName: 'Read', contributor: undefined, + toolCallId: 'tc-read-2', toolName: 'read', displayName: 'Read', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -2117,7 +2118,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, + toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -2165,7 +2166,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); // Start parent tool + subagent - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', _meta: { toolKind: undefined, language: undefined } } }); agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); @@ -2174,7 +2175,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'inner-tc-1', toolName: 'readFile', displayName: 'Read File', contributor: undefined, + toolCallId: 'inner-tc-1', toolName: 'readFile', displayName: 'Read File', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -2214,7 +2215,7 @@ suite('AgentSideEffects', () => { startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', _meta: { toolKind: undefined, language: undefined } } }); agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); // Inner event arrives but `subagent_started` never does. @@ -2222,7 +2223,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'inner-1', toolName: 'read', displayName: 'Read', contributor: undefined, + toolCallId: 'inner-1', toolName: 'read', displayName: 'Read', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -2265,7 +2266,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); // Start parent tool + subagent - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', _meta: { toolKind: undefined, language: undefined } } }); agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); @@ -2301,11 +2302,11 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); // Start two parent tool calls with subagents - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', _meta: { toolKind: undefined, language: undefined } } }); agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating 1...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'sub1', agentDisplayName: 'Sub 1', agentDescription: 'First' }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-2', toolName: 'runSubagent', displayName: 'Sub 2', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-2', toolName: 'runSubagent', displayName: 'Sub 2', _meta: { toolKind: undefined, language: undefined } } }); agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, turnId: 'turn-1', toolCallId: 'tc-2', invocationMessage: 'Delegating 2...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-2', agentName: 'sub2', agentDisplayName: 'Sub 2', agentDescription: 'Second' }); @@ -2327,7 +2328,7 @@ suite('AgentSideEffects', () => { startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', _meta: { toolKind: undefined, language: undefined } } }); agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'sub', agentDisplayName: 'Sub', agentDescription: 'Has subagent' }); @@ -2344,7 +2345,7 @@ suite('AgentSideEffects', () => { startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', _meta: { toolKind: undefined, language: undefined } } }); agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); @@ -2369,7 +2370,7 @@ suite('AgentSideEffects', () => { startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'task', displayName: 'Task', _meta: { toolKind: undefined, language: undefined } } }); agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }); @@ -2414,7 +2415,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); // 1. Parent tool starts (the `task` invocation). - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', _meta: { toolKind: undefined, language: undefined } } }); agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); // 2. Inner tool fires BEFORE subagent_started (race condition). @@ -2422,7 +2423,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'inner-tc-1', toolName: 'readFile', displayName: 'Read File', contributor: undefined, + toolCallId: 'inner-tc-1', toolName: 'readFile', displayName: 'Read File', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -2465,7 +2466,7 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); // Parent task tool spawns a subagent. - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', _meta: { toolKind: undefined, language: undefined } } }); agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); @@ -2475,7 +2476,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'inner-read-1', toolName: 'read', displayName: 'Read', contributor: undefined, + toolCallId: 'inner-read-1', toolName: 'read', displayName: 'Read', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -2528,7 +2529,7 @@ suite('AgentSideEffects', () => { }; } - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', _meta: { toolKind: undefined, language: undefined } } }); agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); @@ -2538,7 +2539,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'inner-write-1', toolName: 'write', displayName: 'Write', contributor: undefined, + toolCallId: 'inner-write-1', toolName: 'write', displayName: 'Write', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -2580,7 +2581,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-perm-1', toolName: 'CustomTool', displayName: 'Custom Tool', contributor: undefined, + toolCallId: 'tc-perm-1', toolName: 'CustomTool', displayName: 'Custom Tool', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -2630,7 +2631,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-perm-2', toolName: 'CustomTool', displayName: 'Custom Tool', contributor: undefined, + toolCallId: 'tc-perm-2', toolName: 'CustomTool', displayName: 'Custom Tool', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -2686,7 +2687,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-perm-3', toolName: 'CustomTool', displayName: 'Custom Tool', contributor: undefined, + toolCallId: 'tc-perm-3', toolName: 'CustomTool', displayName: 'Custom Tool', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -2731,7 +2732,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', contributor: undefined, + toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -2755,7 +2756,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'inner-perm-1', toolName: 'CustomTool', displayName: 'Custom Tool', contributor: undefined, + toolCallId: 'inner-perm-1', toolName: 'CustomTool', displayName: 'Custom Tool', _meta: { toolKind: undefined, language: undefined }, }, }); @@ -2808,7 +2809,7 @@ suite('AgentSideEffects', () => { kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, turnId: 'turn-1', - toolCallId: 'tc-edit-1', toolName: 'write', displayName: 'Write', contributor: undefined, + toolCallId: 'tc-edit-1', toolName: 'write', displayName: 'Write', _meta: { toolKind: undefined, language: undefined }, }, }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index d5a90f2140df8..48364ded288c7 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -2428,7 +2428,8 @@ suite('CopilotAgentSession', () => { const startSignal = signals.find(s => isAction(s, ActionType.SessionToolCallStart)); assert.ok(startSignal && isAction(startSignal, ActionType.SessionToolCallStart)); if (isAction(startSignal!, ActionType.SessionToolCallStart)) { - assert.deepStrictEqual((startSignal.action as SessionToolCallStartAction).contributor, { kind: ToolCallContributorKind.Client, clientId: 'test-client' }); + const startAction = startSignal.action as SessionToolCallStartAction; + assert.strictEqual(startAction.contributor?.kind === ToolCallContributorKind.Client ? startAction.contributor.clientId : undefined, 'test-client'); } // SDK invokes the handler — it creates a deferred and waits, diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 269b2113f5f4f..7822bbca0c8bd 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -11,10 +11,10 @@ import { URI } from '../../../../base/common/uri.js'; import { type ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSession, type AgentProvider, type AgentSignal, type IAgent, type IAgentActionSignal, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentModelInfo, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; import { buildSubagentTurnsFromHistory, buildTurnsFromHistory, type IHistoryRecord } from './historyRecordFixtures.js'; -import { ProtectedResourceMetadata, ToolCallContributorKind, type MessageAttachment, type ModelSelection } from '../../common/state/protocol/state.js'; +import { ProtectedResourceMetadata, type MessageAttachment, type ModelSelection } from '../../common/state/protocol/state.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, CustomizationLoadStatus, parseSubagentSessionUri, type ClientPluginCustomization, type Customization, type PendingMessage, type StringOrMarkdown, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { ResponsePartKind, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, CustomizationLoadStatus, parseSubagentSessionUri, type ClientPluginCustomization, type Customization, type PendingMessage, type StringOrMarkdown, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; import { hasKey } from '../../../../base/common/types.js'; /** Well-known auto-generated title used by the 'with-title' prompt. */ @@ -546,7 +546,7 @@ export class ScriptedMockAgent implements IAgent { // tool_ready once its deferred is in place. (async () => { await timeout(10); - // Client tools don't get auto-ready — toolStart with toolClientId only emits tool_start + // Client tools don't get auto-ready — toolStart with contributor only emits tool_start this._onDidSessionProgress.fire(_action(session, { type: ActionType.SessionToolCallStart, turnId: tid, @@ -570,7 +570,7 @@ export class ScriptedMockAgent implements IAgent { } case 'client-tool-with-permission': { - // Fires tool_start with toolClientId followed by a permission request. + // Fires tool_start with contributor followed by a permission request. (async () => { await timeout(10); this._onDidSessionProgress.fire(_action(session, { diff --git a/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts index 9dae0001f2850..e8acaf39b3dd6 100644 --- a/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts @@ -17,7 +17,7 @@ */ import assert from 'assert'; -import { ToolCallContributorKind, ToolResultContentType, type ToolCallContributor } from '../../../common/state/sessionState.js'; +import { ToolResultContentType } from '../../../common/state/sessionState.js'; import { createAndSubscribeSession, dispatchTurnStarted, @@ -67,10 +67,10 @@ suite('Protocol WebSocket — Client Tools', function () { ]); const toolStartAction = getActionEnvelope(toolStartNotif).action as { toolCallId: string; - contributor?: ToolCallContributor; + contributor?: { kind: string; clientId?: string }; }; assert.strictEqual(toolStartAction.toolCallId, 'tc-client-1'); - assert.deepStrictEqual(toolStartAction.contributor, { kind: ToolCallContributorKind.Client, clientId: 'test-client-tool' }); + assert.strictEqual(toolStartAction.contributor?.clientId, 'test-client-tool'); const toolReadyAction = getActionEnvelope(toolReadyNotif).action as { toolCallId: string; @@ -115,10 +115,10 @@ suite('Protocol WebSocket — Client Tools', function () { ); const toolStartAction = getActionEnvelope(toolStartNotif).action as { toolCallId: string; - contributor?: ToolCallContributor; + contributor?: { kind: string; clientId?: string }; }; assert.strictEqual(toolStartAction.toolCallId, 'tc-client-perm-1'); - assert.deepStrictEqual(toolStartAction.contributor, { kind: ToolCallContributorKind.Client, clientId: 'test-client-tool' }); + assert.strictEqual(toolStartAction.contributor?.clientId, 'test-client-tool'); // Wait for toolCallReady with confirmationTitle (permission flow) const toolReadyNotif = await client.waitForNotification( diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index e784b039e6834..a13eee8fa18df 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -558,21 +558,17 @@ suite('ProtocolServerHandler', () => { assert.deepStrictEqual(result.items.map(item => item.project), [undefined]); }); - test('listSessions surfaces the changeset catalogue from the agent', async () => { + test('listSessions surfaces the changes summary from the agent', async () => { agentService.listedSessions.push({ session: URI.parse(sessionUri), startTime: 1000, modifiedTime: 2000, summary: 'Session With Changesets', - changesets: [ - { - label: 'Branch Changes', - uriTemplate: `${sessionUri}/changeset/session`, - additions: 5, - deletions: 2, - files: 3, - }, - ], + changes: { + additions: 5, + deletions: 2, + files: 3, + }, }); const transport = connectClient('client-list-changesets'); @@ -583,15 +579,11 @@ suite('ProtocolServerHandler', () => { const resp = await responsePromise; const result = (resp as unknown as { result: ListSessionsResult }).result; - assert.deepStrictEqual(result.items[0].changesets, [ - { - label: 'Branch Changes', - uriTemplate: `${sessionUri}/changeset/session`, - additions: 5, - deletions: 2, - files: 3, - }, - ]); + assert.deepStrictEqual(result.items[0].changes, { + additions: 5, + deletions: 2, + files: 3, + }); }); test('createSession returns null and broadcasts project in sessionAdded summary', async () => { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts index f6ad18e52599b..d001dd0d6c4d8 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts @@ -8,7 +8,7 @@ import { isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { buildCompareTurnsChangesetUri, buildTurnChangesetUri, BASELINE_TURN_ID } from '../../../../../platform/agentHost/common/changesetUri.js'; -import { ChangesetStatus, ChangesetSummary, StateComponents, type ChangesetState, type Turn } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { ChangesetStatus, Changeset, StateComponents, type ChangesetState, type Turn } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { ISessionChangeset, ISessionFileChange, sessionFileChangesEqual } from '../../../../services/sessions/common/session.js'; import { changesetFilesToChanges } from './agentHostDiffs.js'; import { IAgentHostAdapterOptions } from './baseAgentHostSessionsProvider.js'; @@ -17,7 +17,7 @@ export function createChangesets( sessionUri: URI, options: IAgentHostAdapterOptions, isActiveSessionObs: IObservable, - changesets: readonly ChangesetSummary[] | undefined + changesets: readonly Changeset[] | undefined ): readonly ISessionChangeset[] { if (!changesets) { return []; @@ -139,7 +139,7 @@ export class AgentHostCatalogChangeset extends AbstractAgentHostChangeset { constructor( options: IAgentHostAdapterOptions, isActiveSessionObs: IObservable, - changesetSummary: ChangesetSummary & { isDefault: boolean }, + changesetSummary: Changeset & { isDefault: boolean }, ) { super(options); @@ -157,7 +157,7 @@ export class AgentHostCatalogChangeset extends AbstractAgentHostChangeset { this.isDefault = constObservable(changesetSummary.isDefault); } - update(changesetSummary: ChangesetSummary): void { + update(changesetSummary: Changeset): void { this._label = changesetSummary.label; this._description = changesetSummary.description; } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 714eb66be4655..aee2d65112a70 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -23,7 +23,7 @@ import { getEffectiveAgents } from '../../../../../platform/agentHost/common/cus import { KNOWN_AUTO_APPROVE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { AgentCustomization, AgentSelection, Customization, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionActiveClient, SessionState, SessionSummary, type ChangesetSummary } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { AgentCustomization, AgentSelection, Customization, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionActiveClient, SessionState, SessionSummary, type Changeset, type ChangesSummary } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isSessionAction, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { readSessionGitState, ROOT_STATE_URI, SessionMeta, StateComponents, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -176,19 +176,18 @@ export class AgentHostSessionAdapter implements ISession { private readonly _changesSummary = observableValueOpts({ equalsFn: structuralEquals }, undefined); readonly changesSummary: IObservable; - setChangesSummary(catalogue: readonly ChangesetSummary[] | undefined): boolean { - const summary = catalogue?.find(c => !c.uriTemplate.includes('{')); - if (!summary) { + setChangesSummary(changes: ChangesSummary | undefined): boolean { + if (!changes) { return false; } - const { additions, deletions, files } = summary; + const { additions, deletions, files } = changes; const currentChangesSummary = this._changesSummary.get(); if ( - (currentChangesSummary?.files ?? 0) === files && - (currentChangesSummary?.additions ?? 0) === additions && - (currentChangesSummary?.deletions ?? 0) === deletions + (currentChangesSummary?.files ?? 0) === (files ?? 0) && + (currentChangesSummary?.additions ?? 0) === (additions ?? 0) && + (currentChangesSummary?.deletions ?? 0) === (deletions ?? 0) ) { return false; } @@ -311,11 +310,11 @@ export class AgentHostSessionAdapter implements ISession { return isEqual(activeSession?.resource, this.resource); }); - // Set the changes summary from the catalogue. While the session is active, + // Set the changes summary from the aggregate. While the session is active, // the changes summary will be updated through the session changeset changes. // As soon as the session is no longer active, the changes summary will be - // updated from the catalogue. - this.setChangesSummary(metadata.changesets); + // updated from `metadata.changes` (mirroring `SessionSummary.changes`). + this.setChangesSummary(metadata.changes); const sessionUri = AgentSession.uri(this.sessionType, rawId); const { changesSummary, changes } = this._createChangesObs(sessionUri, isActiveSessionObs); @@ -492,9 +491,9 @@ export class AgentHostSessionAdapter implements ISession { didChange = true; } - // `metadata.changesets` (catalogue) drives the chip aggregate. + // `metadata.changes` (aggregate) drives the chip aggregate. // The dropdown content is built separately via `createChangesets`. - if (metadata.changesets !== undefined && this.setChangesSummary(metadata.changesets)) { + if (metadata.changes !== undefined && this.setChangesSummary(metadata.changes)) { didChange = true; } @@ -539,7 +538,7 @@ export class AgentHostSessionAdapter implements ISession { return workspaceChanged; } - updateChangesets(changesets: readonly ChangesetSummary[] | undefined) { + updateChangesets(changesets: readonly Changeset[] | undefined) { if (!changesets) { return; } @@ -2506,7 +2505,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement model: summary.model, agent: summary.agent, workingDirectory: workingDir, - changesets: summary.changesets, + changes: summary.changes, isArchived: !!(summary.status & ProtocolSessionStatus.IsArchived), }; const cached = this.createAdapter(meta); @@ -2601,14 +2600,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement didChange = true; } - // `changes.changesets` carries the catalogue (counts + URI - // templates). The chip aggregate is recomputed from those counts - // here; per-file detail is not part of this notification path. - if (changes.changesets !== undefined) { - cached.updateChangesets(changes.changesets); - if (cached.setChangesSummary(changes.changesets)) { - didChange = true; - } + // `changes.changes` carries the chip aggregate. The catalogue + // itself (label / URI template / `changeKind`) arrives via the + // `SessionChangesetsChanged` action, handled by + // `_handleChangesetsChanged`. + if (changes.changes !== undefined && cached.setChangesSummary(changes.changes)) { + didChange = true; } if (Object.prototype.hasOwnProperty.call(changes, 'activity') && cached.setActivity(changes.activity)) { @@ -2646,7 +2643,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._onDidChangeSessionConfig.fire(sessionId); } - private _handleChangesetsChanged(session: string, changesets: readonly ChangesetSummary[] | undefined): void { + private _handleChangesetsChanged(session: string, changesets: readonly Changeset[] | undefined): void { const rawId = AgentSession.id(session); const cached = this._sessionCache.get(rawId); if (cached) { 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 c7fd1b6d2dea0..b3593f0f7832e 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 @@ -15,7 +15,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { AgentSession, IAgentHostService, type IAgentCreateSessionConfig, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { CustomizationLoadStatus, CustomizationType, SessionLifecycle, type AgentInfo, type ChangesetSummary, type Customization, type ModelSelection, type RootState, type SessionConfigState, type SessionState, type SessionSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { CustomizationLoadStatus, CustomizationType, SessionLifecycle, type AgentInfo, type ChangesSummary, type Customization, type ModelSelection, type RootState, type SessionConfigState, type SessionState, type SessionSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ChangesetStatus, SessionStatus as ProtocolSessionStatus, StateComponents, type ChangesetState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; @@ -2213,8 +2213,10 @@ suite('LocalAgentHostSessionsProvider - active-session branch changeset subscrip return `${AgentSession.uri(sessionType, rawId).toString()}/changeset/session`; } - function catalogueFor(rawId: string, additions: number, deletions: number, sessionType: string = 'copilotcli'): ChangesetSummary[] { - return [{ label: 'Branch Changes', uriTemplate: branchChangesKeyFor(rawId, sessionType), additions, deletions, files: 1 }]; + function catalogueFor(rawId: string, additions: number, deletions: number, sessionType: string = 'copilotcli'): ChangesSummary { + void rawId; + void sessionType; + return { additions, deletions, files: 1 }; } // The adapter subscribes to its branch changeset lazily — only while the @@ -2366,7 +2368,7 @@ suite('LocalAgentHostSessionsProvider - active-session branch changeset subscrip // Once another session becomes active, the catalogue-seeded summary // takes over again. activeSession.set(makeActive('sess-B'), undefined); - fireSessionSummaryChanged(agentHost, 'sess-A', { changesets: catalogueFor('sess-A', 5, 3) }); + fireSessionSummaryChanged(agentHost, 'sess-A', { changes: catalogueFor('sess-A', 5, 3) }); assert.deepStrictEqual(session.changesSummary?.get(), { additions: 5, deletions: 3, files: 1 }); }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts index 1f0994a2b44a2..d0f06550d574e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -172,11 +172,12 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto const directoryCustomizations = []; for (const sessionCustomization of customizations) { + if (sessionCustomization.type === CustomizationType.McpServer) { + // TODO: Surface MCP server customizations in the editor. + continue; + } if (isDirectoryCustomization(sessionCustomization)) { directoryCustomizations.push(sessionCustomization); - } else if (sessionCustomization.type === CustomizationType.McpServer) { - // Bare MCP server entries aren't shown as plugin items in this view. - continue; } else { const isBundleItem = isSyntheticBundle(sessionCustomization); const isClientSynced = sessionCustomization.clientId !== undefined; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 9b3caafbc9e0a..5ddecd641d669 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -23,10 +23,10 @@ import { AgentProvider, AgentSession, type IAgentConnection } from '../../../../ import { IAgentSubscription, observableFromSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { SessionTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { CompletionItemKind as AhpCompletionItemKind, type CompletionItem as AhpCompletionItem } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { ConfirmationOptionKind, TerminalClaimKind, ToolCallContributorKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ConfirmationOptionKind, TerminalClaimKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, SessionTurnStartedAction, type ClientSessionAction, type SessionAction, type SessionInputCompletedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { buildSubagentSessionUri, getToolSubagentContent, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type Message, type MessageAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type SessionInputAnswer, type SessionInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentSessionUri, getToolSubagentContent, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, TurnState, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type Message, type MessageAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type SessionInputAnswer, type SessionInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; @@ -1496,8 +1496,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC observedSubagentToolIds: Set, ): void { const initial = part$.get().toolCall; - const contributor = initial.contributor; - if (contributor?.kind === ToolCallContributorKind.Client && contributor.clientId === this._config.connection.clientId) { + if (initial.contributor?.kind === ToolCallContributorKind.Client && initial.contributor.clientId === this._config.connection.clientId) { this._setupClientToolCall(initial, part$, store, opts); } else { this._setupServerToolCall(initial, part$, store, opts, observedSubagentToolIds); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts index a740f6a48d780..ba33ec131d306 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -10,7 +10,7 @@ import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resour import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import { AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; -import type { ChangesetSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import type { ChangesSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { SessionStatus, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; @@ -18,35 +18,6 @@ import { ChatSessionStatus, IChatNewSessionRequest, IChatSessionItem, IChatSessi import { getAgentHostIcon } from '../agentSessions.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; -/** - * Picks the default catalogue entry to render the sidebar chip from. - * - * The default is always `summary.changesets[0]` — the first entry of - * the ordered catalogue. Templated entries (with `{...}` variables in - * their `uriTemplate`) are skipped because they require expansion the - * sidebar cannot perform. - */ -function pickDefaultChangeset(catalogue: readonly ChangesetSummary[] | undefined): ChangesetSummary | undefined { - return catalogue?.find(c => !c.uriTemplate.includes('{')); -} - -/** - * Maps the catalogue counts onto the aggregate-counts shape supported by - * {@link IChatSessionItem.changes}. Returns `undefined` when the - * catalogue entry is missing or carries no counts so the sidebar - * doesn't render an empty chip. - */ -function changesetCountsToChanges(summary: ChangesetSummary | undefined): IChatSessionItem['changes'] { - if (!summary || summary.files === undefined || summary.files === 0) { - return undefined; - } - return { - files: summary.files, - insertions: summary.additions ?? 0, - deletions: summary.deletions ?? 0, - }; -} - function mapSessionStatus(status: SessionStatus | undefined): ChatSessionStatus { if (status !== undefined && (status & SessionStatus.InputNeeded) === SessionStatus.InputNeeded) { return ChatSessionStatus.NeedsInput; @@ -277,7 +248,6 @@ export class AgentHostSessionListController extends Disposable implements IChatS createdAt: s.startTime, modifiedAt: s.modifiedTime, workingDirectory: s.workingDirectory?.toString(), - changesets: s.changesets ? [...s.changesets] : undefined, }); return this._makeItem(rawId, { title: s.summary, @@ -286,7 +256,6 @@ export class AgentHostSessionListController extends Disposable implements IChatS workingDirectory: s.workingDirectory, createdAt: s.startTime, modifiedAt: s.modifiedTime, - changesets: s.changesets, }); }); this._cacheValid = true; @@ -329,8 +298,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS activity: summary.activity, workingDirectory: workingDir, createdAt: summary.createdAt, - modifiedAt: summary.modifiedAt, - changesets: summary.changesets, + modifiedAt: summary.modifiedAt }); } @@ -341,7 +309,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS workingDirectory?: URI; createdAt: number; modifiedAt: number; - changesets?: readonly ChangesetSummary[]; + changesSummary?: ChangesSummary; }): IChatSessionItem { const inProgress = opts.status !== undefined && (opts.status & SessionStatus.InProgress) !== 0; const description = inProgress && opts.activity ? opts.activity : this._description; @@ -358,12 +326,13 @@ export class AgentHostSessionListController extends Disposable implements IChatS lastRequestStarted: opts.modifiedAt, lastRequestEnded: opts.modifiedAt, }, - // Sidebar chip data: aggregate `{ files, insertions, deletions }` - // from the catalogue. Per-file detail (used by the session detail - // "Changes" view) requires subscribing to the changeset URI; - // `BaseAgentHostSessionsProvider._ensureChangesetSubscription` - // owns that path. - changes: changesetCountsToChanges(pickDefaultChangeset(opts.changesets)), + changes: opts.changesSummary + ? { + files: opts.changesSummary.files ?? 0, + insertions: opts.changesSummary.additions ?? 0, + deletions: opts.changesSummary.deletions ?? 0, + } + : undefined, }; }