From 0f6461f9a1e46cfd8d93c1027267385c4954c92d Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 3 Feb 2026 22:17:50 -0800 Subject: [PATCH] chat: polishes for steering/queued messages Steering messages are not quite wired up in the extension but I'm happy with the UX. Quick runthrough of various states: https://memes.peet.io/img/26-02-81ac36ef-1319-46b2-9093-3c05628291dc.mp4 Incidentally fixes #278614 since I ran into that same bug here. Refs #260330 --- .../browser/menuEntryActionViewItem.ts | 18 +- .../browser/actions/chatExecuteActions.ts | 3 +- .../chat/browser/actions/chatQueueActions.ts | 147 +++++++++++- .../chat/browser/widget/chatListRenderer.ts | 8 +- .../contrib/chat/browser/widget/chatWidget.ts | 71 +++++- .../chat/browser/widget/media/chat.css | 13 +- .../chat/common/actions/chatContextKeys.ts | 2 +- .../chat/common/chatService/chatService.ts | 7 + .../common/chatService/chatServiceImpl.ts | 85 ++++--- .../contrib/chat/common/model/chatModel.ts | 211 +++++++++++------ .../common/model/chatSessionOperationLog.ts | 10 +- .../localAgentSessionsProvider.test.ts | 4 + .../common/chatService/mockChatService.ts | 3 + .../chat/test/common/model/chatModel.test.ts | 223 +++++++++++++++++- 14 files changed, 680 insertions(+), 125 deletions(-) diff --git a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts index 1b3e2d595b6b..96e950596204 100644 --- a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts +++ b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts @@ -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 { 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 { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 3e4f7655a1e2..7605b5b69fe7 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -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', diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts index c4f3d740741b..bac6879be0dd 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts @@ -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 } }); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 53368319cefc..383d91fa9f05 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -648,6 +648,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(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; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 07aa3be77dca..ce12dece5d17 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -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; private readonly _agentSupportsAttachmentsContextKey: IContextKey; private readonly _sessionIsEmptyContextKey: IContextKey; + private readonly _hasPendingRequestsContextKey: IContextKey; 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 { - 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 { + 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 { return { modeInfo: this.input.currentModeInfo, diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 887cbf1bf027..d94ccd6dbacf 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -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 { diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 2c31f62bdd0d..4331b69fa59b 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -23,6 +23,7 @@ export namespace ChatContextKeys { export const isResponse = new RawContextKey('chatResponse', false, { type: 'boolean', description: localize('chatResponse', "The chat item is a response.") }); export const isRequest = new RawContextKey('chatRequest', false, { type: 'boolean', description: localize('chatRequest', "The chat item is a request") }); + export const isPendingRequest = new RawContextKey('chatRequestIsPending', false, { type: 'boolean', description: localize('chatRequestIsPending', "True when the chat request item is pending in the queue.") }); export const itemId = new RawContextKey('chatItemId', '', { type: 'string', description: localize('chatItemId', "The id of the chat item.") }); export const lastItemId = new RawContextKey('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('chatHasFileAttachments', false, { type: 'boolean', description: localize('chatHasFileAttachments', "True when the chat has file attachments.") }); export const chatSessionIsEmpty = new RawContextKey('chatSessionIsEmpty', true, { type: 'boolean', description: localize('chatSessionIsEmpty', "True when the current chat session has no requests.") }); export const hasPendingRequests = new RawContextKey('chatHasPendingRequests', false, { type: 'boolean', description: localize('chatHasPendingRequests', "True when there are pending requests in the queue.") }); - export const pendingRequestCount = new RawContextKey('chatPendingRequestCount', 0, { type: 'number', description: localize('chatPendingRequestCount', "Number of pending requests in the queue.") }); export const remoteJobCreating = new RawContextKey('chatRemoteJobCreating', false, { type: 'boolean', description: localize('chatRemoteJobCreating', "True when a remote coding agent job is being created.") }); export const hasRemoteCodingAgent = new RawContextKey('hasRemoteCodingAgent', false, localize('hasRemoteCodingAgent', "Whether any remote coding agent is available")); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 08a63e50dbd1..ff9fdf7e038b 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -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; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 2fe2f182f672..0f72bc13a665 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -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'; @@ -726,6 +726,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(); + 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 { this.trace('sendRequest', `sessionResource: ${sessionResource.toString()}, message: ${request.substring(0, 20)}${request.length > 20 ? '[...]' : ''}}`); @@ -740,41 +768,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(); - 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]; @@ -1076,6 +1088,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 => { @@ -1105,13 +1118,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 { @@ -1120,6 +1136,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. @@ -1132,8 +1155,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; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index c27ff320162f..056abf3a60f0 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -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 = { png: 'image/png', jpg: 'image/jpeg', @@ -1479,6 +1507,8 @@ export interface ISerializableChatData3 extends Omit [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(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(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 i.contrib, objectsEqual), }); +const pendingRequestSchema = Adapt.object({ + 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({ version: Adapt.v(() => 3), creationDate: Adapt.v(m => m.timestamp), @@ -170,6 +177,7 @@ export const storageSchema = Adapt.object({ 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 implements IChatDataSerializerLog { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts index 104a1bd528f0..405472354099 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts @@ -62,6 +62,10 @@ class MockChatService implements IChatService { } + processPendingRequests(sessionResource: URI): void { + + } + setLiveSessionItems(items: IChatDetail[]): void { this.liveSessionItems = items; } diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts index 2faa8332a53a..68c2d724b002 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts @@ -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. diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index ee07a7c00d42..934d86152e6b 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -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); + }); +});