Skip to content
Draft
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 @@ -20,7 +20,7 @@ export interface IWebContentExtractorOptions {
}

export type WebContentExtractResult =
| { status: 'ok'; result: string }
| { status: 'ok'; result: string; title?: string }
| { status: 'error'; error: string }
| { status: 'redirect'; toURI: URI };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface CacheEntry {
result: string;
timestamp: number;
finalURI: URI;
title?: string;
}

export class NativeWebContentExtractorService implements IWebContentExtractorService {
Expand Down Expand Up @@ -54,7 +55,7 @@ export class NativeWebContentExtractorService implements IWebContentExtractorSer
} else if (!options?.followRedirects && cached.finalURI.authority !== uri.authority) {
return { status: 'redirect', toURI: cached.finalURI };
} else {
return { status: 'ok', result: cached.result };
return { status: 'ok', result: cached.result, title: cached.title };
}
}

Expand All @@ -81,7 +82,7 @@ export class NativeWebContentExtractorService implements IWebContentExtractorSer
: await Promise.race([this.interceptRedirects(win, uri, store), this.extractAX(win, uri)]);

if (result.status === 'ok') {
this._webContentsCache.set(uri, { result: result.result, timestamp: Date.now(), finalURI: URI.parse(win.webContents.getURL()) });
this._webContentsCache.set(uri, { result: result.result, timestamp: Date.now(), finalURI: URI.parse(win.webContents.getURL()), title: result.title });
}

return result;
Expand All @@ -95,12 +96,13 @@ export class NativeWebContentExtractorService implements IWebContentExtractorSer

private async extractAX(win: BrowserWindow, uri: URI): Promise<WebContentExtractResult> {
await win.loadURL(uri.toString(true));
const title = win.webContents.getTitle();
win.webContents.debugger.attach('1.1');
const result: { nodes: AXNode[] } = await win.webContents.debugger.sendCommand('Accessibility.getFullAXTree');
const str = convertAXTreeToMarkdown(uri, result.nodes);
this._logger.info(`[NativeWebContentExtractorService] Content extracted from ${uri}`);
this._logger.trace(`[NativeWebContentExtractorService] Extracted content: ${str}`);
return { status: 'ok', result: str };
return { status: 'ok', result: str, title };
}

