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
Original file line number Diff line number Diff line change
Expand Up @@ -6811,7 +6811,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
}

resolveChatResponseUri(_sessionResource: URI, href: string, _kind: 'link' | 'image'): string {
return rewriteAgentHostLinkTarget(href, this._config.connectionAuthority);
return rewriteAgentHostLinkTarget(href, this._config.connectionAuthority, this._config.connection.resourceUris);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2126,7 +2126,7 @@ function normalizeFileUriSelection(uri: URI, href: string): URI {
}

/** Wraps an absolute path or internal URI target for the owning Agent Host connection. */
export function rewriteAgentHostLinkTarget(href: string, connectionAuthority: string): string {
export function rewriteAgentHostLinkTarget(href: string, connectionAuthority: string, resourceUris: IAgentHostResourceUriMapper = createAgentHostResourceUriMapper(connectionAuthority)): string {
let parsed = parseAbsoluteFileLinkTarget(href);
if (!parsed) {
try {
Expand All @@ -2146,7 +2146,10 @@ export function rewriteAgentHostLinkTarget(href: string, connectionAuthority: st

let agentHostUri: URI;
try {
agentHostUri = toAgentHostUri(parsed, connectionAuthority);
agentHostUri = resourceUris.fromAgentHost(parsed);
if (parsed.scheme !== Schemas.file && isEqual(agentHostUri, parsed)) {
agentHostUri = toAgentHostUri(parsed, connectionAuthority);
}
} catch {
return href;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/ho
import { IMarkdownString } from '../../../../../base/common/htmlContent.js';
import { DisposableStore } from '../../../../../base/common/lifecycle.js';
import { type MarkedExtension } from '../../../../../base/common/marked/marked.js';
import { URI } from '../../../../../base/common/uri.js';
import { ILabelService } from '../../../../../platform/label/common/label.js';
import { IMarkdownRenderer, IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js';
import { ILanguageService } from '../../../../../editor/common/languages/language.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
Expand Down Expand Up @@ -124,6 +126,7 @@ export class ChatContentMarkdownRenderer implements IMarkdownRenderer {
@IConfigurationService configurationService: IConfigurationService,
@IHoverService private readonly hoverService: IHoverService,
@IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService,
@ILabelService private readonly labelService: ILabelService,
) { }

render(markdown: IMarkdownString, options?: MarkdownRenderOptions, outElement?: HTMLElement): IRenderedMarkdown {
Expand Down Expand Up @@ -162,7 +165,12 @@ export class ChatContentMarkdownRenderer implements IMarkdownRenderer {
// eslint-disable-next-line no-restricted-syntax
result.element.querySelectorAll('a').forEach((element) => {
if (element.title) {
const title = element.title;
let title = element.title;
if (title === element.dataset.href && title.startsWith(`${AGENT_HOST_SCHEME}:`)) {
const uri = URI.parse(title);
const label = this.labelService.getUriLabel(uri);
title = uri.fragment ? `${label}#${uri.fragment}` : label;
}
element.title = '';
store.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), element, title));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js';
import type { ChatInputRequestWithPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js';
import { createAgentHostResourceUriMapper, identityAgentHostResourceUriMapper, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js';
import { agentHostAuthority, createAgentHostResourceUriMapper, fromAgentHostUri, identityAgentHostResourceUriMapper, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js';
import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAttachments.js';
import { VSCODE_EPHEMERAL_SESSION_META_KEY } from '../../../../../../platform/agentHost/common/meta/agentEphemeralSessionMeta.js';
import { getElementAttachmentCorrelationId, toElementAttachmentMeta } from '../../../../../../platform/agentHost/common/meta/agentElementAttachments.js';
Expand Down Expand Up @@ -1271,6 +1271,90 @@ suite('AgentHostChatContribution', () => {

});

suite('response resource links', () => {
test('uses the WSL connection for file links despite a local session authority', () => {
const { sessionHandler, agentHostService } = createContribution(disposables);
const authority = agentHostAuthority('vscode-remote://wsl+Ubuntu');
agentHostService.resourceUris = createAgentHostResourceUriMapper(authority);
const session = URI.parse('agent-host-copilot:/session');
const file = URI.file('/home/user/project/src/file.ts').with({ fragment: 'L42,7' });
const targets = [
'/home/user/project/src/file.ts:42:7',
'file:///home/user/project/src/file.ts#L42,7',
];

assert.deepStrictEqual(targets.map(href => {
const resolved = URI.parse(sessionHandler.resolveChatResponseUri(session, href, 'link'));
return { resolved: resolved.toString(), hostUri: fromAgentHostUri(resolved).toString() };
}), targets.map(() => ({
resolved: toAgentHostUri(file, authority).toString(),
hostUri: file.toString(),
})));
});

test('uses the WSL connection for image paths and preserves encoded path characters', () => {
const { sessionHandler, agentHostService } = createContribution(disposables);
const authority = agentHostAuthority('vscode-remote://wsl+Ubuntu');
agentHostService.resourceUris = createAgentHostResourceUriMapper(authority);
const session = URI.parse('agent-host-copilot:/session');
const image = URI.file('/home/user/my project/image.png');

assert.strictEqual(
sessionHandler.resolveChatResponseUri(session, '/home/user/my%20project/image.png', 'image'),
toAgentHostUri(image, authority).toString(),
);
});

for (const host of ['local', 'WSL']) {
test(`routes ${host} internal resource links and images through the owning Agent Host`, () => {
const { sessionHandler, agentHostService } = createContribution(disposables);
const authority = host === 'local' ? 'local' : agentHostAuthority('vscode-remote://wsl+Ubuntu');
agentHostService.resourceUris = host === 'local' ? identityAgentHostResourceUriMapper : createAgentHostResourceUriMapper(authority);
const session = URI.parse('agent-host-copilot:/session');
const resources = [
URI.parse('agenthost-content:///session/my%20result.txt?view=raw#L42,7'),
URI.parse('git-blob:///project/src/file.ts?ref=HEAD#L7'),
];

assert.deepStrictEqual(resources.map(resource => {
const href = resource.toString();
const link = sessionHandler.resolveChatResponseUri(session, href, 'link');
return {
link,
image: sessionHandler.resolveChatResponseUri(session, href, 'image'),
unwrapped: fromAgentHostUri(URI.parse(link)).toString(),
alreadyMapped: sessionHandler.resolveChatResponseUri(session, toAgentHostUri(resource, authority).toString(), 'link'),
};
}), resources.map(resource => ({
link: toAgentHostUri(resource, authority).toString(),
image: toAgentHostUri(resource, authority).toString(),
unwrapped: resource.toString(),
alreadyMapped: toAgentHostUri(resource, authority).toString(),
})));
});
}

test('preserves local file links and external or already mapped links', () => {
const { sessionHandler, agentHostService } = createContribution(disposables);
const session = URI.parse('agent-host-copilot:/session');
const local = sessionHandler.resolveChatResponseUri(session, '/project/file.ts:42', 'link');
const authority = agentHostAuthority('vscode-remote://wsl+Ubuntu');
agentHostService.resourceUris = createAgentHostResourceUriMapper(authority);
const mapped = toAgentHostUri(URI.file('/home/user/file.ts'), authority).toString();
const external = 'https://example.com/file.ts';

assert.deepStrictEqual({
local,
mapped: sessionHandler.resolveChatResponseUri(session, mapped, 'link'),
external: sessionHandler.resolveChatResponseUri(session, external, 'link'),
}, {
local: URI.file('/project/file.ts').with({ fragment: 'L42' }).toString(),
mapped,
external,
});
});
});

// ---- Download progress notification (editor window) -----------------

suite('download progress', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,93 @@
*--------------------------------------------------------------------------------------------*/

import assert from 'assert';
import sinon from 'sinon';
import { MarkdownString } from '../../../../../../base/common/htmlContent.js';
import { OperatingSystem } from '../../../../../../base/common/platform.js';
import { URI } from '../../../../../../base/common/uri.js';
import { assertSnapshot } from '../../../../../../base/test/common/snapshot.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
import { AGENT_HOST_LABEL_FORMATTER, agentHostAuthority, agentHostLabelFormatter, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js';
import { IHoverService } from '../../../../../../platform/hover/browser/hover.js';
import { NullHoverService } from '../../../../../../platform/hover/test/browser/nullHoverService.js';
import { ILabelService } from '../../../../../../platform/label/common/label.js';
import { ChatContentMarkdownRenderer } from '../../../browser/widget/chatContentMarkdownRenderer.js';
import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js';

suite('ChatMarkdownRenderer', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();

let testRenderer: ChatContentMarkdownRenderer;
let instantiationService: ReturnType<typeof workbenchInstantiationService>;
setup(() => {
const instantiationService = store.add(workbenchInstantiationService(undefined, store));
instantiationService = store.add(workbenchInstantiationService(undefined, store));
testRenderer = instantiationService.createInstance(ChatContentMarkdownRenderer);
});

suite('link hovers', () => {
let setupManagedHover: sinon.SinonSpy<Parameters<IHoverService['setupManagedHover']>, ReturnType<IHoverService['setupManagedHover']>>;

setup(() => {
setupManagedHover = sinon.spy(NullHoverService.setupManagedHover);
instantiationService.stub(IHoverService, { ...NullHoverService, setupManagedHover });
store.add(instantiationService.get(ILabelService).registerFormatter(AGENT_HOST_LABEL_FORMATTER));
testRenderer = instantiationService.createInstance(ChatContentMarkdownRenderer);
});

test('shows host paths for transformed and already mapped links without changing their targets', () => {
const authority = agentHostAuthority('vscode-remote://wsl+Ubuntu');
store.add(instantiationService.get(ILabelService).registerFormatter(agentHostLabelFormatter(authority, OperatingSystem.Linux)));
const file = URI.file('/home/user/my project/a&b.ts').with({ fragment: 'L42,7' });
const target = toAgentHostUri(file, authority).toString();
const links = [
{ href: '/home/user/my%20project/a&b.ts:42:7', transformUri: () => target },
{ href: target, transformUri: undefined },
];

const actual = links.map(({ href, transformUri }) => {
const result = store.add(testRenderer.render(new MarkdownString(`[file](${href})`), { transformUri }));
const link = result.element.querySelector('a');
return {
hover: setupManagedHover.lastCall.args[2],
target: link?.dataset.href,
text: link?.textContent,
nativeTitle: link?.title,
};
});

assert.deepStrictEqual(actual, links.map(() => ({
hover: '/home/user/my project/a&b.ts#L42,7',
target,
text: 'file',
nativeTitle: '',
})));
});

test('uses the remote host operating system for path formatting', () => {
const labelService = instantiationService.get(ILabelService);
store.add(labelService.registerFormatter(agentHostLabelFormatter('windows-host', OperatingSystem.Windows)));
const target = toAgentHostUri(URI.file('C:/my project/file.ts'), 'windows-host').toString();
store.add(testRenderer.render(new MarkdownString(`[file](${target})`)));

assert.strictEqual(setupManagedHover.lastCall.args[2], 'C:\\my project\\file.ts');
});

test('preserves explicit titles and ordinary file, external, and command link behavior', () => {
const target = toAgentHostUri(URI.file('/home/user/file.ts'), 'remote-host').toString();
const file = URI.file('/my project/file.ts').with({ fragment: 'L7' });
const markdown = new MarkdownString(`[custom](${target} "Custom title") [file](${file}) [web](https://example.com/) [command](command:example)`, { isTrusted: true });
const result = store.add(testRenderer.render(markdown));

assert.deepStrictEqual({
hovers: setupManagedHover.getCalls().map(call => call.args[2]),
targets: Array.from(result.element.querySelectorAll('a'), link => link.dataset.href),
}, {
hovers: ['Custom title', `${file.fsPath}#L7`, 'https://example.com/'],
targets: [target, file.toString(), 'https://example.com/', 'command:example'],
});
});
});

test('simple', async () => {
const md = new MarkdownString('a');
const result = store.add(testRenderer.render(md));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { buildAgentMergePrompt, IAgentMergePromptSummary, parseAgentMergePrompt
import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';
import { ILabelService } from '../../../../../platform/label/common/label.js';
import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js';
import { ChatContentMarkdownRenderer } from '../../../../contrib/chat/browser/widget/chatContentMarkdownRenderer.js';
import { ChatAgentMergeContentPart } from '../../../../contrib/chat/browser/widget/chatContentParts/chatAgentMergeContentPart.js';
Expand Down Expand Up @@ -178,6 +179,9 @@ function renderAgentMerge({ container, disposableStore, theme }: ComponentFixtur
const instantiationService = createEditorServices(disposableStore, {
colorTheme: theme,
additionalServices: (reg) => {
reg.defineInstance(ILabelService, new class extends mock<ILabelService>() {
override getUriLabel(uri: URI): string { return uri.path; }
}());
reg.define(IMarkdownRendererService, MarkdownRendererService);
reg.defineInstance(ICommandService, commandService);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
*--------------------------------------------------------------------------------------------*/

import * as dom from '../../../../../base/browser/dom.js';
import { URI } from '../../../../../base/common/uri.js';
import { mock } from '../../../../../base/test/common/mock.js';
import { agentMergeDisableReasons, agentMergeDisabledNotice, agentMergeEnabledNotice } from '../../../../../platform/agentHost/common/agentMerge.js';
import { AgentSystemNotificationKind, toAgentSystemNotificationMeta } from '../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js';
import { ILabelService } from '../../../../../platform/label/common/label.js';
import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js';
import { systemNotificationToChatPart } from '../../../../contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.js';
import { ChatContentMarkdownRenderer } from '../../../../contrib/chat/browser/widget/chatContentMarkdownRenderer.js';
Expand Down Expand Up @@ -35,6 +37,9 @@ function renderNotice(context: ComponentFixtureContext, content: string, kind: A
const instantiationService = createEditorServices(disposableStore, {
colorTheme: context.theme,
additionalServices: (reg) => {
reg.defineInstance(ILabelService, new class extends mock<ILabelService>() {
override getUriLabel(uri: URI): string { return uri.path; }
}());
reg.define(IMarkdownRendererService, MarkdownRendererService);
reg.defineInstance(IChatMarkdownAnchorService, anchorService);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import { Event } from '../../../../../base/common/event.js';
import { observableValue } from '../../../../../base/common/observable.js';
import { Codicon } from '../../../../../base/common/codicons.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { URI } from '../../../../../base/common/uri.js';
import { mock, upcastPartial } from '../../../../../base/test/common/mock.js';
import { ILabelService } from '../../../../../platform/label/common/label.js';
import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js';
import { ChatProgressContentPart } from '../../../../contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.js';
import { ChatContentMarkdownRenderer } from '../../../../contrib/chat/browser/widget/chatContentMarkdownRenderer.js';
Expand Down Expand Up @@ -68,6 +70,9 @@ function renderProgressPart(
const instantiationService = createEditorServices(disposableStore, {
colorTheme: context.theme,
additionalServices: (reg) => {
reg.defineInstance(ILabelService, new class extends mock<ILabelService>() {
override getUriLabel(uri: URI): string { return uri.path; }
}());
reg.define(IMarkdownRendererService, MarkdownRendererService);
reg.defineInstance(IChatMarkdownAnchorService, mockAnchorService);
},
Expand Down
Loading