From 50031518b8b32ea83fdc69307d4550d3ecb1b42a Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Apr 2026 16:05:58 -0700 Subject: [PATCH 01/23] working changes to keep session title with panel and cli resume Co-authored-by: Copilot --- .../node/copilotcliSessionService.ts | 67 ++++++++++++++++--- .../test/copilotCliSessionService.spec.ts | 50 +++++++++++++- .../copilotcli/node/test/testHelpers.ts | 19 +++++- .../customSessionTitleServiceImpl.ts | 6 -- .../vscode-node/copilotCLIChatSessions.ts | 5 +- .../copilotCLIChatSessionsContribution.ts | 4 +- 6 files changed, 129 insertions(+), 22 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 3a47cf7d6ee94..b94a621ee5c58 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { internal, LocalSessionMetadata, Session, SessionContext, SessionEvent, SessionOptions, SweCustomAgent } from '@github/copilot/sdk'; +import type { internal, LocalSession, LocalSessionMetadata, Session, SessionContext, SessionEvent, SessionOptions, SweCustomAgent } from '@github/copilot/sdk'; import * as l10n from '@vscode/l10n'; import { createReadStream } from 'node:fs'; import { devNull } from 'node:os'; @@ -98,6 +98,7 @@ export interface ICopilotCLISessionService { // Session rename renameSession(sessionId: string, title: string): Promise; + updateSessionSummary(sessionId: string, title: string): Promise; // Session wrapper tracking getSession(options: IGetSessionOptions, token: CancellationToken): Promise | undefined>; @@ -110,6 +111,7 @@ export interface ICopilotCLISessionService { export const ICopilotCLISessionService = createServiceIdentifier('ICopilotCLISessionService'); const SESSION_SHUTDOWN_TIMEOUT_MS = 300 * 1000; +type LocalSessionWithTitleUpdates = Session & Pick; export class CopilotCLISessionService extends Disposable implements ICopilotCLISessionService { declare _serviceBrand: undefined; @@ -362,7 +364,9 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } public async getSessionTitle(sessionId: string, token: CancellationToken): Promise { - return this.getSessionTitleImpl(sessionId, undefined, token); + const sessionManager = await raceCancellation(this.getSessionManager(), token); + const metadata = sessionManager ? await raceCancellationError(sessionManager.getSessionMetadata({ sessionId }), token) : undefined; + return this.getSessionTitleImpl(sessionId, metadata, token); } /** @@ -371,10 +375,14 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS * If we have the metadata then use that over extracting label ourselves or using any cache. */ private async getSessionTitleImpl(sessionId: string, metadata: LocalSessionMetadata | undefined, token: CancellationToken): Promise { - // Always give preference to label defined by user, then title from CLI and finally label from prompt summary. This is to ensure that if user has renamed the session, we do not override that with title from CLI or label from prompt. - const accurateTitle = await this.customSessionTitleService.getCustomSessionTitle(sessionId) ?? - labelFromPrompt(this._sessionWrappers.get(sessionId)?.object.pendingPrompt ?? '') ?? - this._sessionWrappers.get(sessionId)?.object.title; + // Prefer SDK-backed data first. Local title storage remains only as a fallback for + // legacy sessions and for newly-created VS Code sessions that have not yet been + // materialized in the SDK. + const accurateTitle = + this._sessionWrappers.get(sessionId)?.object.title ?? + metadata?.name ?? + await this.customSessionTitleService.getCustomSessionTitle(sessionId) ?? + labelFromPrompt(this._sessionWrappers.get(sessionId)?.object.pendingPrompt ?? ''); if (accurateTitle) { return accurateTitle; @@ -426,7 +434,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS const id = metadata.sessionId; const startTime = metadata.startTime.getTime(); const endTime = metadata.modifiedTime.getTime(); - const label = await this.customSessionTitleService.getCustomSessionTitle(metadata.sessionId) ?? this._sessionWrappers.get(metadata.sessionId)?.object.title ?? this._sessionLabels.get(metadata.sessionId) ?? (metadata.summary ? labelFromPrompt(metadata.summary) : undefined); + const label = await this.getSessionTitleImpl(metadata.sessionId, metadata, token); // CLI adds `` tags to user prompt, this needs to be removed. // However in summary CLI can end up truncating the prompt and adding `... !diskSessionIds.has(session.object.sessionId)) .filter(session => session.object.status === ChatSessionStatus.InProgress) .map(async (session): Promise => { - const label = await this.customSessionTitleService.getCustomSessionTitle(session.object.sessionId) ?? labelFromPrompt(session.object.pendingPrompt ?? ''); + const label = session.object.title ?? await this.customSessionTitleService.getCustomSessionTitle(session.object.sessionId) ?? labelFromPrompt(session.object.pendingPrompt ?? ''); if (!label) { return; } @@ -548,12 +556,19 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS const sessionOptions = await this.createSessionsOptions({ ...options, mcpServers }); const sessionManager = await raceCancellationError(this.getSessionManager(), token); const sdkSession = await sessionManager.createSession({ ...sessionOptions, sessionId: options.sessionId }); - this._newSessionIds.delete(sdkSession.sessionId); + const wasNewSession = this._newSessionIds.delete(sdkSession.sessionId); // After the first session creation, the SDK's OTel TracerProvider is // initialized. Install the bridge processor so SDK-native spans flow // to the debug panel. this._installBridgeIfNeeded(); + + if (wasNewSession) { + const stagedTitle = await this.customSessionTitleService.getCustomSessionTitle(sdkSession.sessionId); + if (stagedTitle) { + await sdkSession.updateSessionSummary(stagedTitle); + } + } if (sessionOptions.copilotUrl) { sdkSession.setAuthInfo({ type: 'hmac', @@ -1110,9 +1125,39 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } } + private async withWritableSession(sessionId: string, operation: (sdkSession: LocalSessionWithTitleUpdates) => Promise): Promise { + let sessionManager: internal.LocalSessionManager | undefined; + let shouldCloseSession = false; + const sdkSession = (this._sessionWrappers.get(sessionId)?.object.sdkSession as LocalSessionWithTitleUpdates | undefined) ?? await (async () => { + sessionManager = await this.getSessionManager(); + const session = await sessionManager.getSession({ sessionId }, true) as LocalSessionWithTitleUpdates | undefined; + shouldCloseSession = !!session; + return session; + })(); + + if (!sdkSession) { + throw new Error(`Failed to update missing Copilot CLI session ${sessionId}.`); + } + + try { + return await operation(sdkSession); + } finally { + if (shouldCloseSession && sessionManager) { + await sessionManager.closeSession(sessionId).catch(error => { + this.logService.error(`[CopilotCLISession] Failed to close session ${sessionId} after updating title metadata: ${error}`); + }); + } + } + } + public async renameSession(sessionId: string, title: string): Promise { - await this.customSessionTitleService.setCustomSessionTitle(sessionId, title); - this._sessionLabels.set(sessionId, title); + await this.withWritableSession(sessionId, sdkSession => sdkSession.renameSession(title)); + this._sessionLabels.delete(sessionId); + this._onDidChangeSessions.fire(); + } + + public async updateSessionSummary(sessionId: string, title: string): Promise { + await this.withWritableSession(sessionId, sdkSession => sdkSession.updateSessionSummary(title)); this._onDidChangeSessions.fire(); } } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts index 66b87def8c573..9d90240d7850c 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts @@ -77,8 +77,11 @@ class NullChatSessionWorktreeService extends mock() class NullCustomSessionTitleService implements ICustomSessionTitleService { declare _serviceBrand: undefined; - async getCustomSessionTitle(_sessionId: string): Promise { return undefined; } - async setCustomSessionTitle(_sessionId: string, _title: string): Promise { } + private readonly titles = new Map(); + async getCustomSessionTitle(sessionId: string): Promise { return this.titles.get(sessionId); } + async setCustomSessionTitle(sessionId: string, title: string): Promise { + this.titles.set(sessionId, title); + } async generateSessionTitle(_sessionId: string, _request: { prompt?: string; command?: string }): Promise { return undefined; } } @@ -333,6 +336,49 @@ describe('CopilotCLISessionService', () => { }); }); + describe('CopilotCLISessionService.renameSession', () => { + it('renames an inactive session through copilot/sdk and stores the custom title', async () => { + const sessionId = 'rename-inactive'; + manager.sessions.set(sessionId, new MockCliSdkSession(sessionId, new Date())); + + await service.renameSession(sessionId, 'Renamed From VS Code'); + + expect(manager.sessions.get(sessionId)?.title).toBe('Renamed From VS Code'); + expect(await service.getSessionTitle(sessionId, CancellationToken.None)).toBe('Renamed From VS Code'); + }); + + it('renames an active wrapped session through copilot/sdk', async () => { + const session = await service.createSession({ sessionId: 'rename-active', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None); + + await service.renameSession(session.object.sessionId, 'Wrapped Session Name'); + + expect((session.object.sdkSession as MockCliSdkSession).title).toBe('Wrapped Session Name'); + expect(await service.getSessionTitle(session.object.sessionId, CancellationToken.None)).toBe('Wrapped Session Name'); + session.dispose(); + }); + + it('updates session summaries through copilot/sdk for untitled sessions', async () => { + const sessionId = 'summary-session'; + manager.sessions.set(sessionId, new MockCliSdkSession(sessionId, new Date())); + + await service.updateSessionSummary(sessionId, 'Generated Summary'); + + expect(manager.sessions.get(sessionId)?.summary).toBe('Generated Summary'); + expect(await service.getSessionTitle(sessionId, CancellationToken.None)).toBe('Generated Summary'); + }); + + it('syncs staged titles for newly created vscode sessions into copilot/sdk', async () => { + const sessionId = service.createNewSessionId(); + await (service as unknown as { customSessionTitleService: ICustomSessionTitleService }).customSessionTitleService.setCustomSessionTitle(sessionId, 'Staged Session Title'); + + const session = await service.createSession({ sessionId, ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None); + + expect((session.object.sdkSession as MockCliSdkSession).summary).toBe('Staged Session Title'); + expect(await service.getSessionTitle(sessionId, CancellationToken.None)).toBe('Staged Session Title'); + session.dispose(); + }); + }); + describe('CopilotCLISessionService.tryGetPartialSesionHistory', () => { it('reconstructs history from persisted files', async () => { tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-')); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts index 19ee9639ceb1c..cb0227f283fb0 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts @@ -19,6 +19,19 @@ export class MockCliSdkSession { public aborted = false; public messages: {}[] = []; public events: {}[] = []; + public title: string | undefined; + public name: string | undefined; + public readonly renameSession = async (name: string): Promise => { + this.title = name; + this.name = name; + this.summary = name; + }; + public readonly updateSessionSummary = async (summary: string): Promise => { + if (!this.name) { + this.title = summary; + } + this.summary = summary; + }; public summary?: string; constructor(public readonly sessionId: string, public readonly startTime: Date) { } getChatContextMessages(): Promise<{}[]> { return Promise.resolve(this.messages); } @@ -64,7 +77,11 @@ export class MockCliSdkSessionManager { return Promise.resolve(undefined); } listSessions() { - return Promise.resolve(Array.from(this.sessions.values()).map(s => ({ sessionId: s.sessionId, startTime: s.startTime, modifiedTime: s.startTime, summary: s.summary }))); + return Promise.resolve(Array.from(this.sessions.values()).map(s => ({ sessionId: s.sessionId, startTime: s.startTime, modifiedTime: s.startTime, summary: s.summary, name: s.name }))); + } + getSessionMetadata({ sessionId }: { sessionId: string }) { + const session = this.sessions.get(sessionId); + return Promise.resolve(session ? { sessionId: session.sessionId, startTime: session.startTime, modifiedTime: session.startTime, summary: session.summary, name: session.name, isRemote: false } : undefined); } deleteSession(id: string) { this.sessions.delete(id); return Promise.resolve(); } closeSession(_id: string) { return Promise.resolve(); } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/customSessionTitleServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/customSessionTitleServiceImpl.ts index 5ae0648820de9..628ba294bf833 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/customSessionTitleServiceImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/customSessionTitleServiceImpl.ts @@ -58,11 +58,6 @@ export class CustomSessionTitleService implements ICustomSessionTitleService { } public async generateSessionTitle(sessionId: string, request: { prompt?: string; command?: string }, token: CancellationToken): Promise { - const title = await this.getCustomSessionTitle(sessionId); - if (title) { - return title; - } - return this._keyedSessionGenerator.queue(sessionId, () => this.generateSessionTitleImpl(sessionId, request, token)); } @@ -80,7 +75,6 @@ export class CustomSessionTitleService implements ICustomSessionTitleService { }; const title = await titleProvider.provideChatTitle(fakeContext, token); if (title) { - await this.setCustomSessionTitle(sessionId, title); return title; } } catch (error) { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 27a48a7ab4a12..b1ae189d1338d 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -185,7 +185,10 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements const resource = SessionIdForCLI.getResource(sessionId); const session = controller.createChatSessionItem(resource, context.request.prompt ?? context.request.command ?? ''); this.customSessionTitleService.generateSessionTitle(sessionId, context.request, CancellationToken.None) - .then(() => { + .then(async title => { + if (title) { + await this.customSessionTitleService.setCustomSessionTitle(sessionId, title); + } // Given we're done generating a title, refresh the contents of this session so that the new title is picked up. if (this.controller.items.get(resource)) { this.refreshSession({ reason: 'update', sessionId }).catch(() => { /* expected if session was deleted */ }); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 7f381a538e635..4ed557ff7f8fa 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -1409,7 +1409,9 @@ export class CopilotCLIChatSessionParticipant extends Disposable { // If user has selected a repo, then update with repo information (right icons, etc). if (isUntitled) { void this.lockRepoOptionForSession(context, token); - this.customSessionTitleService.generateSessionTitle(session.object.sessionId, request, token).catch(ex => this.logService.error(ex, 'Failed to generate custom session title')); + this.customSessionTitleService.generateSessionTitle(session.object.sessionId, request, token) + .then(title => title ? this.copilotCLISessionService.updateSessionSummary(session.object.sessionId, title) : undefined) + .catch(ex => this.logService.error(ex, 'Failed to generate custom session title')); } const requestsForSession = this.pendingRequestBySession.get(session.object.sessionId) ?? new Set(); requestsForSession.add(request); From 701e10dde7aa2132e5c010e106a4f7042b273146 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Apr 2026 16:21:55 -0700 Subject: [PATCH 02/23] allow syncing of session title from side bar and editor tab Co-authored-by: Copilot --- .../copilotcli/node/copilotcliSessionService.ts | 1 + .../chatSessions/vscode-node/copilotCLIChatSessions.ts | 2 +- src/vs/workbench/api/browser/mainThreadChatSessions.ts | 6 ++++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index b94a621ee5c58..6566277251d11 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -89,6 +89,7 @@ export interface ICopilotCLISessionService { // Session metadata querying getSessionItem(sessionId: string, token: CancellationToken): Promise; + getSessionTitle(sessionId: string, token: CancellationToken): Promise; getAllSessions(token: CancellationToken): Promise; // SDK session management diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index b1ae189d1338d..d5e000654c759 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -531,7 +531,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements const folderRepo = await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token); const [history, title, optionGroups] = await Promise.all([ this.getSessionHistory(copilotcliSessionId, folderRepo, token), - this.customSessionTitleService.getCustomSessionTitle(copilotcliSessionId), + this.sessionService.getSessionTitle(copilotcliSessionId, token), this._optionGroupBuilder.buildExistingSessionInputStateGroups(resource, token), ]); diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 2a6a562efad8f..658d2df2f6dbb 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -478,6 +478,12 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes return existing; } + // Propagate a renamed item label to the open chat model so the chat editor tab + // and chat panel header reflect the new title. + if (existing && existing.label !== updated.label && this._chatService.getSession(resource)) { + this._chatService.setSessionTitle(resource, updated.label); + } + this._items.set(resource, updated); this._onDidChangeChatSessionItems.fire({ addedOrUpdated: [updated], From 372bb78a4bad0765a12fe88278614bede1b61c84 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Apr 2026 16:43:51 -0700 Subject: [PATCH 03/23] remove raceCanellation Co-authored-by: Copilot --- .../chatSessions/copilotcli/node/copilotcliSessionService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 6566277251d11..24ff297430cda 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -365,8 +365,8 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } public async getSessionTitle(sessionId: string, token: CancellationToken): Promise { - const sessionManager = await raceCancellation(this.getSessionManager(), token); - const metadata = sessionManager ? await raceCancellationError(sessionManager.getSessionMetadata({ sessionId }), token) : undefined; + const sessionManager = await this.getSessionManager(); + const metadata = await sessionManager.getSessionMetadata({ sessionId }); return this.getSessionTitleImpl(sessionId, metadata, token); } From 159d3e428a4a471408d384f7578a403ed0bff46b Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Apr 2026 16:51:09 -0700 Subject: [PATCH 04/23] Stop using for void Co-authored-by: Copilot --- .../chatSessions/copilotcli/node/copilotcliSessionService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 24ff297430cda..ce702e0294139 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -1126,7 +1126,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } } - private async withWritableSession(sessionId: string, operation: (sdkSession: LocalSessionWithTitleUpdates) => Promise): Promise { + private async withWritableSession(sessionId: string, operation: (sdkSession: LocalSessionWithTitleUpdates) => Promise): Promise { let sessionManager: internal.LocalSessionManager | undefined; let shouldCloseSession = false; const sdkSession = (this._sessionWrappers.get(sessionId)?.object.sdkSession as LocalSessionWithTitleUpdates | undefined) ?? await (async () => { @@ -1141,7 +1141,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } try { - return await operation(sdkSession); + await operation(sdkSession); } finally { if (shouldCloseSession && sessionManager) { await sessionManager.closeSession(sessionId).catch(error => { From 0a1c2f51bcfc197e909eb66cfaec66ff36aba753 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Apr 2026 17:01:07 -0700 Subject: [PATCH 05/23] better name Co-authored-by: Copilot --- .../copilotcli/node/copilotcliSessionService.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index ce702e0294139..499d643afac5a 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -1126,7 +1126,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } } - private async withWritableSession(sessionId: string, operation: (sdkSession: LocalSessionWithTitleUpdates) => Promise): Promise { + private async updateSdkSessionMetadata(sessionId: string, operation: (sdkSession: LocalSessionWithTitleUpdates) => Promise): Promise { let sessionManager: internal.LocalSessionManager | undefined; let shouldCloseSession = false; const sdkSession = (this._sessionWrappers.get(sessionId)?.object.sdkSession as LocalSessionWithTitleUpdates | undefined) ?? await (async () => { @@ -1152,13 +1152,13 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } public async renameSession(sessionId: string, title: string): Promise { - await this.withWritableSession(sessionId, sdkSession => sdkSession.renameSession(title)); + await this.updateSdkSessionMetadata(sessionId, sdkSession => sdkSession.renameSession(title)); this._sessionLabels.delete(sessionId); this._onDidChangeSessions.fire(); } public async updateSessionSummary(sessionId: string, title: string): Promise { - await this.withWritableSession(sessionId, sdkSession => sdkSession.updateSessionSummary(title)); + await this.updateSdkSessionMetadata(sessionId, sdkSession => sdkSession.updateSessionSummary(title)); this._onDidChangeSessions.fire(); } } From 41c78e8e5b4ae5ea7d1e4af5f9d58bc5f46c8e8e Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Apr 2026 17:34:12 -0700 Subject: [PATCH 06/23] stage title locally when SDK session isn't materialized yet Co-authored-by: Copilot --- .../copilotcli/node/copilotcliSessionService.ts | 16 +++++++++------- .../node/test/copilotCliSessionService.spec.ts | 6 +++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 499d643afac5a..b72ff872f9246 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -112,7 +112,6 @@ export interface ICopilotCLISessionService { export const ICopilotCLISessionService = createServiceIdentifier('ICopilotCLISessionService'); const SESSION_SHUTDOWN_TIMEOUT_MS = 300 * 1000; -type LocalSessionWithTitleUpdates = Session & Pick; export class CopilotCLISessionService extends Disposable implements ICopilotCLISessionService { declare _serviceBrand: undefined; @@ -1126,18 +1125,21 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } } - private async updateSdkSessionMetadata(sessionId: string, operation: (sdkSession: LocalSessionWithTitleUpdates) => Promise): Promise { + private async updateSdkSessionMetadata(sessionId: string, title: string, operation: (sdkSession: LocalSession) => Promise): Promise { let sessionManager: internal.LocalSessionManager | undefined; let shouldCloseSession = false; - const sdkSession = (this._sessionWrappers.get(sessionId)?.object.sdkSession as LocalSessionWithTitleUpdates | undefined) ?? await (async () => { + const sdkSession = (this._sessionWrappers.get(sessionId)?.object.sdkSession as LocalSession | undefined) ?? await (async () => { sessionManager = await this.getSessionManager(); - const session = await sessionManager.getSession({ sessionId }, true) as LocalSessionWithTitleUpdates | undefined; + const session = await sessionManager.getSession({ sessionId }, true) as LocalSession | undefined; shouldCloseSession = !!session; return session; })(); if (!sdkSession) { - throw new Error(`Failed to update missing Copilot CLI session ${sessionId}.`); + // SDK session not yet materialized (e.g. brand-new VS Code sessionId). + // Stage locally; `createSession` syncs it into the SDK once the session is created. + await this.customSessionTitleService.setCustomSessionTitle(sessionId, title); + return; } try { @@ -1152,13 +1154,13 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } public async renameSession(sessionId: string, title: string): Promise { - await this.updateSdkSessionMetadata(sessionId, sdkSession => sdkSession.renameSession(title)); + await this.updateSdkSessionMetadata(sessionId, title, sdkSession => sdkSession.renameSession(title)); this._sessionLabels.delete(sessionId); this._onDidChangeSessions.fire(); } public async updateSessionSummary(sessionId: string, title: string): Promise { - await this.updateSdkSessionMetadata(sessionId, sdkSession => sdkSession.updateSessionSummary(title)); + await this.updateSdkSessionMetadata(sessionId, title, sdkSession => sdkSession.updateSessionSummary(title)); this._onDidChangeSessions.fire(); } } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts index 9d90240d7850c..8a81f822de1fa 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts @@ -337,7 +337,7 @@ describe('CopilotCLISessionService', () => { }); describe('CopilotCLISessionService.renameSession', () => { - it('renames an inactive session through copilot/sdk and stores the custom title', async () => { + it('renames an inactive session through copilot/sdk', async () => { const sessionId = 'rename-inactive'; manager.sessions.set(sessionId, new MockCliSdkSession(sessionId, new Date())); @@ -352,7 +352,7 @@ describe('CopilotCLISessionService', () => { await service.renameSession(session.object.sessionId, 'Wrapped Session Name'); - expect((session.object.sdkSession as MockCliSdkSession).title).toBe('Wrapped Session Name'); + expect(manager.sessions.get(session.object.sessionId)?.title).toBe('Wrapped Session Name'); expect(await service.getSessionTitle(session.object.sessionId, CancellationToken.None)).toBe('Wrapped Session Name'); session.dispose(); }); @@ -373,7 +373,7 @@ describe('CopilotCLISessionService', () => { const session = await service.createSession({ sessionId, ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None); - expect((session.object.sdkSession as MockCliSdkSession).summary).toBe('Staged Session Title'); + expect(manager.sessions.get(sessionId)?.summary).toBe('Staged Session Title'); expect(await service.getSessionTitle(sessionId, CancellationToken.None)).toBe('Staged Session Title'); session.dispose(); }); From 279b49982beba328a025af149301ba3962ed587d Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Apr 2026 20:32:48 -0700 Subject: [PATCH 07/23] rename to fix test --- .../vscode-node/copilotCLIChatSessionsContribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 2e6d8d736113b..216f4003bd377 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -1455,7 +1455,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { if (isUntitled) { void this.lockRepoOptionForSession(context, token); this.customSessionTitleService.generateSessionTitle(session.object.sessionId, request, token) - .then(title => title ? this.copilotCLISessionService.updateSessionSummary(session.object.sessionId, title) : undefined) + .then(title => title ? this.sessionService.updateSessionSummary(session.object.sessionId, title) : undefined) .catch(ex => this.logService.error(ex, 'Failed to generate custom session title')); } const requestsForSession = this.pendingRequestBySession.get(session.object.sessionId) ?? new Set(); From 1ee441e3bcb8df8b399201c940ec67f5aae4e252 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 22 Apr 2026 21:21:46 -0700 Subject: [PATCH 08/23] futher unify title resolution through single resolver Co-authored-by: Copilot --- .../node/copilotcliSessionService.ts | 89 +++++++++---------- .../test/copilotCLIChatSessions.spec.ts | 1 + 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index b72ff872f9246..49ed9d73bf902 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -370,35 +370,49 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } /** - * Gets the session title. - * Always give preference to label defined by user, then title from CLI session object. - * If we have the metadata then use that over extracting label ourselves or using any cache. + * Single source of truth for both `getSessionTitle()` (editor/header) and + * `_getAllSessions()` (sidebar list) so the two surfaces never diverge. + * + * Precedence: + * 1. Explicit renamed title — active wrapper title, SDK `name`, or legacy custom title. + * 2. Cached derived label in `_sessionLabels` (from a previous history scan). + * 3. Pending prompt for in-flight new sessions. + * 4. Clean metadata `summary` (rejected if it looks truncated). + * 5. First user message from session history (cached on success). + * 6. Raw metadata `summary` as a display-only last resort (not cached). */ private async getSessionTitleImpl(sessionId: string, metadata: LocalSessionMetadata | undefined, token: CancellationToken): Promise { - // Prefer SDK-backed data first. Local title storage remains only as a fallback for - // legacy sessions and for newly-created VS Code sessions that have not yet been - // materialized in the SDK. - const accurateTitle = + const explicitTitle = this._sessionWrappers.get(sessionId)?.object.title ?? metadata?.name ?? - await this.customSessionTitleService.getCustomSessionTitle(sessionId) ?? - labelFromPrompt(this._sessionWrappers.get(sessionId)?.object.pendingPrompt ?? ''); + await this.customSessionTitleService.getCustomSessionTitle(sessionId); + if (explicitTitle) { + return explicitTitle; + } + + const cached = this._sessionLabels.get(sessionId); + if (cached) { + return cached; + } - if (accurateTitle) { - return accurateTitle; + const pendingLabel = labelFromPrompt(this._sessionWrappers.get(sessionId)?.object.pendingPrompt ?? ''); + if (pendingLabel) { + return pendingLabel; } const summarizedTitle = labelFromPrompt(metadata?.summary ?? ''); - if (summarizedTitle) { - if (summarizedTitle.endsWith('...')) { - // If the SDK is going to just give us a truncated version of the first user message as the summary, then we might as well extract the label ourselves from the first user message instead of using the truncated summary. - } else { - return summarizedTitle; - } + if (summarizedTitle && !summarizedTitle.endsWith('...') && !summarizedTitle.includes('<')) { + return summarizedTitle; } const firstUserMessage = await this.getFirstUserMessageFromSession(sessionId, token); - return labelFromPrompt(firstUserMessage ?? ''); + const fromHistory = labelFromPrompt(firstUserMessage ?? ''); + if (fromHistory) { + this._sessionLabels.set(sessionId, fromHistory); + return fromHistory; + } + + return metadata?.summary ?? ''; } @@ -435,34 +449,15 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS const startTime = metadata.startTime.getTime(); const endTime = metadata.modifiedTime.getTime(); const label = await this.getSessionTitleImpl(metadata.sessionId, metadata, token); - // CLI adds `` tags to user prompt, this needs to be removed. - // However in summary CLI can end up truncating the prompt and adding `... { await this.updateSdkSessionMetadata(sessionId, title, sdkSession => sdkSession.updateSessionSummary(title)); + // Invalidate the derived-label cache so a subsequent title resolution + // can pick up the freshly-written summary instead of returning a stale + // label that was extracted from session history on a prior pass. + this._sessionLabels.delete(sessionId); this._onDidChangeSessions.fire(); } } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts index 28e14e0e2f385..f2ebfb8bbc70d 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts @@ -70,6 +70,7 @@ class TestSessionService extends mock() { override isNewSessionId = vi.fn(() => false); override deleteSession = vi.fn(async () => { }); override renameSession = vi.fn(async () => { }); + override getSessionTitle = vi.fn(async () => ''); override getSession = vi.fn(async () => ({ object: { sessionId: 'session-1', From 82c6dad36dd5b73bbf9ae31861a654313f9d5efc Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 23 Apr 2026 10:11:04 -0700 Subject: [PATCH 09/23] Support async terminal in cli chat panel Co-authored-by: Copilot --- .../copilotcli/node/copilotcliSession.ts | 125 ++++++++++++++++-- .../node/copilotcliSessionService.ts | 69 +++++++++- .../node/test/copilotcliSession.spec.ts | 1 + .../copilotcli/node/test/testHelpers.ts | 3 + .../vscode-node/copilotCLIChatSessions.ts | 13 +- .../api/browser/mainThreadChatSessions.ts | 28 +++- .../workbench/api/common/extHost.api.impl.ts | 4 + .../workbench/api/common/extHost.protocol.ts | 2 + .../api/common/extHostChatSessions.ts | 19 ++- .../vscode.proposed.chatSessionsProvider.d.ts | 36 +++++ 10 files changed, 288 insertions(+), 12 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index f476606bf274b..e4deb925839d5 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { Attachment, SendOptions, Session, SessionOptions } from '@github/copilot/sdk'; +import type { Attachment, SendOptions, Session, SessionOptions, SystemNotificationEvent } from '@github/copilot/sdk'; import * as l10n from '@vscode/l10n'; import * as cp from 'child_process'; import * as crypto from 'crypto'; @@ -117,6 +117,11 @@ function getPromptLabel(input: CopilotCLISessionInput): string { return input.prompt; } +export interface ICopilotCLISystemNotification { + readonly message: string; + readonly label: string; +} + export interface ICopilotCLISession extends IDisposable { readonly sessionId: string; readonly title?: string; @@ -124,13 +129,14 @@ export interface ICopilotCLISession extends IDisposable { readonly onDidChangeTitle: vscode.Event; readonly status: vscode.ChatSessionStatus | undefined; readonly onDidChangeStatus: vscode.Event; + readonly onDidReceiveSystemNotification: vscode.Event; readonly workspace: IWorkspaceInfo; readonly additionalWorkspaces: IWorkspaceInfo[]; readonly pendingPrompt: string | undefined; attachStream(stream: vscode.ChatResponseStream): IDisposable; setPermissionLevel(level: string | undefined): void; handleRequest( - request: { id: string; toolInvocationToken: ChatParticipantToolToken; sessionResource?: vscode.Uri }, + request: { id: string; toolInvocationToken: ChatParticipantToolToken; sessionResource?: vscode.Uri; isSystemInitiated?: boolean }, input: CopilotCLISessionInput, attachments: Attachment[], model: { model: string; reasoningEffort?: string } | undefined, @@ -166,6 +172,8 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } private _onDidChangeTitle = this.add(new Emitter()); public onDidChangeTitle = this._onDidChangeTitle.event; + private _onDidReceiveSystemNotification = this.add(new Emitter()); + public onDidReceiveSystemNotification = this._onDidReceiveSystemNotification.event; private _stream?: vscode.ChatResponseStream; private _toolInvocationToken?: ChatParticipantToolToken; public get sdkSession() { @@ -222,6 +230,57 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes super(); this.sessionId = _sdkSession.sessionId; this.add(toDisposable(() => this._todoSqlQuery.dispose())); + + // Forward SDK system notifications (async/detached shell completions, + // background agent completions, etc.) to consumers as a typed event. + // The chat sessions provider uses this to inject a system-initiated + // chat request via `vscode.chat.sendSystemInitiatedRequest` so the + // notification surfaces as a UI bubble and the SDK's auto-injected + // follow-up turn streams into a fresh chat response (issue #309290). + this.logService.info(`[anthony] [CopilotCLISession] subscribing to system.notification for session ${this.sessionId}`); + this.add(toDisposable(this._sdkSession.on('system.notification', (event: SystemNotificationEvent) => { + try { + this.logService.info(`[anthony] [CopilotCLISession] system.notification received for session ${this.sessionId}: kind=${(event?.data?.kind as { type?: string } | undefined)?.type ?? 'unknown'}`); + const notification = this._buildSystemNotification(event); + if (notification) { + this.logService.info(`[anthony] [CopilotCLISession] firing onDidReceiveSystemNotification for session ${this.sessionId}, label=${notification.label}`); + this._onDidReceiveSystemNotification.fire(notification); + } else { + this.logService.info(`[anthony] [CopilotCLISession] system.notification skipped (unsupported kind) for session ${this.sessionId}`); + } + } catch (err) { + this.logService.error(err, `[anthony] [CopilotCLISession] Failed to translate system.notification for session ${this.sessionId}`); + } + }))); + } + + private _buildSystemNotification(event: SystemNotificationEvent): ICopilotCLISystemNotification | undefined { + const data = event?.data; + const kind = data?.kind; + const message = data?.content; + if (!kind || typeof message !== 'string' || message.length === 0) { + return undefined; + } + const description = 'description' in kind ? kind.description : undefined; + const shellId = 'shellId' in kind ? kind.shellId : undefined; + let label: string | undefined; + switch (kind.type) { + case 'shell_completed': + case 'shell_detached_completed': + label = description + ? l10n.t("`{0}` completed", description) + : shellId + ? l10n.t("Shell `{0}` completed", shellId) + : l10n.t("Shell completed"); + break; + case 'agent_completed': + label = l10n.t("Background agent completed"); + break; + default: + // Skip event kinds we don't surface (agent_idle, new_inbox_message, etc.) + return undefined; + } + return { message, label }; } attachStream(stream: vscode.ChatResponseStream): IDisposable { @@ -267,7 +326,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes * When the session is idle, a normal full request is started instead. */ public async handleRequest( - request: { id: string; toolInvocationToken: ChatParticipantToolToken; sessionResource?: vscode.Uri }, + request: { id: string; toolInvocationToken: ChatParticipantToolToken; sessionResource?: vscode.Uri; isSystemInitiated?: boolean }, input: CopilotCLISessionInput, attachments: Attachment[], model: { model: string; reasoningEffort?: string } | undefined, @@ -288,10 +347,19 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes const handled = this._requestLogger.captureInvocation(capturingToken, async () => { await this.updateModel(model?.model, model?.reasoningEffort, authInfo, token); + if (request.isSystemInitiated) { + // System-initiated requests are triggered by `system.notification` + // events. The SDK has already received the notification and queued + // its own follow-up turn, so we must NOT call `_sdkSession.send()` + // again. Just attach our event listeners to a fresh chat response + // and stream the in-flight follow-up assistant turn into it. + return this._handleRequestImpl(request, input, attachments, model, token, /*isSystemInitiated*/ true); + } + if (isAlreadyBusyWithAnotherRequest) { return this._handleRequestSteering(input, attachments, model, previousRequestSnapshot, token); } else { - return this._handleRequestImpl(request, input, attachments, model, token); + return this._handleRequestImpl(request, input, attachments, model, token, /*isSystemInitiated*/ false); } }); @@ -352,7 +420,8 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes input: CopilotCLISessionInput, attachments: Attachment[], model: { model: string; reasoningEffort?: string } | undefined, - token: vscode.CancellationToken + token: vscode.CancellationToken, + isSystemInitiated: boolean ): Promise { const modelId = model?.model; const promptLabel = getPromptLabel(input); @@ -387,7 +456,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this._updateSdkTraceContext(traceparent); } try { - return await this._handleRequestImplInner(span, request, input, attachments, modelId, token); + return await this._handleRequestImplInner(span, request, input, attachments, modelId, token, isSystemInitiated); } finally { if (traceCtx && this._bridgeProcessor) { this._bridgeProcessor.unregisterTrace(traceCtx.traceId); @@ -407,7 +476,8 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes input: CopilotCLISessionInput, attachments: Attachment[], modelId: string | undefined, - token: vscode.CancellationToken + token: vscode.CancellationToken, + isSystemInitiated: boolean ): Promise { this.attachments.push(...attachments); const prompt = getPromptLabel(input); @@ -812,8 +882,18 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes }))); if (!token.isCancellationRequested) { - await this.sendRequestInternal(input, attachments, false, logStartTime); + if (isSystemInitiated) { + // The SDK has already enqueued the system notification and is + // running its own follow-up turn. We just wait for that turn + // to complete (next `session.idle`) so the listeners above + // can stream `assistant.message_delta` / `tool.execution_*` + // events into this fresh chat response. + await this._awaitNextSessionIdle(token); + } else { + await this.sendRequestInternal(input, attachments, false, logStartTime); + } } + this.logService.trace(`[CopilotCLISession] Invoking session (completed) ${this.sessionId}`); const resolvedToolIdEditMap: Record = {}; await Promise.all(Array.from(toolIdEditMap.entries()).map(async ([toolId, editFilePromise]) => { @@ -914,6 +994,35 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this.logService.error(error, '[CopilotCLISession] Failed to update artifacts'); }); } + /** + * Wait for the SDK to finish its current (or imminent) follow-up turn. + * + * Used when handling a system-initiated request: the SDK has already + * received a `system.notification`, will run its own follow-up turn (via + * `send({mode:'immediate', source:'system'})`), and we just need to keep + * our chat response open so listeners stream `assistant.message_delta` / + * `tool.execution_*` events into it. Resolves on the next + * `assistant.turn_end`, on cancellation, or after a safety timeout. + */ + private async _awaitNextSessionIdle(token: vscode.CancellationToken): Promise { + if (token.isCancellationRequested) { + return; + } + const SAFETY_TIMEOUT_MS = 5 * 60 * 1000; + const disposables = new DisposableStore(); + try { + await new Promise(resolve => { + const off = this._sdkSession.on('assistant.turn_end', () => resolve()); + disposables.add(toDisposable(off)); + disposables.add(token.onCancellationRequested(() => resolve())); + const timer = setTimeout(() => resolve(), SAFETY_TIMEOUT_MS); + disposables.add(toDisposable(() => clearTimeout(timer))); + }); + } finally { + disposables.dispose(); + } + } + /** * Sends a request to the underlying SDK session. * diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 49ed9d73bf902..dd66238fb3817 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -26,7 +26,7 @@ import { disposableTimeout, raceCancellation, raceCancellationError, SequencerBy import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; import { Emitter, Event } from '../../../../util/vs/base/common/event'; import { Lazy } from '../../../../util/vs/base/common/lazy'; -import { Disposable, DisposableMap, IDisposable, IReference, RefCountedDisposable, toDisposable } from '../../../../util/vs/base/common/lifecycle'; +import { Disposable, DisposableMap, IDisposable, IReference, MutableDisposable, RefCountedDisposable, toDisposable } from '../../../../util/vs/base/common/lifecycle'; import { basename, dirname, joinPath } from '../../../../util/vs/base/common/resources'; import { URI } from '../../../../util/vs/base/common/uri'; import { generateUuid } from '../../../../util/vs/base/common/uuid'; @@ -85,6 +85,14 @@ export interface ICopilotCLISessionService { onDidChangeSession: Event; onDidCreateSession: Event; + /** + * Fires when an SDK session emits a system notification (e.g. an async + * shell completes, a background agent finishes). Used by the chat sessions + * provider to inject a system-initiated chat request so the notification + * surfaces as a UI bubble (issue #309290). + */ + onDidReceiveSystemNotification: Event<{ readonly sessionId: string; readonly message: string; readonly label: string }>; + getSessionWorkingDirectory(sessionId: string): Uri | undefined; // Session metadata querying @@ -132,6 +140,9 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS private readonly _onDidCreateSession = this._register(new Emitter()); public readonly onDidCreateSession = this._onDidCreateSession.event; + private readonly _onDidReceiveSystemNotification = this._register(new Emitter<{ readonly sessionId: string; readonly message: string; readonly label: string }>()); + public readonly onDidReceiveSystemNotification = this._onDidReceiveSystemNotification.event; + private readonly _onDidCloseSession = this._register(new Emitter()); private readonly sessionTerminators = new DisposableMap(); @@ -1051,6 +1062,9 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS this.triggerOnDidChangeSessionItem(sdkSession.sessionId, 'statusChange'); this._onDidChangeSessions.fire(); })); + session.add(session.onDidReceiveSystemNotification(notification => { + this._onDidReceiveSystemNotification.fire({ sessionId: sdkSession.sessionId, ...notification }); + })); session.add(toDisposable(() => { this._sessionWrappers.deleteAndLeak(sdkSession.sessionId); this.sessionMutexForGetSession.delete(sdkSession.sessionId); @@ -1090,6 +1104,59 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS const refCountedSession = new RefCountedSession(session); this._sessionWrappers.set(sdkSession.sessionId, refCountedSession); + + // Keep the `RefCountedSession` alive for a post-turn window so the SDK + // session (and its shell-completion polling loops) survives past + // `assistant.turn_end`. Without this, the per-request `DisposableStore` + // releases its single ref and the SDK session is closed before any + // `system.notification` for an async/detached shell completion can + // fire, so no fresh chat bubble appears (issue #309290). + // + // Note: we can't rely on `getBackgroundTasks()` here because the public + // task list only includes *detached* shells and background agents, not + // plain `mode: "async"` shells (they're tracked internally by + // `shellContext.currentExecutions`). A status-based window matches the + // existing `sessionTerminators` 5-minute lifetime and covers every + // async-completion path the SDK emits. + let hasKeepAliveRef = false; + const releaseKeepAlive = (reason: string) => { + if (hasKeepAliveRef) { + hasKeepAliveRef = false; + this.logService.info(`[anthony] [CopilotCLISession] releasing post-turn keep-alive ref for session ${sdkSession.sessionId} (${reason})`); + refCountedSession.release(); + } + }; + const keepAliveTimer = new MutableDisposable(); + session.add(keepAliveTimer); + session.add(toDisposable(() => releaseKeepAlive('session disposed'))); + session.add(session.onDidChangeStatus(() => { + const status = session.status; + this.logService.info(`[anthony] [CopilotCLISession] onDidChangeStatus for session ${sdkSession.sessionId}: status=${status}, permissionRequested=${session.permissionRequested}`); + if (session.permissionRequested) { + keepAliveTimer.clear(); + return; + } + if (status === undefined || status === ChatSessionStatus.Completed || status === ChatSessionStatus.Failed) { + if (!hasKeepAliveRef) { + refCountedSession.acquire(); + hasKeepAliveRef = true; + this.logService.info(`[anthony] [CopilotCLISession] acquired post-turn keep-alive ref for session ${sdkSession.sessionId}`); + } + keepAliveTimer.value = disposableTimeout(() => releaseKeepAlive('post-turn window elapsed'), SESSION_SHUTDOWN_TIMEOUT_MS); + } else { + // Session is busy again (new turn started); hold the ref and + // cancel the release timer. + keepAliveTimer.clear(); + } + })); + // Also log system.notification turnaround from the SDK so we can see + // the full path: status → notification received → forwarded. + session.add(toDisposable(sdkSession.on('session.background_tasks_changed', () => { + const tasks = sdkSession.getBackgroundTasks(); + const taskSummary = tasks.map(t => `${t.type}:${t.status}`).join(', '); + this.logService.info(`[anthony] [CopilotCLISession] background_tasks_changed for session ${sdkSession.sessionId}: [${taskSummary}]`); + }))); + return refCountedSession; } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts index b791740b78014..0d254f53241ba 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts @@ -134,6 +134,7 @@ class MockSdkSession { async setSelectedModel(model: string, _reasoningEffort?: string) { this._selectedModel = model; } async getEvents() { return []; } getPlanPath(): string | null { return null; } + getBackgroundTasks(): unknown[] { return []; } usage = { getMetrics: async () => ({ diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts index cb0227f283fb0..556415b6cf9c8 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts @@ -45,6 +45,9 @@ export class MockCliSdkSession { emit(event: string, args: { content: string | undefined }): void { this.emittedEvents.push({ event, content: args.content }); } + on(_event: string, _handler: (payload: unknown) => void): () => void { + return () => { /* no-op */ }; + } clearCustomAgent() { return; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 23cad19203e45..62f2a529901c7 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -150,6 +150,17 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements ) { super(); + // Forward SDK system notifications (async shell completed, etc.) into + // the chat panel as a system-initiated request so the user sees a UI + // chip + fresh response bubble (issue #309290). + this._register(this.sessionService.onDidReceiveSystemNotification(({ sessionId, message, label }) => { + const sessionResource = SessionIdForCLI.getResource(sessionId); + this.logService.info(`[anthony] [CopilotCLIChatSessionContentProvider] Sending system-initiated request for session ${sessionId}, scheme=${sessionResource.scheme}, label=${label}`); + vscode.chat.sendSystemInitiatedRequest(sessionResource, message, { systemInitiatedLabel: label }) + .then(() => this.logService.info(`[anthony] [CopilotCLIChatSessionContentProvider] system-initiated request accepted for session ${sessionId}`), + err => this.logService.error(err, `[anthony] [CopilotCLIChatSessionContentProvider] Failed to send system-initiated request for session ${sessionId}`)); + })); + let isRefreshing = false; const refreshSessions = async () => { if (isRefreshing) { @@ -854,7 +865,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { await this.handleDelegationToCloud(session.object, request, context, stream, token); } else { const { input, attachments } = await this.resolveInput(request, session.object, isNewSession, token); - await session.object.handleRequest(request, input, attachments, model, authInfo, token); + await session.object.handleRequest({ ...request, isSystemInitiated: request.isSystemInitiated }, input, attachments, model, authInfo, token); } return {}; diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 09acdfa10143d..d4dcdcf7b9f8f 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -28,7 +28,7 @@ import { IChatEditorOptions } from '../../contrib/chat/browser/widgetHosts/edito import { ChatEditorInput } from '../../contrib/chat/browser/widgetHosts/editor/chatEditorInput.js'; import { IChatRequestVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { IChatDebugService } from '../../contrib/chat/common/chatDebugService.js'; -import { IChatContentInlineReference, IChatDetail, IChatProgress, IChatService, IChatSessionTiming } from '../../contrib/chat/common/chatService/chatService.js'; +import { ChatRequestQueueKind, IChatContentInlineReference, IChatDetail, IChatProgress, IChatService, IChatSessionTiming } from '../../contrib/chat/common/chatService/chatService.js'; import { ChatSessionOptionsMap, ChatSessionStatus, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsService, ReadonlyChatSessionOptionsMap } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatModel } from '../../contrib/chat/common/model/chatModel.js'; @@ -1004,6 +1004,32 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat // throw new Error('Method not implemented.'); } + async $sendSystemInitiatedRequest(handle: number, sessionResource: UriComponents, message: string, options: { systemInitiatedLabel: string }): Promise { + const resource = URI.revive(sessionResource); + this._logService.info(`[anthony] [MainThreadChatSessions] $sendSystemInitiatedRequest handle=${handle} resource=${resource.toString()} label=${options.systemInitiatedLabel}`); + const ownedHandle = this._sessionTypeToHandle.get(resource.scheme); + if (ownedHandle !== handle) { + throw new Error(`sendSystemInitiatedRequest: extension does not own a chat session content provider for scheme '${resource.scheme}'`); + } + // Keep the chat model alive across the send so it isn't disposed if the + // user navigates away from the panel (mirrors RunInTerminalTool). + const sessionRef = this._chatService.acquireExistingSession(resource, 'MainThreadChatSessions#sendSystemInitiatedRequest'); + if (!sessionRef) { + this._logService.warn(`[anthony] [MainThreadChatSessions] $sendSystemInitiatedRequest: chat model not loaded for ${resource.toString()}; dropping request`); + return; + } + try { + const result = await this._chatService.sendRequest(resource, message, { + isSystemInitiated: true, + systemInitiatedLabel: options.systemInitiatedLabel, + queue: ChatRequestQueueKind.Steering, + }); + this._logService.info(`[anthony] [MainThreadChatSessions] $sendSystemInitiatedRequest result kind=${result.kind}`); + } finally { + sessionRef.dispose(); + } + } + $onDidChangeChatSessionProviderOptions(handle: number): void { let sessionType: string | undefined; for (const [type, h] of this._sessionTypeToHandle) { diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index e897e8057ad82..150a06d9740c8 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1668,6 +1668,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.registerChatSessionContentProvider(extension, scheme, chatParticipant, provider, capabilities); }, + sendSystemInitiatedRequest(sessionResource: vscode.Uri, message: string, options: vscode.SystemInitiatedChatRequestOptions): Thenable { + checkProposedApiEnabled(extension, 'chatSessionsProvider'); + return extHostChatSessions.sendSystemInitiatedRequest(extension, sessionResource, message, options); + }, registerChatOutputRenderer: (viewType: string, renderer: vscode.ChatOutputRenderer) => { checkProposedApiEnabled(extension, 'chatOutputRenderer'); return extHostChatOutputRenderer.registerChatOutputRenderer(extension, viewType, renderer); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index f03878df02b2a..87419e92b9275 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3715,6 +3715,8 @@ export interface MainThreadChatSessionsShape extends IDisposable { $handleProgressChunk(handle: number, sessionResource: UriComponents, requestId: string, chunks: (IChatProgressDto | [IChatProgressDto, number])[]): Promise; $handleAnchorResolve(handle: number, sessionResource: UriComponents, requestId: string, requestHandle: string, anchor: Dto): void; $handleProgressComplete(handle: number, sessionResource: UriComponents, requestId: string): void; + + $sendSystemInitiatedRequest(handle: number, sessionResource: UriComponents, message: string, options: { systemInitiatedLabel: string }): Promise; } export interface ExtHostChatSessionsShape { diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 38d3b8669c654..551dcd0f0d68c 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -16,7 +16,7 @@ import * as objects from '../../../base/common/objects.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { SymbolKind, SymbolKinds } from '../../../editor/common/languages.js'; -import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; +import { ExtensionIdentifier, IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { ILogService } from '../../../platform/log/common/log.js'; import { IChatRequestVariableEntry, IDiagnosticVariableEntryFilterData, ISymbolVariableEntry, PromptFileVariableKind, toPromptFileVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; @@ -629,6 +629,23 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }); } + sendSystemInitiatedRequest(extension: IExtensionDescription, sessionResource: vscode.Uri, message: string, options: vscode.SystemInitiatedChatRequestOptions): Promise { + let ownedHandle: number | undefined; + for (const [handle, entry] of this._chatSessionContentProviders) { + if (entry.chatSessionScheme === sessionResource.scheme && ExtensionIdentifier.equals(entry.extension.identifier, extension.identifier)) { + ownedHandle = handle; + break; + } + } + if (ownedHandle === undefined) { + return Promise.reject(new Error(`Extension '${extension.identifier.value}' has not registered a chat session content provider for scheme '${sessionResource.scheme}'`)); + } + if (!options || typeof options.systemInitiatedLabel !== 'string' || options.systemInitiatedLabel.length === 0) { + return Promise.reject(new Error(`sendSystemInitiatedRequest: 'systemInitiatedLabel' is required`)); + } + return this._proxy.$sendSystemInitiatedRequest(ownedHandle, sessionResource, message, { systemInitiatedLabel: options.systemInitiatedLabel }); + } + async $provideChatSessionContent(handle: number, sessionResourceComponents: UriComponents, context: ChatSessionContentContextDto, token: CancellationToken): Promise { const provider = this._chatSessionContentProviders.get(handle); if (!provider) { diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index fc12969f6bac6..1a0594528db60 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -579,6 +579,42 @@ declare module 'vscode' { * @returns A disposable that unregisters the provider when disposed. */ export function registerChatSessionContentProvider(scheme: string, provider: ChatSessionContentProvider, defaultChatParticipant: ChatParticipant, capabilities?: ChatSessionCapabilities): Disposable; + + /** + * Inject a system-initiated request into a chat session that this extension owns. + * + * Use this from a {@link ChatSessionContentProvider} to surface out-of-band events + * (for example async terminal command completion, background agent finishing, + * external notifications) as a new turn in the chat UI without requiring the user + * to type a message. + * + * The request is rendered as a compact system-notification bubble rather than a + * normal user message and is queued as a steering request so it preempts the + * currently active turn (if any). + * + * @param sessionResource The URI of the target chat session. Its scheme must + * match a {@link ChatSessionContentProvider} registered by this extension. + * @param message The message body sent to the session's participant. + * @param options Display and routing options for the system-initiated request. + * + * @returns A thenable that resolves once the request has been accepted and queued + * for the session participant. + */ + export function sendSystemInitiatedRequest(sessionResource: Uri, message: string, options: SystemInitiatedChatRequestOptions): Thenable; + } + + /** + * Display and routing options for {@link chat.sendSystemInitiatedRequest}. + */ + export interface SystemInitiatedChatRequestOptions { + /** + * Short human-readable label describing the system event that triggered this + * request. Rendered as a compact bubble in the chat UI in place of a user + * message. + * + * Examples: ``"`sleep 10` completed"``, `"Background agent finished"`. + */ + readonly systemInitiatedLabel: string; } export interface ChatContext { From 4971ba66e30311cc1aa8b01a612e8226e0fbcb6c Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 23 Apr 2026 13:00:02 -0700 Subject: [PATCH 10/23] stop using unknown in test Co-authored-by: Copilot --- .../copilotcli/node/test/copilotcliSession.spec.ts | 4 ++-- .../chatSessions/copilotcli/node/test/testHelpers.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts index 0d254f53241ba..ce4cb72c38c19 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { Session, SessionOptions } from '@github/copilot/sdk'; +import type { BackgroundTask, Session, SessionOptions } from '@github/copilot/sdk'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ChatParticipantToolToken } from 'vscode'; import { ConfigKey, IConfigurationService } from '../../../../../platform/configuration/common/configurationService'; @@ -134,7 +134,7 @@ class MockSdkSession { async setSelectedModel(model: string, _reasoningEffort?: string) { this._selectedModel = model; } async getEvents() { return []; } getPlanPath(): string | null { return null; } - getBackgroundTasks(): unknown[] { return []; } + getBackgroundTasks(): BackgroundTask[] { return []; } usage = { getMetrics: async () => ({ diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts index 556415b6cf9c8..c35d6c3358423 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { SessionOptions, SweCustomAgent } from '@github/copilot/sdk'; +import type { SessionEvent, SessionOptions, SweCustomAgent } from '@github/copilot/sdk'; import type { CancellationToken, Uri } from 'vscode'; import { Event } from '../../../../../util/vs/base/common/event'; import { Disposable, IDisposable } from '../../../../../util/vs/base/common/lifecycle'; @@ -45,7 +45,7 @@ export class MockCliSdkSession { emit(event: string, args: { content: string | undefined }): void { this.emittedEvents.push({ event, content: args.content }); } - on(_event: string, _handler: (payload: unknown) => void): () => void { + on(_event: string, _handler: (payload: SessionEvent) => void): () => void { return () => { /* no-op */ }; } clearCustomAgent() { From 6576f4f1a53d7fb27714e152ac7851a1097d0d5e Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 23 Apr 2026 13:08:24 -0700 Subject: [PATCH 11/23] clean up anthony's comment Co-authored-by: Copilot --- .../copilotcli/node/copilotcliSession.ts | 7 +------ .../copilotcli/node/copilotcliSessionService.ts | 16 +++------------- .../vscode-node/copilotCLIChatSessions.ts | 4 +--- .../api/browser/mainThreadChatSessions.ts | 6 ++---- 4 files changed, 7 insertions(+), 26 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index c5fd5da18e66d..52cc5182cdf6b 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -230,19 +230,14 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // chat request via `vscode.chat.sendSystemInitiatedRequest` so the // notification surfaces as a UI bubble and the SDK's auto-injected // follow-up turn streams into a fresh chat response (issue #309290). - this.logService.info(`[anthony] [CopilotCLISession] subscribing to system.notification for session ${this.sessionId}`); this.add(toDisposable(this._sdkSession.on('system.notification', (event: SystemNotificationEvent) => { try { - this.logService.info(`[anthony] [CopilotCLISession] system.notification received for session ${this.sessionId}: kind=${(event?.data?.kind as { type?: string } | undefined)?.type ?? 'unknown'}`); const notification = this._buildSystemNotification(event); if (notification) { - this.logService.info(`[anthony] [CopilotCLISession] firing onDidReceiveSystemNotification for session ${this.sessionId}, label=${notification.label}`); this._onDidReceiveSystemNotification.fire(notification); - } else { - this.logService.info(`[anthony] [CopilotCLISession] system.notification skipped (unsupported kind) for session ${this.sessionId}`); } } catch (err) { - this.logService.error(err, `[anthony] [CopilotCLISession] Failed to translate system.notification for session ${this.sessionId}`); + this.logService.error(err, `[CopilotCLISession] Failed to translate system.notification for session ${this.sessionId}`); } }))); } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index dd66238fb3817..36ecebeb1aeda 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -1119,19 +1119,17 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS // existing `sessionTerminators` 5-minute lifetime and covers every // async-completion path the SDK emits. let hasKeepAliveRef = false; - const releaseKeepAlive = (reason: string) => { + const releaseKeepAlive = () => { if (hasKeepAliveRef) { hasKeepAliveRef = false; - this.logService.info(`[anthony] [CopilotCLISession] releasing post-turn keep-alive ref for session ${sdkSession.sessionId} (${reason})`); refCountedSession.release(); } }; const keepAliveTimer = new MutableDisposable(); session.add(keepAliveTimer); - session.add(toDisposable(() => releaseKeepAlive('session disposed'))); + session.add(toDisposable(releaseKeepAlive)); session.add(session.onDidChangeStatus(() => { const status = session.status; - this.logService.info(`[anthony] [CopilotCLISession] onDidChangeStatus for session ${sdkSession.sessionId}: status=${status}, permissionRequested=${session.permissionRequested}`); if (session.permissionRequested) { keepAliveTimer.clear(); return; @@ -1140,22 +1138,14 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS if (!hasKeepAliveRef) { refCountedSession.acquire(); hasKeepAliveRef = true; - this.logService.info(`[anthony] [CopilotCLISession] acquired post-turn keep-alive ref for session ${sdkSession.sessionId}`); } - keepAliveTimer.value = disposableTimeout(() => releaseKeepAlive('post-turn window elapsed'), SESSION_SHUTDOWN_TIMEOUT_MS); + keepAliveTimer.value = disposableTimeout(releaseKeepAlive, SESSION_SHUTDOWN_TIMEOUT_MS); } else { // Session is busy again (new turn started); hold the ref and // cancel the release timer. keepAliveTimer.clear(); } })); - // Also log system.notification turnaround from the SDK so we can see - // the full path: status → notification received → forwarded. - session.add(toDisposable(sdkSession.on('session.background_tasks_changed', () => { - const tasks = sdkSession.getBackgroundTasks(); - const taskSummary = tasks.map(t => `${t.type}:${t.status}`).join(', '); - this.logService.info(`[anthony] [CopilotCLISession] background_tasks_changed for session ${sdkSession.sessionId}: [${taskSummary}]`); - }))); return refCountedSession; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 16604412a7361..560cd4cfdef4f 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -153,10 +153,8 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements // chip + fresh response bubble (issue #309290). this._register(this.sessionService.onDidReceiveSystemNotification(({ sessionId, message, label }) => { const sessionResource = SessionIdForCLI.getResource(sessionId); - this.logService.info(`[anthony] [CopilotCLIChatSessionContentProvider] Sending system-initiated request for session ${sessionId}, scheme=${sessionResource.scheme}, label=${label}`); vscode.chat.sendSystemInitiatedRequest(sessionResource, message, { systemInitiatedLabel: label }) - .then(() => this.logService.info(`[anthony] [CopilotCLIChatSessionContentProvider] system-initiated request accepted for session ${sessionId}`), - err => this.logService.error(err, `[anthony] [CopilotCLIChatSessionContentProvider] Failed to send system-initiated request for session ${sessionId}`)); + .then(undefined, err => this.logService.error(err, `[CopilotCLIChatSessionContentProvider] Failed to send system-initiated request for session ${sessionId}`)); })); let isRefreshing = false; diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index d4dcdcf7b9f8f..681e7f5ba77ce 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -1006,7 +1006,6 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat async $sendSystemInitiatedRequest(handle: number, sessionResource: UriComponents, message: string, options: { systemInitiatedLabel: string }): Promise { const resource = URI.revive(sessionResource); - this._logService.info(`[anthony] [MainThreadChatSessions] $sendSystemInitiatedRequest handle=${handle} resource=${resource.toString()} label=${options.systemInitiatedLabel}`); const ownedHandle = this._sessionTypeToHandle.get(resource.scheme); if (ownedHandle !== handle) { throw new Error(`sendSystemInitiatedRequest: extension does not own a chat session content provider for scheme '${resource.scheme}'`); @@ -1015,16 +1014,15 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat // user navigates away from the panel (mirrors RunInTerminalTool). const sessionRef = this._chatService.acquireExistingSession(resource, 'MainThreadChatSessions#sendSystemInitiatedRequest'); if (!sessionRef) { - this._logService.warn(`[anthony] [MainThreadChatSessions] $sendSystemInitiatedRequest: chat model not loaded for ${resource.toString()}; dropping request`); + this._logService.warn(`[MainThreadChatSessions] $sendSystemInitiatedRequest: chat model not loaded for ${resource.toString()}; dropping request`); return; } try { - const result = await this._chatService.sendRequest(resource, message, { + await this._chatService.sendRequest(resource, message, { isSystemInitiated: true, systemInitiatedLabel: options.systemInitiatedLabel, queue: ChatRequestQueueKind.Steering, }); - this._logService.info(`[anthony] [MainThreadChatSessions] $sendSystemInitiatedRequest result kind=${result.kind}`); } finally { sessionRef.dispose(); } From f58b7fbb446405eb722a51dea2c92d32df89808a Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 23 Apr 2026 14:18:09 -0700 Subject: [PATCH 12/23] Wire system.notification listener for V1 (legacy) session controller path Co-authored-by: Copilot --- .../copilotcli/node/copilotcliSession.ts | 8 +++++++- .../copilotcli/node/copilotcliSessionService.ts | 6 ++++++ .../vscode-node/copilotCLIChatSessions.ts | 5 ++++- .../copilotCLIChatSessionsContribution.ts | 13 +++++++++++++ .../workbench/api/browser/mainThreadChatSessions.ts | 6 ++++-- 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 52cc5182cdf6b..e9fadd6813ce8 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -230,14 +230,20 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // chat request via `vscode.chat.sendSystemInitiatedRequest` so the // notification surfaces as a UI bubble and the SDK's auto-injected // follow-up turn streams into a fresh chat response (issue #309290). + this.logService.info(`[anthony] [CopilotCLISession] subscribing to system.notification for session ${this.sessionId}`); this.add(toDisposable(this._sdkSession.on('system.notification', (event: SystemNotificationEvent) => { try { + const kindType = (event?.data?.kind as { type?: string } | undefined)?.type ?? 'unknown'; + this.logService.info(`[anthony] [CopilotCLISession] system.notification received for session ${this.sessionId}: kind=${kindType}`); const notification = this._buildSystemNotification(event); if (notification) { + this.logService.info(`[anthony] [CopilotCLISession] firing onDidReceiveSystemNotification for session ${this.sessionId}, label=${notification.label}`); this._onDidReceiveSystemNotification.fire(notification); + } else { + this.logService.info(`[anthony] [CopilotCLISession] system.notification skipped (unsupported kind=${kindType}) for session ${this.sessionId}`); } } catch (err) { - this.logService.error(err, `[CopilotCLISession] Failed to translate system.notification for session ${this.sessionId}`); + this.logService.error(err, `[anthony] [CopilotCLISession] Failed to translate system.notification for session ${this.sessionId}`); } }))); } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 36ecebeb1aeda..27bfba2eb4b82 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -142,6 +142,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS private readonly _onDidReceiveSystemNotification = this._register(new Emitter<{ readonly sessionId: string; readonly message: string; readonly label: string }>()); public readonly onDidReceiveSystemNotification = this._onDidReceiveSystemNotification.event; + private readonly _instanceId = Math.random().toString(36).slice(2, 8); private readonly _onDidCloseSession = this._register(new Emitter()); private readonly sessionTerminators = new DisposableMap(); @@ -183,6 +184,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS @ICopilotCLIModels private readonly _copilotCLIModels: ICopilotCLIModels, ) { super(); + this.logService.info(`[anthony] [CopilotCLISessionService] CONSTRUCTED instance=${this._instanceId}`); this.showExternalSessions = this.configurationService.getConfig(ConfigKey.Advanced.CLIShowExternalSessions); this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(ConfigKey.Advanced.CLIShowExternalSessions.fullyQualifiedId)) { @@ -1063,6 +1065,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS this._onDidChangeSessions.fire(); })); session.add(session.onDidReceiveSystemNotification(notification => { + this.logService.info(`[anthony] [CopilotCLISessionService instance=${this._instanceId}] forwarding system notification for session ${sdkSession.sessionId}, label=${notification.label}`); this._onDidReceiveSystemNotification.fire({ sessionId: sdkSession.sessionId, ...notification }); })); session.add(toDisposable(() => { @@ -1121,6 +1124,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS let hasKeepAliveRef = false; const releaseKeepAlive = () => { if (hasKeepAliveRef) { + this.logService.info(`[anthony] [CopilotCLISessionService] releasing post-turn keep-alive ref for session ${sdkSession.sessionId}`); hasKeepAliveRef = false; refCountedSession.release(); } @@ -1130,12 +1134,14 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS session.add(toDisposable(releaseKeepAlive)); session.add(session.onDidChangeStatus(() => { const status = session.status; + this.logService.info(`[anthony] [CopilotCLISessionService] onDidChangeStatus for session ${sdkSession.sessionId}: status=${status}, permissionRequested=${session.permissionRequested}`); if (session.permissionRequested) { keepAliveTimer.clear(); return; } if (status === undefined || status === ChatSessionStatus.Completed || status === ChatSessionStatus.Failed) { if (!hasKeepAliveRef) { + this.logService.info(`[anthony] [CopilotCLISessionService] acquired post-turn keep-alive ref for session ${sdkSession.sessionId}`); refCountedSession.acquire(); hasKeepAliveRef = true; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 560cd4cfdef4f..3db4d677b4960 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -151,10 +151,13 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements // Forward SDK system notifications (async shell completed, etc.) into // the chat panel as a system-initiated request so the user sees a UI // chip + fresh response bubble (issue #309290). + this.logService.info(`[anthony] [CopilotCLIChatSessionContentProvider] CONSTRUCTED, subscribing to onDidReceiveSystemNotification on sessionService=${(this.sessionService as unknown as { _instanceId?: string })._instanceId ?? 'unknown'}`); this._register(this.sessionService.onDidReceiveSystemNotification(({ sessionId, message, label }) => { const sessionResource = SessionIdForCLI.getResource(sessionId); + this.logService.info(`[anthony] [CopilotCLIChatSessionContentProvider] sending system-initiated request for session ${sessionId}, label=${label}, resource=${sessionResource.toString()}`); vscode.chat.sendSystemInitiatedRequest(sessionResource, message, { systemInitiatedLabel: label }) - .then(undefined, err => this.logService.error(err, `[CopilotCLIChatSessionContentProvider] Failed to send system-initiated request for session ${sessionId}`)); + .then(() => this.logService.info(`[anthony] [CopilotCLIChatSessionContentProvider] system-initiated request resolved for session ${sessionId}`), + err => this.logService.error(err, `[anthony] [CopilotCLIChatSessionContentProvider] Failed to send system-initiated request for session ${sessionId}`)); })); let isRefreshing = false; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index b9bec5c4577b8..a017fb7d2372f 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -571,6 +571,19 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements @IChatFolderMruService private readonly folderMruService: IChatFolderMruService, ) { super(); + + // Forward SDK system notifications (async shell completed, etc.) into + // the chat panel as a system-initiated request so the user sees a UI + // chip + fresh response bubble (issue #309290). + this.logService.info(`[anthony] [CopilotCLIChatSessionContentProviderV1] CONSTRUCTED, subscribing to onDidReceiveSystemNotification`); + this._register(this.sessionService.onDidReceiveSystemNotification(({ sessionId, message, label }) => { + const sessionResource = SessionIdForCLI.getResource(sessionId); + this.logService.info(`[anthony] [CopilotCLIChatSessionContentProviderV1] sending system-initiated request for session ${sessionId}, label=${label}, resource=${sessionResource.toString()}`); + vscode.chat.sendSystemInitiatedRequest(sessionResource, message, { systemInitiatedLabel: label }) + .then(() => this.logService.info(`[anthony] [CopilotCLIChatSessionContentProviderV1] system-initiated request resolved for session ${sessionId}`), + err => this.logService.error(err, `[anthony] [CopilotCLIChatSessionContentProviderV1] Failed to send system-initiated request for session ${sessionId}`)); + })); + const originalRepos = this.getRepositoryOptionItems().length; this._register(this.gitService.onDidFinishInitialization(() => { if (originalRepos !== this.getRepositoryOptionItems().length) { diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 681e7f5ba77ce..b11da2c346781 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -1006,6 +1006,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat async $sendSystemInitiatedRequest(handle: number, sessionResource: UriComponents, message: string, options: { systemInitiatedLabel: string }): Promise { const resource = URI.revive(sessionResource); + this._logService.info(`[anthony] [MainThreadChatSessions] $sendSystemInitiatedRequest handle=${handle} resource=${resource.toString()} label=${options.systemInitiatedLabel}`); const ownedHandle = this._sessionTypeToHandle.get(resource.scheme); if (ownedHandle !== handle) { throw new Error(`sendSystemInitiatedRequest: extension does not own a chat session content provider for scheme '${resource.scheme}'`); @@ -1014,15 +1015,16 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat // user navigates away from the panel (mirrors RunInTerminalTool). const sessionRef = this._chatService.acquireExistingSession(resource, 'MainThreadChatSessions#sendSystemInitiatedRequest'); if (!sessionRef) { - this._logService.warn(`[MainThreadChatSessions] $sendSystemInitiatedRequest: chat model not loaded for ${resource.toString()}; dropping request`); + this._logService.warn(`[anthony] [MainThreadChatSessions] $sendSystemInitiatedRequest: chat model not loaded for ${resource.toString()}; dropping request`); return; } try { - await this._chatService.sendRequest(resource, message, { + const result = await this._chatService.sendRequest(resource, message, { isSystemInitiated: true, systemInitiatedLabel: options.systemInitiatedLabel, queue: ChatRequestQueueKind.Steering, }); + this._logService.info(`[anthony] [MainThreadChatSessions] $sendSystemInitiatedRequest result kind=${result?.kind}`); } finally { sessionRef.dispose(); } From 4c2390c9ebed52060fd3991a32158870c260314c Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 23 Apr 2026 14:29:31 -0700 Subject: [PATCH 13/23] try to clean up logs a bit Co-authored-by: Copilot --- .../chatSessions/copilotcli/node/copilotcliSession.ts | 10 +++------- .../copilotcli/node/copilotcliSessionService.ts | 6 ------ .../chatSessions/vscode-node/copilotCLIChatSessions.ts | 5 +---- .../vscode-node/copilotCLIChatSessionsContribution.ts | 5 +---- src/vs/workbench/api/browser/mainThreadChatSessions.ts | 6 ++---- 5 files changed, 7 insertions(+), 25 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index e9fadd6813ce8..62f41d60cbd20 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -230,20 +230,16 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // chat request via `vscode.chat.sendSystemInitiatedRequest` so the // notification surfaces as a UI bubble and the SDK's auto-injected // follow-up turn streams into a fresh chat response (issue #309290). - this.logService.info(`[anthony] [CopilotCLISession] subscribing to system.notification for session ${this.sessionId}`); + // notification surfaces as a UI bubble and the SDK's auto-injected + // follow-up turn streams into a fresh chat response (issue #309290). this.add(toDisposable(this._sdkSession.on('system.notification', (event: SystemNotificationEvent) => { try { - const kindType = (event?.data?.kind as { type?: string } | undefined)?.type ?? 'unknown'; - this.logService.info(`[anthony] [CopilotCLISession] system.notification received for session ${this.sessionId}: kind=${kindType}`); const notification = this._buildSystemNotification(event); if (notification) { - this.logService.info(`[anthony] [CopilotCLISession] firing onDidReceiveSystemNotification for session ${this.sessionId}, label=${notification.label}`); this._onDidReceiveSystemNotification.fire(notification); - } else { - this.logService.info(`[anthony] [CopilotCLISession] system.notification skipped (unsupported kind=${kindType}) for session ${this.sessionId}`); } } catch (err) { - this.logService.error(err, `[anthony] [CopilotCLISession] Failed to translate system.notification for session ${this.sessionId}`); + this.logService.error(err, `[CopilotCLISession] Failed to translate system.notification for session ${this.sessionId}`); } }))); } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 27bfba2eb4b82..36ecebeb1aeda 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -142,7 +142,6 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS private readonly _onDidReceiveSystemNotification = this._register(new Emitter<{ readonly sessionId: string; readonly message: string; readonly label: string }>()); public readonly onDidReceiveSystemNotification = this._onDidReceiveSystemNotification.event; - private readonly _instanceId = Math.random().toString(36).slice(2, 8); private readonly _onDidCloseSession = this._register(new Emitter()); private readonly sessionTerminators = new DisposableMap(); @@ -184,7 +183,6 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS @ICopilotCLIModels private readonly _copilotCLIModels: ICopilotCLIModels, ) { super(); - this.logService.info(`[anthony] [CopilotCLISessionService] CONSTRUCTED instance=${this._instanceId}`); this.showExternalSessions = this.configurationService.getConfig(ConfigKey.Advanced.CLIShowExternalSessions); this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(ConfigKey.Advanced.CLIShowExternalSessions.fullyQualifiedId)) { @@ -1065,7 +1063,6 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS this._onDidChangeSessions.fire(); })); session.add(session.onDidReceiveSystemNotification(notification => { - this.logService.info(`[anthony] [CopilotCLISessionService instance=${this._instanceId}] forwarding system notification for session ${sdkSession.sessionId}, label=${notification.label}`); this._onDidReceiveSystemNotification.fire({ sessionId: sdkSession.sessionId, ...notification }); })); session.add(toDisposable(() => { @@ -1124,7 +1121,6 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS let hasKeepAliveRef = false; const releaseKeepAlive = () => { if (hasKeepAliveRef) { - this.logService.info(`[anthony] [CopilotCLISessionService] releasing post-turn keep-alive ref for session ${sdkSession.sessionId}`); hasKeepAliveRef = false; refCountedSession.release(); } @@ -1134,14 +1130,12 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS session.add(toDisposable(releaseKeepAlive)); session.add(session.onDidChangeStatus(() => { const status = session.status; - this.logService.info(`[anthony] [CopilotCLISessionService] onDidChangeStatus for session ${sdkSession.sessionId}: status=${status}, permissionRequested=${session.permissionRequested}`); if (session.permissionRequested) { keepAliveTimer.clear(); return; } if (status === undefined || status === ChatSessionStatus.Completed || status === ChatSessionStatus.Failed) { if (!hasKeepAliveRef) { - this.logService.info(`[anthony] [CopilotCLISessionService] acquired post-turn keep-alive ref for session ${sdkSession.sessionId}`); refCountedSession.acquire(); hasKeepAliveRef = true; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 3db4d677b4960..560cd4cfdef4f 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -151,13 +151,10 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements // Forward SDK system notifications (async shell completed, etc.) into // the chat panel as a system-initiated request so the user sees a UI // chip + fresh response bubble (issue #309290). - this.logService.info(`[anthony] [CopilotCLIChatSessionContentProvider] CONSTRUCTED, subscribing to onDidReceiveSystemNotification on sessionService=${(this.sessionService as unknown as { _instanceId?: string })._instanceId ?? 'unknown'}`); this._register(this.sessionService.onDidReceiveSystemNotification(({ sessionId, message, label }) => { const sessionResource = SessionIdForCLI.getResource(sessionId); - this.logService.info(`[anthony] [CopilotCLIChatSessionContentProvider] sending system-initiated request for session ${sessionId}, label=${label}, resource=${sessionResource.toString()}`); vscode.chat.sendSystemInitiatedRequest(sessionResource, message, { systemInitiatedLabel: label }) - .then(() => this.logService.info(`[anthony] [CopilotCLIChatSessionContentProvider] system-initiated request resolved for session ${sessionId}`), - err => this.logService.error(err, `[anthony] [CopilotCLIChatSessionContentProvider] Failed to send system-initiated request for session ${sessionId}`)); + .then(undefined, err => this.logService.error(err, `[CopilotCLIChatSessionContentProvider] Failed to send system-initiated request for session ${sessionId}`)); })); let isRefreshing = false; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index a017fb7d2372f..31bc8d943d30a 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -575,13 +575,10 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements // Forward SDK system notifications (async shell completed, etc.) into // the chat panel as a system-initiated request so the user sees a UI // chip + fresh response bubble (issue #309290). - this.logService.info(`[anthony] [CopilotCLIChatSessionContentProviderV1] CONSTRUCTED, subscribing to onDidReceiveSystemNotification`); this._register(this.sessionService.onDidReceiveSystemNotification(({ sessionId, message, label }) => { const sessionResource = SessionIdForCLI.getResource(sessionId); - this.logService.info(`[anthony] [CopilotCLIChatSessionContentProviderV1] sending system-initiated request for session ${sessionId}, label=${label}, resource=${sessionResource.toString()}`); vscode.chat.sendSystemInitiatedRequest(sessionResource, message, { systemInitiatedLabel: label }) - .then(() => this.logService.info(`[anthony] [CopilotCLIChatSessionContentProviderV1] system-initiated request resolved for session ${sessionId}`), - err => this.logService.error(err, `[anthony] [CopilotCLIChatSessionContentProviderV1] Failed to send system-initiated request for session ${sessionId}`)); + .then(undefined, err => this.logService.error(err, `[CopilotCLIChatSessionContentProvider] Failed to send system-initiated request for session ${sessionId}`)); })); const originalRepos = this.getRepositoryOptionItems().length; diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index b11da2c346781..681e7f5ba77ce 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -1006,7 +1006,6 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat async $sendSystemInitiatedRequest(handle: number, sessionResource: UriComponents, message: string, options: { systemInitiatedLabel: string }): Promise { const resource = URI.revive(sessionResource); - this._logService.info(`[anthony] [MainThreadChatSessions] $sendSystemInitiatedRequest handle=${handle} resource=${resource.toString()} label=${options.systemInitiatedLabel}`); const ownedHandle = this._sessionTypeToHandle.get(resource.scheme); if (ownedHandle !== handle) { throw new Error(`sendSystemInitiatedRequest: extension does not own a chat session content provider for scheme '${resource.scheme}'`); @@ -1015,16 +1014,15 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat // user navigates away from the panel (mirrors RunInTerminalTool). const sessionRef = this._chatService.acquireExistingSession(resource, 'MainThreadChatSessions#sendSystemInitiatedRequest'); if (!sessionRef) { - this._logService.warn(`[anthony] [MainThreadChatSessions] $sendSystemInitiatedRequest: chat model not loaded for ${resource.toString()}; dropping request`); + this._logService.warn(`[MainThreadChatSessions] $sendSystemInitiatedRequest: chat model not loaded for ${resource.toString()}; dropping request`); return; } try { - const result = await this._chatService.sendRequest(resource, message, { + await this._chatService.sendRequest(resource, message, { isSystemInitiated: true, systemInitiatedLabel: options.systemInitiatedLabel, queue: ChatRequestQueueKind.Steering, }); - this._logService.info(`[anthony] [MainThreadChatSessions] $sendSystemInitiatedRequest result kind=${result?.kind}`); } finally { sessionRef.dispose(); } From 81d17eeb67bae28525d42d13ef2d32752a2ea057 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 23 Apr 2026 14:31:28 -0700 Subject: [PATCH 14/23] Try to fix test --- .../vscode-node/test/copilotCLIChatSessionParticipant.spec.ts | 1 + .../chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts index 24f16b7055438..0cea1c5c342ff 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts @@ -268,6 +268,7 @@ class TestCopilotCLISession extends CopilotCLISession { class FakeCopilotCLISessionService extends mock() { private _sessionWorkingDirs = new Map(); + override onDidReceiveSystemNotification = Event.None; override tryGetPartialSessionHistory: ICopilotCLISessionService['tryGetPartialSessionHistory'] = vi.fn(async () => undefined); override getSessionWorkingDirectory = vi.fn((sessionId: string): vscode.Uri | undefined => { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts index f2ebfb8bbc70d..364414a305830 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts @@ -63,6 +63,7 @@ class TestSessionService extends mock() { override onDidDeleteSession = Event.None; override onDidChangeSession = Event.None; override onDidCreateSession = Event.None; + override onDidReceiveSystemNotification = Event.None; override getSessionWorkingDirectory = vi.fn(() => undefined); override getSessionItem = vi.fn(async () => undefined); override getAllSessions = vi.fn(async () => [] as ICopilotCLISessionItem[]); From 7dbe0738c1e6790bd873ce8ee6f94f9071eec948 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 23 Apr 2026 14:36:47 -0700 Subject: [PATCH 15/23] tighten the wording in proposed api Co-authored-by: Copilot --- .../vscode.proposed.chatSessionsProvider.d.ts | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 1a0594528db60..1ec85a332e80d 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -581,38 +581,30 @@ declare module 'vscode' { export function registerChatSessionContentProvider(scheme: string, provider: ChatSessionContentProvider, defaultChatParticipant: ChatParticipant, capabilities?: ChatSessionCapabilities): Disposable; /** - * Inject a system-initiated request into a chat session that this extension owns. + * Inject a system-initiated request into a chat session owned by this extension. * - * Use this from a {@link ChatSessionContentProvider} to surface out-of-band events - * (for example async terminal command completion, background agent finishing, - * external notifications) as a new turn in the chat UI without requiring the user - * to type a message. + * Used by a {@link ChatSessionContentProvider} to surface out-of-band events + * (async terminal completion, background agent finishing, etc.) as a new turn in + * the chat UI without requiring the user to type a message. The request is queued + * as a steering request so it preempts the currently active turn. * - * The request is rendered as a compact system-notification bubble rather than a - * normal user message and is queued as a steering request so it preempts the - * currently active turn (if any). - * - * @param sessionResource The URI of the target chat session. Its scheme must - * match a {@link ChatSessionContentProvider} registered by this extension. + * @param sessionResource Uri of the target chat session. Its scheme must match a {@link ChatSessionContentProvider} registered by this extension. * @param message The message body sent to the session's participant. - * @param options Display and routing options for the system-initiated request. + * @param options Display and routing options for the request. * - * @returns A thenable that resolves once the request has been accepted and queued - * for the session participant. + * @returns A thenable that resolves once the request has been accepted and queued. */ export function sendSystemInitiatedRequest(sessionResource: Uri, message: string, options: SystemInitiatedChatRequestOptions): Thenable; } /** - * Display and routing options for {@link chat.sendSystemInitiatedRequest}. + * Options for {@link chat.sendSystemInitiatedRequest}. */ export interface SystemInitiatedChatRequestOptions { /** - * Short human-readable label describing the system event that triggered this - * request. Rendered as a compact bubble in the chat UI in place of a user - * message. - * - * Examples: ``"`sleep 10` completed"``, `"Background agent finished"`. + * Short human-readable label for the system event that triggered this request. + * Rendered as a compact system-notification chip in the chat transcript + * (e.g. ``"`sleep 10` completed"``). */ readonly systemInitiatedLabel: string; } From 913dcae6abc4098fde6121e7f3b43a31e6c6df70 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 23 Apr 2026 22:40:29 -0700 Subject: [PATCH 16/23] skip mid turn: sdk already injects notification Co-authored-by: Copilot --- .../chatSessions/copilotcli/node/copilotcliSession.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 62f41d60cbd20..30f9d5e8bacdc 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -230,10 +230,12 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // chat request via `vscode.chat.sendSystemInitiatedRequest` so the // notification surfaces as a UI bubble and the SDK's auto-injected // follow-up turn streams into a fresh chat response (issue #309290). - // notification surfaces as a UI bubble and the SDK's auto-injected - // follow-up turn streams into a fresh chat response (issue #309290). this.add(toDisposable(this._sdkSession.on('system.notification', (event: SystemNotificationEvent) => { try { + // Skip mid-turn: SDK already injects the notification inline; forwarding would duplicate it. + if (this._status === ChatSessionStatus.InProgress) { + return; + } const notification = this._buildSystemNotification(event); if (notification) { this._onDidReceiveSystemNotification.fire(notification); From f38308d191e5180ee93ea3ac1299695ff3dd5d45 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 23 Apr 2026 23:17:34 -0700 Subject: [PATCH 17/23] Inline keep-alive timeout; drop permissionRequested check Co-authored-by: Copilot --- .../copilotcli/node/copilotcliSessionService.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 478818d3f76b6..b0bbac40fc41b 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -1301,9 +1301,9 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS // Note: we can't rely on `getBackgroundTasks()` here because the public // task list only includes *detached* shells and background agents, not // plain `mode: "async"` shells (they're tracked internally by - // `shellContext.currentExecutions`). A status-based window matches the - // existing `sessionTerminators` 5-minute lifetime and covers every + // `shellContext.currentExecutions`). A status-based window covers every // async-completion path the SDK emits. + const KEEP_ALIVE_TIMEOUT_MS = 5 * 60 * 1000; let hasKeepAliveRef = false; const releaseKeepAlive = () => { if (hasKeepAliveRef) { @@ -1316,16 +1316,12 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS session.add(toDisposable(releaseKeepAlive)); session.add(session.onDidChangeStatus(() => { const status = session.status; - if (session.permissionRequested) { - keepAliveTimer.clear(); - return; - } if (status === undefined || status === ChatSessionStatus.Completed || status === ChatSessionStatus.Failed) { if (!hasKeepAliveRef) { refCountedSession.acquire(); hasKeepAliveRef = true; } - keepAliveTimer.value = disposableTimeout(releaseKeepAlive, SESSION_SHUTDOWN_TIMEOUT_MS); + keepAliveTimer.value = disposableTimeout(releaseKeepAlive, KEEP_ALIVE_TIMEOUT_MS); } else { // Session is busy again (new turn started); hold the ref and // cancel the release timer. From d09c10673d720d653225df0f176452897274bd0e Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Fri, 24 Apr 2026 09:49:18 -0700 Subject: [PATCH 18/23] temporarily ship the logs to save status Co-authored-by: Copilot --- .../copilotcli/node/copilotcliSession.ts | 14 ++++++++++++++ .../vscode-node/copilotCLIChatSessions.ts | 6 +++++- .../copilotCLIChatSessionsContribution.ts | 6 +++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 3a845ca18a453..0af627b964e93 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -245,6 +245,12 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this._missionControlApiClient = this.instantiationService.createInstance(MissionControlApiClient); this.add(toDisposable(() => this._todoSqlQuery.dispose())); + // [anthony] Session-lifetime wildcard trace so we can see every SDK event + // even after per-request listeners in _handleRequestImplInner are disposed. + this.add(toDisposable(this._sdkSession.on('*', (event: { type: string }) => { + this.logService.info(`[anthony] SDK event: type=${event.type} status=${this._status} session=${this.sessionId}`); + }))); + // Forward SDK system notifications (async/detached shell completions, // background agent completions, etc.) to consumers as a typed event. // The chat sessions provider uses this to inject a system-initiated @@ -253,13 +259,18 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // follow-up turn streams into a fresh chat response (issue #309290). this.add(toDisposable(this._sdkSession.on('system.notification', (event: SystemNotificationEvent) => { try { + this.logService.info(`[anthony] system.notification received: kind=${event?.data?.kind} status=${this._status} session=${this.sessionId}`); // Skip mid-turn: SDK already injects the notification inline; forwarding would duplicate it. if (this._status === ChatSessionStatus.InProgress) { + this.logService.info(`[anthony] system.notification SKIPPED (mid-turn) kind=${event?.data?.kind}`); return; } const notification = this._buildSystemNotification(event); if (notification) { + this.logService.info(`[anthony] system.notification FORWARDING label="${notification.label}" message="${notification.message.slice(0, 80)}"`); this._onDidReceiveSystemNotification.fire(notification); + } else { + this.logService.info(`[anthony] system.notification DROPPED (unhandled kind=${event?.data?.kind})`); } } catch (err) { this.logService.error(err, `[CopilotCLISession] Failed to translate system.notification for session ${this.sessionId}`); @@ -360,6 +371,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes const handled = this._requestLogger.captureInvocation(capturingToken, async () => { await this.updateModel(model?.model, model?.reasoningEffort, authInfo, token); + this.logService.info(`[anthony] handleRequest entry: id=${request.id} isSystemInitiated=${!!request.isSystemInitiated} status=${this._status} session=${this.sessionId}`); if (request.isSystemInitiated) { // System-initiated requests are triggered by `system.notification` // events. The SDK has already received the notification and queued @@ -909,7 +921,9 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // to complete (next `session.idle`) so the listeners above // can stream `assistant.message_delta` / `tool.execution_*` // events into this fresh chat response. + this.logService.info(`[anthony] system-initiated handler: awaiting next turn_end for session ${this.sessionId}`); await this._awaitNextSessionIdle(token); + this.logService.info(`[anthony] system-initiated handler: turn_end resolved for session ${this.sessionId}`); } else { await this.sendRequestInternal(input, attachments, false, logStartTime); } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 47e6e698ffa61..f239d8f858b2b 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -154,8 +154,12 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements // chip + fresh response bubble (issue #309290). this._register(this.sessionService.onDidReceiveSystemNotification(({ sessionId, message, label }) => { const sessionResource = SessionIdForCLI.getResource(sessionId); + this.logService.info(`[anthony] V2 sendSystemInitiatedRequest -> session=${sessionId} label="${label}"`); vscode.chat.sendSystemInitiatedRequest(sessionResource, message, { systemInitiatedLabel: label }) - .then(undefined, err => this.logService.error(err, `[CopilotCLIChatSessionContentProvider] Failed to send system-initiated request for session ${sessionId}`)); + .then( + () => this.logService.info(`[anthony] V2 sendSystemInitiatedRequest RESOLVED for session=${sessionId}`), + err => this.logService.error(err, `[anthony] V2 sendSystemInitiatedRequest FAILED for session ${sessionId}`), + ); })); let isRefreshing = false; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 8acff295cf347..00603ac7bd2a7 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -604,8 +604,12 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements // chip + fresh response bubble (issue #309290). this._register(this.sessionService.onDidReceiveSystemNotification(({ sessionId, message, label }) => { const sessionResource = SessionIdForCLI.getResource(sessionId); + this.logService.info(`[anthony] V1 sendSystemInitiatedRequest -> session=${sessionId} label="${label}"`); vscode.chat.sendSystemInitiatedRequest(sessionResource, message, { systemInitiatedLabel: label }) - .then(undefined, err => this.logService.error(err, `[CopilotCLIChatSessionContentProvider] Failed to send system-initiated request for session ${sessionId}`)); + .then( + () => this.logService.info(`[anthony] V1 sendSystemInitiatedRequest RESOLVED for session=${sessionId}`), + err => this.logService.error(err, `[anthony] V1 sendSystemInitiatedRequest FAILED for session ${sessionId}`), + ); })); const originalRepos = this.getRepositoryOptionItems().length; From 283a8eee385e41a0a145f4572d07167b793626b2 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Fri, 24 Apr 2026 11:21:26 -0700 Subject: [PATCH 19/23] temp resolve case where async after another async is blocked Co-authored-by: Copilot --- .../copilotcli/node/copilotcliSession.ts | 63 ++++++++++++++++--- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 0af627b964e93..6b581ee8df575 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -199,6 +199,15 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } private _lastUsedModel: string | undefined; private _permissionLevel: string | undefined; + /** + * True while we are inside `_handleRequestImplInner` for an + * `isSystemInitiated` request (the SDK's auto-injected follow-up turn + * triggered by a `system.notification`). The interactive confirmation + * widget rendered by `handleShellPermission` does not surface in this + * mode (no usable `toolInvocationToken` for system-initiated requests), + * so we auto-approve shell permissions to avoid hanging the SDK turn. + */ + private _isInSystemInitiatedTurn = false; private _pendingPrompt: string | undefined; private _bridgeProcessor: CopilotCliBridgeSpanProcessor | undefined; private readonly _todoSqlQuery = new TodoSqlQuery(); @@ -599,6 +608,27 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes return; } + // During a system-initiated follow-up turn (triggered by a shell + // completion `system.notification`), auto-approve `shell` + // permissions to keep chained async-shell flows working. + // + // The actual gap is: when our extension calls + // `toolsService.invokeTool('CoreTerminalConfirmationTool', + // { toolInvocationToken })` during a system-initiated chat + // request, the inline confirmation widget doesn't render in + // the response (or doesn't take user input back). The + // `invokeTool` call never resolves, so `respondToPermission` + // is never called and the SDK turn hangs forever. + // + // TODO: Until the chat platform supports interactive permission + // widgets inside system-initiated requests, we auto-approve + // here so the chained scenario from issue #309290 works. + if (this._isInSystemInitiatedTurn && permissionRequest.kind === 'shell') { + this.logService.info(`[anthony] permission auto-approved during system-initiated turn: kind=${permissionRequest.kind}`); + this._sdkSession.respondToPermission(requestId, { kind: 'approved' }); + return; + } + // Resolve tool call data for the permission request. const toolData = permissionRequest.toolCallId ? toolCalls.get(permissionRequest.toolCallId) : undefined; const pendingData = permissionRequest.toolCallId ? pendingToolInvocations.get(permissionRequest.toolCallId) : undefined; @@ -921,9 +951,14 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // to complete (next `session.idle`) so the listeners above // can stream `assistant.message_delta` / `tool.execution_*` // events into this fresh chat response. - this.logService.info(`[anthony] system-initiated handler: awaiting next turn_end for session ${this.sessionId}`); - await this._awaitNextSessionIdle(token); - this.logService.info(`[anthony] system-initiated handler: turn_end resolved for session ${this.sessionId}`); + this.logService.info(`[anthony] system-initiated handler: awaiting next session.idle for session ${this.sessionId}`); + this._isInSystemInitiatedTurn = true; + try { + await this._awaitNextSessionIdle(token); + } finally { + this._isInSystemInitiatedTurn = false; + } + this.logService.info(`[anthony] system-initiated handler: session.idle resolved for session ${this.sessionId}`); } else { await this.sendRequestInternal(input, attachments, false, logStartTime); } @@ -1030,14 +1065,22 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes }); } /** - * Wait for the SDK to finish its current (or imminent) follow-up turn. + * Wait for the SDK to finish all turns triggered by a system notification. * * Used when handling a system-initiated request: the SDK has already - * received a `system.notification`, will run its own follow-up turn (via - * `send({mode:'immediate', source:'system'})`), and we just need to keep - * our chat response open so listeners stream `assistant.message_delta` / - * `tool.execution_*` events into it. Resolves on the next - * `assistant.turn_end`, on cancellation, or after a safety timeout. + * received a `system.notification` and will run one or more follow-up + * turns (the model may emit further tool calls — including new async + * shell launches — that require additional turns before the SDK is + * actually done). We must keep our chat response open until the SDK + * itself signals it has nothing left to process, which is `session.idle`. + * + * Waiting on `assistant.turn_end` is incorrect — the SDK emits one + * `turn_end` per turn and may immediately start another, so resolving + * on the first `turn_end` would let `_isInSystemInitiatedTurn` clear + * before later in-flight tool permissions fire and would close the + * response stream prematurely. + * + * Resolves on `session.idle`, on cancellation, or after a safety timeout. */ private async _awaitNextSessionIdle(token: vscode.CancellationToken): Promise { if (token.isCancellationRequested) { @@ -1047,7 +1090,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes const disposables = new DisposableStore(); try { await new Promise(resolve => { - const off = this._sdkSession.on('assistant.turn_end', () => resolve()); + const off = this._sdkSession.on('session.idle', () => resolve()); disposables.add(toDisposable(off)); disposables.add(token.onCancellationRequested(() => resolve())); const timer = setTimeout(() => resolve(), SAFETY_TIMEOUT_MS); From 18773f570ae0bf4aa20205dcb30e378cc6487dec Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Fri, 24 Apr 2026 13:31:09 -0700 Subject: [PATCH 20/23] release cli session keep-alive ref on explicit deletion --- .../copilotcli/node/copilotcliSessionService.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index b0bbac40fc41b..e7cdf7f9c9ea4 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -26,7 +26,7 @@ import { disposableTimeout, raceCancellation, raceCancellationError, SequencerBy import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; import { Emitter, Event } from '../../../../util/vs/base/common/event'; import { Lazy } from '../../../../util/vs/base/common/lazy'; -import { Disposable, DisposableMap, IDisposable, IReference, MutableDisposable, RefCountedDisposable, toDisposable } from '../../../../util/vs/base/common/lifecycle'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, IReference, MutableDisposable, RefCountedDisposable, toDisposable } from '../../../../util/vs/base/common/lifecycle'; import { basename, dirname, joinPath } from '../../../../util/vs/base/common/resources'; import { URI } from '../../../../util/vs/base/common/uri'; import { generateUuid } from '../../../../util/vs/base/common/uuid'; @@ -314,6 +314,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS private _sessionManager: Lazy>; private _sessionWrappers = new DisposableMap(); + private _keepAliveDisposables = new DisposableMap(); private readonly _partialSessionHistories = new Map(); @@ -1274,6 +1275,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS })); session.add(toDisposable(() => { this._sessionWrappers.deleteAndLeak(sdkSession.sessionId); + this._keepAliveDisposables.deleteAndDispose(sdkSession.sessionId); this.sessionMutexForGetSession.delete(sdkSession.sessionId); (async () => { if (sdkSession.isAbortable()) { @@ -1312,8 +1314,10 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } }; const keepAliveTimer = new MutableDisposable(); - session.add(keepAliveTimer); - session.add(toDisposable(releaseKeepAlive)); + const keepAliveDisposable = new DisposableStore(); + keepAliveDisposable.add(keepAliveTimer); + keepAliveDisposable.add(toDisposable(releaseKeepAlive)); + this._keepAliveDisposables.set(sdkSession.sessionId, keepAliveDisposable); session.add(session.onDidChangeStatus(() => { const status = session.status; if (status === undefined || status === ChatSessionStatus.Completed || status === ChatSessionStatus.Failed) { @@ -1336,6 +1340,11 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS this._sessionLabels.delete(sessionId); this._partialSessionHistories.delete(sessionId); this._sessionWorkingDirectories.delete(sessionId); + // Release the post-turn keep-alive ref (if any) before disposing the + // session wrapper, so the underlying refcount can actually reach zero + // and the session disposes synchronously instead of being held alive + // by a pending keep-alive timer for up to KEEP_ALIVE_TIMEOUT_MS. + this._keepAliveDisposables.deleteAndDispose(sessionId); try { { const session = this._sessionWrappers.get(sessionId); From c14f0c57057514075734f8b2a4ce2d647d87c048 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Fri, 24 Apr 2026 15:13:05 -0700 Subject: [PATCH 21/23] please stop hanging --- .../copilotcli/node/copilotcliSession.ts | 75 +++++++++++++++---- 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 6b581ee8df575..a3b0349c08a79 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -269,11 +269,27 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this.add(toDisposable(this._sdkSession.on('system.notification', (event: SystemNotificationEvent) => { try { this.logService.info(`[anthony] system.notification received: kind=${event?.data?.kind} status=${this._status} session=${this.sessionId}`); - // Skip mid-turn: SDK already injects the notification inline; forwarding would duplicate it. - if (this._status === ChatSessionStatus.InProgress) { - this.logService.info(`[anthony] system.notification SKIPPED (mid-turn) kind=${event?.data?.kind}`); - return; - } + // NOTE: do NOT skip when `_status === InProgress`. In chained + // async-shell scenarios (e.g. async shell A done → handler still + // awaiting `session.idle` → assistant launches async shell B → + // SHELL_B_DONE arrives), `_status` is still `InProgress` from the + // first system-initiated turn. Skipping here would silently drop + // the second notification and the user never sees a follow-up bubble. + + // Mark this session as being in a system-initiated turn for as long + // as the SDK is reacting to the notification. The SDK auto-fires + // its own follow-up turn from `Session.send()` *before* vscode's + // `sendSystemInitiatedRequest` round-trips back into our + // `_handleRequestImpl`, so the flag has to be set here for the + // auto-approve in the `permission.requested` listener to take effect + // on tools the SDK schedules during that turn. + // + // We deliberately do NOT clear this flag on `session.idle` — a single + // notification can drive multiple SDK turns (turn 1 reads shell + // output, turn 2 launches a chained async shell), each ending in + // `session.idle`. The flag is cleared in `handleRequest` when the + // next non-system-initiated (user-typed) request arrives. + this._isInSystemInitiatedTurn = true; const notification = this._buildSystemNotification(event); if (notification) { this.logService.info(`[anthony] system.notification FORWARDING label="${notification.label}" message="${notification.message.slice(0, 80)}"`); @@ -285,6 +301,31 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this.logService.error(err, `[CopilotCLISession] Failed to translate system.notification for session ${this.sessionId}`); } }))); + + // Session-lifetime auto-approve for system-initiated turns. + // + // The per-request `permission.requested` listener registered inside + // `_handleRequestImplInner` is torn down when that handler returns + // (e.g. when `_awaitNextSessionIdle` resolves). But a single + // `system.notification` can drive multiple SDK turns — turn 1 reads + // shell output, turn 2 launches a chained async shell that requests + // a `shell` permission. By the time turn 2's `permission.requested` + // fires, the per-request listener is already gone and the SDK hangs + // forever waiting for `respondToPermission`. + // + // To keep chained async-shell flows working we install a session-wide + // listener that auto-approves *only* while `_isInSystemInitiatedTurn` + // is true. The flag is set when a `system.notification` arrives and + // cleared in `handleRequest` when a real user-typed request follows. + this.add(toDisposable(this._sdkSession.on('permission.requested', (event) => { + if (!this._isInSystemInitiatedTurn) { + return; // let the per-request listener handle it + } + const permissionRequest = event.data.permissionRequest; + const requestId = event.data.requestId; + this.logService.info(`[anthony] session-wide permission auto-approved during system-initiated turn: kind=${permissionRequest.kind}`); + this._sdkSession.respondToPermission(requestId, { kind: 'approved' }); + }))); } private _buildSystemNotification(event: SystemNotificationEvent): ICopilotCLISystemNotification | undefined { @@ -381,6 +422,11 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes await this.updateModel(model?.model, model?.reasoningEffort, authInfo, token); this.logService.info(`[anthony] handleRequest entry: id=${request.id} isSystemInitiated=${!!request.isSystemInitiated} status=${this._status} session=${this.sessionId}`); + if (!request.isSystemInitiated && this._isInSystemInitiatedTurn) { + // A real user-typed request closes the system-initiated window. + this.logService.info(`[anthony] handleRequest: clearing _isInSystemInitiatedTurn (user-initiated request arrived) session=${this.sessionId}`); + this._isInSystemInitiatedTurn = false; + } if (request.isSystemInitiated) { // System-initiated requests are triggered by `system.notification` // events. The SDK has already received the notification and queued @@ -623,9 +669,9 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // TODO: Until the chat platform supports interactive permission // widgets inside system-initiated requests, we auto-approve // here so the chained scenario from issue #309290 works. - if (this._isInSystemInitiatedTurn && permissionRequest.kind === 'shell') { - this.logService.info(`[anthony] permission auto-approved during system-initiated turn: kind=${permissionRequest.kind}`); - this._sdkSession.respondToPermission(requestId, { kind: 'approved' }); + if (this._isInSystemInitiatedTurn) { + // Session-wide listener handles auto-approve for system-initiated + // turns; don't double-respond here. return; } @@ -952,12 +998,13 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // can stream `assistant.message_delta` / `tool.execution_*` // events into this fresh chat response. this.logService.info(`[anthony] system-initiated handler: awaiting next session.idle for session ${this.sessionId}`); - this._isInSystemInitiatedTurn = true; - try { - await this._awaitNextSessionIdle(token); - } finally { - this._isInSystemInitiatedTurn = false; - } + // `_isInSystemInitiatedTurn` is set in the `system.notification` + // listener (which fires before this handler is even invoked) and + // cleared in `handleRequest` when the next user-typed request arrives. + // We do NOT touch it here because a single notification can drive + // multiple SDK turns and clearing on the first `session.idle` would + // break auto-approve for permissions raised by later turns. + await this._awaitNextSessionIdle(token); this.logService.info(`[anthony] system-initiated handler: session.idle resolved for session ${this.sessionId}`); } else { await this.sendRequestInternal(input, attachments, false, logStartTime); From b3ccdb44479c5c0b0d53fa2ec08d9a169924a6ff Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Sat, 25 Apr 2026 23:14:21 -0700 Subject: [PATCH 22/23] Use prompt instead of message Co-authored-by: Copilot --- src/vs/workbench/api/browser/mainThreadChatSessions.ts | 4 ++-- src/vs/workbench/api/common/extHost.api.impl.ts | 4 ++-- src/vs/workbench/api/common/extHost.protocol.ts | 2 +- src/vs/workbench/api/common/extHostChatSessions.ts | 4 ++-- src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 681e7f5ba77ce..2a78a01dbb90c 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -1004,7 +1004,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat // throw new Error('Method not implemented.'); } - async $sendSystemInitiatedRequest(handle: number, sessionResource: UriComponents, message: string, options: { systemInitiatedLabel: string }): Promise { + async $sendSystemInitiatedRequest(handle: number, sessionResource: UriComponents, prompt: string, options: { systemInitiatedLabel: string }): Promise { const resource = URI.revive(sessionResource); const ownedHandle = this._sessionTypeToHandle.get(resource.scheme); if (ownedHandle !== handle) { @@ -1018,7 +1018,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat return; } try { - await this._chatService.sendRequest(resource, message, { + await this._chatService.sendRequest(resource, prompt, { isSystemInitiated: true, systemInitiatedLabel: options.systemInitiatedLabel, queue: ChatRequestQueueKind.Steering, diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 150a06d9740c8..6e30b9bb54e71 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1668,9 +1668,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.registerChatSessionContentProvider(extension, scheme, chatParticipant, provider, capabilities); }, - sendSystemInitiatedRequest(sessionResource: vscode.Uri, message: string, options: vscode.SystemInitiatedChatRequestOptions): Thenable { + sendSystemInitiatedRequest(sessionResource: vscode.Uri, prompt: string, options: vscode.SystemInitiatedChatRequestOptions): Thenable { checkProposedApiEnabled(extension, 'chatSessionsProvider'); - return extHostChatSessions.sendSystemInitiatedRequest(extension, sessionResource, message, options); + return extHostChatSessions.sendSystemInitiatedRequest(extension, sessionResource, prompt, options); }, registerChatOutputRenderer: (viewType: string, renderer: vscode.ChatOutputRenderer) => { checkProposedApiEnabled(extension, 'chatOutputRenderer'); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 87419e92b9275..857332047da9a 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3716,7 +3716,7 @@ export interface MainThreadChatSessionsShape extends IDisposable { $handleAnchorResolve(handle: number, sessionResource: UriComponents, requestId: string, requestHandle: string, anchor: Dto): void; $handleProgressComplete(handle: number, sessionResource: UriComponents, requestId: string): void; - $sendSystemInitiatedRequest(handle: number, sessionResource: UriComponents, message: string, options: { systemInitiatedLabel: string }): Promise; + $sendSystemInitiatedRequest(handle: number, sessionResource: UriComponents, prompt: string, options: { systemInitiatedLabel: string }): Promise; } export interface ExtHostChatSessionsShape { diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 551dcd0f0d68c..15ad06c2adb83 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -629,7 +629,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }); } - sendSystemInitiatedRequest(extension: IExtensionDescription, sessionResource: vscode.Uri, message: string, options: vscode.SystemInitiatedChatRequestOptions): Promise { + sendSystemInitiatedRequest(extension: IExtensionDescription, sessionResource: vscode.Uri, prompt: string, options: vscode.SystemInitiatedChatRequestOptions): Promise { let ownedHandle: number | undefined; for (const [handle, entry] of this._chatSessionContentProviders) { if (entry.chatSessionScheme === sessionResource.scheme && ExtensionIdentifier.equals(entry.extension.identifier, extension.identifier)) { @@ -643,7 +643,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio if (!options || typeof options.systemInitiatedLabel !== 'string' || options.systemInitiatedLabel.length === 0) { return Promise.reject(new Error(`sendSystemInitiatedRequest: 'systemInitiatedLabel' is required`)); } - return this._proxy.$sendSystemInitiatedRequest(ownedHandle, sessionResource, message, { systemInitiatedLabel: options.systemInitiatedLabel }); + return this._proxy.$sendSystemInitiatedRequest(ownedHandle, sessionResource, prompt, { systemInitiatedLabel: options.systemInitiatedLabel }); } async $provideChatSessionContent(handle: number, sessionResourceComponents: UriComponents, context: ChatSessionContentContextDto, token: CancellationToken): Promise { diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 1ec85a332e80d..251dc7850af23 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -589,12 +589,12 @@ declare module 'vscode' { * as a steering request so it preempts the currently active turn. * * @param sessionResource Uri of the target chat session. Its scheme must match a {@link ChatSessionContentProvider} registered by this extension. - * @param message The message body sent to the session's participant. + * @param prompt The prompt sent to the session's participant. This becomes {@link ChatRequest.prompt} on the synthesized request. * @param options Display and routing options for the request. * * @returns A thenable that resolves once the request has been accepted and queued. */ - export function sendSystemInitiatedRequest(sessionResource: Uri, message: string, options: SystemInitiatedChatRequestOptions): Thenable; + export function sendSystemInitiatedRequest(sessionResource: Uri, prompt: string, options: SystemInitiatedChatRequestOptions): Thenable; } /** From a8c0f1b173a4f2d575dfda516aea663d51bd65a1 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Sun, 26 Apr 2026 13:17:58 -0700 Subject: [PATCH 23/23] remove wasteful trip Co-authored-by: Copilot --- .../copilotcli/node/copilotcliSession.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index a3b0349c08a79..6e7110c31a295 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -269,12 +269,16 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this.add(toDisposable(this._sdkSession.on('system.notification', (event: SystemNotificationEvent) => { try { this.logService.info(`[anthony] system.notification received: kind=${event?.data?.kind} status=${this._status} session=${this.sessionId}`); - // NOTE: do NOT skip when `_status === InProgress`. In chained - // async-shell scenarios (e.g. async shell A done → handler still - // awaiting `session.idle` → assistant launches async shell B → - // SHELL_B_DONE arrives), `_status` is still `InProgress` from the - // first system-initiated turn. Skipping here would silently drop - // the second notification and the user never sees a follow-up bubble. + // Skip forwarding when a user-typed turn is in flight — the SDK + // will fold the notification into the running turn and we avoid + // a wasteful round-trip plus an extra bubble. Chained + // system-initiated turns must still forward, otherwise the + // follow-up notification (e.g. shell B done while shell A's + // turn is still streaming) would be silently dropped. + if (this._status === ChatSessionStatus.InProgress && !this._isInSystemInitiatedTurn) { + this.logService.info(`[anthony] system.notification SKIPPED (user-typed turn in flight; SDK will fold inline) session=${this.sessionId}`); + return; + } // Mark this session as being in a system-initiated turn for as long // as the SDK is reacting to the notification. The SDK auto-fires