Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5003151
working changes to keep session title with panel and cli resume
anthonykim1 Apr 22, 2026
701e10d
allow syncing of session title from side bar and editor tab
anthonykim1 Apr 22, 2026
372bb78
remove raceCanellation
anthonykim1 Apr 22, 2026
159d3e4
Stop using <T> for void
anthonykim1 Apr 22, 2026
0a1c2f5
better name
anthonykim1 Apr 23, 2026
41c78e8
stage title locally when SDK session isn't materialized yet
anthonykim1 Apr 23, 2026
0c44356
Merge branch 'main' into anthonykim1/updateCLIsessionTitle
anthonykim1 Apr 23, 2026
b461750
Merge branch 'main' into anthonykim1/updateCLIsessionTitle
anthonykim1 Apr 23, 2026
279b499
rename to fix test
anthonykim1 Apr 23, 2026
1ee441e
futher unify title resolution through single resolver
anthonykim1 Apr 23, 2026
82c6dad
Support async terminal in cli chat panel
anthonykim1 Apr 23, 2026
58a13db
Merge branch 'main' into anthonykim1/supportAsyncTerminalInCopilotCLI…
anthonykim1 Apr 23, 2026
4971ba6
stop using unknown in test
anthonykim1 Apr 23, 2026
6576f4f
clean up anthony's comment
anthonykim1 Apr 23, 2026
f58b7fb
Wire system.notification listener for V1 (legacy) session controller …
anthonykim1 Apr 23, 2026
4c2390c
try to clean up logs a bit
anthonykim1 Apr 23, 2026
81d17ee
Try to fix test
anthonykim1 Apr 23, 2026
7dbe073
tighten the wording in proposed api
anthonykim1 Apr 23, 2026
913dcae
skip mid turn: sdk already injects notification
anthonykim1 Apr 24, 2026
2e10a1c
Merge branch 'main' into anthonykim1/supportAsyncTerminalInCopilotCLI…
anthonykim1 Apr 24, 2026
f38308d
Inline keep-alive timeout; drop permissionRequested check
anthonykim1 Apr 24, 2026
d09c106
temporarily ship the logs to save status
anthonykim1 Apr 24, 2026
283a8ee
temp resolve case where async after another async is blocked
anthonykim1 Apr 24, 2026
18773f5
release cli session keep-alive ref on explicit deletion
anthonykim1 Apr 24, 2026
c14f0c5
please stop hanging
anthonykim1 Apr 24, 2026
b3ccdb4
Use prompt instead of message
anthonykim1 Apr 26, 2026
a8c0f1b
remove wasteful trip
anthonykim1 Apr 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -275,6 +275,14 @@ export interface ICopilotCLISessionService {
onDidChangeSession: Event<ICopilotCLISessionItem>;
onDidCreateSession: Event<ICopilotCLISessionItem>;

/**
* 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
Expand Down Expand Up @@ -306,6 +314,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS

private _sessionManager: Lazy<Promise<internal.LocalSessionManager>>;
private _sessionWrappers = new DisposableMap<string, RefCountedSession>();
private _keepAliveDisposables = new DisposableMap<string, IDisposable>();
private readonly _partialSessionHistories = new Map<string, readonly (ChatRequestTurn2 | ChatResponseTurn2)[]>();


Expand All @@ -320,6 +329,9 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
private readonly _onDidCreateSession = this._register(new Emitter<ICopilotCLISessionItem>());
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<string>());

private sessionMutexForGetSession = new Map<string, Mutex>();
Expand Down Expand Up @@ -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()) {
Expand All @@ -1276,13 +1292,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 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<IDisposable>();
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) {
Comment thread
anthonykim1 marked this conversation as resolved.
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;
}

public async deleteSession(sessionId: string): Promise<void> {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ class TestCopilotCLISession extends CopilotCLISession {

class FakeCopilotCLISessionService extends mock<ICopilotCLISessionService>() {
private _sessionWorkingDirs = new Map<string, vscode.Uri>();
override onDidReceiveSystemNotification = Event.None;
override tryGetPartialSessionHistory: ICopilotCLISessionService['tryGetPartialSessionHistory'] = vi.fn(async () => undefined);

override getSessionWorkingDirectory = vi.fn((sessionId: string): vscode.Uri | undefined => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class TestSessionService extends mock<ICopilotCLISessionService>() {
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[]);
Expand Down
26 changes: 25 additions & 1 deletion src/vs/workbench/api/browser/mainThreadChatSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> {
const resource = URI.revive(sessionResource);
const ownedHandle = this._sessionTypeToHandle.get(resource.scheme);
Comment thread
anthonykim1 marked this conversation as resolved.
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) {
Expand Down
4 changes: 4 additions & 0 deletions src/vs/workbench/api/common/extHost.api.impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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);
Expand Down
2 changes: 2 additions & 0 deletions src/vs/workbench/api/common/extHost.protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3715,6 +3715,8 @@ export interface MainThreadChatSessionsShape extends IDisposable {
$handleProgressChunk(handle: number, sessionResource: UriComponents, requestId: string, chunks: (IChatProgressDto | [IChatProgressDto, number])[]): Promise<void>;
$handleAnchorResolve(handle: number, sessionResource: UriComponents, requestId: string, requestHandle: string, anchor: Dto<IChatContentInlineReference>): void;
$handleProgressComplete(handle: number, sessionResource: UriComponents, requestId: string): void;

$sendSystemInitiatedRequest(handle: number, sessionResource: UriComponents, prompt: string, options: { systemInitiatedLabel: string }): Promise<void>;
}

export interface ExtHostChatSessionsShape {
Expand Down
19 changes: 18 additions & 1 deletion src/vs/workbench/api/common/extHostChatSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -629,6 +629,23 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio
});
}

sendSystemInitiatedRequest(extension: IExtensionDescription, sessionResource: vscode.Uri, prompt: string, options: vscode.SystemInitiatedChatRequestOptions): Promise<void> {
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<IChatSessionDto> {
const provider = this._chatSessionContentProviders.get(handle);
if (!provider) {
Expand Down
28 changes: 28 additions & 0 deletions src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}

/**
* 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;
Comment thread
anthonykim1 marked this conversation as resolved.
}

export interface ChatContext {
Expand Down
Loading