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[] = [];