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 @@ -10,6 +10,7 @@ import { Emitter } from '../../../../../base/common/event.js';
import { IMarkdownString } from '../../../../../base/common/htmlContent.js';
import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js';
import { autorun, IObservable, observableValue } from '../../../../../base/common/observable.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { localize } from '../../../../../nls.js';
import { IChatRendererContent } from '../../common/chatViewModel.js';
import { ChatTreeItem } from '../chat.js';
Expand All @@ -26,10 +27,12 @@ export abstract class ChatCollapsibleContentPart extends Disposable implements I
protected readonly hasFollowingContent: boolean;
protected _isExpanded = observableValue<boolean>(this, false);
protected _collapseButton: ButtonWithIcon | undefined;
private _statusIconElement?: HTMLElement;

constructor(
private title: IMarkdownString | string,
protected readonly context: IChatContentPartRenderContext,
protected readonly statusIcon?: ThemeIcon,
) {
super();
this.hasFollowingContent = this.context.contentIndex + 1 < this.context.content.length;
Expand Down Expand Up @@ -60,6 +63,13 @@ export abstract class ChatCollapsibleContentPart extends Disposable implements I
this._domNode = $('.chat-used-context', undefined, buttonElement);
collapseButton.label = referencesLabel;

// Add status icon if provided
if (this.statusIcon) {
this._statusIconElement = $('span' + ThemeIcon.asCSSSelector(this.statusIcon));
this._statusIconElement.classList.add('chat-used-context-status-icon');
collapseButton.element.appendChild(this._statusIconElement);
}

this._register(collapseButton.onDidClick(() => {
const value = this._isExpanded.get();
this._isExpanded.set(!value, undefined);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,15 @@ export class ChatCollapsibleListContentPart extends ChatCollapsibleContentPart {
labelOverride: IMarkdownString | string | undefined,
context: IChatContentPartRenderContext,
private readonly contentReferencesListPool: CollapsibleListPool,
statusIcon: ThemeIcon | undefined,
@IOpenerService private readonly openerService: IOpenerService,
@IMenuService private readonly menuService: IMenuService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IContextMenuService private readonly contextMenuService: IContextMenuService,
) {
super(labelOverride ?? (data.length > 1 ?
localize('usedReferencesPlural', "Used {0} references", data.length) :
localize('usedReferencesSingular', "Used {0} reference", 1)), context);
localize('usedReferencesSingular', "Used {0} reference", 1)), context, statusIcon);
}

protected override initContent(): HTMLElement {
Expand Down Expand Up @@ -151,12 +152,13 @@ export class ChatUsedReferencesListContentPart extends ChatCollapsibleListConten
context: IChatContentPartRenderContext,
contentReferencesListPool: CollapsibleListPool,
private readonly options: IChatUsedReferencesListOptions,
statusIcon: ThemeIcon | undefined,
@IOpenerService openerService: IOpenerService,
@IMenuService menuService: IMenuService,
@IInstantiationService instantiationService: IInstantiationService,
@IContextMenuService contextMenuService: IContextMenuService,
) {
super(data, labelOverride, context, contentReferencesListPool, openerService, menuService, instantiationService, contextMenuService);
super(data, labelOverride, context, contentReferencesListPool, statusIcon, openerService, menuService, instantiationService, contextMenuService);
if (data.length === 0) {
dom.hide(this.domNode);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class ChatTaskContentPart extends Disposable implements IChatContentPart

if (task.progress.length) {
this.isSettled = true;
const refsPart = this._register(instantiationService.createInstance(ChatCollapsibleListContentPart, task.progress, task.content.value, context, contentReferencesListPool));
const refsPart = this._register(instantiationService.createInstance(ChatCollapsibleListContentPart, task.progress, task.content.value, context, contentReferencesListPool, undefined));
this.domNode = dom.$('.chat-progress-task');
this.domNode.appendChild(refsPart.domNode);
this.onDidChangeHeight = refsPart.onDidChangeHeight;
Expand Down
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 { Codicon } from '../../../../../../base/common/codicons.js';
import { IMarkdownString } from '../../../../../../base/common/htmlContent.js';
import { URI } from '../../../../../../base/common/uri.js';
import { Location } from '../../../../../../editor/common/languages.js';
Expand All @@ -23,6 +24,7 @@ export class ChatResultListSubPart extends BaseChatToolInvocationSubPart {
message: string | IMarkdownString,
toolDetails: Array<URI | Location>,
listPool: CollapsibleListPool,
hasError: boolean,
@IInstantiationService instantiationService: IInstantiationService,
) {
super(toolInvocation);
Expand All @@ -36,6 +38,7 @@ export class ChatResultListSubPart extends BaseChatToolInvocationSubPart {
message,
context,
listPool,
hasError ? Codicon.warning : undefined,
));
this._register(collapsibleListPart.onDidChangeHeight(() => this._onDidChangeHeight.fire()));
this.domNode = collapsibleListPart.domNode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,14 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa

const resultDetails = IChatToolInvocation.resultDetails(this.toolInvocation);
if (Array.isArray(resultDetails) && resultDetails.length) {
return this.instantiationService.createInstance(ChatResultListSubPart, this.toolInvocation, this.context, this.toolInvocation.pastTenseMessage ?? this.toolInvocation.invocationMessage, resultDetails, this.listPool);
// Check if tool metadata indicates an error (e.g., from fetch tool)
// The metadata structure is tool-specific, but we check for a common hasError property
const metadata = this.toolInvocation.toolMetadata;
let hasError = false;
if (metadata && typeof metadata === 'object' && 'hasError' in metadata) {
hasError = (metadata as { hasError: boolean }).hasError === true;
}
return this.instantiationService.createInstance(ChatResultListSubPart, this.toolInvocation, this.context, this.toolInvocation.pastTenseMessage ?? this.toolInvocation.invocationMessage, resultDetails, this.listPool, hasError);
}

if (isToolResultOutputDetails(resultDetails)) {
Expand Down
2 changes: 1 addition & 1 deletion src/vs/workbench/contrib/chat/browser/chatListRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1435,7 +1435,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
}

private renderContentReferencesListData(references: IChatReferences, labelOverride: string | undefined, context: IChatContentPartRenderContext, templateData: IChatListItemTemplate): ChatCollapsibleListContentPart {
const referencesPart = this.instantiationService.createInstance(ChatUsedReferencesListContentPart, references.references, labelOverride, context, this._contentReferencesListPool, { expandedWhenEmptyResponse: checkModeOption(this.delegate.currentChatMode(), this.rendererOptions.referencesExpandedWhenEmptyResponse) });
const referencesPart = this.instantiationService.createInstance(ChatUsedReferencesListContentPart, references.references, labelOverride, context, this._contentReferencesListPool, { expandedWhenEmptyResponse: checkModeOption(this.delegate.currentChatMode(), this.rendererOptions.referencesExpandedWhenEmptyResponse) }, undefined);
referencesPart.addDisposable(referencesPart.onDidChangeHeight(() => {
this.updateItemHeight(templateData);
}));
Expand Down
5 changes: 5 additions & 0 deletions src/vs/workbench/contrib/chat/browser/media/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -2175,6 +2175,11 @@ have to be updated for changes to the rules above, or to support more deeply nes
font-size: var(--vscode-chat-font-size-body-s);
}

.interactive-session .chat-used-context-label .chat-used-context-status-icon {
margin-left: 4px;
color: var(--vscode-problemsWarningIcon-foreground);
}

.interactive-item-container .progress-container {
display: flex;
align-items: center;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export class ChatToolInvocation implements IChatToolInvocation {
public readonly source: ToolDataSource;
public readonly fromSubAgent: boolean | undefined;
public readonly parameters: unknown;
public toolMetadata: unknown | undefined;

public readonly toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent;

Expand Down Expand Up @@ -85,6 +86,11 @@ export class ChatToolInvocation implements IChatToolInvocation {
this.pastTenseMessage = this._progress.get().message;
}

// Store tool metadata for later access
if (result?.toolMetadata) {
this.toolMetadata = result.toolMetadata;
}

if (this.confirmationMessages?.confirmResults && !result?.toolResultError && result?.confirmResults !== false && !final) {
this._state.set({
type: IChatToolInvocation.StateKind.WaitingForPostApproval,
Expand Down Expand Up @@ -129,6 +135,7 @@ export class ChatToolInvocation implements IChatToolInvocation {
toolCallId: this.toolCallId,
toolId: this.toolId,
fromSubAgent: this.fromSubAgent,
toolMetadata: this.toolMetadata,
};
}
}
2 changes: 2 additions & 0 deletions src/vs/workbench/contrib/chat/common/chatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ export interface IChatToolInvocation {
readonly parameters: unknown;
readonly fromSubAgent?: boolean;
readonly state: IObservable<IChatToolInvocation.State>;
readonly toolMetadata?: unknown;

kind: 'toolInvocation';
}
Expand Down Expand Up @@ -563,6 +564,7 @@ export interface IChatToolInvocationSerialized {
toolId: string;
source: ToolDataSource;
readonly fromSubAgent?: boolean;
readonly toolMetadata?: unknown;
kind: 'toolInvocationSerialized';
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export const FetchWebPageToolData: IToolData = {

type ResultType = string | { type: 'tooldata'; value: IToolResultDataPart } | { type: 'extracted'; value: WebContentExtractResult } | undefined;

export interface IFetchWebPageToolMetadata {
hasError: boolean;
}

export class FetchWebPageTool implements IToolImpl {

constructor(
Expand Down Expand Up @@ -126,6 +130,7 @@ export class FetchWebPageTool implements IToolImpl {

// Skip confirming any results if every web content we got was an error or redirect
let confirmResults: undefined | boolean;
const hasError = webContents.some(e => e.status === 'error' || e.status === 'redirect');
if (webContents.every(e => e.status === 'error' || e.status === 'redirect')) {
confirmResults = false;
}
Expand All @@ -134,10 +139,13 @@ export class FetchWebPageTool implements IToolImpl {
// Only include URIs that actually had content successfully fetched
const actuallyValidUris = [...webUris.values(), ...successfulFileUris];

const metadata: IFetchWebPageToolMetadata = { hasError };

return {
content: this._getPromptPartsForResults(results),
toolResultDetails: actuallyValidUris,
confirmResults,
toolMetadata: metadata,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -863,4 +863,92 @@ suite('FetchWebPageTool', () => {
}
});
});

test('should set hasError metadata when web content extraction fails or redirects', async () => {
class TestWebContentExtractorServiceWithErrors implements IWebContentExtractorService {
_serviceBrand: undefined;

async extract(uris: URI[]): Promise<WebContentExtractResult[]> {
return uris.map(uri => {
if (uri.toString() === 'https://error.com') {
return { status: 'error', error: '404 Not Found' };
} else if (uri.toString() === 'https://redirect.com') {
return { status: 'redirect', toURI: URI.parse('https://redirected.com') };
} else {
return { status: 'ok', result: 'Success content' };
}
});
}
}

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

// Test with error URL
const errorResult = await tool.invoke(
{
callId: 'test-call-error',
toolId: 'fetch-page',
parameters: { urls: ['https://error.com'] },
context: undefined
},
() => Promise.resolve(0),
{ report: () => { } },
CancellationToken.None
);

assert.ok(errorResult.toolMetadata, 'Should have toolMetadata');
assert.strictEqual((errorResult.toolMetadata as any).hasError, true, 'Should have hasError set to true for error status');

// Test with redirect URL
const redirectResult = await tool.invoke(
{
callId: 'test-call-redirect',
toolId: 'fetch-page',
parameters: { urls: ['https://redirect.com'] },
context: undefined
},
() => Promise.resolve(0),
{ report: () => { } },
CancellationToken.None
);

assert.ok(redirectResult.toolMetadata, 'Should have toolMetadata');
assert.strictEqual((redirectResult.toolMetadata as any).hasError, true, 'Should have hasError set to true for redirect status');

// Test with mix of success and error
const mixedResult = await tool.invoke(
{
callId: 'test-call-mixed',
toolId: 'fetch-page',
parameters: { urls: ['https://success.com', 'https://error.com'] },
context: undefined
},
() => Promise.resolve(0),
{ report: () => { } },
CancellationToken.None
);

assert.ok(mixedResult.toolMetadata, 'Should have toolMetadata');
assert.strictEqual((mixedResult.toolMetadata as any).hasError, true, 'Should have hasError set to true when any URL has error');

// Test with only success
const successResult = await tool.invoke(
{
callId: 'test-call-success',
toolId: 'fetch-page',
parameters: { urls: ['https://success.com'] },
context: undefined
},
() => Promise.resolve(0),
{ report: () => { } },
CancellationToken.None
);

assert.ok(successResult.toolMetadata, 'Should have toolMetadata');
assert.strictEqual((successResult.toolMetadata as any).hasError, false, 'Should have hasError set to false for all success');
});
});