Skip to content

Commit a36bccb

Browse files
authored
Support creating a new notebook via edit tool (#244075)
* Support creating a new notebook via edit tool * Updates * oops
1 parent 90dde06 commit a36bccb

File tree

4 files changed

+234
-9
lines changed

4 files changed

+234
-9
lines changed

src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { IExtensionService } from '../../../../services/extensions/common/extens
3333
import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js';
3434
import { IMultiDiffSourceResolver, IMultiDiffSourceResolverService, IResolvedMultiDiffSource, MultiDiffEditorItem } from '../../../multiDiffEditor/browser/multiDiffSourceResolverService.js';
3535
import { CellUri } from '../../../notebook/common/notebookCommon.js';
36+
import { INotebookService } from '../../../notebook/common/notebookService.js';
3637
import { IChatAgentService } from '../../common/chatAgents.js';
3738
import { CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingAgentSupportsReadonlyReferencesContextKey, chatEditingResourceContextKey, ChatEditingSessionState, chatEditingSnapshotScheme, IChatEditingService, IChatEditingSession, IChatRelatedFile, IChatRelatedFilesProvider, IModifiedFileEntry, inChatEditingSessionContextKey, IStreamingEdits, WorkingSetEntryState } from '../../common/chatEditingService.js';
3839
import { IChatResponseModel, isCellTextEditOperation } from '../../common/chatModel.js';
@@ -75,6 +76,7 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
7576
@ILogService logService: ILogService,
7677
@IExtensionService extensionService: IExtensionService,
7778
@IProductService productService: IProductService,
79+
@INotebookService private readonly notebookService: INotebookService
7880
) {
7981
super();
8082
this._register(decorationsService.registerDecorationsProvider(_instantiationService.createInstance(ChatDecorationsProvider, this.editingSessionsObs)));
@@ -254,7 +256,8 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
254256
return;
255257
}
256258

