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
23 changes: 22 additions & 1 deletion extensions/copilot/src/platform/endpoint/node/messagesApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ interface AnthropicStreamEvent {
ephemeral_1h_input_tokens?: number;
ephemeral_5m_input_tokens?: number;
};
output_tokens_details?: {
thinking_tokens?: number;
};
};
};
index?: number;
Expand Down Expand Up @@ -100,6 +103,9 @@ interface AnthropicStreamEvent {
ephemeral_1h_input_tokens?: number;
ephemeral_5m_input_tokens?: number;
};
output_tokens_details?: {
thinking_tokens?: number;
};
};
copilot_usage?: {
total_nano_aiu: number;
Expand Down Expand Up @@ -677,6 +683,13 @@ interface AnthropicCompletionState {
readonly cacheCreation1hTokens: number | undefined;
readonly cacheCreation5mTokens: number | undefined;
readonly cacheReadTokens: number;
/**
* Anthropic-reported thinking (reasoning) tokens, a subset of
* `output_tokens`. Surfaced as `completion_tokens_details.reasoning_tokens`
* to match the OpenAI/CAPI naming used elsewhere in telemetry. Undefined
* when the server did not include `output_tokens_details`.
*/
readonly thinkingTokens: number | undefined;
readonly requestId: string;
readonly ghRequestId: string;
readonly serverExperiments: string;
Expand Down Expand Up @@ -744,7 +757,7 @@ function buildAnthropicCompletion(state: AnthropicCompletionState, logService: I
: {}),
},
completion_tokens_details: {
reasoning_tokens: 0,
reasoning_tokens: state.thinkingTokens ?? 0,
accepted_prediction_tokens: 0,
rejected_prediction_tokens: 0,
},
Expand Down Expand Up @@ -798,6 +811,9 @@ type AnthropicNonStreamingResponse =
ephemeral_1h_input_tokens?: number;
ephemeral_5m_input_tokens?: number;
};
output_tokens_details?: {
thinking_tokens?: number;
};
};
}
| {
Expand Down Expand Up @@ -933,6 +949,7 @@ export async function processNonStreamingResponseFromMessagesEndpoint(
cacheCreation1hTokens: usage?.cache_creation?.ephemeral_1h_input_tokens,
cacheCreation5mTokens: usage?.cache_creation?.ephemeral_5m_input_tokens,
cacheReadTokens: usage?.cache_read_input_tokens ?? 0,
thinkingTokens: usage?.output_tokens_details?.thinking_tokens,
requestId,
ghRequestId,
serverExperiments,
Expand Down Expand Up @@ -983,6 +1000,7 @@ export class AnthropicMessagesProcessor {
private cacheCreation1hTokens: number | undefined;
private cacheCreation5mTokens: number | undefined;
private cacheReadTokens: number = 0;
private thinkingTokens: number | undefined;
private copilotUsage?: { total_nano_aiu: number };
private contextManagementResponse?: ContextManagementResponse;
private stopReason: string | undefined;
Expand Down Expand Up @@ -1065,6 +1083,7 @@ export class AnthropicMessagesProcessor {
this.cacheCreation1hTokens = chunk.message.usage.cache_creation?.ephemeral_1h_input_tokens ?? this.cacheCreation1hTokens;
this.cacheCreation5mTokens = chunk.message.usage.cache_creation?.ephemeral_5m_input_tokens ?? this.cacheCreation5mTokens;
this.cacheReadTokens = chunk.message.usage.cache_read_input_tokens ?? 0;
this.thinkingTokens = chunk.message.usage.output_tokens_details?.thinking_tokens ?? this.thinkingTokens;
}
return;
case 'content_block_start':
Expand Down Expand Up @@ -1177,6 +1196,7 @@ export class AnthropicMessagesProcessor {
this.cacheCreation1hTokens = chunk.usage.cache_creation?.ephemeral_1h_input_tokens ?? this.cacheCreation1hTokens;
this.cacheCreation5mTokens = chunk.usage.cache_creation?.ephemeral_5m_input_tokens ?? this.cacheCreation5mTokens;
this.cacheReadTokens = chunk.usage.cache_read_input_tokens ?? this.cacheReadTokens;
this.thinkingTokens = chunk.usage.output_tokens_details?.thinking_tokens ?? this.thinkingTokens;
}
if (chunk.copilot_usage && typeof chunk.copilot_usage.total_nano_aiu === 'number') {
this.copilotUsage = chunk.copilot_usage;
Expand Down Expand Up @@ -1272,6 +1292,7 @@ export class AnthropicMessagesProcessor {
cacheCreation1hTokens: this.cacheCreation1hTokens,
cacheCreation5mTokens: this.cacheCreation5mTokens,
cacheReadTokens: this.cacheReadTokens,
thinkingTokens: this.thinkingTokens,
requestId: this.requestId,
ghRequestId: this.ghRequestId,
serverExperiments: this.serverExperiments,
Expand Down
100 changes: 100 additions & 0 deletions extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1479,6 +1479,67 @@ suite('processNonStreamingResponseFromMessagesEndpoint', () => {
expect(details?.anthropic_cache_creation).toBeUndefined();
});

test('surfaces thinking_tokens as completion_tokens_details.reasoning_tokens', async () => {
const response = createNonStreamingResponse({
id: 'msg_thinking',
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: 'thought' }],
model: 'claude-sonnet-4-20250514',
stop_reason: 'end_turn',
usage: {
input_tokens: 10,
output_tokens: 1140,
output_tokens_details: { thinking_tokens: 580 },
},
});

const telemetryData = TelemetryData.createAndMarkAsIssued();
const completions = await processNonStreamingResponseFromMessagesEndpoint(
new NullTelemetryService(),
new TestLogService(),
response,
async () => undefined,
telemetryData,
);

const results = [];
for await (const c of completions) {
results.push(c);
}

expect(results[0].usage?.completion_tokens).toBe(1140);
expect(results[0].usage?.completion_tokens_details?.reasoning_tokens).toBe(580);
});

test('reasoning_tokens defaults to 0 when output_tokens_details is absent', async () => {
const response = createNonStreamingResponse({
id: 'msg_no_thinking',
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: 'no thinking' }],
model: 'claude-sonnet-4-20250514',
stop_reason: 'end_turn',
usage: { input_tokens: 10, output_tokens: 50 },
});

const telemetryData = TelemetryData.createAndMarkAsIssued();
const completions = await processNonStreamingResponseFromMessagesEndpoint(
new NullTelemetryService(),
new TestLogService(),
response,
async () => undefined,
telemetryData,
);

const results = [];
for await (const c of completions) {
results.push(c);
}

expect(results[0].usage?.completion_tokens_details?.reasoning_tokens).toBe(0);
});

test('rejects on malformed JSON', async () => {
const response = Response.fromText(200, 'OK', createNonStreamingHeaders(), 'not json at all', 'node-fetch');
const telemetryData = TelemetryData.createAndMarkAsIssued();
Expand Down Expand Up @@ -1747,4 +1808,43 @@ suite('AnthropicMessagesProcessor streaming cache_creation', () => {
expect(details?.anthropic_cache_creation?.ephemeral_1h_input_tokens).toBe(5000);
expect(details?.anthropic_cache_creation?.ephemeral_5m_input_tokens).toBe(10000);
});

test('streaming thinking_tokens from message_delta surfaces as reasoning_tokens', () => {
// Anthropic typically reports thinking_tokens in the final message_delta
// (after the cumulative output_tokens count is known). Matches the
// observed payload shape from CAPI/Anthropic 1P/Bedrock/Vertex.
const processor = makeProcessor();
const noop = async () => undefined;

processor.push({
type: 'message_start',
message: {
id: 'msg_thinking_stream',
type: 'message',
role: 'assistant',
content: [],
model: 'claude-sonnet-4-20250514',
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 5,
output_tokens: 1,
},
},
}, noop);

processor.push({
type: 'message_delta',
delta: { type: 'message_delta', stop_reason: 'end_turn' },
usage: {
output_tokens: 2024,
input_tokens: 5,
output_tokens_details: { thinking_tokens: 639 },
},
}, noop);

const completion = processor.push({ type: 'message_stop' }, noop);
expect(completion!.usage?.completion_tokens).toBe(2024);
expect(completion!.usage?.completion_tokens_details?.reasoning_tokens).toBe(639);
});
});
12 changes: 11 additions & 1 deletion src/vs/base/parts/ipc/electron-main/ipc.electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,20 @@ export class Server extends IPCServer {
client?.dispose();

const onDidClientReconnect = new Emitter<void>();
Server.Clients.set(id, toDisposable(() => onDidClientReconnect.fire()));
const reconnectDisposable = toDisposable(() => {
onDidClientReconnect.fire();
});
Server.Clients.set(id, reconnectDisposable);

const onMessage = createScopedOnMessageEvent(id, 'vscode:message') as Event<VSBuffer>;
const onDidClientDisconnect = Event.any(Event.signal(createScopedOnMessageEvent(id, 'vscode:disconnect')), onDidClientReconnect.event);
Event.once(onDidClientDisconnect)(() => {
if (Server.Clients.get(id) === reconnectDisposable) {
Server.Clients.delete(id);
}

onDidClientReconnect.dispose();
});
const protocol = new ElectronProtocol(webContents, onMessage);

return { protocol, onDidClientDisconnect };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export class ImplicitContextAttachmentWidget extends Disposable {
}));
}

