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
1 change: 1 addition & 0 deletions src/vs/base/common/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ export const nodeModulesPath: AppResourcePath = 'vs/../../node_modules';
export const nodeModulesAsarPath: AppResourcePath = 'vs/../../node_modules.asar';
export const nodeModulesAsarUnpackedPath: AppResourcePath = 'vs/../../node_modules.asar.unpacked';

export const AGENTS_AUTHORITY = 'agents';
export const VSCODE_AUTHORITY = 'vscode-app';

class FileAccessImpl {
Expand Down
23 changes: 22 additions & 1 deletion src/vs/code/electron-main/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ import { NativeURLService } from '../../platform/url/common/urlService.js';
import { ElectronURLListener } from '../../platform/url/electron-main/electronUrlListener.js';
import { IWebviewManagerService } from '../../platform/webview/common/webviewManagerService.js';
import { WebviewMainService } from '../../platform/webview/electron-main/webviewMainService.js';
import { isFolderToOpen, isWorkspaceToOpen, IWindowOpenable } from '../../platform/window/common/window.js';
import { AgentsWindowOpenSource, isFolderToOpen, isWorkspaceToOpen, IWindowOpenable } from '../../platform/window/common/window.js';
import { getAllWindowsExcludingOffscreen, IWindowsMainService, OpenContext } from '../../platform/windows/electron-main/windows.js';
import { ICodeWindow } from '../../platform/window/electron-main/window.js';
import { WindowsMainService } from '../../platform/windows/electron-main/windowsMainService.js';
Expand Down Expand Up @@ -129,6 +129,7 @@ import { ipcUtilityProcessWorkerChannelName } from '../../platform/utilityProces
import { ILocalPtyService, LocalReconnectConstants, TerminalIpcChannels, TerminalSettingId } from '../../platform/terminal/common/terminal.js';
import { ElectronPtyHostStarter } from '../../platform/terminal/electron-main/electronPtyHostStarter.js';
import { PtyHostService } from '../../platform/terminal/node/ptyHostService.js';
import { parseExternalOpenSessionLinkUri } from '../../platform/agentHost/common/openSessionLink.js';
import { ElectronAgentHostStarter } from '../../platform/agentHost/electron-main/electronAgentHostStarter.js';
import { AgentHostProcessManager } from '../../platform/agentHost/node/agentHostService.js';
import { NODE_REMOTE_RESOURCE_CHANNEL_NAME, NODE_REMOTE_RESOURCE_IPC_METHOD_NAME, NodeRemoteResourceResponse, NodeRemoteResourceRouter } from '../../platform/remote/common/electronRemoteResources.js';
Expand Down Expand Up @@ -1050,6 +1051,15 @@ export class CodeApplication extends Disposable {
private async handleProtocolUrl(windowsMainService: IWindowsMainService, dialogMainService: IDialogMainService, urlService: IURLService, uri: URI, options?: IOpenURLOptions): Promise<boolean> {
this.logService.trace('app#handleProtocolUrl():', uri.toString(true), options);

const agentSessionLink = parseExternalOpenSessionLinkUri(uri, this.productService.urlProtocol);
if (agentSessionLink) {
const windows = await windowsMainService.openAgentsWindow({
context: OpenContext.LINK,
cli: { ...this.environmentMainService.args },
}, undefined, agentSessionLink, AgentsWindowOpenSource.Link);
return windows.length > 0;
}

// Support 'workspace' URLs (https://github.com/microsoft/vscode/issues/124263)
if (uri.scheme === this.productService.urlProtocol && uri.path === 'workspace') {
uri = uri.with({
Expand Down Expand Up @@ -1497,6 +1507,17 @@ export class CodeApplication extends Disposable {

// Then check for windows from protocol links to open
if (initialProtocolUrls) {
const agentSessionProtocolUrlIndex = initialProtocolUrls.urls.findIndex(protocolUrl =>
parseExternalOpenSessionLinkUri(protocolUrl.uri, this.productService.urlProtocol));
if (agentSessionProtocolUrlIndex >= 0) {
const [agentSessionProtocolUrl] = initialProtocolUrls.urls.splice(agentSessionProtocolUrlIndex, 1);
const agentSessionLink = parseExternalOpenSessionLinkUri(agentSessionProtocolUrl.uri, this.productService.urlProtocol);
return windowsMainService.openAgentsWindow({
context: OpenContext.LINK,
cli: args,
initialStartup: true,
}, undefined, agentSessionLink, AgentsWindowOpenSource.Link);
}

// Openables can open as windows directly
if (initialProtocolUrls.openables.length > 0) {
Expand Down
29 changes: 20 additions & 9 deletions src/vs/platform/agentHost/browser/agentHostConnectionsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { localize } from '../../../nls.js';
import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js';
import { AgentSession } from '../common/agent.js';
import { IAgentConnection, IAgentHostService } from '../common/agentService.js';
import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionInfo, IAgentHostConnectionsService, IAgentHostSessionResolution, IAgentHostSessionResolutionPolicy, LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../common/agentHostConnectionsService.js';
import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionInfo, IAgentHostConnectionsService, IAgentHostSessionIdentity, IAgentHostSessionResolution, IAgentHostSessionResolutionPolicy, LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../common/agentHostConnectionsService.js';
import { findRemoteAgentHostSessionTypeAuthority, isRemoteAgentHostSessionType, remoteAgentHostSessionTypeAuthorityPrefix } from '../common/agentHostSessionType.js';
import { agentHostAuthority } from '../common/agentHostUri.js';
import { IRemoteAgentHostService } from '../common/remoteAgentHostService.js';
Expand Down Expand Up @@ -99,39 +99,50 @@ export class AgentHostConnectionsService extends Disposable implements IAgentHos
}

resolveSessionResource(sessionResource: URI): IAgentHostSessionResolution | undefined {
const identity = this.resolveSessionResourceIdentity(sessionResource);
if (!identity) {
return undefined;
}
const connection = this.getConnectionByAuthority(identity.connectionAuthority);
return connection ? { ...identity, connection } : undefined;
}

resolveSessionResourceIdentity(sessionResource: URI): IAgentHostSessionIdentity | undefined {
const scheme = sessionResource.scheme;
const rawSessionId = sessionResource.path.substring(1);

if (scheme.startsWith(LOCAL_AGENT_HOST_SCHEME_PREFIX)) {
const provider = scheme.substring(LOCAL_AGENT_HOST_SCHEME_PREFIX.length);
return provider
? this._createSessionResolution(AMBIENT_AGENT_HOST_AUTHORITY, this._agentHostService, provider, rawSessionId)
? this._createSessionIdentity(AMBIENT_AGENT_HOST_AUTHORITY, provider, rawSessionId)
: undefined;
}

if (isRemoteAgentHostSessionType(scheme)) {
// `remote-<authority>-<provider>`: both segments may contain dashes,
// so resolve the authority against the live connection set (longest
// so resolve the authority against the known connection/policy set (longest
// match wins) rather than splitting the string blindly.
const authority = findRemoteAgentHostSessionTypeAuthority(scheme, this.connections.filter(c => !c.isAmbient).map(c => c.authority));
const authorities = new Set([
...this.connections.filter(c => !c.isAmbient).map(c => c.authority),
...this._sessionResolutionPolicies.keys(),
]);
const authority = findRemoteAgentHostSessionTypeAuthority(scheme, authorities);
if (authority) {
const provider = scheme.substring(remoteAgentHostSessionTypeAuthorityPrefix(authority).length);
const connection = this.getConnectionByAuthority(authority);
if (provider && connection) {
return this._createSessionResolution(authority, connection, provider, rawSessionId);
if (provider) {
return this._createSessionIdentity(authority, provider, rawSessionId);
}
}
}

return undefined;
}

private _createSessionResolution(authority: string, connection: IAgentConnection, provider: string, rawSessionId: string): IAgentHostSessionResolution {
private _createSessionIdentity(authority: string, provider: string, rawSessionId: string): IAgentHostSessionIdentity {
const policy = this._sessionResolutionPolicies.get(authority);
const alias = policy?.sessionSchemeAlias;
const backendProvider = alias?.ui === provider ? alias.backend : provider;
return {
connection,
connectionAuthority: authority,
backendSession: AgentSession.uri(backendProvider, rawSessionId),
defaultChangesetKind: policy?.defaultChangesetKind,
Expand Down
13 changes: 11 additions & 2 deletions src/vs/platform/agentHost/common/agentHostConnectionsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,16 @@ export interface IAgentHostConnectionInfo {
* the owning {@link IAgentConnection}, its authority, and the canonical backend
* agent-session URI used for protocol operations on that connection.
*/
export interface IAgentHostSessionResolution {
readonly connection: IAgentConnection;
export interface IAgentHostSessionIdentity {
readonly connectionAuthority: string;
readonly backendSession: URI;
readonly defaultChangesetKind?: DefaultChangesetKind;
}

export interface IAgentHostSessionResolution extends IAgentHostSessionIdentity {
readonly connection: IAgentConnection;
}

/** Provider-owned policy needed to resolve a workbench session resource back to its host. */
export interface IAgentHostSessionResolutionPolicy {
readonly sessionSchemeAlias?: IAgentHostSessionSchemeAlias;
Expand Down Expand Up @@ -137,6 +140,12 @@ export interface IAgentHostConnectionsService {
*/
registerSessionResolutionPolicy(authority: string, policy: IAgentHostSessionResolutionPolicy): IDisposable;

/**
* Resolves an agent-host chat-session resource to its connection authority
* and backend session URI without requiring the host to be connected.
*/
resolveSessionResourceIdentity(sessionResource: URI): IAgentHostSessionIdentity | undefined;

/**
* Resolves an agent-host chat-session resource to its owning connection and
* backend session URI. Handles both local schemes
Expand Down
61 changes: 61 additions & 0 deletions src/vs/platform/agentHost/common/openSessionLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { AGENTS_AUTHORITY } from '../../../base/common/network.js';
import { URI } from '../../../base/common/uri.js';
import { localize } from '../../../nls.js';
import { ILinkPresentation, ILinkPresentationStatus } from '../../dataChannel/common/dataChannel.js';
Expand All @@ -25,6 +26,9 @@ export const AGENT_HOST_SESSION_LINK_PATTERN = /^agent-host-session:\/\/[^/?#]+\
export const AGENT_HOST_SESSION_ONLY_LINK_PATTERN = /^(?![^#]*[?&]chat=)agent-host-session:\/\/[^/?#]+\/[^?#]+(?:\?[^#]*)?(?:#.*)?$/i;
export const AGENT_HOST_CHAT_LINK_PATTERN = /^(?=[^#]*[?&]chat=)agent-host-session:\/\/[^/?#]+\/[^?#]+(?:\?[^#]*)?(?:#.*)?$/i;

const AGENT_HOST_SESSION_LINK_PATH_PREFIX = `/${AGENT_HOST_SESSION_LINK_SCHEME}/`;
const AGENT_HOST_CHAT_LINK_PATH_SEGMENT = '/chat/';

export type AgentSessionLinkStatus = 'untitled' | 'inProgress' | 'needsInput' | 'completed' | 'error';

export function buildAgentSessionLinkPresentation(title: string, description: string | undefined, status: AgentSessionLinkStatus, kind: 'session' | 'chat' = 'session'): ILinkPresentation {
Expand Down Expand Up @@ -103,6 +107,63 @@ export function buildOpenSessionLinkUri(backendSession: URI | string, chatId?: s
return query.length > 0 ? `${base}?${query.join('&')}` : base;
}

/**
* Builds a product protocol URL that opens an agent-host session in the Agents window.
*
* Shape: `<product-protocol>://agents/agent-host-session/<provider>/<rawSessionId>[/chat/<chatId>]`.
*/
export function buildExternalOpenSessionLinkUri(productUrlProtocol: string, backendSession: URI | string, chatId?: string, turnId?: string): string {
const sessionLink = buildOpenSessionLinkUri(backendSession);
const encodedTarget = sessionLink.slice(`${AGENT_HOST_SESSION_LINK_SCHEME}://`.length);
const chatPath = chatId && chatId !== DEFAULT_CHAT_ID ? `${AGENT_HOST_CHAT_LINK_PATH_SEGMENT}${encodeURIComponent(encodeURIComponent(chatId))}` : '';
const query = turnId ? `?turn=${encodeURIComponent(turnId)}` : '';
return `${productUrlProtocol}://${AGENTS_AUTHORITY}${AGENT_HOST_SESSION_LINK_PATH_PREFIX}${encodedTarget}${chatPath}${query}`;
}

/**
* Recovers the internal agent-host session link carried by an Agents product protocol URL.
*/
export function parseExternalOpenSessionLinkUri(uri: URI | string, productUrlProtocol: string): URI | undefined {
const parsed = typeof uri === 'string' ? URI.parse(uri) : uri;
if (parsed.scheme !== productUrlProtocol || parsed.authority !== AGENTS_AUTHORITY || !parsed.path.startsWith(AGENT_HOST_SESSION_LINK_PATH_PREFIX)) {
return undefined;
}

const sessionPath = parsed.path.slice(AGENT_HOST_SESSION_LINK_PATH_PREFIX.length);
const providerEnd = sessionPath.indexOf('/');
if (providerEnd <= 0 || providerEnd === sessionPath.length - 1) {
return undefined;
}

const chatSegmentIndex = sessionPath.lastIndexOf(AGENT_HOST_CHAT_LINK_PATH_SEGMENT);
const hasChatSegment = chatSegmentIndex > providerEnd;
let chatId: string | undefined;
if (hasChatSegment) {
const encodedChatId = sessionPath.slice(chatSegmentIndex + AGENT_HOST_CHAT_LINK_PATH_SEGMENT.length);
if (!encodedChatId) {
return undefined;
}
try {
chatId = decodeURIComponent(encodedChatId);
} catch {
return undefined;
}
}
const sessionTarget = hasChatSegment ? sessionPath.slice(0, chatSegmentIndex) : sessionPath;
const query = [
chatId ? `chat=${encodeURIComponent(chatId)}` : undefined,
parsed.query,
].filter(queryPart => !!queryPart).join('&');
const sessionLink = URI.from({
scheme: AGENT_HOST_SESSION_LINK_SCHEME,
authority: sessionTarget.slice(0, providerEnd),
path: sessionTarget.slice(providerEnd),
query,
fragment: parsed.fragment,
});
return parseOpenSessionLinkUri(sessionLink) ? sessionLink : undefined;
}

/**
* Recovers the backend session URI from an {@link AGENT_HOST_SESSION_LINK_SCHEME}
* link, or `undefined` when the URI is not such a link.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,31 @@ suite('AgentHostConnectionsService', () => {
resolutionChanges: 2,
});
});

test('resolves remote session identity while disconnected', () => {
const { service } = createService([info('myhost', 'My Remote')], new Map());
store.add(service.registerSessionResolutionPolicy('myhost', {
sessionSchemeAlias: { ui: 'copilot', backend: 'ahp-session' },
defaultChangesetKind: ChangesetKind.Session,
}));

const resource = URI.parse('remote-myhost-copilot:/xyz789');
const identity = service.resolveSessionResourceIdentity(resource);

assert.deepStrictEqual({
identity: identity && {
connectionAuthority: identity.connectionAuthority,
backendSession: identity.backendSession.toString(),
defaultChangesetKind: identity.defaultChangesetKind,
},
resolution: service.resolveSessionResource(resource),
}, {
identity: {
connectionAuthority: 'myhost',
backendSession: 'ahp-session:/xyz789',
defaultChangesetKind: ChangesetKind.Session,
},
resolution: undefined,
});
});
});
51 changes: 50 additions & 1 deletion src/vs/platform/agentHost/test/common/openSessionLink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import assert from 'assert';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { AGENT_HOST_CHAT_LINK_PATTERN, AGENT_HOST_SESSION_ONLY_LINK_PATTERN, buildAgentSessionLinkPresentation, buildOpenSessionLinkForChatResource, buildOpenSessionLinkUri, isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseOpenSessionLinkChatId, parseOpenSessionLinkTurnId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js';
import { AGENT_HOST_CHAT_LINK_PATTERN, AGENT_HOST_SESSION_ONLY_LINK_PATTERN, buildAgentSessionLinkPresentation, buildExternalOpenSessionLinkUri, buildOpenSessionLinkForChatResource, buildOpenSessionLinkUri, isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseExternalOpenSessionLinkUri, parseOpenSessionLinkChatId, parseOpenSessionLinkTurnId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js';
import { buildChatUri, buildDefaultChatUri } from '../../common/state/sessionState.js';

suite('openSessionLink', () => {
Expand Down Expand Up @@ -41,6 +41,55 @@ suite('openSessionLink', () => {
assert.strictEqual(parsed?.toString(), URI.parse(backend).toString());
});

test('builds and parses an external Agents window session link', () => {
const external = buildExternalOpenSessionLinkUri('vscode-insiders', 'copilotcli:/abc-123', 'chat-9', 'turn-7');
assert.deepStrictEqual({
external,
internal: parseExternalOpenSessionLinkUri(external, 'vscode-insiders')?.toString(true),
}, {
external: 'vscode-insiders://agents/agent-host-session/copilotcli/abc-123/chat/chat-9?turn=turn-7',
internal: 'agent-host-session://copilotcli/abc-123?chat=chat-9&turn=turn-7',
});
});

test('encodes chat ids as path segments in external links', () => {
const external = buildExternalOpenSessionLinkUri('vscode-insiders', 'copilotcli:/abc-123', 'chat/9');
const internal = parseExternalOpenSessionLinkUri(external, 'vscode-insiders');

assert.deepStrictEqual({
external,
chatId: internal && parseOpenSessionLinkChatId(internal),
}, {
external: 'vscode-insiders://agents/agent-host-session/copilotcli/abc-123/chat/chat%252F9',
chatId: 'chat/9',
});
});

test('preserves percent escapes in opaque session ids', () => {
const backend = URI.from({ scheme: 'copilotcli', path: '/abc%2Fdef' });
const external = buildExternalOpenSessionLinkUri('vscode-insiders', backend);
const internal = parseExternalOpenSessionLinkUri(external, 'vscode-insiders');

assert.deepStrictEqual({
external,
backend: internal && parseOpenSessionLinkUri(internal)?.toString(),
}, {
external: 'vscode-insiders://agents/agent-host-session/copilotcli/abc%252Fdef',
backend: 'copilotcli:/abc%252Fdef',
});
});

test('rejects invalid external Agents window session links', () => {
assert.deepStrictEqual([
parseExternalOpenSessionLinkUri('vscode://agents/agent-host-session/copilotcli/abc-123', 'vscode-insiders'),
parseExternalOpenSessionLinkUri('vscode-insiders://extensions/agent-host-session/copilotcli/abc-123', 'vscode-insiders'),
parseExternalOpenSessionLinkUri('vscode-insiders://agents/session/copilotcli/abc-123', 'vscode-insiders'),
parseExternalOpenSessionLinkUri('vscode-insiders://agents/agent-host-session/copilotcli', 'vscode-insiders'),
parseExternalOpenSessionLinkUri('vscode-insiders://agents/agent-host-session//abc-123', 'vscode-insiders'),
parseExternalOpenSessionLinkUri('vscode-insiders://agents/agent-host-session/copilotcli/abc-123/chat/', 'vscode-insiders'),
], [undefined, undefined, undefined, undefined, undefined, undefined]);
});

test('carries an optional chat id', () => {
const link = buildOpenSessionLinkUri('copilotcli:/abc-123', 'chat-9');
assert.strictEqual(link, 'agent-host-session://copilotcli/abc-123?chat=chat-9');
Expand Down
2 changes: 2 additions & 0 deletions src/vs/platform/window/common/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export const enum AgentsWindowOpenSource {
ChatHandoff = 'chatHandoff',
Banner = 'banner',
CommandLine = 'commandLine',
Link = 'link',
Unknown = 'unknown',
}

Expand All @@ -124,6 +125,7 @@ export function isAgentsWindowOpenSource(value: unknown): value is AgentsWindowO
case AgentsWindowOpenSource.ChatHandoff:
case AgentsWindowOpenSource.Banner:
case AgentsWindowOpenSource.CommandLine:
case AgentsWindowOpenSource.Link:
case AgentsWindowOpenSource.Unknown:
return true;
default:
Expand Down
Loading