From 09f4febcb5da4e90df7ca7b4281c40943f0eb1d3 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 26 Jun 2026 12:10:43 +0200 Subject: [PATCH] agentHost: aggregate multi-chat progress and changes to the session (#323089) agentHost: aggregate multi-chat progress and changes to the session (fixes #322418) Local agent host sessions with multiple (peer) chats did not surface sub-chat progress or file changes at the session level: - Session status stayed Idle while a peer chat was working. The host now promotes the aggregated session status to InProgress when any chat is running and forwards each chat's own status to the session catalog so its tab shows progress. The default tab also reflects its own status. - The session header "Changes" pill never appeared in multi-chat. Each peer chat records its file edits into its own database (the chat URI is used as the session URI for that chat's edit tracker), but the session changeset only read the session DB. The session changeset now unions the session DB with every peer chat DB so peer-chat edits roll up into the session-level changes. Each file identity remembers which source owns its before/after snapshot so the session-db content URIs resolve to the correct database. The git fast-path now keys off the latest turn across all chats (the shared working tree's most recent checkpoint). Note: the multi-chat union always does a full recompute (the single-chat incremental fast/slow paths are unchanged). An incremental union is left as a documented follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../state/protocol/channels-session/state.ts | 8 +- .../node/agentHostChangesetService.ts | 106 ++++++++- .../agentHost/node/agentHostStateManager.ts | 37 +++- .../agentHost/node/sessionDiffAggregator.ts | 203 +++++++++++++++--- .../test/node/agentHostStateManager.test.ts | 79 +++++++ .../test/node/sessionDiffAggregator.test.ts | 99 ++++++++- .../browser/baseAgentHostSessionsProvider.ts | 17 +- 7 files changed, 501 insertions(+), 48 deletions(-) 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 d02d3f80268..44498ce268a 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 @@ -175,9 +175,11 @@ export interface ProjectInfo { * `Error` — bits 0–4) from the * {@link SessionState.defaultChat | default chat} when present, else from * the most recently modified chat. **Promote** `InputNeeded` whenever any - * chat in the session needs input, and **promote** `Error` whenever any - * chat is in an error state — both override the default-chat bits. The - * orthogonal flag bits (`IsRead`, `IsArchived`) remain session-scoped. + * chat in the session needs input, **promote** `Error` whenever any chat is + * in an error state, and **promote** `InProgress` whenever any chat is + * actively streaming — all override the default-chat bits, with precedence + * `InputNeeded` > `Error` > `InProgress`. The orthogonal flag bits + * (`IsRead`, `IsArchived`) remain session-scoped. * - `activity`: mirror the activity string of the default chat, or of the * chat currently driving the promoted status bits when a non-default chat * wins (e.g. the chat that raised `InputNeeded`). diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index 8f3ed306951..ff3a4e69d1c 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -27,13 +27,14 @@ import { type ISessionFileDiff, type URI as ProtocolURI, readSessionGitState, + isDefaultChatUri, } from '../common/state/sessionState.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../common/agentHostGitService.js'; import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; import { NodeWorkerDiffComputeService } from './diffComputeService.js'; -import { computeSessionDiffs, computeTurnDiffs, type IIncrementalDiffOptions } from './sessionDiffAggregator.js'; +import { computeSessionDiffs, computeTurnDiffs, computeUnionedDiffs, type IIncrementalDiffOptions, type ISessionDiffSource } from './sessionDiffAggregator.js'; import { META_CHECKPOINT_WORKING_DIR } from './agentHostCheckpointService.js'; import { IAgentHostChangesetService, IPersistedChangesetMetadata, IRestoredChangesetDiffs, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY, META_CHANGESET_BRANCH, META_CHANGESET_SESSION, META_LEGACY_DIFFS, StaticChangesetKind } from '../common/agentHostChangesetService.js'; import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; @@ -756,14 +757,40 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC // working dir or not a git work tree). Fall back to the // edit-tracker aggregator — for the session changeset the // SDK-tracked edits are the best available approximation. - let incremental: IIncrementalDiffOptions | undefined; - if (changedTurnId) { - const previousDiffs = this._readPreviousChangesetDiffs(changesetUri); - if (previousDiffs) { - incremental = { changedTurnId, previousDiffs: [...previousDiffs] }; + // + // In multi-chat sessions each peer chat records its file + // edits into its OWN database (the chat URI is used as the + // session URI for that chat's edit tracker). Union the + // session DB with every peer chat DB so peer-chat edits roll + // up into the session-level changes. + const peerSources = this._openPeerChatSources(session); + try { + if (peerSources.length > 0) { + const sources: ISessionDiffSource[] = [ + { sessionUri: session, db: ref.object }, + ...peerSources.map(p => ({ sessionUri: p.sessionUri, db: p.ref.object })), + ]; + // TODO (debt): multi-chat always does a full recompute + // (the incremental `changedTurnId`/`previousDiffs` path is + // only used for single-chat below). A follow-up can make + // `computeUnionedDiffs` incremental — see its doc comment + // and the tracking issue. + diffs = await computeUnionedDiffs(sources, this._diffComputeService); + } else { + let incremental: IIncrementalDiffOptions | undefined; + if (changedTurnId) { + const previousDiffs = this._readPreviousChangesetDiffs(changesetUri); + if (previousDiffs) { + incremental = { changedTurnId, previousDiffs: [...previousDiffs] }; + } + } + diffs = await computeSessionDiffs(session, ref.object, this._diffComputeService, incremental); + } + } finally { + for (const peer of peerSources) { + peer.ref.dispose(); } } - diffs = await computeSessionDiffs(session, ref.object, this._diffComputeService, incremental); } this._publishChangesetDiffs(session, changesetUri, diffs); @@ -874,6 +901,64 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } } + /** + * Opens the databases for every non-default (peer) chat in a multi-chat + * session. Each peer chat records its file edits into its own database + * keyed by the chat URI, so the session changeset must union those + * databases with the session DB. Returns an empty array for single-chat + * sessions. Callers MUST dispose every returned `ref`. + */ + private _openPeerChatSources(session: ProtocolURI): { sessionUri: ProtocolURI; ref: ReturnType }[] { + const chats = this._stateManager.getSessionState(session)?.chats ?? []; + const sources: { sessionUri: ProtocolURI; ref: ReturnType }[] = []; + for (const chat of chats) { + if (isDefaultChatUri(chat.resource)) { + continue; + } + try { + const ref = this._sessionDataService.openDatabase(URI.parse(chat.resource)); + sources.push({ sessionUri: chat.resource, ref }); + } catch (err) { + this._logService.warn(`[AgentHostChangesetService] Failed to open peer chat database for session changes: ${chat.resource}`, err); + } + } + return sources; + } + + /** + * Returns the turn id whose checkpoint best represents the latest state of + * the session's shared working tree. For single-chat sessions this is the + * default chat's last turn. For multi-chat sessions it is the last turn of + * the most-recently-modified chat (peer-chat turn checkpoints are stored + * under the session URI keyed by their turn id). Returns `undefined` when + * no chat has any turns. + */ + private _latestTurnIdAcrossChats(session: ProtocolURI): string | undefined { + const sessionState = this._stateManager.getSessionState(session); + if (!sessionState) { + return undefined; + } + + const chats = sessionState.chats ?? []; + if (chats.length <= 1) { + return sessionState.turns.at(-1)?.id; + } + + let bestTurnId: string | undefined; + let bestModifiedAt = ''; + for (const chat of chats) { + const turns = isDefaultChatUri(chat.resource) + ? sessionState.turns + : this._stateManager.getChatState(chat.resource)?.turns; + const lastTurnId = turns?.at(-1)?.id; + if (lastTurnId && chat.modifiedAt >= bestModifiedAt) { + bestModifiedAt = chat.modifiedAt; + bestTurnId = lastTurnId; + } + } + return bestTurnId; + } + /** * Computes diffs for a static changeset by shelling out to git. * Returns the diff list when the session has a working directory and @@ -902,8 +987,11 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC // Session if (kind === 'session') { - // Get session checkpoints - const latestTurnId = this._stateManager.getSessionState(session)?.turns.at(-1)?.id; + // Get session checkpoints. In multi-chat sessions the working tree + // is shared and each chat's turn checkpoints are stored under the + // session URI keyed by their turn id, so the most-recently-modified + // chat's last turn captures the full working-tree delta. + const latestTurnId = this._latestTurnIdAcrossChats(session); if (!latestTurnId) { return undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index c46cb85e63a..24305095ab2 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -1071,7 +1071,10 @@ export class AgentHostStateManager extends Disposable { * and `hasActiveSessions`, which gate `--enable-remote-auto-shutdown`), * keyed by the owning session URI; * - mirror the chat's denormalized `status`/`activity`/`modifiedAt` - * onto the session summary so the session list reflects progress; and + * onto the session summary so the session list reflects progress; + * - forward the chat's own `status` to the session `chats` catalog (via a + * {@link ActionType.SessionChatUpdated}) so per-chat tabs reflect that + * chat's progress, not just the aggregated session summary; and * - keep the session's `chats` catalog entry in sync. */ private _onChatStateChanged(sessionKey: string, chatUri: string, prev: ChatState, next: ChatState): void { @@ -1107,7 +1110,22 @@ export class AgentHostStateManager extends Disposable { // Mirror denormalized chat summary fields onto the session, aggregating // across the whole chat catalog per the SessionSummary rules. - const chats = sessionState.chats.map(c => c.resource === chatUri ? chatSummaryFromState(next) : c); + const nextEntry = chatSummaryFromState(next); + const prevEntry = sessionState.chats.find(c => c.resource === chatUri); + const chats = sessionState.chats.map(c => c.resource === chatUri ? nextEntry : c); + + // Forward the chat's own status to the session catalog so full + // SessionState subscribers (the per-chat tabs) reflect this chat's + // progress — not just the aggregated session summary. Status changes + // at most a couple of times per turn, so this won't flood the channel. + if (prevEntry?.status !== nextEntry.status) { + this.dispatchServerAction(sessionKey, { + type: ActionType.SessionChatUpdated, + chat: chatUri, + changes: { status: nextEntry.status, activity: nextEntry.activity }, + }); + } + const aggregate = this._aggregateChatSummaries(chats, sessionState.defaultChat); const prevSummary = sessionState.summary; const statusChanged = aggregate.status !== undefined && this._mergeSessionStatus(prevSummary.status, aggregate.status) !== prevSummary.status; @@ -1132,9 +1150,12 @@ export class AgentHostStateManager extends Disposable { /** * Aggregates a session's chat catalog into the derived session-summary * fields per the protocol rules: activity bits come from the default chat - * (else the most recently modified chat) with `InputNeeded`/`Error` - * promoted whenever any chat raises them; the `activity` string follows the - * chat driving the resulting status; `modifiedAt` is the max across chats. + * (else the most recently modified chat) with `InputNeeded`/`Error`/ + * `InProgress` promoted whenever any chat raises them; the `activity` string + * follows the chat driving the resulting status; `modifiedAt` is the max + * across chats. Promotion precedence is `InputNeeded` > `Error` > + * `InProgress`, so a running peer (sub) chat surfaces as `InProgress` on the + * session even when the default chat is idle. */ private _aggregateChatSummaries(chats: readonly ChatSummary[], defaultChat: URI | undefined): { status?: SessionStatus; activity?: string; modifiedAt?: number } { if (chats.length === 0) { @@ -1147,12 +1168,18 @@ export class AgentHostStateManager extends Disposable { let driver = base; const errorChat = chats.find(c => (c.status & SessionStatus.Error) === SessionStatus.Error); const inputChat = chats.find(c => (c.status & SessionStatus.InputNeeded) === SessionStatus.InputNeeded); + // `InputNeeded` is a superset of the `InProgress` bit, so exclude + // input-needed chats here to find one that is purely streaming. + const inProgressChat = chats.find(c => (c.status & SessionStatus.InputNeeded) === SessionStatus.InProgress); if (inputChat) { status = SessionStatus.InputNeeded; driver = inputChat; } else if (errorChat) { status = SessionStatus.Error; driver = errorChat; + } else if (inProgressChat) { + status = SessionStatus.InProgress; + driver = inProgressChat; } const modifiedAt = chats.reduce((max, c) => Math.max(max, Date.parse(c.modifiedAt)), 0); return { status, activity: driver.activity, modifiedAt }; diff --git a/src/vs/platform/agentHost/node/sessionDiffAggregator.ts b/src/vs/platform/agentHost/node/sessionDiffAggregator.ts index 60c8a379eb5..a83c1ec9ef3 100644 --- a/src/vs/platform/agentHost/node/sessionDiffAggregator.ts +++ b/src/vs/platform/agentHost/node/sessionDiffAggregator.ts @@ -13,20 +13,20 @@ function getFileEditUri(diff: ISessionFileDiff): string | undefined { return diff.after?.uri ?? diff.before?.uri; } -function createSessionFileDiff(sessionUri: string, identity: IFileIdentity, added: number, removed: number): ISessionFileDiff { +function createSessionFileDiff(beforeSessionUri: string, afterSessionUri: string, identity: IFileIdentity, added: number, removed: number): ISessionFileDiff { const hasBefore = identity.firstKind !== FileEditKind.Create; const hasAfter = identity.lastKind !== FileEditKind.Delete; return { ...(hasBefore ? { before: { uri: URI.file(identity.firstFilePath).toString(), - content: { uri: buildSessionDbUri(sessionUri, identity.firstToolCallId, identity.firstFilePath, 'before') }, + content: { uri: buildSessionDbUri(beforeSessionUri, identity.firstToolCallId, identity.firstFilePath, 'before') }, }, } : {}), ...(hasAfter ? { after: { uri: URI.file(identity.terminalPath).toString(), - content: { uri: buildSessionDbUri(sessionUri, identity.lastToolCallId, identity.lastFilePath, 'after') }, + content: { uri: buildSessionDbUri(afterSessionUri, identity.lastToolCallId, identity.lastFilePath, 'after') }, }, } : {}), diff: { added, removed }, @@ -46,12 +46,33 @@ interface IFileIdentity { firstFilePath: string; /** The kind of the first edit (Create means no "before" content). */ firstKind: FileEditKind; + /** Index into the sources array of the DB that owns the first edit. */ + firstSourceIdx: number; /** Tool call ID of the last edit (for fetching "after" content). */ lastToolCallId: string; /** File path used in the last edit's database record. */ lastFilePath: string; /** The kind of the last edit (Delete means no "after" content). */ lastKind: FileEditKind; + /** Index into the sources array of the DB that owns the last edit. */ + lastSourceIdx: number; +} + +/** + * A single database whose file edits contribute to a session's aggregated + * diff. For single-chat sessions there is one source (the session DB); for + * multi-chat sessions each peer chat records edits into its own DB, so the + * session changeset unions the session DB with every peer chat DB. + */ +export interface ISessionDiffSource { + /** + * The session / peer-chat URI that owns {@link db}. Encoded into the + * `session-db:` content URIs so the resource resolver opens the correct + * database when fetching before/after blobs. + */ + sessionUri: string; + /** The database holding this source's file edits. */ + db: ISessionDatabase; } /** @@ -86,32 +107,35 @@ export async function computeSessionDiffs( diffService: IDiffComputeService, incremental?: IIncrementalDiffOptions, ): Promise { - // In incremental mode, try to fetch only the current turn's edits. - // When the turn only introduces new files (no renames, no re-edits of - // previously changed files), the full edit history is not needed. + // Full mode (no incremental) is the single-source case of the unioned + // computation — delegate so the identity-graph + diff logic lives in one + // place and multi-chat sessions reuse the exact same code path. + if (!incremental) { + return computeUnionedDiffs([{ sessionUri, db }], diffService); + } + + // Incremental mode (single source): try to fetch only the current turn's + // edits. When the turn only introduces new files (no renames, no re-edits + // of previously changed files), the full edit history is not needed. let edits: IFileEditRecord[]; let fastPath = false; - if (incremental) { - const turnEdits = await db.getFileEditsByTurn(incremental.changedTurnId); - if (turnEdits.length === 0) { - return [...incremental.previousDiffs]; - } + const turnEdits = await db.getFileEditsByTurn(incremental.changedTurnId); + if (turnEdits.length === 0) { + return [...incremental.previousDiffs]; + } - const previousDiffsUris = new Set(incremental.previousDiffs.map(getFileEditUri)); - const needsFullHistory = turnEdits.some(e => - e.kind === FileEditKind.Rename || - previousDiffsUris.has(URI.file(e.filePath).toString()) - ); + const previousDiffsUris = new Set(incremental.previousDiffs.map(getFileEditUri)); + const needsFullHistory = turnEdits.some(e => + e.kind === FileEditKind.Rename || + previousDiffsUris.has(URI.file(e.filePath).toString()) + ); - if (needsFullHistory) { - edits = await db.getAllFileEdits(); - } else { - edits = turnEdits; - fastPath = true; - } - } else { + if (needsFullHistory) { edits = await db.getAllFileEdits(); + } else { + edits = turnEdits; + fastPath = true; } if (edits.length === 0) { @@ -128,7 +152,7 @@ export async function computeSessionDiffs( const identities = new Map(); // Track which identity keys were touched by the incremental turn. // In fast-path mode all identities are from the current turn, so no tracking needed. - const touchedIdentityKeys = (incremental && !fastPath) ? new Set() : undefined; + const touchedIdentityKeys = !fastPath ? new Set() : undefined; for (const edit of edits) { let identityKey: string; @@ -146,7 +170,7 @@ export async function computeSessionDiffs( pathToIdentityKey.set(edit.filePath, identityKey); } - if (touchedIdentityKeys && edit.turnId === incremental!.changedTurnId) { + if (touchedIdentityKeys && edit.turnId === incremental.changedTurnId) { touchedIdentityKeys.add(identityKey); } @@ -158,9 +182,11 @@ export async function computeSessionDiffs( firstToolCallId: edit.toolCallId, firstFilePath: edit.kind === FileEditKind.Rename && edit.originalPath ? edit.originalPath : edit.filePath, firstKind: edit.kind, + firstSourceIdx: 0, lastToolCallId: edit.toolCallId, lastFilePath: edit.filePath, lastKind: edit.kind, + lastSourceIdx: 0, }); } else { // Update last snapshot info and terminal path @@ -173,7 +199,7 @@ export async function computeSessionDiffs( // In incremental slow-path mode, build a lookup map from URI string → // previous diff so untouched identities can carry over their previous results. - const previousDiffsMap = (incremental && !fastPath) + const previousDiffsMap = !fastPath ? new Map(incremental.previousDiffs.map(d => [getFileEditUri(d), d])) : undefined; @@ -218,7 +244,7 @@ export async function computeSessionDiffs( } const counts = await diffService.computeDiffCounts(beforeText, afterText); - results.push(createSessionFileDiff(sessionUri, identity, counts.added, counts.removed)); + results.push(createSessionFileDiff(sessionUri, sessionUri, identity, counts.added, counts.removed)); })()); } @@ -227,12 +253,129 @@ export async function computeSessionDiffs( // In fast-path mode, carry over previous diffs for untouched files // (they were not in the identity graph since we only loaded the current turn) if (fastPath) { - results.push(...incremental!.previousDiffs); + results.push(...incremental.previousDiffs); } return results; } +/** + * Computes aggregated diff statistics across one or more {@link ISessionDiffSource} + * databases by unioning their file edits and comparing each file's first + * snapshot to its last snapshot, tracking renames across the chain. + * + * Single-chat sessions pass one source (the session DB). Multi-chat sessions + * pass the session DB plus every peer chat DB so peer-chat edits (recorded into + * their own databases) roll up into the session-level changes. Each file + * identity remembers which source owns its first and last snapshots so the + * before/after content is read from — and its `session-db:` content URI encodes — + * the correct database. + * + * Sources are unioned in array order (session first, peers next); within a + * source, edits keep their insertion order. When a file is touched by more than + * one source the "before" comes from the earliest source that touched it and the + * "after" from the latest, which matches the shared working tree the chats edit. + * + * TODO (debt): this always does a full recompute — it ignores the + * {@link IIncrementalDiffOptions} fast/slow paths that {@link computeSessionDiffs} + * uses for single-source sessions. An incremental union is a safe follow-up: + * the per-identity `firstSourceIdx`/`lastSourceIdx` already carry the provenance + * needed to recompute only the turn's owning source plus cross-source files and + * carry over the rest. Requires plumbing the owning source of `changedTurnId` + * through `onTurnComplete` → `_doComputeStaticChangeset`. See tracking issue. + */ +export async function computeUnionedDiffs( + sources: readonly ISessionDiffSource[], + diffService: IDiffComputeService, +): Promise { + // Load every source's edits in parallel, then concatenate in source order so + // the identity graph sees a deterministic session-first ordering while each + // source keeps its own insertion order. + const perSourceEdits = await Promise.all(sources.map(source => source.db.getAllFileEdits())); + + const pathToIdentityKey = new Map(); + const identities = new Map(); + let totalEdits = 0; + + for (let sourceIdx = 0; sourceIdx < perSourceEdits.length; sourceIdx++) { + for (const edit of perSourceEdits[sourceIdx]) { + totalEdits++; + let identityKey: string; + + if (edit.kind === FileEditKind.Rename && edit.originalPath) { + identityKey = pathToIdentityKey.get(edit.originalPath) ?? edit.originalPath; + pathToIdentityKey.set(edit.filePath, identityKey); + pathToIdentityKey.delete(edit.originalPath); + } else { + identityKey = pathToIdentityKey.get(edit.filePath) ?? edit.filePath; + pathToIdentityKey.set(edit.filePath, identityKey); + } + + const existing = identities.get(identityKey); + if (!existing) { + identities.set(identityKey, { + terminalPath: edit.filePath, + firstToolCallId: edit.toolCallId, + firstFilePath: edit.kind === FileEditKind.Rename && edit.originalPath ? edit.originalPath : edit.filePath, + firstKind: edit.kind, + firstSourceIdx: sourceIdx, + lastToolCallId: edit.toolCallId, + lastFilePath: edit.filePath, + lastKind: edit.kind, + lastSourceIdx: sourceIdx, + }); + } else { + existing.terminalPath = edit.filePath; + existing.lastToolCallId = edit.toolCallId; + existing.lastFilePath = edit.filePath; + existing.lastKind = edit.kind; + existing.lastSourceIdx = sourceIdx; + } + } + } + + if (totalEdits === 0) { + return []; + } + + const results: ISessionFileDiff[] = []; + const diffPromises: Promise[] = []; + + for (const identity of identities.values()) { + diffPromises.push((async () => { + const firstSource = sources[identity.firstSourceIdx]; + const lastSource = sources[identity.lastSourceIdx]; + + let beforeText: string; + if (identity.firstKind === FileEditKind.Create) { + beforeText = ''; + } else { + const content = await firstSource.db.readFileEditContent(identity.firstToolCallId, identity.firstFilePath); + beforeText = content?.beforeContent ? new TextDecoder().decode(content.beforeContent) : ''; + } + + let afterText: string; + if (identity.lastKind === FileEditKind.Delete) { + afterText = ''; + } else { + const content = await lastSource.db.readFileEditContent(identity.lastToolCallId, identity.lastFilePath); + afterText = content?.afterContent ? new TextDecoder().decode(content.afterContent) : ''; + } + + if (beforeText === afterText) { + return; + } + + const counts = await diffService.computeDiffCounts(beforeText, afterText); + results.push(createSessionFileDiff(firstSource.sessionUri, lastSource.sessionUri, identity, counts.added, counts.removed)); + })()); + } + + await Promise.allSettled(diffPromises); + + return results; +} + /** * Computes the diff statistics for a single turn — files touched only * within `turnId`, with their `before` snapshot taken from the first edit @@ -273,9 +416,11 @@ export async function computeTurnDiffs( firstToolCallId: edit.toolCallId, firstFilePath: edit.kind === FileEditKind.Rename && edit.originalPath ? edit.originalPath : edit.filePath, firstKind: edit.kind, + firstSourceIdx: 0, lastToolCallId: edit.toolCallId, lastFilePath: edit.filePath, lastKind: edit.kind, + lastSourceIdx: 0, }); } else { existing.terminalPath = edit.filePath; @@ -307,7 +452,7 @@ export async function computeTurnDiffs( return; } const counts = await diffService.computeDiffCounts(beforeText, afterText); - results.push(createSessionFileDiff(sessionUri, identity, counts.added, counts.removed)); + results.push(createSessionFileDiff(sessionUri, sessionUri, identity, counts.added, counts.removed)); })()); } await Promise.allSettled(diffPromises); diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 8e5cd1a8398..86549a85dc4 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -1058,6 +1058,85 @@ suite('AgentHostStateManager', () => { ); }); + test('a running peer chat promotes the session summary to InProgress while the default chat is idle', () => { + manager.createSession(makeSessionSummary()); + const defaultChat = buildDefaultChatUri(sessionUri); + manager.addChat(sessionUri, peerChat, { title: 'Peer' }); + + const idle = manager.getSessionState(sessionUri)?.summary.status; + + // Only the peer (sub) chat starts streaming; the default chat stays idle. + manager.dispatchServerAction(peerChat, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-peer', + message: { text: 'b', origin: { kind: MessageKind.User } }, + }); + const whilePeerRuns = manager.getSessionState(sessionUri)?.summary.status; + + // Once the peer finishes the session falls back to idle. + manager.dispatchServerAction(peerChat, { + type: ActionType.ChatTurnComplete, + turnId: 'turn-peer', + }); + const afterPeerComplete = manager.getSessionState(sessionUri)?.summary.status; + + assert.deepStrictEqual( + { + idleHasInProgress: ((idle ?? 0) & SessionStatus.InProgress) === SessionStatus.InProgress, + whilePeerRunsHasInProgress: ((whilePeerRuns ?? 0) & SessionStatus.InProgress) === SessionStatus.InProgress, + afterPeerCompleteHasInProgress: ((afterPeerComplete ?? 0) & SessionStatus.InProgress) === SessionStatus.InProgress, + defaultChatStillIdle: ((manager.getChatState(defaultChat)?.status ?? SessionStatus.Idle) & SessionStatus.InProgress) === 0, + }, + { + idleHasInProgress: false, + whilePeerRunsHasInProgress: true, + afterPeerCompleteHasInProgress: false, + defaultChatStillIdle: true, + }, + ); + }); + + test('a running peer chat forwards its own status to the session catalog so its tab can show progress', () => { + manager.createSession(makeSessionSummary()); + manager.addChat(sessionUri, peerChat, { title: 'Peer' }); + + const envelopes: ActionEnvelope[] = []; + disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); + + const peerCatalogStatus = () => manager.getSessionState(sessionUri)?.chats.find(c => c.resource === peerChat)?.status ?? SessionStatus.Idle; + const chatUpdatesForPeer = () => envelopes.filter(e => e.action.type === ActionType.SessionChatUpdated && (e.action as { chat: string }).chat === peerChat).length; + + const idleCatalog = peerCatalogStatus(); + + manager.dispatchServerAction(peerChat, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-peer', + message: { text: 'b', origin: { kind: MessageKind.User } }, + }); + const runningCatalog = peerCatalogStatus(); + const updatesAfterStart = chatUpdatesForPeer(); + + manager.dispatchServerAction(peerChat, { + type: ActionType.ChatTurnComplete, + turnId: 'turn-peer', + }); + + assert.deepStrictEqual( + { + idleCatalogInProgress: (idleCatalog & SessionStatus.InProgress) === SessionStatus.InProgress, + runningCatalogInProgress: (runningCatalog & SessionStatus.InProgress) === SessionStatus.InProgress, + finalCatalogInProgress: (peerCatalogStatus() & SessionStatus.InProgress) === SessionStatus.InProgress, + emittedChatUpdateOnStart: updatesAfterStart >= 1, + }, + { + idleCatalogInProgress: false, + runningCatalogInProgress: true, + finalCatalogInProgress: false, + emittedChatUpdateOnStart: true, + }, + ); + }); + test('active-turn event and active-session count flip once per session across concurrent chats', () => { manager.createSession(makeSessionSummary()); const defaultChat = buildDefaultChatUri(sessionUri); diff --git a/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts b/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts index 5da1e74ee80..289f5878be8 100644 --- a/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts @@ -8,7 +8,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { FileEditKind, type ISessionFileDiff } from '../../common/state/sessionState.js'; import { encodeString, TestDiffComputeService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; -import { computeSessionDiffs } from '../../node/sessionDiffAggregator.js'; +import { computeSessionDiffs, computeUnionedDiffs } from '../../node/sessionDiffAggregator.js'; import { parseSessionDbUri } from '../../node/shared/fileEditTracker.js'; const TEST_SESSION_URI = 'session://test-session'; @@ -490,3 +490,100 @@ suite('computeSessionDiffs', () => { assert.deepStrictEqual(result, previousDiffs); }); }); + +suite('computeUnionedDiffs', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const PEER_CHAT_URI = 'ahp-chat://peer/encoded'; + + test('returns empty array when no source has edits', async () => { + const result = await computeUnionedDiffs( + [{ sessionUri: TEST_SESSION_URI, db: new TestSessionDatabase() }], + createTestDiffService(), + ); + assert.deepStrictEqual(result, []); + }); + + test('unions edits from the session DB and a peer chat DB', async () => { + const sessionDb = new TestSessionDatabase(); + sessionDb.addEdit({ + turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit, + addedLines: undefined, removedLines: undefined, + beforeContent: encodeString('a1'), afterContent: encodeString('a1\na2'), + }); + + const peerDb = new TestSessionDatabase(); + peerDb.addEdit({ + turnId: 'pt1', toolCallId: 'ptc1', filePath: '/b.txt', kind: FileEditKind.Create, + addedLines: undefined, removedLines: undefined, + beforeContent: undefined, afterContent: encodeString('b1\nb2\nb3'), + }); + + const result = await computeUnionedDiffs( + [ + { sessionUri: TEST_SESSION_URI, db: sessionDb }, + { sessionUri: PEER_CHAT_URI, db: peerDb }, + ], + createTestDiffService(), + ); + + assert.deepStrictEqual( + result.map(simplify).sort((x, y) => (x.uri ?? '').localeCompare(y.uri ?? '')), + [simpleDiff('/a.txt', 1, 0), simpleDiff('/b.txt', 3, 0)], + ); + + // The peer file's content URI must encode the peer chat URI so the + // resource resolver opens the peer DB, not the session DB. + const peerDiff = result.find(d => getDiffUri(d) === URI.file('/b.txt').toString())!; + const afterFields = parseSessionDbUri(peerDiff.after!.content.uri); + assert.deepStrictEqual(afterFields, { + sessionUri: PEER_CHAT_URI, + toolCallId: 'ptc1', + filePath: '/b.txt', + part: 'after', + }); + }); + + test('a file edited by multiple sources takes before from the first and after from the last source', async () => { + const sessionDb = new TestSessionDatabase(); + sessionDb.addEdit({ + turnId: 't1', toolCallId: 'tc1', filePath: '/shared.txt', kind: FileEditKind.Edit, + addedLines: undefined, removedLines: undefined, + beforeContent: encodeString('v1'), afterContent: encodeString('v2'), + }); + + const peerDb = new TestSessionDatabase(); + peerDb.addEdit({ + turnId: 'pt1', toolCallId: 'ptc1', filePath: '/shared.txt', kind: FileEditKind.Edit, + addedLines: undefined, removedLines: undefined, + beforeContent: encodeString('v2'), afterContent: encodeString('v3'), + }); + + const result = await computeUnionedDiffs( + [ + { sessionUri: TEST_SESSION_URI, db: sessionDb }, + { sessionUri: PEER_CHAT_URI, db: peerDb }, + ], + createTestDiffService(), + ); + + assert.strictEqual(result.length, 1); + const [diff] = result; + + // before snapshot from the session DB (first source) + assert.deepStrictEqual(parseSessionDbUri(diff.before!.content.uri), { + sessionUri: TEST_SESSION_URI, + toolCallId: 'tc1', + filePath: '/shared.txt', + part: 'before', + }); + // after snapshot from the peer chat DB (last source) + assert.deepStrictEqual(parseSessionDbUri(diff.after!.content.uri), { + sessionUri: PEER_CHAT_URI, + toolCallId: 'ptc1', + filePath: '/shared.txt', + part: 'after', + }); + }); +}); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index edfd83d53a8..2511e859011 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -274,6 +274,14 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { * (or host) renamed the default chat independently of the session. */ private readonly _defaultChatTitleOverride = observableValue('defaultChatTitleOverride', undefined); + /** + * Independent status override for the default chat tab. `undefined` means the + * default chat reflects the aggregated session status (the single-chat case, + * where they are equivalent); a defined value means a multi-chat session, so + * the default chat shows its own status rather than the session aggregate + * (which may have been promoted by a running peer chat). + */ + private readonly _defaultChatStatusOverride = observableValue('defaultChatStatusOverride', undefined); private readonly _mainChatObs: ISettableObservable; private readonly _chatsObs: ISettableObservable; /** Additional (non-default) peer chats keyed by chatId. */ @@ -498,7 +506,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { createdAt: this.createdAt, title: derived(this, reader => this._defaultChatTitleOverride.read(reader) ?? this.title.read(reader)), updatedAt: this.updatedAt, - status: this.status, + status: derived(this, reader => this._defaultChatStatusOverride.read(reader) ?? this.status.read(reader)), changes: this.changes, checkpoints: observableValue(this, undefined), modelId: this.modelId, @@ -536,6 +544,9 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._defaultChatTitleOverride.set(defaultSummary?.title || undefined, undefined); if (!this.capabilities.supportsMultipleChats || state.chats.length <= 1) { + // Single-chat: the default chat is the session, so let it reflect the + // aggregated session status directly (clear any prior override). + this._defaultChatStatusOverride.set(undefined, undefined); if (this._additionalChats.size > 0) { this._additionalChats.clearAndDisposeAll(); } @@ -548,6 +559,10 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { return; } + // Multi-chat: the default chat must show its own status, not the session + // aggregate which may have been promoted by a running peer chat. + this._defaultChatStatusOverride.set(defaultSummary ? mapProtocolStatus(defaultSummary.status) : undefined, undefined); + const defaultChatUri = defaultChatUriStr; const seen = new Set(); const ordered: IChat[] = [];