Skip to content

Commit b9831f0

Browse files
connor4312Copilot
andauthored
chat: preserve pasted attachments in side chats (#330542)
* chat: preserve pasted attachments in side chats Carry explicit composer attachments into side chats and store pasted text as session-backed files. - Forward attached context through silent slash commands and side-chat request orchestration. - Snapshot pasted text as .txt resources before reducing Agent Host state. - Allow chats to read attachments owned by their Agent Host session without another prompt. - Cover MIME lookup, attachment forwarding, materialization, and permission boundaries. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: address side chat attachment review Tighten side chat attachment handling after review and CI feedback. - Forward only explicit composer attachments through concurrent slash commands. - Keep draft paste attachments in their compatible semantic representation. - Share session attachment path checks between Copilot and Claude permission handling. - Add Claude access coverage and restore the remote draft clear regression. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: centralize session attachment approvals Move session attachment read approval into the shared permission manager. - Resolve the owning session from each exact chat channel. - Auto-approve only files contained by that session's attachment directory. - Remove duplicate Copilot and Claude provider checks. - Cover peer-chat access and cross-session denial in the central permission suite. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 72f433c commit b9831f0

19 files changed

Lines changed: 200 additions & 85 deletions

src/vs/base/common/mime.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,12 @@ export function getMediaMime(path: string): string | undefined {
101101
}
102102

103103
export function getExtensionForMimeType(mimeType: string): string | undefined {
104-
for (const extension in mapExtToMediaMimes) {
105-
const value = mapExtToMediaMimes[extension];
106-
if (Array.isArray(value) ? value.includes(mimeType) : value === mimeType) {
107-
return extension;
104+
for (const mapping of [mapExtToTextMimes, mapExtToMediaMimes]) {
105+
for (const extension in mapping) {
106+
const value = mapping[extension];
107+
if (Array.isArray(value) ? value.includes(mimeType) : value === mimeType) {
108+
return extension;
109+
}
108110
}
109111
}
110112

src/vs/base/test/common/mime.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ suite('Mime', () => {
2929
assert.strictEqual(getExtensionForMimeType('image/webp'), '.webp');
3030
assert.ok(['.mp2', '.mp2a', '.mp3', '.mpga', '.m2a', '.m3a'].includes(getExtensionForMimeType('audio/mpeg')!));
3131
assert.ok(['.mp4', '.mp4v', '.mpg4'].includes(getExtensionForMimeType('video/mp4')!));
32+
assert.strictEqual(getExtensionForMimeType('text/plain'), '.txt');
3233
assert.strictEqual(getExtensionForMimeType('unknown/type'), undefined);
3334
});
3435

src/vs/platform/agentHost/common/sessionDataService.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { IDisposable, IReference } from '../../../base/common/lifecycle.js';
7+
import { extUriBiasedIgnorePathCase, normalizePath } from '../../../base/common/resources.js';
78
import { URI } from '../../../base/common/uri.js';
89
import { createDecorator } from '../../instantiation/common/instantiation.js';
910
import { Event } from '../../../base/common/event.js';
@@ -16,13 +17,19 @@ export const SESSION_DB_FILENAME = 'session.db';
1617

1718
/**
1819
* Subdirectory under a session's data directory that holds snapshotted
19-
* user-message attachments (e.g. pasted images, fetched file references).
20+
* user-message attachments (e.g. pasted content, fetched file references).
2021
* The agent host writes these on dispatch so large blobs stay out of the
2122
* in-memory state tree, and reads of files under this directory are
2223
* auto-approved by the agent's permission flow.
2324
*/
2425
export const SESSION_ATTACHMENTS_DIRNAME = 'attachments';
2526

27+
export function isSessionAttachmentPath(sessionDataService: ISessionDataService, session: URI, filePath: string): boolean {
28+
const attachmentsDir = normalizePath(URI.joinPath(sessionDataService.getSessionDataDir(session), SESSION_ATTACHMENTS_DIRNAME));
29+
const fileUri = normalizePath(URI.file(filePath));
30+
return extUriBiasedIgnorePathCase.isEqualOrParent(fileUri, attachmentsDir);
31+
}
32+
2633
// ---- File-edit types ----------------------------------------------------
2734

2835
/**

src/vs/platform/agentHost/node/agentService.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3327,11 +3327,11 @@ export class AgentService extends Disposable implements IAgentService {
33273327
return false;
33283328
}
33293329

3330-
private _needsAsyncRewrite(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction): action is ChatTurnStartedAction | ChatPendingMessageSetAction {
3330+
private _needsAsyncRewrite(sessionURI: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction): action is ChatTurnStartedAction | ChatPendingMessageSetAction {
33313331
if (action.type !== ActionType.ChatTurnStarted && action.type !== ActionType.ChatPendingMessageSet) {
33323332
return false;
33333333
}
3334-
const attachmentsRootStr = this._attachmentsRoot(channel).toString();
3334+
const attachmentsRootStr = this._attachmentsRoot(sessionURI).toString();
33353335
return !!action.message.attachments?.some(a => this._isRewritableAttachment(a, attachmentsRootStr));
33363336
}
33373337
private _isRewritableAttachment(attachment: MessageAttachment, attachmentsRootStr: string): boolean {
@@ -3352,15 +3352,15 @@ export class AgentService extends Disposable implements IAgentService {
33523352
return false;
33533353
}
33543354

3355-
private _attachmentsRoot(session: string): URI {
3356-
return joinPath(this._sessionDataService.getSessionDataDir(URI.parse(session)), SESSION_ATTACHMENTS_DIRNAME);
3355+
private _attachmentsRoot(sessionURI: string): URI {
3356+
return joinPath(this._sessionDataService.getSessionDataDir(URI.parse(sessionURI)), SESSION_ATTACHMENTS_DIRNAME);
33573357
}
33583358

33593359
/**
33603360
* Snapshot inline / client-resident attachment payloads onto disk
33613361
* under the session's data directory and rewrite the action to
33623362
* reference them via local `file:` URIs. Keeps potentially large
3363-
* blobs (e.g. pasted images) out of the in-memory state tree while
3363+
* blobs (e.g. pasted text or images) out of the in-memory state tree while
33643364
* letting the agent consume them via the standard {@link IFileService}
33653365
* surface — no special URI scheme or blob round-tripping needed.
33663366
*

src/vs/platform/agentHost/node/claude/claudeAgent.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1120,7 +1120,12 @@ export class ClaudeAgent extends Disposable implements IAgent {
11201120
private _makeCanUseTool(sdkSessionId: string, configurationResource: URI): NonNullable<Options['canUseTool']> {
11211121
return (toolName, input, options) =>
11221122
handleCanUseTool(
1123-
{ getSession: id => this._findSessionBySdkId(id), configurationService: this._configurationService, configurationResource, serverToolHost: this._serverToolHost },
1123+
{
1124+
getSession: id => this._findSessionBySdkId(id),
1125+
configurationService: this._configurationService,
1126+
configurationResource,
1127+
serverToolHost: this._serverToolHost,
1128+
},
11241129
sdkSessionId, toolName, input, options,
11251130
);
11261131
}

src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts

Lines changed: 3 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
4242
import { resolveCopilotConfigSlashCommandOnSend } from '../../common/copilotConfigSlashCommands.js';
4343
import { STREAMING_TOOL_DISPLAY_INTERVAL_MS, streamingToolDisplayText } from '../../common/streamingToolCallDisplay.js';
4444
import { isAgentFeedbackAnnotationsAttachment, renderAgentFeedbackAnnotationsAttachment } from '../../common/meta/agentFeedbackAttachments.js';
45-
import { ISessionDatabase, ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../../common/sessionDataService.js';
45+
import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js';
4646
import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js';
4747
import { MessageAttachmentKind, ToolCallContributorKind, type FileEdit, type MessageAttachment, type ToolCallContributor } from '../../common/state/protocol/state.js';
4848
import { ActionType, isChatAction, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js';
@@ -720,8 +720,6 @@ export class CopilotAgentSession extends Disposable {
720720
private readonly _editTracker: FileEditTracker;
721721
/** Session database reference. */
722722
private readonly _databaseRef: IReference<ISessionDatabase>;
723-
/** On-disk root for per-session data (database, attachments, …). */
724-
private readonly _sessionDataDir: URI;
725723
/**
726724
* The current protocol turn and its per-turn bookkeeping, or `undefined`
727725
* when the session is idle (no active turn). Replaces the former set of
@@ -900,7 +898,7 @@ export class CopilotAgentSession extends Disposable {
900898
options: ICopilotAgentSessionOptions,
901899
@IInstantiationService private readonly _instantiationService: IInstantiationService,
902900
@ILogService private readonly _logService: ILogService,
903-
@ISessionDataService sessionDataService: ISessionDataService,
901+
@ISessionDataService private readonly _sessionDataService: ISessionDataService,
904902
@IFileService private readonly _fileService: IFileService,
905903
@INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService,
906904
@IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService,
@@ -943,10 +941,8 @@ export class CopilotAgentSession extends Disposable {
943941
this._activeClientToolSet = options.activeClientToolSet ?? new ActiveClientToolSet();
944942
this._clientReachesChat = options.clientReachesChat ?? (() => true);
945943

946-
this._databaseRef = sessionDataService.openDatabase(this._storageUri);
944+
this._databaseRef = this._sessionDataService.openDatabase(this._storageUri);
947945
this._register(toDisposable(() => this._databaseRef.dispose()));
948-
this._sessionDataDir = sessionDataService.getSessionDataDir(this._storageUri);
949-
950946
this._editTracker = this._instantiationService.createInstance(
951947
FileEditTracker,
952948
this._storageUri.toString(),
@@ -2717,20 +2713,6 @@ export class CopilotAgentSession extends Disposable {
27172713
return { kind: 'approve-once' };
27182714
}
27192715

2720-
// Auto-approve reads of files under the session's attachments
2721-
// directory. The agent host writes user-message attachments
2722-
// (pasted images, snapshotted client-side files, etc.) there
2723-
// before dispatching the turn; the agent ends up needing to
2724-
// read those same files back, and prompting the user to
2725-
// approve a read of bytes they themselves attached is
2726-
// redundant.
2727-
if (!managedApprovalRequired && request.kind === 'read' && typeof request.path === 'string'
2728-
&& this._isSessionAttachmentPath(request.path)
2729-
) {
2730-
this._logService.info(`[Copilot:${this.sessionId}] Auto-approving session attachment ${request.path}`);
2731-
return { kind: 'approve-once' };
2732-
}
2733-
27342716
// Auto-approve reads of large-tool-output temp files written by the
27352717
// Copilot SDK itself. The SDK spills oversized tool results to
27362718
// `os.tmpdir()/copilot-tool-output-…txt` and then asks the model
@@ -2914,19 +2896,6 @@ export class CopilotAgentSession extends Disposable {
29142896
return extUriBiasedIgnorePathCase.isEqualOrParent(permissionUri, sessionDir) ? permissionPath : undefined;
29152897
}
29162898

2917-
/**
2918-
* Returns true when `permissionPath` lives under this session's
2919-
* `<sessionDataDir>/attachments` directory — i.e. the bytes were
2920-
* written by the agent host's user-message attachment rewriter and so
2921-
* are already user-supplied content that does not need to be
2922-
* re-confirmed via a permission prompt.
2923-
*/
2924-
private _isSessionAttachmentPath(permissionPath: string): boolean {
2925-
const attachmentsDir = normalizePath(URI.joinPath(this._sessionDataDir, SESSION_ATTACHMENTS_DIRNAME));
2926-
const permissionUri = normalizePath(URI.file(permissionPath));
2927-
return extUriBiasedIgnorePathCase.isEqualOrParent(permissionUri, attachmentsDir);
2928-
}
2929-
29302899
/**
29312900
* Returns true when shell commands run inside a sandbox by default — either
29322901
* through the AgentHost's own {@link TerminalSandboxEngine} (when the custom

src/vs/platform/agentHost/node/sessionPermissions.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { ILogService } from '../../log/common/log.js';
2222
import { containsCmdDelayedExpansion } from '../../terminal/common/autoApprove/cmdDelayedExpansion.js';
2323
import { AgentHostEditAutoApprovePatternsConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, platformRootSchema, platformSessionSchema } from '../common/agentHostSchema.js';
2424
import type { IAgentToolPendingConfirmationSignal } from '../common/agent.js';
25+
import { ISessionDataService, isSessionAttachmentPath } from '../common/sessionDataService.js';
2526
import { SessionConfigKey } from '../common/sessionConfigKeys.js';
2627
import { ConfirmationOptionKind, type ConfirmationOption } from '../common/state/protocol/state.js';
2728
import { ActionType, type IToolCallReadyAction } from '../common/state/sessionActions.js';
@@ -207,6 +208,7 @@ export class SessionPermissionManager extends Disposable {
207208
options: { realpath?: (fsPath: string) => Promise<string> },
208209
@IAgentConfigurationService private readonly _configService: IAgentConfigurationService,
209210
@ILogService private readonly _logService: ILogService,
211+
@ISessionDataService private readonly _sessionDataService: ISessionDataService,
210212
) {
211213
super();
212214
this._realpath = options?.realpath ?? realpath;
@@ -273,6 +275,11 @@ export class SessionPermissionManager extends Disposable {
273275

274276
// 4. Read auto-approval
275277
if (e.permissionKind === 'read' && e.permissionPath) {
278+
const sessionUri = URI.parse(isAhpChatChannel(sessionKey) ? parseRequiredSessionUriFromChatUri(sessionKey) : sessionKey);
279+
if (isSessionAttachmentPath(this._sessionDataService, sessionUri, e.permissionPath)) {
280+
this._logService.trace(`[SessionPermissionManager] Auto-approving session attachment read of ${e.permissionPath}`);
281+
return ToolCallConfirmationReason.NotNeeded;
282+
}
276283
if (await this._isReadAutoApproved(URI.file(e.permissionPath), workingDirectories)) {
277284
this._logService.trace(`[SessionPermissionManager] Auto-approving read of ${e.permissionPath}`);
278285
return ToolCallConfirmationReason.NotNeeded;

src/vs/platform/agentHost/test/node/agentService.test.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
3636
import { SessionDatabase } from '../../node/sessionDatabase.js';
3737
import { ActionType, ActionEnvelope, NotificationType } from '../../common/state/sessionActions.js';
3838
import { ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionGitHubState, readSessionMultiRootMetadata, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js';
39-
import { ChatInteractivity, type MessageResourceAttachment } from '../../common/state/protocol/state.js';
39+
import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js';
4040
import { IProductService } from '../../../product/common/productService.js';
4141
import { AgentService } from '../../node/agentService.js';
4242
import { IAgentHostDatabase, IAgentHostDatabaseSession } from '../../node/agentHostDatabase.js';
@@ -1708,14 +1708,14 @@ suite('AgentService (node dispatcher)', () => {
17081708
return { svc, agent, session, attachmentsRoot, warnings };
17091709
}
17101710

1711-
async function dispatchTurnAndWait(svc: AgentService, agent: MockAgent, session: URI, attachments: MessageResourceAttachment[] | { type: MessageAttachmentKind.EmbeddedResource; label: string; data: string; contentType: string; displayKind?: string }[]): Promise<void> {
1711+
async function dispatchTurnAndWait(svc: AgentService, agent: MockAgent, session: URI, attachments: MessageAttachment[]): Promise<void> {
17121712
svc.dispatchAction(
17131713
buildDefaultChatUri(session.toString()),
17141714
{
17151715
type: ActionType.ChatTurnStarted,
17161716
turnId: 'turn-1',
17171717
startedAt: '2025-01-01T00:00:00.000Z',
1718-
message: { text: 'hello', origin: { kind: MessageKind.User }, attachments: attachments as never },
1718+
message: { text: 'hello', origin: { kind: MessageKind.User }, attachments },
17191719
},
17201720
'test-client', 1,
17211721
);
@@ -1753,6 +1753,45 @@ suite('AgentService (node dispatcher)', () => {
17531753
assert.deepStrictEqual([...written.value.buffer], [...png]);
17541754
});
17551755

1756+
test('snapshots embedded text attachments as text files without retaining the payload in state', async () => {
1757+
const { svc, agent, session, attachmentsRoot } = await setup();
1758+
const metadata = { kind: 'paste' };
1759+
1760+
await dispatchTurnAndWait(svc, agent, session, [{
1761+
type: MessageAttachmentKind.EmbeddedResource,
1762+
label: 'Pasted text #1',
1763+
data: encodeBase64(VSBuffer.fromString('large pasted text')),
1764+
contentType: 'text/plain',
1765+
_meta: metadata,
1766+
}]);
1767+
1768+
const rewritten = agent.sendMessageCalls[0].attachments?.[0];
1769+
assert.ok(rewritten);
1770+
assert.strictEqual(rewritten.type, MessageAttachmentKind.Resource);
1771+
if (rewritten.type !== MessageAttachmentKind.Resource) {
1772+
return;
1773+
}
1774+
const stateAttachment = svc.stateManager.getSessionState(session.toString())?.activeTurn?.message.attachments?.[0];
1775+
assert.deepStrictEqual(stateAttachment, rewritten);
1776+
const resource = URI.parse(rewritten.uri);
1777+
const contents = await fileService.readFile(resource);
1778+
assert.deepStrictEqual({
1779+
label: rewritten.label,
1780+
displayKind: rewritten.displayKind,
1781+
metadata: rewritten._meta,
1782+
isSessionAttachment: resource.toString().startsWith(`${attachmentsRoot.toString()}/`),
1783+
fileName: resource.path.split('/').at(-1),
1784+
contents: contents.value.toString(),
1785+
}, {
1786+
label: 'Pasted text #1',
1787+
displayKind: undefined,
1788+
metadata,
1789+
isSessionAttachment: true,
1790+
fileName: 'Pasted text #1.txt',
1791+
contents: 'large pasted text',
1792+
});
1793+
});
1794+
17561795
test('preserves existing displayKind / range / selection / _meta on rewrite', async () => {
17571796
const { svc, agent, session } = await setup();
17581797
const range = { start: { line: 1, character: 0 }, end: { line: 1, character: 4 } };

0 commit comments

Comments
 (0)