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
7 changes: 6 additions & 1 deletion extensions/copilot/src/extension/tools/node/readFileTool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { IWorkspaceService } from '../../../platform/workspace/common/workspaceS
import { getCachedSha256Hash } from '../../../util/common/crypto';
import { clamp } from '../../../util/vs/base/common/numbers';
import { dirname, extUriBiasedIgnorePathCase } from '../../../util/vs/base/common/resources';
import { isHighSurrogate, isLowSurrogate } from '../../../util/vs/base/common/strings';
import { sendSkillContentReadTelemetry } from '../common/skillTelemetry';
import { URI } from '../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
Expand Down Expand Up @@ -432,7 +433,11 @@ class ReadFileResult extends PromptElement<ReadFileResultProps> {
let contents = rawContents.split('\n').map(line => {
if (line.length > MAX_LINE_LENGTH) {
hadLongLines = true;
return line.slice(0, MAX_LINE_LENGTH) + ' [truncated]';
let end = MAX_LINE_LENGTH;
if (isHighSurrogate(line.charCodeAt(end - 1)) && isLowSurrogate(line.charCodeAt(end))) {
end--;
}
return line.slice(0, end) + ' [truncated]';
}
return line;
}).join('\n');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@ suite('ReadFile', () => {
const longLine = 'x'.repeat(2500);
const longLinesContent = `normal line\n${longLine}\nanother normal line\n${longLine}`;
const longLinesDoc = createTextDocumentData(URI.file('/workspace/longlines.ts'), longLinesContent, 'ts').document;
const surrogateBoundaryLine = 'x'.repeat(1999) + '\u{1F6E1}' + 'tail';
const surrogateBoundaryDoc = createTextDocumentData(URI.file('/workspace/surrogate-boundary.ts'), surrogateBoundaryLine, 'ts').document;

const services = createExtensionUnitTestingServices();
services.define(IWorkspaceService, new SyncDescriptor(
TestWorkspaceService,
[
[URI.file('/workspace')],
[testDoc, emptyDoc, whitespaceDoc, singleLineDoc, largeDoc, longLinesDoc],
[testDoc, emptyDoc, whitespaceDoc, singleLineDoc, largeDoc, longLinesDoc, surrogateBoundaryDoc],
]
));
accessor = services.createTestingAccessor();
Expand Down Expand Up @@ -208,6 +210,18 @@ suite('ReadFile', () => {
}
});

test('long line truncation does not split surrogate pairs', async () => {
const toolsService = accessor.get(IToolsService);
const input: IReadFileParamsV2 = {
filePath: '/workspace/surrogate-boundary.ts'
};
const result = await toolsService.invokeTool(ToolName.ReadFile, { input, toolInvocationToken: null as never }, CancellationToken.None);
const resultString = await toolResultToString(accessor, result);
const truncatedLine = resultString.split('\n').find(line => line.endsWith(' [truncated]'));

expect(truncatedLine).toBe('x'.repeat(1999) + ' [truncated]');
});

test('read file with offset beyond file line count should throw error', async () => {
const toolsService = accessor.get(IToolsService);

Expand Down
14 changes: 12 additions & 2 deletions src/vs/base/browser/ui/list/listWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1560,6 +1560,7 @@ export class List<T> implements ISpliceable<T>, IDisposable {

this.onDidChangeFocus(this._onFocusChange, this, this.disposables);
this.onDidChangeSelection(this._onSelectionChange, this, this.disposables);
this.view.onDidScroll(this.onDidChangeActiveDescendant, this, this.disposables);

if (this.accessibilityProvider) {
const ariaLabel = this.accessibilityProvider.getWidgetAriaLabel();
Expand Down Expand Up @@ -2059,13 +2060,22 @@ export class List<T> implements ISpliceable<T>, IDisposable {
const focus = this.focus.get();

if (focus.length > 0) {
const index = focus[0];
let id: string | undefined;

if (this.accessibilityProvider?.getActiveDescendantId) {
id = this.accessibilityProvider.getActiveDescendantId(this.view.element(focus[0]));
id = this.accessibilityProvider.getActiveDescendantId(this.view.element(index));
}

this.view.domNode.setAttribute('aria-activedescendant', id || this.view.getElementDomId(focus[0]));
if (!id && this.view.domElement(index)) {
id = this.view.getElementDomId(index);
}

if (id) {
this.view.domNode.setAttribute('aria-activedescendant', id);
} else {
this.view.domNode.removeAttribute('aria-activedescendant');
}
} else {
this.view.domNode.removeAttribute('aria-activedescendant');
}
Expand Down
54 changes: 54 additions & 0 deletions src/vs/base/test/browser/ui/list/listWidget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,58 @@ suite('ListWidget', function () {
await timeout(0);
assert.strictEqual(listWidget.getFocus()[0], 0, 'page up to next page');
});

test('aria-activedescendant references a rendered element', function () {
const element = document.createElement('div');
element.style.height = '20px';
element.style.width = '200px';

const delegate: IListVirtualDelegate<number> = {
getHeight() { return 20; },
getTemplateId() { return 'template'; }
};

const renderer: IListRenderer<number, void> = {
templateId: 'template',
renderTemplate() { },
renderElement() { },
disposeTemplate() { }
};

const listWidget = store.add(new List<number>('test', element, delegate, [renderer], {
accessibilityProvider: {
getAriaLabel: element => String(element),
getWidgetAriaLabel: () => 'Test list',
getActiveDescendantId: () => undefined
}
}));
listWidget.layout(20);
listWidget.splice(0, 0, range(100));

const listElement = element.querySelector<HTMLElement>('.monaco-list')!;
const focusedElementId = listWidget.getElementID(50);

listWidget.setFocus([50]);
const beforeReveal = {
activeDescendant: listElement.getAttribute('aria-activedescendant'),
focusedElementRendered: element.querySelector(`#${focusedElementId}`) !== null
};

listWidget.reveal(50);
const afterReveal = {
activeDescendant: listElement.getAttribute('aria-activedescendant'),
focusedElementRendered: element.querySelector(`#${focusedElementId}`) !== null
};

assert.deepStrictEqual({ beforeReveal, afterReveal }, {
beforeReveal: {
activeDescendant: null,
focusedElementRendered: false
},
afterReveal: {
activeDescendant: focusedElementId,
focusedElementRendered: true
}
});
});
});
4 changes: 2 additions & 2 deletions src/vs/platform/agentHost/common/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1145,8 +1145,8 @@ export interface IAgent {
/** Optional lifecycle operation paired with {@link startMcpServer}. */
stopMcpServer?(session: URI, id: string): Promise<void>;

/** Optional `mcp://` router for providers that advertise MCP side-channel resources. */
handleMcpRequest?(session: URI, serverName: string, method: string, params: Record<string, unknown> | undefined): Promise<unknown>;
/** Optional `mcp://` router for providers that advertise chat-scoped MCP side-channel resources. */
handleMcpRequest?(chat: URI, serverName: string, method: string, params: Record<string, unknown> | undefined): Promise<unknown>;

/** Optional notification stream paired with {@link handleMcpRequest}. */
readonly onMcpNotification?: Event<IMcpNotification>;
Expand Down
3 changes: 1 addition & 2 deletions src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -959,8 +959,7 @@ export class AgentService extends Disposable implements IAgentService {
if (!provider || !provider.handleMcpRequest) {
throw new Error(`Method not found: no provider for mcp:// channel ${channel}`);
}
const sessionUri = AgentSession.uri(route.providerId, route.sessionId);
return provider.handleMcpRequest(sessionUri, route.serverName, method, params);
return provider.handleMcpRequest(route.chatUri, route.serverName, method, params);
}

// ---- session management -------------------------------------------------
Expand Down
34 changes: 23 additions & 11 deletions src/vs/platform/agentHost/node/codex/codexAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5636,8 +5636,10 @@ export class CodexAgent extends Disposable implements IAgent {
return [];
}
const controller = this._getOrCreateMcpController(session);
controller.applyAll(inventoryToSdkServers(this._mcpInventory));
this._refreshMcpCustomizationIds(session, controller);
controller?.applyAll(inventoryToSdkServers(this._mcpInventory));
if (controller) {
this._refreshMcpCustomizationIds(session, controller);
}
const [workspaceAgents, skillHookContainers] = await Promise.all([
discoverCodexWorkspaceAgents(this._workingDirectories(session), this._fileService),
this._fetchSkillHookContainers(session),
Expand All @@ -5648,7 +5650,7 @@ export class CodexAgent extends Disposable implements IAgent {
return [
...workspaceAgents.containers,
...session.clientCustomizations.toCustomizations(),
...controller.topLevelCustomizations(),
...(controller?.topLevelCustomizations() ?? []),
...skillHookContainers,
];
}
Expand Down Expand Up @@ -5709,11 +5711,14 @@ export class CodexAgent extends Disposable implements IAgent {
* `Method not found` so the protocol server maps them to JSON-RPC
* `-32601`.
*/
async handleMcpRequest(sessionUri: URI, serverName: string, method: string, params: Record<string, unknown> | undefined): Promise<unknown> {
const sessionId = AgentSession.id(sessionUri);
async handleMcpRequest(chat: URI, serverName: string, method: string, params: Record<string, unknown> | undefined): Promise<unknown> {
const sessionId = this._sessionIdByChatUri.get(chat.toString());
if (!sessionId) {
throw new Error(`Method not found: no active chat ${chat.toString()}`);
}
const session = this._sessions.get(sessionId);
if (!session) {
throw new Error(`Method not found: no active session ${sessionId}`);
if (!session || !session.chatChannel || !isEqual(session.chatChannel, chat)) {
throw new Error(`Method not found: no active chat ${chat.toString()}`);
}
const entry = this._mcpInventory.get(serverName);
if (!entry) {
Expand Down Expand Up @@ -5780,6 +5785,9 @@ export class CodexAgent extends Disposable implements IAgent {

private _resolveMcpServerName(session: ICodexSession, id: string): string | undefined {
const controller = this._getOrCreateMcpController(session);
if (!controller) {
return undefined;
}
controller.applyAll(inventoryToSdkServers(this._mcpInventory));
this._refreshMcpCustomizationIds(session, controller);
return controller.serverNameForCustomizationId(id);
Expand All @@ -5790,12 +5798,13 @@ export class CodexAgent extends Disposable implements IAgent {
* registered on the agent (sessions come and go) — disposed explicitly
* when the session is removed.
*/
private _getOrCreateMcpController(session: ICodexSession): McpCustomizationController {
private _getOrCreateMcpController(session: ICodexSession): McpCustomizationController | undefined {
if (!session.chatChannel) {
return undefined;
}
if (!session.mcpController) {
session.mcpController = this._instantiationService.createInstance(McpCustomizationController, {
providerId: this.id,
sessionId: session.sessionId,
sessionUri: session.sessionUri,
chatUri: session.chatChannel,
emit: action => this._fire(session.sessionUri, action),
capabilities: CODEX_MCP_APP_CAPABILITIES,
pluginMcpServerSources: () => codexPluginMcpServerSources(session.clientCustomizations.plugins()),
Expand All @@ -5816,6 +5825,9 @@ export class CodexAgent extends Disposable implements IAgent {
continue;
}
const controller = this._getOrCreateMcpController(session);
if (!controller) {
continue;
}
controller.applyAll(servers);
this._refreshMcpCustomizationIds(session, controller);
}
Expand Down
13 changes: 6 additions & 7 deletions src/vs/platform/agentHost/node/copilot/copilotAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1283,10 +1283,10 @@ export class CopilotAgent extends Disposable implements IAgent {
return applyMcpServerEnablement(customizations, this._retainedHostCustomizations(session));
}

async handleMcpRequest(session: URI, serverName: string, method: string, params: Record<string, unknown> | undefined): Promise<unknown> {
const entry = this._findSessionChat(session);
async handleMcpRequest(chat: URI, serverName: string, method: string, params: Record<string, unknown> | undefined): Promise<unknown> {
const entry = this._findChatByUri(chat);
if (!entry) {
throw new Error(`Method not found: no active session ${AgentSession.id(session)}`);
throw new Error(`Method not found: no active chat ${chat.toString()}`);
}
return entry.handleMcpRequest(serverName, method, params);
}
Expand Down Expand Up @@ -2925,7 +2925,7 @@ export class CopilotAgent extends Disposable implements IAgent {
freeLongContext: this._isFreeLongContext(provisional.model?.id),
workspaceless: provisional.workspaceless,
};
const chatChannelUri = this._findBoundSessionChatUri(sdkSessionId) ?? sessionUri;
const chatChannelUri = this._findBoundSessionChatUri(sdkSessionId) ?? URI.parse(buildDefaultChatUri(sessionUri));
agentSession = this._createAgentSession(launchPlan, customizationDirectory, activeClient, {
sessionUri,
chatChannelUri,
Expand Down Expand Up @@ -4134,7 +4134,7 @@ export class CopilotAgent extends Disposable implements IAgent {
/** Instantiates a session; the caller must initialize and register it on success. */
private _createAgentSession(launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: ActiveClient, identity?: ICopilotAgentSessionIdentity): CopilotAgentSession {
const sessionUri = identity?.sessionUri ?? AgentSession.uri(this.id, launchPlan.sessionId);
const chatChannelUri = identity?.chatChannelUri ?? this._findBoundSessionChatUri(launchPlan.sessionId) ?? sessionUri;
const chatChannelUri = identity?.chatChannelUri ?? this._findBoundSessionChatUri(launchPlan.sessionId) ?? URI.parse(buildDefaultChatUri(sessionUri));

const agentSession = this._instantiationService.createInstance(
CopilotAgentSession,
Expand All @@ -4151,7 +4151,7 @@ export class CopilotAgent extends Disposable implements IAgent {
customizationDirectory,
clientSnapshot: launchPlan.snapshot,
activeClientToolSet: launchPlan.activeClientToolSet,
// Evaluate membership against the session's current chat channel; `bindChatChannel` can move it later.
// Evaluate membership against the session's chat channel.
clientReachesChat: (clientId, chat) => activeClient.contributesTo(clientId, chat.toString()),
// MCP reconcile has no host call of its own, so read the retained host snapshot lazily.
hostCustomizations: () => this._retainedHostCustomizations(sessionUri),
Expand Down Expand Up @@ -4211,7 +4211,6 @@ export class CopilotAgent extends Disposable implements IAgent {
this._throwIfClientReplaced(client, agentSession);
const boundChat = this._findBoundSessionChatUri(sessionId);
if (boundChat) {
agentSession.bindChatChannel?.(boundChat);
this._registerLiveChat(boundChat, agentSession, activeClient);
return;
}
Expand Down
12 changes: 3 additions & 9 deletions src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,18 +625,14 @@ export class CopilotAgentSession extends Disposable {
get ownerSessionUri(): URI { return this._ownerSessionUri; }
/** @deprecated Compatibility alias for SDK callbacks; this is the exact persistence resource. */
get sessionUri(): URI { return this.resourceUri; }
private _chatChannelUri: URI;
private readonly _chatChannelUri: URI;
/** Fixed persistence scope for this chat; never re-derived from the mutable routing channel. Config reads/writes must use {@link _ownerSessionUri} instead — peer chats share that scope but have distinct storage. */
private readonly _storageUri: URI;

get chatChannelUri(): URI {
return this._chatChannelUri;
}

bindChatChannel(chatChannelUri: URI): void {
this._chatChannelUri = chatChannelUri;
}

/** Working directory this session operates in, if any. */
get workingDirectory(): URI | undefined { return this._workingDirectory; }

Expand Down Expand Up @@ -954,9 +950,7 @@ export class CopilotAgentSession extends Disposable {
return sourceUri === undefined ? [] : plugin.mcpServers.map(server => [server.name, sourceUri.toString()] as const);
}));
this._mcpCustomizations = this._register(this._instantiationService.createInstance(McpCustomizationController, {
providerId: this.resourceUri.scheme,
sessionId: this.sessionId,
sessionUri: this.resourceUri,
chatUri: this._chatChannelUri,
emit: action => this._emitAction(action),
pluginMcpServerSources: () => pluginMcpServerSources,
resolveEnablement: (server, owningPluginUri) => {
Expand Down Expand Up @@ -2440,7 +2434,7 @@ export class CopilotAgentSession extends Disposable {
} catch {
// Database may not exist yet — that's fine
}
const result = await mapSessionEvents(this._storageUri, db, events, {
const result = await mapSessionEvents(this._storageUri, db, events, this._chatChannelUri, {
workingDirectory: this._workingDirectory,
model: this._launchPlan.kind === 'create'
? this._launchPlan.model
Expand Down
Loading
Loading