From 1af3166612ed563c4fc83af1a5e329cc655aa363 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Sat, 25 Jul 2026 21:06:25 +0200 Subject: [PATCH 1/2] Send hydrated image attachments to the agent as embedded bytes Uri-only image attachments (e.g. hydrated edit-and-resend images) reached the model as a Resource path reference instead of pixels, so the Agents window agent could not actually see them. In _toImageAttachment's no-inline-bytes branch, read the referenced file via IFileService and emit an EmbeddedResource (base64) for model-facing conversions (turn + pending), falling back to the existing Resource path reference on any read failure. Draft input-state sync stays a lightweight path reference. The attachment-conversion caller chain (_convertVariableToAttachment, _variableEntriesToAttachments, _appendActiveEditorAttachments, _convertVariablesToAttachments, _syncPendingMessages, _inputStateToDraft) is now async. Per-session pending-message syncs are serialized and coalesced through a ThrottlerByKey so the added hydration I/O cannot interleave concurrent runs and dispatch a stale same-id message. Helps Copilot and Codex; Claude still drops EmbeddedResource today (pre-existing, out of scope). Follow-up to #327381 for #327329. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c77566c8-d94f-4245-b965-75ea069af547 --- .../agentHost/agentHostSessionHandler.ts | 92 ++++++++++---- .../agentHostChatContribution.test.ts | 113 +++++++++++++++++- 2 files changed, 179 insertions(+), 26 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 4e752e981ad33..b253f78035ccf 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Delayer, disposableTimeout, raceCancellation } from '../../../../../../base/common/async.js'; +import { Delayer, disposableTimeout, raceCancellation, ThrottlerByKey } from '../../../../../../base/common/async.js'; import { encodeBase64, VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { getErrorCode, isCancellationError } from '../../../../../../base/common/errors.js'; @@ -45,6 +45,7 @@ import { ActionType, ChatTurnStartedAction, isChatAction, type ClientChatAction, import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { buildSubagentChatUri, ChatOriginKind, getToolSubagentContent, isChatReadOnly, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readUsageInfoMeta, type ChatState, type ISessionWithDefaultChat, type ClientPluginCustomization, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type SessionState, type StringOrMarkdown, type ToolCallResponsePart, type ToolCallState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; +import { IFileService } from '../../../../../../platform/files/common/files.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'; @@ -715,6 +716,15 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC private readonly _chatURIsBySessionResource = new ResourceMap(); /** Per-session subscription to chat model pending request changes. */ private readonly _pendingMessageSubscriptions = this._register(new DisposableResourceMap()); + /** + * Serializes and coalesces per-session pending-message syncs. Because + * {@link _syncPendingMessages} now awaits file I/O (image hydration) between + * reading its diff baseline and dispatching, concurrent fire-and-forget runs + * could otherwise interleave and dispatch a stale same-id message. The + * throttler guarantees the latest run observes the freshest state and + * dispatches last. + */ + private readonly _pendingSyncThrottler = this._register(new ThrottlerByKey()); /** Per-session debounced sync from chat input state to AHP draft state. */ private readonly _draftSyncSubscriptions = this._register(new DisposableResourceMap()); /** Per-session subscription watching for server-initiated turns. */ @@ -795,6 +805,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, @IAgentHostCustomizationService private readonly _customizationService: IAgentHostCustomizationService, @ITelemetryService private readonly _telemetryService: ITelemetryService, + @IFileService private readonly _fileService: IFileService, ) { super(); this._config = config; @@ -1534,7 +1545,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * Diffs the chat model's pending requests against the protocol state in * `_clientState` and dispatches Set/Removed/Reordered actions as needed. */ - private _syncPendingMessages(sessionResource: URI, backendSession: URI): void { + private async _syncPendingMessages(sessionResource: URI, backendSession: URI): Promise { const chatModel = this._chatService.getSession(sessionResource); if (!chatModel) { return; @@ -1552,7 +1563,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const currentQueued: IPendingSnapshot[] = []; for (const p of pending) { const variables = p.request.variableData?.variables ?? []; - const messageAttachments = this._variableEntriesToAttachments(variables, sessionResource, p.request.message.text); + const messageAttachments = await this._variableEntriesToAttachments(variables, sessionResource, p.request.message.text, true); const attachments = messageAttachments.length > 0 ? messageAttachments : undefined; const snapshot: IPendingSnapshot = { id: p.request.id, message: userOriginMessage(p.request.message.text, attachments) }; if (p.kind === ChatRequestQueueKind.Steering) { @@ -4129,9 +4140,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._applyRemotePendingMessages(sessionResource, backendSession); store.add(chatModel.onDidChangePendingRequests(() => { - this._syncPendingMessages(sessionResource, backendSession); + this._queuePendingMessageSync(sessionResource, backendSession); })); - this._syncPendingMessages(sessionResource, backendSession); + this._queuePendingMessageSync(sessionResource, backendSession); const sessionStr = backendSession.toString(); const chatURI = this._chatURIsBySessionResource.get(sessionResource); @@ -4152,6 +4163,17 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC })); } + /** + * Schedules a pending-message sync through {@link _pendingSyncThrottler} so + * that rapid changes for the same session are serialized and coalesced (the + * latest run wins), avoiding interleaved dispatches now that the sync awaits + * image-hydration I/O. + */ + private _queuePendingMessageSync(sessionResource: URI, backendSession: URI): void { + this._pendingSyncThrottler.queue(sessionResource.toString(), () => this._syncPendingMessages(sessionResource, backendSession)) + .catch(err => this._logService.error(`[AgentHost] Failed to sync pending messages: ${sessionResource.toString()}`, err)); + } + private _ensureDraftSyncSubscription(sessionResource: URI, backendSession: URI, chatKey: string): void { if (this._draftSyncSubscriptions.has(sessionResource)) { return; @@ -4199,8 +4221,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC let lastDraft = this._getSessionState(backendSession.toString(), chatKey)?.draft; store.add(autorun(reader => { const state = inputModel.state.read(reader); - delayer.trigger(() => { - const draft = this._inputStateToDraft(sessionResource, state); + delayer.trigger(async () => { + const draft = await this._inputStateToDraft(sessionResource, state); if (equals(lastDraft, draft)) { return; } @@ -4214,13 +4236,13 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC })); } - private _inputStateToDraft(sessionResource: URI, state: IChatModelInputState | undefined): Message | undefined { + private async _inputStateToDraft(sessionResource: URI, state: IChatModelInputState | undefined): Promise { if (!state) { return undefined; } const model = this._createModelSelection(state.selectedModel?.identifier, state.modelConfiguration); const agentUri = state.mode.kind === ChatModeKind.Agent && state.mode.id !== ChatMode.Agent.id ? state.mode.id : undefined; - const attachments = this._variableEntriesToAttachments(state.attachments, sessionResource, state.inputText); + const attachments = await this._variableEntriesToAttachments(state.attachments, sessionResource, state.inputText); if (!state.inputText && !model && !agentUri && attachments.length === 0) { return undefined; } @@ -4408,10 +4430,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return !!await this._workspaceTrustRequestService.requestResourcesTrust({ uri: workingDirectory, message }); } - private _convertVariablesToAttachments(request: IChatAgentRequest): MessageAttachment[] { - const attachments = this._variableEntriesToAttachments(request.variables.variables, request.sessionResource, request.message); + private async _convertVariablesToAttachments(request: IChatAgentRequest): Promise { + const attachments = await this._variableEntriesToAttachments(request.variables.variables, request.sessionResource, request.message, true); const explicitCount = attachments.length; - this._appendActiveEditorAttachments(attachments, request); + await this._appendActiveEditorAttachments(attachments, request, true); if (attachments.length !== explicitCount) { this._logService.trace(`[AgentHost] Forwarded ${attachments.length - explicitCount} active editor attachment(s); ${attachments.length} total`); } @@ -4424,7 +4446,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * {@link ChatConfiguration.ImplicitContextActiveEditor} (on by default, off in the Agents window). * Unsaved handling lives in {@link _convertVariableToAttachment}. */ - private _appendActiveEditorAttachments(attachments: MessageAttachment[], request: IChatAgentRequest): void { + private async _appendActiveEditorAttachments(attachments: MessageAttachment[], request: IChatAgentRequest, hydrateImageBytes: boolean = false): Promise { if (!this._configurationService.getValue(ChatConfiguration.ImplicitContextActiveEditor)) { return; } @@ -4460,7 +4482,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } existingKeys.add(key); } - const attachment = this._convertVariableToAttachment(entry, request.sessionResource, request.message); + const attachment = await this._convertVariableToAttachment(entry, request.sessionResource, request.message, hydrateImageBytes); if (!Array.isArray(attachment) && attachment) { attachments.push(attachment); } @@ -4575,10 +4597,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return isSelectionEntry ? value as Location : undefined; } - private _variableEntriesToAttachments(variables: readonly IChatRequestVariableEntry[], sessionResource: URI, messageText?: string): MessageAttachment[] { + private async _variableEntriesToAttachments(variables: readonly IChatRequestVariableEntry[], sessionResource: URI, messageText?: string, hydrateImageBytes: boolean = false): Promise { const attachments: MessageAttachment[] = []; for (const v of variables) { - const attachment = this._convertVariableToAttachment(v, sessionResource, messageText); + const attachment = await this._convertVariableToAttachment(v, sessionResource, messageText, hydrateImageBytes); if (Array.isArray(attachment)) { attachments.push(...attachment); } else if (attachment) { @@ -4591,7 +4613,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return attachments; } - private _convertVariableToAttachment(v: IChatRequestVariableEntry, sessionResource: URI, messageText?: string): MessageAttachment | MessageAttachment[] | undefined { + private async _convertVariableToAttachment(v: IChatRequestVariableEntry, sessionResource: URI, messageText?: string, hydrateImageBytes: boolean = false): Promise { const referenceRange = this._toAttachmentReferenceRange(messageText, v.range); // Copilot CLI and Codex can't read unsaved content from disk, so inline the live buffer; drop unreadable schemes. if ((v.kind === 'file' || v.kind === 'implicit') && this._backendInlinesUnsavedEditors()) { @@ -4627,10 +4649,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (v.kind === 'promptFile' && v.value instanceof URI) { return this._toResourceAttachment(v.value, v.name, 'document', sessionResource, v._meta, referenceRange); } - // Image: send inline as base64 when we have the bytes; otherwise fall - // back to a file resource reference. + // Image: send inline as base64 when we have the bytes; when hydrating a + // model-facing conversion, read a uri-only reference into bytes; otherwise + // fall back to a file resource reference. if (isImageVariableEntry(v)) { - return this._toImageAttachment(v, sessionResource, referenceRange); + return this._toImageAttachment(v, sessionResource, referenceRange, hydrateImageBytes); } if (isAgentFeedbackVariableEntry(v)) { return this._toAgentFeedbackAttachment(v); @@ -4751,7 +4774,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return attachment; } - private _toImageAttachment(v: IImageVariableEntry, sessionResource: URI, range?: MessageAttachment['range']): MessageAttachment | undefined { + private async _toImageAttachment(v: IImageVariableEntry, sessionResource: URI, range?: MessageAttachment['range'], hydrateImageBytes: boolean = false): Promise { const buffer = coerceImageBuffer(v.value); const contentType = v.mimeType ?? 'image/png'; if (buffer) { @@ -4770,9 +4793,34 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } return attachment; } - // No inline bytes — fall back to a file reference if one is available. + // No inline bytes. For model-facing conversions, hydrate the referenced + // file into embedded bytes so the agent can actually see the image; + // otherwise (e.g. draft sync) fall back to a lightweight file reference. const refUri = v.references?.find(r => URI.isUri(r.reference))?.reference; if (URI.isUri(refUri)) { + if (hydrateImageBytes) { + try { + // Read the original (non-rebased) URI; the registered + // vscode-agent-host file system provider handles remote reads. + const content = await this._fileService.readFile(refUri); + const attachment: MessageAttachment = { + type: MessageAttachmentKind.EmbeddedResource, + label: v.name, + displayKind: 'image', + data: encodeBase64(content.value), + contentType, + }; + if (range) { + attachment.range = range; + } + if (v._meta) { + attachment._meta = v._meta; + } + return attachment; + } catch (err) { + this._logService.trace(`[AgentHost] Failed to hydrate image bytes for ${refUri.toString()}; falling back to resource reference: ${err}`); + } + } return this._toResourceAttachment(refUri, v.name, 'image', sessionResource, v._meta, range); } return undefined; 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 13c6f8ccb30cf..200e56c8702e6 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 @@ -679,7 +679,8 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv instantiationService.stub(IChatEntitlementService, { entitlement: ChatEntitlement.Free, quotas: {} } as Partial as IChatEntitlementService); instantiationService.stub(IChatAgentService, chatAgentService); instantiationService.stub(IChatWidgetService, chatWidgetService); - instantiationService.stub(IFileService, TestFileService); + const fileService = new TestFileService(); + instantiationService.stub(IFileService, fileService); instantiationService.stub(ILabelService, MockLabelService); instantiationService.stub(IPathService, new class extends mock() { override userHome(options: { preferLocal: true }): URI; @@ -911,7 +912,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, modelService, workingCopyService, commandService }; + return { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, activeClientService, seedActiveClient, chatSessionContributions, chatSessionItemControllers, newSessionFolderService, trustController, modelService, workingCopyService, commandService, fileService }; } function createSessionListStore(disposables: DisposableStore, instantiationService: TestInstantiationService, connection: IAgentHostSessionListConnection): AgentHostSessionListStore { @@ -924,7 +925,7 @@ function createSessionListController(disposables: DisposableStore, instantiation } function createContribution(disposables: DisposableStore, opts?: { authServiceOverride?: Partial; workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }; languageModels?: ReadonlyMap; provisionalServiceOverride?: Partial; languageModelToolsServiceOverride?: Partial; configOverrides?: Record; provider?: string; chatSessionsServiceOverride?: Partial; chatDebugServiceOverride?: Partial; remoteAgentHostServiceOverride?: Partial; customizationServiceOverride?: IAgentHostCustomizationService; agentHostTerminalServiceOverride?: Partial }) { - 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, opts?.chatSessionsServiceOverride, opts?.chatDebugServiceOverride, opts?.remoteAgentHostServiceOverride, opts?.customizationServiceOverride, opts?.agentHostTerminalServiceOverride); + const { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, trustController, modelService, workingCopyService, fileService } = createTestServices(disposables, opts?.workingDirectoryResolver, opts?.authServiceOverride, opts?.languageModels, opts?.provisionalServiceOverride, false, opts?.languageModelToolsServiceOverride, opts?.configOverrides, opts?.chatSessionsServiceOverride, opts?.chatDebugServiceOverride, opts?.remoteAgentHostServiceOverride, opts?.customizationServiceOverride, opts?.agentHostTerminalServiceOverride); const listController = createSessionListController(disposables, instantiationService, agentHostService); const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { @@ -939,7 +940,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, modelService, workingCopyService }; + return { contribution, listController, sessionHandler, agentHostService, chatAgentService, chatWidgetService, chatService, instantiationService, openerService, trustController, modelService, workingCopyService, fileService }; } function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; variables: IChatAgentRequest['variables']; userSelectedModelId: string; modelConfiguration: Record; agentHostSessionConfig: Record; agentId: string; requestId: string }> = {}): IChatAgentRequest { @@ -6209,6 +6210,108 @@ suite('AgentHostChatContribution', () => { ]); })); + test('uri-only image variable is hydrated into an embedded image attachment', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, fileService } = createContribution(disposables); + fileService.setContent('fake-image-bytes'); + const imageUri = URI.file('/workspace/pic.png'); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'describe this image', + variables: { + variables: [ + upcastPartial({ + kind: 'image', + id: 'v-img', + name: 'pic.png', + value: imageUri, + mimeType: 'image/png', + references: [{ reference: imageUri, kind: 'reference' }], + }), + ], + }, + }); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', 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: 'pic.png', + displayKind: 'image', + data: encodeBase64(VSBuffer.fromString('fake-image-bytes')), + contentType: 'image/png', + }, + ]); + })); + + test('uri-only image falls back to a resource reference when hydration fails', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, fileService } = createContribution(disposables); + fileService.readShouldThrowError = new Error('read failed'); + const imageUri = URI.file('/workspace/pic.png'); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'describe this image', + variables: { + variables: [ + upcastPartial({ + kind: 'image', + id: 'v-img', + name: 'pic.png', + value: imageUri, + mimeType: 'image/png', + references: [{ reference: imageUri, kind: 'reference' }], + }), + ], + }, + }); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', 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: imageUri.toString(), label: 'pic.png', displayKind: 'image' }, + ]); + })); + + test('inline-bytes image is embedded without reading from the file service', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, fileService } = createContribution(disposables); + const imageBytes = VSBuffer.fromString('inline image bytes'); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'describe this image', + variables: { + variables: [ + upcastPartial({ + kind: 'image', + id: 'v-img', + name: 'pic.png', + value: imageBytes.buffer, + mimeType: 'image/png', + }), + ], + }, + }); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', 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: 'pic.png', + displayKind: 'image', + data: encodeBase64(imageBytes), + contentType: 'image/png', + }, + ]); + // Inline bytes must never trigger a file read. + assert.strictEqual(fileService.readOperations.length, 0); + })); + test('browser view variable becomes a model-readable browser attachment', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); const browserUri = URI.from({ scheme: 'vscode-browser', path: '/page-1' }); @@ -8383,6 +8486,7 @@ suite('AgentHostChatContribution', () => { const request = upcastPartial({ id: 'queued-request-1', message: { text, parts: [] } }); pendingRequests.push({ request, kind: ChatRequestQueueKind.Queued, sendOptions: {} }); chatModel.firePendingRequestsChanged(); + await timeout(0); const action = agentHostService.dispatchedActions.map(d => d.action).find((action): action is Extract => action.type === ActionType.ChatPendingMessageSet); assert.ok(action, 'queued message should be dispatched to the agent host'); @@ -8556,6 +8660,7 @@ suite('AgentHostChatContribution', () => { }]; const chatModel = createPendingChatModel(sessionResource, pendingRequests); chatService.setSession(sessionResource, chatModel.model); + await timeout(0); const action = agentHostService.dispatchedActions.map(d => d.action).find((action): action is Extract => action.type === ActionType.ChatPendingMessageSet); assert.ok(action, 'queued message text update should be dispatched to the agent host'); From 84197b91caa95135e9facb2056173680c59f71fc Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Sat, 25 Jul 2026 21:27:54 +0200 Subject: [PATCH 2/2] Add regression test for non-hydrating draft image attachments A uri-only image in a chat draft is not model-facing, so the draft path keeps it as a lightweight MessageAttachmentKind.Resource reference and must not read file bytes on every debounced draft sync (hydrateImageBytes defaults to false). Locks in that behavior, which was previously untested. Follow-up to #327381 for #327329. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c77566c8-d94f-4245-b965-75ea069af547 --- .../agentHostChatContribution.test.ts | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) 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 200e56c8702e6..f015ab532fb39 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 @@ -1855,6 +1855,110 @@ suite('AgentHostChatContribution', () => { }]); })); + + test('uri-only image in a draft stays a resource reference without reading bytes', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const modelMetadata = upcastPartial({ id: 'opus-4.7', name: 'Opus 4.7' }); + const languageModels = new Map([ + ['agent-host-copilot:opus-4.7', modelMetadata], + ]); + const { sessionHandler, agentHostService, chatService, fileService } = createContribution(disposables, { languageModels }); + // Content the draft path must never read. Drafts are persisted on every + // debounced input change and are not model-facing, so a uri-only image + // must stay a lightweight resource reference rather than hydrating bytes. + // If the non-hydrating default ever regressed, readFile would embed these + // bytes and both assertions below would fail. + fileService.setContent('draft image bytes'); + const backendSession = AgentSession.uri('copilot', 'draft-image'); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/draft-image' }); + const imageUri = URI.file('/workspace/draft-pic.png'); + + agentHostService.sessionStates.set(backendSession.toString(), { + ...createSessionState({ + resource: backendSession.toString(), + provider: 'copilot', + title: 'Draft Image', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }), + lifecycle: SessionLifecycle.Ready, + activeClients: [], + chats: [], + }); + + const inputState = observableValue('test.inputState', { + attachments: [ + upcastPartial({ + kind: 'image', + id: 'v-img', + name: 'draft-pic.png', + value: imageUri, + mimeType: 'image/png', + references: [{ reference: imageUri, kind: 'reference' }], + }), + ], + mode: { id: 'agent://reviewer', kind: ChatModeKind.Agent }, + selectedModel: { identifier: 'agent-host-copilot:opus-4.7', metadata: modelMetadata }, + inputText: 'draft body', + selections: [], + contrib: {}, + }); + const inputModel = upcastPartial({ + state: inputState, + setState(state: Partial): void { + inputState.set({ + attachments: [], + mode: { id: 'agent', kind: ChatModeKind.Agent }, + selectedModel: undefined, + inputText: '', + selections: [], + contrib: {}, + ...inputState.get(), + ...state, + }, undefined); + }, + clearState(): void { + inputState.set(undefined, undefined); + }, + toJSON: () => undefined, + }); + const chatModel = upcastPartial({ + sessionResource, + inputModel, + onDidChangePendingRequests: Event.None, + getPendingRequests: () => [], + }); + + chatService.setSession(sessionResource, chatModel); + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + agentHostService.dispatchedActions.length = 0; + + await timeout(499); + assert.strictEqual(agentHostService.dispatchedActions.length, 0); + + await timeout(1); + // Flush the now-async draft conversion chain. + await timeout(0); + assert.deepStrictEqual(agentHostService.dispatchedActions.map(d => ({ channel: d.channel, action: d.action })), [{ + channel: buildDefaultChatUri(backendSession.toString()), + action: { + type: ActionType.ChatDraftChanged, + draft: { + text: 'draft body', + origin: { kind: MessageKind.User }, + attachments: [ + { type: MessageAttachmentKind.Resource, uri: imageUri.toString(), label: 'draft-pic.png', displayKind: 'image' }, + ], + model: { id: 'opus-4.7' }, + agent: { uri: 'agent://reviewer' }, + }, + }, + }]); + // The draft path must stay a lightweight reference and never read bytes. + assert.strictEqual(fileService.readOperations.length, 0); + + })); }); // ---- Session disposal -----------------------------------------------