257-
editedFilesExist.set(uri, this._fileService.exists(uri).then((e) => {
259+
const fileExists = this.notebookService.getNotebookTextModel(uri) ? Promise.resolve(true) : this._fileService.exists(uri);
260+
editedFilesExist.set(uri, fileExists.then((e) => {
258261
if (!e) {
259262
return;
260263
}

src/vs/workbench/contrib/chat/common/tools/editFileTool.ts

+7-7
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,10 @@ export class EditTool implements IToolImpl {
9090
}
9191

9292
const parameters = invocation.parameters as EditToolParams;
93-
const uri = URI.revive(parameters.file); // TODO@roblourens do revive in MainThreadLanguageModelTools
93+
const fileUri = URI.revive(parameters.file); // TODO@roblourens do revive in MainThreadLanguageModelTools
94+
const uri = CellUri.parse(fileUri)?.notebook || fileUri;
9495

95-
if (!this.workspaceContextService.isInsideWorkspace(uri)) {
96+
if (!this.workspaceContextService.isInsideWorkspace(uri) && !this.notebookService.getNotebookTextModel(uri)) {
9697
const groupsByLastActive = this.editorGroupsService.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE);
9798
const uriIsOpenInSomeEditor = groupsByLastActive.some((group) => {
9899
return group.editors.some((editor) => {
@@ -134,13 +135,12 @@ export class EditTool implements IToolImpl {
134135
kind: 'markdownContent',
135136
content: new MarkdownString(parameters.code + '\n````\n')
136137
});
137-
const notebookUri = CellUri.parse(uri)?.notebook || uri;
138138
// Signal start.
139-
if (this.notebookService.hasSupportedNotebooks(notebookUri) && (this.notebookService.getNotebookTextModel(notebookUri))) {
139+
if (this.notebookService.hasSupportedNotebooks(uri) && (this.notebookService.getNotebookTextModel(uri))) {
140140
model.acceptResponseProgress(request, {
141141
kind: 'notebookEdit',
142142
edits: [],
143-
uri: notebookUri
143+
uri
144144
});
145145
} else {
146146
model.acceptResponseProgress(request, {
@@ -169,8 +169,8 @@ export class EditTool implements IToolImpl {
169169
}, token);
170170

171171
// Signal end.
172-
if (this.notebookService.hasSupportedNotebooks(notebookUri) && (this.notebookService.getNotebookTextModel(notebookUri))) {
173-
model.acceptResponseProgress(request, { kind: 'notebookEdit', uri: notebookUri, edits: [], done: true });
172+
if (this.notebookService.hasSupportedNotebooks(uri) && (this.notebookService.getNotebookTextModel(uri))) {
173+
model.acceptResponseProgress(request, { kind: 'notebookEdit', uri, edits: [], done: true });
174174
} else {
175175
model.acceptResponseProgress(request, { kind: 'textEdit', uri, edits: [], done: true });
176176
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { CancellationToken } from '../../../../../base/common/cancellation.js';
7+
import { MarkdownString } from '../../../../../base/common/htmlContent.js';
8+
import { IDisposable } from '../../../../../base/common/lifecycle.js';
9+
import { autorun } from '../../../../../base/common/observable.js';
10+
import { URI, UriComponents } from '../../../../../base/common/uri.js';
11+
import { generateUuid } from '../../../../../base/common/uuid.js';
12+
import { localize } from '../../../../../nls.js';
13+
import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';
14+
import { SaveReason } from '../../../../common/editor.js';
15+
import { ITextFileService } from '../../../../services/textfile/common/textfiles.js';
16+
import { CellUri } from '../../../notebook/common/notebookCommon.js';
17+
import { INotebookService } from '../../../notebook/common/notebookService.js';
18+
import { ICodeMapperService } from '../chatCodeMapperService.js';
19+
import { IChatEditingService } from '../chatEditingService.js';
20+
import { ChatModel } from '../chatModel.js';
21+
import { IChatService } from '../chatService.js';
22+
import { ILanguageModelIgnoredFilesService } from '../ignoredFiles.js';
23+
import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolResult } from '../languageModelToolsService.js';
24+
import { IToolInputProcessor } from './tools.js';
25+
26+
const codeInstructions = `
27+
The user is very smart and can understand how to insert cells to their new Notebook files
28+
`;
29+
30+
export const ExtensionEditToolId = 'vscode_insert_notebook_cells';
31+
export const InternalEditToolId = 'vscode_insert_notebook_cells_internal';
32+
export const EditToolData: IToolData = {
33+
id: InternalEditToolId,
34+
displayName: localize('chat.tools.editFile', "Edit File"),
35+
modelDescription: `Insert cells into a new notebook n the workspace. Use this tool once per file that needs to be modified, even if there are multiple changes for a file. Generate the "explanation" property first. ${codeInstructions}`,
36+
inputSchema: {
37+
type: 'object',
38+
properties: {
39+
explanation: {
40+
type: 'string',
41+
description: 'A short explanation of the edit being made. Can be the same as the explanation you showed to the user.',
42+
},
43+
filePath: {
44+
type: 'string',
45+
description: 'An absolute path to the file to edit, or the URI of a untitled, not yet named, file, such as `untitled:Untitled-1.',
46+
},
47+
cells: {
48+
type: 'array',
49+
description: 'The cells to insert to apply to the file. ' + codeInstructions
50+
}
51+
},
52+
required: ['explanation', 'filePath', 'code']
53+
}
54+
};
55+
56+
export class EditTool implements IToolImpl {
57+
58+
constructor(
59+
@IChatService private readonly chatService: IChatService,
60+
@IChatEditingService private readonly chatEditingService: IChatEditingService,
61+
@ICodeMapperService private readonly codeMapperService: ICodeMapperService,
62+
@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,
63+
@ILanguageModelIgnoredFilesService private readonly ignoredFilesService: ILanguageModelIgnoredFilesService,
64+
@ITextFileService private readonly textFileService: ITextFileService,
65+
@INotebookService private readonly notebookService: INotebookService,
66+
) { }
67+
68+
async invoke(invocation: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise<IToolResult> {
69+
if (!invocation.context) {
70+
throw new Error('toolInvocationToken is required for this tool');
71+
}
72+
73+
const parameters = invocation.parameters as EditToolParams;
74+
const uri = URI.revive(parameters.file); // TODO@roblourens do revive in MainThreadLanguageModelTools
75+
if (!this.workspaceContextService.isInsideWorkspace(uri)) {
76+
throw new Error(`File ${uri.fsPath} can't be edited because it's not inside the current workspace`);
77+
}
78+
79+
if (await this.ignoredFilesService.fileIsIgnored(uri, token)) {
80+
throw new Error(`File ${uri.fsPath} can't be edited because it is configured to be ignored by Copilot`);
81+
}
82+
83+
const model = this.chatService.getSession(invocation.context?.sessionId) as ChatModel;
84+
const request = model.getRequests().at(-1)!;
85+
86+
// Undo stops mark groups of response data in the output. Operations, such
87+
// as text edits, that happen between undo stops are all done or undone together.
88+
if (request.response?.response.getMarkdown().length) {
89+
// slightly hacky way to avoid an extra 'no-op' undo stop at the start of responses that are just edits
90+
model.acceptResponseProgress(request, {
91+
kind: 'undoStop',
92+
id: generateUuid(),
93+
});
94+
}
95+
96+
model.acceptResponseProgress(request, {
97+
kind: 'markdownContent',
98+
content: new MarkdownString('\n````\n')
99+
});
100+
model.acceptResponseProgress(request, {
101+
kind: 'codeblockUri',
102+
uri
103+
});
104+
model.acceptResponseProgress(request, {
105+
kind: 'markdownContent',
106+
content: new MarkdownString(parameters.code + '\n````\n')
107+
});
108+
const notebookUri = CellUri.parse(uri)?.notebook || uri;
109+
// Signal start.
110+
if (this.notebookService.hasSupportedNotebooks(notebookUri) && (this.notebookService.getNotebookTextModel(notebookUri))) {
111+
model.acceptResponseProgress(request, {
112+
kind: 'notebookEdit',
113+
edits: [],
114+
uri: notebookUri
115+
});
116+
} else {
117+
model.acceptResponseProgress(request, {
118+
kind: 'textEdit',
119+
edits: [],
120+
uri
121+
});
122+
}
123+
124+
const editSession = this.chatEditingService.getEditingSession(model.sessionId);
125+
if (!editSession) {
126+
throw new Error('This tool must be called from within an editing session');
127+
}
128+
129+
const result = await this.codeMapperService.mapCode({
130+
codeBlocks: [{ code: parameters.code, resource: uri, markdownBeforeBlock: parameters.explanation }],
131+
location: 'tool',
132+
chatRequestId: invocation.chatRequestId
133+
}, {
134+
textEdit: (target, edits) => {
135+
model.acceptResponseProgress(request, { kind: 'textEdit', uri: target, edits });
136+
},
137+
notebookEdit(target, edits) {
138+
model.acceptResponseProgress(request, { kind: 'notebookEdit', uri: target, edits });
139+
},
140+
}, token);
141+
142+
// Signal end.
143+
if (this.notebookService.hasSupportedNotebooks(notebookUri) && (this.notebookService.getNotebookTextModel(notebookUri))) {
144+
model.acceptResponseProgress(request, { kind: 'notebookEdit', uri: notebookUri, edits: [], done: true });
145+
} else {
146+
model.acceptResponseProgress(request, { kind: 'textEdit', uri, edits: [], done: true });
147+
}
148+
149+
if (result?.errorMessage) {
150+
throw new Error(result.errorMessage);
151+
}
152+
153+
let dispose: IDisposable;
154+
await new Promise((resolve) => {
155+
// The file will not be modified until the first edits start streaming in,
156+
// so wait until we see that it _was_ modified before waiting for it to be done.
157+
let wasFileBeingModified = false;
158+
159+
dispose = autorun((r) => {
160+
161+
const entries = editSession.entries.read(r);
162+
const currentFile = entries?.find((e) => e.modifiedURI.toString() === uri.toString());
163+
if (currentFile) {
164+
if (currentFile.isCurrentlyBeingModifiedBy.read(r)) {
165+
wasFileBeingModified = true;
166+
} else if (wasFileBeingModified) {
167+
resolve(true);
168+
}
169+
}
170+
});
171+
}).finally(() => {
172+
dispose.dispose();
173+
});
174+
175+
await this.textFileService.save(uri, {
176+
reason: SaveReason.AUTO,
177+
skipSaveParticipants: true,
178+
});
179+
180+
return {
181+
content: [{ kind: 'text', value: 'The file was edited successfully' }]
182+
};
183+
}
184+
185+
async prepareToolInvocation(parameters: any, token: CancellationToken): Promise<IPreparedToolInvocation | undefined> {
186+
return {
187+
presentation: 'hidden'
188+
};
189+
}
190+
}
191+
192+
export interface EditToolParams {
193+
file: UriComponents;
194+
explanation: string;
195+
code: string;
196+
}
197+
198+
export interface EditToolRawParams {
199+
filePath: string;
200+
explanation: string;
201+
code: string;
202+
}
203+
204+
export class EditToolInputProcessor implements IToolInputProcessor {
205+
processInput(input: EditToolRawParams): EditToolParams {
206+
if (!input.filePath) {
207+
// Tool name collision, or input wasn't properly validated upstream
208+
return input as any;
209+
}
210+
const filePath = input.filePath;
211+
// Runs in EH, will be mapped
212+
return {
213+
file: filePath.startsWith('untitled:') ? URI.parse(filePath) : URI.file(filePath),
214+
explanation: input.explanation,
215+
code: input.code,
216+
};
217+
}
218+
}

src/vs/workbench/contrib/chat/test/browser/chatEditingService.test.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { waitForState } from '../../../../../base/common/observable.js';
3232
import { INotebookService } from '../../../notebook/common/notebookService.js';
3333
import { Range } from '../../../../../editor/common/core/range.js';
3434
import { ChatAgentLocation } from '../../common/constants.js';
35+
import { NotebookTextModel } from '../../../notebook/common/model/notebookTextModel.js';
3536

3637
function getAgentData(id: string) {
3738
return {
@@ -69,7 +70,10 @@ suite('ChatEditingService', function () {
6970
}
7071
});
7172
collection.set(INotebookService, new class extends mock<INotebookService>() {
72-
override hasSupportedNotebooks(resource: URI): boolean {
73+
override getNotebookTextModel(_uri: URI): NotebookTextModel | undefined {
74+
return undefined;
75+
}
76+
override hasSupportedNotebooks(_resource: URI): boolean {
7377
return false;
7478
}
7579
});

0 commit comments

Comments
 (0)