const label = this.resourceLabels.create(contextNode, { supportIcons: true });
const label = this.renderDisposables.add(this.resourceLabels.create(contextNode, { supportIcons: true }));

let title: string | undefined;
let markdownTooltip: IMarkdownString | undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { $ } from '../../../../../../base/browser/dom.js';
import { ButtonWithIcon } from '../../../../../../base/browser/ui/button/button.js';
import { HoverStyle } from '../../../../../../base/browser/ui/hover/hover.js';
import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js';
import { Disposable, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js';
import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js';
import { autorun, IObservable, observableValue } from '../../../../../../base/common/observable.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { IHoverService } from '../../../../../../platform/hover/browser/hover.js';
Expand All @@ -28,6 +28,7 @@ export abstract class ChatCollapsibleContentPart extends Disposable implements I

private _domNode?: HTMLElement;
private readonly _renderedTitleWithWidgets = this._register(new MutableDisposable<IRenderedMarkdown>());
protected readonly _titleFileWidgetStore = this._register(new DisposableStore());

protected readonly hasFollowingContent: boolean;
protected _isExpanded = observableValue<boolean>(this, false);
Expand Down Expand Up @@ -181,7 +182,8 @@ export abstract class ChatCollapsibleContentPart extends Disposable implements I
const result = chatContentMarkdownRenderer.render(content);
result.element.classList.add('collapsible-title-content');

renderFileWidgets(result.element, instantiationService, chatMarkdownAnchorService, this._store);
this._titleFileWidgetStore.clear();
renderFileWidgets(result.element, instantiationService, chatMarkdownAnchorService, this._titleFileWidgetStore);

const labelElement = this._collapseButton.labelElement;
labelElement.textContent = '';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen
this.titleShimmerSpan = undefined;

this._titleDetailRendered.clear();
this._titleFileWidgetStore.clear();
this.titleDetailContainer = undefined;

const prefixSpan = $('span');
Expand All @@ -517,6 +518,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen

// Dispose previous detail rendering
this._titleDetailRendered.clear();
this._titleFileWidgetStore.clear();

if (!toolCallText) {
if (this.titleDetailContainer) {
Expand All @@ -526,7 +528,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen
} else {
const result = this.chatContentMarkdownRenderer.render(new MarkdownString(toolCallText));
result.element.classList.add('collapsible-title-content', 'chat-thinking-title-detail');
renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._store);
renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._titleFileWidgetStore);
this._titleDetailRendered.value = result;

if (this.titleDetailContainer) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2173,6 +2173,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks):
this.titleShimmerSpan = undefined;
this.titleDetailContainer = undefined;
this._titleDetailRendered.clear();
this._titleFileWidgetStore.clear();
this.currentTitle = title;
return;
}
Expand All @@ -2197,10 +2198,11 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks):

