From fe9f87ca05dca0086aabaf24c7ffaf33888e2a1d Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:29:29 +0000 Subject: [PATCH] AgentHost - adopt the ChangesetContentChanged action (#323170) * AgentHost - switch over to ChangesetContentChanged action * Revert the change to the tests * Fix integration test --- .../agentHostChangesetOperationService.ts | 7 ++ .../agentHost/common/state/sessionActions.ts | 1 + .../node/agentHostChangesetCoordinator.ts | 8 -- .../agentHostChangesetOperationService.ts | 77 ++++++++++--------- .../node/agentHostChangesetService.ts | 38 ++++----- .../agentHost/node/agentHostStateManager.ts | 2 +- .../platform/agentHost/node/agentService.ts | 18 ++++- .../agentHostChangesetCoordinator.test.ts | 1 + ...agentHostChangesetOperationService.test.ts | 2 - .../node/agentHostChangesetService.test.ts | 75 ++++++++++++------ .../protocol/sessionDiffs.integrationTest.ts | 35 ++++++--- 11 files changed, 156 insertions(+), 108 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts index 239a16cfa12..32a9f737713 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts @@ -115,6 +115,13 @@ export interface IAgentHostChangesetOperationService extends IDisposable { * be used. */ updateOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): void; + + /** + * Returns the operations that should be advertised for the given changeset, or + * `undefined` when no operations are available. + */ + getOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): readonly ChangesetOperation[] | undefined; + /** * Invokes an advertised operation after validating the changeset, operation id, * and requested target scope. diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index a8b72183236..ab66bfe8657 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -63,6 +63,7 @@ export { type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, + type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, diff --git a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts index 1f12cb432dd..04f13e09f9e 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts @@ -119,9 +119,6 @@ export class AgentHostChangesetCoordinator extends Disposable { // subscribed for the session (the service reads the exposed // subscription list). this._changesets.recomputeSubscribedChangesets(sessionStr); - - // Update the operations for all subscribed changesets - this._changesetOperationService.updateOperations(sessionStr, undefined, gitState); } /** @@ -183,21 +180,18 @@ export class AgentHostChangesetCoordinator extends Disposable { if (parsed?.kind === ChangesetKind.Branch) { this._addSubscription(parsed.sessionUri, resourceStr); this._changesets.refreshBranchChangeset(parsed.sessionUri); - this._changesetOperationService.updateOperations(parsed.sessionUri, resourceStr); this._changesetFileMonitor.trackSessionChanges(resourceStr, parsed.sessionUri); return; } if (parsed?.kind === ChangesetKind.Uncommitted) { this._addSubscription(parsed.sessionUri, resourceStr); void this._changesets.computeUncommittedChangeset(parsed.sessionUri); - this._changesetOperationService.updateOperations(parsed.sessionUri, resourceStr); this._changesetFileMonitor.trackSessionChanges(resourceStr, parsed.sessionUri); return; } if (parsed?.kind === ChangesetKind.Session) { this._addSubscription(parsed.sessionUri, resourceStr); this._changesets.refreshSessionChangeset(parsed.sessionUri); - this._changesetOperationService.updateOperations(parsed.sessionUri, resourceStr); this._changesetFileMonitor.trackSessionChanges(resourceStr, parsed.sessionUri); return; } @@ -208,7 +202,6 @@ export class AgentHostChangesetCoordinator extends Disposable { // subsequent deltas flow from `onToolCallEditsApplied` / // `onTurnComplete` once we've added this turn id here. this._addSubscription(parsed.sessionUri, resourceStr); - this._changesetOperationService.updateOperations(parsed.sessionUri, resourceStr); return; } if (!parsed && this._stateManager.getSessionState(resourceStr)) { @@ -312,7 +305,6 @@ export class AgentHostChangesetCoordinator extends Disposable { await this.restoreSessionIfChangesetSubscription(resource, restoreSession); if (parsed.kind === ChangesetKind.Turn && parsed.turnId) { await this._changesets.computeTurnChangeset(parsed.sessionUri, parsed.turnId); - this._changesetOperationService.updateOperations(parsed.sessionUri, resourceStr); } else if (parsed.kind === ChangesetKind.Compare && parsed.originalTurnId && parsed.modifiedTurnId) { // Compare-turns is computed once on subscribe. Both turns are // typically historical so the snapshot doesn't need to track diff --git a/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts index f29a636bbb4..98ae5a674b3 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts @@ -15,11 +15,6 @@ import type { IChangesetOperationContribution, IAgentHostChangesetOperationServi import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; -import { IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { AgentHostPullRequestOperationContribution } from './agentHostPullRequestOperationProvider.js'; -import { AgentHostCommitOperationContribution } from './agentHostCommitOperationProvider.js'; -import { AgentHostDiscardChangesOperationContribution } from './agentHostDiscardChangesOperationProvider.js'; -import { AgentHostSyncOperationContribution } from './agentHostSyncOperationProvider.js'; export class AgentHostChangesetOperationService extends Disposable implements IAgentHostChangesetOperationService { declare readonly _serviceBrand: undefined; @@ -33,19 +28,14 @@ export class AgentHostChangesetOperationService extends Disposable implements IA private readonly _stateManager: AgentHostStateManager, @IAgentHostGitStateService private readonly _gitStateService: IAgentHostGitStateService, @IAgentHostChangesetSubscriptionService private readonly _changesetSubscriptions: IAgentHostChangesetSubscriptionService, - @IInstantiationService instantiationService: IInstantiationService ) { super(); + this._registry = { registerChangesetOperationHandler: (operationId, handler) => this._registerChangesetOperationHandler(operationId, handler), onDidChangeOperations: sessionKey => this.updateOperations(sessionKey), refreshSessionGitState: sessionKey => this._refreshSessionGitStateAndOperations(sessionKey), }; - - this._register(this.registerContribution(instantiationService.createInstance(AgentHostCommitOperationContribution, this._stateManager))); - this._register(this.registerContribution(instantiationService.createInstance(AgentHostPullRequestOperationContribution, this._stateManager))); - this._register(this.registerContribution(instantiationService.createInstance(AgentHostSyncOperationContribution, this._stateManager))); - this._register(this.registerContribution(instantiationService.createInstance(AgentHostDiscardChangesOperationContribution, this._stateManager))); } registerContribution(contribution: IChangesetOperationContribution): IDisposable { @@ -59,12 +49,12 @@ export class AgentHostChangesetOperationService extends Disposable implements IA }); } - updateOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): void { + getOperations(sessionKey: string, changeset: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): readonly ChangesetOperation[] | undefined { if (!gitState) { const sessionState = this._stateManager.getSessionState(sessionKey); gitState = readSessionGitState(sessionState?._meta); if (!gitState) { - return; + return undefined; } } @@ -73,29 +63,18 @@ export class AgentHostChangesetOperationService extends Disposable implements IA gitHubState = readSessionGitHubState(sessionState?.summary._meta); } - const changesets = changeset - ? [changeset] - : this._changesetSubscriptions.getSessionSubscriptions(sessionKey); - - for (const changeset of changesets) { - const parsed = parseChangesetUri(changeset); - if (!parsed) { - continue; - } - - const operations = this._getOperations({ - sessionKey, - changesetUri: changeset, - changesetKind: parsed.kind, - gitState, - gitHubState - }); - - this._stateManager.dispatchServerAction(changeset, { - type: ActionType.ChangesetOperationsChanged, - operations: operations ? [...operations] : undefined, - }); + const parsed = parseChangesetUri(changeset); + if (!parsed) { + return undefined; } + + return this._getOperations({ + sessionKey, + changesetUri: changeset, + changesetKind: parsed.kind, + gitState, + gitHubState + }); } private _getOperations(context: IChangesetOperationContext): readonly ChangesetOperation[] | undefined { @@ -122,6 +101,34 @@ export class AgentHostChangesetOperationService extends Disposable implements IA return operations; } + updateOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): void { + if (!gitState) { + const sessionState = this._stateManager.getSessionState(sessionKey); + gitState = readSessionGitState(sessionState?._meta); + if (!gitState) { + return; + } + } + + if (!gitHubState) { + const sessionState = this._stateManager.getSessionState(sessionKey); + gitHubState = readSessionGitHubState(sessionState?.summary._meta); + } + + const changesets = changeset + ? [changeset] + : this._changesetSubscriptions.getSessionSubscriptions(sessionKey); + + for (const changeset of changesets) { + const operations = this.getOperations(sessionKey, changeset, gitState, gitHubState); + + this._stateManager.dispatchServerAction(changeset, { + type: ActionType.ChangesetOperationsChanged, + operations: operations ? [...operations] : undefined, + }); + } + } + private async _refreshSessionGitStateAndOperations(sessionKey: string): Promise { const gitState = await this._gitStateService.refreshSessionGitState(sessionKey); if (!gitState) { diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index ff3a4e69d1c..4e0e5f574df 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -38,6 +38,7 @@ import { computeSessionDiffs, computeTurnDiffs, computeUnionedDiffs, type IIncre 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'; +import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; function staticChangesetUri(session: ProtocolURI, kind: StaticChangesetKind): ProtocolURI { return kind === 'branch' @@ -158,6 +159,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @IAgentHostCheckpointService private readonly _checkpointService: IAgentHostCheckpointService, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, + @IAgentHostChangesetOperationService private readonly _changesetOperationService: IAgentHostChangesetOperationService, @IAgentHostChangesetSubscriptionService private readonly _changesetSubscriptions: IAgentHostChangesetSubscriptionService, ) { super(); @@ -794,6 +796,7 @@ 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. @@ -859,36 +862,27 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC * fresh file list has been applied. */ private _publishChangesetDiffs(session: ProtocolURI, changesetUri: ProtocolURI, diffs: readonly ISessionFileDiff[]): void { - const previous = this._stateManager.getChangesetState(changesetUri); - const previousIds = new Set(previous?.files.map(f => f.id) ?? []); + // Get the available operations for this changeset. This call assumes that at this point + // the git state of the session is up-to-date as it is being used to determine the available + // operations. Long term this should be replaced with a more robust mechanism. + const operations = this._changesetOperationService.getOperations(session, changesetUri); - // Emit file upserts. Use `after.uri` as the stable id when available - // (covers creates and edits) and fall back to `before.uri` for - // deletions; this matches the spec's recommendation and avoids id - // collisions for renames (which carry distinct before/after URIs). - const nextFilesById = new Map(); + const files: ChangesetFile[] = []; for (const edit of diffs) { const id = edit.after?.uri ?? edit.before?.uri; if (!id) { continue; } - nextFilesById.set(id, edit); - const file: ChangesetFile = { id, edit }; - this._stateManager.dispatchServerAction(changesetUri, { - type: ActionType.ChangesetFileSet, - file, - }); + files.push({ id, edit }); } - // Emit removals for any file that disappeared in this pass. - for (const id of previousIds) { - if (!nextFilesById.has(id)) { - this._stateManager.dispatchServerAction(changesetUri, { - type: ActionType.ChangesetFileRemoved, - fileId: id, - }); - } - } + this._stateManager.dispatchServerAction(changesetUri, { + type: ActionType.ChangesetContentChanged, + files, + operations: operations + ? [...operations] + : undefined, + }); // Move the changeset out of `computing` (or out of an earlier error) // now that we have a fresh, complete file list. diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 24305095ab2..fc8082ed37f 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -1033,7 +1033,7 @@ export class AgentHostStateManager extends Disposable { origin, }; - this._logService.trace(`[AgentHostStateManager] Emitting envelope: seq=${envelope.serverSeq}, type=${action.type}${origin ? `, origin=${origin.clientId}:${origin.clientSeq}` : ''}`); + this._logService.trace(`[AgentHostStateManager] Emitting envelope: seq=${envelope.serverSeq}, channel=${envelope.channel}, type=${action.type}${origin ? `, origin=${origin.clientId}:${origin.clientSeq}` : ''}`); this._onDidEmitEnvelope.fire(envelope); return resultingState; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 7cfca6949d2..08c0be544bf 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -64,6 +64,10 @@ import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChang import { AgentHostChangesetSubscriptionService } from './agentHostChangesetSubscriptionService.js'; import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; +import { AgentHostCommitOperationContribution } from './agentHostCommitOperationProvider.js'; +import { AgentHostDiscardChangesOperationContribution } from './agentHostDiscardChangesOperationProvider.js'; +import { AgentHostPullRequestOperationContribution } from './agentHostPullRequestOperationProvider.js'; +import { AgentHostSyncOperationContribution } from './agentHostSyncOperationProvider.js'; /** * Grace period before an empty, unsubscribed session is garbage-collected @@ -274,19 +278,25 @@ export class AgentService extends Disposable implements IAgentService { this._changesetSubscriptions = instantiationService.createInstance(AgentHostChangesetSubscriptionService); services.set(IAgentHostChangesetSubscriptionService, this._changesetSubscriptions); - // The changeset service is responsible for computing, publishing, and persisting changesets. - this._changesets = this._register(instantiationService.createInstance(AgentHostChangesetService, this._stateManager)); - services.set(IAgentHostChangesetService, this._changesets); - // The operation contribution service manages the lifecycle of changeset operations. this._changesetOperationService = this._register(instantiationService.createInstance(AgentHostChangesetOperationService, this._stateManager)); services.set(IAgentHostChangesetOperationService, this._changesetOperationService); + // The changeset service is responsible for computing, publishing, and persisting changesets. + this._changesets = this._register(instantiationService.createInstance(AgentHostChangesetService, this._stateManager)); + services.set(IAgentHostChangesetService, this._changesets); + // The coordinator owns all AgentService-side orchestration of the changeset feature: lifecycle // hooks, listSessions overlay, subscription URI routing, and the deferred-refresh state machine. this._changesetCoordinator = this._register(instantiationService.createInstance(AgentHostChangesetCoordinator, this._stateManager)); this._register(this._stateManager.onDidChangeSessionActiveTurn(e => this._changesetCoordinator.onSessionTurnActiveChanged(e.session, e.active))); + // Register the changeset operation contributions. + this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostCommitOperationContribution, this._stateManager))); + this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostPullRequestOperationContribution, this._stateManager))); + this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostSyncOperationContribution, this._stateManager))); + this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostDiscardChangesOperationContribution, this._stateManager))); + this._completions = this._register(instantiationService.createInstance(AgentHostCompletions)); // Built-in generic provider: completes files in the session's workspace folder. const workspaceFiles = this._register(instantiationService.createInstance(AgentHostWorkspaceFiles)); diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts index 555b6613864..18697abde6e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts @@ -67,6 +67,7 @@ suite('ChangesetSessionCoordinator', () => { const operationContributionService: IAgentHostChangesetOperationService = { _serviceBrand: undefined, registerContribution: () => Disposable.None, + getOperations: () => undefined, updateOperations: () => { }, invokeChangesetOperation: async () => ({}), dispose: () => { }, diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts index d0e9a149d28..d2f78e4922a 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts @@ -7,7 +7,6 @@ import assert from 'assert'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { NullLogService } from '../../../log/common/log.js'; import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationHandler, IChangesetOperationRegistry } from '../../common/agentHostChangesetOperationService.js'; import { buildUncommittedChangesetUri } from '../../common/changesetUri.js'; @@ -85,7 +84,6 @@ suite('AgentHostChangesetOperationService', () => { stateManager, new TestGitStateService(), new AgentHostChangesetSubscriptionService(), - disposables.add(new InstantiationService()), )); } diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index 58052e501b4..b3ce5edd80b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -16,6 +16,7 @@ import { ActionEnvelope, ActionType } from '../../common/state/sessionActions.js import { ChangesetStatus, SessionStatus, withSessionGitState, type Changeset } from '../../common/state/sessionState.js'; import { AgentHostChangesetService } from '../../node/agentHostChangesetService.js'; import { IAgentHostChangesetSubscriptionService } from '../../common/agentHostChangesetSubscriptionService.js'; +import { IAgentHostChangesetOperationService } from '../../common/agentHostChangesetOperationService.js'; import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; @@ -41,6 +42,22 @@ function createSubscriptionService(...changesets: string[]): IAgentHostChangeset }; } +/** + * Builds a no-op changeset operation service for tests. It advertises no + * operations, which mirrors the default behaviour of a session without any + * operation contributions. + */ +function createOperationService(): IAgentHostChangesetOperationService { + return { + _serviceBrand: undefined, + registerContribution: () => toDisposable(() => { }), + updateOperations: () => { }, + getOperations: () => undefined, + invokeChangesetOperation: async () => { throw new Error('not implemented'); }, + dispose: () => { }, + }; +} + suite.skip('AgentHostChangesetService', () => { const disposables = new DisposableStore(); @@ -73,6 +90,7 @@ suite.skip('AgentHostChangesetService', () => { createNoopGitService(), NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), + createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())), )); }); @@ -258,7 +276,7 @@ suite.skip('AgentHostChangesetService', () => { } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())))); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())))); localStateManager.createSession({ resource: sessionUri.toString(), @@ -299,15 +317,16 @@ suite.skip('AgentHostChangesetService', () => { { workingDirectory: 'file:///wd', sessionUri: sessionUri.toString(), baseBranch: undefined }, { workingDirectory: 'file:///wd', sessionUri: sessionUri.toString(), baseBranch: 'main' }, ]); - // Each git diff lands as its own `changeset/fileSet` envelope. - // Walk the captured stream and reconstruct the per-changeset - // file lists to assert each matches the git service output. - const fileSets = envelopes - .filter(e => e.action.type === ActionType.ChangesetFileSet) as Array<{ channel: string; action: { file: { edit: unknown } } }>; - const sessionFileSets = fileSets.filter(e => e.channel === `${sessionUri.toString()}/changeset/session`); - const uncommittedFileSets = fileSets.filter(e => e.channel === `${sessionUri.toString()}/changeset/uncommitted`); - assert.deepStrictEqual(sessionFileSets.map(e => e.action.file.edit), gitDiffs); - assert.deepStrictEqual(uncommittedFileSets.map(e => e.action.file.edit), gitDiffs); + // Each compute pass lands as a single `changeset/contentChanged` + // envelope carrying the full file list. Walk the captured stream + // and reconstruct the per-changeset file lists to assert each + // matches the git service output. + const contentChanges = envelopes + .filter(e => e.action.type === ActionType.ChangesetContentChanged) as Array<{ channel: string; action: { files: Array<{ edit: unknown }> } }>; + const sessionContent = contentChanges.filter(e => e.channel === `${sessionUri.toString()}/changeset/session`); + const uncommittedContent = contentChanges.filter(e => e.channel === `${sessionUri.toString()}/changeset/uncommitted`); + assert.deepStrictEqual(sessionContent.at(-1)?.action.files.map(f => f.edit), gitDiffs); + assert.deepStrictEqual(uncommittedContent.at(-1)?.action.files.map(f => f.edit), gitDiffs); // The compute pass also persists the file list under the // legacy `'diffs'` slot so it survives restarts. The write @@ -335,7 +354,7 @@ suite.skip('AgentHostChangesetService', () => { }, } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())))); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())))); const sessionStr = sessionUri.toString(); localStateManager.createSession({ @@ -371,7 +390,7 @@ suite.skip('AgentHostChangesetService', () => { }, } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createSubscriptionService())); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService())); const sessionStr = sessionUri.toString(); localStateManager.createSession({ @@ -404,7 +423,7 @@ suite.skip('AgentHostChangesetService', () => { } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createSubscriptionService())); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService())); localStateManager.createSession({ resource: sessionUri.toString(), @@ -431,14 +450,15 @@ suite.skip('AgentHostChangesetService', () => { await diffsEmitted; // With no recorded edits, the edit-tracker aggregator returns an - // empty array — no `changeset/fileSet` envelopes are emitted. The - // important assertion is that we still ran the producer through - // to a `changeset/statusChanged → ready` envelope, which proves - // the fallback path executed without throwing. - const fileSets = envelopes + // empty array — the single `changeset/contentChanged` envelope + // carries an empty file list. The important assertion is that we + // still ran the producer through to a `changeset/statusChanged → + // ready` envelope, which proves the fallback path executed without + // throwing. + const contentChanges = envelopes .map(e => e.action) - .filter(a => a.type === ActionType.ChangesetFileSet); - assert.deepStrictEqual(fileSets, []); + .filter(a => a.type === ActionType.ChangesetContentChanged) as Array<{ files: unknown[] }>; + assert.deepStrictEqual(contentChanges.map(a => a.files), [[]]); const statusAction = envelopes .map(e => e.action) .find(a => a.type === ActionType.ChangesetStatusChanged); @@ -463,7 +483,7 @@ suite.skip('AgentHostChangesetService', () => { } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createSubscriptionService())); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService())); const sessionStr = sessionUri.toString(); localStateManager.createSession({ @@ -537,7 +557,7 @@ suite.skip('AgentHostChangesetService', () => { } as unknown as IAgentHostGitService; const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), createNullSessionDataService(), stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())))); + localStateManager, new NullLogService(), createNullSessionDataService(), stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())))); const sessionStr = sessionUri.toString(); localStateManager.createSession({ @@ -584,6 +604,7 @@ suite.skip('AgentHostChangesetService', () => { stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), + createOperationService(), subscriptionService, )); return { service, localStateManager, computes, subscriptions: subscriptionService.subscriptions }; @@ -904,6 +925,7 @@ suite.skip('AgentHostChangesetService', () => { createNoopGitService(), NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), + createOperationService(), subscriptionService, )); } @@ -995,10 +1017,10 @@ suite.skip('AgentHostChangesetService', () => { }); }); - test('per-turn URI streams incremental ChangesetFileSet / ChangesetFileRemoved as the same turn is recomputed', async () => { + test('per-turn URI streams a ChangesetContentChanged snapshot as the same turn is recomputed', async () => { // End-to-end variant exercising the real `computeTurnDiffs` path // — produces actual diff payloads from session-DB messages so - // `_publishChangesetDiffs` emits real per-file actions on each + // `_publishChangesetDiffs` emits a full content snapshot on each // recompute pass. const sessionDb = new SessionDatabase(':memory:'); disposables.add(toDisposable(() => sessionDb.close())); @@ -1010,6 +1032,7 @@ suite.skip('AgentHostChangesetService', () => { createNoopGitService(), NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), + createOperationService(), createSubscriptionService(buildTurnChangesetUri(sessionUri.toString(), 'turn-1')), )); @@ -1089,6 +1112,7 @@ suite.skip('AgentHostChangesetService', () => { 'mod': { parent: 'ref-orig', current: 'ref-mod' }, }), disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), + createOperationService(), createSubscriptionService(), )); @@ -1121,6 +1145,7 @@ suite.skip('AgentHostChangesetService', () => { // 'mod' is intentionally absent }), disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), + createOperationService(), createSubscriptionService(), )); @@ -1150,6 +1175,7 @@ suite.skip('AgentHostChangesetService', () => { 'mod': { parent: 'same-ref', current: 'same-ref' }, }), disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), + createOperationService(), createSubscriptionService(), )); @@ -1180,6 +1206,7 @@ suite.skip('AgentHostChangesetService', () => { 'mod': { parent: 'ref-orig', current: 'ref-mod' }, }), disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), + createOperationService(), createSubscriptionService(), )); diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts index a5b86e2d04d..9583cabf108 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts @@ -10,7 +10,7 @@ import { tmpdir } from 'os'; import { join } from '../../../../../base/common/path.js'; import { URI } from '../../../../../base/common/uri.js'; import { SubscribeResult } from '../../../common/state/protocol/commands.js'; -import type { ChangesetFileSetAction, SessionAddedParams } from '../../../common/state/sessionActions.js'; +import type { ChangesetContentChangedAction, SessionAddedParams } from '../../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registry.js'; import { dispatchTurnStarted, @@ -101,19 +101,30 @@ const hasGit = (() => { const editedFile = join(tmpRoot, 'from-terminal.txt'); dispatchTurnStarted(client, sessionUri, 'turn-1', `terminal-edit:${editedFile}`, 1); - // Wait for a `changeset/fileSet` action targeting the edited file. - // On macOS, git's `--show-toplevel` resolves symlinks (/var → - // /private/var) so the URI may differ in prefix; match by basename. - const fileSetNotif = await client.waitForNotification(n => { - if (!isActionNotification(n, 'changeset/fileSet')) { + // Wait for a `changeset/contentChanged` action whose file list + // includes the edited file. On macOS, git's `--show-toplevel` + // resolves symlinks (/var → /private/var) so the URI may differ in + // prefix; match by basename. + const fileUri = (edit: ChangesetContentChangedAction['files'][number]['edit']) => + edit.after?.uri ?? edit.before?.uri; + const matchesEditedFile = (action: ChangesetContentChangedAction) => + action.files.some(f => { + const u = fileUri(f.edit); + return typeof u === 'string' && u.endsWith('/from-terminal.txt'); + }); + const contentChangedNotif = await client.waitForNotification(n => { + if (!isActionNotification(n, 'changeset/contentChanged')) { return false; } - const action = getActionEnvelope(n).action as ChangesetFileSetAction; - const u = action.file.edit.after?.uri ?? action.file.edit.before?.uri; - return typeof u === 'string' && u.endsWith('/from-terminal.txt'); + return matchesEditedFile(getActionEnvelope(n).action as ChangesetContentChangedAction); }, 10_000); - const action = getActionEnvelope(fileSetNotif).action as ChangesetFileSetAction; - assert.ok(action.file.edit.after, 'expected after-side for newly added file'); - assert.ok(!action.file.edit.before, 'newly added file should have no before-side'); + const action = getActionEnvelope(contentChangedNotif).action as ChangesetContentChangedAction; + const file = action.files.find(f => { + const u = fileUri(f.edit); + return typeof u === 'string' && u.endsWith('/from-terminal.txt'); + }); + assert.ok(file, 'expected the edited file in the changeset content'); + assert.ok(file.edit.after, 'expected after-side for newly added file'); + assert.ok(!file.edit.before, 'newly added file should have no before-side'); }); });