mirror of
https://github.com/microsoft/vscode.git
synced 2026-10-05 15:36:22 +01:00
Handle opened and unsaved documents as attachments for Agent Host/Copilot CLI (#321591)
This commit is contained in:
1 parent
9d557c6230
commit
ed577eeefb
7 files changed
+725
-35
No files matched your search
@@ -64,7 +64,7 @@ export {
|
||||
type ConfigSchema,
|
||||
type ContentRef, type Customization, type CustomizationDegradedState,
|
||||
type CustomizationErrorState, type CustomizationLoadedState, type CustomizationLoadingState, type CustomizationLoadState, type DirectoryCustomization, type ErrorInfo, type HookCustomization, type FileEdit as ISessionFileDiff, type ToolResultEmbeddedResourceContent as IToolResultBinaryContent, type MarkdownResponsePart, type McpServerCustomization, type MessageAttachment,
|
||||
type MessageResourceAttachment, type MessageAnnotationsAttachment, type ModelSelection, type PendingMessage, type PluginCustomization, type ProjectInfo, type PromptCustomization, type ReasoningResponsePart,
|
||||
type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type MessageAnnotationsAttachment, type ModelSelection, type PendingMessage, type PluginCustomization, type ProjectInfo, type PromptCustomization, type ReasoningResponsePart,
|
||||
type ResponsePart,
|
||||
type RootState, type RuleCustomization, type SessionActiveClient,
|
||||
type SessionConfigState, type ChatInputAnswer as SessionInputAnswer,
|
||||
|
||||
@@ -1371,23 +1371,19 @@ export class CopilotAgentSession extends Disposable {
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a protocol {@link MessageAttachment} into the Copilot CLI
|
||||
* SDK's `attachments` payload shape. Resource attachments map to the
|
||||
* SDK's reference-style `file`/`directory`/`selection` variants (the
|
||||
* {@link MessageAttachmentBase.displayKind} advisory hint controls
|
||||
* which one). Embedded resources (e.g. inline image bytes) map to the
|
||||
* SDK's `blob` variant.
|
||||
* Simple attachments with a model representation map to `text/plain`
|
||||
* blob attachments.
|
||||
* Translate a protocol {@link MessageAttachment} into the Copilot CLI SDK's `attachments` payload shape. Resource
|
||||
* attachments map to the SDK's reference-style `file`/`directory`/`selection` variants (the
|
||||
* {@link MessageAttachmentBase.displayKind} advisory hint controls which one). Embedded resources (e.g. inline
|
||||
* image bytes, or unsaved editor content) map to the SDK's `blob` variant, and simple attachments with a model
|
||||
* representation map to `text/plain` blob attachments.
|
||||
*
|
||||
* Any Resource attachment carrying a {@link TextSelection} (e.g. `displayKind === 'selection'` or `'symbol'`) is
|
||||
* mapped to the SDK's `selection` variant so the range survives the round-trip — keying off the `selection` field
|
||||
* rather than just `displayKind` avoids symbol attachments degrading to a plain file reference (#315193).
|
||||
*
|
||||
* For selections we read the resource content from disk and slice it
|
||||
* by the carried range (the protocol's {@link TextSelection} only
|
||||
* carries the range, not the inline text). On read failure the
|
||||
* selection downgrades to a plain file reference.
|
||||
* rather than just `displayKind` avoids symbol attachments degrading to a plain file reference (#315193). For those
|
||||
* we read the resource content from disk and slice it by the carried range (the protocol's {@link TextSelection}
|
||||
* only carries the range, not the inline text); on read failure the selection downgrades to a plain file reference.
|
||||
* A textual embedded resource already carries the exact inline text to send (the whole live buffer for a document,
|
||||
* or just the selected text for a selection), so it is forwarded as-is without further slicing.
|
||||
*/
|
||||
private async _toSdkAttachment(attachment: MessageAttachment): Promise<CopilotSdkAttachment | undefined> {
|
||||
if (isAgentFeedbackAnnotationsAttachment(attachment)) {
|
||||
|
||||
@@ -658,6 +658,52 @@ suite('CopilotAgentSession', () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
test('forwards an embedded resource with a selection as its already-sliced inline blob', async () => {
|
||||
const { session, mockSession } = await createAgentSession(disposables);
|
||||
|
||||
// The handler inlines only the selected text into `data`, so the adapter forwards it verbatim (no re-slicing).
|
||||
await session.send('what is the selected word?', [{
|
||||
type: MessageAttachmentKind.EmbeddedResource,
|
||||
label: 'file:test.js',
|
||||
displayKind: 'selection',
|
||||
data: encodeBase64(VSBuffer.fromString('world')),
|
||||
contentType: 'text/plain',
|
||||
selection: { range: { start: { line: 1, character: 6 }, end: { line: 1, character: 11 } } },
|
||||
}]);
|
||||
|
||||
assert.deepStrictEqual(mockSession.sendRequests, [{
|
||||
prompt: 'what is the selected word?',
|
||||
attachments: [{
|
||||
type: 'blob',
|
||||
data: encodeBase64(VSBuffer.fromString('world')),
|
||||
mimeType: 'text/plain',
|
||||
displayName: 'file:test.js',
|
||||
}],
|
||||
}]);
|
||||
});
|
||||
|
||||
test('sends an embedded resource without a selection as the full blob', async () => {
|
||||
const { session, mockSession } = await createAgentSession(disposables);
|
||||
|
||||
await session.send('what is in this file?', [{
|
||||
type: MessageAttachmentKind.EmbeddedResource,
|
||||
label: 'file:test.js',
|
||||
displayKind: 'document',
|
||||
data: encodeBase64(VSBuffer.fromString('line0\nhello world\nline2')),
|
||||
contentType: 'text/plain',
|
||||
}]);
|
||||
|
||||
assert.deepStrictEqual(mockSession.sendRequests, [{
|
||||
prompt: 'what is in this file?',
|
||||
attachments: [{
|
||||
type: 'blob',
|
||||
data: encodeBase64(VSBuffer.fromString('line0\nhello world\nline2')),
|
||||
mimeType: 'text/plain',
|
||||
displayName: 'file:test.js',
|
||||
}],
|
||||
}]);
|
||||
});
|
||||
|
||||
test('sends paste simple attachments as text blobs', async () => {
|
||||
const { session, mockSession } = await createAgentSession(disposables);
|
||||
|
||||
|
||||
+186
-9
@@ -12,6 +12,7 @@ import { MarkdownString } from '../../../../../../base/common/htmlContent.js';
|
||||
import { getChatErrorDetailsFromMeta, getCopilotPlanFromEntitlement, IChatErrorContext } from '../../../common/chatErrorMessages.js';
|
||||
import { Disposable, DisposableResourceMap, DisposableStore, IReference, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../base/common/lifecycle.js';
|
||||
import { ResourceMap } from '../../../../../../base/common/map.js';
|
||||
import { Schemas } from '../../../../../../base/common/network.js';
|
||||
import { equals } from '../../../../../../base/common/objects.js';
|
||||
import { autorun, autorunPerKeyedItem, derived, derivedOpts, IObservable, ISettableObservable, observableValue, transaction } from '../../../../../../base/common/observable.js';
|
||||
import { extUriBiasedIgnorePathCase, isEqual } from '../../../../../../base/common/resources.js';
|
||||
@@ -19,7 +20,10 @@ import { StopWatch } from '../../../../../../base/common/stopwatch.js';
|
||||
import { Mutable } from '../../../../../../base/common/types.js';
|
||||
import { URI } from '../../../../../../base/common/uri.js';
|
||||
import { IPosition } from '../../../../../../editor/common/core/position.js';
|
||||
import type { IRange } from '../../../../../../editor/common/core/range.js';
|
||||
import { isLocation, type Location } from '../../../../../../editor/common/languages.js';
|
||||
import type { ITextModel } from '../../../../../../editor/common/model.js';
|
||||
import { IModelService } from '../../../../../../editor/common/services/model.js';
|
||||
import { localize } from '../../../../../../nls.js';
|
||||
import { AgentProvider, AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js';
|
||||
import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAttachments.js';
|
||||
@@ -33,9 +37,10 @@ import { CompletionItemKind as AhpCompletionItemKind, type CompletionItem as Ahp
|
||||
import { ConfirmationOptionKind, CustomizationType, JsonPrimitive, McpServerAuthRequiredState, McpServerStatus, TerminalClaimKind, ToolCallContributorKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js';
|
||||
import { ActionType, ChatTurnStartedAction, isChatAction, type ChatAction, type ClientChatAction, type ClientSessionAction, type ChatInputCompletedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
|
||||
import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js';
|
||||
import { buildSubagentChatUri, getToolSubagentContent, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, type ChatState, type ISessionWithDefaultChat, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js';
|
||||
import { buildSubagentChatUri, getToolSubagentContent, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, type ChatState, type ISessionWithDefaultChat, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js';
|
||||
import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js';
|
||||
import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js';
|
||||
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
|
||||
import { ILogService } from '../../../../../../platform/log/common/log.js';
|
||||
import { IOpenerService } from '../../../../../../platform/opener/common/opener.js';
|
||||
import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js';
|
||||
@@ -53,10 +58,11 @@ import {
|
||||
} from '../../../common/attachments/chatVariableEntries.js';
|
||||
import { coerceImageBuffer } from '../../../common/chatImageExtraction.js';
|
||||
import { ChatRequestQueueKind, ConfirmedReason, ElicitationState, IChatProgress, IChatQuestion, IChatQuestionAnswers, IChatService, IChatToolInvocation, ToolConfirmKind, type IChatMcpAuthenticationRequired, type IChatMcpAuthenticationRequiredServer, type IChatMultiSelectAnswer, type IChatPlanReviewResult, type IChatQuestionAnswerValue, type IChatResponseErrorDetails, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData } from '../../../common/chatService/chatService.js';
|
||||
import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult, type IChatSessionServerRequest } from '../../../common/chatSessionsService.js';
|
||||
import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, SessionType, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult, type IChatSessionServerRequest } from '../../../common/chatSessionsService.js';
|
||||
import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js';
|
||||
import { IWorkingCopyService } from '../../../../../services/workingCopy/common/workingCopyService.js';
|
||||
import { ChatMode } from '../../../common/chatModes.js';
|
||||
import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js';
|
||||
import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js';
|
||||
import { IChatEditingService } from '../../../common/editing/chatEditingService.js';
|
||||
import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../common/languageModels.js';
|
||||
import { type IChatModel, type IChatModelInputState, type IChatRequestVariableData, type ISerializableChatModelInputState } from '../../../common/model/chatModel.js';
|
||||
@@ -81,6 +87,12 @@ import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallTo
|
||||
import { resolveMcpServerAuthentication, agentHostMcpServerId } from './agentHostAuth.js';
|
||||
export { toolDataToDefinition };
|
||||
|
||||
/**
|
||||
* Upper bound on the live editor text we inline for an unsaved document, matching the 1 MB per-file cap chat uses
|
||||
* elsewhere (`chatRepoInfo`). Larger buffers are not inlined; a dirty saved file then falls back to its on-disk path.
|
||||
*/
|
||||
const MAX_INLINED_UNSAVED_EDITOR_BYTES = 1024 * 1024;
|
||||
|
||||
// =============================================================================
|
||||
// AgentHostSessionHandler - renderer-side handler for a single agent host
|
||||
// chat session type. Bridges the protocol state layer with the chat UI:
|
||||
@@ -639,6 +651,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
|
||||
@IAgentHostActiveClientService private readonly _activeClientService: IAgentHostActiveClientService,
|
||||
@IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService,
|
||||
@IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService,
|
||||
@IModelService private readonly _modelService: IModelService,
|
||||
@IWorkingCopyService private readonly _workingCopyService: IWorkingCopyService,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@IChatResponseFileChangesService private readonly _chatResponseFileChangesService: IChatResponseFileChangesService,
|
||||
) {
|
||||
super();
|
||||
@@ -3729,7 +3744,156 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
|
||||
}
|
||||
|
||||
private _convertVariablesToAttachments(request: IChatAgentRequest): MessageAttachment[] {
|
||||
return this._variableEntriesToAttachments(request.variables.variables, request.sessionResource, request.message);
|
||||
const attachments = this._variableEntriesToAttachments(request.variables.variables, request.sessionResource, request.message);
|
||||
const explicitCount = attachments.length;
|
||||
this._appendActiveEditorAttachments(attachments, request);
|
||||
if (attachments.length !== explicitCount) {
|
||||
this._logService.trace(`[AgentHost] Forwarded ${attachments.length - explicitCount} active editor attachment(s); ${attachments.length} total`);
|
||||
}
|
||||
return attachments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward the active editor (which the suggested-context flow omits in agent mode) as ambient context, deduped
|
||||
* against files the user attached explicitly. Gated on
|
||||
* {@link ChatConfiguration.ImplicitContextActiveEditor} (on by default, off in the Agents window).
|
||||
* Unsaved handling lives in {@link _convertVariableToAttachment}.
|
||||
*/
|
||||
private _appendActiveEditorAttachments(attachments: MessageAttachment[], request: IChatAgentRequest): void {
|
||||
if (!this._configurationService.getValue<boolean>(ChatConfiguration.ImplicitContextActiveEditor)) {
|
||||
return;
|
||||
}
|
||||
const implicitContext = this._chatWidgetService.getWidgetBySessionResource(request.sessionResource)?.input.implicitContext;
|
||||
if (!implicitContext) {
|
||||
return;
|
||||
}
|
||||
// Key on source entries (not produced attachments) so inlined unsaved buffers (no URI) still dedupe.
|
||||
const existingKeys = new Set<string>();
|
||||
for (const v of request.variables.variables) {
|
||||
const key = this._fileEntryDedupeKey(v, request.sessionResource);
|
||||
if (key) {
|
||||
existingKeys.add(key);
|
||||
}
|
||||
}
|
||||
// Non-Copilot-CLI backends can't read an untitled buffer, so don't forward it as a broken path.
|
||||
const skipUntitled = this._config.provider !== SessionType.CopilotCLI;
|
||||
for (const entry of implicitContext.values) {
|
||||
if (entry.value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (skipUntitled && 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) {
|
||||
attachments.push(attachment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Dedupe identity for a file/implicit entry: rebased URI, suffixed with the range for a selection. */
|
||||
private _fileEntryDedupeKey(entry: IChatRequestVariableEntry, sessionResource: URI): string | undefined {
|
||||
if (entry.kind !== 'file' && entry.kind !== 'implicit') {
|
||||
return undefined;
|
||||
}
|
||||
const value = entry.value;
|
||||
const uri = isLocation(value) ? value.uri : (value instanceof URI ? value : undefined);
|
||||
if (!uri) {
|
||||
return undefined;
|
||||
}
|
||||
const selection = this._entrySelection(entry);
|
||||
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(entry: IChatRequestVariableEntry): MessageEmbeddedResourceAttachment['selection'] {
|
||||
const location = this._entrySelectionLocation(entry);
|
||||
return location ? { range: this._toTextRange(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) {
|
||||
return uri;
|
||||
}
|
||||
const { start, end } = selection.range;
|
||||
return `${uri}#${start.line}:${start.character}-${end.line}:${end.character}`;
|
||||
}
|
||||
|
||||
/** A resource is unsaved when it's untitled or a saved file with in-memory (dirty) changes. */
|
||||
private _isUnsavedResource(uri: URI): boolean {
|
||||
return uri.scheme === Schemas.untitled || this._workingCopyService.isDirty(uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline the live (in-memory) text of an unsaved editor as an embedded resource so a path-reading backend still
|
||||
* gets current content, preserving the entry's selection, range and `_meta`. Selection entries inline only the
|
||||
* selected text; whole-document entries inline the full buffer. Returns `undefined` when no loaded text model is
|
||||
* available or the inlined text exceeds {@link MAX_INLINED_UNSAVED_EDITOR_BYTES}.
|
||||
*/
|
||||
private _buildUnsavedEditorAttachment(uri: URI, v: IChatRequestVariableEntry, range: MessageAttachment['range']): MessageAttachment | undefined {
|
||||
const model = this._modelService.getModel(uri);
|
||||
if (!model) {
|
||||
return undefined;
|
||||
}
|
||||
const text = this._getUnsavedEditorAttachmentText(model, this._entryModelSelectionRange(v));
|
||||
const buffer = text === undefined ? undefined : VSBuffer.fromString(text);
|
||||
if (!buffer || buffer.byteLength > MAX_INLINED_UNSAVED_EDITOR_BYTES) {
|
||||
this._logService.trace(`[AgentHost] Skipping inline of unsaved editor ${uri.toString()}: exceeds ${MAX_INLINED_UNSAVED_EDITOR_BYTES} byte cap`);
|
||||
return undefined;
|
||||
}
|
||||
const selection = this._entrySelection(v);
|
||||
const attachment: MessageEmbeddedResourceAttachment = {
|
||||
type: MessageAttachmentKind.EmbeddedResource,
|
||||
label: v.name,
|
||||
displayKind: selection ? 'selection' : 'document',
|
||||
data: encodeBase64(buffer),
|
||||
contentType: 'text/plain',
|
||||
};
|
||||
if (selection) {
|
||||
attachment.selection = selection;
|
||||
}
|
||||
if (range) {
|
||||
attachment.range = range;
|
||||
}
|
||||
if (v._meta) {
|
||||
attachment._meta = v._meta;
|
||||
}
|
||||
return attachment;
|
||||
}
|
||||
|
||||
/**
|
||||
* The inline text to send for an unsaved editor: the selected text for a selection, else the whole buffer. Uses the
|
||||
* model length APIs so an over-cap buffer is skipped (returns `undefined`) without ever being materialized.
|
||||
*/
|
||||
private _getUnsavedEditorAttachmentText(model: ITextModel, range: IRange | undefined): string | undefined {
|
||||
if (range) {
|
||||
const selection = model.validateRange(range);
|
||||
const selectionLength = model.getValueLengthInRange(selection);
|
||||
if (selectionLength > 0) {
|
||||
return selectionLength > MAX_INLINED_UNSAVED_EDITOR_BYTES ? undefined : model.getValueInRange(selection);
|
||||
}
|
||||
}
|
||||
return model.getValueLength() > MAX_INLINED_UNSAVED_EDITOR_BYTES ? undefined : model.getValue();
|
||||
}
|
||||
|
||||
/** The editor range of a file/implicit selection entry, used to slice the live model; `undefined` otherwise. */
|
||||
private _entryModelSelectionRange(entry: IChatRequestVariableEntry): IRange | undefined {
|
||||
return this._entrySelectionLocation(entry)?.range;
|
||||
}
|
||||
|
||||
/** The {@link Location} of a file/implicit entry that represents a selection, or `undefined` for whole documents. */
|
||||
private _entrySelectionLocation(entry: IChatRequestVariableEntry): Location | undefined {
|
||||
const value = entry.value;
|
||||
const isSelectionEntry = (entry.kind === 'file' || (entry.kind === 'implicit' && entry.isSelection)) && isLocation(value);
|
||||
return isSelectionEntry ? value as Location : undefined;
|
||||
}
|
||||
|
||||
private _variableEntriesToAttachments(variables: readonly IChatRequestVariableEntry[], sessionResource: URI, messageText?: string): MessageAttachment[] {
|
||||
@@ -3743,20 +3907,33 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
|
||||
}
|
||||
}
|
||||
if (attachments.length > 0) {
|
||||
this._logService.trace(`[AgentHost] Converted ${attachments.length} attachments from ${variables.length} variables`);
|
||||
this._logService.trace(`[AgentHost] Converted ${attachments.length} attachments from ${variables.length} explicit variables`);
|
||||
}
|
||||
return attachments;
|
||||
}
|
||||
|
||||
private _convertVariableToAttachment(v: IChatRequestVariableEntry, sessionResource: URI, messageText?: string): MessageAttachment | MessageAttachment[] | undefined {
|
||||
const referenceRange = this._toAttachmentReferenceRange(messageText, v.range);
|
||||
// File / implicit attachments: a Location → selection, a URI → resource.
|
||||
// Only the selection variant of an implicit attachment becomes a
|
||||
// `selection`; the bare visible-document case stays a plain file
|
||||
// reference (or, when there's no value at all, gets dropped).
|
||||
// Copilot CLI can't read unsaved content from disk, so inline the live buffer; drop unreadable schemes.
|
||||
if ((v.kind === 'file' || v.kind === 'implicit') && this._config.provider === SessionType.CopilotCLI) {
|
||||
const uri = isLocation(v.value) ? v.value.uri : (v.value instanceof URI ? v.value : undefined);
|
||||
if (uri && this._isUnsavedResource(uri)) {
|
||||
const embedded = this._buildUnsavedEditorAttachment(uri, v, referenceRange);
|
||||
if (embedded) {
|
||||
return embedded;
|
||||
}
|
||||
if (uri.scheme !== Schemas.file) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
// File/implicit: a selection Location → 'selection'; a whole document/URI → 'document' (range dropped).
|
||||
if ((v.kind === 'file' || (v.kind === 'implicit' && v.isSelection)) && isLocation(v.value)) {
|
||||
return this._toSelectionAttachment(v.value, v.name, 'selection', sessionResource, v._meta, referenceRange);
|
||||
}
|
||||
if (v.kind === 'implicit' && isLocation(v.value)) {
|
||||
return this._toResourceAttachment(v.value.uri, v.name, 'document', sessionResource, v._meta, referenceRange);
|
||||
}
|
||||
if ((v.kind === 'file' || v.kind === 'implicit') && v.value instanceof URI) {
|
||||
return this._toResourceAttachment(v.value, v.name, 'document', sessionResource, v._meta, referenceRange);
|
||||
}
|
||||
|
||||
@@ -326,6 +326,13 @@ configurationRegistry.registerConfiguration({
|
||||
default: true,
|
||||
agentsWindow: { default: false },
|
||||
},
|
||||
'chat.implicitContext.includeActiveEditor': {
|
||||
type: 'boolean',
|
||||
markdownDescription: nls.localize('chat.implicitContext.includeActiveEditor', "When enabled, the active editor is automatically forwarded as context, even when it would otherwise only be suggested. Selections and explicitly attached files are always included regardless of this setting.\n\nNote: this setting currently only applies to Agent Host sessions (such as the Copilot CLI)."),
|
||||
default: true,
|
||||
tags: ['experimental'],
|
||||
agentsWindow: { default: false },
|
||||
},
|
||||
'chat.editing.autoAcceptDelay': {
|
||||
type: 'number',
|
||||
markdownDescription: nls.localize('chat.editing.autoAcceptDelay', "Delay after which changes made by chat are automatically accepted. Values are in seconds, `0` means disabled and `100` seconds is the maximum."),
|
||||
|
||||
@@ -103,6 +103,7 @@ export enum ChatConfiguration {
|
||||
IncrementalRenderingBuffering = 'chat.experimental.incrementalRendering.buffering',
|
||||
|
||||
CollectInstructionsInExtension = 'chat.experimental.collectInstructionsInExtension',
|
||||
ImplicitContextActiveEditor = 'chat.implicitContext.includeActiveEditor',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+474
-11
@@ -15,6 +15,9 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/
|
||||
import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js';
|
||||
import { timeout } from '../../../../../../base/common/async.js';
|
||||
import { Range } from '../../../../../../editor/common/core/range.js';
|
||||
import { ITextModel } from '../../../../../../editor/common/model.js';
|
||||
import { IModelService } from '../../../../../../editor/common/services/model.js';
|
||||
import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js';
|
||||
import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js';
|
||||
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
|
||||
import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js';
|
||||
@@ -30,7 +33,7 @@ import { IDefaultAccountService } from '../../../../../../platform/defaultAccoun
|
||||
import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js';
|
||||
import { ChatEntitlement, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js';
|
||||
import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentService } from '../../../common/participants/chatAgents.js';
|
||||
import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js';
|
||||
import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js';
|
||||
import { ChatRequestQueueKind, ElicitationState, IChatService, IChatMarkdownContent, IChatProgress, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, IChatUsage, ToolConfirmKind } from '../../../common/chatService/chatService.js';
|
||||
import { IChatEditingService } from '../../../common/editing/chatEditingService.js';
|
||||
import { IChatResponseFileChangesService } from '../../../browser/chatResponseFileChangesService.js';
|
||||
@@ -54,6 +57,7 @@ import { ILabelService } from '../../../../../../platform/label/common/label.js'
|
||||
import { MockLabelService } from '../../../../../services/label/test/common/mockLabelService.js';
|
||||
import { IAgentHostFileSystemService } from '../../../../../services/agentHost/common/agentHostFileSystemService.js';
|
||||
import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js';
|
||||
import { IWorkingCopyService } from '../../../../../services/workingCopy/common/workingCopyService.js';
|
||||
import { ICustomizationHarnessService } from '../../../common/customizationHarnessService.js';
|
||||
import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js';
|
||||
import { IStorageService, InMemoryStorageService } from '../../../../../../platform/storage/common/storage.js';
|
||||
@@ -77,7 +81,7 @@ import { ChatPlanReviewData } from '../../../common/model/chatProgressTypes/chat
|
||||
import { ChatElicitationRequestPart } from '../../../common/model/chatProgressTypes/chatElicitationRequestPart.js';
|
||||
import type { IChatModel, IChatModelInputState, IChatPendingRequest, IChatRequestModel, IInputModel } from '../../../common/model/chatModel.js';
|
||||
import { convertBufferToScreenshotVariable } from '../../../browser/attachments/chatScreenshotContext.js';
|
||||
import { AgentHostCompletionReferenceKind, ChatPasteAttachmentMetadata, toAgentHostCompletionVariableEntry } from '../../../common/attachments/chatVariableEntries.js';
|
||||
import { AgentHostCompletionReferenceKind, ChatPasteAttachmentMetadata, toAgentHostCompletionVariableEntry, type IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js';
|
||||
import { messageAttachmentsToVariableData } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js';
|
||||
|
||||
// ---- Mock agent host service ------------------------------------------------
|
||||
@@ -476,13 +480,14 @@ class MockChatWidgetService extends mock<IChatWidgetService>() {
|
||||
readonly clearPlanReviewCalls: { sessionResource: URI; responseId: string | undefined; resolveId: string | undefined }[] = [];
|
||||
private readonly _widgets = new Map<string, ReturnType<IChatWidgetService['getWidgetBySessionResource']>>();
|
||||
|
||||
setWidgetForSession(sessionResource: URI): void {
|
||||
setWidgetForSession(sessionResource: URI, implicitContextValues?: readonly Partial<IChatRequestVariableEntry>[]): void {
|
||||
// eslint-disable-next-line local/code-no-any-casts
|
||||
this._widgets.set(sessionResource.toString(), {
|
||||
input: {
|
||||
clearQuestionCarousel: (responseId?: string, resolveId?: string) => {
|
||||
this.clearQuestionCarouselCalls.push({ sessionResource, responseId, resolveId });
|
||||
},
|
||||
implicitContext: implicitContextValues ? { values: implicitContextValues } : undefined,
|
||||
clearPlanReview: (responseId?: string, resolveId?: string) => {
|
||||
this.clearPlanReviewCalls.push({ sessionResource, responseId, resolveId });
|
||||
},
|
||||
@@ -495,9 +500,41 @@ class MockChatWidgetService extends mock<IChatWidgetService>() {
|
||||
}
|
||||
}
|
||||
|
||||
class MockModelService extends mock<IModelService>() {
|
||||
private readonly _models = new Map<string, ITextModel>();
|
||||
|
||||
constructor(private readonly _disposables: DisposableStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
setModelContent(uri: URI, content: string): void {
|
||||
this._models.set(uri.toString(), this._disposables.add(createTextModel(content, null, undefined, uri)));
|
||||
}
|
||||
|
||||
override getModel(uri: URI): ITextModel | null {
|
||||
return this._models.get(uri.toString()) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class MockWorkingCopyService extends mock<IWorkingCopyService>() {
|
||||
private readonly _dirty = new Set<string>();
|
||||
|
||||
setDirty(uri: URI, dirty: boolean): void {
|
||||
if (dirty) {
|
||||
this._dirty.add(uri.toString());
|
||||
} else {
|
||||
this._dirty.delete(uri.toString());
|
||||
}
|
||||
}
|
||||
|
||||
override isDirty(resource: URI): boolean {
|
||||
return this._dirty.has(resource.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ----------------------------------------------------------------
|
||||
|
||||
function createTestServices(disposables: DisposableStore, workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }, authServiceOverride?: Partial<IAuthenticationService>, languageModels?: ReadonlyMap<string, ILanguageModelChatMetadata>, provisionalServiceOverride?: Partial<IAgentHostUntitledProvisionalSessionService>, isSessionsWindow = false, languageModelToolsServiceOverride?: Partial<ILanguageModelToolsService>) {
|
||||
function createTestServices(disposables: DisposableStore, workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }, authServiceOverride?: Partial<IAuthenticationService>, languageModels?: ReadonlyMap<string, ILanguageModelChatMetadata>, provisionalServiceOverride?: Partial<IAgentHostUntitledProvisionalSessionService>, isSessionsWindow = false, languageModelToolsServiceOverride?: Partial<ILanguageModelToolsService>, configOverrides?: Record<string, unknown>) {
|
||||
const instantiationService = disposables.add(new TestInstantiationService());
|
||||
|
||||
const agentHostService = new MockAgentHostService();
|
||||
@@ -528,6 +565,10 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv
|
||||
instantiationService.stub(IChatWidgetService, chatWidgetService);
|
||||
instantiationService.stub(IFileService, TestFileService);
|
||||
instantiationService.stub(ILabelService, MockLabelService);
|
||||
const modelService = new MockModelService(disposables);
|
||||
const workingCopyService = new MockWorkingCopyService();
|
||||
instantiationService.stub(IModelService, modelService);
|
||||
instantiationService.stub(IWorkingCopyService, workingCopyService);
|
||||
instantiationService.stub(IChatSessionsService, {
|
||||
registerChatSessionItemController: (type, controller) => {
|
||||
const entry = { type, controller };
|
||||
@@ -554,7 +595,18 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv
|
||||
});
|
||||
instantiationService.stub(IConfigurationService, {
|
||||
onDidChangeConfiguration: Event.None,
|
||||
getValue: (...args: any[]) => typeof args[0] === 'string' && args[0] === 'chat.agentHost.clientTools' ? [] : true,
|
||||
getValue: (...args: any[]) => {
|
||||
const key = args[0];
|
||||
if (typeof key === 'string') {
|
||||
if (configOverrides && Object.hasOwn(configOverrides, key)) {
|
||||
return configOverrides[key];
|
||||
}
|
||||
if (key === 'chat.agentHost.clientTools') {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
instantiationService.stub(ILanguageModelToolsService, {
|
||||
observeTools: () => observableValue('tools', []),
|
||||
@@ -687,7 +739,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv
|
||||
instantiationService.stub(IAgentHostActiveClientService, activeClientService);
|
||||
instantiationService.stub(IOpenerService, openerService as IOpenerService);
|
||||
|
||||
return { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, activeClientService, seedActiveClient, chatSessionContributions, chatSessionItemControllers, newSessionFolderService, trustController };
|
||||
return { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, activeClientService, seedActiveClient, chatSessionContributions, chatSessionItemControllers, newSessionFolderService, trustController, modelService, workingCopyService };
|
||||
}
|
||||
|
||||
function createSessionListStore(disposables: DisposableStore, instantiationService: TestInstantiationService, connection: IAgentHostSessionListConnection): AgentHostSessionListStore {
|
||||
@@ -699,12 +751,12 @@ function createSessionListController(disposables: DisposableStore, instantiation
|
||||
return disposables.add(instantiationService.createInstance(AgentHostSessionListController, sessionType, provider, sessionListStore, description, 'local'));
|
||||
}
|
||||
|
||||
function createContribution(disposables: DisposableStore, opts?: { authServiceOverride?: Partial<IAuthenticationService>; workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }; languageModels?: ReadonlyMap<string, ILanguageModelChatMetadata>; provisionalServiceOverride?: Partial<IAgentHostUntitledProvisionalSessionService>; languageModelToolsServiceOverride?: Partial<ILanguageModelToolsService> }) {
|
||||
const { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, trustController } = createTestServices(disposables, opts?.workingDirectoryResolver, opts?.authServiceOverride, opts?.languageModels, opts?.provisionalServiceOverride, false, opts?.languageModelToolsServiceOverride);
|
||||
function createContribution(disposables: DisposableStore, opts?: { authServiceOverride?: Partial<IAuthenticationService>; workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }; languageModels?: ReadonlyMap<string, ILanguageModelChatMetadata>; provisionalServiceOverride?: Partial<IAgentHostUntitledProvisionalSessionService>; languageModelToolsServiceOverride?: Partial<ILanguageModelToolsService>; configOverrides?: Record<string, unknown>; provider?: string }) {
|
||||
const { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, trustController, modelService, workingCopyService } = createTestServices(disposables, opts?.workingDirectoryResolver, opts?.authServiceOverride, opts?.languageModels, opts?.provisionalServiceOverride, false, opts?.languageModelToolsServiceOverride, opts?.configOverrides);
|
||||
|
||||
const listController = createSessionListController(disposables, instantiationService, agentHostService);
|
||||
const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, {
|
||||
provider: 'copilot' as const,
|
||||
provider: opts?.provider ?? 'copilot',
|
||||
agentId: 'agent-host-copilot',
|
||||
sessionType: 'agent-host-copilot',
|
||||
fullName: 'Agent Host - Copilot',
|
||||
@@ -715,7 +767,7 @@ function createContribution(disposables: DisposableStore, opts?: { authServiceOv
|
||||
}));
|
||||
const contribution = disposables.add(instantiationService.createInstance(AgentHostContribution));
|
||||
|
||||
return { contribution, listController, sessionHandler, agentHostService, chatAgentService, chatWidgetService, chatService, instantiationService, openerService, trustController };
|
||||
return { contribution, listController, sessionHandler, agentHostService, chatAgentService, chatWidgetService, chatService, instantiationService, openerService, trustController, modelService, workingCopyService };
|
||||
}
|
||||
|
||||
function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; variables: IChatAgentRequest['variables']; userSelectedModelId: string; modelConfiguration: Record<string, unknown>; agentHostSessionConfig: Record<string, string>; agentId: string }> = {}): IChatAgentRequest {
|
||||
@@ -4899,7 +4951,7 @@ suite('AgentHostChatContribution', () => {
|
||||
]);
|
||||
}));
|
||||
|
||||
test('implicit visible code location does not become selection attachment', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
test('implicit visible code location becomes document attachment without selection', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables);
|
||||
|
||||
const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, {
|
||||
@@ -4922,11 +4974,276 @@ suite('AgentHostChatContribution', () => {
|
||||
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: URI.file('/workspace/foo.ts').toString(),
|
||||
label: 'visible code',
|
||||
displayKind: 'document',
|
||||
},
|
||||
]);
|
||||
}));
|
||||
|
||||
test('active editor implicit context is not forwarded when the setting is disabled', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables, {
|
||||
configOverrides: { [ChatConfiguration.ImplicitContextActiveEditor]: false },
|
||||
});
|
||||
const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/new-implicit-disabled' });
|
||||
const fileUri = URI.file('/workspace/foo.ts');
|
||||
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,
|
||||
});
|
||||
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.strictEqual(turnAction.message.attachments, undefined);
|
||||
}));
|
||||
|
||||
test('active editor implicit context is forwarded as a document attachment for agent sessions', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables);
|
||||
const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/new-implicit-active-editor' });
|
||||
const fileUri = URI.file('/workspace/foo.ts');
|
||||
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,
|
||||
});
|
||||
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('active editor implicit context is not duplicated when also attached explicitly', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables);
|
||||
const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/new-implicit-dedup' });
|
||||
const fileUri = URI.file('/workspace/foo.ts');
|
||||
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.Resource, uri: fileUri.toString(), label: 'foo.ts', displayKind: 'document' },
|
||||
]);
|
||||
}));
|
||||
|
||||
test('active editor implicit selection is forwarded even when the same file is attached as a document', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables);
|
||||
const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/new-implicit-selection-dedup' });
|
||||
const fileUri = URI.file('/workspace/foo.ts');
|
||||
chatWidgetService.setWidgetForSession(sessionResource, [
|
||||
{ kind: 'implicit', id: 'vscode.implicit.selection', name: 'foo.ts', isSelection: true, uri: fileUri, value: { uri: fileUri, range: new Range(2, 1, 4, 10) } },
|
||||
]);
|
||||
|
||||
const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, {
|
||||
message: 'what\'s in this selection?',
|
||||
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.Resource, uri: fileUri.toString(), label: 'foo.ts', displayKind: 'document' },
|
||||
{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'foo.ts', displayKind: 'selection', selection: { range: { start: { line: 1, character: 0 }, end: { line: 3, character: 9 } } } },
|
||||
]);
|
||||
}));
|
||||
|
||||
test('active editor implicit context is not forwarded for untitled editors on non-Copilot-CLI backends', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables);
|
||||
const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/new-implicit-untitled' });
|
||||
const untitledUri = URI.from({ scheme: 'untitled', path: '/Untitled-1' });
|
||||
chatWidgetService.setWidgetForSession(sessionResource, [
|
||||
{ kind: 'implicit', id: 'vscode.implicit.file', name: 'Untitled-1', isSelection: false, uri: untitledUri, value: untitledUri },
|
||||
]);
|
||||
|
||||
const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, {
|
||||
message: 'what\'s in this file?',
|
||||
sessionResource,
|
||||
});
|
||||
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.strictEqual(turnAction.message.attachments, undefined);
|
||||
}));
|
||||
|
||||
test('active editor implicit context for an untitled editor is inlined for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const { sessionHandler, agentHostService, chatAgentService, chatWidgetService, modelService } = createContribution(disposables, { provider: 'copilotcli' });
|
||||
const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/copilot-implicit-untitled' });
|
||||
const untitledUri = URI.from({ scheme: 'untitled', path: '/Untitled-1' });
|
||||
modelService.setModelContent(untitledUri, 'console.log("draft")');
|
||||
chatWidgetService.setWidgetForSession(sessionResource, [
|
||||
{ kind: 'implicit', id: 'vscode.implicit.file', name: 'Untitled-1', isSelection: false, uri: untitledUri, value: untitledUri },
|
||||
]);
|
||||
|
||||
const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, {
|
||||
message: 'what\'s in this file?',
|
||||
sessionResource,
|
||||
});
|
||||
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('active editor implicit context for a dirty saved file is inlined 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-implicit-dirty' });
|
||||
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,
|
||||
});
|
||||
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('active editor implicit selection in an unsaved editor preserves the selection range when inlined 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-implicit-dirty-selection' });
|
||||
const fileUri = URI.file('/workspace/foo.ts');
|
||||
modelService.setModelContent(fileUri, 'first line\nsecond line\nthird line\nfourth line content');
|
||||
workingCopyService.setDirty(fileUri, true);
|
||||
chatWidgetService.setWidgetForSession(sessionResource, [
|
||||
{ kind: 'implicit', id: 'vscode.implicit.selection', name: 'foo.ts', isSelection: true, uri: fileUri, value: { uri: fileUri, range: new Range(2, 1, 4, 10) } },
|
||||
]);
|
||||
|
||||
const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, {
|
||||
message: 'what\'s in this selection?',
|
||||
sessionResource,
|
||||
});
|
||||
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: 'selection', data: encodeBase64(VSBuffer.fromString('second line\nthird line\nfourth li')), contentType: 'text/plain', selection: { range: { start: { line: 1, character: 0 }, end: { line: 3, character: 9 } } } },
|
||||
]);
|
||||
}));
|
||||
|
||||
test('active editor implicit context for a clean saved file is forwarded as a path for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const { sessionHandler, agentHostService, chatAgentService, chatWidgetService, modelService } = createContribution(disposables, { provider: 'copilotcli' });
|
||||
const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/copilot-implicit-clean' });
|
||||
const fileUri = URI.file('/workspace/foo.ts');
|
||||
modelService.setModelContent(fileUri, 'saved content');
|
||||
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,
|
||||
});
|
||||
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('active editor implicit context over the size cap is dropped for an untitled editor on the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const { sessionHandler, agentHostService, chatAgentService, chatWidgetService, modelService } = createContribution(disposables, { provider: 'copilotcli' });
|
||||
const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/copilot-implicit-untitled-toolarge' });
|
||||
const untitledUri = URI.from({ scheme: 'untitled', path: '/Untitled-1' });
|
||||
modelService.setModelContent(untitledUri, 'x'.repeat(1024 * 1024 + 1));
|
||||
chatWidgetService.setWidgetForSession(sessionResource, [
|
||||
{ kind: 'implicit', id: 'vscode.implicit.file', name: 'Untitled-1', isSelection: false, uri: untitledUri, value: untitledUri },
|
||||
]);
|
||||
|
||||
const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, {
|
||||
message: 'what\'s in this file?',
|
||||
sessionResource,
|
||||
});
|
||||
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.strictEqual(turnAction.message.attachments, undefined);
|
||||
}));
|
||||
|
||||
test('active editor implicit context over the size cap falls back to a path for a dirty saved file on 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-implicit-dirty-toolarge' });
|
||||
const fileUri = URI.file('/workspace/foo.ts');
|
||||
modelService.setModelContent(fileUri, 'x'.repeat(1024 * 1024 + 1));
|
||||
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,
|
||||
});
|
||||
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('non-file URI variables (e.g. untitled documents) are forwarded as attachments', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables);
|
||||
const uri = URI.from({ scheme: 'untitled', path: '/foo' });
|
||||
@@ -4949,6 +5266,152 @@ 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('inlined unsaved attachment preserves _meta and selection 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, 'first line\nsecond line\nthird line\nfourth line content');
|
||||
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: { uri: fileUri, range: new Range(2, 1, 4, 10) }, _meta: { provider: 'fs', score: 0.42 } }),
|
||||
],
|
||||
},
|
||||
});
|
||||
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: 'selection', data: encodeBase64(VSBuffer.fromString('second line\nthird line\nfourth li')), contentType: 'text/plain', selection: { range: { start: { line: 1, character: 0 }, end: { line: 3, character: 9 } } }, _meta: { provider: 'fs', score: 0.42 } },
|
||||
]);
|
||||
}));
|
||||
|
||||
test('dirty non-file resource that cannot be inlined is dropped for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const { sessionHandler, agentHostService, chatAgentService, workingCopyService } = createContribution(disposables, { provider: 'copilotcli' });
|
||||
// A dirty resource with no loaded text model (e.g. a notebook cell or remote URI) can't be inlined; for the
|
||||
// CLI a non-file path is unreadable, so it must be dropped rather than forwarded as a broken path.
|
||||
const remoteUri = URI.from({ scheme: 'vscode-remote', authority: 'ssh-remote+host', path: '/remote/foo.ts' });
|
||||
workingCopyService.setDirty(remoteUri, 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: remoteUri }),
|
||||
],
|
||||
},
|
||||
});
|
||||
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.strictEqual(turnAction.message.attachments, undefined);
|
||||
}));
|
||||
|
||||
test('tool variables are skipped', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables);
|
||||
|
||||
|
||||
Reference in new issue
Block a user