// Dispose previous detail rendering
this._titleDetailRendered.clear();
this._titleFileWidgetStore.clear();

const result = this.chatContentMarkdownRenderer.render(new MarkdownString(title));
result.element.classList.add('collapsible-title-content', 'chat-thinking-title-detail');
renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._store);
renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._titleFileWidgetStore);
this._titleDetailRendered.value = result;

if (this.titleDetailContainer) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,20 @@ suite('ChatSubagentContentPart', () => {
(toolInvocation as { toolSpecificData: IChatSubagentToolInvocationData }).toolSpecificData = data;
}

test('updateTitle clears previous title file widget disposables', () => {
const toolInvocation = createMockToolInvocation({ invocationMessage: 'first' });
const context = createMockRenderContext(false);
const part = createPart(toolInvocation, context);

let disposed = false;
(part as unknown as { _titleFileWidgetStore: DisposableStore })._titleFileWidgetStore.add({ dispose: () => { disposed = true; } });

// Trigger a title re-render
part.trackToolState(createMockToolInvocation({ invocationMessage: 'second' }));

assert.strictEqual(disposed, true, 'Previous title file widget disposable should be cleared');
});

test('default description with no agentName → real description arrives later → title updates', () => {
const toolInvocation = createMockToolInvocation({
stateType: IChatToolInvocation.StateKind.WaitingForConfirmation,
Expand Down
16 changes: 13 additions & 3 deletions src/vs/workbench/contrib/search/browser/searchResultsView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as DOM from '../../../../base/browser/dom.js';
import { CountBadge } from '../../../../base/browser/ui/countBadge/countBadge.js';
import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js';
import { IListAccessibilityProvider } from '../../../../base/browser/ui/list/listWidget.js';
import { ITreeNode } from '../../../../base/browser/ui/tree/tree.js';
import { ITreeElementRenderDetails, ITreeNode } from '../../../../base/browser/ui/tree/tree.js';
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
import * as paths from '../../../../base/common/path.js';
import * as nls from '../../../../nls.js';
Expand Down Expand Up @@ -70,6 +70,7 @@ interface IMatchTemplate {
after: HTMLElement;
actions: MenuWorkbenchToolBar;
disposables: DisposableStore;
elementDisposables: DisposableStore;
contextKeyService: IContextKeyService;
}

Expand Down Expand Up @@ -429,6 +430,10 @@ export class MatchRenderer extends Disposable implements ICompressibleTreeRender
},
}));

const elementDisposables = new DisposableStore();
disposables.add(elementDisposables);


return {
parent,
before,
Expand All @@ -438,6 +443,7 @@ export class MatchRenderer extends Disposable implements ICompressibleTreeRender
lineNumber,
actions,
disposables,
elementDisposables,
contextKeyService: contextKeyServiceMain
};
}
Expand All @@ -456,7 +462,7 @@ export class MatchRenderer extends Disposable implements ICompressibleTreeRender
templateData.after.textContent = preview.after;

const title = (preview.fullBefore + (replace ? match.replaceString : preview.inside) + preview.after).trim().substr(0, 999);
templateData.disposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), templateData.parent, title));
templateData.elementDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), templateData.parent, title));

SearchContext.IsEditableItemKey.bindTo(templateData.contextKeyService).set(!match.isReadonly);

Expand All @@ -468,12 +474,16 @@ export class MatchRenderer extends Disposable implements ICompressibleTreeRender
templateData.lineNumber.classList.toggle('show', (numLines > 0) || showLineNumbers);

templateData.lineNumber.textContent = lineNumberStr + extraLinesStr;
templateData.disposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), templateData.lineNumber, this.getMatchTitle(match, showLineNumbers)));
templateData.elementDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), templateData.lineNumber, this.getMatchTitle(match, showLineNumbers)));

templateData.actions.context = { viewer: this.searchView.getControl(), element: match } satisfies ISearchActionContext;

}

disposeElement(element: ITreeNode<ISearchTreeMatch, void>, index: number, templateData: IMatchTemplate, details?: ITreeElementRenderDetails): void {
templateData.elementDisposables.clear();
}

disposeTemplate(templateData: IMatchTemplate): void {
templateData.disposables.dispose();
}
Expand Down
Loading