diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index 99dae46519efb..615a47d033400 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -187,6 +187,12 @@ export interface ISessionDatabase extends IDisposable { */ getFirstTurnEventId(): Promise; + /** + * Returns whether the session database contains any persisted conversation + * turn, including host-injected local turns. + */ + hasConversationTurns(): Promise; + /** * Persists the JSON-serialized {@link UsageInfo} reported for a turn. * Idempotent — last writer wins per turn. diff --git a/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts b/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts index 65cd8d3f7b449..21f85d51b58b0 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts @@ -92,6 +92,8 @@ export interface IAgentHostCatalogReconciliationOptions { readonly canSchedule?: () => boolean; /** Cheap pre-check so a session whose source cannot resolve never opens local storage. */ readonly isSourceAvailable?: (registered: IRegisteredSession) => boolean; + /** Mirrors retroactive provisional markers into the owner process. */ + readonly onDidMarkSessionProvisional?: (session: string) => void; } export class AgentHostCatalogReconciliationService extends Disposable { @@ -108,6 +110,7 @@ export class AgentHostCatalogReconciliationService extends Disposable { private readonly _now: () => number; private readonly _canSchedule: () => boolean; private readonly _isSourceAvailable: (registered: IRegisteredSession) => boolean; + private readonly _onDidMarkSessionProvisional: (session: string) => void; private readonly _scheduledPass = this._register(new MutableDisposable()); private _scheduledPassKind: ScheduledPassKind | undefined; private _payloadDirtyMark: Promise | undefined; @@ -149,6 +152,7 @@ export class AgentHostCatalogReconciliationService extends Disposable { this._now = options.now ?? Date.now; this._canSchedule = options.canSchedule ?? (() => true); this._isSourceAvailable = options.isSourceAvailable ?? (() => true); + this._onDidMarkSessionProvisional = options.onDidMarkSessionProvisional ?? (() => { }); this._initialPayloadDirtyMarkPending = this._storageService.get(VERIFICATION_VERSION_STORAGE_KEY) !== CATALOG_VERIFICATION_VERSION; const lastVerification = this._storageService.get(LAST_VERIFICATION_STORAGE_KEY); this._lastCompatibilityVerification = typeof lastVerification === 'number' && Number.isFinite(lastVerification) && lastVerification <= this._now() ? lastVerification : 0; @@ -355,6 +359,35 @@ export class AgentHostCatalogReconciliationService extends Disposable { this._parkedSessions.delete(session); } + /** + * Records that an unresolvable session holds no conversation, so a later + * listing can hide it (#321269). Pre-existing orphans carry no marker, and + * nothing else would ever retract the catalog row the provider cannot vouch + * for. + * + * The emptiness evidence is only valid for the revision it was gathered + * against: a mutation or a concurrent materialization during source + * resolution can add turns or clear the marker, and an unconditional write + * would then re-mark a session that holds real work. The dirty marker is + * therefore re-read and a changed one abandons the write, matching how + * {@link _park} yields to the same race. + */ + private async _markEmptySourceUnresolvableAsProvisional(session: URI, database: AgentHostCatalogDatabaseReference | undefined, observedDirty: number | undefined): Promise { + const sessionKey = session.toString(); + try { + if (database && await database.object.hasConversationTurns()) { + return; + } + if (await this._catalogDatabase.getSessionV2PayloadDirty(sessionKey) !== observedDirty) { + return; + } + await this._catalogDatabase.setSessionProvisional(sessionKey, true); + this._onDidMarkSessionProvisional(sessionKey); + } catch (error) { + this._logService.warn(`[AgentHostCatalogReconciliation] Failed to confirm empty source-unresolvable session ${sessionKey}`, error); + } + } + private _runBatch( selected: readonly IRegisteredSession[], receiptBySession: ReadonlyMap, @@ -412,6 +445,7 @@ export class AgentHostCatalogReconciliationService extends Disposable { return { session: sessionKey, status: 'retry', reason: 'providerUnavailable' }; } if (error instanceof CatalogReconciliationSourceUnresolvableError) { + await this._markEmptySourceUnresolvableAsProvisional(session, database, observedDirty); return await this._park(sessionKey, observedDirty); } throw error; @@ -477,6 +511,7 @@ export class AgentHostCatalogReconciliationService extends Disposable { return { session: sessionKey, status: 'retry', reason: 'providerUnavailable' }; } if (sourceResult.status === 'sourceUnresolvable') { + await this._markEmptySourceUnresolvableAsProvisional(session, database, observedDirty); return await this._park(sessionKey, observedDirty); } if (token.isCancellationRequested) { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 46f52f5df94bd..ee61eb85cb862 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -832,6 +832,10 @@ export class AgentService extends Disposable implements IAgentService { ...options.catalogReconciliationOptions, canSchedule: () => this._startupSettled.isOpen() && this._isSessionCatalogEnabled(), isSourceAvailable: registered => !!this._providerService.getProvider(registered.provider), + onDidMarkSessionProvisional: session => { + this._provisionalSessionKeys.add(session); + this._invalidateSessionList(); + }, }, )); this._runWhenStartupSettled('catalog reconciliation', () => { @@ -1980,6 +1984,9 @@ export class AgentService extends Disposable implements IAgentService { } const metadata = await this._getCatalogReconciliationMetadata(agent, registered, () => this._isChatBacking(registered.session)); if (!metadata) { + if (!this._readableProviderCatalogs.has(registered.provider)) { + return { status: 'providerUnavailable' }; + } // The provider is registered but cannot vouch for this session, so // there is nothing authoritative to project. Reported distinctly from // an unregistered provider so reconciliation can park it instead of diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index 851831d9c5864..92bf04383500b 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -570,6 +570,13 @@ export class SessionDatabase implements ISessionDatabase { }); } + hasConversationTurns(): Promise { + return this._queueOperation(async db => { + const row = await dbGet(db, `SELECT EXISTS(SELECT 1 FROM turns LIMIT 1) AS has_turns, EXISTS(SELECT 1 FROM local_turns LIMIT 1) AS has_local_turns`, []); + return !!row?.has_turns || !!row?.has_local_turns; + }); + } + setTurnUsage(turnId: string, usage: string): Promise { return this._mutateTurnUsage(async db => { // Ensure the turn exists — lazily insert since the turn record may not diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index ecab41eba15a9..3bb6edd07d70e 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -19,6 +19,7 @@ export class TestSessionDatabase implements ISessionDatabase { private _catalogSyncSnapshot: ISessionCatalogSyncSnapshot | undefined; private readonly _drafts = new Map(); private readonly _reviewedFiles: IReviewedFileRecord[] = []; + private readonly _turns = new Set(); private readonly _localTurns = new Map(); private readonly _turnUsages = new Map(); private readonly _turnDelegations = new Map(); @@ -37,9 +38,12 @@ export class TestSessionDatabase implements ISessionDatabase { this._edits.push(edit); } - async createTurn(): Promise { } + async createTurn(turnId: string): Promise { + this._turns.add(turnId); + } async deleteTurn(turnId: string): Promise { + this._turns.delete(turnId); this._turnDelegations.delete(turnId); this._turnWorkspaceTransitions.delete(turnId); this._turnEventIds.delete(turnId); @@ -52,6 +56,7 @@ export class TestSessionDatabase implements ISessionDatabase { } async storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise { + this._turns.add(edit.turnId); const existingIndex = this._edits.findIndex(e => e.toolCallId === edit.toolCallId && e.filePath === edit.filePath); if (existingIndex >= 0) { this._edits[existingIndex] = edit; @@ -219,6 +224,7 @@ export class TestSessionDatabase implements ISessionDatabase { async setTurnEventId(turnId: string, eventId: string): Promise { this.setTurnEventIdCalls.push({ turnId, eventId }); + this._turns.add(turnId); this._turnEventIds.set(turnId, eventId); } @@ -230,13 +236,19 @@ export class TestSessionDatabase implements ISessionDatabase { async getFirstTurnEventId(): Promise { return undefined; } + async hasConversationTurns(): Promise { + return this._turns.size > 0 || this._localTurns.size > 0; + } + async setTurnUsage(turnId: string, usage: string): Promise { + this._turns.add(turnId); this._turnUsages.set(turnId, usage); } async getTurnUsages(): Promise> { return new Map(this._turnUsages); } async setTurnDelegation(turnId: string, delegation: string): Promise { + this._turns.add(turnId); this._turnDelegations.set(turnId, delegation); } @@ -252,11 +264,13 @@ export class TestSessionDatabase implements ISessionDatabase { } async setTurnWorkspaceTransition(turnId: string, transition: string): Promise { + this._turns.add(turnId); this._turnWorkspaceTransitions.set(turnId, transition); this._metadata.set(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, 'true'); } async setWorkspaceConversion(turnId: string, transition: string, metadata: Readonly>): Promise { + this._turns.add(turnId); for (const [key, value] of Object.entries(metadata)) { this._metadata.set(key, value); } @@ -289,11 +303,13 @@ export class TestSessionDatabase implements ISessionDatabase { async deleteAllTurns(): Promise { this.deleteAllTurnsCalls++; + this._turns.clear(); this._edits.length = 0; this._turnDelegations.clear(); this._turnWorkspaceTransitions.clear(); this._metadata.delete(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY); this._turnEventIds.clear(); + this._localTurns.clear(); } async insertLocalTurn(record: ILocalTurnRecord): Promise { @@ -310,6 +326,16 @@ export class TestSessionDatabase implements ISessionDatabase { } } async remapTurnIds(mapping: ReadonlyMap, eventIds?: ReadonlyMap): Promise { + for (const turnId of [...this._turns]) { + if (!mapping.has(turnId)) { + this._turns.delete(turnId); + } + } + for (const [oldId, newId] of mapping) { + if (this._turns.delete(oldId)) { + this._turns.add(newId); + } + } for (const turnId of [...this._turnDelegations.keys()]) { if (!mapping.has(turnId)) { this._turnDelegations.delete(turnId); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 72fae94e4b04a..a42507187af4f 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -4785,8 +4785,9 @@ suite('AgentService (node dispatcher)', () => { override async getSessionV2(session: string): Promise { const envelope = this._catalogs.get(session); const registered = await this.getSessionV2Registration(session); + const current = await super.getSessionV2(session); return envelope && registered - ? { ...registered, ...envelope, isChatBacking: isChatBackingPayload(envelope.payload), payloadDirty: 0 } + ? { ...registered, ...envelope, isChatBacking: isChatBackingPayload(envelope.payload), payloadDirty: current?.payloadDirty ?? 0 } : undefined; } } @@ -4806,7 +4807,7 @@ suite('AgentService (node dispatcher)', () => { } } - function centralData(modifiedTime: number, summary: string, ehcliAdoptable = false): AgentHostCatalogData { + function centralData(session: URI, modifiedTime: number, summary: string, ehcliAdoptable = false): AgentHostCatalogData { return { modifiedTime, summary, @@ -4814,7 +4815,7 @@ suite('AgentService (node dispatcher)', () => { isArchived: false, ...(ehcliAdoptable ? { _meta: { [SESSION_META_EHCLI_ADOPTABLE_KEY]: true } } : {}), workingDirectories: [], - chats: [], + chats: [{ uri: buildDefaultChatUri(session), order: 0, kind: 'default' }], }; } @@ -5172,14 +5173,14 @@ suite('AgentService (node dispatcher)', () => { startTime: 10, source: 'explicit', }, { checkTombstone: false }); - orchestratorDatabase.setCatalog(session, centralData(20, 'Central')); + orchestratorDatabase.setCatalog(session, centralData(session, 20, 'Central')); const backingSession = AgentSession.uri('copilot', 'central-backing'); await orchestratorDatabase.registerSessionV2(backingSession.toString(), { provider: 'copilot', startTime: 11, source: 'explicit', }, { checkTombstone: false }); - orchestratorDatabase.setCatalog(backingSession, { ...centralData(21, 'Backing'), isChatBacking: true }); + orchestratorDatabase.setCatalog(backingSession, { ...centralData(backingSession, 21, 'Backing'), isChatBacking: true }); let databaseOpens = 0; const sessionDataService: ISessionDataService = { ...createSessionDataService(), @@ -5248,7 +5249,7 @@ suite('AgentService (node dispatcher)', () => { modifiedTime: 10, source: 'explicit', }, { checkTombstone: false, provisional }); - const data = centralData(10, 'Session'); + const data = centralData(session, 10, 'Session'); orchestratorDatabase.setCatalog(session, data); await orchestratorDatabase.upsertSessionV2(catalogEnvelope(session, data), undefined); if (catalogReadable) { @@ -5260,10 +5261,9 @@ suite('AgentService (node dispatcher)', () => { return { orchestratorDatabase, session }; } - async function createCrashedService(orchestratorDatabase: CentralCatalogDatabase): Promise { - const svc = createCentralCatalogService(createSessionDataService(), orchestratorDatabase); + async function createCrashedService(orchestratorDatabase: CentralCatalogDatabase, sessionDataService = createSessionDataService(), agent: IAgent = disposables.add(new DeferredBackingAgent('copilot'))): Promise { + const svc = createCentralCatalogService(sessionDataService, orchestratorDatabase); await svc.whenCatalogReconciliationIdle(); - const agent = disposables.add(new DeferredBackingAgent('copilot')); registerTestAgentProvider(svc, agent); await waitForInitialProviderMigration(svc, agent); return svc; @@ -5287,22 +5287,88 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('keeps a registered session whose provider is merely unavailable', async () => { - // Same undescribable provider, but no provisional marker: this is a - // real session whose provider cannot answer right now, so it must - // stay listed and must not be reported as permanently absent. + async function runCatalogReconciliationPass(svc: AgentService): Promise { + const report = await (svc as unknown as { _catalogReconciliationService: { runPass(): Promise<{ readonly outcomes: readonly unknown[] }> } })._catalogReconciliationService.runPass(); + return report.outcomes; + } + + test('retroactively marks and hides a catalog-served unmarked crash orphan with empty local storage', async () => { const { orchestratorDatabase, session } = await seedCrashedProvisional(false); - const svc = await createCrashedService(orchestratorDatabase); + const svc = await createCrashedService(orchestratorDatabase, createNullSessionDataService()); - const listed = await svc.listSessions(); + const beforeReconciliation = await svc.listSessions(); + const outcomes = await runCatalogReconciliationPass(svc); + const afterReconciliation = await svc.listSessions(); const restoreError = await svc.restoreSession(session).then(() => undefined, err => err); assert.deepStrictEqual({ - listed: listed.map(metadata => metadata.session.toString()), + beforeReconciliation: beforeReconciliation.map(metadata => metadata.session.toString()), + outcomes, + provisionalMarkers: await orchestratorDatabase.listProvisionalSessions(), + afterReconciliation: afterReconciliation.map(metadata => metadata.session.toString()), restoreCode: restoreError instanceof ProtocolError ? restoreError.code : undefined, }, { + beforeReconciliation: [session.toString()], + outcomes: [{ session: session.toString(), status: 'retry', reason: 'sourceUnresolvable' }], + provisionalMarkers: [session.toString()], + afterReconciliation: [], + restoreCode: AHP_SESSION_NOT_FOUND, + }); + }); + + test('does not mark a session that gained conversation turns during source resolution', async () => { + // The emptiness evidence is gathered before the source resolves, so a + // mutation landing in that window must abandon the write: re-marking a + // session that now holds real work would hide it on the next listing. + class MutatingCatalogDatabase extends CentralCatalogDatabase { + private _bumped = false; + override async getSessionV2PayloadDirty(session: string): Promise { + const current = await super.getSessionV2PayloadDirty(session); + if (this._bumped) { + return current; + } + this._bumped = true; + await super.markSessionV2PayloadDirty(session); + return current; + } + } + const { orchestratorDatabase, session } = await seedCrashedProvisional(false, new MutatingCatalogDatabase(), 'crashed-raced-mutation'); + const svc = await createCrashedService(orchestratorDatabase, createNullSessionDataService()); + + const outcomes = await runCatalogReconciliationPass(svc); + const listed = await svc.listSessions(); + + assert.deepStrictEqual({ + outcomes, + provisionalMarkers: await orchestratorDatabase.listProvisionalSessions(), + listed: listed.map(metadata => metadata.session.toString()), + }, { + outcomes: [{ session: session.toString(), status: 'retry', reason: 'superseded' }], + provisionalMarkers: [], listed: [session.toString()], - restoreCode: JSON_RPC_INTERNAL_ERROR, + }); + }); + + test('keeps a catalog-served unmarked provider miss when local storage contains conversation turns', async () => { + const { orchestratorDatabase, session } = await seedCrashedProvisional(false, new CentralCatalogDatabase(), 'crashed-with-turn'); + const sessionData = createPerSessionDataService(); + await sessionData.database(session).createTurn('turn-1'); + const svc = await createCrashedService(orchestratorDatabase, sessionData.service); + + const beforeReconciliation = await svc.listSessions(); + const outcomes = await runCatalogReconciliationPass(svc); + const afterReconciliation = await svc.listSessions(); + + assert.deepStrictEqual({ + beforeReconciliation: beforeReconciliation.map(metadata => metadata.session.toString()), + outcomes, + provisionalMarkers: await orchestratorDatabase.listProvisionalSessions(), + afterReconciliation: afterReconciliation.map(metadata => metadata.session.toString()), + }, { + beforeReconciliation: [session.toString()], + outcomes: [{ session: session.toString(), status: 'retry', reason: 'sourceUnresolvable' }], + provisionalMarkers: [], + afterReconciliation: [session.toString()], }); }); @@ -5338,6 +5404,23 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('keeps the marked-orphan fast path independent of local turn storage', async () => { + const { orchestratorDatabase, session } = await seedCrashedProvisional(true, new CentralCatalogDatabase(), 'crashed-marked-fast-path'); + const data = centralData(session, 10, 'Session'); + orchestratorDatabase.setCatalog(session, data); + class ThrowingTurnInspectionDatabase extends TestSessionDatabase { + override async hasConversationTurns(): Promise { + throw new Error('marked suppression must not inspect turns'); + } + } + const svc = await createCrashedService(orchestratorDatabase, createSessionDataService(new ThrowingTurnInspectionDatabase())); + await (svc as unknown as { _whenProvisionalSessionKeysLoaded(): Promise })._whenProvisionalSessionKeysLoaded(); + + const listed = await svc.listSessions(); + + assert.deepStrictEqual(listed.map(metadata => metadata.session.toString()), []); + }); + test('suppresses a crash-orphaned session cached by a listing that raced the marker read', async () => { // A real marker read hits SQLite, so an early listing can be computed // and cached before any marker is known. The in-memory double always @@ -5358,8 +5441,10 @@ suite('AgentService (node dispatcher)', () => { // Deferred work settles only once startup is complete *and* a first // listing has been served, so mark it before awaiting below. svc.markStartupComplete(); - // Listed while the markers are still being read: listing fails open - // rather than hiding a session it cannot yet classify. + // The marker read is still in flight, so the listing cannot yet know + // this placeholder is empty and leaves it visible — failing open is + // deliberate, since hiding a real session costs more than showing a + // junk row for one listing. await markerReadHasStarted; const listedDuringRead = await svc.listSessions(); @@ -5375,13 +5460,11 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('keeps a materialized session whose provider catalog is not readable yet', async () => { - // The failure CCR identified: marker-clearing is best-effort, so a - // materialized session can still carry one. If its provider cannot - // answer yet it returns `undefined` *without throwing*, which is not - // evidence of absence — suppressing on that would hide real work and - // report it as never created. Mirrors Claude before its SDK is - // downloaded (#331648), which defers rather than failing. + test('keeps a provider miss whose provider catalog is not readable yet', async () => { + // The failure CCR identified: if a provider cannot answer yet it + // returns `undefined` *without throwing*, which is not evidence of + // absence. Mirrors Claude before its SDK is downloaded (#331648), + // which defers rather than failing. const { orchestratorDatabase, session } = await seedCrashedProvisional(true, new CentralCatalogDatabase(), 'crashed-unreadable-catalog', false); const svc = createCentralCatalogService(createSessionDataService(), orchestratorDatabase); await svc.whenCatalogReconciliationIdle(); @@ -5405,6 +5488,30 @@ suite('AgentService (node dispatcher)', () => { restoreCode: JSON_RPC_INTERNAL_ERROR, }); }); + + test('keeps an unmarked provider miss when the provider is unavailable', async () => { + const { orchestratorDatabase, session } = await seedCrashedProvisional(false, new CentralCatalogDatabase(), 'crashed-no-provider'); + const svc = createCentralCatalogService(createNullSessionDataService(), orchestratorDatabase); + await svc.whenCatalogReconciliationIdle(); + + const listed = await svc.listSessions(); + + assert.deepStrictEqual(listed.map(metadata => metadata.session.toString()), [session.toString()]); + }); + + test('keeps an unmarked provider miss when provider metadata throws', async () => { + class ThrowingMetadataAgent extends DeferredBackingAgent { + override async getChatMetadata(): Promise { + throw new Error('metadata failed'); + } + } + const { orchestratorDatabase, session } = await seedCrashedProvisional(false, new CentralCatalogDatabase(), 'crashed-throwing-provider'); + const svc = await createCrashedService(orchestratorDatabase, createNullSessionDataService(), disposables.add(new ThrowingMetadataAgent('copilot'))); + + const listed = await svc.listSessions(); + + assert.deepStrictEqual(listed.map(metadata => metadata.session.toString()), [session.toString()]); + }); }); test('recency discovery invalidates an overlapping central list', async () => { @@ -5416,7 +5523,7 @@ suite('AgentService (node dispatcher)', () => { modifiedTime: 10, source: 'explicit', }, { checkTombstone: false }); - orchestratorDatabase.setCatalog(session, centralData(10, 'Central')); + orchestratorDatabase.setCatalog(session, centralData(session, 10, 'Central')); const svc = createCentralCatalogService(createSessionDataService(), orchestratorDatabase); await svc.whenCatalogReconciliationIdle(); const agent = disposables.add(new CountingMetadataAgent('copilot')); @@ -5473,7 +5580,7 @@ suite('AgentService (node dispatcher)', () => { startTime: 10, source: 'explicit', }, { checkTombstone: false }); - orchestratorDatabase.setCatalog(session, centralData(20, 'Adoptable', true)); + orchestratorDatabase.setCatalog(session, centralData(session, 20, 'Adoptable', true)); let databaseOpens = 0; const sessionDataService: ISessionDataService = { ...createSessionDataService(), @@ -5495,7 +5602,7 @@ suite('AgentService (node dispatcher)', () => { getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); (svc as unknown as { _invalidateSessionList(): void })._invalidateSessionList(); const whileEnabled = await svc.listSessions(); - orchestratorDatabase.setCatalog(session, centralData(20, 'Adopted')); + orchestratorDatabase.setCatalog(session, centralData(session, 20, 'Adopted')); getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: false }); (svc as unknown as { _invalidateSessionList(): void })._invalidateSessionList(); const afterAdoption = await svc.listSessions(); @@ -5526,7 +5633,7 @@ suite('AgentService (node dispatcher)', () => { source: 'explicit', }, { checkTombstone: false }); } - orchestratorDatabase.setCatalog(centralSession, centralData(30, 'Central')); + orchestratorDatabase.setCatalog(centralSession, centralData(centralSession, 30, 'Central')); const databaseOpens: string[] = []; const fallbackDatabase = new TestSessionDatabase(); const devContainerWorktree = { version: 1, handle: '00000000-0000-4000-8000-000000000001' }; @@ -5580,7 +5687,7 @@ suite('AgentService (node dispatcher)', () => { source: 'explicit', }, { checkTombstone: false }); } - orchestratorDatabase.setCatalog(eligible, centralData(40, 'Available centrally')); + orchestratorDatabase.setCatalog(eligible, centralData(eligible, 40, 'Available centrally')); let databaseOpens = 0; const sessionDataService: ISessionDataService = { ...createSessionDataService(), @@ -5612,7 +5719,7 @@ suite('AgentService (node dispatcher)', () => { const session = await svc.createSession({ provider: 'copilot' }); await svc.whenCatalogReconciliationIdle(); orchestratorDatabase.setCatalog(session, { - ...centralData(20, 'Persisted title'), + ...centralData(session, 20, 'Persisted title'), workingDirectories: ['file:///persisted'], changes: { files: 1 }, }); @@ -5652,7 +5759,7 @@ suite('AgentService (node dispatcher)', () => { startTime: index, source: 'discovery', }, { checkTombstone: false }); - orchestratorDatabase.setCatalog(session, centralData(now - index, `Session ${index}`)); + orchestratorDatabase.setCatalog(session, centralData(session, now - index, `Session ${index}`)); } const svc = createCentralCatalogService(createSessionDataService(), orchestratorDatabase); await svc.whenCatalogReconciliationIdle(); diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index 134050ae65011..695b18a18cf58 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -507,6 +507,25 @@ suite('SessionDatabase', () => { db = disposables.add(await SessionDatabase.open(':memory:')); await db.deleteTurn('nonexistent'); // should not throw }); + + test('hasConversationTurns tracks persisted and local turns', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + const empty = await db.hasConversationTurns(); + await db.createTurn('turn-1'); + const afterTurn = await db.hasConversationTurns(); + await db.deleteAllTurns(); + const afterDeleteAll = await db.hasConversationTurns(); + await db.insertLocalTurn({ turnId: 'local-1', chatUri: 'chat', anchorTurnId: undefined, seq: 0, payload: '{}' }); + const afterLocalTurn = await db.hasConversationTurns(); + + assert.deepStrictEqual({ empty, afterTurn, afterDeleteAll, afterLocalTurn }, { + empty: false, + afterTurn: true, + afterDeleteAll: false, + afterLocalTurn: true, + }); + }); }); // ---- Turn event ids -------------------------------------------------