Path: blob/main/extensions/copilot/src/extension/prompts/node/agent/test/summarization.spec.tsx
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Raw } from '@vscode/prompt-tsx';6import { afterAll, beforeAll, beforeEach, expect, suite, test } from 'vitest';7import { IChatMLFetcher } from '../../../../../platform/chat/common/chatMLFetcher';8import { ChatLocation } from '../../../../../platform/chat/common/commonTypes';9import { StaticChatMLFetcher } from '../../../../../platform/chat/test/common/staticChatMLFetcher';10import { CodeGenerationTextInstruction, ConfigKey, IConfigurationService } from '../../../../../platform/configuration/common/configurationService';11import { MockEndpoint } from '../../../../../platform/endpoint/test/node/mockEndpoint';12import { messageToMarkdown } from '../../../../../platform/log/common/messageStringify';13import { IResponseDelta } from '../../../../../platform/networking/common/fetch';14import { ITestingServicesAccessor } from '../../../../../platform/test/node/services';15import { TestWorkspaceService } from '../../../../../platform/test/node/testWorkspaceService';16import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';17import { createTextDocumentData } from '../../../../../util/common/test/shims/textDocument';18import { URI } from '../../../../../util/vs/base/common/uri';19import { SyncDescriptor } from '../../../../../util/vs/platform/instantiation/common/descriptors';20import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';21import { LanguageModelTextPart, LanguageModelToolResult } from '../../../../../vscodeTypes';22import { addCacheBreakpoints } from '../../../../intents/node/cacheBreakpoints';23import { ChatVariablesCollection } from '../../../../prompt/common/chatVariablesCollection';24import { Conversation, ICopilotChatResultIn, normalizeSummariesOnRounds, Turn, TurnStatus } from '../../../../prompt/common/conversation';25import { IBuildPromptContext, IToolCall } from '../../../../prompt/common/intents';26import { ToolCallRound } from '../../../../prompt/common/toolCallRound';27import { createExtensionUnitTestingServices } from '../../../../test/node/services';28import { ToolName } from '../../../../tools/common/toolNames';29import { PromptRenderer } from '../../base/promptRenderer';30import { AgentPrompt, AgentPromptProps } from '../agentPrompt';31import { PromptRegistry } from '../promptRegistry';32import { ISessionTranscriptService, NullSessionTranscriptService } from '../../../../../platform/chat/common/sessionTranscriptService';33import { appendTranscriptHintToSummary, ConversationHistorySummarizationPrompt, extractInlineSummary, stripToolSearchMessages, SummarizedConversationHistory, SummarizedConversationHistoryMetadata, SummarizedConversationHistoryPropsBuilder } from '../summarizedConversationHistory';3435suite('Agent Summarization', () => {36let accessor: ITestingServicesAccessor;37let chatResponse: (string | IResponseDelta[])[] = [];38const fileTsUri = URI.file('/workspace/file.ts');3940let conversation: Conversation;4142beforeAll(() => {43const testDoc = createTextDocumentData(fileTsUri, 'line 1\nline 2\n\nline 4\nline 5', 'ts').document;4445const services = createExtensionUnitTestingServices();46services.define(IWorkspaceService, new SyncDescriptor(47TestWorkspaceService,48[49[URI.file('/workspace')],50[testDoc]51]52));53chatResponse = [];54services.define(IChatMLFetcher, new StaticChatMLFetcher(chatResponse));55accessor = services.createTestingAccessor();56accessor.get(IConfigurationService).setConfig(ConfigKey.CodeGenerationInstructions, [{57text: 'This is a test custom instruction file',58} satisfies CodeGenerationTextInstruction]);59});6061beforeEach(() => {62const turn = new Turn('turnId', { type: 'user', message: 'hello' });63conversation = new Conversation('sessionId', [turn]);64});6566afterAll(() => {67accessor.dispose();68});6970enum TestPromptType {71Agent = 'Agent',72FullSummarization = 'FullSumm',73SimpleSummarization = 'SimpleSummarizedHistory'74}7576async function agentPromptToString(accessor: ITestingServicesAccessor, promptContext: IBuildPromptContext, otherProps?: Partial<AgentPromptProps>, promptType: TestPromptType = TestPromptType.Agent): Promise<string> {77const instaService = accessor.get(IInstantiationService);78const endpoint = instaService.createInstance(MockEndpoint, undefined);79normalizeSummariesOnRounds(promptContext.history);80if (!promptContext.conversation) {81promptContext = { ...promptContext, conversation };82}8384const baseProps = {85priority: 1,86endpoint,87location: ChatLocation.Panel,88promptContext,89maxToolResultLength: Infinity,90...otherProps91};9293let renderer;94if (promptType === 'Agent') {95const customizations = await PromptRegistry.resolveAllCustomizations(instaService, endpoint);96const props: AgentPromptProps = { ...baseProps, customizations };97renderer = PromptRenderer.create(instaService, endpoint, AgentPrompt, props);98} else {99const propsInfo = instaService.createInstance(SummarizedConversationHistoryPropsBuilder).getProps(baseProps);100const simpleMode = promptType === TestPromptType.SimpleSummarization;101renderer = PromptRenderer.create(instaService, endpoint, ConversationHistorySummarizationPrompt, { ...propsInfo.props, simpleMode });102}103104const r = await renderer.render();105const summarizedConversationMetadata = r.metadata.get(SummarizedConversationHistoryMetadata);106if (summarizedConversationMetadata && promptContext.toolCallRounds) {107for (const toolCallRound of promptContext.toolCallRounds) {108if (toolCallRound.id === summarizedConversationMetadata.toolCallRoundId) {109toolCallRound.summary = summarizedConversationMetadata.text;110}111}112}113addCacheBreakpoints(r.messages);114return r.messages115.filter(message => message.role !== Raw.ChatRole.System)116.map(m => messageToMarkdown(m))117.join('\n\n')118.replace(/\\+/g, '/')119.replace(/The current date is.*/g, '(Date removed from snapshot)');120}121122function createEditFileToolCall(idx: number): IToolCall {123return {124id: `tooluse_${idx}`,125name: ToolName.EditFile,126arguments: JSON.stringify({127filePath: fileTsUri.fsPath, code: `// existing code...\nconsole.log('hi')`128})129};130}131132function createEditFileToolResult(...idxs: number[]): Record<string, LanguageModelToolResult> {133const result: Record<string, LanguageModelToolResult> = {};134for (const idx of idxs) {135result[`tooluse_${idx}`] = new LanguageModelToolResult([new LanguageModelTextPart('success')]);136}137return result;138}139140function getSnapshotFile(promptType: TestPromptType, name: string): string {141return `./__snapshots__/summarization-${name}-${promptType}.spec.snap`;142}143144const tools: IBuildPromptContext['tools'] = {145availableTools: [],146toolInvocationToken: null as never,147toolReferences: [],148};149150test('continuation turns are not rendered in conversation history', async () => {151const firstTurn = new Turn('id1', { type: 'user', message: 'previous turn message' });152const continuationTurn = new Turn('id2', { type: 'user', message: 'continuation turn message' }, undefined, [], undefined, undefined, true);153154const promptContext: IBuildPromptContext = {155chatVariables: new ChatVariablesCollection([{ id: 'vscode.file', name: 'file', value: fileTsUri }]),156history: [firstTurn, continuationTurn],157query: 'edit this file',158toolCallRounds: [],159tools,160};161162const rendered = await agentPromptToString(163accessor,164promptContext,165{ enableCacheBreakpoints: true },166TestPromptType.Agent167);168169expect(rendered).toContain('previous turn message');170expect(rendered).not.toContain('continuation turn message');171});172173test('cannot summarize with no history', async () => {174const promptContextNoHistory: IBuildPromptContext = {175chatVariables: new ChatVariablesCollection([{ id: 'vscode.file', name: 'file', value: fileTsUri }]),176history: [],177query: 'edit this file',178toolCallRounds: [],179tools,180};181await expect(() => agentPromptToString(182accessor, promptContextNoHistory, undefined, TestPromptType.FullSummarization)).rejects.toThrow();183await expect(() => agentPromptToString(184accessor,185{186...promptContextNoHistory,187toolCallRounds: [188new ToolCallRound('ok', [createEditFileToolCall(1)]),189],190toolCallResults: createEditFileToolResult(1),191tools,192}, undefined, TestPromptType.FullSummarization)).rejects.toThrow();193});194195async function testTriggerSummarizationDuringToolCalling(promptType: TestPromptType) {196chatResponse[0] = 'summarized!';197const toolCallRounds = [198new ToolCallRound('ok', [createEditFileToolCall(1)]),199new ToolCallRound('ok 2', [createEditFileToolCall(2)]),200new ToolCallRound('ok 3', [createEditFileToolCall(3)]),201];202await expect(await agentPromptToString(203accessor,204{205chatVariables: new ChatVariablesCollection([{ id: 'vscode.file', name: 'file', value: fileTsUri }]),206history: [],207query: 'edit this file',208toolCallRounds,209toolCallResults: createEditFileToolResult(1, 2, 3),210tools211},212{213enableCacheBreakpoints: true,214triggerSummarize: true,215}, promptType)).toMatchFileSnapshot(getSnapshotFile(promptType, 'duringToolCalling'));216if (promptType === TestPromptType.Agent) {217expect(toolCallRounds.at(-2)?.summary).toBe('summarized!');218}219}220221// Summarization for rounds in current turn222test('trigger summarization during tool calling', async () => await testTriggerSummarizationDuringToolCalling(TestPromptType.Agent));223test('FullSummarization - trigger summarization during tool calling', async () => await testTriggerSummarizationDuringToolCalling(TestPromptType.FullSummarization));224test('SimpleSummarization - trigger summarization during tool calling', async () => await testTriggerSummarizationDuringToolCalling(TestPromptType.SimpleSummarization));225226async function testSummaryCurrentTurn(promptType: TestPromptType) {227const excludedPreviousRound = new ToolCallRound('previous round EXCLUDED', [createEditFileToolCall(1)]);228const round = new ToolCallRound('ok', [createEditFileToolCall(2)]);229round.summary = 'summarized!';230await expect(await agentPromptToString(231accessor,232{233chatVariables: new ChatVariablesCollection([{ id: 'vscode.file', name: 'file', value: fileTsUri }]),234history: [],235query: 'edit this file',236toolCallRounds: [237excludedPreviousRound,238round239],240toolCallResults: createEditFileToolResult(1, 2),241tools242},243{244enableCacheBreakpoints: true,245}, promptType)).toMatchFileSnapshot(getSnapshotFile(promptType, 'currentTurn'));246}247248// SummarizationPrompt test is not relevant when the last round was summarized249test('render summary in current turn', async () => await testSummaryCurrentTurn(TestPromptType.Agent));250251async function testSummaryCurrentTurnEarlierRound(promptType: TestPromptType) {252const round = new ToolCallRound('round 1', [createEditFileToolCall(1)]);253round.summary = 'summarized!';254const round2 = new ToolCallRound('round 2', [createEditFileToolCall(2)]);255const round3 = new ToolCallRound('round 3', [createEditFileToolCall(3)]);256await expect(await agentPromptToString(257accessor,258{259chatVariables: new ChatVariablesCollection([{ id: 'vscode.file', name: 'file', value: fileTsUri }]),260history: [],261query: 'edit this file',262toolCallRounds: [263round,264round2,265round3266],267toolCallResults: createEditFileToolResult(1, 2, 3),268tools269},270{271enableCacheBreakpoints: true,272}, promptType)).toMatchFileSnapshot(getSnapshotFile(promptType, 'currentTurnEarlierRound'));273}274275test('render summary in previous turn', async () => await testSummaryCurrentTurnEarlierRound(TestPromptType.Agent));276test('FullSummarization - render summary in previous turn', async () => await testSummaryCurrentTurnEarlierRound(TestPromptType.FullSummarization));277test('SimpleSummarization - render summary in previous turn', async () => await testSummaryCurrentTurnEarlierRound(TestPromptType.SimpleSummarization));278279async function testSummaryPrevTurnMultiple(promptType: TestPromptType) {280const previousTurn = new Turn('id', { type: 'user', message: 'previous turn excluded' });281const previousTurnResult: ICopilotChatResultIn = {282metadata: {283summary: {284text: 'summarized 1!',285toolCallRoundId: 'toolCallRoundId1'286},287toolCallRounds: [288new ToolCallRound('response', [createEditFileToolCall(1)], undefined, 'toolCallRoundId1'),289],290toolCallResults: createEditFileToolResult(1),291}292};293previousTurn.setResponse(TurnStatus.Success, { type: 'user', message: 'response' }, 'responseId', previousTurnResult);294295const turn = new Turn('id', { type: 'user', message: 'hello' });296const result: ICopilotChatResultIn = {297metadata: {298summary: {299text: 'summarized 2!',300toolCallRoundId: 'toolCallRoundId3'301},302toolCallRounds: [303new ToolCallRound('response excluded', [createEditFileToolCall(2)], undefined, 'toolCallRoundId2'),304new ToolCallRound('response with summary', [createEditFileToolCall(3)], undefined, 'toolCallRoundId3'),305new ToolCallRound('next response', [createEditFileToolCall(4)], undefined, 'toolCallRoundId4'),306],307toolCallResults: createEditFileToolResult(2, 3, 4),308}309};310turn.setResponse(TurnStatus.Success, { type: 'user', message: 'response' }, 'responseId', result);311312await expect(await agentPromptToString(313accessor,314{315chatVariables: new ChatVariablesCollection([{ id: 'vscode.file', name: 'file', value: fileTsUri }]),316history: [previousTurn, turn],317query: 'edit this file',318toolCallRounds: [(new ToolCallRound('hello next round', [createEditFileToolCall(5)]))],319toolCallResults: createEditFileToolResult(5),320tools321},322{323enableCacheBreakpoints: true,324}, promptType)).toMatchFileSnapshot(getSnapshotFile(promptType, 'previousTurnMultiple'));325}326327test('render summary in previous turn (with multiple)', () => testSummaryPrevTurnMultiple(TestPromptType.Agent));328test('FullSummarization - render summary in previous turn (with multiple)', () => testSummaryPrevTurnMultiple(TestPromptType.FullSummarization));329test('SimpleSummarization - render summary in previous turn (with multiple)', () => testSummaryPrevTurnMultiple(TestPromptType.SimpleSummarization));330331async function testSummarizeWithNoRoundsInCurrentTurn(promptType: TestPromptType) {332const previousTurn1 = new Turn('id', { type: 'user', message: 'previous turn 1' });333previousTurn1.setResponse(TurnStatus.Success, { type: 'user', message: 'response' }, 'responseId', {});334335const previousTurn2 = new Turn('id', { type: 'user', message: 'previous turn 2' });336const previousTurn2Result: ICopilotChatResultIn = {337metadata: {338toolCallRounds: [],339summary: {340toolCallRoundId: 'previous',341text: 'previous turn 1 summary'342}343}344};345previousTurn2.setResponse(TurnStatus.Success, { type: 'user', message: 'response' }, 'responseId', previousTurn2Result);346347await expect(await agentPromptToString(348accessor,349{350chatVariables: new ChatVariablesCollection([{ id: 'vscode.file', name: 'file', value: fileTsUri }]),351history: [previousTurn1, previousTurn2],352query: 'hello',353tools354},355{356enableCacheBreakpoints: true,357}, promptType)).toMatchFileSnapshot(getSnapshotFile(promptType, 'previousTurnNoRounds'));358}359360test('summary for previous turn, no tool call rounds', async () => testSummarizeWithNoRoundsInCurrentTurn(TestPromptType.Agent));361test('FullSummarization - summary for previous turn, no tool call rounds', async () => testSummarizeWithNoRoundsInCurrentTurn(TestPromptType.FullSummarization));362test('SimpleSummarization - summary for previous turn, no tool call rounds', async () => testSummarizeWithNoRoundsInCurrentTurn(TestPromptType.SimpleSummarization));363364function createSummarizationTestContext() {365const instaService = accessor.get(IInstantiationService);366const endpoint = instaService.createInstance(MockEndpoint, undefined);367368const toolCallRounds = [369new ToolCallRound('ok', [createEditFileToolCall(1)]),370new ToolCallRound('ok 2', [createEditFileToolCall(2)]),371new ToolCallRound('ok 3', [createEditFileToolCall(3)]),372];373374const turn = new Turn('turnId', { type: 'user', message: 'hello' });375const testConversation = new Conversation('sessionId', [turn]);376377const promptContext: IBuildPromptContext = {378chatVariables: new ChatVariablesCollection([{ id: 'vscode.file', name: 'file', value: fileTsUri }]),379history: [],380query: 'edit this file',381toolCallRounds,382toolCallResults: createEditFileToolResult(1, 2, 3),383tools,384conversation: testConversation,385};386387const historyProps = {388priority: 1,389endpoint,390location: ChatLocation.Panel,391promptContext,392maxToolResultLength: Infinity,393enableCacheBreakpoints: true,394triggerSummarize: true,395};396397return { instaService, endpoint, toolCallRounds, turn, testConversation, promptContext, historyProps };398}399400test('failed summarization throws from renderer (fallback is in agentIntent)', async () => {401// Keep the summary tiny-budgeted so the failure is immediate. The PromptRenderer propagates the error;402// the fallback to a no-cache-breakpoints render lives in agentIntent.ts's403// renderWithSummarization, not here.404chatResponse[0] = 'summary that is definitely too large for one token';405const { instaService, endpoint, historyProps } = createSummarizationTestContext();406407const renderer = PromptRenderer.create(instaService, endpoint, SummarizedConversationHistory, {408...historyProps,409maxSummaryTokens: 1,410});411await expect(renderer.render()).rejects.toThrow('Summary too large');412});413414test('successful summarization records metadata on render result', async () => {415chatResponse[0] = 'summarized successfully!';416const { instaService, endpoint, historyProps } = createSummarizationTestContext();417418const renderer = PromptRenderer.create(instaService, endpoint, SummarizedConversationHistory, historyProps);419const result = await renderer.render();420421const summaryMeta = result.metadata.get(SummarizedConversationHistoryMetadata);422expect(summaryMeta).toBeDefined();423expect(summaryMeta!.text).toBe('summarized successfully!');424expect(summaryMeta!.toolCallRoundId).toBeTruthy();425});426427test('failed summarization does not set round.summary', async () => {428chatResponse[0] = 'summary that is definitely too large for one token';429const { instaService, endpoint, toolCallRounds, historyProps } = createSummarizationTestContext();430431const renderer = PromptRenderer.create(instaService, endpoint, SummarizedConversationHistory, {432...historyProps,433maxSummaryTokens: 1,434});435await expect(renderer.render()).rejects.toThrow('Summary too large');436437// None of the rounds should have summary set since summarization failed438for (const round of toolCallRounds) {439expect(round.summary).toBeUndefined();440}441});442443test('simple mode summarization with small token budget renders zero messages (repro for No messages provided)', async () => {444// Repro for: "Prompt failed validation with the reason: No messages provided"445//446// Root cause: when modelMaxPromptTokens is small enough that the summarization447// prompt content exceeds the budget, prompt-tsx prunes all child elements.448// After pruning, toChatMessages() silently skips messages whose content is449// empty (isEmpty check), producing an empty messages array — without throwing450// BudgetExceededError. The downstream makeChatRequest2 then hits the451// isValidChatPayload check: "No messages provided".452const instaService = accessor.get(IInstantiationService);453const endpoint = instaService.createInstance(MockEndpoint, 'claude-sonnet');454endpoint.modelMaxPromptTokens = 5; // So small that even a single short message cannot fit455456const toolCallRounds = [457new ToolCallRound('ok', [createEditFileToolCall(1)]),458new ToolCallRound('ok 2', [createEditFileToolCall(2)]),459];460461const turn = new Turn('turnId', { type: 'user', message: 'hello' });462const testConversation = new Conversation('sessionId', [turn]);463464const promptContext: IBuildPromptContext = {465chatVariables: new ChatVariablesCollection([]),466history: [],467query: 'edit this file',468toolCallRounds,469toolCallResults: createEditFileToolResult(1, 2),470tools,471conversation: testConversation,472};473474const baseProps = {475priority: 1,476endpoint,477location: ChatLocation.Panel,478promptContext,479maxToolResultLength: Infinity,480};481482const propsInfo = instaService.createInstance(SummarizedConversationHistoryPropsBuilder).getProps(baseProps);483const renderer = PromptRenderer.create(instaService, endpoint, ConversationHistorySummarizationPrompt, { ...propsInfo.props, simpleMode: true });484const result = await renderer.render();485486// prompt-tsx prunes all content and silently drops empty messages → 0 messages487expect(result.messages.length).toBe(0);488});489490test('failure metadata on turn prevents repeated foreground summarization attempts', async () => {491// This test verifies the contract that agentIntent.ts relies on:492// after a foreground summarization failure, setting SummarizedConversationHistoryMetadata493// with outcome !== 'success' on the turn causes the retry guard to skip summarization.494495const turn = new Turn('turnId', { type: 'user', message: 'hello' });496497// Simulate what agentIntent.ts does after a failed foreground summarization498turn.setMetadata(new SummarizedConversationHistoryMetadata(499'', // no toolCallRoundId for failures500'', // no summary text for failures501{502model: 'test-model',503source: 'foreground',504outcome: 'budgetExceeded',505contextLengthBefore: 100_000,506},507));508509// Verify the retry guard condition from renderWithSummarization matches510const previousForegroundSummary = turn.getMetadata(SummarizedConversationHistoryMetadata);511expect(previousForegroundSummary).toBeDefined();512expect(previousForegroundSummary!.source).toBe('foreground');513expect(previousForegroundSummary!.outcome).toBe('budgetExceeded');514expect(previousForegroundSummary!.outcome).not.toBe('success');515516// The guard condition: source === 'foreground' && outcome && outcome !== 'success'517const shouldSkip = previousForegroundSummary!.source === 'foreground'518&& !!previousForegroundSummary!.outcome519&& previousForegroundSummary!.outcome !== 'success';520expect(shouldSkip).toBe(true);521522// Also verify that successful summarization does NOT trigger the skip guard523turn.setMetadata(new SummarizedConversationHistoryMetadata(524'roundId',525'summary text',526{527model: 'test-model',528source: 'foreground',529outcome: 'success',530},531));532const successMeta = turn.getMetadata(SummarizedConversationHistoryMetadata);533const shouldSkipAfterSuccess = successMeta!.source === 'foreground'534&& !!successMeta!.outcome535&& successMeta!.outcome !== 'success';536expect(shouldSkipAfterSuccess).toBe(false);537});538});539540suite('extractInlineSummary', () => {541test('extracts clean summary tags', () => {542const text = 'Some preamble\n<summary>\nThis is the summary content.\n</summary>\nSome trailing text';543const result = extractInlineSummary(text);544expect(result).toBe('This is the summary content.');545});546547test('extracts summary with no closing tag', () => {548const text = 'Preamble text\n<summary>\nThis is a partial summary that was cut off';549const result = extractInlineSummary(text);550expect(result).toBe('This is a partial summary that was cut off');551});552553test('returns undefined when no tags found', () => {554const text = 'This is just a normal response with no summary tags at all.';555const result = extractInlineSummary(text);556expect(result).toBeUndefined();557});558559test('uses first complete summary when multiple blocks exist', () => {560const text = '<summary>First summary</summary>\n<summary>Second summary</summary>';561const result = extractInlineSummary(text);562expect(result).toBe('First summary');563});564565test('handles empty summary tags', () => {566const text = '<summary></summary>';567const result = extractInlineSummary(text);568expect(result).toBe('');569});570571test('handles summary with analysis tags inside', () => {572const text = '<summary>\n<analysis>Some analysis</analysis>\n\n1. Overview: test\n2. Details: test\n</summary>';573const result = extractInlineSummary(text);574expect(result).toContain('1. Overview: test');575expect(result).toContain('<analysis>Some analysis</analysis>');576});577578test('trims whitespace from extracted summary', () => {579const text = '<summary>\n\n Padded summary text \n\n</summary>';580const result = extractInlineSummary(text);581expect(result).toBe('Padded summary text');582});583});584585suite('stripToolSearchMessages', () => {586function makeAssistantMessage(toolCalls: { id: string; name: string }[], text = 'response'): Raw.ChatMessage {587return {588role: Raw.ChatRole.Assistant,589content: [{ type: Raw.ChatCompletionContentPartKind.Text, text }],590toolCalls: toolCalls.map(tc => ({591type: 'function' as const,592id: tc.id,593function: { name: tc.name, arguments: '{}' },594})),595};596}597598function makeToolResult(toolCallId: string, text = 'result'): Raw.ChatMessage {599return {600role: Raw.ChatRole.Tool,601content: [{ type: Raw.ChatCompletionContentPartKind.Text, text }],602toolCallId,603};604}605606function makeUserMessage(text = 'hello'): Raw.ChatMessage {607return {608role: Raw.ChatRole.User,609content: [{ type: Raw.ChatCompletionContentPartKind.Text, text }],610};611}612613test('returns messages unchanged when no tool_search calls present', () => {614const messages = [615makeUserMessage(),616makeAssistantMessage([{ id: 'tc1', name: 'read_file' }]),617makeToolResult('tc1'),618];619const result = stripToolSearchMessages(messages);620expect(result).toBe(messages);621});622623test('strips custom tool_search tool_use and tool_result', () => {624const messages = [625makeUserMessage(),626makeAssistantMessage([627{ id: 'tc1', name: 'read_file' },628{ id: 'tc2', name: 'tool_search' },629]),630makeToolResult('tc1'),631makeToolResult('tc2', '["read_file", "edit_file"]'),632];633const result = stripToolSearchMessages(messages);634expect(result).toHaveLength(3);635const assistant = result[1];636expect(assistant.role).toBe(Raw.ChatRole.Assistant);637if (assistant.role === Raw.ChatRole.Assistant) {638expect(assistant.toolCalls).toHaveLength(1);639expect(assistant.toolCalls![0].id).toBe('tc1');640}641expect(result.find(m => m.role === Raw.ChatRole.Tool && m.toolCallId === 'tc2')).toBeUndefined();642});643644test('removes toolCalls property when all tool calls are tool_search', () => {645const messages = [646makeUserMessage(),647makeAssistantMessage([{ id: 'tc1', name: 'tool_search' }]),648makeToolResult('tc1'),649];650const result = stripToolSearchMessages(messages);651expect(result).toHaveLength(2);652const assistant = result[1];653if (assistant.role === Raw.ChatRole.Assistant) {654expect(assistant.toolCalls).toBeUndefined();655}656});657658test('preserves non-tool messages', () => {659const messages = [660makeUserMessage('first'),661makeAssistantMessage([{ id: 'tc1', name: 'tool_search' }]),662makeToolResult('tc1'),663makeUserMessage('second'),664makeAssistantMessage([{ id: 'tc2', name: 'edit_file' }]),665makeToolResult('tc2'),666];667const result = stripToolSearchMessages(messages);668expect(result).toHaveLength(5);669expect(result[0].content[0]).toEqual({ type: Raw.ChatCompletionContentPartKind.Text, text: 'first' });670expect(result[2].content[0]).toEqual({ type: Raw.ChatCompletionContentPartKind.Text, text: 'second' });671});672});673674suite('appendTranscriptHintToSummary', () => {675class FakeTranscriptService extends NullSessionTranscriptService {676constructor(677private readonly path: URI | undefined,678private readonly lineCount: number | undefined,679) {680super();681}682override getTranscriptPath(): URI | undefined { return this.path; }683override getLineCount(): number | undefined { return this.lineCount; }684}685686function makeService(path: URI | undefined, lineCount: number | undefined): ISessionTranscriptService {687return new FakeTranscriptService(path, lineCount);688}689690test('returns summary unchanged when no transcript path is available', () => {691const svc = makeService(undefined, undefined);692const result = appendTranscriptHintToSummary('original summary', 'session-1', svc);693expect(result).toBe('original summary');694});695696test('appends path-only hint when line count is missing', () => {697const transcript = URI.file('/tmp/transcript.jsonl');698const svc = makeService(transcript, undefined);699const result = appendTranscriptHintToSummary('S', 'session-1', svc);700expect(result.startsWith('S\n')).toBe(true);701expect(result).toContain(transcript.fsPath);702expect(result).toContain(`${ToolName.ReadFile}`);703expect(result).not.toContain('the transcript had');704});705706test('bakes line count snapshot into hint when available', () => {707const transcript = URI.file('/tmp/transcript.jsonl');708const svc = makeService(transcript, 42);709const result = appendTranscriptHintToSummary('S', 'session-1', svc);710expect(result).toContain('At the time this summary was created, the transcript had 42 lines.');711expect(result).toContain(transcript.fsPath);712});713});714715716