private interceptRedirects(win: BrowserWindow, uri: URI, store: DisposableStore) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { IMarkdownString } from '../../../../../../base/common/htmlContent.js';
import { URI } from '../../../../../../base/common/uri.js';
import { Location } from '../../../../../../editor/common/languages.js';
import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js';
import { isToolResultReference, IToolResultReference } from '../../../common/languageModelToolsService.js';
import { IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService.js';
import { IChatCodeBlockInfo } from '../../chat.js';
import { IChatContentPartRenderContext } from '../chatContentParts.js';
Expand All @@ -21,18 +22,27 @@ export class ChatResultListSubPart extends BaseChatToolInvocationSubPart {
toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized,
context: IChatContentPartRenderContext,
message: string | IMarkdownString,
toolDetails: Array<URI | Location>,
toolDetails: Array<URI | Location | IToolResultReference>,
listPool: CollapsibleListPool,
@IInstantiationService instantiationService: IInstantiationService,
) {
super(toolInvocation);

const collapsibleListPart = this._register(instantiationService.createInstance(
ChatCollapsibleListContentPart,
toolDetails.map<IChatCollapsibleListItem>(detail => ({
kind: 'reference',
reference: detail,
})),
toolDetails.map<IChatCollapsibleListItem>(detail => {
if (isToolResultReference(detail)) {
return {
kind: 'reference',
reference: detail.uri,
title: detail.title,
};
}
return {
kind: 'reference',
reference: detail,
};
}),
message,
context,
listPool,
Expand Down
4 changes: 2 additions & 2 deletions src/vs/workbench/contrib/chat/common/chatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { IChatParserContext } from './chatRequestParser.js';
import { IChatRequestVariableEntry } from './chatVariableEntries.js';
import { IChatRequestVariableValue } from './chatVariables.js';
import { ChatAgentLocation, ChatModeKind } from './constants.js';
import { IPreparedToolInvocation, IToolConfirmationMessages, IToolResult, IToolResultInputOutputDetails, ToolDataSource } from './languageModelToolsService.js';
import { IPreparedToolInvocation, IToolConfirmationMessages, IToolResult, IToolResultInputOutputDetails, IToolResultReference, ToolDataSource } from './languageModelToolsService.js';

export interface IChatRequest {
message: string;
Expand Down Expand Up @@ -555,7 +555,7 @@ export interface IChatToolInvocationSerialized {
invocationMessage: string | IMarkdownString;
originMessage: string | IMarkdownString | undefined;
pastTenseMessage: string | IMarkdownString | undefined;
resultDetails?: Array<URI | Location> | IToolResultInputOutputDetails | IToolResultOutputDetailsSerialized;
resultDetails?: Array<URI | Location | IToolResultReference> | IToolResultInputOutputDetails | IToolResultOutputDetailsSerialized;
/** boolean used by pre-1.104 versions */
isConfirmed: ConfirmedReason | boolean | undefined;
isComplete: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,10 +192,19 @@ export function isToolResultOutputDetails(obj: any): obj is IToolResultOutputDet
return typeof obj === 'object' && typeof obj?.output === 'object' && typeof obj?.output?.mimeType === 'string' && obj?.output?.type === 'data';
}

export interface IToolResultReference {
readonly uri: URI;
readonly title?: string;
}

export function isToolResultReference(obj: any): obj is IToolResultReference {
return typeof obj === 'object' && URI.isUri(obj?.uri);
}

export interface IToolResult {
content: (IToolResultPromptTsxPart | IToolResultTextPart | IToolResultDataPart)[];
toolResultMessage?: string | IMarkdownString;
toolResultDetails?: Array<URI | Location> | IToolResultInputOutputDetails | IToolResultOutputDetails;
toolResultDetails?: Array<URI | Location | IToolResultReference> | IToolResultInputOutputDetails | IToolResultOutputDetails;
toolResultError?: string;
toolMetadata?: unknown;
/** Whether to ask the user to confirm these tool results. Overrides {@link IToolConfirmationMessages.confirmResults}. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { IWebContentExtractorService, WebContentExtractResult } from '../../../.
import { detectEncodingFromBuffer } from '../../../../services/textfile/common/encoding.js';
import { ITrustedDomainService } from '../../../url/browser/trustedDomainService.js';
import { ChatImageMimeType } from '../../common/languageModels.js';
import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, IToolResultDataPart, IToolResultTextPart, ToolDataSource, ToolProgress } from '../../common/languageModelToolsService.js';
import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, IToolResultDataPart, IToolResultReference, IToolResultTextPart, ToolDataSource, ToolProgress } from '../../common/languageModelToolsService.js';
import { InternalFetchWebPageToolId } from '../../common/tools/tools.js';

export const FetchWebPageToolData: IToolData = {
Expand Down Expand Up @@ -106,6 +106,17 @@ export class FetchWebPageTool implements IToolImpl {
}
}

// Build references with titles for web URIs
const webReferences: IToolResultReference[] = [];
let webRefIndex = 0;
for (const uri of webUris.values()) {
const content = webContents[webRefIndex];
if (content && content.status === 'ok') {
webReferences.push({ uri, title: content.title });
}
webRefIndex++;
}

// Build results array in original order
const results: ResultType[] = [];
let webIndex = 0;
Expand All @@ -131,12 +142,32 @@ export class FetchWebPageTool implements IToolImpl {
}


// Only include URIs that actually had content successfully fetched
const actuallyValidUris = [...webUris.values(), ...successfulFileUris];
// Build the toolResultDetails with references and titles
const actuallyValidUris: Array<URI | IToolResultReference> = [...webReferences, ...successfulFileUris];

// Create toolResultMessage with title for single web resource
let toolResultMessage: MarkdownString | undefined;
if (webReferences.length === 1 && successfulFileUris.length === 0 && webReferences[0].title) {
const title = webReferences[0].title;
const url = webReferences[0].uri.toString();
toolResultMessage = new MarkdownString();
if (url.length > 400) {
toolResultMessage.appendMarkdown(localize({
key: 'fetchWebPage.toolResultMessage.singularAsLink',
comment: [
// Make sure the link syntax is correct
'{Locked="]({0})"}',
]
}, 'Fetched [{0}]({1})', title, url));
} else {
toolResultMessage.appendMarkdown(localize('fetchWebPage.toolResultMessage.singular', 'Fetched {0}', title));
}
}

return {
content: this._getPromptPartsForResults(results),
toolResultDetails: actuallyValidUris,
toolResultMessage,
confirmResults,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,21 @@ import { FetchWebPageTool } from '../../electron-browser/tools/fetchPageTool.js'
import { TestFileService } from '../../../../test/common/workbenchTestServices.js';
import { MockTrustedDomainService } from '../../../url/test/browser/mockTrustedDomainService.js';
import { InternalFetchWebPageToolId } from '../../common/tools/tools.js';
import { isToolResultReference } from '../../common/languageModelToolsService.js';

class TestWebContentExtractorService implements IWebContentExtractorService {
_serviceBrand: undefined;

constructor(private uriToContentMap: ResourceMap<string>) { }
constructor(private uriToContentMap: ResourceMap<string>, private uriToTitleMap?: ResourceMap<string>) { }

async extract(uris: URI[]): Promise<WebContentExtractResult[]> {
return uris.map(uri => {
const content = this.uriToContentMap.get(uri);
if (content === undefined) {
throw new Error(`No content configured for URI: ${uri.toString()}`);
}
return { status: 'ok', result: content };
const title = this.uriToTitleMap?.get(uri);
return { status: 'ok', result: content, title };
});
}
}
Expand Down Expand Up @@ -511,9 +513,15 @@ suite('FetchWebPageTool', () => {
assert.ok(Array.isArray(result.toolResultDetails), 'toolResultDetails should be an array');
assert.strictEqual(result.toolResultDetails.length, 4, 'Should have 4 successful URIs');

// Check that all entries are URI objects
const uriDetails = result.toolResultDetails as URI[];
assert.ok(uriDetails.every(uri => uri instanceof URI), 'All toolResultDetails entries should be URI objects');
// Check that all entries are URI objects or IToolResultReference
const actualUriStrings = result.toolResultDetails.map(detail => {
if (URI.isUri(detail)) {
return detail.toString();
} else if (typeof detail === 'object' && 'uri' in detail) {
return detail.uri.toString();
}
throw new Error('Unexpected detail type');
});

// Check specific URIs are included (web URIs first, then successful file URIs)
const expectedUris = [
Expand All @@ -523,7 +531,6 @@ suite('FetchWebPageTool', () => {
'mcp-resource://server/file.txt'
];

const actualUriStrings = uriDetails.map(uri => uri.toString());
assert.deepStrictEqual(actualUriStrings.sort(), expectedUris.sort(), 'Should contain exactly the expected successful URIs');

// Verify content array matches input order (including failures)
Expand Down Expand Up @@ -863,4 +870,131 @@ suite('FetchWebPageTool', () => {
}
});
});

test('should include page titles in toolResultDetails for web URIs', async () => {
const webContentMap = new ResourceMap<string>([
[URI.parse('https://example1.com'), 'Content 1'],
[URI.parse('https://example2.com'), 'Content 2']
]);

const titleMap = new ResourceMap<string>([
[URI.parse('https://example1.com'), 'Example 1 - Page Title'],
[URI.parse('https://example2.com'), 'Example 2 - Page Title']
]);

const fileContentMap = new ResourceMap<string | VSBuffer>([
[URI.parse('file:///test.txt'), 'File content']
]);

const tool = new FetchWebPageTool(
new TestWebContentExtractorService(webContentMap, titleMap),
new ExtendedTestFileService(fileContentMap),
new MockTrustedDomainService(),
);

const result = await tool.invoke(
{
callId: 'test-titles',
toolId: 'fetch-page',
parameters: { urls: ['https://example1.com', 'https://example2.com', 'file:///test.txt'] },
context: undefined
},
() => Promise.resolve(0),
{ report: () => { } },
CancellationToken.None
);

// Verify toolResultDetails contains the URIs with titles
assert.ok(Array.isArray(result.toolResultDetails), 'toolResultDetails should be an array');
assert.strictEqual(result.toolResultDetails.length, 3, 'Should have 3 entries');

// First two should be IToolResultReference with titles
const firstDetail = result.toolResultDetails[0];
assert.ok(isToolResultReference(firstDetail), 'First entry should be IToolResultReference');
if (isToolResultReference(firstDetail)) {
assert.strictEqual(firstDetail.uri.toString(), 'https://example1.com/', 'First URI should match');
assert.strictEqual(firstDetail.title, 'Example 1 - Page Title', 'First title should match');
}

const secondDetail = result.toolResultDetails[1];
assert.ok(isToolResultReference(secondDetail), 'Second entry should be IToolResultReference');
if (isToolResultReference(secondDetail)) {
assert.strictEqual(secondDetail.uri.toString(), 'https://example2.com/', 'Second URI should match');
assert.strictEqual(secondDetail.title, 'Example 2 - Page Title', 'Second title should match');
}

// Third should be just a URI (file)
const thirdDetail = result.toolResultDetails[2];
assert.ok(URI.isUri(thirdDetail), 'Third entry should be a plain URI');
if (URI.isUri(thirdDetail)) {
assert.strictEqual(thirdDetail.toString(), 'file:///test.txt', 'Third URI should match');
}
});

test('should include page title in toolResultMessage for single web URI', async () => {
const webContentMap = new ResourceMap<string>([
[URI.parse('https://example.com'), 'Content']
]);

const titleMap = new ResourceMap<string>([
[URI.parse('https://example.com'), 'Example Page Title']
]);

const tool = new FetchWebPageTool(
new TestWebContentExtractorService(webContentMap, titleMap),
new ExtendedTestFileService(new ResourceMap<string | VSBuffer>()),
new MockTrustedDomainService(),
);

const result = await tool.invoke(
{
callId: 'test-title-message',
toolId: 'fetch-page',
parameters: { urls: ['https://example.com'] },
context: undefined
},
() => Promise.resolve(0),
{ report: () => { } },
CancellationToken.None
);

// Verify toolResultMessage contains the title
assert.ok(result.toolResultMessage, 'Should have toolResultMessage');
const messageText = typeof result.toolResultMessage === 'string' ? result.toolResultMessage : result.toolResultMessage.value;
assert.ok(messageText.includes('Example Page Title'), 'toolResultMessage should contain the title');
assert.ok(messageText.includes('Fetched'), 'toolResultMessage should contain "Fetched"');
});

test('should not include toolResultMessage for multiple resources', async () => {
const webContentMap = new ResourceMap<string>([
[URI.parse('https://example1.com'), 'Content 1'],
[URI.parse('https://example2.com'), 'Content 2']
]);

const titleMap = new ResourceMap<string>([
[URI.parse('https://example1.com'), 'Example 1'],
[URI.parse('https://example2.com'), 'Example 2']
]);

const tool = new FetchWebPageTool(
new TestWebContentExtractorService(webContentMap, titleMap),
new ExtendedTestFileService(new ResourceMap<string | VSBuffer>()),
new MockTrustedDomainService(),
);

const result = await tool.invoke(
{
callId: 'test-no-message',
toolId: 'fetch-page',
parameters: { urls: ['https://example1.com', 'https://example2.com'] },
context: undefined
},
() => Promise.resolve(0),
{ report: () => { } },
CancellationToken.None
);

// Should not have toolResultMessage for multiple resources
assert.strictEqual(result.toolResultMessage, undefined, 'Should not have toolResultMessage for multiple resources');
});
});