Blame Raw
Cohee · 51ad27fb · · 1263 lines (50.4 KB)
2 contributors
1import { describe, test, expect, jest, beforeAll } from '@jest/globals';
2
3jest.unstable_mockModule('../src/util.js', () => ({
4 getConfigValue: jest.fn((_key, defaultValue) => defaultValue),
5 tryParse: (str) => { try { return JSON.parse(str); } catch { return undefined; } },
6}));
7
8/** @type {import('../src/prompt-converters.js')} */
9let mod;
10
11beforeAll(async () => {
12 mod = await import('../src/prompt-converters.js');
13});
14
15function makeNames(charName = '', userName = '', groupNames = []) {
16 return {
17 charName,
18 userName,
19 groupNames,
20 startsWithGroupName(message) {
21 return this.groupNames.some(name => message.startsWith(`${name}: `));
22 },
23 };
24}
25
26
27describe('addAssistantPrefix', () => {
28 test('returns empty array unchanged', () => {
29 expect(mod.addAssistantPrefix([], [], 'prefix')).toEqual([]);
30 });
31
32 test('sets property on last message when it is assistant and no tools', () => {
33 const prompt = [
34 { role: 'user', content: 'hi' },
35 { role: 'assistant', content: 'hello' },
36 ];
37 mod.addAssistantPrefix(prompt, [], 'prefix');
38 expect(prompt[1].prefix).toBe(true);
39 });
40
41 test('does not set property when tools are present', () => {
42 const prompt = [
43 { role: 'user', content: 'hi' },
44 { role: 'assistant', content: 'hello' },
45 ];
46 mod.addAssistantPrefix(prompt, [{ type: 'function' }], 'prefix');
47 expect(prompt[1].prefix).toBeUndefined();
48 });
49
50 test('does not set property when last message is not assistant', () => {
51 const prompt = [
52 { role: 'assistant', content: 'hello' },
53 { role: 'user', content: 'hi' },
54 ];
55 mod.addAssistantPrefix(prompt, [], 'prefix');
56 expect(prompt[0].prefix).toBeUndefined();
57 expect(prompt[1].prefix).toBeUndefined();
58 });
59
60 test('does not set property when a tool role message exists in prompt', () => {
61 const prompt = [
62 { role: 'user', content: 'hi' },
63 { role: 'tool', content: 'result' },
64 { role: 'assistant', content: 'hello' },
65 ];
66 mod.addAssistantPrefix(prompt, [], 'prefix');
67 expect(prompt[2].prefix).toBeUndefined();
68 });
69});
70
71
72describe('convertTextCompletionPrompt', () => {
73 test('passes through string input unchanged', () => {
74 expect(mod.convertTextCompletionPrompt('raw prompt')).toBe('raw prompt');
75 });
76
77 test('converts basic message array to text format', () => {
78 const messages = [
79 { role: 'system', content: 'You are helpful.' },
80 { role: 'user', content: 'Hello' },
81 { role: 'assistant', content: 'Hi there' },
82 ];
83 const result = mod.convertTextCompletionPrompt(messages);
84 expect(result).toBe(
85 'System: You are helpful.\nuser: Hello\nassistant: Hi there\nassistant:',
86 );
87 });
88
89 test('uses name instead of role for named system messages', () => {
90 const messages = [
91 { role: 'system', name: 'Narrator', content: 'Once upon a time' },
92 ];
93 const result = mod.convertTextCompletionPrompt(messages);
94 expect(result).toBe('Narrator: Once upon a time\nassistant:');
95 });
96
97 test('handles empty array', () => {
98 expect(mod.convertTextCompletionPrompt([])).toBe('\nassistant:');
99 });
100});
101
102
103describe('calculateClaudeBudgetTokens', () => {
104 describe('adaptive model (Opus 4.6+)', () => {
105 test('auto returns null', () => {
106 expect(mod.calculateClaudeBudgetTokens(8192, 'auto', true, true)).toBeNull();
107 });
108
109 test('min returns "low"', () => expect(mod.calculateClaudeBudgetTokens(8192, 'min', true, true)).toBe('low'));
110 test('low returns "low"', () => expect(mod.calculateClaudeBudgetTokens(8192, 'low', true, true)).toBe('low'));
111 test('medium returns "medium"', () => expect(mod.calculateClaudeBudgetTokens(8192, 'medium', true, true)).toBe('medium'));
112 test('high returns "high"', () => expect(mod.calculateClaudeBudgetTokens(8192, 'high', true, true)).toBe('high'));
113 test('max returns "max"', () => expect(mod.calculateClaudeBudgetTokens(8192, 'max', true, true)).toBe('max'));
114 });
115
116 describe('traditional model', () => {
117 test('auto returns null', () => {
118 expect(mod.calculateClaudeBudgetTokens(8192, 'auto', true, false)).toBeNull();
119 });
120
121 test('min returns 1024 regardless of maxTokens', () => {
122 expect(mod.calculateClaudeBudgetTokens(100, 'min', true, false)).toBe(1024);
123 expect(mod.calculateClaudeBudgetTokens(100000, 'min', true, false)).toBe(1024);
124 });
125
126 test('low is 10% of maxTokens, floored to 1024', () => {
127 expect(mod.calculateClaudeBudgetTokens(50000, 'low', true, false)).toBe(5000);
128 // 10% of 5000 = 500, but floor is 1024
129 expect(mod.calculateClaudeBudgetTokens(5000, 'low', true, false)).toBe(1024);
130 });
131
132 test('medium is 25% of maxTokens', () => {
133 expect(mod.calculateClaudeBudgetTokens(40000, 'medium', true, false)).toBe(10000);
134 });
135
136 test('high is 50% of maxTokens', () => {
137 expect(mod.calculateClaudeBudgetTokens(40000, 'high', true, false)).toBe(20000);
138 });
139
140 test('max is 95% of maxTokens', () => {
141 expect(mod.calculateClaudeBudgetTokens(40000, 'max', true, false)).toBe(38000);
142 });
143
144 test('non-streaming caps at 21333', () => {
145 expect(mod.calculateClaudeBudgetTokens(100000, 'max', false, false)).toBe(21333);
146 });
147
148 test('streaming does not cap at 21333', () => {
149 expect(mod.calculateClaudeBudgetTokens(100000, 'max', true, false)).toBe(95000);
150 });
151 });
152});
153
154
155describe('calculateGoogleBudgetTokens', () => {
156 test('returns null for unrecognized model', () => {
157 expect(mod.calculateGoogleBudgetTokens(8192, 'medium', 'gpt-4')).toBeNull();
158 });
159
160 describe('gemini-3 flash', () => {
161 test('auto returns null', () => expect(mod.calculateGoogleBudgetTokens(8192, 'auto', 'gemini-3.5-flash')).toBeNull());
162 test('min returns minimal', () => expect(mod.calculateGoogleBudgetTokens(8192, 'min', 'gemini-3.5-flash')).toBe('minimal'));
163 test('low returns low', () => expect(mod.calculateGoogleBudgetTokens(8192, 'low', 'gemini-3.5-flash')).toBe('low'));
164 test('medium returns medium', () => expect(mod.calculateGoogleBudgetTokens(8192, 'medium', 'gemini-3.5-flash')).toBe('medium'));
165 test('high returns high', () => expect(mod.calculateGoogleBudgetTokens(8192, 'high', 'gemini-3.5-flash')).toBe('high'));
166 test('max returns high', () => expect(mod.calculateGoogleBudgetTokens(8192, 'max', 'gemini-3.5-flash')).toBe('high'));
167 });
168
169 describe('gemini-3 pro', () => {
170 test('auto returns null', () => expect(mod.calculateGoogleBudgetTokens(8192, 'auto', 'gemini-3.0-pro')).toBeNull());
171 test('min returns low', () => expect(mod.calculateGoogleBudgetTokens(8192, 'min', 'gemini-3.0-pro')).toBe('low'));
172 test('low returns low', () => expect(mod.calculateGoogleBudgetTokens(8192, 'low', 'gemini-3.0-pro')).toBe('low'));
173 test('medium returns low', () => expect(mod.calculateGoogleBudgetTokens(8192, 'medium', 'gemini-3.0-pro')).toBe('low'));
174 test('high returns high', () => expect(mod.calculateGoogleBudgetTokens(8192, 'high', 'gemini-3.0-pro')).toBe('high'));
175 test('max returns high', () => expect(mod.calculateGoogleBudgetTokens(8192, 'max', 'gemini-3.0-pro')).toBe('high'));
176 });
177
178 describe('flash (non-gemini-3)', () => {
179 test('auto returns -1', () => {
180 expect(mod.calculateGoogleBudgetTokens(8192, 'auto', 'gemini-2.0-flash')).toBe(-1);
181 });
182
183 test('min returns 0', () => {
184 expect(mod.calculateGoogleBudgetTokens(8192, 'min', 'gemini-2.0-flash')).toBe(0);
185 });
186
187 test('caps at 24576', () => {
188 expect(mod.calculateGoogleBudgetTokens(500000, 'max', 'gemini-2.0-flash')).toBe(24576);
189 });
190 });
191
192 describe('flash-lite', () => {
193 test('min returns 0', () => {
194 expect(mod.calculateGoogleBudgetTokens(8192, 'min', 'gemini-2.0-flash-lite')).toBe(0);
195 });
196
197 test('floors at 512', () => {
198 expect(mod.calculateGoogleBudgetTokens(1000, 'low', 'gemini-2.0-flash-lite')).toBe(512);
199 });
200 });
201
202 describe('pro (non-gemini-3)', () => {
203 test('min returns 128', () => {
204 expect(mod.calculateGoogleBudgetTokens(8192, 'min', 'gemini-2.5-pro')).toBe(128);
205 });
206
207 test('floors at 128', () => {
208 expect(mod.calculateGoogleBudgetTokens(500, 'low', 'gemini-2.5-pro')).toBe(128);
209 });
210
211 test('caps at 32768', () => {
212 expect(mod.calculateGoogleBudgetTokens(500000, 'max', 'gemini-2.5-pro')).toBe(32768);
213 });
214 });
215
216 describe('model matching priority', () => {
217 // flash-lite must match before flash
218 test('flash-lite matches before flash', () => {
219 // flash-lite min = 0, flash min = 0 too, so use a differentiating case
220 // flash-lite low floors at 512, flash low does not
221 expect(mod.calculateGoogleBudgetTokens(1000, 'low', 'gemini-2.0-flash-lite')).toBe(512);
222 expect(mod.calculateGoogleBudgetTokens(1000, 'low', 'gemini-2.0-flash')).toBe(100);
223 });
224 });
225});
226
227
228describe('addReasoningContentToToolCalls', () => {
229 test('adds reasoning_content to messages with tool_calls', () => {
230 const messages = [
231 { role: 'assistant', tool_calls: [{ id: '1', function: { name: 'f' } }] },
232 ];
233 mod.addReasoningContentToToolCalls(messages);
234 expect(messages[0].reasoning_content).toBe('');
235 });
236
237 test('does not overwrite existing reasoning_content', () => {
238 const messages = [
239 { role: 'assistant', tool_calls: [{ id: '1' }], reasoning_content: 'existing' },
240 ];
241 mod.addReasoningContentToToolCalls(messages);
242 expect(messages[0].reasoning_content).toBe('existing');
243 });
244
245 test('skips messages without tool_calls', () => {
246 const messages = [{ role: 'user', content: 'hi' }];
247 mod.addReasoningContentToToolCalls(messages);
248 expect(messages[0].reasoning_content).toBeUndefined();
249 });
250
251 test('handles non-array input gracefully', () => {
252 expect(() => mod.addReasoningContentToToolCalls(null)).not.toThrow();
253 expect(() => mod.addReasoningContentToToolCalls('string')).not.toThrow();
254 });
255});
256
257
258describe('embedOpenRouterMedia', () => {
259 test('converts audio data URLs to input_audio format', () => {
260 const messages = [{
261 role: 'user',
262 content: [{
263 type: 'audio_url',
264 audio_url: { url: 'data:audio/wav;base64,AAAA' },
265 }],
266 }];
267 mod.embedOpenRouterMedia(messages);
268 expect(messages[0].content[0]).toEqual({
269 type: 'input_audio',
270 input_audio: { format: 'wav', data: 'AAAA' },
271 });
272 });
273
274 test('defaults to mp3 for unknown audio mime types', () => {
275 const messages = [{
276 role: 'user',
277 content: [{
278 type: 'audio_url',
279 audio_url: { url: 'data:audio/ogg;base64,BBBB' },
280 }],
281 }];
282 mod.embedOpenRouterMedia(messages);
283 expect(messages[0].content[0].input_audio.format).toBe('mp3');
284 });
285
286 test('skips non-data-URL audio', () => {
287 const messages = [{
288 role: 'user',
289 content: [{
290 type: 'audio_url',
291 audio_url: { url: 'https://example.com/audio.mp3' },
292 }],
293 }];
294 mod.embedOpenRouterMedia(messages);
295 expect(messages[0].content[0].type).toBe('audio_url');
296 });
297
298 test('keeps video_url type for video data URLs', () => {
299 const messages = [{
300 role: 'user',
301 content: [{
302 type: 'video_url',
303 video_url: { url: 'data:video/mp4;base64,CCCC' },
304 }],
305 }];
306 mod.embedOpenRouterMedia(messages);
307 expect(messages[0].content[0].type).toBe('video_url');
308 });
309
310 test('skips non-data-URL video', () => {
311 const messages = [{
312 role: 'user',
313 content: [{
314 type: 'video_url',
315 video_url: { url: 'https://example.com/video.mp4' },
316 }],
317 }];
318 mod.embedOpenRouterMedia(messages);
319 expect(messages[0].content[0].type).toBe('video_url');
320 });
321
322 test('respects audio=false option', () => {
323 const messages = [{
324 role: 'user',
325 content: [{
326 type: 'audio_url',
327 audio_url: { url: 'data:audio/wav;base64,AAAA' },
328 }],
329 }];
330 mod.embedOpenRouterMedia(messages, { audio: false, video: true });
331 expect(messages[0].content[0].type).toBe('audio_url');
332 });
333
334 test('respects video=false option', () => {
335 const messages = [{
336 role: 'user',
337 content: [{
338 type: 'video_url',
339 video_url: { url: 'data:video/mp4;base64,CCCC' },
340 }],
341 }];
342 mod.embedOpenRouterMedia(messages, { audio: true, video: false });
343 expect(messages[0].content[0].type).toBe('video_url');
344 });
345
346 test('handles non-array input gracefully', () => {
347 expect(() => mod.embedOpenRouterMedia(null)).not.toThrow();
348 expect(() => mod.embedOpenRouterMedia('string')).not.toThrow();
349 });
350
351 test('skips messages with string content', () => {
352 const messages = [{ role: 'user', content: 'just text' }];
353 mod.embedOpenRouterMedia(messages);
354 expect(messages[0].content).toBe('just text');
355 });
356});
357
358
359describe('cachingSystemPromptForOpenRouter', () => {
360 test('adds cache_control to string system message', () => {
361 const messages = [{ role: 'system', content: 'You are helpful.' }];
362 mod.cachingSystemPromptForOpenRouter(messages);
363 expect(messages[0].content).toEqual([
364 { type: 'text', text: 'You are helpful.', cache_control: { type: 'ephemeral' } },
365 ]);
366 });
367
368 test('adds cache_control with TTL', () => {
369 const messages = [{ role: 'system', content: 'System prompt' }];
370 mod.cachingSystemPromptForOpenRouter(messages, '300');
371 expect(messages[0].content[0].cache_control).toEqual({ type: 'ephemeral', ttl: '300' });
372 });
373
374 test('adds cache_control to last text part of array content', () => {
375 const messages = [{
376 role: 'system',
377 content: [
378 { type: 'text', text: 'first' },
379 { type: 'text', text: 'second' },
380 ],
381 }];
382 mod.cachingSystemPromptForOpenRouter(messages);
383 expect(messages[0].content[0].cache_control).toBeUndefined();
384 expect(messages[0].content[1].cache_control).toEqual({ type: 'ephemeral' });
385 });
386
387 test('skips if system message already has cache_control', () => {
388 const messages = [{ role: 'system', content: 'prompt', cache_control: { type: 'ephemeral' } }];
389 mod.cachingSystemPromptForOpenRouter(messages);
390 // content should remain a string, not converted
391 expect(messages[0].content).toBe('prompt');
392 });
393
394 test('skips if array content already has cache_control', () => {
395 const messages = [{
396 role: 'system',
397 content: [{ type: 'text', text: 'cached', cache_control: { type: 'ephemeral' } }],
398 }];
399 mod.cachingSystemPromptForOpenRouter(messages);
400 // Should not add another cache_control
401 expect(messages[0].content).toHaveLength(1);
402 });
403
404 test('does nothing with empty array', () => {
405 expect(() => mod.cachingSystemPromptForOpenRouter([])).not.toThrow();
406 });
407
408 test('does nothing without system message', () => {
409 const messages = [{ role: 'user', content: 'hi' }];
410 mod.cachingSystemPromptForOpenRouter(messages);
411 expect(messages[0].content).toBe('hi');
412 });
413});
414
415
416describe('convertAI21Messages', () => {
417 const names = makeNames('Bot', 'User');
418
419 test('returns empty array for non-array input', () => {
420 expect(mod.convertAI21Messages(null, names)).toEqual([]);
421 expect(mod.convertAI21Messages('string', names)).toEqual([]);
422 });
423
424 test('extracts leading system messages into merged system prompt', () => {
425 const messages = [
426 { role: 'system', content: 'You are a bot.' },
427 { role: 'system', content: 'Be helpful.' },
428 { role: 'user', content: 'Hello' },
429 ];
430 const result = mod.convertAI21Messages(messages, names);
431 expect(result[0]).toEqual({ role: 'system', content: 'You are a bot.\n\nBe helpful.' });
432 expect(result[1]).toEqual({ role: 'user', content: 'Hello' });
433 });
434
435 test('prepends character name to example_assistant system messages', () => {
436 const messages = [
437 { role: 'system', name: 'example_assistant', content: 'I greet you.' },
438 { role: 'user', content: 'Hi' },
439 ];
440 const result = mod.convertAI21Messages(messages, names);
441 expect(result[0].content).toContain('Bot: I greet you.');
442 });
443
444 test('does not double-prepend if name already present', () => {
445 const messages = [
446 { role: 'system', name: 'example_assistant', content: 'Bot: I greet you.' },
447 { role: 'user', content: 'Hi' },
448 ];
449 const result = mod.convertAI21Messages(messages, names);
450 expect(result[0].content).not.toContain('Bot: Bot:');
451 });
452
453 test('merges consecutive same-role messages', () => {
454 const messages = [
455 { role: 'user', content: 'First' },
456 { role: 'user', content: 'Second' },
457 { role: 'assistant', content: 'Reply' },
458 ];
459 const result = mod.convertAI21Messages(messages, names);
460 expect(result).toHaveLength(2);
461 expect(result[0].content).toBe('First\n\nSecond');
462 });
463
464 test('prepends name to non-system messages and deletes name property', () => {
465 const messages = [
466 { role: 'user', name: 'Alice', content: 'Hello' },
467 ];
468 const result = mod.convertAI21Messages(messages, names);
469 expect(result[0].content).toBe('Alice: Hello');
470 expect(result[0].name).toBeUndefined();
471 });
472
473 test('inserts placeholder when all messages are system', () => {
474 const messages = [
475 { role: 'system', content: 'Setup' },
476 ];
477 const result = mod.convertAI21Messages(messages, names);
478 // After extracting the system message, array is empty, so placeholder is inserted
479 expect(result).toHaveLength(2);
480 expect(result[0]).toEqual({ role: 'system', content: 'Setup' });
481 expect(result[1].role).toBe('user');
482 });
483});
484
485
486describe('convertCohereMessages', () => {
487 const names = makeNames('Bot', 'User');
488
489 test('inserts placeholder for empty messages', () => {
490 const messages = [];
491 const result = mod.convertCohereMessages(messages, names);
492 expect(result.chatHistory).toHaveLength(1);
493 expect(result.chatHistory[0].role).toBe('user');
494 });
495
496 test('prepends character name to example_assistant system messages', () => {
497 const messages = [
498 { role: 'system', name: 'example_assistant', content: 'Greetings' },
499 ];
500 const result = mod.convertCohereMessages(messages, names);
501 expect(result.chatHistory[0].content).toBe('Bot: Greetings');
502 });
503
504 test('prepends user name to example_user system messages', () => {
505 const messages = [
506 { role: 'system', name: 'example_user', content: 'Hello there' },
507 ];
508 const result = mod.convertCohereMessages(messages, names);
509 expect(result.chatHistory[0].content).toBe('User: Hello there');
510 });
511
512 test('prepends name to non-system messages', () => {
513 const messages = [
514 { role: 'assistant', name: 'NPC', content: 'I am an NPC' },
515 ];
516 const result = mod.convertCohereMessages(messages, names);
517 expect(result.chatHistory[0].content).toBe('NPC: I am an NPC');
518 expect(result.chatHistory[0].name).toBeUndefined();
519 });
520
521 test('handles tool_calls by extracting function names', () => {
522 const messages = [
523 { role: 'assistant', tool_calls: [{ function: { name: 'search' } }, { function: { name: 'fetch' } }] },
524 ];
525 const result = mod.convertCohereMessages(messages, names);
526 expect(result.chatHistory[0].content).toContain('search, fetch');
527 });
528});
529
530
531describe('convertXAIMessages', () => {
532 const names = makeNames('Char', 'Player');
533
534 test('returns empty array for non-array input', () => {
535 expect(mod.convertXAIMessages(null, names)).toEqual([]);
536 });
537
538 test('prepends char name to assistant messages', () => {
539 const messages = [
540 { role: 'assistant', name: 'Char', content: 'Hello' },
541 ];
542 const result = mod.convertXAIMessages(messages, names);
543 expect(result[0].content).toBe('Char: Hello');
544 expect(result[0].name).toBeUndefined();
545 });
546
547 test('skips user role messages entirely', () => {
548 const messages = [
549 { role: 'user', name: 'Someone', content: 'Hello' },
550 ];
551 const result = mod.convertXAIMessages(messages, names);
552 expect(result[0].content).toBe('Hello');
553 expect(result[0].name).toBe('Someone');
554 });
555
556 test('handles group names - does not double prefix', () => {
557 const groupNames = makeNames('Char', 'Player', ['Alice', 'Bob']);
558 const messages = [
559 { role: 'assistant', name: 'SomeNPC', content: 'Alice: speaking as Alice' },
560 ];
561 const result = mod.convertXAIMessages(messages, groupNames);
562 // Starts with group name, so charName prefix should not be added
563 expect(result[0].content).toBe('Alice: speaking as Alice');
564 });
565});
566
567
568describe('mergeMessages', () => {
569 const names = makeNames('Bot', 'User');
570
571 test('squashes consecutive same-role messages', () => {
572 const messages = [
573 { role: 'user', content: 'A' },
574 { role: 'user', content: 'B' },
575 { role: 'assistant', content: 'C' },
576 ];
577 const result = mod.mergeMessages(messages, names);
578 expect(result).toHaveLength(2);
579 expect(result[0].content).toBe('A\n\nB');
580 });
581
582 test('inserts placeholder for empty messages', () => {
583 const result = mod.mergeMessages([], names);
584 expect(result).toHaveLength(1);
585 expect(result[0].role).toBe('user');
586 });
587
588 test('removes name property and prepends to content for non-system', () => {
589 const messages = [
590 { role: 'assistant', name: 'NPC', content: 'dialogue' },
591 ];
592 const result = mod.mergeMessages(messages, names);
593 expect(result[0].content).toBe('NPC: dialogue');
594 expect(result[0].name).toBeUndefined();
595 });
596
597 test('strict mode forces mid-prompt system to user', () => {
598 const messages = [
599 { role: 'system', content: 'Setup' },
600 { role: 'system', content: 'More setup' },
601 { role: 'user', content: 'Hi' },
602 ];
603 const result = mod.mergeMessages(messages, names, { strict: true });
604 // First system stays, second should become user and merge
605 expect(result[0].role).toBe('system');
606 expect(result.filter(m => m.role === 'system')).toHaveLength(1);
607 });
608
609 test('single mode forces all roles to user', () => {
610 const messages = [
611 { role: 'system', content: 'Setup' },
612 { role: 'user', content: 'Hello' },
613 { role: 'assistant', content: 'Response' },
614 ];
615 const result = mod.mergeMessages(messages, names, { single: true });
616 expect(result.every(m => m.role === 'user')).toBe(true);
617 });
618
619 test('single mode prepends charName to assistant, userName to user', () => {
620 const messages = [
621 { role: 'user', content: 'Hello' },
622 { role: 'assistant', content: 'Hi back' },
623 ];
624 const result = mod.mergeMessages(messages, names, { single: true });
625 // Both become user and get merged
626 expect(result[0].content).toContain('User: Hello');
627 expect(result[0].content).toContain('Bot: Hi back');
628 });
629
630 test('tools=false removes tool_calls and tool_call_id', () => {
631 const messages = [
632 { role: 'assistant', content: 'calling', tool_calls: [{ id: '1' }] },
633 { role: 'tool', content: 'result', tool_call_id: '1' },
634 ];
635 const result = mod.mergeMessages(messages, names, { tools: false });
636 expect(result[0].tool_calls).toBeUndefined();
637 // tool role should be converted to user
638 const toolMsg = result.find(m => m.content === 'result' || m.content?.includes?.('result'));
639 expect(toolMsg).toBeDefined();
640 });
641
642 test('flattens array content and preserves image URLs via tokens', () => {
643 const imageContent = { type: 'image_url', image_url: { url: 'data:image/png;base64,abc' } };
644 const messages = [
645 { role: 'user', content: [{ type: 'text', text: 'Look at this' }, imageContent] },
646 ];
647 const result = mod.mergeMessages(messages, names);
648 // Content should be reconstituted with image preserved
649 expect(Array.isArray(result[0].content)).toBe(true);
650 const types = result[0].content.map(c => c.type);
651 expect(types).toContain('text');
652 expect(types).toContain('image_url');
653 });
654});
655
656
657describe('postProcessPrompt', () => {
658 const names = makeNames('Bot', 'User');
659
660 test('NONE type returns messages unchanged', () => {
661 const messages = [{ role: 'user', content: 'hi' }];
662 expect(mod.postProcessPrompt(messages, '', names)).toBe(messages);
663 });
664
665 test('MERGE type merges consecutive same-role messages', () => {
666 const messages = [
667 { role: 'user', content: 'A' },
668 { role: 'user', content: 'B' },
669 ];
670 const result = mod.postProcessPrompt(messages, 'merge', names);
671 expect(result).toHaveLength(1);
672 expect(result[0].content).toBe('A\n\nB');
673 });
674
675 test('deprecated CLAUDE type works same as MERGE', () => {
676 const messages = [
677 { role: 'user', content: 'A' },
678 { role: 'user', content: 'B' },
679 ];
680 const result = mod.postProcessPrompt(messages, 'claude', names);
681 expect(result).toHaveLength(1);
682 });
683});
684
685
686describe('addOpenRouterSignatures', () => {
687 test('handles non-array input gracefully', () => {
688 expect(() => mod.addOpenRouterSignatures(null, 'model')).not.toThrow();
689 });
690
691 test('detects google model format', () => {
692 const messages = [{ role: 'assistant', content: 'hi', signature: 'sig123' }];
693 mod.addOpenRouterSignatures(messages, 'google/gemini-2.5-pro');
694 expect(messages[0].reasoning_details).toBeDefined();
695 expect(messages[0].reasoning_details[0].format).toBe('google-gemini-v1');
696 });
697
698 test('detects anthropic model format', () => {
699 const messages = [{ role: 'assistant', content: 'hi', signature: 'sig123' }];
700 mod.addOpenRouterSignatures(messages, 'anthropic/claude-sonnet-4');
701 expect(messages[0].reasoning_details).toBeDefined();
702 expect(messages[0].reasoning_details[0].format).toBe('anthropic-claude-v1');
703 });
704
705 test('removes signature property from messages', () => {
706 const messages = [{ role: 'assistant', content: 'hi', signature: 'sig123' }];
707 mod.addOpenRouterSignatures(messages, 'anthropic/claude-sonnet-4');
708 expect(messages[0].signature).toBeUndefined();
709 });
710
711 test('removes signature property from tool_calls', () => {
712 const messages = [{
713 role: 'assistant',
714 content: '',
715 tool_calls: [{ id: 'tc1', function: { name: 'f' }, signature: 'toolsig' }],
716 }];
717 mod.addOpenRouterSignatures(messages, 'anthropic/claude-sonnet-4');
718 expect(messages[0].tool_calls[0].signature).toBeUndefined();
719 });
720});
721
722
723describe('getPromptNames', () => {
724 test('extracts names from request body', () => {
725 const request = { body: { char_name: 'Bot', user_name: 'User', group_names: ['Alice', 'Bob'] } };
726 const names = mod.getPromptNames(request);
727 expect(names.charName).toBe('Bot');
728 expect(names.userName).toBe('User');
729 expect(names.groupNames).toEqual(['Alice', 'Bob']);
730 });
731
732 test('defaults to empty strings and array when missing', () => {
733 const request = { body: {} };
734 const names = mod.getPromptNames(request);
735 expect(names.charName).toBe('');
736 expect(names.userName).toBe('');
737 expect(names.groupNames).toEqual([]);
738 });
739
740 test('startsWithGroupName checks message prefix', () => {
741 const request = { body: { char_name: '', user_name: '', group_names: ['Alice', 'Bob'] } };
742 const names = mod.getPromptNames(request);
743 expect(names.startsWithGroupName('Alice: hello')).toBe(true);
744 expect(names.startsWithGroupName('Charlie: hello')).toBe(false);
745 });
746
747 test('coerces non-string group_names to strings', () => {
748 const request = { body: { group_names: [123, null] } };
749 const names = mod.getPromptNames(request);
750 expect(names.groupNames).toEqual(['123', 'null']);
751 });
752});
753
754
755describe('convertClaudePrompt', () => {
756 test('returns empty string for empty messages', () => {
757 const result = mod.convertClaudePrompt([], false, '', false, false, '', false);
758 expect(result).toBe('');
759 });
760
761 test('basic conversion with Human/Assistant prefixes', () => {
762 const messages = [
763 { role: 'system', content: 'You are helpful.' },
764 { role: 'user', content: 'Hello' },
765 { role: 'assistant', content: 'Hi there' },
766 ];
767 const result = mod.convertClaudePrompt(messages, false, '', false, false, '', false);
768 expect(result).toContain('Hello');
769 expect(result).toContain('Hi there');
770 expect(result).toContain('\n\nHuman: ');
771 expect(result).toContain('\n\nAssistant: ');
772 });
773
774 test('adds assistant postfix when requested', () => {
775 const messages = [
776 { role: 'user', content: 'Hello' },
777 ];
778 const result = mod.convertClaudePrompt(messages, true, '', false, false, '', false);
779 expect(result).toMatch(/\n\nAssistant: $/);
780 });
781
782 test('adds assistant prefill when provided', () => {
783 const messages = [
784 { role: 'user', content: 'Hello' },
785 ];
786 const result = mod.convertClaudePrompt(messages, true, 'Sure, I', false, false, '', false);
787 expect(result).toMatch(/\n\nAssistant: Sure, I$/);
788 });
789
790 test('system prompt mode sets first message role to system', () => {
791 const messages = [
792 { role: 'system', content: 'System prompt' },
793 { role: 'user', content: 'Hello' },
794 ];
795 mod.convertClaudePrompt(messages, false, '', true, true, '', false);
796 // First message should remain system (no prefix)
797 expect(messages[0].role).toBe('system');
798 });
799
800 test('exclude prefixes mode sets non-last messages to system', () => {
801 const messages = [
802 { role: 'user', content: 'First' },
803 { role: 'assistant', content: 'Second' },
804 { role: 'user', content: 'Third' },
805 ];
806 const result = mod.convertClaudePrompt(messages, false, '', false, false, '', true);
807 // Non-last messages should have been set to system role (no Human/Assistant prefix)
808 // Last message keeps its original role
809 expect(result).toContain('Third');
810 });
811
812 test('stringifies tool_calls into content', () => {
813 const messages = [
814 { role: 'assistant', content: 'Let me check', tool_calls: [{ id: '1', function: { name: 'search' } }] },
815 { role: 'user', content: 'OK' },
816 ];
817 const result = mod.convertClaudePrompt(messages, false, '', false, false, '', false);
818 expect(result).toContain('search');
819 });
820});
821
822
823describe('convertClaudeMessages', () => {
824 const names = makeNames('Bot', 'User');
825
826 test('extracts leading system messages when useSysPrompt is true', () => {
827 const messages = [
828 { role: 'system', content: 'System instruction' },
829 { role: 'user', content: 'Hello' },
830 ];
831 const result = mod.convertClaudeMessages(messages, '', true, false, names);
832 expect(result.systemPrompt).toHaveLength(1);
833 expect(result.systemPrompt[0]).toEqual({ type: 'text', text: 'System instruction' });
834 expect(result.messages[0].role).toBe('user');
835 });
836
837 test('inserts placeholder when all messages are system and useSysPrompt is true', () => {
838 const messages = [
839 { role: 'system', content: 'Only system' },
840 ];
841 const result = mod.convertClaudeMessages(messages, '', true, false, names);
842 expect(result.messages).toHaveLength(1);
843 expect(result.messages[0].role).toBe('user');
844 });
845
846 test('converts system messages to user role after extraction', () => {
847 const messages = [
848 { role: 'user', content: 'Hi' },
849 { role: 'system', content: 'Mid-prompt system' },
850 { role: 'assistant', content: 'Reply' },
851 ];
852 const result = mod.convertClaudeMessages(messages, '', false, false, names);
853 // system messages should become user
854 const roles = result.messages.map(m => m.role);
855 expect(roles).not.toContain('system');
856 });
857
858 test('merges consecutive same-role messages', () => {
859 const messages = [
860 { role: 'user', content: 'A' },
861 { role: 'user', content: 'B' },
862 { role: 'assistant', content: 'C' },
863 ];
864 const result = mod.convertClaudeMessages(messages, '', false, false, names);
865 // Two user messages should be merged
866 expect(result.messages.filter(m => m.role === 'user')).toHaveLength(1);
867 });
868
869 test('converts string content to array of text parts', () => {
870 const messages = [
871 { role: 'user', content: 'Hello' },
872 ];
873 const result = mod.convertClaudeMessages(messages, '', false, false, names);
874 expect(Array.isArray(result.messages[0].content)).toBe(true);
875 expect(result.messages[0].content[0]).toEqual({ type: 'text', text: 'Hello' });
876 });
877
878 test('converts image_url content to Claude base64 format', () => {
879 const messages = [
880 { role: 'user', content: [
881 { type: 'text', text: 'Look' },
882 { type: 'image_url', image_url: { url: 'data:image/png;base64,abc123' } },
883 ] },
884 ];
885 const result = mod.convertClaudeMessages(messages, '', false, false, names);
886 const imagePart = result.messages[0].content.find(c => c.type === 'image');
887 expect(imagePart).toBeDefined();
888 expect(imagePart.source.type).toBe('base64');
889 expect(imagePart.source.media_type).toBe('image/png');
890 expect(imagePart.source.data).toBe('abc123');
891 });
892
893 test('moves images from assistant to next user message', () => {
894 const messages = [
895 { role: 'assistant', content: [
896 { type: 'text', text: 'Here is an image' },
897 { type: 'image_url', image_url: { url: 'data:image/png;base64,abc' } },
898 ] },
899 { role: 'user', content: 'Thanks' },
900 ];
901 const result = mod.convertClaudeMessages(messages, '', false, false, names);
902 // Assistant message should not have images
903 const assistantImages = result.messages
904 .filter(m => m.role === 'assistant')
905 .flatMap(m => m.content)
906 .filter(c => c.type === 'image');
907 expect(assistantImages).toHaveLength(0);
908 });
909
910 test('adds prefill as trailing assistant message', () => {
911 const messages = [
912 { role: 'user', content: 'Hello' },
913 ];
914 const result = mod.convertClaudeMessages(messages, 'Sure, I will ', false, false, names);
915 const last = result.messages[result.messages.length - 1];
916 expect(last.role).toBe('assistant');
917 // Prefill should be trimmed on the right
918 expect(last.content[0].text).toBe('Sure, I will');
919 });
920
921 test('converts tool_calls to tool_use format', () => {
922 const messages = [
923 { role: 'user', content: 'search for cats' },
924 { role: 'assistant', content: '', tool_calls: [
925 { id: 'tc1', function: { name: 'search', arguments: '{"q":"cats"}' } },
926 ] },
927 ];
928 const result = mod.convertClaudeMessages(messages, '', false, true, names);
929 const assistantContent = result.messages.find(m => m.role === 'assistant')?.content;
930 const toolUse = assistantContent?.find(c => c.type === 'tool_use');
931 expect(toolUse).toBeDefined();
932 expect(toolUse.name).toBe('search');
933 expect(toolUse.input).toEqual({ q: 'cats' });
934 });
935
936 test('converts tool messages to tool_result format', () => {
937 const messages = [
938 { role: 'user', content: 'search' },
939 { role: 'assistant', content: '', tool_calls: [
940 { id: 'tc1', function: { name: 'search', arguments: '{}' } },
941 ] },
942 { role: 'tool', content: 'Found results', tool_call_id: 'tc1' },
943 ];
944 const result = mod.convertClaudeMessages(messages, '', false, true, names);
945 const userContent = result.messages
946 .filter(m => m.role === 'user')
947 .flatMap(m => m.content);
948 const toolResult = userContent.find(c => c.type === 'tool_result');
949 expect(toolResult).toBeDefined();
950 expect(toolResult.content).toBe('Found results');
951 });
952
953 test('replaces empty text content with zero-width space', () => {
954 const messages = [
955 { role: 'user', content: [{ type: 'text', text: '' }] },
956 ];
957 const result = mod.convertClaudeMessages(messages, '', false, false, names);
958 expect(result.messages[0].content[0].text).toBe('\u200b');
959 });
960
961 test('prepends example_assistant name with charName in system prompt', () => {
962 const messages = [
963 { role: 'system', name: 'example_assistant', content: 'Greetings' },
964 { role: 'user', content: 'Hi' },
965 ];
966 const result = mod.convertClaudeMessages(messages, '', true, false, names);
967 expect(result.systemPrompt[0].text).toBe('Bot: Greetings');
968 });
969
970 test('removes name, tool_calls, and tool_call_id properties from output', () => {
971 const messages = [
972 { role: 'user', name: 'Alice', content: 'Hello' },
973 ];
974 const result = mod.convertClaudeMessages(messages, '', false, false, names);
975 const msg = result.messages[0];
976 expect(msg.name).toBeUndefined();
977 expect(msg.tool_calls).toBeUndefined();
978 expect(msg.tool_call_id).toBeUndefined();
979 });
980});
981
982
983describe('convertGooglePrompt', () => {
984 const names = makeNames('Bot', 'User');
985
986 test('extracts leading system messages into system_instruction', () => {
987 const messages = [
988 { role: 'system', content: 'Be helpful' },
989 { role: 'system', content: 'Be concise' },
990 { role: 'user', content: 'Hello' },
991 ];
992 const result = mod.convertGooglePrompt(messages, 'gemini-2.0-flash', true, names);
993 expect(result.system_instruction.parts).toEqual([
994 { text: 'Be helpful' },
995 { text: 'Be concise' },
996 ]);
997 });
998
999 test('converts assistant role to model role', () => {
1000 const messages = [
1001 { role: 'user', content: 'Hi' },
1002 { role: 'assistant', content: 'Hello' },
1003 ];
1004 const result = mod.convertGooglePrompt(messages, 'gemini-2.0-flash', false, names);
1005 expect(result.contents[1].role).toBe('model');
1006 });
1007
1008 test('converts system and tool roles to user role', () => {
1009 const messages = [
1010 { role: 'system', content: 'System' },
1011 { role: 'tool', content: 'Tool result', tool_call_id: 'tc1' },
1012 { role: 'user', content: 'Hi' },
1013 ];
1014 const result = mod.convertGooglePrompt(messages, 'gemini-2.0-flash', false, names);
1015 // Both system and tool should become user
1016 const roles = result.contents.map(c => c.role);
1017 expect(roles).not.toContain('system');
1018 expect(roles).not.toContain('tool');
1019 });
1020
1021 test('merges consecutive same-role messages', () => {
1022 const messages = [
1023 { role: 'user', content: 'A' },
1024 { role: 'user', content: 'B' },
1025 { role: 'assistant', content: 'Reply' },
1026 ];
1027 const result = mod.convertGooglePrompt(messages, 'gemini-2.0-flash', false, names);
1028 expect(result.contents.filter(c => c.role === 'user')).toHaveLength(1);
1029 });
1030
1031 test('converts image_url to inlineData', () => {
1032 const messages = [
1033 { role: 'user', content: [
1034 { type: 'text', text: 'Look' },
1035 { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,/9j/abc' } },
1036 ] },
1037 ];
1038 const result = mod.convertGooglePrompt(messages, 'gemini-2.0-flash', false, names);
1039 const parts = result.contents[0].parts;
1040 const inlineData = parts.find(p => p.inlineData);
1041 expect(inlineData).toBeDefined();
1042 expect(inlineData.inlineData.mimeType).toBe('image/jpeg');
1043 expect(inlineData.inlineData.data).toBe('/9j/abc');
1044 });
1045
1046 test('converts tool_calls to functionCall parts', () => {
1047 const messages = [
1048 { role: 'user', content: 'search cats' },
1049 { role: 'assistant', tool_calls: [
1050 { id: 'tc1', function: { name: 'search', arguments: '{"q":"cats"}' } },
1051 ] },
1052 ];
1053 const result = mod.convertGooglePrompt(messages, 'gemini-2.0-flash', false, names);
1054 const modelParts = result.contents.find(c => c.role === 'model')?.parts;
1055 const funcCall = modelParts?.find(p => p.functionCall);
1056 expect(funcCall).toBeDefined();
1057 expect(funcCall.functionCall.name).toBe('search');
1058 });
1059
1060 test('converts tool_call_id to functionResponse parts', () => {
1061 const messages = [
1062 { role: 'user', content: 'search' },
1063 { role: 'assistant', tool_calls: [
1064 { id: 'tc1', function: { name: 'search', arguments: '{}' } },
1065 ] },
1066 { role: 'tool', content: 'Results here', tool_call_id: 'tc1' },
1067 ];
1068 const result = mod.convertGooglePrompt(messages, 'gemini-2.0-flash', false, names);
1069 const userParts = result.contents
1070 .filter(c => c.role === 'user')
1071 .flatMap(c => c.parts);
1072 const funcResp = userParts.find(p => p.functionResponse);
1073 expect(funcResp).toBeDefined();
1074 expect(funcResp.functionResponse.name).toBe('search');
1075 });
1076
1077 test('prepends example names when useSysPrompt is true', () => {
1078 const messages = [
1079 { role: 'system', name: 'example_assistant', content: 'Greetings' },
1080 { role: 'user', content: 'Hi' },
1081 ];
1082 const result = mod.convertGooglePrompt(messages, 'gemini-2.0-flash', true, names);
1083 expect(result.system_instruction.parts[0].text).toBe('Bot: Greetings');
1084 });
1085
1086 test('does not extract system if useSysPrompt is false', () => {
1087 const messages = [
1088 { role: 'system', content: 'System' },
1089 { role: 'user', content: 'Hi' },
1090 ];
1091 const result = mod.convertGooglePrompt(messages, 'gemini-2.0-flash', false, names);
1092 expect(result.system_instruction.parts).toHaveLength(0);
1093 // System message should be in contents as user
1094 expect(result.contents[0].role).toBe('user');
1095 });
1096});
1097
1098
1099describe('convertMistralMessages', () => {
1100 const names = makeNames('Bot', 'User');
1101
1102 test('returns empty array for non-array input', () => {
1103 expect(mod.convertMistralMessages(null, names)).toEqual([]);
1104 });
1105
1106 test('prepends charName to example_assistant system messages', () => {
1107 const messages = [
1108 { role: 'system', name: 'example_assistant', content: 'Greetings' },
1109 { role: 'user', content: 'Hi' },
1110 ];
1111 const result = mod.convertMistralMessages(messages, names);
1112 expect(result[0].content).toBe('Bot: Greetings');
1113 expect(result[0].name).toBeUndefined();
1114 });
1115
1116 test('prepends userName to example_user system messages', () => {
1117 const messages = [
1118 { role: 'system', name: 'example_user', content: 'Hello there' },
1119 { role: 'user', content: 'Hi' },
1120 ];
1121 const result = mod.convertMistralMessages(messages, names);
1122 expect(result[0].content).toBe('User: Hello there');
1123 });
1124
1125 test('prepends name to non-system messages', () => {
1126 const messages = [
1127 { role: 'assistant', name: 'NPC', content: 'dialogue' },
1128 ];
1129 const result = mod.convertMistralMessages(messages, names);
1130 expect(result[0].content).toBe('NPC: dialogue');
1131 expect(result[0].name).toBeUndefined();
1132 });
1133
1134 test('hashes tool call IDs', () => {
1135 const messages = [
1136 { role: 'assistant', content: 'calling', tool_calls: [{ id: 'original-id', function: { name: 'f' } }] },
1137 { role: 'tool', content: 'result', tool_call_id: 'original-id' },
1138 ];
1139 const result = mod.convertMistralMessages(messages, names);
1140 // IDs should be hashed to 9-char hex
1141 expect(result[0].tool_calls[0].id).toMatch(/^[a-f0-9]{9}$/);
1142 expect(result[1].tool_call_id).toMatch(/^[a-f0-9]{9}$/);
1143 // Both should match (same input -> same hash)
1144 expect(result[0].tool_calls[0].id).toBe(result[1].tool_call_id);
1145 });
1146
1147 test('changes system to user when following assistant', () => {
1148 const messages = [
1149 { role: 'user', content: 'Hi' },
1150 { role: 'assistant', content: 'Hello' },
1151 { role: 'system', content: 'Narrator text' },
1152 ];
1153 const result = mod.convertMistralMessages(messages, names);
1154 expect(result[2].role).toBe('user');
1155 });
1156
1157 test('merges user message after tool into previous user message', () => {
1158 const messages = [
1159 { role: 'user', content: 'search cats' },
1160 { role: 'assistant', content: 'calling', tool_calls: [{ id: 'tc1', function: { name: 'f' } }] },
1161 { role: 'tool', content: 'result', tool_call_id: 'tc1' },
1162 { role: 'user', content: 'thanks' },
1163 ];
1164 const result = mod.convertMistralMessages(messages, names);
1165 // The 'thanks' user message should be merged into the first user message
1166 expect(result[0].content).toContain('search cats');
1167 expect(result[0].content).toContain('thanks');
1168 });
1169});
1170
1171
1172describe('cachingAtDepthForClaude', () => {
1173 test('adds cache_control at specified depth', () => {
1174 const messages = [
1175 { role: 'user', content: [{ type: 'text', text: 'A' }] },
1176 { role: 'assistant', content: [{ type: 'text', text: 'B' }] },
1177 { role: 'user', content: [{ type: 'text', text: 'C' }] },
1178 { role: 'assistant', content: [{ type: 'text', text: 'D' }] },
1179 { role: 'user', content: [{ type: 'text', text: 'E' }] },
1180 ];
1181 mod.cachingAtDepthForClaude(messages, 0, '300');
1182 // Depth 0 should be the last non-assistant message (working backwards)
1183 const cached = messages.filter(m =>
1184 m.content.some(c => c.cache_control),
1185 );
1186 expect(cached.length).toBeGreaterThan(0);
1187 const cacheControl = cached[0].content[cached[0].content.length - 1].cache_control;
1188 expect(cacheControl).toEqual({ type: 'ephemeral', ttl: '300' });
1189 });
1190
1191 test('skips trailing assistant messages (prefill)', () => {
1192 const messages = [
1193 { role: 'user', content: [{ type: 'text', text: 'A' }] },
1194 { role: 'assistant', content: [{ type: 'text', text: 'B' }] },
1195 { role: 'assistant', content: [{ type: 'text', text: 'Prefill' }] },
1196 ];
1197 mod.cachingAtDepthForClaude(messages, 0, '300');
1198 // The trailing assistant prefill should not get cache_control
1199 const lastMsg = messages[messages.length - 1];
1200 expect(lastMsg.content[0].cache_control).toBeUndefined();
1201 });
1202
1203 test('does not modify messages when depth exceeds message count', () => {
1204 const messages = [
1205 { role: 'user', content: [{ type: 'text', text: 'A' }] },
1206 ];
1207 mod.cachingAtDepthForClaude(messages, 10, '300');
1208 expect(messages[0].content[0].cache_control).toBeUndefined();
1209 });
1210});
1211
1212
1213describe('cachingAtDepthForOpenRouterClaude', () => {
1214 test('adds cache_control to string content by wrapping in array', () => {
1215 const messages = [
1216 { role: 'user', content: 'A' },
1217 { role: 'assistant', content: 'B' },
1218 { role: 'user', content: 'C' },
1219 ];
1220 mod.cachingAtDepthForOpenRouterClaude(messages, 0, '300');
1221 // Find which message got cached
1222 const cached = messages.find(m =>
1223 Array.isArray(m.content) && m.content.some(c => c.cache_control),
1224 );
1225 expect(cached).toBeDefined();
1226 expect(cached.content[0].type).toBe('text');
1227 expect(cached.content[0].cache_control).toEqual({ type: 'ephemeral', ttl: '300' });
1228 });
1229
1230 test('adds cache_control to array content on last part', () => {
1231 const messages = [
1232 { role: 'user', content: [{ type: 'text', text: 'A' }, { type: 'text', text: 'B' }] },
1233 { role: 'assistant', content: [{ type: 'text', text: 'Reply' }] },
1234 { role: 'user', content: [{ type: 'text', text: 'C' }] },
1235 ];
1236 mod.cachingAtDepthForOpenRouterClaude(messages, 0, '300');
1237 const cached = messages.find(m =>
1238 m.content.some?.(c => c.cache_control),
1239 );
1240 expect(cached).toBeDefined();
1241 });
1242
1243 test('skips system messages for depth counting', () => {
1244 const messages = [
1245 { role: 'system', content: 'System prompt' },
1246 { role: 'user', content: 'A' },
1247 { role: 'assistant', content: 'B' },
1248 { role: 'user', content: 'C' },
1249 ];
1250 mod.cachingAtDepthForOpenRouterClaude(messages, 0, '300');
1251 // System message should not get cache_control
1252 expect(messages[0].content).toBe('System prompt');
1253 });
1254
1255 test('skips trailing assistant prefill', () => {
1256 const messages = [
1257 { role: 'user', content: 'A' },
1258 { role: 'assistant', content: 'Prefill' },
1259 ];
1260 mod.cachingAtDepthForOpenRouterClaude(messages, 0, '300');
1261 expect(typeof messages[1].content).toBe('string');
1262 });
1263});