Handle user-attached unsaved docs as well

This commit is contained in:
Dmitriy Vasyura
2026-06-26 19:16:32 -07:00
parent 3733d05a1b
commit 067c9338c1
2 changed files with 161 additions and 33 deletions
@@ -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<string>();
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
@@ -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);