diff --git a/src/vs/platform/webContentExtractor/common/webContentExtractor.ts b/src/vs/platform/webContentExtractor/common/webContentExtractor.ts index 6c9230d227b06..68a8160ed1016 100644 --- a/src/vs/platform/webContentExtractor/common/webContentExtractor.ts +++ b/src/vs/platform/webContentExtractor/common/webContentExtractor.ts @@ -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 }; diff --git a/src/vs/platform/webContentExtractor/electron-main/webContentExtractorService.ts b/src/vs/platform/webContentExtractor/electron-main/webContentExtractorService.ts index 2783aaed88d0b..f367b89f8b6b3 100644 --- a/src/vs/platform/webContentExtractor/electron-main/webContentExtractorService.ts +++ b/src/vs/platform/webContentExtractor/electron-main/webContentExtractorService.ts @@ -18,6 +18,7 @@ interface CacheEntry { result: string; timestamp: number; finalURI: URI; + title?: string; } export class NativeWebContentExtractorService implements IWebContentExtractorService { @@ -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 }; } } @@ -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; @@ -95,12 +96,13 @@ export class NativeWebContentExtractorService implements IWebContentExtractorSer private async extractAX(win: BrowserWindow, uri: URI): Promise { 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) { diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatResultListSubPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatResultListSubPart.ts index 5df65ab89fd3c..4c515ec3209d5 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatResultListSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatResultListSubPart.ts @@ -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'; @@ -21,7 +22,7 @@ export class ChatResultListSubPart extends BaseChatToolInvocationSubPart { toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, context: IChatContentPartRenderContext, message: string | IMarkdownString, - toolDetails: Array, + toolDetails: Array, listPool: CollapsibleListPool, @IInstantiationService instantiationService: IInstantiationService, ) { @@ -29,10 +30,19 @@ export class ChatResultListSubPart extends BaseChatToolInvocationSubPart { const collapsibleListPart = this._register(instantiationService.createInstance( ChatCollapsibleListContentPart, - toolDetails.map(detail => ({ - kind: 'reference', - reference: detail, - })), + toolDetails.map(detail => { + if (isToolResultReference(detail)) { + return { + kind: 'reference', + reference: detail.uri, + title: detail.title, + }; + } + return { + kind: 'reference', + reference: detail, + }; + }), message, context, listPool, diff --git a/src/vs/workbench/contrib/chat/common/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService.ts index 303ea2c29fceb..bcc8be1e66832 100644 --- a/src/vs/workbench/contrib/chat/common/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService.ts @@ -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; @@ -555,7 +555,7 @@ export interface IChatToolInvocationSerialized { invocationMessage: string | IMarkdownString; originMessage: string | IMarkdownString | undefined; pastTenseMessage: string | IMarkdownString | undefined; - resultDetails?: Array | IToolResultInputOutputDetails | IToolResultOutputDetailsSerialized; + resultDetails?: Array | IToolResultInputOutputDetails | IToolResultOutputDetailsSerialized; /** boolean used by pre-1.104 versions */ isConfirmed: ConfirmedReason | boolean | undefined; isComplete: boolean; diff --git a/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts index f7a17c118605b..ef16e3cc4f497 100644 --- a/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts @@ -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 | IToolResultInputOutputDetails | IToolResultOutputDetails; + toolResultDetails?: Array | IToolResultInputOutputDetails | IToolResultOutputDetails; toolResultError?: string; toolMetadata?: unknown; /** Whether to ask the user to confirm these tool results. Overrides {@link IToolConfirmationMessages.confirmResults}. */ diff --git a/src/vs/workbench/contrib/chat/electron-browser/tools/fetchPageTool.ts b/src/vs/workbench/contrib/chat/electron-browser/tools/fetchPageTool.ts index 7fc8b70b6f912..75d5e77484746 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/tools/fetchPageTool.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/tools/fetchPageTool.ts @@ -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 = { @@ -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; @@ -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 = [...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, }; } diff --git a/src/vs/workbench/contrib/chat/test/electron-browser/fetchPageTool.test.ts b/src/vs/workbench/contrib/chat/test/electron-browser/fetchPageTool.test.ts index 43cb02cf26aea..959469593d2d0 100644 --- a/src/vs/workbench/contrib/chat/test/electron-browser/fetchPageTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/electron-browser/fetchPageTool.test.ts @@ -15,11 +15,12 @@ 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) { } + constructor(private uriToContentMap: ResourceMap, private uriToTitleMap?: ResourceMap) { } async extract(uris: URI[]): Promise { return uris.map(uri => { @@ -27,7 +28,8 @@ class TestWebContentExtractorService implements IWebContentExtractorService { 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 }; }); } } @@ -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 = [ @@ -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) @@ -863,4 +870,131 @@ suite('FetchWebPageTool', () => { } }); }); + + test('should include page titles in toolResultDetails for web URIs', async () => { + const webContentMap = new ResourceMap([ + [URI.parse('https://example1.com'), 'Content 1'], + [URI.parse('https://example2.com'), 'Content 2'] + ]); + + const titleMap = new ResourceMap([ + [URI.parse('https://example1.com'), 'Example 1 - Page Title'], + [URI.parse('https://example2.com'), 'Example 2 - Page Title'] + ]); + + const fileContentMap = new ResourceMap([ + [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([ + [URI.parse('https://example.com'), 'Content'] + ]); + + const titleMap = new ResourceMap([ + [URI.parse('https://example.com'), 'Example Page Title'] + ]); + + const tool = new FetchWebPageTool( + new TestWebContentExtractorService(webContentMap, titleMap), + new ExtendedTestFileService(new ResourceMap()), + 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([ + [URI.parse('https://example1.com'), 'Content 1'], + [URI.parse('https://example2.com'), 'Content 2'] + ]); + + const titleMap = new ResourceMap([ + [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()), + 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'); + }); });