From 067c9338c1935a2a4ecfa09319d6ebbe83c0cfa6 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Fri, 26 Jun 2026 19:16:32 -0700 Subject: [PATCH] Handle user-attached unsaved docs as well --- .../agentHost/agentHostSessionHandler.ts | 95 +++++++++++------- .../agentHostChatContribution.test.ts | 99 +++++++++++++++++++ 2 files changed, 161 insertions(+), 33 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 95557d5ff18..0ca0662cc4a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -3632,60 +3632,63 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /** * Forward the active editor (which the suggested-context flow otherwise omits) as ambient context, deduped against - * explicit attachments. Copilot CLI inlines unsaved buffers; other backends skip untitled and read from disk. + * files the user attached explicitly. Unsaved handling lives in {@link _convertVariableToAttachment}. */ private _appendActiveEditorAttachments(attachments: MessageAttachment[], request: IChatAgentRequest): void { const implicitContext = this._chatWidgetService.getWidgetBySessionResource(request.sessionResource)?.input.implicitContext; if (!implicitContext) { return; } - // Key on URI *and* selection range so a whole-document reference and a selection for the same file coexist. + // Dedupe keys come from the source entries, not the produced attachments, so inlined unsaved buffers (which + // carry no URI) still dedupe. const existingKeys = new Set(); - for (const attachment of attachments) { - if (attachment.type === MessageAttachmentKind.Resource) { - existingKeys.add(this._attachmentDedupeKey(attachment.uri, attachment.selection)); + for (const v of request.variables.variables) { + const key = this._fileEntryDedupeKey(v, request.sessionResource); + if (key) { + existingKeys.add(key); } } for (const entry of implicitContext.values) { if (entry.value === undefined) { continue; } - const uri = entry.uri; - if (uri && this._isUnsavedResource(uri)) { - if (this._config.provider === SessionType.CopilotCLI) { - const selection = entry.isSelection && isLocation(entry.value) - ? { range: this._toTextRange(entry.value.range) } - : undefined; - const embedded = this._buildUnsavedEditorAttachment(uri, entry.name, selection); - if (embedded) { - const dedupeKey = this._attachmentDedupeKey(this._rebaseAttachmentUri(uri, request.sessionResource).toString(), selection); - if (!existingKeys.has(dedupeKey)) { - existingKeys.add(dedupeKey); - attachments.push(embedded); - } - continue; - } - // Couldn't inline (no model or too large): untitled has no on-disk fallback, so drop it; a dirty - // saved file falls through to its (stale) on-disk path below. - if (uri.scheme === Schemas.untitled) { - continue; - } - } else if (uri.scheme === Schemas.untitled) { - // Untitled has no on-disk path, so a path reference would be unreadable here. + // Non-Copilot-CLI backends can't read an untitled buffer, so don't forward it as a broken path. + if (this._config.provider !== SessionType.CopilotCLI && entry.uri?.scheme === Schemas.untitled) { + continue; + } + const key = this._fileEntryDedupeKey(entry, request.sessionResource); + if (key) { + if (existingKeys.has(key)) { continue; } + existingKeys.add(key); } const attachment = this._convertVariableToAttachment(entry, request.sessionResource, request.message); - if (!Array.isArray(attachment) && attachment?.type === MessageAttachmentKind.Resource) { - const key = this._attachmentDedupeKey(attachment.uri, attachment.selection); - if (!existingKeys.has(key)) { - existingKeys.add(key); - attachments.push(attachment); - } + if (!Array.isArray(attachment) && attachment) { + attachments.push(attachment); } } } + /** Dedupe identity for a file/implicit entry: rebased URI, suffixed with the range for a selection. */ + private _fileEntryDedupeKey(v: IChatRequestVariableEntry, sessionResource: URI): string | undefined { + if (v.kind !== 'file' && v.kind !== 'implicit') { + return undefined; + } + const uri = isLocation(v.value) ? v.value.uri : (v.value instanceof URI ? v.value : undefined); + if (!uri) { + return undefined; + } + const selection = this._entrySelection(v); + return this._attachmentDedupeKey(this._rebaseAttachmentUri(uri, sessionResource).toString(), selection); + } + + /** The selection range carried by a file/implicit entry, or `undefined` for whole-document references. */ + private _entrySelection(v: IChatRequestVariableEntry): MessageEmbeddedResourceAttachment['selection'] { + const isSelectionEntry = (v.kind === 'file' || (v.kind === 'implicit' && v.isSelection)) && isLocation(v.value); + return isSelectionEntry ? { range: this._toTextRange((v.value as Location).range) } : undefined; + } + /** Dedupe identity: the bare URI for a whole document, suffixed with the range for a selection. */ private _attachmentDedupeKey(uri: string, selection?: MessageResourceAttachment['selection']): string { if (!selection) { @@ -3700,6 +3703,22 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return uri.scheme === Schemas.untitled || this._workingCopyService.isDirty(uri); } + /** + * Convert an unsaved (untitled or dirty) file/implicit attachment: Copilot CLI inlines the live buffer, other + * backends use the on-disk path. Returns `'fallthrough'` to request the normal on-disk path conversion. + */ + private _convertUnsavedFileAttachment(v: IChatRequestVariableEntry, uri: URI): MessageAttachment | undefined | 'fallthrough' { + if (this._config.provider !== SessionType.CopilotCLI) { + return 'fallthrough'; + } + const embedded = this._buildUnsavedEditorAttachment(uri, v.name, this._entrySelection(v)); + if (embedded) { + return embedded; + } + // Couldn't inline: untitled has no on-disk fallback, so drop it; a dirty saved file falls through to its path. + return uri.scheme === Schemas.untitled ? undefined : 'fallthrough'; + } + /** * Inline the live (in-memory) text of an unsaved editor as an embedded resource so a path-reading backend still * gets current content, preserving any active selection. Returns `undefined` when no loaded text model is available @@ -3746,6 +3765,16 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC private _convertVariableToAttachment(v: IChatRequestVariableEntry, sessionResource: URI, messageText?: string): MessageAttachment | MessageAttachment[] | undefined { const referenceRange = this._toAttachmentReferenceRange(messageText, v.range); + // Unsaved (untitled or dirty) files: content on disk is missing or stale, so route through special handling. + if (v.kind === 'file' || v.kind === 'implicit') { + const uri = isLocation(v.value) ? v.value.uri : (v.value instanceof URI ? v.value : undefined); + if (uri && this._isUnsavedResource(uri)) { + const unsaved = this._convertUnsavedFileAttachment(v, uri); + if (unsaved !== 'fallthrough') { + return unsaved; + } + } + } // File / implicit attachments: a Location → selection, a URI → resource. // Only an implicit selection becomes a `selection`; an implicit visible // document is forwarded as a plain document reference (its viewport range diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 538ce69338a..ec3e0a4779f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -5092,6 +5092,105 @@ suite('AgentHostChatContribution', () => { ]); })); + test('explicitly attached untitled editor is inlined for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, modelService } = createContribution(disposables, { provider: 'copilotcli' }); + const untitledUri = URI.from({ scheme: 'untitled', path: '/Untitled-1' }); + modelService.setModelContent(untitledUri, 'console.log("draft")'); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'check this', + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'Untitled-1', value: untitledUri }), + ], + }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.EmbeddedResource, label: 'Untitled-1', displayKind: 'document', data: encodeBase64(VSBuffer.fromString('console.log("draft")')), contentType: 'text/plain' }, + ]); + })); + + test('explicitly attached dirty saved file is inlined for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, modelService, workingCopyService } = createContribution(disposables, { provider: 'copilotcli' }); + const fileUri = URI.file('/workspace/foo.ts'); + modelService.setModelContent(fileUri, 'edited but not saved'); + workingCopyService.setDirty(fileUri, true); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'check this', + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'foo.ts', value: fileUri }), + ], + }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.EmbeddedResource, label: 'foo.ts', displayKind: 'document', data: encodeBase64(VSBuffer.fromString('edited but not saved')), contentType: 'text/plain' }, + ]); + })); + + test('explicitly attached clean saved file is forwarded as a path for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, modelService } = createContribution(disposables, { provider: 'copilotcli' }); + const fileUri = URI.file('/workspace/foo.ts'); + modelService.setModelContent(fileUri, 'saved content'); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'check this', + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'foo.ts', value: fileUri }), + ], + }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'foo.ts', displayKind: 'document' }, + ]); + })); + + test('explicitly attached unsaved file is not duplicated by the active editor for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService, modelService, workingCopyService } = createContribution(disposables, { provider: 'copilotcli' }); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/copilot-explicit-implicit-dedup' }); + const fileUri = URI.file('/workspace/foo.ts'); + modelService.setModelContent(fileUri, 'edited but not saved'); + workingCopyService.setDirty(fileUri, true); + chatWidgetService.setWidgetForSession(sessionResource, [ + { kind: 'implicit', id: 'vscode.implicit.file', name: 'foo.ts', isSelection: false, uri: fileUri, value: fileUri }, + ]); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'what\'s in this file?', + sessionResource, + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'foo.ts', value: fileUri }), + ], + }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.EmbeddedResource, label: 'foo.ts', displayKind: 'document', data: encodeBase64(VSBuffer.fromString('edited but not saved')), contentType: 'text/plain' }, + ]); + })); + test('tool variables are skipped', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables);