Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/vs/base/common/mime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,12 @@ export function getMediaMime(path: string): string | undefined {
}

export function getExtensionForMimeType(mimeType: string): string | undefined {
for (const extension in mapExtToMediaMimes) {
const value = mapExtToMediaMimes[extension];
if (Array.isArray(value) ? value.includes(mimeType) : value === mimeType) {
return extension;
for (const mapping of [mapExtToTextMimes, mapExtToMediaMimes]) {
for (const extension in mapping) {
const value = mapping[extension];
if (Array.isArray(value) ? value.includes(mimeType) : value === mimeType) {
return extension;
}
}
}

Expand Down
1 change: 1 addition & 0 deletions src/vs/base/test/common/mime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ suite('Mime', () => {
assert.strictEqual(getExtensionForMimeType('image/webp'), '.webp');
assert.ok(['.mp2', '.mp2a', '.mp3', '.mpga', '.m2a', '.m3a'].includes(getExtensionForMimeType('audio/mpeg')!));
assert.ok(['.mp4', '.mp4v', '.mpg4'].includes(getExtensionForMimeType('video/mp4')!));
assert.strictEqual(getExtensionForMimeType('text/plain'), '.txt');
assert.strictEqual(getExtensionForMimeType('unknown/type'), undefined);
});

Expand Down
9 changes: 8 additions & 1 deletion src/vs/platform/agentHost/common/sessionDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/

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

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

export function isSessionAttachmentPath(sessionDataService: ISessionDataService, session: URI, filePath: string): boolean {
const attachmentsDir = normalizePath(URI.joinPath(sessionDataService.getSessionDataDir(session), SESSION_ATTACHMENTS_DIRNAME));
const fileUri = normalizePath(URI.file(filePath));
return extUriBiasedIgnorePathCase.isEqualOrParent(fileUri, attachmentsDir);
}

// ---- File-edit types ----------------------------------------------------

/**
Expand Down
10 changes: 5 additions & 5 deletions src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3327,11 +3327,11 @@ export class AgentService extends Disposable implements IAgentService {
return false;
}

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

private _attachmentsRoot(session: string): URI {
return joinPath(this._sessionDataService.getSessionDataDir(URI.parse(session)), SESSION_ATTACHMENTS_DIRNAME);
private _attachmentsRoot(sessionURI: string): URI {
return joinPath(this._sessionDataService.getSessionDataDir(URI.parse(sessionURI)), SESSION_ATTACHMENTS_DIRNAME);
}

/**
* Snapshot inline / client-resident attachment payloads onto disk
* under the session's data directory and rewrite the action to
* reference them via local `file:` URIs. Keeps potentially large
* blobs (e.g. pasted images) out of the in-memory state tree while
* blobs (e.g. pasted text or images) out of the in-memory state tree while
* letting the agent consume them via the standard {@link IFileService}
* surface — no special URI scheme or blob round-tripping needed.
*
Expand Down
7 changes: 6 additions & 1 deletion src/vs/platform/agentHost/node/claude/claudeAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1123,7 +1123,12 @@ export class ClaudeAgent extends Disposable implements IAgent {
private _makeCanUseTool(sdkSessionId: string, configurationResource: URI): NonNullable<Options['canUseTool']> {
return (toolName, input, options) =>
handleCanUseTool(
{ getSession: id => this._findSessionBySdkId(id), configurationService: this._configurationService, configurationResource, serverToolHost: this._serverToolHost },
{
getSession: id => this._findSessionBySdkId(id),
configurationService: this._configurationService,
configurationResource,
serverToolHost: this._serverToolHost,
},
sdkSessionId, toolName, input, options,
);
}
Expand Down
37 changes: 3 additions & 34 deletions src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
import { resolveCopilotConfigSlashCommandOnSend } from '../../common/copilotConfigSlashCommands.js';
import { STREAMING_TOOL_DISPLAY_INTERVAL_MS, streamingToolDisplayText } from '../../common/streamingToolCallDisplay.js';
import { isAgentFeedbackAnnotationsAttachment, renderAgentFeedbackAnnotationsAttachment } from '../../common/meta/agentFeedbackAttachments.js';
import { ISessionDatabase, ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../../common/sessionDataService.js';
import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js';
import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js';
import { MessageAttachmentKind, ToolCallContributorKind, type FileEdit, type MessageAttachment, type ToolCallContributor } from '../../common/state/protocol/state.js';
import { ActionType, isChatAction, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js';
Expand Down Expand Up @@ -715,8 +715,6 @@ export class CopilotAgentSession extends Disposable {
private readonly _editTracker: FileEditTracker;
/** Session database reference. */
private readonly _databaseRef: IReference<ISessionDatabase>;
/** On-disk root for per-session data (database, attachments, …). */
private readonly _sessionDataDir: URI;
/**
* The current protocol turn and its per-turn bookkeeping, or `undefined`
* when the session is idle (no active turn). Replaces the former set of
Expand Down Expand Up @@ -893,7 +891,7 @@ export class CopilotAgentSession extends Disposable {
options: ICopilotAgentSessionOptions,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@ILogService private readonly _logService: ILogService,
@ISessionDataService sessionDataService: ISessionDataService,
@ISessionDataService private readonly _sessionDataService: ISessionDataService,
@IFileService private readonly _fileService: IFileService,
@INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService,
@IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService,
Expand Down Expand Up @@ -943,10 +941,8 @@ export class CopilotAgentSession extends Disposable {
this._activeClientToolSet = options.activeClientToolSet ?? new ActiveClientToolSet();
this._clientReachesChat = options.clientReachesChat ?? (() => true);

this._databaseRef = sessionDataService.openDatabase(this._storageUri);
this._databaseRef = this._sessionDataService.openDatabase(this._storageUri);
this._register(toDisposable(() => this._databaseRef.dispose()));
this._sessionDataDir = sessionDataService.getSessionDataDir(this._storageUri);

this._editTracker = this._instantiationService.createInstance(
FileEditTracker,
this._storageUri.toString(),
Expand Down Expand Up @@ -2710,20 +2706,6 @@ export class CopilotAgentSession extends Disposable {
return { kind: 'approve-once' };
}

// Auto-approve reads of files under the session's attachments
// directory. The agent host writes user-message attachments
// (pasted images, snapshotted client-side files, etc.) there
// before dispatching the turn; the agent ends up needing to
// read those same files back, and prompting the user to
// approve a read of bytes they themselves attached is
// redundant.
if (!managedApprovalRequired && request.kind === 'read' && typeof request.path === 'string'
&& this._isSessionAttachmentPath(request.path)
) {
this._logService.info(`[Copilot:${this.sessionId}] Auto-approving session attachment ${request.path}`);
return { kind: 'approve-once' };
}

// Auto-approve reads of large-tool-output temp files written by the
// Copilot SDK itself. The SDK spills oversized tool results to
// `os.tmpdir()/copilot-tool-output-…txt` and then asks the model
Expand Down Expand Up @@ -2907,19 +2889,6 @@ export class CopilotAgentSession extends Disposable {
return extUriBiasedIgnorePathCase.isEqualOrParent(permissionUri, sessionDir) ? permissionPath : undefined;
}

/**
* Returns true when `permissionPath` lives under this session's
* `<sessionDataDir>/attachments` directory — i.e. the bytes were
* written by the agent host's user-message attachment rewriter and so
* are already user-supplied content that does not need to be
* re-confirmed via a permission prompt.
*/
private _isSessionAttachmentPath(permissionPath: string): boolean {
const attachmentsDir = normalizePath(URI.joinPath(this._sessionDataDir, SESSION_ATTACHMENTS_DIRNAME));
const permissionUri = normalizePath(URI.file(permissionPath));
return extUriBiasedIgnorePathCase.isEqualOrParent(permissionUri, attachmentsDir);
}

/**
* Returns true when shell commands run inside a sandbox by default — either
* through the AgentHost's own {@link TerminalSandboxEngine} (when the custom
Expand Down
7 changes: 7 additions & 0 deletions src/vs/platform/agentHost/node/sessionPermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { ILogService } from '../../log/common/log.js';
import { containsCmdDelayedExpansion } from '../../terminal/common/autoApprove/cmdDelayedExpansion.js';
import { AgentHostEditAutoApprovePatternsConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, platformRootSchema, platformSessionSchema } from '../common/agentHostSchema.js';
import type { IAgentToolPendingConfirmationSignal } from '../common/agent.js';
import { ISessionDataService, isSessionAttachmentPath } from '../common/sessionDataService.js';
import { SessionConfigKey } from '../common/sessionConfigKeys.js';
import { ConfirmationOptionKind, type ConfirmationOption } from '../common/state/protocol/state.js';
import { ActionType, type IToolCallReadyAction } from '../common/state/sessionActions.js';
Expand Down Expand Up @@ -207,6 +208,7 @@ export class SessionPermissionManager extends Disposable {
options: { realpath?: (fsPath: string) => Promise<string> },
@IAgentConfigurationService private readonly _configService: IAgentConfigurationService,
@ILogService private readonly _logService: ILogService,
@ISessionDataService private readonly _sessionDataService: ISessionDataService,
) {
super();
this._realpath = options?.realpath ?? realpath;
Expand Down Expand Up @@ -273,6 +275,11 @@ export class SessionPermissionManager extends Disposable {

// 4. Read auto-approval
if (e.permissionKind === 'read' && e.permissionPath) {
const sessionUri = URI.parse(isAhpChatChannel(sessionKey) ? parseRequiredSessionUriFromChatUri(sessionKey) : sessionKey);
if (isSessionAttachmentPath(this._sessionDataService, sessionUri, e.permissionPath)) {
this._logService.trace(`[SessionPermissionManager] Auto-approving session attachment read of ${e.permissionPath}`);
return ToolCallConfirmationReason.NotNeeded;
}
if (await this._isReadAutoApproved(URI.file(e.permissionPath), workingDirectories)) {
this._logService.trace(`[SessionPermissionManager] Auto-approving read of ${e.permissionPath}`);
return ToolCallConfirmationReason.NotNeeded;
Expand Down
45 changes: 42 additions & 3 deletions src/vs/platform/agentHost/test/node/agentService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
import { SessionDatabase } from '../../node/sessionDatabase.js';
import { ActionType, ActionEnvelope, NotificationType } from '../../common/state/sessionActions.js';
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';
import { ChatInteractivity, type MessageResourceAttachment } from '../../common/state/protocol/state.js';
import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js';
import { IProductService } from '../../../product/common/productService.js';
import { AgentService } from '../../node/agentService.js';
import { IAgentHostDatabase, IAgentHostDatabaseSession } from '../../node/agentHostDatabase.js';
Expand Down Expand Up @@ -1708,14 +1708,14 @@ suite('AgentService (node dispatcher)', () => {
return { svc, agent, session, attachmentsRoot, warnings };
}

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

test('snapshots embedded text attachments as text files without retaining the payload in state', async () => {
const { svc, agent, session, attachmentsRoot } = await setup();
const metadata = { kind: 'paste' };

await dispatchTurnAndWait(svc, agent, session, [{
type: MessageAttachmentKind.EmbeddedResource,
label: 'Pasted text #1',
data: encodeBase64(VSBuffer.fromString('large pasted text')),
contentType: 'text/plain',
_meta: metadata,
}]);

const rewritten = agent.sendMessageCalls[0].attachments?.[0];
assert.ok(rewritten);
assert.strictEqual(rewritten.type, MessageAttachmentKind.Resource);
if (rewritten.type !== MessageAttachmentKind.Resource) {
return;
}
const stateAttachment = svc.stateManager.getSessionState(session.toString())?.activeTurn?.message.attachments?.[0];
assert.deepStrictEqual(stateAttachment, rewritten);
const resource = URI.parse(rewritten.uri);
const contents = await fileService.readFile(resource);
assert.deepStrictEqual({
label: rewritten.label,
displayKind: rewritten.displayKind,
metadata: rewritten._meta,
isSessionAttachment: resource.toString().startsWith(`${attachmentsRoot.toString()}/`),
fileName: resource.path.split('/').at(-1),
contents: contents.value.toString(),
}, {
label: 'Pasted text #1',
displayKind: undefined,
metadata,
isSessionAttachment: true,
fileName: 'Pasted text #1.txt',
contents: 'large pasted text',
});
});

test('preserves existing displayKind / range / selection / _meta on rewrite', async () => {
const { svc, agent, session } = await setup();
const range = { start: { line: 1, character: 0 }, end: { line: 1, character: 4 } };
Expand Down
Loading
Loading