fix: normalize message roles for local model backends (llama.cpp, Ollama)
Local backends using strict chat templates (e.g. Mistral 3) rejected Flynn's Anthropic-style tool_use/tool_result content blocks, causing 'roles must alternate' errors. Added getMessageTextWithTools() and normalizeMessagesForLocal() to serialize structured blocks to plain text, drop empty messages, and merge consecutive same-role messages. Also fixed compaction to ensure kept messages start with user role.
This commit is contained in:
@@ -8,6 +8,8 @@ import {
|
||||
attachmentToImageSource,
|
||||
buildUserMessage,
|
||||
getMessageText,
|
||||
getMessageTextWithTools,
|
||||
normalizeMessagesForLocal,
|
||||
hasImages,
|
||||
transcribeAudio,
|
||||
buildUserMessageWithAudio,
|
||||
@@ -605,3 +607,216 @@ describe('buildUserMessageWithAudio', () => {
|
||||
expect(result.content).toContain('[Voice message]:');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. getMessageTextWithTools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getMessageTextWithTools', () => {
|
||||
it('returns string directly for string content', () => {
|
||||
const msg: Message = { role: 'user', content: 'plain text' };
|
||||
expect(getMessageTextWithTools(msg)).toBe('plain text');
|
||||
});
|
||||
|
||||
it('extracts text from text-only array content', () => {
|
||||
const msg: Message = {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Hello ' },
|
||||
{ type: 'text', text: 'World' },
|
||||
],
|
||||
};
|
||||
expect(getMessageTextWithTools(msg)).toBe('Hello \nWorld');
|
||||
});
|
||||
|
||||
it('serializes tool_use blocks to readable text', () => {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'search', input: { query: 'foo' } },
|
||||
],
|
||||
} as unknown as Message;
|
||||
|
||||
expect(getMessageTextWithTools(msg)).toBe('[Calling tool: search({"query":"foo"})]');
|
||||
});
|
||||
|
||||
it('serializes tool_result blocks to readable text', () => {
|
||||
const msg = {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', content: 'Found 3 results' },
|
||||
],
|
||||
} as unknown as Message;
|
||||
|
||||
expect(getMessageTextWithTools(msg)).toBe('[Tool result: Found 3 results]');
|
||||
});
|
||||
|
||||
it('marks error tool_result blocks', () => {
|
||||
const msg = {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', content: 'File not found', is_error: true },
|
||||
],
|
||||
} as unknown as Message;
|
||||
|
||||
expect(getMessageTextWithTools(msg)).toBe('[Tool result (error): File not found]');
|
||||
});
|
||||
|
||||
it('handles mixed content (text + tool_use + tool_result) joined with newline', () => {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Let me search for that.' },
|
||||
{ type: 'tool_use', name: 'web_search', input: { q: 'test' } },
|
||||
{ type: 'tool_result', content: 'No results' },
|
||||
],
|
||||
} as unknown as Message;
|
||||
|
||||
const result = getMessageTextWithTools(msg);
|
||||
expect(result).toBe(
|
||||
'Let me search for that.\n[Calling tool: web_search({"q":"test"})]\n[Tool result: No results]',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty string for empty array content', () => {
|
||||
const msg: Message = {
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
};
|
||||
expect(getMessageTextWithTools(msg)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11. normalizeMessagesForLocal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('normalizeMessagesForLocal', () => {
|
||||
it('passes through simple text messages', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there' },
|
||||
];
|
||||
|
||||
const result = normalizeMessagesForLocal(undefined, messages);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('prepends system message when provided', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
];
|
||||
|
||||
const result = normalizeMessagesForLocal('You are helpful.', messages);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ role: 'system', content: 'You are helpful.' },
|
||||
{ role: 'user', content: 'Hello' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits system message when undefined', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
];
|
||||
|
||||
const result = normalizeMessagesForLocal(undefined, messages);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ role: 'user', content: 'Hello' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges consecutive same-role messages', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'Part 1' },
|
||||
{ role: 'user', content: 'Part 2' },
|
||||
{ role: 'assistant', content: 'Response' },
|
||||
];
|
||||
|
||||
const result = normalizeMessagesForLocal(undefined, messages);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ role: 'user', content: 'Part 1\n\nPart 2' },
|
||||
{ role: 'assistant', content: 'Response' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops empty messages (e.g. image-only content that serializes to "")', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'Before' },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image', source: { type: 'url', media_type: 'image/png', url: 'https://example.com/img.png' } },
|
||||
],
|
||||
},
|
||||
{ role: 'assistant', content: 'After' },
|
||||
];
|
||||
|
||||
const result = normalizeMessagesForLocal(undefined, messages);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ role: 'user', content: 'Before' },
|
||||
{ role: 'assistant', content: 'After' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles realistic agent tool loop sequence', () => {
|
||||
// Simulates: user asks question → assistant calls tool → user provides result → assistant responds
|
||||
const messages = [
|
||||
{ role: 'user', content: 'What is the weather?' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Let me check.' },
|
||||
{ type: 'tool_use', name: 'get_weather', input: { city: 'London' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', content: 'Sunny, 22°C' },
|
||||
],
|
||||
},
|
||||
{ role: 'assistant', content: 'The weather in London is sunny at 22°C.' },
|
||||
] as unknown as Message[];
|
||||
|
||||
const result = normalizeMessagesForLocal('You are a weather bot.', messages);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ role: 'system', content: 'You are a weather bot.' },
|
||||
{ role: 'user', content: 'What is the weather?' },
|
||||
{ role: 'assistant', content: 'Let me check.\n[Calling tool: get_weather({"city":"London"})]' },
|
||||
{ role: 'user', content: '[Tool result: Sunny, 22°C]' },
|
||||
{ role: 'assistant', content: 'The weather in London is sunny at 22°C.' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when all messages are empty', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: '' },
|
||||
{ role: 'assistant', content: '' },
|
||||
];
|
||||
|
||||
const result = normalizeMessagesForLocal(undefined, messages);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns only system message when all messages are empty but system is set', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: '' },
|
||||
];
|
||||
|
||||
const result = normalizeMessagesForLocal('System prompt', messages);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ role: 'system', content: 'System prompt' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user