Merge pull request #292775 from microsoft/connor4312/queuing-polishes

chat: polishes for steering/queued messages
This commit is contained in:
Connor Peet
2026-02-03 22:41:49 -08:00
committed by GitHub
14 changed files with 680 additions and 125 deletions
@@ -427,6 +427,7 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem {
private readonly _dropdown: DropdownMenuActionViewItem;
private _container: HTMLElement | null = null;
private readonly _storageKey: string;
private readonly _primaryActionListener = this._register(new MutableDisposable());
get onDidChangeDropdownVisibility(): Event<boolean> {
return this._dropdown.onDidChangeVisibility;
@@ -468,14 +469,18 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem {
this._dropdown = this._register(new DropdownMenuActionViewItem(submenuAction, submenuAction.actions, this._contextMenuService, dropdownOptions));
if (options?.togglePrimaryAction) {
this._register(this._dropdown.actionRunner.onDidRun((e: IRunEvent) => {
if (e.action instanceof MenuItemAction) {
this.update(e.action);
}
}));
this.registerTogglePrimaryActionListener();
}
}
private registerTogglePrimaryActionListener(): void {
this._primaryActionListener.value = this._dropdown.actionRunner.onDidRun((e: IRunEvent) => {
if (e.action instanceof MenuItemAction) {
this.update(e.action);
}
});
}
private update(lastAction: MenuItemAction): void {
if (this._options?.togglePrimaryAction) {
this._storageService.store(this._storageKey, lastAction.id, StorageScope.WORKSPACE, StorageTarget.MACHINE);
@@ -516,6 +521,9 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem {
this._defaultAction.actionRunner = actionRunner;
this._dropdown.actionRunner = actionRunner;
if (this._primaryActionListener.value) {
this.registerTogglePrimaryActionListener();
}
}
override get actionRunner(): IActionRunner {
@@ -822,7 +822,8 @@ export class CancelAction extends Action2 {
id: MenuId.ChatExecute,
when: ContextKeyExpr.and(
ChatContextKeys.requestInProgress,
ChatContextKeys.remoteJobCreating.negate()
ChatContextKeys.remoteJobCreating.negate(),
ChatContextKeys.currentlyEditing.negate(),
),
order: 4,
group: 'navigation',
@@ -4,14 +4,17 @@
*--------------------------------------------------------------------------------------------*/
import { Codicon } from '../../../../../base/common/codicons.js';
import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js';
import { URI } from '../../../../../base/common/uri.js';
import { localize2 } from '../../../../../nls.js';
import { localize, localize2 } from '../../../../../nls.js';
import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js';
import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';
import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js';
import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js';
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
import { ChatRequestQueueKind, IChatService } from '../../common/chatService/chatService.js';
import { ChatConfiguration } from '../../common/constants.js';
import { isRequestVM } from '../../common/model/chatViewModel.js';
import { IChatWidgetService } from '../chat.js';
import { CHAT_CATEGORY } from './chatActions.js';
@@ -38,6 +41,7 @@ export class ChatQueueMessageAction extends Action2 {
super({
id: ChatQueueMessageAction.ID,
title: localize2('chat.queueMessage', "Add to Queue"),
tooltip: localize('chat.queueMessage.tooltip', "Queue this message to send after the current request completes"),
icon: Codicon.add,
f1: false,
category: CHAT_CATEGORY,
@@ -46,6 +50,15 @@ export class ChatQueueMessageAction extends Action2 {
ChatContextKeys.requestInProgress,
ChatContextKeys.inputHasText
),
keybinding: {
when: ContextKeyExpr.and(
ChatContextKeys.inChatInput,
ChatContextKeys.requestInProgress,
queueingEnabledCondition
),
primary: KeyCode.Enter,
weight: KeybindingWeight.EditorContrib + 1
},
menu: [{
id: MenuId.ChatExecuteQueue,
group: 'navigation',
@@ -77,6 +90,7 @@ export class ChatSteerWithMessageAction extends Action2 {
super({
id: ChatSteerWithMessageAction.ID,
title: localize2('chat.steerWithMessage', "Steer with Message"),
tooltip: localize('chat.steerWithMessage.tooltip', "Send this message at the next opportunity, signaling the current request to yield"),
icon: Codicon.arrowRight,
f1: false,
category: CHAT_CATEGORY,
@@ -85,6 +99,15 @@ export class ChatSteerWithMessageAction extends Action2 {
ChatContextKeys.requestInProgress,
ChatContextKeys.inputHasText
),
keybinding: {
when: ContextKeyExpr.and(
ChatContextKeys.inChatInput,
ChatContextKeys.requestInProgress,
queueingEnabledCondition
),
primary: KeyMod.Alt | KeyCode.Enter,
weight: KeybindingWeight.EditorContrib + 1
},
menu: [{
id: MenuId.ChatExecuteQueue,
group: 'navigation',
@@ -119,17 +142,131 @@ export class ChatRemovePendingRequestAction extends Action2 {
icon: Codicon.close,
f1: false,
category: CHAT_CATEGORY,
menu: [{
id: MenuId.ChatMessageTitle,
group: 'navigation',
order: 4,
when: ContextKeyExpr.and(
queueingEnabledCondition,
ChatContextKeys.isRequest,
ChatContextKeys.isPendingRequest
)
}]
});
}
override run(accessor: ServicesAccessor, ...args: unknown[]): void {
const chatService = accessor.get(IChatService);
const [context] = args;
if (!isRemovePendingRequestContext(context)) {
// Support both toolbar context (IChatRequestViewModel) and command context (IChatRemovePendingRequestContext)
if (isRequestVM(context) && context.pendingKind) {
chatService.removePendingRequest(context.sessionResource, context.id);
return;
}
chatService.removePendingRequest(context.sessionResource, context.pendingRequestId);
if (isRemovePendingRequestContext(context)) {
chatService.removePendingRequest(context.sessionResource, context.pendingRequestId);
return;
}
}
}
export class ChatSendPendingImmediatelyAction extends Action2 {
static readonly ID = 'workbench.action.chat.sendPendingImmediately';
constructor() {
super({
id: ChatSendPendingImmediatelyAction.ID,
title: localize2('chat.sendPendingImmediately', "Send Immediately"),
icon: Codicon.arrowUp,
f1: false,
category: CHAT_CATEGORY,
menu: [{
id: MenuId.ChatMessageTitle,
group: 'navigation',
order: 3,
when: ContextKeyExpr.and(
queueingEnabledCondition,
ChatContextKeys.isRequest,
ChatContextKeys.isPendingRequest
)
}]
});
}
override run(accessor: ServicesAccessor, ...args: unknown[]): void {
const chatService = accessor.get(IChatService);
const widgetService = accessor.get(IChatWidgetService);
const [context] = args;
if (!isRequestVM(context) || !context.pendingKind) {
return;
}
const widget = widgetService.getWidgetBySessionResource(context.sessionResource);
const model = widget?.viewModel?.model;
if (!model) {
return;
}
const pendingRequests = model.getPendingRequests();
const targetIndex = pendingRequests.findIndex(r => r.request.id === context.id);
if (targetIndex === -1) {
return;
}
// Keep the target item's kind (queued vs steering)
const targetRequest = pendingRequests[targetIndex];
// Reorder: move target to front, keep others in their relative order
const reordered = [
{ requestId: targetRequest.request.id, kind: targetRequest.kind },
...pendingRequests.filter((_, i) => i !== targetIndex).map(r => ({ requestId: r.request.id, kind: r.kind }))
];
chatService.setPendingRequests(context.sessionResource, reordered);
chatService.cancelCurrentRequestForSession(context.sessionResource);
chatService.processPendingRequests(context.sessionResource);
}
}
export class ChatRemoveAllPendingRequestsAction extends Action2 {
static readonly ID = 'workbench.action.chat.removeAllPendingRequests';
constructor() {
super({
id: ChatRemoveAllPendingRequestsAction.ID,
title: localize2('chat.removeAllPendingRequests', "Remove All Queued"),
icon: Codicon.clearAll,
f1: false,
category: CHAT_CATEGORY,
menu: [{
id: MenuId.ChatContext,
group: 'navigation',
order: 3,
when: ContextKeyExpr.and(
queueingEnabledCondition,
ChatContextKeys.hasPendingRequests
)
}]
});
}
override run(accessor: ServicesAccessor, ...args: unknown[]): void {
const chatService = accessor.get(IChatService);
const widgetService = accessor.get(IChatWidgetService);
const [context] = args;
const widget = (isRequestVM(context) && widgetService.getWidgetBySessionResource(context.sessionResource)) || widgetService.lastFocusedWidget;
const model = widget?.viewModel?.model;
if (!model) {
return;
}
for (const pendingRequest of [...model.getPendingRequests()]) {
chatService.removePendingRequest(model.sessionResource, pendingRequest.request.id);
}
}
}
@@ -137,6 +274,8 @@ export function registerChatQueueActions(): void {
registerAction2(ChatQueueMessageAction);
registerAction2(ChatSteerWithMessageAction);
registerAction2(ChatRemovePendingRequestAction);
registerAction2(ChatSendPendingImmediatelyAction);
registerAction2(ChatRemoveAllPendingRequestsAction);
// Register the queue submenu as a split button dropdown in the execute toolbar
// This shows "Add to Queue" / "Steer with Message" when a request is in progress and input has text
@@ -150,7 +289,7 @@ export function registerChatQueueActions(): void {
ChatContextKeys.inputHasText
),
group: 'navigation',
order: 3,
order: 4,
isSplitButton: { togglePrimaryAction: true }
});
}
@@ -648,6 +648,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
ChatContextKeys.isResponse.bindTo(templateData.contextKeyService).set(isResponseVM(element));
ChatContextKeys.itemId.bindTo(templateData.contextKeyService).set(element.id);
ChatContextKeys.isRequest.bindTo(templateData.contextKeyService).set(isRequestVM(element));
ChatContextKeys.isPendingRequest.bindTo(templateData.contextKeyService).set(isRequestVM(element) && !!element.pendingKind);
ChatContextKeys.responseDetectedAgentCommand.bindTo(templateData.contextKeyService).set(isResponseVM(element) && element.agentOrSlashCommandDetected);
if (isResponseVM(element)) {
ChatContextKeys.responseSupportsIssueReporting.bindTo(templateData.contextKeyService).set(!!element.agent?.metadata.supportIssueReporting);
@@ -698,11 +699,12 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
templateData.checkpointToolbar.context = element;
const checkpointEnabled = this.configService.getValue<boolean>(ChatConfiguration.CheckpointsEnabled)
&& (this.rendererOptions.restorable ?? true);
const isPendingRequest = isRequestVM(element) && !!element.pendingKind;
templateData.checkpointContainer.classList.toggle('hidden', isResponseVM(element) || !(checkpointEnabled));
templateData.checkpointContainer.classList.toggle('hidden', isResponseVM(element) || isPendingRequest || !(checkpointEnabled));
// Only show restore container when we have a checkpoint and not editing
const shouldShowRestore = this.viewModel?.model.checkpoint && !this.viewModel?.editing && (index === this.delegate.getListLength() - 1);
// Only show restore container when we have a checkpoint and not editing, and not a pending request
const shouldShowRestore = this.viewModel?.model.checkpoint && !this.viewModel?.editing && (index === this.delegate.getListLength() - 1) && !isPendingRequest;
templateData.checkpointRestoreContainer.classList.toggle('hidden', !(shouldShowRestore && checkpointEnabled));
const editing = element.id === this.viewModel?.editing?.id;
@@ -31,6 +31,7 @@ import { localize } from '../../../../../nls.js';
import { MenuId } from '../../../../../platform/actions/common/actions.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
import { ITextResourceEditorInput } from '../../../../../platform/editor/common/editor.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
@@ -256,6 +257,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
private readonly _lockedToCodingAgentContextKey: IContextKey<boolean>;
private readonly _agentSupportsAttachmentsContextKey: IContextKey<boolean>;
private readonly _sessionIsEmptyContextKey: IContextKey<boolean>;
private readonly _hasPendingRequestsContextKey: IContextKey<boolean>;
private _attachmentCapabilities: IChatAgentAttachmentCapabilities = supportsAllAttachments;
// Cache for prompt file descriptions to avoid async calls during rendering
@@ -342,6 +344,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
@IEditorService private readonly editorService: IEditorService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IDialogService private readonly dialogService: IDialogService,
@IContextKeyService private readonly contextKeyService: IContextKeyService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IChatService private readonly chatService: IChatService,
@@ -369,6 +372,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
this._lockedToCodingAgentContextKey = ChatContextKeys.lockedToCodingAgent.bindTo(this.contextKeyService);
this._agentSupportsAttachmentsContextKey = ChatContextKeys.agentSupportsAttachments.bindTo(this.contextKeyService);
this._sessionIsEmptyContextKey = ChatContextKeys.chatSessionIsEmpty.bindTo(this.contextKeyService);
this._hasPendingRequestsContextKey = ChatContextKeys.hasPendingRequests.bindTo(this.contextKeyService);
this.viewContext = viewContext ?? {};
@@ -1804,6 +1808,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
}
this.viewModel = undefined;
this.onDidChangeItems();
this._hasPendingRequestsContextKey.set(false);
return;
}
@@ -1867,6 +1872,12 @@ export class ChatWidget extends Disposable implements IChatWidget {
this.onDidChangeItems();
}));
this._sessionIsEmptyContextKey.set(model.getRequests().length === 0);
const updatePendingRequestKeys = () => {
const pendingCount = model.getPendingRequests().length;
this._hasPendingRequestsContextKey.set(pendingCount > 0);
};
updatePendingRequestKeys();
this.viewModelDisposables.add(model.onDidChangePendingRequests(() => updatePendingRequestKeys()));
this.refreshParsedInput();
this.viewModelDisposables.add(model.onDidChange((e) => {
@@ -2056,10 +2067,6 @@ export class ChatWidget extends Disposable implements IChatWidget {
}
private async _acceptInput(query: { query: string } | undefined, options: IChatAcceptInputOptions = {}): Promise<IChatResponseModel | undefined> {
if (this.viewModel?.model.requestInProgress.get()) {
options.queue ??= ChatRequestQueueKind.Queued;
}
if (!query && this.input.generating) {
// if the user submits the input and generation finishes quickly, just submit it for them
const generatingAutoSubmitWindow = 500;
@@ -2099,10 +2106,26 @@ export class ChatWidget extends Disposable implements IChatWidget {
const isUserQuery = !query;
if (this.viewModel?.editing) {
const editingPendingRequest = this.viewModel.editing.pendingKind;
if (editingPendingRequest !== undefined) {
const editingRequestId = this.viewModel.editing!.id;
this.chatService.removePendingRequest(this.viewModel.sessionResource, editingRequestId);
options.queue ??= editingPendingRequest;
}
this.finishedEditing(true);
this.viewModel.model?.setCheckpoint(undefined);
}
const model = this.viewModel.model;
const requestInProgress = model.requestInProgress.get();
if (requestInProgress) {
options.queue ??= ChatRequestQueueKind.Queued;
}
if (!requestInProgress && !(await this.confirmPendingRequestsBeforeSend(model, options))) {
return;
}
// process the prompt command
await this._applyPromptFileIfSet(requestInputs);
await this._autoAttachInstructions(requestInputs);
@@ -2203,6 +2226,46 @@ export class ChatWidget extends Disposable implements IChatWidget {
return sent.data.responseCreatedPromise;
}
private async confirmPendingRequestsBeforeSend(model: IChatModel, options: IChatAcceptInputOptions): Promise<boolean> {
if (options.queue) {
return true;
}
const hasPendingRequests = model.getPendingRequests().length > 0;
if (!hasPendingRequests) {
return true;
}
const promptResult = await this.dialogService.prompt({
type: 'question',
message: localize('chat.pendingRequests.prompt.message', "You already have pending requests."),
detail: localize('chat.pendingRequests.prompt.detail', "Do you want to keep them in the queue or remove them before sending this message?"),
buttons: [
{
label: localize('chat.pendingRequests.prompt.keep', "Keep Pending Requests"),
run: () => 'keep'
},
{
label: localize('chat.pendingRequests.prompt.remove', "Remove Pending Requests"),
run: () => 'remove'
}
],
cancelButton: true
});
if (!promptResult.result) {
return false;
}
if (promptResult.result === 'remove') {
for (const pendingRequest of [...model.getPendingRequests()]) {
this.chatService.removePendingRequest(model.sessionResource, pendingRequest.request.id);
}
}
return true;
}
getModeRequestOptions(): Partial<IChatSendRequestOptions> {
return {
modeInfo: this.input.currentModeInfo,
@@ -2537,7 +2537,6 @@ have to be updated for changes to the rules above, or to support more deeply nes
top: -13px;
right: 20px;
border-radius: 3px;
width: 28px;
height: 26px;
}
@@ -2554,7 +2553,6 @@ have to be updated for changes to the rules above, or to support more deeply nes
}
.request-hover:not(.expanded) .actions-container {
width: 22px;
height: 22px;
}
@@ -2563,11 +2561,8 @@ have to be updated for changes to the rules above, or to support more deeply nes
}
.request-hover:not(.expanded) .actions-container {
.action-label.codicon-discard,
.action-label.codicon-x,
.action-label.codicon-edit {
margin-top: 4px;
.action-label {
margin: 4px 2px 0;
padding: 3px 3px;
}
}
@@ -2859,6 +2854,10 @@ have to be updated for changes to the rules above, or to support more deeply nes
/* Pending request styles */
.interactive-item-container.pending-request {
opacity: 0.7;
.request-hover {
top: -17px !important;
}
}
.interactive-item-container .chat-request-status {
@@ -23,6 +23,7 @@ export namespace ChatContextKeys {
export const isResponse = new RawContextKey<boolean>('chatResponse', false, { type: 'boolean', description: localize('chatResponse', "The chat item is a response.") });
export const isRequest = new RawContextKey<boolean>('chatRequest', false, { type: 'boolean', description: localize('chatRequest', "The chat item is a request") });
export const isPendingRequest = new RawContextKey<boolean>('chatRequestIsPending', false, { type: 'boolean', description: localize('chatRequestIsPending', "True when the chat request item is pending in the queue.") });
export const itemId = new RawContextKey<string>('chatItemId', '', { type: 'string', description: localize('chatItemId', "The id of the chat item.") });
export const lastItemId = new RawContextKey<string[]>('chatLastItemId', [], { type: 'string', description: localize('chatLastItemId', "The id of the last chat item.") });
@@ -70,7 +71,6 @@ export namespace ChatContextKeys {
export const hasFileAttachments = new RawContextKey<boolean>('chatHasFileAttachments', false, { type: 'boolean', description: localize('chatHasFileAttachments', "True when the chat has file attachments.") });
export const chatSessionIsEmpty = new RawContextKey<boolean>('chatSessionIsEmpty', true, { type: 'boolean', description: localize('chatSessionIsEmpty', "True when the current chat session has no requests.") });
export const hasPendingRequests = new RawContextKey<boolean>('chatHasPendingRequests', false, { type: 'boolean', description: localize('chatHasPendingRequests', "True when there are pending requests in the queue.") });
export const pendingRequestCount = new RawContextKey<number>('chatPendingRequestCount', 0, { type: 'number', description: localize('chatPendingRequestCount', "Number of pending requests in the queue.") });
export const remoteJobCreating = new RawContextKey<boolean>('chatRemoteJobCreating', false, { type: 'boolean', description: localize('chatRemoteJobCreating', "True when a remote coding agent job is being created.") });
export const hasRemoteCodingAgent = new RawContextKey<boolean>('hasRemoteCodingAgent', false, localize('hasRemoteCodingAgent', "Whether any remote coding agent is available"));
@@ -1332,6 +1332,13 @@ export interface IChatService {
* Adding new requests should go through sendRequest with the queue option.
*/
setPendingRequests(sessionResource: URI, requests: readonly { requestId: string; kind: ChatRequestQueueKind }[]): void;
/**
* Ensures pending requests for the session are processing. If restoring from
* storage or after an error, pending requests may be present without an
* active chat message 'loop' happening. THis triggers the loop to happen
* as needed. Idempotent, safe to call at any time.
*/
processPendingRequests(sessionResource: URI): void;
addCompleteRequest(sessionResource: URI, message: IParsedChatRequest | string, variableData: IChatRequestVariableData | undefined, attempt: number | undefined, response: IChatCompleteResponse): void;
setChatSessionTitle(sessionResource: URI, title: string): void;
getLocalSessionHistory(): Promise<IChatDetail[]>;
@@ -37,7 +37,7 @@ import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, ICha
import { ChatModelStore, IStartSessionProps } from '../model/chatModelStore.js';
import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, chatSubcommandLeader, getPromptText, IParsedChatRequest } from '../requestParser/chatParserTypes.js';
import { ChatRequestParser } from '../requestParser/chatRequestParser.js';
import { ChatMcpServersStarting, ChatRequestQueueKind, ChatSendResult, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent, ResponseModelState } from './chatService.js';
import { ChatMcpServersStarting, ChatRequestQueueKind, ChatSendResult, ChatSendResultQueued, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent, ResponseModelState } from './chatService.js';
import { ChatRequestTelemetry, ChatServiceTelemetry } from './chatServiceTelemetry.js';
import { IChatSessionsService } from '../chatSessionsService.js';
import { ChatSessionStore, IChatSessionEntryMetadata } from '../model/chatSessionStore.js';
@@ -731,6 +731,34 @@ export class ChatService extends Disposable implements IChatService {
await this._sendRequestAsync(model, model.sessionResource, request.message, attempt, enableCommandDetection, defaultAgent, location, resendOptions).responseCompletePromise;
}
private queuePendingRequest(model: ChatModel, sessionResource: URI, request: string, options: IChatSendRequestOptions): ChatSendResultQueued {
const location = options.location ?? model.initialLocation;
const parsedRequest = this.parseChatRequest(sessionResource, request, location, options);
const requestModel = new ChatRequestModel({
session: model,
message: parsedRequest,
variableData: { variables: [] },
timestamp: Date.now(),
modeInfo: options.modeInfo,
locationData: options.locationData,
attachedContext: options.attachedContext,
modelId: options.userSelectedModelId,
userSelectedTools: options.userSelectedTools?.get(),
});
const deferred = new DeferredPromise<ChatSendResult>();
this._queuedRequestDeferreds.set(requestModel.id, deferred);
model.addPendingRequest(requestModel, options.queue ?? ChatRequestQueueKind.Queued, { ...options, queue: undefined });
if (options.queue === ChatRequestQueueKind.Steering) {
this.setYieldRequested(sessionResource);
}
this.trace('sendRequest', `Queued message for session ${sessionResource}`);
return { kind: 'queued', deferred: deferred.p };
}
async sendRequest(sessionResource: URI, request: string, options?: IChatSendRequestOptions): Promise<ChatSendResult> {
this.trace('sendRequest', `sessionResource: ${sessionResource.toString()}, message: ${request.substring(0, 20)}${request.length > 20 ? '[...]' : ''}}`);
@@ -745,41 +773,25 @@ export class ChatService extends Disposable implements IChatService {
throw new Error(`Unknown session: ${sessionResource}`);
}
if (this._pendingRequests.has(sessionResource)) {
const hasPendingRequest = this._pendingRequests.has(sessionResource);
const hasPendingQueue = model.getPendingRequests().length > 0;
if (hasPendingRequest) {
// A request is already in progress
if (options?.queue) {
// Queue this message to be sent after the current request completes
const location = options?.location ?? model.initialLocation;
const parsedRequest = this.parseChatRequest(sessionResource, request, location, options);
const requestModel = new ChatRequestModel({
session: model as ChatModel,
message: parsedRequest,
variableData: { variables: [] },
timestamp: Date.now(),
modeInfo: options?.modeInfo,
locationData: options?.locationData,
attachedContext: options?.attachedContext,
modelId: options?.userSelectedModelId,
userSelectedTools: options?.userSelectedTools?.get(),
});
// Create a deferred promise that will be resolved when this queued request is processed
const deferred = new DeferredPromise<ChatSendResult>();
this._queuedRequestDeferreds.set(requestModel.id, deferred);
model.addPendingRequest(requestModel, options.queue, { ...options, queue: undefined });
if (options.queue === ChatRequestQueueKind.Steering) {
this.setYieldRequested(sessionResource);
}
this.trace('sendRequest', `Queued message for session ${sessionResource}`);
return { kind: 'queued', deferred: deferred.p };
return this.queuePendingRequest(model, sessionResource, request, options);
}
this.trace('sendRequest', `Session ${sessionResource} already has a pending request`);
return { kind: 'rejected', reason: 'Request already in progress' };
}
if (options?.queue && hasPendingQueue) {
const queued = this.queuePendingRequest(model, sessionResource, request, options);
this.processNextPendingRequest(model);
return queued;
}
const requests = model.getRequests();
for (let i = requests.length - 1; i >= 0; i -= 1) {
const request = requests[i];
@@ -1094,6 +1106,7 @@ export class ChatService extends Disposable implements IChatService {
completeResponseCreated();
this.trace('sendRequest', `Provider returned response for session ${model.sessionResource}`);
shouldProcessPending = !rawResult.errorDetails && !token.isCancellationRequested;
request.response?.complete();
if (agentOrCommandFollowups) {
agentOrCommandFollowups.then(followups => {
@@ -1123,13 +1136,16 @@ export class ChatService extends Disposable implements IChatService {
store.dispose();
}
};
let shouldProcessPending = false;
const rawResponsePromise = sendRequestInternal();
// Note- requestId is not known at this point, assigned later
this._pendingRequests.set(model.sessionResource, this.instantiationService.createInstance(CancellableRequest, source, undefined));
rawResponsePromise.finally(() => {
this._pendingRequests.deleteAndDispose(model.sessionResource);
// Process the next pending request from the queue if any
this.processNextPendingRequest(model);
if (shouldProcessPending) {
this.processNextPendingRequest(model);
}
});
this._onDidSubmitRequest.fire({ chatSessionResource: model.sessionResource });
return {
@@ -1138,6 +1154,13 @@ export class ChatService extends Disposable implements IChatService {
};
}
processPendingRequests(sessionResource: URI): void {
const model = this._sessionModels.get(sessionResource);
if (model && !this._pendingRequests.has(sessionResource)) {
this.processNextPendingRequest(model);
}
}
/**
* Process the next pending request from the model's queue, if any.
* Called after a request completes to continue processing queued requests.
@@ -1150,8 +1173,8 @@ export class ChatService extends Disposable implements IChatService {
this.trace('processNextPendingRequest', `Processing queued request for session ${model.sessionResource}`);
const deferred = this._queuedRequestDeferreds.get(pendingRequest.id);
this._queuedRequestDeferreds.delete(pendingRequest.id);
const deferred = this._queuedRequestDeferreds.get(pendingRequest.request.id);
this._queuedRequestDeferreds.delete(pendingRequest.request.id);
const sendOptions = pendingRequest.sendOptions;
const location = sendOptions.location ?? sendOptions.locationData?.type ?? model.initialLocation;
@@ -14,7 +14,7 @@ import { ResourceMap } from '../../../../../base/common/map.js';
import { revive } from '../../../../../base/common/marshalling.js';
import { Schemas } from '../../../../../base/common/network.js';
import { equals } from '../../../../../base/common/objects.js';
import { IObservable, autorun, autorunSelfDisposable, derived, observableFromEvent, observableSignalFromEvent, observableValue, observableValueOpts } from '../../../../../base/common/observable.js';
import { IObservable, autorun, autorunSelfDisposable, constObservable, derived, observableFromEvent, observableSignalFromEvent, observableValue, observableValueOpts } from '../../../../../base/common/observable.js';
import { basename, isEqual } from '../../../../../base/common/resources.js';
import { hasKey, WithDefinedProps } from '../../../../../base/common/types.js';
import { URI, UriDto } from '../../../../../base/common/uri.js';
@@ -43,7 +43,6 @@ import { ObjectMutationLog } from './objectMutationLog.js';
* Represents a queued chat request waiting to be processed.
*/
export interface IChatPendingRequest {
readonly id: string;
readonly request: IChatRequestModel;
readonly kind: ChatRequestQueueKind;
/**
@@ -53,6 +52,35 @@ export interface IChatPendingRequest {
readonly sendOptions: IChatSendRequestOptions;
}
/**
* Serializable version of IChatSendRequestOptions for pending requests.
* Excludes observables and non-serializable fields.
*/
export interface ISerializableSendOptions {
modeInfo?: IChatRequestModeInfo;
userSelectedModelId?: string;
/** Static snapshot of user-selected tools (not an observable) */
userSelectedTools?: UserSelectedTools;
location?: ChatAgentLocation;
locationData?: IChatLocationData;
attempt?: number;
noCommandDetection?: boolean;
agentId?: string;
agentIdSilent?: string;
slashCommand?: string;
confirmation?: string;
}
/**
* Serializable representation of a pending chat request.
*/
export interface ISerializablePendingRequestData {
id: string;
request: ISerializableChatRequestData;
kind: ChatRequestQueueKind;
sendOptions: ISerializableSendOptions;
}
export const CHAT_ATTACHABLE_IMAGE_MIME_TYPES: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
@@ -1479,6 +1507,8 @@ export interface ISerializableChatData3 extends Omit<ISerializableChatData2, 've
/** Current draft input state (added later, fully backwards compatible) */
inputState?: ISerializableChatModelInputState;
repoData?: IExportableRepoData;
/** Pending requests that were queued but not yet processed */
pendingRequests?: ISerializablePendingRequestData[];
}
/**
@@ -1825,13 +1855,13 @@ export class ChatModel extends Disposable implements IChatModel {
}
setPendingRequests(requests: readonly { requestId: string; kind: ChatRequestQueueKind }[]): void {
const existingMap = new Map(this._pendingRequests.map(p => [p.id, p]));
const existingMap = new Map(this._pendingRequests.map(p => [p.request.id, p]));
const newPending: IChatPendingRequest[] = [];
for (const { requestId, kind } of requests) {
const existing = existingMap.get(requestId);
if (existing) {
// Update kind if changed, keep existing request and sendOptions
newPending.push(existing.kind === kind ? existing : { id: existing.id, request: existing.request, kind, sendOptions: existing.sendOptions });
newPending.push(existing.kind === kind ? existing : { request: existing.request, kind, sendOptions: existing.sendOptions });
}
}
this._pendingRequests.length = 0;
@@ -1845,7 +1875,6 @@ export class ChatModel extends Disposable implements IChatModel {
*/
addPendingRequest(request: ChatRequestModel, kind: ChatRequestQueueKind, sendOptions: IChatSendRequestOptions): IChatPendingRequest {
const pendingRequest: IChatPendingRequest = {
id: request.id,
request,
kind,
sendOptions,
@@ -1875,7 +1904,7 @@ export class ChatModel extends Disposable implements IChatModel {
* @internal Used by ChatService to remove a pending request
*/
removePendingRequest(id: string): void {
const index = this._pendingRequests.findIndex(r => r.id === id);
const index = this._pendingRequests.findIndex(r => r.request.id === id);
if (index !== -1) {
this._pendingRequests.splice(index, 1);
this._onDidChangePendingRequests.fire();
@@ -2049,6 +2078,11 @@ export class ChatModel extends Disposable implements IChatModel {
this._repoData = isValidFullData && initialData.repoData ? initialData.repoData : undefined;
// Hydrate pending requests from serialized data
if (isValidFullData && initialData.pendingRequests) {
this._pendingRequests = this._deserializePendingRequests(initialData.pendingRequests);
}
this._initialLocation = initialData?.initialLocation ?? initialModelProps.initialLocation;
this._canUseTools = initialModelProps.canUseTools;
@@ -2152,72 +2186,74 @@ export class ChatModel extends Disposable implements IChatModel {
}
try {
return requests.map((raw: ISerializableChatRequestData) => {
const parsedRequest =
typeof raw.message === 'string'
? this.getParsedRequestFromString(raw.message)
: reviveParsedChatRequest(raw.message);
// Old messages don't have variableData, or have it in the wrong (non-array) shape
const variableData: IChatRequestVariableData = this.reviveVariableData(raw.variableData);
const request = new ChatRequestModel({
session: this,
message: parsedRequest,
variableData,
timestamp: raw.timestamp ?? -1,
restoredId: raw.requestId,
confirmation: raw.confirmation,
editedFileEvents: raw.editedFileEvents,
modelId: raw.modelId,
});
request.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend;
// eslint-disable-next-line @typescript-eslint/no-explicit-any, local/code-no-any-casts
if (raw.response || raw.result || (raw as any).responseErrorDetails) {
const agent = (raw.agent && 'metadata' in raw.agent) ? // Check for the new format, ignore entries in the old format
reviveSerializedAgent(raw.agent) : undefined;
// Port entries from old format
const result = 'responseErrorDetails' in raw ?
// eslint-disable-next-line local/code-no-dangerous-type-assertions
{ errorDetails: raw.responseErrorDetails } as IChatAgentResult : raw.result;
let modelState = raw.modelState || { value: raw.isCanceled ? ResponseModelState.Cancelled : ResponseModelState.Complete, completedAt: Date.now() };
if (modelState.value === ResponseModelState.Pending || modelState.value === ResponseModelState.NeedsInput) {
modelState = { value: ResponseModelState.Cancelled, completedAt: Date.now() };
}
request.response = new ChatResponseModel({
responseContent: raw.response ?? [new MarkdownString(raw.response)],
session: this,
agent,
slashCommand: raw.slashCommand,
requestId: request.id,
modelState,
vote: raw.vote,
timestamp: raw.timestamp,
voteDownReason: raw.voteDownReason,
result,
followups: raw.followups,
restoredId: raw.responseId,
timeSpentWaiting: raw.timeSpentWaiting,
shouldBeBlocked: request.shouldBeBlocked.get(),
codeBlockInfos: raw.responseMarkdownInfo?.map<ICodeBlockInfo>(info => ({ suggestionId: info.suggestionId })),
});
request.response.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend;
if (raw.usedContext) { // @ulugbekna: if this's a new vscode sessions, doc versions are incorrect anyway?
request.response.applyReference(revive(raw.usedContext));
}
raw.contentReferences?.forEach(r => request.response!.applyReference(revive(r)));
raw.codeCitations?.forEach(c => request.response!.applyCodeCitation(revive(c)));
}
return request;
});
return requests.map(r => this._deserializeRequest(r));
} catch (error) {
this.logService.error('Failed to parse chat data', error);
return [];
}
}
private _deserializeRequest(raw: ISerializableChatRequestData): ChatRequestModel {
const parsedRequest =
typeof raw.message === 'string'
? this.getParsedRequestFromString(raw.message)
: reviveParsedChatRequest(raw.message);
// Old messages don't have variableData, or have it in the wrong (non-array) shape
const variableData: IChatRequestVariableData = this.reviveVariableData(raw.variableData);
const request = new ChatRequestModel({
session: this,
message: parsedRequest,
variableData,
timestamp: raw.timestamp ?? -1,
restoredId: raw.requestId,
confirmation: raw.confirmation,
editedFileEvents: raw.editedFileEvents,
modelId: raw.modelId,
});
request.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend;
// eslint-disable-next-line @typescript-eslint/no-explicit-any, local/code-no-any-casts
if (raw.response || raw.result || (raw as any).responseErrorDetails) {
const agent = (raw.agent && 'metadata' in raw.agent) ? // Check for the new format, ignore entries in the old format
reviveSerializedAgent(raw.agent) : undefined;
// Port entries from old format
const result = 'responseErrorDetails' in raw ?
// eslint-disable-next-line local/code-no-dangerous-type-assertions
{ errorDetails: raw.responseErrorDetails } as IChatAgentResult : raw.result;
let modelState = raw.modelState || { value: raw.isCanceled ? ResponseModelState.Cancelled : ResponseModelState.Complete, completedAt: Date.now() };
if (modelState.value === ResponseModelState.Pending || modelState.value === ResponseModelState.NeedsInput) {
modelState = { value: ResponseModelState.Cancelled, completedAt: Date.now() };
}
request.response = new ChatResponseModel({
responseContent: raw.response ?? [new MarkdownString(raw.response)],
session: this,
agent,
slashCommand: raw.slashCommand,
requestId: request.id,
modelState,
vote: raw.vote,
timestamp: raw.timestamp,
voteDownReason: raw.voteDownReason,
result,
followups: raw.followups,
restoredId: raw.responseId,
timeSpentWaiting: raw.timeSpentWaiting,
shouldBeBlocked: request.shouldBeBlocked.get(),
codeBlockInfos: raw.responseMarkdownInfo?.map<ICodeBlockInfo>(info => ({ suggestionId: info.suggestionId })),
});
request.response.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend;
if (raw.usedContext) { // @ulugbekna: if this's a new vscode sessions, doc versions are incorrect anyway?
request.response.applyReference(revive(raw.usedContext));
}
raw.contentReferences?.forEach(r => request.response!.applyReference(revive(r)));
raw.codeCitations?.forEach(c => request.response!.applyCodeCitation(revive(c)));
}
return request;
}
private reviveVariableData(raw: IChatRequestVariableData): IChatRequestVariableData {
const variableData = raw && Array.isArray(raw.variables)
? raw :
@@ -2237,6 +2273,29 @@ export class ChatModel extends Disposable implements IChatModel {
};
}
/**
* Hydrates pending requests from serialized data.
* For each serialized pending request, finds the matching request model and adds it to the pending queue.
*/
private _deserializePendingRequests(pendingRequests: ISerializablePendingRequestData[]): IChatPendingRequest[] {
try {
return pendingRequests.map(pending => ({
id: pending.id,
request: this._deserializeRequest(pending.request),
kind: pending.kind,
sendOptions: {
...pending.sendOptions,
userSelectedTools: pending.sendOptions.userSelectedTools
? constObservable(pending.sendOptions.userSelectedTools)
: undefined,
}
}));
} catch (e) {
this.logService.error('Failed to parse pending chat requests', e);
return [];
}
}
getRequests(): ChatRequestModel[] {
@@ -2553,6 +2612,26 @@ export function getCodeCitationsMessage(citations: ReadonlyArray<IChatCodeCitati
return label;
}
/**
* Converts IChatSendRequestOptions to a serializable format by extracting only
* serializable fields and converting observables to static values.
*/
export function serializeSendOptions(options: IChatSendRequestOptions): ISerializableSendOptions {
return {
modeInfo: options.modeInfo,
userSelectedModelId: options.userSelectedModelId,
userSelectedTools: options.userSelectedTools?.get(),
location: options.location,
locationData: options.locationData,
attempt: options.attempt,
noCommandDetection: options.noCommandDetection,
agentId: options.agentId,
agentIdSilent: options.agentIdSilent,
slashCommand: options.slashCommand,
confirmation: options.confirmation,
};
}
export enum ChatRequestEditedFileEventKind {
Keep = 1,
Undo = 2,
@@ -12,7 +12,7 @@ import { URI, UriComponents } from '../../../../../base/common/uri.js';
import { IChatMarkdownContent, ResponseModelState } from '../chatService/chatService.js';
import { ModifiedFileEntryState } from '../editing/chatEditingService.js';
import { IParsedChatRequest } from '../requestParser/chatParserTypes.js';
import { IChatAgentEditedFileEvent, IChatDataSerializerLog, IChatModel, IChatProgressResponseContent, IChatRequestModel, IChatRequestVariableData, ISerializableChatData, ISerializableChatModelInputState, ISerializableChatRequestData, SerializedChatResponsePart } from './chatModel.js';
import { IChatAgentEditedFileEvent, IChatDataSerializerLog, IChatModel, IChatPendingRequest, IChatProgressResponseContent, IChatRequestModel, IChatRequestVariableData, ISerializableChatData, ISerializableChatModelInputState, ISerializableChatRequestData, ISerializablePendingRequestData, SerializedChatResponsePart, serializeSendOptions } from './chatModel.js';
import * as Adapt from './objectMutationLog.js';
/**
@@ -159,6 +159,13 @@ const inputStateSchema = Adapt.object<ISerializableChatModelInputState, ISeriali
contrib: Adapt.v(i => i.contrib, objectsEqual),
});
const pendingRequestSchema = Adapt.object<IChatPendingRequest, ISerializablePendingRequestData>({
id: Adapt.t(p => p.request.id, Adapt.key()),
request: Adapt.t(p => p.request, requestSchema),
kind: Adapt.v(p => p.kind),
sendOptions: Adapt.v(p => serializeSendOptions(p.sendOptions), objectsEqual),
});
export const storageSchema = Adapt.object<IChatModel, ISerializableChatData>({
version: Adapt.v(() => 3),
creationDate: Adapt.v(m => m.timestamp),
@@ -170,6 +177,7 @@ export const storageSchema = Adapt.object<IChatModel, ISerializableChatData>({
requests: Adapt.t(m => m.getRequests(), Adapt.array(requestSchema)),
hasPendingEdits: Adapt.v(m => m.editingSession?.entries.get().some(e => e.state.get() === ModifiedFileEntryState.Modified)),
repoData: Adapt.v(m => m.repoData, objectsEqual),
pendingRequests: Adapt.t(m => m.getPendingRequests(), Adapt.array(pendingRequestSchema)),
});
export class ChatSessionOperationLog extends Adapt.ObjectMutationLog<IChatModel, ISerializableChatData> implements IChatDataSerializerLog {
@@ -62,6 +62,10 @@ class MockChatService implements IChatService {
}
processPendingRequests(sessionResource: URI): void {
}
setLiveSessionItems(items: IChatDetail[]): void {
this.liveSessionItems = items;
}
@@ -67,6 +67,9 @@ export class MockChatService implements IChatService {
}
appendProgress(request: IChatRequestModel, progress: IChatProgress): void {
}
processPendingRequests(sessionResource: URI): void {
}
/**
* Returns whether the request was accepted.
@@ -25,9 +25,9 @@ import { TestExtensionService, TestStorageService } from '../../../../../test/co
import { CellUri } from '../../../../notebook/common/notebookCommon.js';
import { IChatRequestImplicitVariableEntry, IChatRequestStringVariableEntry, IChatRequestFileEntry, StringChatContextValue } from '../../../common/attachments/chatVariableEntries.js';
import { ChatAgentService, IChatAgentService } from '../../../common/participants/chatAgents.js';
import { ChatModel, IExportableChatData, ISerializableChatData1, ISerializableChatData2, ISerializableChatData3, isExportableSessionData, isSerializableSessionData, normalizeSerializableChatData, Response } from '../../../common/model/chatModel.js';
import { ChatModel, ChatRequestModel, IExportableChatData, ISerializableChatData1, ISerializableChatData2, ISerializableChatData3, isExportableSessionData, isSerializableSessionData, normalizeSerializableChatData, Response } from '../../../common/model/chatModel.js';
import { ChatRequestTextPart } from '../../../common/requestParser/chatParserTypes.js';
import { IChatService, IChatToolInvocation } from '../../../common/chatService/chatService.js';
import { ChatRequestQueueKind, IChatService, IChatToolInvocation } from '../../../common/chatService/chatService.js';
import { ChatAgentLocation } from '../../../common/constants.js';
import { MockChatService } from '../chatService/mockChatService.js';
@@ -738,3 +738,222 @@ suite('ChatResponseModel', () => {
}
});
});
suite('ChatModel - Pending Requests', () => {
const testDisposables = ensureNoDisposablesAreLeakedInTestSuite();
let instantiationService: TestInstantiationService;
function createModel(): ChatModel {
return testDisposables.add(instantiationService.createInstance(
ChatModel,
undefined,
{ initialLocation: ChatAgentLocation.Chat, canUseTools: true }
));
}
function addRequestToModel(model: ChatModel, text: string): ChatRequestModel {
return model.addRequest(
{ text, parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, text.length, 1, text.length), text)] },
{ variables: [] },
0
);
}
setup(async () => {
instantiationService = testDisposables.add(new TestInstantiationService());
instantiationService.stub(IStorageService, testDisposables.add(new TestStorageService()));
instantiationService.stub(ILogService, new NullLogService());
instantiationService.stub(IExtensionService, new TestExtensionService());
instantiationService.stub(IContextKeyService, new MockContextKeyService());
instantiationService.stub(IChatAgentService, testDisposables.add(instantiationService.createInstance(ChatAgentService)));
instantiationService.stub(IConfigurationService, new TestConfigurationService());
instantiationService.stub(IChatService, new MockChatService());
});
test('addPendingRequest - queued messages are added at the end', () => {
const model = createModel();
const request1 = addRequestToModel(model, 'first');
const request2 = addRequestToModel(model, 'second');
model.addPendingRequest(request1, ChatRequestQueueKind.Queued, {});
model.addPendingRequest(request2, ChatRequestQueueKind.Queued, {});
const pending = model.getPendingRequests();
assert.strictEqual(pending.length, 2);
assert.strictEqual(pending[0].request.id, request1.id);
assert.strictEqual(pending[1].request.id, request2.id);
});
test('addPendingRequest - steering messages are inserted before queued messages', () => {
const model = createModel();
const queued = addRequestToModel(model, 'queued');
const steering = addRequestToModel(model, 'steering');
model.addPendingRequest(queued, ChatRequestQueueKind.Queued, {});
model.addPendingRequest(steering, ChatRequestQueueKind.Steering, {});
const pending = model.getPendingRequests();
assert.strictEqual(pending.length, 2);
assert.strictEqual(pending[0].request.id, steering.id);
assert.strictEqual(pending[0].kind, ChatRequestQueueKind.Steering);
assert.strictEqual(pending[1].request.id, queued.id);
assert.strictEqual(pending[1].kind, ChatRequestQueueKind.Queued);
});
test('addPendingRequest - multiple steering messages maintain order', () => {
const model = createModel();
const [steering1, steering2, queued] = ['s1', 's2', 'q'].map(t => addRequestToModel(model, t));
model.addPendingRequest(queued, ChatRequestQueueKind.Queued, {});
model.addPendingRequest(steering1, ChatRequestQueueKind.Steering, {});
model.addPendingRequest(steering2, ChatRequestQueueKind.Steering, {});
const pending = model.getPendingRequests();
assert.strictEqual(pending.length, 3);
assert.strictEqual(pending[0].request.id, steering1.id);
assert.strictEqual(pending[1].request.id, steering2.id);
assert.strictEqual(pending[2].request.id, queued.id);
});
test('addPendingRequest - fires onDidChangePendingRequests event', () => {
const model = createModel();
const request = addRequestToModel(model, 'test');
let eventFired = false;
testDisposables.add(model.onDidChangePendingRequests(() => { eventFired = true; }));
model.addPendingRequest(request, ChatRequestQueueKind.Queued, {});
assert.strictEqual(eventFired, true);
});
test('removePendingRequest - removes specified request', () => {
const model = createModel();
const [request1, request2] = ['r1', 'r2'].map(t => addRequestToModel(model, t));
model.addPendingRequest(request1, ChatRequestQueueKind.Queued, {});
model.addPendingRequest(request2, ChatRequestQueueKind.Queued, {});
model.removePendingRequest(request1.id);
const pending = model.getPendingRequests();
assert.strictEqual(pending.length, 1);
assert.strictEqual(pending[0].request.id, request2.id);
});
test('removePendingRequest - no-op for non-existent request', () => {
const model = createModel();
const request = addRequestToModel(model, 'test');
model.addPendingRequest(request, ChatRequestQueueKind.Queued, {});
let eventCount = 0;
testDisposables.add(model.onDidChangePendingRequests(() => { eventCount++; }));
model.removePendingRequest('non-existent-id');
assert.strictEqual(model.getPendingRequests().length, 1);
assert.strictEqual(eventCount, 0);
});
test('dequeuePendingRequest - returns and removes first request', () => {
const model = createModel();
const [request1, request2] = ['r1', 'r2'].map(t => addRequestToModel(model, t));
model.addPendingRequest(request1, ChatRequestQueueKind.Queued, {});
model.addPendingRequest(request2, ChatRequestQueueKind.Queued, {});
const dequeued = model.dequeuePendingRequest();
assert.strictEqual(dequeued?.request.id, request1.id);
assert.strictEqual(model.getPendingRequests().length, 1);
assert.strictEqual(model.getPendingRequests()[0].request.id, request2.id);
});
test('dequeuePendingRequest - returns undefined when empty', () => {
const model = createModel();
assert.strictEqual(model.dequeuePendingRequest(), undefined);
});
test('dequeuePendingRequest - fires event when request dequeued', () => {
const model = createModel();
const request = addRequestToModel(model, 'test');
model.addPendingRequest(request, ChatRequestQueueKind.Queued, {});
let eventFired = false;
testDisposables.add(model.onDidChangePendingRequests(() => { eventFired = true; }));
model.dequeuePendingRequest();
assert.strictEqual(eventFired, true);
});
test('clearPendingRequests - removes all pending requests', () => {
const model = createModel();
['r1', 'r2', 'r3'].forEach(t => {
model.addPendingRequest(addRequestToModel(model, t), ChatRequestQueueKind.Queued, {});
});
model.clearPendingRequests();
assert.strictEqual(model.getPendingRequests().length, 0);
});
test('clearPendingRequests - no event when already empty', () => {
const model = createModel();
let eventFired = false;
testDisposables.add(model.onDidChangePendingRequests(() => { eventFired = true; }));
model.clearPendingRequests();
assert.strictEqual(eventFired, false);
});
test('setPendingRequests - reorders existing pending requests', () => {
const model = createModel();
const [r1, r2, r3] = ['r1', 'r2', 'r3'].map(t => addRequestToModel(model, t));
model.addPendingRequest(r1, ChatRequestQueueKind.Queued, {});
model.addPendingRequest(r2, ChatRequestQueueKind.Queued, {});
model.addPendingRequest(r3, ChatRequestQueueKind.Steering, {});
// Reverse the order
model.setPendingRequests([
{ requestId: r2.id, kind: ChatRequestQueueKind.Queued },
{ requestId: r1.id, kind: ChatRequestQueueKind.Steering }, // Change kind
]);
const pending = model.getPendingRequests();
assert.strictEqual(pending.length, 2);
assert.strictEqual(pending[0].request.id, r2.id);
assert.strictEqual(pending[1].request.id, r1.id);
assert.strictEqual(pending[1].kind, ChatRequestQueueKind.Steering);
});
test('setPendingRequests - ignores non-existent request IDs', () => {
const model = createModel();
const request = addRequestToModel(model, 'test');
model.addPendingRequest(request, ChatRequestQueueKind.Queued, {});
model.setPendingRequests([
{ requestId: 'non-existent', kind: ChatRequestQueueKind.Queued },
{ requestId: request.id, kind: ChatRequestQueueKind.Queued },
]);
const pending = model.getPendingRequests();
assert.strictEqual(pending.length, 1);
assert.strictEqual(pending[0].request.id, request.id);
});
test('pending requests preserve send options', () => {
const model = createModel();
const request = addRequestToModel(model, 'test');
const sendOptions = { agentId: 'test-agent', attempt: 3 };
const pending = model.addPendingRequest(request, ChatRequestQueueKind.Queued, sendOptions);
assert.strictEqual(pending.sendOptions.agentId, 'test-agent');
assert.strictEqual(pending.sendOptions.attempt, 3);
});
});