Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatHistoryBuilder.spec.ts
13405 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 Anthropic from '@anthropic-ai/sdk';
7
import { describe, expect, it } from 'vitest';
8
import type * as vscode from 'vscode';
9
import { URI } from '../../../../util/vs/base/common/uri';
10
import { ChatReferenceBinaryData, ChatRequestTurn, ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponseThinkingProgressPart, ChatResponseTurn2, ChatToolInvocationPart } from '../../../../vscodeTypes';
11
import { IClaudeCodeSession, ISubagentSession, StoredMessage, SYNTHETIC_MODEL_ID } from '../../claude/node/sessionParser/claudeSessionSchema';
12
import { buildChatHistory } from '../chatHistoryBuilder';
13
14
// #region Test Helpers
15
16
let _msgCounter = 0;
17
18
function userMsg(content: string | Anthropic.Messages.ContentBlockParam[]): StoredMessage {
19
const uuid = `user-${++_msgCounter}`;
20
return {
21
uuid,
22
sessionId: 'test-session',
23
timestamp: new Date(),
24
parentUuid: null,
25
type: 'user',
26
message: { role: 'user' as const, content },
27
} as StoredMessage;
28
}
29
30
function assistantMsg(content: readonly Record<string, unknown>[], model = 'claude-3-sonnet'): StoredMessage {
31
const uuid = `asst-${++_msgCounter}`;
32
return {
33
uuid,
34
sessionId: 'test-session',
35
timestamp: new Date(),
36
parentUuid: null,
37
type: 'assistant',
38
message: {
39
id: uuid,
40
type: 'message',
41
role: 'assistant' as const,
42
content,
43
model,
44
stop_reason: content.some(b => b.type === 'tool_use') ? 'tool_use' : 'end_turn',
45
stop_sequence: null,
46
usage: { input_tokens: 10, output_tokens: 10 },
47
},
48
} as StoredMessage;
49
}
50
51
function toolResult(toolUseId: string, content: string, isError = false): StoredMessage {
52
return userMsg([{ type: 'tool_result', tool_use_id: toolUseId, content, is_error: isError }]);
53
}
54
55
function session(messages: StoredMessage[], subagents: ISubagentSession[] = []): IClaudeCodeSession {
56
const timestamp = new Date();
57
return {
58
id: 'test-session',
59
label: 'Test',
60
messages,
61
created: (messages[0]?.timestamp ?? timestamp).getTime(),
62
lastRequestEnded: (messages[messages.length - 1]?.timestamp ?? timestamp).getTime(),
63
subagents,
64
};
65
}
66
67
interface SnapshotRequest {
68
type: 'request';
69
prompt: string;
70
}
71
72
interface SnapshotResponse {
73
type: 'response';
74
parts: Array<Record<string, unknown>>;
75
}
76
77
type SnapshotTurn = SnapshotRequest | SnapshotResponse | { type: 'unknown' };
78
79
function getResponseParts(snapshot: SnapshotTurn[], index: number): Array<Record<string, unknown>> {
80
const turn = snapshot[index];
81
if (turn.type !== 'response') {
82
throw new Error(`Expected response at index ${index}, got ${turn.type}`);
83
}
84
return turn.parts;
85
}
86
87
function mapHistoryForSnapshot(history: readonly (vscode.ChatRequestTurn | vscode.ChatResponseTurn2)[]): SnapshotTurn[] {
88
return history.map(turn => {
89
if (turn instanceof ChatRequestTurn || turn instanceof ChatRequestTurn2) {
90
return {
91
type: 'request',
92
prompt: turn.prompt,
93
};
94
} else if (turn instanceof ChatResponseTurn2) {
95
return {
96
type: 'response',
97
parts: turn.response.map(part => {
98
if (part instanceof ChatResponseMarkdownPart) {
99
return {
100
type: 'markdown',
101
content: part.value.value,
102
};
103
} else if (part instanceof ChatToolInvocationPart) {
104
return {
105
type: 'tool',
106
toolName: part.toolName,
107
toolCallId: part.toolCallId,
108
isError: part.isError,
109
isComplete: part.isComplete,
110
};
111
} else if (part instanceof ChatResponseThinkingProgressPart) {
112
return {
113
type: 'thinking',
114
};
115
}
116
return { type: 'unknown' };
117
}),
118
};
119
}
120
return { type: 'unknown' };
121
});
122
}
123
124
// #endregion
125
126
describe('buildChatHistory', () => {
127
128
// #region Empty and Minimal Cases
129
130
describe('empty and minimal cases', () => {
131
it('returns empty array for session with no messages', () => {
132
const result = buildChatHistory(session([]));
133
expect(result).toEqual([]);
134
});
135
136
it('converts a single user message to a request turn', () => {
137
const result = buildChatHistory(session([
138
userMsg('Hello'),
139
]));
140
expect(mapHistoryForSnapshot(result)).toMatchInlineSnapshot(`
141
[
142
{
143
"prompt": "Hello",
144
"type": "request",
145
},
146
]
147
`);
148
});
149
150
it('converts a single assistant text message to a response turn', () => {
151
const result = buildChatHistory(session([
152
assistantMsg([{ type: 'text', text: 'Hi there!' }]),
153
]));
154
expect(mapHistoryForSnapshot(result)).toMatchInlineSnapshot(`
155
[
156
{
157
"parts": [
158
{
159
"content": "Hi there!",
160
"type": "markdown",
161
},
162
],
163
"type": "response",
164
},
165
]
166
`);
167
});
168
});
169
170
// #endregion
171
172
// #region Simple Request/Response Pairs
173
174
describe('simple request/response pairs', () => {
175
it('converts a user message followed by an assistant text response', () => {
176
const result = buildChatHistory(session([
177
userMsg('What is 2+2?'),
178
assistantMsg([{ type: 'text', text: 'The answer is 4.' }]),
179
]));
180
expect(mapHistoryForSnapshot(result)).toMatchInlineSnapshot(`
181
[
182
{
183
"prompt": "What is 2+2?",
184
"type": "request",
185
},
186
{
187
"parts": [
188
{
189
"content": "The answer is 4.",
190
"type": "markdown",
191
},
192
],
193
"type": "response",
194
},
195
]
196
`);
197
});
198
199
it('handles multiple conversation turns', () => {
200
const result = buildChatHistory(session([
201
userMsg('First question'),
202
assistantMsg([{ type: 'text', text: 'First answer' }]),
203
userMsg('Second question'),
204
assistantMsg([{ type: 'text', text: 'Second answer' }]),
205
]));
206
expect(result).toHaveLength(4);
207
expect(result[0]).toBeInstanceOf(ChatRequestTurn2);
208
expect(result[1]).toBeInstanceOf(ChatResponseTurn2);
209
expect(result[2]).toBeInstanceOf(ChatRequestTurn2);
210
expect(result[3]).toBeInstanceOf(ChatResponseTurn2);
211
});
212
});
213
214
// #endregion
215
216
// #region Consecutive Message Grouping
217
218
describe('consecutive message grouping', () => {
219
it('combines consecutive user messages into a single request turn', () => {
220
const result = buildChatHistory(session([
221
userMsg('First part.'),
222
userMsg('Second part.'),
223
assistantMsg([{ type: 'text', text: 'Response' }]),
224
]));
225
const snapshot = mapHistoryForSnapshot(result);
226
expect(snapshot).toHaveLength(2);
227
expect(snapshot[0]).toEqual({
228
type: 'request',
229
prompt: 'First part.\n\nSecond part.',
230
});
231
});
232
233
it('combines consecutive assistant messages into a single response turn', () => {
234
const result = buildChatHistory(session([
235
userMsg('Hello'),
236
assistantMsg([{ type: 'text', text: 'Part one.' }]),
237
assistantMsg([{ type: 'text', text: 'Part two.' }]),
238
]));
239
const snapshot = mapHistoryForSnapshot(result);
240
expect(snapshot).toHaveLength(2);
241
expect(snapshot[1]).toEqual({
242
type: 'response',
243
parts: [
244
{ type: 'markdown', content: 'Part one.' },
245
{ type: 'markdown', content: 'Part two.' },
246
],
247
});
248
});
249
});
250
251
// #endregion
252
253
// #region Single Tool Call
254
255
describe('single tool call', () => {
256
it('creates a tool invocation part for tool_use blocks', () => {
257
const result = buildChatHistory(session([
258
userMsg('List files'),
259
assistantMsg([
260
{ type: 'text', text: 'Let me check.' },
261
{ type: 'tool_use', id: 'tool-1', name: 'bash', input: { command: 'ls' } },
262
]),
263
]));
264
const snapshot = mapHistoryForSnapshot(result);
265
expect(snapshot[1]).toEqual({
266
type: 'response',
267
parts: [
268
{ type: 'markdown', content: 'Let me check.' },
269
{ type: 'tool', toolName: 'bash', toolCallId: 'tool-1', isComplete: undefined },
270
],
271
});
272
});
273
274
it('marks tool invocations as complete when tool result follows', () => {
275
const result = buildChatHistory(session([
276
userMsg('List files'),
277
assistantMsg([
278
{ type: 'tool_use', id: 'tool-1', name: 'bash', input: { command: 'ls' } },
279
]),
280
toolResult('tool-1', 'file1.txt\nfile2.txt'),
281
]));
282
const snapshot = mapHistoryForSnapshot(result);
283
// Should be a single response with a completed tool
284
expect(snapshot).toHaveLength(2);
285
expect(snapshot[1]).toEqual({
286
type: 'response',
287
parts: [
288
{ type: 'tool', toolName: 'bash', toolCallId: 'tool-1', isError: false, isComplete: true },
289
],
290
});
291
});
292
293
it('marks tool invocations as error when tool result is an error', () => {
294
const result = buildChatHistory(session([
295
userMsg('Run command'),
296
assistantMsg([
297
{ type: 'tool_use', id: 'tool-1', name: 'bash', input: { command: 'bad-cmd' } },
298
]),
299
toolResult('tool-1', 'command not found', true),
300
]));
301
const snapshot = mapHistoryForSnapshot(result);
302
expect(getResponseParts(snapshot, 1)[0]).toMatchObject({
303
type: 'tool',
304
isError: true,
305
isComplete: true,
306
});
307
});
308
});
309
310
// #endregion
311
312
// #region Multi-Round Tool Use (Core Bug Fix)
313
314
describe('multi-round tool use merging', () => {
315
it('merges assistant → tool_result → assistant into a single response', () => {
316
const result = buildChatHistory(session([
317
userMsg('Find and read config'),
318
assistantMsg([
319
{ type: 'text', text: 'Let me find it.' },
320
{ type: 'tool_use', id: 'tool-1', name: 'Glob', input: { pattern: '**/config.*' } },
321
]),
322
toolResult('tool-1', 'config.json'),
323
assistantMsg([
324
{ type: 'text', text: 'Found it. Let me read it.' },
325
{ type: 'tool_use', id: 'tool-2', name: 'Read', input: { file_path: 'config.json' } },
326
]),
327
toolResult('tool-2', '{ "key": "value" }'),
328
assistantMsg([
329
{ type: 'text', text: 'Done.' },
330
]),
331
]));
332
333
const snapshot = mapHistoryForSnapshot(result);
334
// Must be exactly 1 request + 1 response
335
expect(snapshot).toHaveLength(2);
336
expect(snapshot[0].type).toBe('request');
337
expect(snapshot[1].type).toBe('response');
338
expect(getResponseParts(snapshot, 1)).toHaveLength(5);
339
});
340
341
it('merges many rounds of tool use into a single response', () => {
342
const result = buildChatHistory(session([
343
userMsg('Do complex task'),
344
assistantMsg([{ type: 'tool_use', id: 't1', name: 'Glob', input: {} }]),
345
toolResult('t1', 'result1'),
346
assistantMsg([{ type: 'tool_use', id: 't2', name: 'Read', input: {} }]),
347
toolResult('t2', 'result2'),
348
assistantMsg([{ type: 'tool_use', id: 't3', name: 'Grep', input: {} }]),
349
toolResult('t3', 'result3'),
350
assistantMsg([{ type: 'tool_use', id: 't4', name: 'bash', input: {} }]),
351
toolResult('t4', 'result4'),
352
assistantMsg([{ type: 'text', text: 'All done.' }]),
353
]));
354
355
const snapshot = mapHistoryForSnapshot(result);
356
expect(snapshot).toHaveLength(2);
357
expect(getResponseParts(snapshot, 1)).toHaveLength(5); // 4 tools + 1 text
358
expect(getResponseParts(snapshot, 1)[0]).toMatchObject({ type: 'tool', isComplete: true });
359
expect(getResponseParts(snapshot, 1)[1]).toMatchObject({ type: 'tool', isComplete: true });
360
expect(getResponseParts(snapshot, 1)[2]).toMatchObject({ type: 'tool', isComplete: true });
361
expect(getResponseParts(snapshot, 1)[3]).toMatchObject({ type: 'tool', isComplete: true });
362
expect(getResponseParts(snapshot, 1)[4]).toMatchObject({ type: 'markdown', content: 'All done.' });
363
});
364
365
it('correctly separates two user requests each with their own tool loops', () => {
366
const result = buildChatHistory(session([
367
// First user request with tool loop
368
userMsg('First task'),
369
assistantMsg([{ type: 'tool_use', id: 't1', name: 'Glob', input: {} }]),
370
toolResult('t1', 'found'),
371
assistantMsg([{ type: 'text', text: 'Done with first.' }]),
372
// Second user request with tool loop
373
userMsg('Second task'),
374
assistantMsg([{ type: 'tool_use', id: 't2', name: 'Read', input: {} }]),
375
toolResult('t2', 'content'),
376
assistantMsg([{ type: 'text', text: 'Done with second.' }]),
377
]));
378
379
const snapshot = mapHistoryForSnapshot(result);
380
expect(snapshot).toHaveLength(4); // req, resp, req, resp
381
expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'First task' });
382
expect(snapshot[1]).toMatchObject({ type: 'response' });
383
expect(getResponseParts(snapshot, 1)).toHaveLength(2); // tool + text
384
expect(snapshot[2]).toMatchObject({ type: 'request', prompt: 'Second task' });
385
expect(snapshot[3]).toMatchObject({ type: 'response' });
386
expect(getResponseParts(snapshot, 3)).toHaveLength(2); // tool + text
387
});
388
389
it('handles parallel tool calls in a single assistant message', () => {
390
const result = buildChatHistory(session([
391
userMsg('Search broadly'),
392
assistantMsg([
393
{ type: 'text', text: 'Searching...' },
394
{ type: 'tool_use', id: 't1', name: 'Glob', input: {} },
395
{ type: 'tool_use', id: 't2', name: 'Grep', input: {} },
396
]),
397
// Both tool results come in the same user message
398
userMsg([
399
{ type: 'tool_result', tool_use_id: 't1', content: 'glob result' },
400
{ type: 'tool_result', tool_use_id: 't2', content: 'grep result' },
401
]),
402
assistantMsg([{ type: 'text', text: 'Found everything.' }]),
403
]));
404
405
const snapshot = mapHistoryForSnapshot(result);
406
expect(snapshot).toHaveLength(2);
407
expect(getResponseParts(snapshot, 1)).toHaveLength(4); // text + 2 tools + text
408
expect(getResponseParts(snapshot, 1)[1]).toMatchObject({ type: 'tool', isComplete: true });
409
expect(getResponseParts(snapshot, 1)[2]).toMatchObject({ type: 'tool', isComplete: true });
410
});
411
412
it('handles tool results that arrive in separate user messages', () => {
413
const result = buildChatHistory(session([
414
userMsg('Do thing'),
415
assistantMsg([
416
{ type: 'tool_use', id: 't1', name: 'Glob', input: {} },
417
{ type: 'tool_use', id: 't2', name: 'Grep', input: {} },
418
]),
419
// Each tool result as a separate user message (both should be merged)
420
toolResult('t1', 'glob result'),
421
toolResult('t2', 'grep result'),
422
assistantMsg([{ type: 'text', text: 'Done.' }]),
423
]));
424
425
const snapshot = mapHistoryForSnapshot(result);
426
expect(snapshot).toHaveLength(2);
427
expect(getResponseParts(snapshot, 1)[0]).toMatchObject({ type: 'tool', isComplete: true });
428
expect(getResponseParts(snapshot, 1)[1]).toMatchObject({ type: 'tool', isComplete: true });
429
});
430
});
431
432
// #endregion
433
434
// #region System Reminder Filtering
435
436
describe('system reminder filtering', () => {
437
it('filters out system-reminder blocks from user messages', () => {
438
const result = buildChatHistory(session([
439
userMsg([
440
{ type: 'text', text: '<system-reminder>\nInternal context.\n</system-reminder>' },
441
{ type: 'text', text: 'What does this do?' },
442
]),
443
]));
444
const snapshot = mapHistoryForSnapshot(result);
445
expect(snapshot).toHaveLength(1);
446
expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'What does this do?' });
447
});
448
449
it('strips system-reminders from legacy string format', () => {
450
const result = buildChatHistory(session([
451
userMsg('<system-reminder>\nInternal.\n</system-reminder>\n\nActual question'),
452
]));
453
const snapshot = mapHistoryForSnapshot(result);
454
expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'Actual question' });
455
});
456
457
it('produces no request turn when user message is only a system-reminder', () => {
458
const result = buildChatHistory(session([
459
userMsg([
460
{ type: 'text', text: '<system-reminder>\nInternal.\n</system-reminder>' },
461
]),
462
assistantMsg([{ type: 'text', text: 'Hello!' }]),
463
]));
464
const snapshot = mapHistoryForSnapshot(result);
465
// Only the assistant response should appear
466
expect(snapshot).toHaveLength(1);
467
expect(snapshot[0]).toMatchObject({ type: 'response' });
468
});
469
470
it('filters system-reminder user messages mid-tool-loop without breaking the response', () => {
471
const result = buildChatHistory(session([
472
userMsg('Do task'),
473
assistantMsg([
474
{ type: 'tool_use', id: 't1', name: 'bash', input: {} },
475
]),
476
// Tool result + system reminder in the same user message group
477
userMsg([
478
{ type: 'tool_result', tool_use_id: 't1', content: 'done' },
479
]),
480
userMsg([
481
{ type: 'text', text: '<system-reminder>\nReminder.\n</system-reminder>' },
482
]),
483
assistantMsg([{ type: 'text', text: 'Finished.' }]),
484
]));
485
486
const snapshot = mapHistoryForSnapshot(result);
487
// System-reminder-only user messages should not break the response
488
expect(snapshot).toHaveLength(2);
489
expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'Do task' });
490
expect(getResponseParts(snapshot, 1)).toHaveLength(2); // tool + text
491
});
492
});
493
494
// #endregion
495
496
// #region Interrupted Requests
497
498
describe('interrupted requests', () => {
499
it('skips user messages that are interruption markers', () => {
500
const result = buildChatHistory(session([
501
userMsg('Do something'),
502
assistantMsg([
503
{ type: 'tool_use', id: 't1', name: 'bash', input: {} },
504
]),
505
toolResult('t1', 'partial'),
506
assistantMsg([{ type: 'text', text: 'Working...' }]),
507
userMsg('[Request interrupted by user]'),
508
assistantMsg([{ type: 'text', text: 'Stopped.' }]),
509
]));
510
511
const snapshot = mapHistoryForSnapshot(result);
512
// The interruption marker should not create a request turn
513
// The "Stopped." response merges into a new response (since the interrupted
514
// user message broke the assistant grouping but produced no request turn)
515
expect(snapshot.filter(s => s.type === 'request')).toHaveLength(1);
516
});
517
});
518
519
// #endregion
520
521
// #region Thinking Blocks
522
523
describe('thinking blocks', () => {
524
it('includes thinking blocks in response parts', () => {
525
const result = buildChatHistory(session([
526
userMsg('Think about this'),
527
assistantMsg([
528
{ type: 'thinking', thinking: 'Let me reason...' },
529
{ type: 'text', text: 'Here is my answer.' },
530
]),
531
]));
532
533
// Thinking block + text = 2 parts
534
expect(result).toHaveLength(2);
535
const response = result[1] as vscode.ChatResponseTurn2;
536
expect(response.response).toHaveLength(2);
537
});
538
539
it('preserves thinking blocks across multi-round tool use', () => {
540
const result = buildChatHistory(session([
541
userMsg('Complex task'),
542
assistantMsg([
543
{ type: 'thinking', thinking: 'First thinking...' },
544
{ type: 'tool_use', id: 't1', name: 'Glob', input: {} },
545
]),
546
toolResult('t1', 'found'),
547
assistantMsg([
548
{ type: 'thinking', thinking: 'Second thinking...' },
549
{ type: 'text', text: 'Done.' },
550
]),
551
]));
552
553
const snapshot = mapHistoryForSnapshot(result);
554
expect(snapshot).toHaveLength(2); // 1 request, 1 merged response
555
});
556
});
557
558
// #endregion
559
560
// #region Edge Cases
561
562
describe('edge cases', () => {
563
it('handles tool_use without a corresponding tool_result', () => {
564
const result = buildChatHistory(session([
565
userMsg('Start'),
566
assistantMsg([
567
{ type: 'tool_use', id: 't1', name: 'bash', input: {} },
568
]),
569
// No tool result - session may have been interrupted
570
]));
571
572
const snapshot = mapHistoryForSnapshot(result);
573
expect(snapshot).toHaveLength(2);
574
expect(getResponseParts(snapshot, 1)[0]).toMatchObject({
575
type: 'tool',
576
isComplete: undefined, // Not completed since no result arrived
577
});
578
});
579
580
it('handles user message with mixed text and tool_result content', () => {
581
const result = buildChatHistory(session([
582
userMsg([
583
{ type: 'text', text: 'Here is context: ' },
584
{ type: 'tool_result', tool_use_id: 'orphan', content: 'result', is_error: false },
585
]),
586
]));
587
588
const snapshot = mapHistoryForSnapshot(result);
589
// The text should become a request; the tool_result is processed (but orphaned)
590
expect(snapshot).toHaveLength(1);
591
expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'Here is context: ' });
592
});
593
594
it('handles session starting with assistant message (no preceding user message)', () => {
595
const result = buildChatHistory(session([
596
assistantMsg([{ type: 'text', text: 'I was already running.' }]),
597
]));
598
599
const snapshot = mapHistoryForSnapshot(result);
600
expect(snapshot).toHaveLength(1);
601
expect(snapshot[0]).toMatchObject({ type: 'response' });
602
});
603
604
it('handles tool_result for a tool_use_id that does not exist', () => {
605
const result = buildChatHistory(session([
606
userMsg('Start'),
607
assistantMsg([{ type: 'text', text: 'Response' }]),
608
toolResult('nonexistent-id', 'result'),
609
]));
610
611
// Should not throw, the orphaned tool result is just ignored
612
const snapshot = mapHistoryForSnapshot(result);
613
expect(snapshot).toHaveLength(2);
614
});
615
616
it('handles empty assistant content blocks', () => {
617
const result = buildChatHistory(session([
618
userMsg('Hello'),
619
assistantMsg([]),
620
]));
621
622
const snapshot = mapHistoryForSnapshot(result);
623
// Empty content produces no parts, so no response turn is created.
624
// Only the request turn from the user message exists.
625
expect(snapshot).toHaveLength(1);
626
expect(snapshot[0]).toMatchObject({ type: 'request' });
627
});
628
629
it('handles whitespace-only user messages', () => {
630
const result = buildChatHistory(session([
631
userMsg(' \n\t '),
632
assistantMsg([{ type: 'text', text: 'Response' }]),
633
]));
634
635
const snapshot = mapHistoryForSnapshot(result);
636
// Whitespace-only should not create a request turn
637
expect(snapshot).toHaveLength(1);
638
expect(snapshot[0]).toMatchObject({ type: 'response' });
639
});
640
641
it('appends system messages as separated markdown parts in the preceding response', () => {
642
const systemMessage: StoredMessage = {
643
uuid: 'sys-1',
644
sessionId: 'test-session',
645
timestamp: new Date(),
646
parentUuid: null,
647
type: 'system',
648
message: { role: 'system' as const, content: 'Conversation compacted' },
649
};
650
651
const result = buildChatHistory(session([
652
userMsg('Hello'),
653
assistantMsg([{ type: 'text', text: 'Hi there' }]),
654
systemMessage,
655
userMsg('After compaction'),
656
assistantMsg([{ type: 'text', text: 'Continuing' }]),
657
]));
658
659
const snapshot = mapHistoryForSnapshot(result);
660
// Request, Response (with system appended), Request, Response
661
expect(snapshot).toHaveLength(4);
662
expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'Hello' });
663
expect(snapshot[1]).toMatchObject({ type: 'response' });
664
expect(snapshot[2]).toMatchObject({ type: 'request', prompt: 'After compaction' });
665
expect(snapshot[3]).toMatchObject({ type: 'response' });
666
667
// The system message should be appended as a second markdown part with separator
668
const responseParts = getResponseParts(snapshot, 1);
669
expect(responseParts).toHaveLength(2);
670
expect(responseParts[0]).toMatchObject({ type: 'markdown', content: 'Hi there' });
671
expect(responseParts[1]).toMatchObject({ type: 'markdown', content: '\n\n---\n\n*Conversation compacted*' });
672
});
673
674
it('creates a standalone response turn when system message appears with no preceding response', () => {
675
const systemMessage: StoredMessage = {
676
uuid: 'sys-1',
677
sessionId: 'test-session',
678
timestamp: new Date(),
679
parentUuid: null,
680
type: 'system',
681
message: { role: 'system' as const, content: 'Conversation compacted' },
682
};
683
684
const result = buildChatHistory(session([
685
systemMessage,
686
userMsg('After compaction'),
687
assistantMsg([{ type: 'text', text: 'Continuing' }]),
688
]));
689
690
const snapshot = mapHistoryForSnapshot(result);
691
// System response (standalone since no preceding parts), Request, Response
692
expect(snapshot).toHaveLength(3);
693
expect(snapshot[0]).toMatchObject({ type: 'response' });
694
expect(snapshot[1]).toMatchObject({ type: 'request', prompt: 'After compaction' });
695
expect(snapshot[2]).toMatchObject({ type: 'response' });
696
697
const systemParts = getResponseParts(snapshot, 0);
698
expect(systemParts).toHaveLength(1);
699
expect(systemParts[0]).toMatchObject({ type: 'markdown', content: '\n\n---\n\n*Conversation compacted*' });
700
});
701
});
702
703
// #endregion
704
705
// #region Subagent Tool Calls
706
707
describe('subagent tool calls', () => {
708
function subagentSession(agentId: string, messages: StoredMessage[], parentToolUseId?: string): ISubagentSession {
709
return {
710
agentId,
711
parentToolUseId,
712
messages,
713
timestamp: new Date(),
714
};
715
}
716
717
it('injects subagent tool calls after Task tool result', () => {
718
const taskToolUseId = 'toolu_task_001';
719
const subagentBashId = 'toolu_bash_sub_001';
720
721
const subagent = subagentSession('agent-abc', [
722
assistantMsg([{ type: 'tool_use', id: subagentBashId, name: 'Bash', input: { command: 'sleep 10' } }]),
723
toolResult(subagentBashId, 'command completed'),
724
], taskToolUseId);
725
726
const result = buildChatHistory(session([
727
userMsg('run a task'),
728
assistantMsg([{ type: 'tool_use', id: taskToolUseId, name: 'Task', input: { description: 'Run sleep', prompt: 'sleep 10' } }]),
729
toolResult(taskToolUseId, 'Task completed'),
730
assistantMsg([{ type: 'text', text: 'Done!' }]),
731
], [subagent]));
732
733
// Should have: request, response
734
expect(result).toHaveLength(2);
735
736
const response = result[1] as vscode.ChatResponseTurn2;
737
const toolParts = response.response.filter((p): p is vscode.ChatToolInvocationPart => p instanceof ChatToolInvocationPart);
738
739
// Should have Task tool + subagent Bash tool
740
expect(toolParts).toHaveLength(2);
741
742
// First tool is the Task itself
743
expect(toolParts[0].toolName).toBe('Task');
744
expect(toolParts[0].toolCallId).toBe(taskToolUseId);
745
expect(toolParts[0].isComplete).toBe(true);
746
747
// Second tool is the subagent's Bash call
748
expect(toolParts[1].toolName).toBe('Bash');
749
expect(toolParts[1].toolCallId).toBe(subagentBashId);
750
expect(toolParts[1].subAgentInvocationId).toBe(taskToolUseId);
751
expect(toolParts[1].isComplete).toBe(true);
752
});
753
754
it('handles Agent tool name (renamed from Task in Claude Code v2.1.63)', () => {
755
const agentToolUseId = 'toolu_agent_001';
756
const subagentBashId = 'toolu_bash_sub_agent';
757
758
const subagent = subagentSession('agent-new', [
759
assistantMsg([{ type: 'tool_use', id: subagentBashId, name: 'Bash', input: { command: 'ls' } }]),
760
toolResult(subagentBashId, 'files listed'),
761
], agentToolUseId);
762
763
const result = buildChatHistory(session([
764
userMsg('run an agent'),
765
assistantMsg([{ type: 'tool_use', id: agentToolUseId, name: 'Agent', input: { description: 'List files', prompt: 'ls' } }]),
766
toolResult(agentToolUseId, 'Agent completed'),
767
assistantMsg([{ type: 'text', text: 'Done!' }]),
768
], [subagent]));
769
770
expect(result).toHaveLength(2);
771
772
const response = result[1] as vscode.ChatResponseTurn2;
773
const toolParts = response.response.filter((p): p is vscode.ChatToolInvocationPart => p instanceof ChatToolInvocationPart);
774
775
expect(toolParts).toHaveLength(2);
776
expect(toolParts[0].toolName).toBe('Agent');
777
expect(toolParts[0].toolCallId).toBe(agentToolUseId);
778
expect(toolParts[1].toolName).toBe('Bash');
779
expect(toolParts[1].subAgentInvocationId).toBe(agentToolUseId);
780
});
781
782
it('sets subAgentInvocationId on all subagent tool calls', () => {
783
const taskToolUseId = 'toolu_task_002';
784
785
const subagent = subagentSession('agent-xyz', [
786
assistantMsg([{ type: 'tool_use', id: 'toolu_read_001', name: 'Read', input: { file_path: '/tmp/test.txt' } }]),
787
toolResult('toolu_read_001', 'file contents'),
788
assistantMsg([{ type: 'tool_use', id: 'toolu_edit_001', name: 'Edit', input: { file_path: '/tmp/test.txt', old_string: 'a', new_string: 'b' } }]),
789
toolResult('toolu_edit_001', 'edit applied'),
790
], taskToolUseId);
791
792
const result = buildChatHistory(session([
793
userMsg('edit a file'),
794
assistantMsg([{ type: 'tool_use', id: taskToolUseId, name: 'Task', input: { description: 'Edit file', prompt: 'edit the file' } }]),
795
toolResult(taskToolUseId, 'Edits done'),
796
assistantMsg([{ type: 'text', text: 'All done.' }]),
797
], [subagent]));
798
799
const response = result[1] as vscode.ChatResponseTurn2;
800
const toolParts = response.response.filter((p): p is vscode.ChatToolInvocationPart => p instanceof ChatToolInvocationPart);
801
802
// Task + 2 subagent tools (Read returns undefined from createFormattedToolInvocation for Edit/Write)
803
// Read should produce an invocation, Edit/Write return undefined
804
// Let's just check all subagent tools have the correct subAgentInvocationId
805
const subagentTools = toolParts.filter(t => t.subAgentInvocationId === taskToolUseId);
806
expect(subagentTools.length).toBeGreaterThan(0);
807
808
for (const tool of subagentTools) {
809
expect(tool.subAgentInvocationId).toBe(taskToolUseId);
810
}
811
});
812
813
it('handles session with no subagents (backward compatible)', () => {
814
const result = buildChatHistory(session([
815
userMsg('hello'),
816
assistantMsg([{ type: 'text', text: 'hi' }]),
817
]));
818
819
expect(result).toHaveLength(2);
820
});
821
822
it('handles Task tool with no matching subagent', () => {
823
const taskToolUseId = 'toolu_task_003';
824
825
const result = buildChatHistory(session([
826
userMsg('run a task'),
827
assistantMsg([{ type: 'tool_use', id: taskToolUseId, name: 'Task', input: { description: 'Do something', prompt: 'do it' } }]),
828
toolResult(taskToolUseId, 'Task completed'),
829
assistantMsg([{ type: 'text', text: 'Done!' }]),
830
]));
831
832
const response = result[1] as vscode.ChatResponseTurn2;
833
const toolParts = response.response.filter((p): p is vscode.ChatToolInvocationPart => p instanceof ChatToolInvocationPart);
834
835
// Only the Task tool itself, no subagent tools
836
expect(toolParts).toHaveLength(1);
837
expect(toolParts[0].toolName).toBe('Task');
838
});
839
840
it('handles multiple Task tools with different subagents', () => {
841
const task1Id = 'toolu_task_multi_1';
842
const task2Id = 'toolu_task_multi_2';
843
844
const subagent1 = subagentSession('agent-1', [
845
assistantMsg([{ type: 'tool_use', id: 'toolu_bash_1', name: 'Bash', input: { command: 'echo hello' } }]),
846
toolResult('toolu_bash_1', 'hello'),
847
], task1Id);
848
849
const subagent2 = subagentSession('agent-2', [
850
assistantMsg([{ type: 'tool_use', id: 'toolu_bash_2', name: 'Bash', input: { command: 'echo world' } }]),
851
toolResult('toolu_bash_2', 'world'),
852
], task2Id);
853
854
const result = buildChatHistory(session([
855
userMsg('run two tasks'),
856
assistantMsg([
857
{ type: 'tool_use', id: task1Id, name: 'Task', input: { description: 'Task 1', prompt: 'echo hello' } },
858
{ type: 'tool_use', id: task2Id, name: 'Task', input: { description: 'Task 2', prompt: 'echo world' } },
859
]),
860
toolResult(task1Id, 'Task 1 done'),
861
toolResult(task2Id, 'Task 2 done'),
862
assistantMsg([{ type: 'text', text: 'Both done!' }]),
863
], [subagent1, subagent2]));
864
865
const response = result[1] as vscode.ChatResponseTurn2;
866
const toolParts = response.response.filter((p): p is vscode.ChatToolInvocationPart => p instanceof ChatToolInvocationPart);
867
868
// 2 Task tools + 2 subagent Bash tools
869
expect(toolParts).toHaveLength(4);
870
871
// First two are the Task tools
872
expect(toolParts[0].toolName).toBe('Task');
873
expect(toolParts[1].toolName).toBe('Task');
874
875
// Subagent tools follow their respective Task results
876
const subagent1Tools = toolParts.filter(t => t.subAgentInvocationId === task1Id);
877
expect(subagent1Tools).toHaveLength(1);
878
expect(subagent1Tools[0].toolName).toBe('Bash');
879
880
const subagent2Tools = toolParts.filter(t => t.subAgentInvocationId === task2Id);
881
expect(subagent2Tools).toHaveLength(1);
882
expect(subagent2Tools[0].toolName).toBe('Bash');
883
});
884
885
it('correctly associates subagents when Task results are interleaved with non-Task results', () => {
886
const taskId = 'toolu_task_interleave';
887
const bashId = 'toolu_bash_main';
888
889
const subagent = subagentSession('agent-interleave', [
890
assistantMsg([{ type: 'tool_use', id: 'toolu_sub_glob', name: 'Glob', input: { pattern: '*.ts' } }]),
891
toolResult('toolu_sub_glob', 'found files'),
892
], taskId);
893
894
const result = buildChatHistory(session([
895
userMsg('do stuff'),
896
assistantMsg([
897
{ type: 'tool_use', id: bashId, name: 'Bash', input: { command: 'echo hi' } },
898
{ type: 'tool_use', id: taskId, name: 'Task', input: { description: 'Sub task', prompt: 'find files' } },
899
]),
900
// Non-Task tool result first, then Task result — separate StoredMessages
901
toolResult(bashId, 'hi'),
902
toolResult(taskId, 'Sub task done'),
903
assistantMsg([{ type: 'text', text: 'All done.' }]),
904
], [subagent]));
905
906
const response = result[1] as vscode.ChatResponseTurn2;
907
const toolParts = response.response.filter((p): p is vscode.ChatToolInvocationPart => p instanceof ChatToolInvocationPart);
908
909
// Bash (main) + Task + subagent Glob = 3 tools
910
expect(toolParts).toHaveLength(3);
911
expect(toolParts[0].toolName).toBe('Bash');
912
expect(toolParts[0].subAgentInvocationId).toBeUndefined();
913
914
expect(toolParts[1].toolName).toBe('Task');
915
expect(toolParts[1].subAgentInvocationId).toBeUndefined();
916
917
// Subagent tool is correctly linked to the Task, not the Bash tool
918
const subagentTools = toolParts.filter(t => t.subAgentInvocationId === taskId);
919
expect(subagentTools).toHaveLength(1);
920
expect(subagentTools[0].toolName).toBe('Glob');
921
});
922
923
it('handles mixed Agent and Task tool names in same session', () => {
924
const taskId = 'toolu_task_old';
925
const agentId = 'toolu_agent_new';
926
927
const subagent1 = subagentSession('old-agent', [
928
assistantMsg([{ type: 'tool_use', id: 'toolu_bash_old', name: 'Bash', input: { command: 'echo old' } }]),
929
toolResult('toolu_bash_old', 'old'),
930
], taskId);
931
932
const subagent2 = subagentSession('new-agent', [
933
assistantMsg([{ type: 'tool_use', id: 'toolu_bash_new', name: 'Bash', input: { command: 'echo new' } }]),
934
toolResult('toolu_bash_new', 'new'),
935
], agentId);
936
937
const result = buildChatHistory(session([
938
userMsg('do stuff'),
939
assistantMsg([
940
{ type: 'tool_use', id: taskId, name: 'Task', input: { description: 'Old task', prompt: 'old' } },
941
{ type: 'tool_use', id: agentId, name: 'Agent', input: { description: 'New agent', prompt: 'new' } },
942
]),
943
toolResult(taskId, 'Old done'),
944
toolResult(agentId, 'New done'),
945
assistantMsg([{ type: 'text', text: 'Both done.' }]),
946
], [subagent1, subagent2]));
947
948
const response = result[1] as vscode.ChatResponseTurn2;
949
const toolParts = response.response.filter((p): p is vscode.ChatToolInvocationPart => p instanceof ChatToolInvocationPart);
950
951
// Task + its subagent Bash + Agent + its subagent Bash = 4
952
expect(toolParts).toHaveLength(4);
953
expect(toolParts[0].toolName).toBe('Task');
954
expect(toolParts[1].toolName).toBe('Agent');
955
expect(toolParts.filter(t => t.subAgentInvocationId === taskId)).toHaveLength(1);
956
expect(toolParts.filter(t => t.subAgentInvocationId === agentId)).toHaveLength(1);
957
});
958
959
it('excludes subagents without parentToolUseId from injection', () => {
960
const taskToolUseId = 'toolu_task_orphan';
961
962
const orphanSubagent = subagentSession('orphan-agent', [
963
assistantMsg([{ type: 'tool_use', id: 'toolu_bash_orphan', name: 'Bash', input: { command: 'echo orphan' } }]),
964
toolResult('toolu_bash_orphan', 'orphan output'),
965
]);
966
967
const result = buildChatHistory(session([
968
userMsg('run a task'),
969
assistantMsg([{ type: 'tool_use', id: taskToolUseId, name: 'Agent', input: { description: 'Do work', prompt: 'work' } }]),
970
toolResult(taskToolUseId, 'Done'),
971
assistantMsg([{ type: 'text', text: 'Finished.' }]),
972
], [orphanSubagent]));
973
974
const response = result[1] as vscode.ChatResponseTurn2;
975
const toolParts = response.response.filter((p): p is vscode.ChatToolInvocationPart => p instanceof ChatToolInvocationPart);
976
977
// Only the Agent tool itself, no subagent tools injected
978
expect(toolParts).toHaveLength(1);
979
expect(toolParts[0].toolName).toBe('Agent');
980
expect(toolParts[0].subAgentInvocationId).toBeUndefined();
981
});
982
});
983
984
// #endregion
985
986
// #region Image References
987
988
describe('image references', () => {
989
it('creates request turn with image references from base64 image blocks', () => {
990
const result = buildChatHistory(session([
991
userMsg([
992
{
993
type: 'image',
994
source: {
995
type: 'base64',
996
media_type: 'image/png',
997
data: 'iVBORw0KGgo=',
998
},
999
} as Anthropic.ImageBlockParam,
1000
{ type: 'text', text: 'What is this?' },
1001
]),
1002
assistantMsg([{ type: 'text', text: 'An image.' }]),
1003
]));
1004
1005
expect(result).toHaveLength(2);
1006
const requestTurn = result[0] as vscode.ChatRequestTurn2;
1007
expect(requestTurn.prompt).toBe('What is this?');
1008
expect(requestTurn.references).toHaveLength(1);
1009
1010
const ref = requestTurn.references[0];
1011
expect(ref.value).toBeInstanceOf(ChatReferenceBinaryData);
1012
const binaryData = ref.value as InstanceType<typeof ChatReferenceBinaryData>;
1013
expect(binaryData.mimeType).toBe('image/png');
1014
});
1015
1016
it('reconstructs binary data from base64 in image references', async () => {
1017
const result = buildChatHistory(session([
1018
userMsg([
1019
{
1020
type: 'image',
1021
source: {
1022
type: 'base64',
1023
media_type: 'image/jpeg',
1024
data: Buffer.from([0xFF, 0xD8]).toString('base64'),
1025
},
1026
} as Anthropic.ImageBlockParam,
1027
{ type: 'text', text: 'Describe' },
1028
]),
1029
]));
1030
1031
const requestTurn = result[0] as vscode.ChatRequestTurn2;
1032
const binaryData = requestTurn.references[0].value as InstanceType<typeof ChatReferenceBinaryData>;
1033
const data = await binaryData.data();
1034
expect(Buffer.from(data)).toEqual(Buffer.from([0xFF, 0xD8]));
1035
});
1036
1037
it('creates request turn with multiple image references', () => {
1038
const result = buildChatHistory(session([
1039
userMsg([
1040
{
1041
type: 'image',
1042
source: { type: 'base64', media_type: 'image/png', data: 'aQ==' },
1043
} as Anthropic.ImageBlockParam,
1044
{
1045
type: 'image',
1046
source: { type: 'base64', media_type: 'image/jpeg', data: 'bQ==' },
1047
} as Anthropic.ImageBlockParam,
1048
{ type: 'text', text: 'Compare these' },
1049
]),
1050
]));
1051
1052
const requestTurn = result[0] as vscode.ChatRequestTurn2;
1053
expect(requestTurn.references).toHaveLength(2);
1054
expect((requestTurn.references[0].value as InstanceType<typeof ChatReferenceBinaryData>).mimeType).toBe('image/png');
1055
expect((requestTurn.references[1].value as InstanceType<typeof ChatReferenceBinaryData>).mimeType).toBe('image/jpeg');
1056
});
1057
1058
it('creates request turn for image-only messages with no text', () => {
1059
const result = buildChatHistory(session([
1060
userMsg([
1061
{
1062
type: 'image',
1063
source: { type: 'base64', media_type: 'image/png', data: 'aQ==' },
1064
} as Anthropic.ImageBlockParam,
1065
]),
1066
]));
1067
1068
// Even with no text, should produce a request turn because of the image
1069
expect(result).toHaveLength(1);
1070
const requestTurn = result[0] as vscode.ChatRequestTurn2;
1071
expect(requestTurn.references).toHaveLength(1);
1072
});
1073
1074
it('creates URI reference for URL-based image blocks', () => {
1075
const result = buildChatHistory(session([
1076
userMsg([
1077
{
1078
type: 'image',
1079
source: { type: 'url', url: 'https://example.com/img.png' },
1080
} as Anthropic.ImageBlockParam,
1081
{ type: 'text', text: 'What is this?' },
1082
]),
1083
]));
1084
1085
const requestTurn = result[0] as vscode.ChatRequestTurn2;
1086
expect(requestTurn.references).toHaveLength(1);
1087
const ref = requestTurn.references[0];
1088
expect(URI.isUri(ref.value)).toBe(true);
1089
expect((ref.value as URI).toString()).toBe('https://example.com/img.png');
1090
});
1091
});
1092
1093
// #endregion
1094
1095
// #region Slash Command Messages
1096
1097
describe('slash command messages', () => {
1098
it('renders /compact command as request turn with stdout as response turn', () => {
1099
const result = buildChatHistory(session([
1100
userMsg('Hello'),
1101
assistantMsg([{ type: 'text', text: 'Hi there' }]),
1102
// Command message with <command-name> tags
1103
userMsg([
1104
{ type: 'text', text: '<system-reminder>\nContext.\n</system-reminder>' },
1105
{ type: 'text', text: '<command-name>/compact</command-name>\n <command-message>compact</command-message>\n <command-args></command-args>' },
1106
]),
1107
// Command stdout in a separate user message
1108
userMsg('<local-command-stdout>Compacted PreCompact [callback] completed successfully</local-command-stdout>'),
1109
]));
1110
1111
const snapshot = mapHistoryForSnapshot(result);
1112
// Request, Response, Command Request, Command Response
1113
expect(snapshot).toHaveLength(4);
1114
expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'Hello' });
1115
expect(snapshot[1]).toMatchObject({ type: 'response' });
1116
expect(snapshot[2]).toMatchObject({ type: 'request', prompt: '/compact' });
1117
expect(snapshot[3]).toMatchObject({
1118
type: 'response',
1119
parts: [{ type: 'markdown', content: 'Compacted PreCompact [callback] completed successfully' }],
1120
});
1121
});
1122
1123
it('renders /init command as request turn without stdout', () => {
1124
const result = buildChatHistory(session([
1125
// Init command message (string format from real fixture)
1126
userMsg('<command-message>init is analyzing your codebase…</command-message>\n<command-name>/init</command-name>'),
1127
assistantMsg([{ type: 'text', text: 'Analyzing...' }]),
1128
]));
1129
1130
const snapshot = mapHistoryForSnapshot(result);
1131
expect(snapshot).toHaveLength(2);
1132
expect(snapshot[0]).toMatchObject({ type: 'request', prompt: '/init' });
1133
expect(snapshot[1]).toMatchObject({ type: 'response' });
1134
});
1135
1136
it('finalizes pending response before command request turn', () => {
1137
const result = buildChatHistory(session([
1138
userMsg('Do task'),
1139
assistantMsg([
1140
{ type: 'text', text: 'Working...' },
1141
{ type: 'tool_use', id: 't1', name: 'bash', input: { command: 'echo done' } },
1142
]),
1143
toolResult('t1', 'done'),
1144
assistantMsg([{ type: 'text', text: 'Finished.' }]),
1145
// Now the user runs /compact
1146
userMsg([
1147
{ type: 'text', text: '<command-name>/compact</command-name>\n<command-message>compact</command-message>\n<command-args></command-args>' },
1148
]),
1149
userMsg('<local-command-stdout>Compacted successfully</local-command-stdout>'),
1150
]));
1151
1152
const snapshot = mapHistoryForSnapshot(result);
1153
// Request, Response (with tool + text), Command Request, Command Response
1154
expect(snapshot).toHaveLength(4);
1155
expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'Do task' });
1156
expect(snapshot[1]).toMatchObject({ type: 'response' });
1157
expect(snapshot[2]).toMatchObject({ type: 'request', prompt: '/compact' });
1158
expect(snapshot[3]).toMatchObject({
1159
type: 'response',
1160
parts: [{ type: 'markdown', content: 'Compacted successfully' }],
1161
});
1162
});
1163
1164
it('handles command without stdout (no response turn emitted)', () => {
1165
const result = buildChatHistory(session([
1166
userMsg([
1167
{ type: 'text', text: '<command-name>/help</command-name>\n<command-message>help</command-message>\n<command-args></command-args>' },
1168
]),
1169
]));
1170
1171
const snapshot = mapHistoryForSnapshot(result);
1172
// Only the command request turn, no response
1173
expect(snapshot).toHaveLength(1);
1174
expect(snapshot[0]).toMatchObject({ type: 'request', prompt: '/help' });
1175
});
1176
1177
it('renders full compact sequence: system message, command, and stdout', () => {
1178
const systemMessage: StoredMessage = {
1179
uuid: 'sys-1',
1180
sessionId: 'test-session',
1181
timestamp: new Date(),
1182
parentUuid: null,
1183
type: 'system',
1184
message: { role: 'system' as const, content: 'Conversation compacted' },
1185
};
1186
1187
const result = buildChatHistory(session([
1188
userMsg('Hello'),
1189
assistantMsg([{ type: 'text', text: 'Hi there' }]),
1190
// System compact_boundary
1191
systemMessage,
1192
// /compact command
1193
userMsg([
1194
{ type: 'text', text: '<system-reminder>\nContext.\n</system-reminder>' },
1195
{ type: 'text', text: '<command-name>/compact</command-name>\n<command-message>compact</command-message>\n<command-args></command-args>' },
1196
]),
1197
// Stdout
1198
userMsg('<local-command-stdout>Compacted successfully</local-command-stdout>'),
1199
// In real sessions, a synthetic assistant message separates the command from the next turn
1200
assistantMsg([{ type: 'text', text: 'No response requested.' }], '<synthetic>'),
1201
// Conversation continues
1202
userMsg('What were we talking about?'),
1203
assistantMsg([{ type: 'text', text: 'We were discussing...' }]),
1204
]));
1205
1206
const snapshot = mapHistoryForSnapshot(result);
1207
// Request, Response (with system appended), Command Request, Command Response, Request, Response
1208
expect(snapshot).toHaveLength(6);
1209
expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'Hello' });
1210
expect(snapshot[1]).toMatchObject({ type: 'response' });
1211
expect(snapshot[2]).toMatchObject({ type: 'request', prompt: '/compact' });
1212
expect(snapshot[3]).toMatchObject({ type: 'response', parts: [{ type: 'markdown', content: 'Compacted successfully' }] });
1213
expect(snapshot[4]).toMatchObject({ type: 'request', prompt: 'What were we talking about?' });
1214
expect(snapshot[5]).toMatchObject({ type: 'response' });
1215
1216
// The first response should have the assistant text + system separator
1217
const responseParts = getResponseParts(snapshot, 1);
1218
expect(responseParts).toHaveLength(2);
1219
expect(responseParts[0]).toMatchObject({ type: 'markdown', content: 'Hi there' });
1220
expect(responseParts[1]).toMatchObject({ type: 'markdown', content: '\n\n---\n\n*Conversation compacted*' });
1221
});
1222
});
1223
1224
// #endregion
1225
1226
// #region Synthetic Message Filtering
1227
1228
describe('Synthetic Message Filtering', () => {
1229
it('filters out synthetic assistant messages', () => {
1230
const s = session([
1231
userMsg('Hello'),
1232
assistantMsg([{ type: 'text', text: 'Hi there!' }]),
1233
userMsg('Do something'),
1234
assistantMsg([{ type: 'text', text: 'No response requested.' }], '<synthetic>'),
1235
]);
1236
1237
const result = buildChatHistory(s);
1238
const snapshot = mapHistoryForSnapshot(result);
1239
1240
// The synthetic message should be filtered out entirely
1241
expect(snapshot).toEqual([
1242
{ type: 'request', prompt: 'Hello' },
1243
{ type: 'response', parts: [{ type: 'markdown', content: 'Hi there!' }] },
1244
{ type: 'request', prompt: 'Do something' },
1245
// No response from the synthetic message
1246
]);
1247
});
1248
1249
it('preserves non-synthetic assistant messages around synthetic ones', () => {
1250
const s = session([
1251
userMsg('Hello'),
1252
assistantMsg([{ type: 'text', text: 'Real response' }]),
1253
assistantMsg([{ type: 'text', text: 'No response requested.' }], '<synthetic>'),
1254
]);
1255
1256
const result = buildChatHistory(s);
1257
const snapshot = mapHistoryForSnapshot(result);
1258
1259
expect(snapshot).toEqual([
1260
{ type: 'request', prompt: 'Hello' },
1261
{ type: 'response', parts: [{ type: 'markdown', content: 'Real response' }] },
1262
]);
1263
});
1264
1265
it('filters synthetic messages in the middle of a tool loop', () => {
1266
const result = buildChatHistory(session([
1267
userMsg('Do task'),
1268
assistantMsg([
1269
{ type: 'text', text: 'Working...' },
1270
{ type: 'tool_use', id: 't1', name: 'bash', input: { command: 'echo hi' } },
1271
]),
1272
toolResult('t1', 'hi'),
1273
// Synthetic message mid-loop (e.g., from an abort)
1274
assistantMsg([{ type: 'text', text: 'No response requested.' }], '<synthetic>'),
1275
// Real assistant continues
1276
assistantMsg([{ type: 'text', text: 'Done.' }]),
1277
]));
1278
1279
const snapshot = mapHistoryForSnapshot(result);
1280
expect(snapshot).toHaveLength(2);
1281
expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'Do task' });
1282
1283
// The response should contain the tool call, the text before, and the text after — but not the synthetic message
1284
const parts = getResponseParts(snapshot, 1);
1285
const markdownParts = parts.filter(p => p.type === 'markdown');
1286
expect(markdownParts).toEqual([
1287
{ type: 'markdown', content: 'Working...' },
1288
{ type: 'markdown', content: 'Done.' },
1289
]);
1290
// No "No response requested." in any part
1291
expect(parts.every(p => p.type !== 'markdown' || (p as Record<string, unknown>).content !== 'No response requested.')).toBe(true);
1292
});
1293
});
1294
1295
// #endregion
1296
1297
// #region Model ID Resolution
1298
1299
describe('model ID resolution via parseClaudeModelId', () => {
1300
it('converts SDK model ID to endpoint format on request turns', () => {
1301
const s = session([
1302
userMsg('Hello'),
1303
assistantMsg([{ type: 'text', text: 'Hi' }], 'claude-opus-4-5-20251101'),
1304
]);
1305
1306
const result = buildChatHistory(s);
1307
1308
const requestTurn = result[0] as vscode.ChatRequestTurn2;
1309
expect(requestTurn).toBeInstanceOf(ChatRequestTurn2);
1310
expect(requestTurn.modelId).toBe('claude-opus-4.5');
1311
});
1312
1313
it('falls back to raw model ID when parsing fails', () => {
1314
const s = session([
1315
userMsg('Hello'),
1316
assistantMsg([{ type: 'text', text: 'Hi' }], 'unknown-model-id'),
1317
]);
1318
1319
const result = buildChatHistory(s);
1320
1321
const requestTurn = result[0] as vscode.ChatRequestTurn2;
1322
expect(requestTurn.modelId).toBe('unknown-model-id');
1323
});
1324
1325
it('skips synthetic assistant messages when resolving model ID', () => {
1326
const s = session([
1327
userMsg('Hello'),
1328
assistantMsg([{ type: 'text', text: 'No response requested.' }], SYNTHETIC_MODEL_ID),
1329
assistantMsg([{ type: 'text', text: 'Real response' }], 'claude-sonnet-4-20250514'),
1330
]);
1331
1332
const result = buildChatHistory(s);
1333
1334
const requestTurn = result[0] as vscode.ChatRequestTurn2;
1335
expect(requestTurn.modelId).toBe('claude-sonnet-4');
1336
});
1337
1338
it('returns undefined modelId when no assistant message follows', () => {
1339
const s = session([
1340
userMsg('Hello'),
1341
]);
1342
1343
const result = buildChatHistory(s);
1344
1345
const requestTurn = result[0] as vscode.ChatRequestTurn2;
1346
expect(requestTurn.modelId).toBeUndefined();
1347
});
1348
1349
it('uses the correct model for each request in multi-turn conversations', () => {
1350
const s = session([
1351
userMsg('First question'),
1352
assistantMsg([{ type: 'text', text: 'First answer' }], 'claude-sonnet-4-20250514'),
1353
userMsg('Second question'),
1354
assistantMsg([{ type: 'text', text: 'Second answer' }], 'claude-opus-4-5-20251101'),
1355
]);
1356
1357
const result = buildChatHistory(s);
1358
1359
const firstRequest = result[0] as vscode.ChatRequestTurn2;
1360
expect(firstRequest.modelId).toBe('claude-sonnet-4');
1361
1362
const secondRequest = result[2] as vscode.ChatRequestTurn2;
1363
expect(secondRequest.modelId).toBe('claude-opus-4.5');
1364
});
1365
1366
it('tags command request turns with converted model ID', () => {
1367
const s = session([
1368
userMsg('<command-name>/compact</command-name><command-message>compact</command-message>'),
1369
assistantMsg([{ type: 'text', text: 'Compacted.' }], 'claude-sonnet-4-20250514'),
1370
]);
1371
1372
const result = buildChatHistory(s);
1373
1374
const commandTurn = result[0] as vscode.ChatRequestTurn2;
1375
expect(commandTurn.prompt).toBe('/compact');
1376
expect(commandTurn.modelId).toBe('claude-sonnet-4');
1377
});
1378
1379
it('preserves endpoint-format model IDs as-is', () => {
1380
const s = session([
1381
userMsg('Hello'),
1382
assistantMsg([{ type: 'text', text: 'Hi' }], 'claude-opus-4.5'),
1383
]);
1384
1385
const result = buildChatHistory(s);
1386
1387
const requestTurn = result[0] as vscode.ChatRequestTurn2;
1388
expect(requestTurn.modelId).toBe('claude-opus-4.5');
1389
});
1390
});
1391
1392
// #endregion
1393
1394
// #region Response Details (model footer)
1395
1396
describe('response details via getModelDetails', () => {
1397
// Returns the raw model id back so we can spot-check exactly which id the
1398
// builder fed into the lookup for each response turn.
1399
const echoLookup = (id: string) => `details:${id}`;
1400
1401
it('omits details when no lookup is provided (regression)', () => {
1402
const s = session([
1403
userMsg('Hello'),
1404
assistantMsg([{ type: 'text', text: 'Hi' }], 'claude-opus-4-5-20251101'),
1405
]);
1406
1407
const result = buildChatHistory(s);
1408
1409
const responseTurn = result[1] as vscode.ChatResponseTurn2;
1410
expect(responseTurn.result).toEqual({});
1411
});
1412
1413
it('attaches details from the assistant model id to the response turn', () => {
1414
const s = session([
1415
userMsg('Hello'),
1416
assistantMsg([{ type: 'text', text: 'Hi' }], 'claude-opus-4-5-20251101'),
1417
]);
1418
1419
const result = buildChatHistory(s, echoLookup);
1420
1421
const responseTurn = result[1] as vscode.ChatResponseTurn2;
1422
expect(responseTurn.result).toEqual({ details: 'details:claude-opus-4-5-20251101' });
1423
});
1424
1425
it('omits details when the lookup returns undefined', () => {
1426
const s = session([
1427
userMsg('Hello'),
1428
assistantMsg([{ type: 'text', text: 'Hi' }], 'unknown-model-id'),
1429
]);
1430
1431
const result = buildChatHistory(s, () => undefined);
1432
1433
const responseTurn = result[1] as vscode.ChatResponseTurn2;
1434
expect(responseTurn.result).toEqual({});
1435
});
1436
1437
it('attributes per-response model details across model switches', () => {
1438
const s = session([
1439
userMsg('First'),
1440
assistantMsg([{ type: 'text', text: 'A1' }], 'claude-sonnet-4-20250514'),
1441
userMsg('Second'),
1442
assistantMsg([{ type: 'text', text: 'A2' }], 'claude-opus-4-5-20251101'),
1443
]);
1444
1445
const result = buildChatHistory(s, echoLookup);
1446
1447
const firstResponse = result[1] as vscode.ChatResponseTurn2;
1448
const secondResponse = result[3] as vscode.ChatResponseTurn2;
1449
expect(firstResponse.result).toEqual({ details: 'details:claude-sonnet-4-20250514' });
1450
expect(secondResponse.result).toEqual({ details: 'details:claude-opus-4-5-20251101' });
1451
});
1452
1453
it('uses the last non-synthetic assistant model in a multi-message response group', () => {
1454
const s = session([
1455
userMsg('Run'),
1456
assistantMsg([{ type: 'tool_use', id: 't1', name: 'bash', input: {} }], 'claude-sonnet-4-20250514'),
1457
toolResult('t1', 'done'),
1458
// Final assistant message uses a different model — that's the one we attribute.
1459
assistantMsg([{ type: 'text', text: 'OK' }], 'claude-opus-4-5-20251101'),
1460
]);
1461
1462
const result = buildChatHistory(s, echoLookup);
1463
1464
const responseTurn = result[1] as vscode.ChatResponseTurn2;
1465
expect(responseTurn.result).toEqual({ details: 'details:claude-opus-4-5-20251101' });
1466
});
1467
1468
it('does not bleed model ids across response groups when lookup is undefined for one', () => {
1469
const s = session([
1470
userMsg('First'),
1471
assistantMsg([{ type: 'text', text: 'A1' }], 'claude-sonnet-4-20250514'),
1472
userMsg('Second'),
1473
assistantMsg([{ type: 'text', text: 'A2' }], 'unknown-model-id'),
1474
]);
1475
1476
const result = buildChatHistory(s, id => id === 'claude-sonnet-4-20250514' ? 'Sonnet' : undefined);
1477
1478
const firstResponse = result[1] as vscode.ChatResponseTurn2;
1479
const secondResponse = result[3] as vscode.ChatResponseTurn2;
1480
expect(firstResponse.result).toEqual({ details: 'Sonnet' });
1481
expect(secondResponse.result).toEqual({});
1482
});
1483
1484
it('ignores synthetic assistant messages when picking the response model id', () => {
1485
const s = session([
1486
userMsg('Hello'),
1487
assistantMsg([{ type: 'text', text: 'Real reply' }], 'claude-sonnet-4-20250514'),
1488
// A trailing synthetic message (e.g. cancellation marker) must not
1489
// override the real model id we just observed.
1490
assistantMsg([{ type: 'text', text: 'No response requested.' }], SYNTHETIC_MODEL_ID),
1491
]);
1492
1493
const result = buildChatHistory(s, echoLookup);
1494
1495
const responseTurn = result[1] as vscode.ChatResponseTurn2;
1496
expect(responseTurn.result).toEqual({ details: 'details:claude-sonnet-4-20250514' });
1497
});
1498
1499
it('attaches details to slash-command response turns', () => {
1500
const s = session([
1501
userMsg('<command-name>/compact</command-name><command-message>compact</command-message>'),
1502
assistantMsg([{ type: 'text', text: 'Compacted.' }], 'claude-sonnet-4-20250514'),
1503
]);
1504
1505
const result = buildChatHistory(s, echoLookup);
1506
1507
// [request, response]
1508
const responseTurn = result[1] as vscode.ChatResponseTurn2;
1509
expect(responseTurn.result).toEqual({ details: 'details:claude-sonnet-4-20250514' });
1510
});
1511
});
1512
1513
// #endregion
1514
});
1515
1516