Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/prompts/node/agent/test/summarization.spec.tsx
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Raw } from '@vscode/prompt-tsx';
7
import { afterAll, beforeAll, beforeEach, expect, suite, test } from 'vitest';
8
import { IChatMLFetcher } from '../../../../../platform/chat/common/chatMLFetcher';
9
import { ChatLocation } from '../../../../../platform/chat/common/commonTypes';
10
import { StaticChatMLFetcher } from '../../../../../platform/chat/test/common/staticChatMLFetcher';
11
import { CodeGenerationTextInstruction, ConfigKey, IConfigurationService } from '../../../../../platform/configuration/common/configurationService';
12
import { MockEndpoint } from '../../../../../platform/endpoint/test/node/mockEndpoint';
13
import { messageToMarkdown } from '../../../../../platform/log/common/messageStringify';
14
import { IResponseDelta } from '../../../../../platform/networking/common/fetch';
15
import { ITestingServicesAccessor } from '../../../../../platform/test/node/services';
16
import { TestWorkspaceService } from '../../../../../platform/test/node/testWorkspaceService';
17
import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';
18
import { createTextDocumentData } from '../../../../../util/common/test/shims/textDocument';
19
import { URI } from '../../../../../util/vs/base/common/uri';
20
import { SyncDescriptor } from '../../../../../util/vs/platform/instantiation/common/descriptors';
21
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
22
import { LanguageModelTextPart, LanguageModelToolResult } from '../../../../../vscodeTypes';
23
import { addCacheBreakpoints } from '../../../../intents/node/cacheBreakpoints';
24
import { ChatVariablesCollection } from '../../../../prompt/common/chatVariablesCollection';
25
import { Conversation, ICopilotChatResultIn, normalizeSummariesOnRounds, Turn, TurnStatus } from '../../../../prompt/common/conversation';
26
import { IBuildPromptContext, IToolCall } from '../../../../prompt/common/intents';
27
import { ToolCallRound } from '../../../../prompt/common/toolCallRound';
28
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
29
import { ToolName } from '../../../../tools/common/toolNames';
30
import { PromptRenderer } from '../../base/promptRenderer';
31
import { AgentPrompt, AgentPromptProps } from '../agentPrompt';
32
import { PromptRegistry } from '../promptRegistry';
33
import { ISessionTranscriptService, NullSessionTranscriptService } from '../../../../../platform/chat/common/sessionTranscriptService';
34
import { appendTranscriptHintToSummary, ConversationHistorySummarizationPrompt, extractInlineSummary, stripToolSearchMessages, SummarizedConversationHistory, SummarizedConversationHistoryMetadata, SummarizedConversationHistoryPropsBuilder } from '../summarizedConversationHistory';
35
36
suite('Agent Summarization', () => {
37
let accessor: ITestingServicesAccessor;
38
let chatResponse: (string | IResponseDelta[])[] = [];
39
const fileTsUri = URI.file('/workspace/file.ts');
40
41
let conversation: Conversation;
42
43
beforeAll(() => {
44
const testDoc = createTextDocumentData(fileTsUri, 'line 1\nline 2\n\nline 4\nline 5', 'ts').document;
45
46
const services = createExtensionUnitTestingServices();
47
services.define(IWorkspaceService, new SyncDescriptor(
48
TestWorkspaceService,
49
[
50
[URI.file('/workspace')],
51
[testDoc]
52
]
53
));
54
chatResponse = [];
55
services.define(IChatMLFetcher, new StaticChatMLFetcher(chatResponse));
56
accessor = services.createTestingAccessor();
57
accessor.get(IConfigurationService).setConfig(ConfigKey.CodeGenerationInstructions, [{
58
text: 'This is a test custom instruction file',
59
} satisfies CodeGenerationTextInstruction]);
60
});
61
62
beforeEach(() => {
63
const turn = new Turn('turnId', { type: 'user', message: 'hello' });
64
conversation = new Conversation('sessionId', [turn]);
65
});
66
67
afterAll(() => {
68
accessor.dispose();
69
});
70
71
enum TestPromptType {
72
Agent = 'Agent',
73
FullSummarization = 'FullSumm',
74
SimpleSummarization = 'SimpleSummarizedHistory'
75
}
76
77
async function agentPromptToString(accessor: ITestingServicesAccessor, promptContext: IBuildPromptContext, otherProps?: Partial<AgentPromptProps>, promptType: TestPromptType = TestPromptType.Agent): Promise<string> {
78
const instaService = accessor.get(IInstantiationService);
79
const endpoint = instaService.createInstance(MockEndpoint, undefined);
80
normalizeSummariesOnRounds(promptContext.history);
81
if (!promptContext.conversation) {
82
promptContext = { ...promptContext, conversation };
83
}
84
85
const baseProps = {
86
priority: 1,
87
endpoint,
88
location: ChatLocation.Panel,
89
promptContext,
90
maxToolResultLength: Infinity,
91
...otherProps
92
};
93
94
let renderer;
95
if (promptType === 'Agent') {
96
const customizations = await PromptRegistry.resolveAllCustomizations(instaService, endpoint);
97
const props: AgentPromptProps = { ...baseProps, customizations };
98
renderer = PromptRenderer.create(instaService, endpoint, AgentPrompt, props);
99
} else {
100
const propsInfo = instaService.createInstance(SummarizedConversationHistoryPropsBuilder).getProps(baseProps);
101
const simpleMode = promptType === TestPromptType.SimpleSummarization;
102
renderer = PromptRenderer.create(instaService, endpoint, ConversationHistorySummarizationPrompt, { ...propsInfo.props, simpleMode });
103
}
104
105
const r = await renderer.render();
106
const summarizedConversationMetadata = r.metadata.get(SummarizedConversationHistoryMetadata);
107
if (summarizedConversationMetadata && promptContext.toolCallRounds) {
108
for (const toolCallRound of promptContext.toolCallRounds) {
109
if (toolCallRound.id === summarizedConversationMetadata.toolCallRoundId) {
110
toolCallRound.summary = summarizedConversationMetadata.text;
111
}
112
}
113
}
114
addCacheBreakpoints(r.messages);
115
return r.messages
116
.filter(message => message.role !== Raw.ChatRole.System)
117
.map(m => messageToMarkdown(m))
118
.join('\n\n')
119
.replace(/\\+/g, '/')
120
.replace(/The current date is.*/g, '(Date removed from snapshot)');
121
}
122
123
function createEditFileToolCall(idx: number): IToolCall {
124
return {
125
id: `tooluse_${idx}`,
126
name: ToolName.EditFile,
127
arguments: JSON.stringify({
128
filePath: fileTsUri.fsPath, code: `// existing code...\nconsole.log('hi')`
129
})
130
};
131
}
132
133
function createEditFileToolResult(...idxs: number[]): Record<string, LanguageModelToolResult> {
134
const result: Record<string, LanguageModelToolResult> = {};
135
for (const idx of idxs) {
136
result[`tooluse_${idx}`] = new LanguageModelToolResult([new LanguageModelTextPart('success')]);
137
}
138
return result;
139
}
140
141
function getSnapshotFile(promptType: TestPromptType, name: string): string {
142
return `./__snapshots__/summarization-${name}-${promptType}.spec.snap`;
143
}
144
145
const tools: IBuildPromptContext['tools'] = {
146
availableTools: [],
147
toolInvocationToken: null as never,
148
toolReferences: [],
149
};
150
151
test('continuation turns are not rendered in conversation history', async () => {
152
const firstTurn = new Turn('id1', { type: 'user', message: 'previous turn message' });
153
const continuationTurn = new Turn('id2', { type: 'user', message: 'continuation turn message' }, undefined, [], undefined, undefined, true);
154
155
const promptContext: IBuildPromptContext = {
156
chatVariables: new ChatVariablesCollection([{ id: 'vscode.file', name: 'file', value: fileTsUri }]),
157
history: [firstTurn, continuationTurn],
158
query: 'edit this file',
159
toolCallRounds: [],
160
tools,
161
};
162
163
const rendered = await agentPromptToString(
164
accessor,
165
promptContext,
166
{ enableCacheBreakpoints: true },
167
TestPromptType.Agent
168
);
169
170
expect(rendered).toContain('previous turn message');
171
expect(rendered).not.toContain('continuation turn message');
172
});
173
174
test('cannot summarize with no history', async () => {
175
const promptContextNoHistory: IBuildPromptContext = {
176
chatVariables: new ChatVariablesCollection([{ id: 'vscode.file', name: 'file', value: fileTsUri }]),
177
history: [],
178
query: 'edit this file',
179
toolCallRounds: [],
180
tools,
181
};
182
await expect(() => agentPromptToString(
183
accessor, promptContextNoHistory, undefined, TestPromptType.FullSummarization)).rejects.toThrow();
184
await expect(() => agentPromptToString(
185
accessor,
186
{
187
...promptContextNoHistory,
188
toolCallRounds: [
189
new ToolCallRound('ok', [createEditFileToolCall(1)]),
190
],
191
toolCallResults: createEditFileToolResult(1),
192
tools,
193
}, undefined, TestPromptType.FullSummarization)).rejects.toThrow();
194
});
195
196
async function testTriggerSummarizationDuringToolCalling(promptType: TestPromptType) {
197
chatResponse[0] = 'summarized!';
198
const toolCallRounds = [
199
new ToolCallRound('ok', [createEditFileToolCall(1)]),
200
new ToolCallRound('ok 2', [createEditFileToolCall(2)]),
201
new ToolCallRound('ok 3', [createEditFileToolCall(3)]),
202
];
203
await expect(await agentPromptToString(
204
accessor,
205
{
206
chatVariables: new ChatVariablesCollection([{ id: 'vscode.file', name: 'file', value: fileTsUri }]),
207
history: [],
208
query: 'edit this file',
209
toolCallRounds,
210
toolCallResults: createEditFileToolResult(1, 2, 3),
211
tools
212
},
213
{
214
enableCacheBreakpoints: true,
215
triggerSummarize: true,
216
}, promptType)).toMatchFileSnapshot(getSnapshotFile(promptType, 'duringToolCalling'));
217
if (promptType === TestPromptType.Agent) {
218
expect(toolCallRounds.at(-2)?.summary).toBe('summarized!');
219
}
220
}
221
222
// Summarization for rounds in current turn
223
test('trigger summarization during tool calling', async () => await testTriggerSummarizationDuringToolCalling(TestPromptType.Agent));
224
test('FullSummarization - trigger summarization during tool calling', async () => await testTriggerSummarizationDuringToolCalling(TestPromptType.FullSummarization));
225
test('SimpleSummarization - trigger summarization during tool calling', async () => await testTriggerSummarizationDuringToolCalling(TestPromptType.SimpleSummarization));
226
227
async function testSummaryCurrentTurn(promptType: TestPromptType) {
228
const excludedPreviousRound = new ToolCallRound('previous round EXCLUDED', [createEditFileToolCall(1)]);
229
const round = new ToolCallRound('ok', [createEditFileToolCall(2)]);
230
round.summary = 'summarized!';
231
await expect(await agentPromptToString(
232
accessor,
233
{
234
chatVariables: new ChatVariablesCollection([{ id: 'vscode.file', name: 'file', value: fileTsUri }]),
235
history: [],
236
query: 'edit this file',
237
toolCallRounds: [
238
excludedPreviousRound,
239
round
240
],
241
toolCallResults: createEditFileToolResult(1, 2),
242
tools
243
},
244
{
245
enableCacheBreakpoints: true,
246
}, promptType)).toMatchFileSnapshot(getSnapshotFile(promptType, 'currentTurn'));
247
}
248
249
// SummarizationPrompt test is not relevant when the last round was summarized
250
test('render summary in current turn', async () => await testSummaryCurrentTurn(TestPromptType.Agent));
251
252
async function testSummaryCurrentTurnEarlierRound(promptType: TestPromptType) {
253
const round = new ToolCallRound('round 1', [createEditFileToolCall(1)]);
254
round.summary = 'summarized!';
255
const round2 = new ToolCallRound('round 2', [createEditFileToolCall(2)]);
256
const round3 = new ToolCallRound('round 3', [createEditFileToolCall(3)]);
257
await expect(await agentPromptToString(
258
accessor,
259
{
260
chatVariables: new ChatVariablesCollection([{ id: 'vscode.file', name: 'file', value: fileTsUri }]),
261
history: [],
262
query: 'edit this file',
263
toolCallRounds: [
264
round,
265
round2,
266
round3
267
],
268
toolCallResults: createEditFileToolResult(1, 2, 3),
269
tools
270
},
271
{
272
enableCacheBreakpoints: true,
273
}, promptType)).toMatchFileSnapshot(getSnapshotFile(promptType, 'currentTurnEarlierRound'));
274
}
275
276
test('render summary in previous turn', async () => await testSummaryCurrentTurnEarlierRound(TestPromptType.Agent));
277
test('FullSummarization - render summary in previous turn', async () => await testSummaryCurrentTurnEarlierRound(TestPromptType.FullSummarization));
278
test('SimpleSummarization - render summary in previous turn', async () => await testSummaryCurrentTurnEarlierRound(TestPromptType.SimpleSummarization));
279
280
async function testSummaryPrevTurnMultiple(promptType: TestPromptType) {
281
const previousTurn = new Turn('id', { type: 'user', message: 'previous turn excluded' });
282
const previousTurnResult: ICopilotChatResultIn = {
283
metadata: {
284
summary: {
285
text: 'summarized 1!',
286
toolCallRoundId: 'toolCallRoundId1'
287
},
288
toolCallRounds: [
289
new ToolCallRound('response', [createEditFileToolCall(1)], undefined, 'toolCallRoundId1'),
290
],
291
toolCallResults: createEditFileToolResult(1),
292
}
293
};
294
previousTurn.setResponse(TurnStatus.Success, { type: 'user', message: 'response' }, 'responseId', previousTurnResult);
295
296
const turn = new Turn('id', { type: 'user', message: 'hello' });
297
const result: ICopilotChatResultIn = {
298
metadata: {
299
summary: {
300
text: 'summarized 2!',
301
toolCallRoundId: 'toolCallRoundId3'
302
},
303
toolCallRounds: [
304
new ToolCallRound('response excluded', [createEditFileToolCall(2)], undefined, 'toolCallRoundId2'),
305
new ToolCallRound('response with summary', [createEditFileToolCall(3)], undefined, 'toolCallRoundId3'),
306
new ToolCallRound('next response', [createEditFileToolCall(4)], undefined, 'toolCallRoundId4'),
307
],
308
toolCallResults: createEditFileToolResult(2, 3, 4),
309
}
310
};
311
turn.setResponse(TurnStatus.Success, { type: 'user', message: 'response' }, 'responseId', result);
312
313
await expect(await agentPromptToString(
314
accessor,
315
{
316
chatVariables: new ChatVariablesCollection([{ id: 'vscode.file', name: 'file', value: fileTsUri }]),
317
history: [previousTurn, turn],
318
query: 'edit this file',
319
toolCallRounds: [(new ToolCallRound('hello next round', [createEditFileToolCall(5)]))],
320
toolCallResults: createEditFileToolResult(5),
321
tools
322
},
323
{
324
enableCacheBreakpoints: true,
325
}, promptType)).toMatchFileSnapshot(getSnapshotFile(promptType, 'previousTurnMultiple'));
326
}
327
328
test('render summary in previous turn (with multiple)', () => testSummaryPrevTurnMultiple(TestPromptType.Agent));
329
test('FullSummarization - render summary in previous turn (with multiple)', () => testSummaryPrevTurnMultiple(TestPromptType.FullSummarization));
330
test('SimpleSummarization - render summary in previous turn (with multiple)', () => testSummaryPrevTurnMultiple(TestPromptType.SimpleSummarization));
331
332
async function testSummarizeWithNoRoundsInCurrentTurn(promptType: TestPromptType) {
333
const previousTurn1 = new Turn('id', { type: 'user', message: 'previous turn 1' });
334
previousTurn1.setResponse(TurnStatus.Success, { type: 'user', message: 'response' }, 'responseId', {});
335
336
const previousTurn2 = new Turn('id', { type: 'user', message: 'previous turn 2' });
337
const previousTurn2Result: ICopilotChatResultIn = {
338
metadata: {
339
toolCallRounds: [],
340
summary: {
341
toolCallRoundId: 'previous',
342
text: 'previous turn 1 summary'
343
}
344
}
345
};
346
previousTurn2.setResponse(TurnStatus.Success, { type: 'user', message: 'response' }, 'responseId', previousTurn2Result);
347
348
await expect(await agentPromptToString(
349
accessor,
350
{
351
chatVariables: new ChatVariablesCollection([{ id: 'vscode.file', name: 'file', value: fileTsUri }]),
352
history: [previousTurn1, previousTurn2],
353
query: 'hello',
354
tools
355
},
356
{
357
enableCacheBreakpoints: true,
358
}, promptType)).toMatchFileSnapshot(getSnapshotFile(promptType, 'previousTurnNoRounds'));
359
}
360
361
test('summary for previous turn, no tool call rounds', async () => testSummarizeWithNoRoundsInCurrentTurn(TestPromptType.Agent));
362
test('FullSummarization - summary for previous turn, no tool call rounds', async () => testSummarizeWithNoRoundsInCurrentTurn(TestPromptType.FullSummarization));
363
test('SimpleSummarization - summary for previous turn, no tool call rounds', async () => testSummarizeWithNoRoundsInCurrentTurn(TestPromptType.SimpleSummarization));
364
365
function createSummarizationTestContext() {
366
const instaService = accessor.get(IInstantiationService);
367
const endpoint = instaService.createInstance(MockEndpoint, undefined);
368
369
const toolCallRounds = [
370
new ToolCallRound('ok', [createEditFileToolCall(1)]),
371
new ToolCallRound('ok 2', [createEditFileToolCall(2)]),
372
new ToolCallRound('ok 3', [createEditFileToolCall(3)]),
373
];
374
375
const turn = new Turn('turnId', { type: 'user', message: 'hello' });
376
const testConversation = new Conversation('sessionId', [turn]);
377
378
const promptContext: IBuildPromptContext = {
379
chatVariables: new ChatVariablesCollection([{ id: 'vscode.file', name: 'file', value: fileTsUri }]),
380
history: [],
381
query: 'edit this file',
382
toolCallRounds,
383
toolCallResults: createEditFileToolResult(1, 2, 3),
384
tools,
385
conversation: testConversation,
386
};
387
388
const historyProps = {
389
priority: 1,
390
endpoint,
391
location: ChatLocation.Panel,
392
promptContext,
393
maxToolResultLength: Infinity,
394
enableCacheBreakpoints: true,
395
triggerSummarize: true,
396
};
397
398
return { instaService, endpoint, toolCallRounds, turn, testConversation, promptContext, historyProps };
399
}
400
401
test('failed summarization throws from renderer (fallback is in agentIntent)', async () => {
402
// Keep the summary tiny-budgeted so the failure is immediate. The PromptRenderer propagates the error;
403
// the fallback to a no-cache-breakpoints render lives in agentIntent.ts's
404
// renderWithSummarization, not here.
405
chatResponse[0] = 'summary that is definitely too large for one token';
406
const { instaService, endpoint, historyProps } = createSummarizationTestContext();
407
408
const renderer = PromptRenderer.create(instaService, endpoint, SummarizedConversationHistory, {
409
...historyProps,
410
maxSummaryTokens: 1,
411
});
412
await expect(renderer.render()).rejects.toThrow('Summary too large');
413
});
414
415
test('successful summarization records metadata on render result', async () => {
416
chatResponse[0] = 'summarized successfully!';
417
const { instaService, endpoint, historyProps } = createSummarizationTestContext();
418
419
const renderer = PromptRenderer.create(instaService, endpoint, SummarizedConversationHistory, historyProps);
420
const result = await renderer.render();
421
422
const summaryMeta = result.metadata.get(SummarizedConversationHistoryMetadata);
423
expect(summaryMeta).toBeDefined();
424
expect(summaryMeta!.text).toBe('summarized successfully!');
425
expect(summaryMeta!.toolCallRoundId).toBeTruthy();
426
});
427
428
test('failed summarization does not set round.summary', async () => {
429
chatResponse[0] = 'summary that is definitely too large for one token';
430
const { instaService, endpoint, toolCallRounds, historyProps } = createSummarizationTestContext();
431
432
const renderer = PromptRenderer.create(instaService, endpoint, SummarizedConversationHistory, {
433
...historyProps,
434
maxSummaryTokens: 1,
435
});
436
await expect(renderer.render()).rejects.toThrow('Summary too large');
437
438
// None of the rounds should have summary set since summarization failed
439
for (const round of toolCallRounds) {
440
expect(round.summary).toBeUndefined();
441
}
442
});
443
444
test('simple mode summarization with small token budget renders zero messages (repro for No messages provided)', async () => {
445
// Repro for: "Prompt failed validation with the reason: No messages provided"
446
//
447
// Root cause: when modelMaxPromptTokens is small enough that the summarization
448
// prompt content exceeds the budget, prompt-tsx prunes all child elements.
449
// After pruning, toChatMessages() silently skips messages whose content is
450
// empty (isEmpty check), producing an empty messages array — without throwing
451
// BudgetExceededError. The downstream makeChatRequest2 then hits the
452
// isValidChatPayload check: "No messages provided".
453
const instaService = accessor.get(IInstantiationService);
454
const endpoint = instaService.createInstance(MockEndpoint, 'claude-sonnet');
455
endpoint.modelMaxPromptTokens = 5; // So small that even a single short message cannot fit
456
457
const toolCallRounds = [
458
new ToolCallRound('ok', [createEditFileToolCall(1)]),
459
new ToolCallRound('ok 2', [createEditFileToolCall(2)]),
460
];
461
462
const turn = new Turn('turnId', { type: 'user', message: 'hello' });
463
const testConversation = new Conversation('sessionId', [turn]);
464
465
const promptContext: IBuildPromptContext = {
466
chatVariables: new ChatVariablesCollection([]),
467
history: [],
468
query: 'edit this file',
469
toolCallRounds,
470
toolCallResults: createEditFileToolResult(1, 2),
471
tools,
472
conversation: testConversation,
473
};
474
475
const baseProps = {
476
priority: 1,
477
endpoint,
478
location: ChatLocation.Panel,
479
promptContext,
480
maxToolResultLength: Infinity,
481
};
482
483
const propsInfo = instaService.createInstance(SummarizedConversationHistoryPropsBuilder).getProps(baseProps);
484
const renderer = PromptRenderer.create(instaService, endpoint, ConversationHistorySummarizationPrompt, { ...propsInfo.props, simpleMode: true });
485
const result = await renderer.render();
486
487
// prompt-tsx prunes all content and silently drops empty messages → 0 messages
488
expect(result.messages.length).toBe(0);
489
});
490
491
test('failure metadata on turn prevents repeated foreground summarization attempts', async () => {
492
// This test verifies the contract that agentIntent.ts relies on:
493
// after a foreground summarization failure, setting SummarizedConversationHistoryMetadata
494
// with outcome !== 'success' on the turn causes the retry guard to skip summarization.
495
496
const turn = new Turn('turnId', { type: 'user', message: 'hello' });
497
498
// Simulate what agentIntent.ts does after a failed foreground summarization
499
turn.setMetadata(new SummarizedConversationHistoryMetadata(
500
'', // no toolCallRoundId for failures
501
'', // no summary text for failures
502
{
503
model: 'test-model',
504
source: 'foreground',
505
outcome: 'budgetExceeded',
506
contextLengthBefore: 100_000,
507
},
508
));
509
510
// Verify the retry guard condition from renderWithSummarization matches
511
const previousForegroundSummary = turn.getMetadata(SummarizedConversationHistoryMetadata);
512
expect(previousForegroundSummary).toBeDefined();
513
expect(previousForegroundSummary!.source).toBe('foreground');
514
expect(previousForegroundSummary!.outcome).toBe('budgetExceeded');
515
expect(previousForegroundSummary!.outcome).not.toBe('success');
516
517
// The guard condition: source === 'foreground' && outcome && outcome !== 'success'
518
const shouldSkip = previousForegroundSummary!.source === 'foreground'
519
&& !!previousForegroundSummary!.outcome
520
&& previousForegroundSummary!.outcome !== 'success';
521
expect(shouldSkip).toBe(true);
522
523
// Also verify that successful summarization does NOT trigger the skip guard
524
turn.setMetadata(new SummarizedConversationHistoryMetadata(
525
'roundId',
526
'summary text',
527
{
528
model: 'test-model',
529
source: 'foreground',
530
outcome: 'success',
531
},
532
));
533
const successMeta = turn.getMetadata(SummarizedConversationHistoryMetadata);
534
const shouldSkipAfterSuccess = successMeta!.source === 'foreground'
535
&& !!successMeta!.outcome
536
&& successMeta!.outcome !== 'success';
537
expect(shouldSkipAfterSuccess).toBe(false);
538
});
539
});
540
541
suite('extractInlineSummary', () => {
542
test('extracts clean summary tags', () => {
543
const text = 'Some preamble\n<summary>\nThis is the summary content.\n</summary>\nSome trailing text';
544
const result = extractInlineSummary(text);
545
expect(result).toBe('This is the summary content.');
546
});
547
548
test('extracts summary with no closing tag', () => {
549
const text = 'Preamble text\n<summary>\nThis is a partial summary that was cut off';
550
const result = extractInlineSummary(text);
551
expect(result).toBe('This is a partial summary that was cut off');
552
});
553
554
test('returns undefined when no tags found', () => {
555
const text = 'This is just a normal response with no summary tags at all.';
556
const result = extractInlineSummary(text);
557
expect(result).toBeUndefined();
558
});
559
560
test('uses first complete summary when multiple blocks exist', () => {
561
const text = '<summary>First summary</summary>\n<summary>Second summary</summary>';
562
const result = extractInlineSummary(text);
563
expect(result).toBe('First summary');
564
});
565
566
test('handles empty summary tags', () => {
567
const text = '<summary></summary>';
568
const result = extractInlineSummary(text);
569
expect(result).toBe('');
570
});
571
572
test('handles summary with analysis tags inside', () => {
573
const text = '<summary>\n<analysis>Some analysis</analysis>\n\n1. Overview: test\n2. Details: test\n</summary>';
574
const result = extractInlineSummary(text);
575
expect(result).toContain('1. Overview: test');
576
expect(result).toContain('<analysis>Some analysis</analysis>');
577
});
578
579
test('trims whitespace from extracted summary', () => {
580
const text = '<summary>\n\n Padded summary text \n\n</summary>';
581
const result = extractInlineSummary(text);
582
expect(result).toBe('Padded summary text');
583
});
584
});
585
586
suite('stripToolSearchMessages', () => {
587
function makeAssistantMessage(toolCalls: { id: string; name: string }[], text = 'response'): Raw.ChatMessage {
588
return {
589
role: Raw.ChatRole.Assistant,
590
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text }],
591
toolCalls: toolCalls.map(tc => ({
592
type: 'function' as const,
593
id: tc.id,
594
function: { name: tc.name, arguments: '{}' },
595
})),
596
};
597
}
598
599
function makeToolResult(toolCallId: string, text = 'result'): Raw.ChatMessage {
600
return {
601
role: Raw.ChatRole.Tool,
602
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text }],
603
toolCallId,
604
};
605
}
606
607
function makeUserMessage(text = 'hello'): Raw.ChatMessage {
608
return {
609
role: Raw.ChatRole.User,
610
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text }],
611
};
612
}
613
614
test('returns messages unchanged when no tool_search calls present', () => {
615
const messages = [
616
makeUserMessage(),
617
makeAssistantMessage([{ id: 'tc1', name: 'read_file' }]),
618
makeToolResult('tc1'),
619
];
620
const result = stripToolSearchMessages(messages);
621
expect(result).toBe(messages);
622
});
623
624
test('strips custom tool_search tool_use and tool_result', () => {
625
const messages = [
626
makeUserMessage(),
627
makeAssistantMessage([
628
{ id: 'tc1', name: 'read_file' },
629
{ id: 'tc2', name: 'tool_search' },
630
]),
631
makeToolResult('tc1'),
632
makeToolResult('tc2', '["read_file", "edit_file"]'),
633
];
634
const result = stripToolSearchMessages(messages);
635
expect(result).toHaveLength(3);
636
const assistant = result[1];
637
expect(assistant.role).toBe(Raw.ChatRole.Assistant);
638
if (assistant.role === Raw.ChatRole.Assistant) {
639
expect(assistant.toolCalls).toHaveLength(1);
640
expect(assistant.toolCalls![0].id).toBe('tc1');
641
}
642
expect(result.find(m => m.role === Raw.ChatRole.Tool && m.toolCallId === 'tc2')).toBeUndefined();
643
});
644
645
test('removes toolCalls property when all tool calls are tool_search', () => {
646
const messages = [
647
makeUserMessage(),
648
makeAssistantMessage([{ id: 'tc1', name: 'tool_search' }]),
649
makeToolResult('tc1'),
650
];
651
const result = stripToolSearchMessages(messages);
652
expect(result).toHaveLength(2);
653
const assistant = result[1];
654
if (assistant.role === Raw.ChatRole.Assistant) {
655
expect(assistant.toolCalls).toBeUndefined();
656
}
657
});
658
659
test('preserves non-tool messages', () => {
660
const messages = [
661
makeUserMessage('first'),
662
makeAssistantMessage([{ id: 'tc1', name: 'tool_search' }]),
663
makeToolResult('tc1'),
664
makeUserMessage('second'),
665
makeAssistantMessage([{ id: 'tc2', name: 'edit_file' }]),
666
makeToolResult('tc2'),
667
];
668
const result = stripToolSearchMessages(messages);
669
expect(result).toHaveLength(5);
670
expect(result[0].content[0]).toEqual({ type: Raw.ChatCompletionContentPartKind.Text, text: 'first' });
671
expect(result[2].content[0]).toEqual({ type: Raw.ChatCompletionContentPartKind.Text, text: 'second' });
672
});
673
});
674
675
suite('appendTranscriptHintToSummary', () => {
676
class FakeTranscriptService extends NullSessionTranscriptService {
677
constructor(
678
private readonly path: URI | undefined,
679
private readonly lineCount: number | undefined,
680
) {
681
super();
682
}
683
override getTranscriptPath(): URI | undefined { return this.path; }
684
override getLineCount(): number | undefined { return this.lineCount; }
685
}
686
687
function makeService(path: URI | undefined, lineCount: number | undefined): ISessionTranscriptService {
688
return new FakeTranscriptService(path, lineCount);
689
}
690
691
test('returns summary unchanged when no transcript path is available', () => {
692
const svc = makeService(undefined, undefined);
693
const result = appendTranscriptHintToSummary('original summary', 'session-1', svc);
694
expect(result).toBe('original summary');
695
});
696
697
test('appends path-only hint when line count is missing', () => {
698
const transcript = URI.file('/tmp/transcript.jsonl');
699
const svc = makeService(transcript, undefined);
700
const result = appendTranscriptHintToSummary('S', 'session-1', svc);
701
expect(result.startsWith('S\n')).toBe(true);
702
expect(result).toContain(transcript.fsPath);
703
expect(result).toContain(`${ToolName.ReadFile}`);
704
expect(result).not.toContain('the transcript had');
705
});
706
707
test('bakes line count snapshot into hint when available', () => {
708
const transcript = URI.file('/tmp/transcript.jsonl');
709
const svc = makeService(transcript, 42);
710
const result = appendTranscriptHintToSummary('S', 'session-1', svc);
711
expect(result).toContain('At the time this summary was created, the transcript had 42 lines.');
712
expect(result).toContain(transcript.fsPath);
713
});
714
});
715
716