/** * openai-format 孤立 tool 消息过滤测试(v0.6.2 会话停止根因的纵深防御) * * 背景:OpenAI/DeepSeek 协议要求 role='tool' 消息必须紧跟带 tool_calls 的 * assistant 消息,违反直接 400 且不可重试。engine 侧已保证配对,此处验证 * 共享构建函数对任何来源历史污染的兜底过滤。 */ import { describe, it, expect, vi } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); import { buildOpenAICompatibleMessages } from '../shared/openai-format'; import type { MetonaRequest } from '../../types'; const systemPrompt = { roleDefinition: 'r', outputConstraints: 'o', safetyGuidelines: 's' }; function makeRequest(messages: MetonaRequest['messages']): MetonaRequest { return { meta: { sessionId: 's', iteration: 1, requestId: 'r', timestamp: Date.now(), agentVersion: 't', }, systemPrompt, messages, params: { stream: false }, }; } describe('buildOpenAICompatibleMessages — 孤立 tool 过滤', () => { it('正常配对序列完整保留(assistant(tool_calls) → tool)', () => { const out = buildOpenAICompatibleMessages( makeRequest([ { role: 'user', content: 'hi', timestamp: Date.now() }, { role: 'assistant', content: null, toolCalls: [ { id: 'tc_1', name: 'read_file', args: {}, iteration: 1, timestamp: Date.now() }, ], timestamp: Date.now(), }, { role: 'tool', content: null, toolResult: { toolCallId: 'tc_1', toolName: 'read_file', result: 'data', success: true, durationMs: 1, timestamp: Date.now(), }, timestamp: Date.now(), }, ]), ); // system + user + assistant + tool expect(out).toHaveLength(4); expect(out[2].role).toBe('assistant'); expect(out[2].tool_calls).toHaveLength(1); expect(out[3].role).toBe('tool'); expect(out[3].tool_call_id).toBe('tc_1'); }); it('孤立 tool 消息(前面无 assistant tool_calls)被丢弃,不产生 400 序列', () => { const out = buildOpenAICompatibleMessages( makeRequest([ { role: 'user', content: 'hi', timestamp: Date.now() }, { role: 'tool', content: null, toolResult: { toolCallId: 'tc_orphan', toolName: 'read_file', result: 'x', success: true, durationMs: 1, timestamp: Date.now(), }, timestamp: Date.now(), }, ]), ); // system + user(孤立 tool 被过滤) expect(out).toHaveLength(2); expect(out.every((m) => m.role !== 'tool')).toBe(true); }); it('tool_call_id 不匹配最近 assistant 的 tool 消息也被过滤', () => { const out = buildOpenAICompatibleMessages( makeRequest([ { role: 'user', content: 'hi', timestamp: Date.now() }, { role: 'assistant', content: null, toolCalls: [ { id: 'tc_a', name: 'read_file', args: {}, iteration: 1, timestamp: Date.now() }, ], timestamp: Date.now(), }, { role: 'tool', content: null, // id 不匹配 tc_a toolResult: { toolCallId: 'tc_other', toolName: 'read_file', result: 'x', success: true, durationMs: 1, timestamp: Date.now(), }, timestamp: Date.now(), }, ]), ); expect(out).toHaveLength(3); expect(out.every((m) => m.role !== 'tool')).toBe(true); }); it('多轮工具配对全部保留', () => { const out = buildOpenAICompatibleMessages( makeRequest([ { role: 'user', content: 'hi', timestamp: Date.now() }, { role: 'assistant', content: null, toolCalls: [ { id: 'tc_1', name: 'a', args: {}, iteration: 1, timestamp: Date.now() }, { id: 'tc_2', name: 'b', args: {}, iteration: 1, timestamp: Date.now() }, ], timestamp: Date.now(), }, { role: 'tool', content: null, toolResult: { toolCallId: 'tc_2', toolName: 'b', result: 'r2', success: true, durationMs: 1, timestamp: Date.now(), }, timestamp: Date.now(), }, { role: 'tool', content: null, toolResult: { toolCallId: 'tc_1', toolName: 'a', result: 'r1', success: true, durationMs: 1, timestamp: Date.now(), }, timestamp: Date.now(), }, { role: 'assistant', content: null, toolCalls: [{ id: 'tc_3', name: 'c', args: {}, iteration: 2, timestamp: Date.now() }], timestamp: Date.now(), }, { role: 'tool', content: null, toolResult: { toolCallId: 'tc_3', toolName: 'c', result: 'r3', success: true, durationMs: 1, timestamp: Date.now(), }, timestamp: Date.now(), }, ]), ); expect(out).toHaveLength(7); expect(out.filter((m) => m.role === 'tool')).toHaveLength(3); }); it('includeImages=true 时 images 转 content parts(原位转换,索引与过滤后序列对齐)', () => { const out = buildOpenAICompatibleMessages( makeRequest([ { role: 'user', content: 'look', images: [{ url: 'data:image/png;base64,xxx' }], timestamp: Date.now(), }, ]), true, ); const content = out[1].content as Array>; expect(Array.isArray(content)).toBe(true); expect(content[0]).toEqual({ type: 'text', text: 'look' }); expect(content[1]).toEqual({ type: 'image_url', image_url: { url: 'data:image/png;base64,xxx' }, }); }); it('includeImages=false(DeepSeek 非 vision)时 images 静默丢弃', () => { const out = buildOpenAICompatibleMessages( makeRequest([ { role: 'user', content: 'look', images: [{ url: 'data:image/png;base64,xxx' }], timestamp: Date.now(), }, ]), ); expect(out[1].content).toBe('look'); }); });