diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 51ec6f29bcdbe..6e7110c31a295 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'; @@ -133,6 +133,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; @@ -140,13 +145,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, @@ -178,6 +184,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() { @@ -191,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(); @@ -236,6 +253,112 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this.sessionId = _sdkSession.sessionId; 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 + // 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.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 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 + // 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)}"`); + 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}`); + } + }))); + + // 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 { + 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 { @@ -281,7 +404,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, @@ -302,10 +425,25 @@ 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 && 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 + // 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); } }); @@ -366,7 +504,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); @@ -401,7 +540,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); @@ -421,7 +560,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); @@ -518,6 +658,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) { + // Session-wide listener handles auto-approve for system-initiated + // turns; don't double-respond here. + 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; @@ -834,8 +995,26 @@ 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. + this.logService.info(`[anthony] system-initiated handler: awaiting next session.idle for session ${this.sessionId}`); + // `_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); + } } + this.logService.trace(`[CopilotCLISession] Invoking session (completed) ${this.sessionId}`); const resolvedToolIdEditMap: Record = {}; await Promise.all(Array.from(toolIdEditMap.entries()).map(async ([toolId, editFilePromise]) => { @@ -936,6 +1115,43 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this.logService.error(error, '[CopilotCLISession] Failed to update artifacts'); }); } + /** + * 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` 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) { + return; + } + const SAFETY_TIMEOUT_MS = 5 * 60 * 1000; + const disposables = new DisposableStore(); + try { + await new Promise(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); + 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 a2694570ca40b..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, 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'; @@ -275,6 +275,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 @@ -306,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(); @@ -320,6 +329,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 sessionMutexForGetSession = new Map(); @@ -1258,8 +1270,12 @@ 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._keepAliveDisposables.deleteAndDispose(sdkSession.sessionId); this.sessionMutexForGetSession.delete(sdkSession.sessionId); (async () => { if (sdkSession.isAbortable()) { @@ -1276,6 +1292,47 @@ 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 covers every + // async-completion path the SDK emits. + const KEEP_ALIVE_TIMEOUT_MS = 5 * 60 * 1000; + let hasKeepAliveRef = false; + const releaseKeepAlive = () => { + if (hasKeepAliveRef) { + hasKeepAliveRef = false; + refCountedSession.release(); + } + }; + const keepAliveTimer = new MutableDisposable(); + 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) { + if (!hasKeepAliveRef) { + refCountedSession.acquire(); + hasKeepAliveRef = true; + } + keepAliveTimer.value = disposableTimeout(releaseKeepAlive, KEEP_ALIVE_TIMEOUT_MS); + } else { + // Session is busy again (new turn started); hold the ref and + // cancel the release timer. + keepAliveTimer.clear(); + } + })); + return refCountedSession; } @@ -1283,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); 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 72ed404c49281..de9a7bef4b42f 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,6 +134,7 @@ class MockSdkSession { async setSelectedModel(model: string, _reasoningEffort?: string) { this._selectedModel = model; } async getEvents() { return []; } getPlanPath(): string | null { return null; } + 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 e5050690ae70c..32f002f771cde 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,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: SessionEvent) => 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 c54f4369df5cc..f239d8f858b2b 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -149,6 +149,19 @@ 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] V2 sendSystemInitiatedRequest -> session=${sessionId} label="${label}"`); + vscode.chat.sendSystemInitiatedRequest(sessionResource, message, { systemInitiatedLabel: label }) + .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; const refreshSessions = async () => { if (isRefreshing) { @@ -858,7 +871,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/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 88b9c494e327b..00603ac7bd2a7 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -598,6 +598,20 @@ 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._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( + () => 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; this._register(this.gitService.onDidFinishInitialization(() => { if (originalRepos !== this.getRepositoryOptionItems().length) { 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 00b3a3f4463e3..d05f91a62d389 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[]); diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 09acdfa10143d..2a78a01dbb90c 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,30 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat // throw new Error('Method not implemented.'); } + 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) { + 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(`[MainThreadChatSessions] $sendSystemInitiatedRequest: chat model not loaded for ${resource.toString()}; dropping request`); + return; + } + try { + await this._chatService.sendRequest(resource, prompt, { + isSystemInitiated: true, + systemInitiatedLabel: options.systemInitiatedLabel, + queue: ChatRequestQueueKind.Steering, + }); + } 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..6e30b9bb54e71 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, prompt: string, options: vscode.SystemInitiatedChatRequestOptions): Thenable { + checkProposedApiEnabled(extension, 'chatSessionsProvider'); + return extHostChatSessions.sendSystemInitiatedRequest(extension, sessionResource, prompt, 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..857332047da9a 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, 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 38d3b8669c654..15ad06c2adb83 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, 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)) { + 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, prompt, { 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..251dc7850af23 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -579,6 +579,34 @@ 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 owned by this extension. + * + * 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. + * + * @param sessionResource Uri of the target chat session. Its scheme must match a {@link ChatSessionContentProvider} registered by this extension. + * @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, prompt: string, options: SystemInitiatedChatRequestOptions): Thenable; + } + + /** + * Options for {@link chat.sendSystemInitiatedRequest}. + */ + export interface SystemInitiatedChatRequestOptions { + /** + * 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; } export interface ChatContext {