Skip to content

Commit 135c59a

Browse files
authored
Fix issue with "reun without" while request is pending (microsoft#213926)
- send out `ChatRequestRemovalReason` - make inline chat re-enter `SHOW_REQUEST` when resending an active request - the `SHOW_REQUEST` state should be prepared to come after the request has started - more cancellation checking with `asProgressiveEdit` - add tests microsoft/vscode-copilot#5997
1 parent 576e1dc commit 135c59a

File tree

5 files changed

+170
-59
lines changed

5 files changed

+170
-59
lines changed

src/vs/workbench/contrib/chat/common/chatModel.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -529,10 +529,28 @@ export interface IChatAddResponseEvent {
529529
response: IChatResponseModel;
530530
}
531531

532+
export const enum ChatRequestRemovalReason {
533+
/**
534+
* "Normal" remove
535+
*/
536+
Removal,
537+
538+
/**
539+
* Removed because the request will be resent
540+
*/
541+
Resend,
542+
543+
/**
544+
* Remove because the request is moving to another model
545+
*/
546+
Adoption
547+
}
548+
532549
export interface IChatRemoveRequestEvent {
533550
kind: 'removeRequest';
534551
requestId: string;
535552
responseId?: string;
553+
reason: ChatRequestRemovalReason;
536554
}
537555

538556
export interface IChatInitEvent {
@@ -804,7 +822,7 @@ export class ChatModel extends Disposable implements IChatModel {
804822
request.response?.adoptTo(this);
805823
this._requests.push(request);
806824

807-
oldOwner._onDidChange.fire({ kind: 'removeRequest', requestId: request.id, responseId: request.response?.id });
825+
oldOwner._onDidChange.fire({ kind: 'removeRequest', requestId: request.id, responseId: request.response?.id, reason: ChatRequestRemovalReason.Adoption });
808826
this._onDidChange.fire({ kind: 'addRequest', request });
809827
}
810828

@@ -841,12 +859,12 @@ export class ChatModel extends Disposable implements IChatModel {
841859
}
842860
}
843861

844-
removeRequest(id: string): void {
862+
removeRequest(id: string, reason: ChatRequestRemovalReason = ChatRequestRemovalReason.Removal): void {
845863
const index = this._requests.findIndex(request => request.id === id);
846864
const request = this._requests[index];
847865

848866
if (index !== -1) {
849-
this._onDidChange.fire({ kind: 'removeRequest', requestId: request.id, responseId: request.response?.id });
867+
this._onDidChange.fire({ kind: 'removeRequest', requestId: request.id, responseId: request.response?.id, reason });
850868
this._requests.splice(index, 1);
851869
request.response?.dispose();
852870
}

src/vs/workbench/contrib/chat/common/chatServiceImpl.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storag
2424
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
2525
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
2626
import { ChatAgentLocation, IChatAgent, IChatAgentRequest, IChatAgentResult, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents';
27-
import { ChatModel, ChatRequestModel, ChatWelcomeMessageModel, IChatModel, IChatRequestModel, IChatRequestVariableData, IChatRequestVariableEntry, IChatResponseModel, IExportableChatData, ISerializableChatData, ISerializableChatsData, getHistoryEntriesFromModel, updateRanges } from 'vs/workbench/contrib/chat/common/chatModel';
27+
import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, ChatWelcomeMessageModel, IChatModel, IChatRequestModel, IChatRequestVariableData, IChatRequestVariableEntry, IChatResponseModel, IExportableChatData, ISerializableChatData, ISerializableChatsData, getHistoryEntriesFromModel, updateRanges } from 'vs/workbench/contrib/chat/common/chatModel';
2828
import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, IParsedChatRequest, chatAgentLeader, chatSubcommandLeader, getPromptText } from 'vs/workbench/contrib/chat/common/chatParserTypes';
2929
import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser';
3030
import { ChatCopyKind, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatProgress, IChatSendRequestData, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatTransferredSessionData, IChatUserActionEvent, ChatAgentVoteDirection } from 'vs/workbench/contrib/chat/common/chatService';
@@ -418,17 +418,18 @@ export class ChatService extends Disposable implements IChatService {
418418

419419
await model.waitForInitialization();
420420

421-
if (this._pendingRequests.has(request.session.sessionId)) {
422-
this.trace('sendRequest', `Session ${request.session.sessionId} already has a pending request`);
423-
return;
421+
const cts = this._pendingRequests.get(request.session.sessionId);
422+
if (cts) {
423+
this.trace('resendRequest', `Session ${request.session.sessionId} already has a pending request, cancelling...`);
424+
cts.cancel();
424425
}
425426

426427
const location = options?.location ?? model.initialLocation;
427428
const attempt = options?.attempt ?? 0;
428429
const enableCommandDetection = !options?.noCommandDetection;
429430
const defaultAgent = this.chatAgentService.getDefaultAgent(location)!;
430431

431-
this.removeRequest(model.sessionId, request.id);
432+
model.removeRequest(request.id, ChatRequestRemovalReason.Resend);
432433

433434
await this._sendRequestAsync(model, model.sessionId, request.message, attempt, enableCommandDetection, defaultAgent, location, options).responseCompletePromise;
434435
}

src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts

Lines changed: 58 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import { StashedSession } from './inlineChatSession';
4040
import { IModelDeltaDecoration, ITextModel, IValidEditOperation } from 'vs/editor/common/model';
4141
import { InlineChatContentWidget } from 'vs/workbench/contrib/inlineChat/browser/inlineChatContentWidget';
4242
import { MessageController } from 'vs/editor/contrib/message/browser/messageController';
43-
import { ChatModel, IChatRequestModel, IChatTextEditGroup, IChatTextEditGroupState, IResponse } from 'vs/workbench/contrib/chat/common/chatModel';
43+
import { ChatModel, ChatRequestRemovalReason, IChatRequestModel, IChatTextEditGroup, IChatTextEditGroupState, IResponse } from 'vs/workbench/contrib/chat/common/chatModel';
4444
import { InlineChatError } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl';
4545
import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures';
4646
import { ChatInputPart } from 'vs/workbench/contrib/chat/browser/chatInputPart';
@@ -445,10 +445,16 @@ export class InlineChatController implements IEditorContribution {
445445
const exchange = this._session!.exchanges.find(candidate => candidate.prompt.request.id === e.requestId);
446446
if (exchange && this._editor.hasModel()) {
447447
// undo till this point
448-
const model = this._editor.getModel();
449-
const targetAltVersion = exchange.prompt.modelAltVersionId;
450-
while (targetAltVersion < model.getAlternativeVersionId() && model.canUndo()) {
451-
await model.undo();
448+
this._session!.hunkData.ignoreTextModelNChanges = true;
449+
try {
450+
451+
const model = this._editor.getModel();
452+
const targetAltVersion = exchange.prompt.modelAltVersionId;
453+
while (targetAltVersion < model.getAlternativeVersionId() && model.canUndo()) {
454+
await model.undo();
455+
}
456+
} finally {
457+
this._session!.hunkData.ignoreTextModelNChanges = false;
452458
}
453459
}
454460
}
@@ -641,7 +647,7 @@ export class InlineChatController implements IEditorContribution {
641647
const progressiveEditsClock = StopWatch.create();
642648
const progressiveEditsQueue = new Queue();
643649

644-
let next: State.SHOW_RESPONSE | State.CANCEL | State.PAUSE | State.ACCEPT | State.WAIT_FOR_INPUT = State.SHOW_RESPONSE;
650+
let next: State.SHOW_RESPONSE | State.SHOW_REQUEST | State.CANCEL | State.PAUSE | State.ACCEPT | State.WAIT_FOR_INPUT = State.SHOW_RESPONSE;
645651
store.add(Event.once(this._messages.event)(message => {
646652
this._log('state=_makeRequest) message received', message);
647653
this._chatService.cancelCurrentRequestForSession(chatModel.sessionId);
@@ -658,7 +664,11 @@ export class InlineChatController implements IEditorContribution {
658664
if (e.kind === 'removeRequest' && e.requestId === request.id) {
659665
progressiveEditsCts.cancel();
660666
responsePromise.complete();
661-
next = State.CANCEL;
667+
if (e.reason === ChatRequestRemovalReason.Resend) {
668+
next = State.SHOW_REQUEST;
669+
} else {
670+
next = State.CANCEL;
671+
}
662672
}
663673
}));
664674

@@ -678,61 +688,59 @@ export class InlineChatController implements IEditorContribution {
678688
let localEditGroup: IChatTextEditGroup | undefined;
679689

680690
// apply edits
681-
store.add(response.onDidChange(() => {
682-
683-
if (response.isCanceled) {
684-
progressiveEditsCts.cancel();
685-
responsePromise.complete();
686-
return;
687-
}
688-
689-
if (response.isComplete) {
690-
responsePromise.complete();
691-
return;
692-
}
691+
const handleResponse = () => {
693692

694693
if (!localEditGroup) {
695-
localEditGroup = <IChatTextEditGroup>response.response.value.find(part => part.kind === 'textEditGroup' && isEqual(part.uri, this._session?.textModelN.uri));
694+
localEditGroup = <IChatTextEditGroup | undefined>response.response.value.find(part => part.kind === 'textEditGroup' && isEqual(part.uri, this._session?.textModelN.uri));
696695
}
697696

698-
if (!localEditGroup) {
699-
return;
700-
}
697+
if (localEditGroup) {
701698

702-
localEditGroup.state ??= editState;
699+
localEditGroup.state ??= editState;
703700

704-
const edits = localEditGroup.edits;
705-
const newEdits = edits.slice(lastLength);
706-
if (newEdits.length === 0) {
707-
return; // NO change
708-
}
709-
lastLength = edits.length;
710-
progressiveEditsAvgDuration.update(progressiveEditsClock.elapsed());
711-
progressiveEditsClock.reset();
701+
const edits = localEditGroup.edits;
702+
const newEdits = edits.slice(lastLength);
703+
if (newEdits.length > 0) {
704+
// NEW changes
705+
lastLength = edits.length;
706+
progressiveEditsAvgDuration.update(progressiveEditsClock.elapsed());
707+
progressiveEditsClock.reset();
712708

713-
progressiveEditsQueue.queue(async () => {
709+
progressiveEditsQueue.queue(async () => {
714710

715-
const startThen = this._session!.wholeRange.value.getStartPosition();
711+
const startThen = this._session!.wholeRange.value.getStartPosition();
716712

717-
// making changes goes into a queue because otherwise the async-progress time will
718-
// influence the time it takes to receive the changes and progressive typing will
719-
// become infinitely fast
720-
for (const edits of newEdits) {
721-
await this._makeChanges(edits, {
722-
duration: progressiveEditsAvgDuration.value,
723-
token: progressiveEditsCts.token
724-
}, isFirstChange);
713+
// making changes goes into a queue because otherwise the async-progress time will
714+
// influence the time it takes to receive the changes and progressive typing will
715+
// become infinitely fast
716+
for (const edits of newEdits) {
717+
await this._makeChanges(edits, {
718+
duration: progressiveEditsAvgDuration.value,
719+
token: progressiveEditsCts.token
720+
}, isFirstChange);
725721

726-
isFirstChange = false;
727-
}
722+
isFirstChange = false;
723+
}
728724

729-
// reshow the widget if the start position changed or shows at the wrong position
730-
const startNow = this._session!.wholeRange.value.getStartPosition();
731-
if (!startNow.equals(startThen) || !this._ui.value.zone.position?.equals(startNow)) {
732-
this._showWidget(false, startNow.delta(-1));
725+
// reshow the widget if the start position changed or shows at the wrong position
726+
const startNow = this._session!.wholeRange.value.getStartPosition();
727+
if (!startNow.equals(startThen) || !this._ui.value.zone.position?.equals(startNow)) {
728+
this._showWidget(false, startNow.delta(-1));
729+
}
730+
});
733731
}
734-
});
735-
}));
732+
}
733+
734+
if (response.isCanceled) {
735+
progressiveEditsCts.cancel();
736+
responsePromise.complete();
737+
738+
} else if (response.isComplete) {
739+
responsePromise.complete();
740+
}
741+
};
742+
store.add(response.onDidChange(handleResponse));
743+
handleResponse();
736744

737745
// (1) we must wait for the request to finish
738746
// (2) we must wait for all edits that came in via progress to complete

src/vs/workbench/contrib/inlineChat/browser/utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ export function asProgressiveEdit(interval: IntervalTimer, edit: IIdentifiedSing
6464
let newText = edit.text ?? '';
6565

6666
interval.cancelAndSet(() => {
67+
if (token.isCancellationRequested) {
68+
return;
69+
}
6770
const r = getNWords(newText, 1);
6871
stream.emitOne(r.value);
6972
newText = newText.substring(r.value.length);

src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import * as assert from 'assert';
77
import { equals } from 'vs/base/common/arrays';
8-
import { timeout } from 'vs/base/common/async';
8+
import { raceCancellation, timeout } from 'vs/base/common/async';
99
import { Emitter, Event } from 'vs/base/common/event';
1010
import { DisposableStore } from 'vs/base/common/lifecycle';
1111
import { mock } from 'vs/base/test/common/mock';
@@ -62,6 +62,7 @@ import { TestCommandService } from 'vs/editor/test/browser/editorTestServices';
6262
import { INotebookEditorService } from 'vs/workbench/contrib/notebook/browser/services/notebookEditorService';
6363
import { RerunAction } from 'vs/workbench/contrib/inlineChat/browser/inlineChatActions';
6464
import { CancellationToken } from 'vs/base/common/cancellation';
65+
import { assertType } from 'vs/base/common/types';
6566

6667
suite('InteractiveChatController', function () {
6768

@@ -642,4 +643,84 @@ suite('InteractiveChatController', function () {
642643

643644
await r;
644645
});
646+
647+
test('Clicking "re-run without /doc" while a request is in progress closes the widget #5997', async function () {
648+
649+
model.setValue('');
650+
651+
let count = 0;
652+
const commandDetection: (boolean | undefined)[] = [];
653+
654+
store.add(chatAgentService.registerDynamicAgent({
655+
id: 'testEditorAgent2',
656+
...agentData
657+
}, {
658+
async invoke(request, progress, history, token) {
659+
commandDetection.push(request.enableCommandDetection);
660+
progress({ kind: 'textEdit', uri: model.uri, edits: [{ range: new Range(1, 1, 1, 1), text: request.message + (count++) }] });
661+
662+
if (count === 1) {
663+
// FIRST call waits for cancellation
664+
await raceCancellation(new Promise<never>(() => { }), token);
665+
} else {
666+
await timeout(10);
667+
}
668+
669+
return {};
670+
},
671+
}));
672+
ctrl = instaService.createInstance(TestController, editor);
673+
674+
// REQUEST 1
675+
const p = ctrl.waitFor([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST]);
676+
ctrl.run({ message: 'Hello-', autoSend: true });
677+
await p;
678+
679+
// resend pending request without command detection
680+
const request = ctrl.chatWidget.viewModel?.model.getRequests().at(-1);
681+
assertType(request);
682+
const p2 = ctrl.waitFor([State.SHOW_REQUEST, State.SHOW_RESPONSE]);
683+
chatService.resendRequest(request, { noCommandDetection: true, attempt: request.attempt + 1, location: ChatAgentLocation.Editor });
684+
685+
await p2;
686+
687+
assert.deepStrictEqual(commandDetection, [true, false]);
688+
assert.strictEqual(model.getValue(), 'Hello-1');
689+
});
690+
691+
test('Re-run without after request is done', async function () {
692+
693+
model.setValue('');
694+
695+
let count = 0;
696+
const commandDetection: (boolean | undefined)[] = [];
697+
698+
store.add(chatAgentService.registerDynamicAgent({
699+
id: 'testEditorAgent2',
700+
...agentData
701+
}, {
702+
async invoke(request, progress, history, token) {
703+
commandDetection.push(request.enableCommandDetection);
704+
progress({ kind: 'textEdit', uri: model.uri, edits: [{ range: new Range(1, 1, 1, 1), text: request.message + (count++) }] });
705+
return {};
706+
},
707+
}));
708+
ctrl = instaService.createInstance(TestController, editor);
709+
710+
// REQUEST 1
711+
const p = ctrl.waitFor([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]);
712+
ctrl.run({ message: 'Hello-', autoSend: true });
713+
await p;
714+
715+
// resend pending request without command detection
716+
const request = ctrl.chatWidget.viewModel?.model.getRequests().at(-1);
717+
assertType(request);
718+
const p2 = ctrl.waitFor([State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]);
719+
chatService.resendRequest(request, { noCommandDetection: true, attempt: request.attempt + 1, location: ChatAgentLocation.Editor });
720+
721+
await p2;
722+
723+
assert.deepStrictEqual(commandDetection, [true, false]);
724+
assert.strictEqual(model.getValue(), 'Hello-1');
725+
});
645726
});

0 commit comments

Comments
 (0)