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:
William Valentin
2026-02-10 22:04:17 -08:00
parent 2f6d045e2a
commit 6761dca1c2
6 changed files with 318 additions and 43 deletions
+215
View File
@@ -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' },
]);
});
});