/** * OllamaAdapter 独立测试 * * 覆盖契约: * - NDJSON 流式解析(thinking/content/tool_calls/done usage) * - 工具参数坏 JSON 自愈(_truncatedArguments) * - pullModel 进度回调 / 取消信号 / 非 JSON 行跳过 * - probeCapabilities / showModel / listModels / getVersion / listRunning * - 图片 URL → base64 下载、data URI 剥前缀、失败降级 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); import { OllamaAdapter } from '../ollama.adapter'; // v0.8.2 P0-1: 图片下载走 SSRF 安全通道(注入点替代 global fetch 打桩) import { __imageFetcher } from '../shared/ssrf-image-fetch'; import type { ImageFetcher } from '../shared/ssrf-image-fetch'; import type { MetonaRequest } from '../../types'; import { MetonaStreamEventType } from '../../types'; const mockFetch = vi.fn(); function makeAdapter(model = 'qwen3', overrides: Record = {}): OllamaAdapter { return new OllamaAdapter({ provider: 'ollama', baseURL: 'http://localhost:11434', defaultModel: model, ...overrides, }); } function makeRequest(overrides?: Partial): MetonaRequest { return { meta: { sessionId: 's1', iteration: 1, requestId: 'r1', timestamp: Date.now(), agentVersion: 'test', }, systemPrompt: { roleDefinition: 'You are Metona.', outputConstraints: '', safetyGuidelines: '', }, messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }], params: { maxTokens: 4096, temperature: 0, stream: true }, ...overrides, }; } function mockNDJSON(lines: unknown[]): void { const payload = lines.map((l) => JSON.stringify(l)).join('\n') + '\n'; const body = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(payload)); controller.close(); }, }); mockFetch.mockResolvedValue(new Response(body, { status: 200 })); } async function collectStream( adapter: OllamaAdapter, request: MetonaRequest, ): Promise< Array<{ type: string; delta?: string; usage?: Record; toolCall?: Record; }> > { const events: Array<{ type: string; delta?: string; usage?: Record; toolCall?: Record; }> = []; for await (const ev of adapter.sendStream(request)) { events.push({ type: ev.type, delta: ev.delta, usage: ev.usage as Record | undefined, toolCall: ev.toolCall as Record | undefined, }); } return events; } beforeEach(() => { mockFetch.mockReset(); vi.stubGlobal('fetch', mockFetch); }); afterEach(() => { vi.unstubAllGlobals(); }); // ===== NDJSON 流式解析 ===== describe('OllamaAdapter — NDJSON 流式解析', () => { it('thinking + content 逐行产出 REASONING_DELTA / TEXT_DELTA', async () => { const adapter = makeAdapter(); mockNDJSON([ { model: 'qwen3', message: { role: 'assistant', thinking: '思考一' } }, { model: 'qwen3', message: { role: 'assistant', content: '正文一' } }, { model: 'qwen3', message: { role: 'assistant', thinking: '思考二' } }, { model: 'qwen3', message: { role: 'assistant', content: '正文二' } }, { model: 'qwen3', message: { role: 'assistant', content: '' }, done: true, done_reason: 'stop', prompt_eval_count: 3, eval_count: 2, }, ]); const events = await collectStream(adapter, makeRequest()); const reasoning = events.filter((e) => e.type === MetonaStreamEventType.REASONING_DELTA); expect(reasoning.map((e) => e.delta)).toEqual(['思考一', '思考二']); const text = events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA); expect(text.map((e) => e.delta)).toEqual(['正文一', '正文二']); }); it('tool_calls 整块返回 → TOOL_CALL_COMPLETE(字符串 arguments 解析)', async () => { const adapter = makeAdapter(); mockNDJSON([ { model: 'qwen3', message: { role: 'assistant', content: '', tool_calls: [ { function: { name: 'read_file', arguments: '{"path":"a.txt"}' } }, { function: { name: 'list_dir', arguments: '{"path":"."}' } }, ], }, }, { model: 'qwen3', message: { role: 'assistant', content: '' }, done: true, done_reason: 'stop', prompt_eval_count: 1, eval_count: 1, }, ]); const events = await collectStream(adapter, makeRequest()); const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE); expect(completes).toHaveLength(2); expect(completes[0].toolCall!.name).toBe('read_file'); expect(completes[0].toolCall!.args).toEqual({ path: 'a.txt' }); expect(completes[1].toolCall!.name).toBe('list_dir'); expect(completes[1].toolCall!.id).toMatch(/^tc_/); }); it('done chunk → USAGE(prompt_eval_count/eval_count)+ DONE', async () => { const adapter = makeAdapter(); mockNDJSON([ { model: 'qwen3', message: { role: 'assistant', content: 'x' } }, { model: 'qwen3', message: { role: 'assistant', content: '' }, done: true, done_reason: 'stop', prompt_eval_count: 11, eval_count: 7, }, ]); const events = await collectStream(adapter, makeRequest()); const usage = events.find((e) => e.type === MetonaStreamEventType.USAGE)?.usage; expect(usage).toMatchObject({ inputTokens: 11, outputTokens: 7, totalTokens: 18 }); expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE); }); it('坏 NDJSON 行被跳过不中断流(记录警告)', async () => { const adapter = makeAdapter(); mockFetch.mockResolvedValue( new Response( new ReadableStream({ start(controller) { const encoder = new TextEncoder(); controller.enqueue(encoder.encode('{bad json\n')); controller.enqueue( encoder.encode( JSON.stringify({ model: 'm', message: { role: 'assistant', content: 'ok' } }) + '\n', ), ); controller.enqueue( encoder.encode( JSON.stringify({ model: 'm', message: { role: 'assistant', content: '' }, done: true, }) + '\n', ), ); controller.close(); }, }), { status: 200 }, ), ); const events = await collectStream(adapter, makeRequest()); expect( events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA && e.delta === 'ok'), ).toBe(true); expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE); }); it('流断开(无 done 行)→ 补发 DONE 防止挂起', async () => { const adapter = makeAdapter(); mockNDJSON([{ model: 'm', message: { role: 'assistant', content: 'partial' } }]); const events = await collectStream(adapter, makeRequest()); expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE); }); it('工具参数坏 JSON → _truncatedArguments 自愈,且同 chunk 后续 done 仍处理', async () => { const adapter = makeAdapter(); mockNDJSON([ { model: 'qwen3', message: { role: 'assistant', tool_calls: [{ function: { name: 'write', arguments: '{"path":"a","cont' } }], }, }, { model: 'qwen3', message: { role: 'assistant', content: '' }, done: true, done_reason: 'stop', prompt_eval_count: 5, eval_count: 1, }, ]); const events = await collectStream(adapter, makeRequest()); const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE); expect(completes).toHaveLength(1); const args = completes[0].toolCall!.args as Record; expect(args._truncatedArguments).toBe(true); expect(String(args._truncatedReason)).toContain('truncated'); // done/USAGE 未被吞 expect(events.some((e) => e.type === MetonaStreamEventType.USAGE)).toBe(true); expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE); }); it('工具参数为对象类型 → 原样保留', async () => { const adapter = makeAdapter(); mockNDJSON([ { model: 'm', message: { role: 'assistant', tool_calls: [{ function: { name: 'f', arguments: { a: 1 } } }], }, }, { model: 'm', message: { role: 'assistant', content: '' }, done: true, done_reason: 'stop' }, ]); const events = await collectStream(adapter, makeRequest()); const complete = events.find((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE); expect(complete?.toolCall?.args).toEqual({ a: 1 }); }); }); // ===== 非流式 send 响应 ===== describe('OllamaAdapter — 非流式 send', () => { function mockJsonResponse(body: unknown): void { mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => body, } as unknown as Response); } it('send 组装 MetonaResponse(含 perfStats / done_reason 映射)', async () => { const adapter = makeAdapter(); mockJsonResponse({ model: 'qwen3', message: { role: 'assistant', content: 'answer', thinking: 'trace' }, done: true, done_reason: 'stop', prompt_eval_count: 4, eval_count: 3, load_duration: 1_000_000, eval_duration: 2_000_000_000, }); const res = await adapter.send(makeRequest({ params: { stream: false } })); expect(res.content).toBe('answer'); expect(res.reasoningContent).toBe('trace'); expect(res.usage.totalTokens).toBe(7); expect(res.finishReason).toBe('stop'); expect(res.meta.perfStats).toMatchObject({ loadDurationMs: 1, evalDurationMs: 2000, }); }); it('done_reason=length → LENGTH finishReason', async () => { const adapter = makeAdapter(); mockJsonResponse({ message: { role: 'assistant', content: 'cut' }, done: true, done_reason: 'length', prompt_eval_count: 1, eval_count: 100, }); const res = await adapter.send(makeRequest()); expect(res.finishReason).toBe('length'); }); it('message.tool_calls 存在 → TOOL_CALLS finishReason(done_reason 无关)', async () => { const adapter = makeAdapter(); mockJsonResponse({ message: { role: 'assistant', content: '', tool_calls: [{ function: { name: 'f', arguments: '{"a":1}' } }], }, done: true, done_reason: 'stop', prompt_eval_count: 0, eval_count: 0, }); const res = await adapter.send(makeRequest()); expect(res.finishReason).toBe('tool_calls'); expect(res.toolCalls).toHaveLength(1); }); it('非流式坏参 → _truncatedArguments 自愈', async () => { const adapter = makeAdapter(); mockJsonResponse({ message: { role: 'assistant', content: '', tool_calls: [{ function: { name: 'f', arguments: '{bad' } }], }, done: true, done_reason: 'tool_calls', prompt_eval_count: 0, eval_count: 0, }); const res = await adapter.send(makeRequest()); const args = res.toolCalls![0].args as Record; expect(args._truncatedArguments).toBe(true); }); it('HTTP 错误 → throwHttpError 携带 status', async () => { const adapter = makeAdapter(); mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error', text: async () => 'oops', } as unknown as Response); const err = await adapter.send(makeRequest()).catch((e: unknown) => e); expect((err as Error & { status?: number }).status).toBe(500); }); }); // ===== pullModel ===== describe('OllamaAdapter — pullModel 进度 / 取消', () => { it('进度逐行回调(status/completed/total),结束后 resolve', async () => { const adapter = makeAdapter(); const lines = [ { status: 'pulling manifest' }, { status: 'downloading', completed: 50, total: 100 }, { status: 'downloading', completed: 100, total: 100 }, { status: 'success', completed: 100, total: 100 }, ]; const body = new ReadableStream({ start(controller) { const encoder = new TextEncoder(); for (const l of lines) controller.enqueue(encoder.encode(JSON.stringify(l) + '\n')); controller.close(); }, }); mockFetch.mockResolvedValue(new Response(body, { status: 200 })); const progress: Array> = []; await adapter.pullModel('qwen3:8b', (p) => progress.push(p as Record)); expect(progress).toHaveLength(4); expect(progress[1]).toEqual({ status: 'downloading', completed: 50, total: 100 }); expect(mockFetch).toHaveBeenCalledWith( 'http://localhost:11434/api/pull', expect.objectContaining({ body: JSON.stringify({ model: 'qwen3:8b', stream: true }) }), ); }); it('非 JSON 行(进度提示)被跳过不中断', async () => { const adapter = makeAdapter(); const body = new ReadableStream({ start(controller) { const encoder = new TextEncoder(); controller.enqueue(encoder.encode('some text line\n')); controller.enqueue(encoder.encode(JSON.stringify({ status: 'success' }) + '\n')); controller.close(); }, }); mockFetch.mockResolvedValue(new Response(body, { status: 200 })); const progress: Array> = []; await adapter.pullModel('m', (p) => progress.push(p as Record)); expect(progress).toEqual([{ status: 'success' }]); }); it('取消信号中止底层 fetch(signal 透传)', async () => { const adapter = makeAdapter(); const controller = new AbortController(); const signalSpy = vi.fn(); mockFetch.mockImplementation((_url: string, init: RequestInit) => { init.signal?.addEventListener('abort', signalSpy); return new Promise((_r, rej) => { init.signal?.addEventListener('abort', () => rej(new DOMException('aborted', 'AbortError')), ); }); }); const promise = adapter.pullModel('qwen3:8b', undefined, controller.signal); controller.abort(); await expect(promise).rejects.toMatchObject({ name: 'AbortError' }); expect(signalSpy).toHaveBeenCalled(); }); it('HTTP 非 2xx → 抛 Ollama pull error', async () => { const adapter = makeAdapter(); mockFetch.mockResolvedValue({ ok: false, status: 404, body: null } as unknown as Response); await expect(adapter.pullModel('nope')).rejects.toThrow('Ollama pull error: 404'); }); }); // ===== probeCapabilities / showModel / listModels ===== describe('OllamaAdapter — 能力探测', () => { it('probeCapabilities 从 capabilities[] 解析三能力', async () => { const adapter = makeAdapter(); mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ parameters: '', template: '', capabilities: ['tools', 'vision', 'thinking'], }), } as unknown as Response); const caps = await adapter.probeCapabilities('qwen3'); expect(caps).toEqual({ supportsTools: true, supportsVision: true, supportsThinking: true }); }); // v0.8.1: showModel 保留 undefined 语义 —— 响应缺 capabilities 字段 = 未知 → null //(fail-open),不再把"字段缺失"与"权威空"混同(探测竞态下曾误判不支持思考) it('capabilities 字段缺失 → 返回 null(未知,fail-open)', async () => { const adapter = makeAdapter(); mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ parameters: '', template: '' }), } as unknown as Response); expect(await adapter.probeCapabilities('qwen3')).toBeNull(); }); it('capabilities 为显式空数组(服务端权威无能力)→ 三能力全 false', async () => { const adapter = makeAdapter(); mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ parameters: '', template: '', capabilities: [] }), } as unknown as Response); expect(await adapter.probeCapabilities('qwen3')).toEqual({ supportsTools: false, supportsVision: false, supportsThinking: false, }); }); it('showModel 失败 → probeCapabilities 返回 null(调用方回退保守 true)', async () => { const adapter = makeAdapter(); mockFetch.mockRejectedValue(new Error('down')); expect(await adapter.probeCapabilities('qwen3')).toBeNull(); }); it('showModel 非 2xx → null', async () => { const adapter = makeAdapter(); mockFetch.mockResolvedValue({ ok: false, status: 500 } as unknown as Response); expect(await adapter.showModel('m')).toBeNull(); }); it('showModel 网络异常 → null(不抛出)', async () => { const adapter = makeAdapter(); mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); expect(await adapter.showModel('m')).toBeNull(); }); it('listModels:/api/tags 成功 → 逐模型探测并组装元信息', async () => { // 构造时的 fire-and-forget /api/show 探测会先消费第一个 mock → 先给良性响应 mockFetch .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ parameters: '' }), } as unknown as Response) .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ models: [ { name: 'qwen3', size: 123, details: { family: 'qwen', parameter_size: '8B', quantization_level: 'Q4' }, }, ], }), } as unknown as Response) .mockResolvedValue({ ok: true, status: 200, json: async () => ({ parameters: '', template: '', capabilities: ['tools', 'vision'] }), } as unknown as Response); const adapter = makeAdapter(); const models = await adapter.listModels(); expect(models).toHaveLength(1); expect(models[0]).toMatchObject({ id: 'qwen3', supportsToolCalling: true, supportsVision: true, supportsThinking: false, }); expect(models[0].description).toContain('qwen / 8B / Q4'); }); it('listModels:API 失败 → 降级 supportedModels', async () => { const adapter = makeAdapter(); mockFetch.mockRejectedValue(new Error('network')); const models = await adapter.listModels(); expect(models.map((m) => m.id)).toEqual([ 'qwen3:latest', 'gemma3:latest', 'deepseek-r1:latest', ]); }); it('getVersion 成功返回版本号;失败返回 unknown', async () => { const adapter = makeAdapter(); mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ version: '0.5.1' }), } as unknown as Response); expect(await adapter.getVersion()).toBe('0.5.1'); mockFetch.mockReset(); mockFetch.mockRejectedValue(new Error('down')); expect(await adapter.getVersion()).toBe('unknown'); }); it('listRunning 映射运行中模型;失败返回空数组', async () => { const adapter = makeAdapter(); mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ models: [{ name: 'qwen3', size: 100, size_vram: 90, context_length: 4096 }], }), } as unknown as Response); const running = await adapter.listRunning(); expect(running[0]).toEqual({ name: 'qwen3', size: 100, sizeVram: 90, contextLength: 4096 }); mockFetch.mockReset(); mockFetch.mockRejectedValue(new Error('down')); expect(await adapter.listRunning()).toEqual([]); }); }); // ===== 图片 URL → base64 ===== describe('OllamaAdapter — 图片归一化', () => { function okChatResponse(): Response { return { ok: true, status: 200, json: async () => ({ message: { role: 'assistant', content: 'ok' }, done: true, done_reason: 'stop', }), } as unknown as Response; } function lastBody(): Record { const calls = mockFetch.mock.calls; const chatCall = calls.find(([url]) => String(url).includes('/api/chat')) as [ string, RequestInit, ]; return JSON.parse(String(chatCall[1].body)); } it('http URL 图片下载为纯 base64(无 data: 前缀)', async () => { const adapter = makeAdapter(); const imageBytes = new TextEncoder().encode('IMG-BYTES'); // v0.8.2 P0-1: 下载经 SSRF 安全通道(注入受控下载器) const restore = __imageFetcher.current; __imageFetcher.current = (async () => new Response(imageBytes, { status: 200, headers: { 'content-type': 'image/png' }, })) as unknown as ImageFetcher; try { mockFetch.mockResolvedValue(okChatResponse()); await adapter.send( makeRequest({ messages: [ { role: 'user', content: '看图', images: [{ url: 'https://example.com/pic.png' }], timestamp: Date.now(), }, ], }), ); const body = lastBody(); const userMsg = (body.messages as Array>).find( (m) => m.role === 'user', ); expect(userMsg!.images).toEqual([Buffer.from('IMG-BYTES').toString('base64')]); } finally { __imageFetcher.current = restore; } }); it('http URL 下载失败 → 图片被忽略(空数组/不发送),请求不阻断', async () => { const adapter = makeAdapter(); const restore = __imageFetcher.current; __imageFetcher.current = (async () => { throw new Error('ECONNREFUSED'); }) as unknown as ImageFetcher; try { mockFetch.mockResolvedValue(okChatResponse()); const res = await adapter.send( makeRequest({ messages: [ { role: 'user', content: '看图', images: [{ url: 'https://example.com/pic.png' }], timestamp: Date.now(), }, ], }), ); expect(res.content).toBe('ok'); } finally { __imageFetcher.current = restore; } }); it('data URI 图片剥前缀;纯 base64 原样保留', async () => { const adapter = makeAdapter(); mockFetch.mockResolvedValue(okChatResponse()); await adapter.send( makeRequest({ messages: [ { role: 'user', content: '看图', images: [ { url: 'data:image/png;base64,AAAABBBB' }, { url: 'iVBORw0KGgo', detail: 'high' }, ], timestamp: Date.now(), }, ], }), ); const body = lastBody(); const userMsg = (body.messages as Array>).find( (m) => m.role === 'user', ); expect(userMsg!.images).toEqual(['AAAABBBB', 'iVBORw0KGgo']); }); it('HTTP 下载响应非 2xx → 图片降级忽略', async () => { const adapter = makeAdapter(); const restore = __imageFetcher.current; __imageFetcher.current = (async () => new Response(null, { status: 404 })) as unknown as ImageFetcher; try { mockFetch.mockResolvedValue(okChatResponse()); await adapter.send( makeRequest({ messages: [ { role: 'user', content: '看图', images: [{ url: 'https://example.com/missing.png' }], timestamp: Date.now(), }, ], }), ); const body = lastBody(); const userMsg = (body.messages as Array>).find( (m) => m.role === 'user', ); expect(userMsg!.images).toEqual([]); } finally { __imageFetcher.current = restore; } }); }); // ===== getContextWindow ===== describe('OllamaAdapter — getContextWindow(v0.8.1:唯一来源是设置面板配置)', () => { it('未配置 contextWindow → 返回 0(无 4096 写死兜底,引擎跳过压缩判定)', () => { const adapter = makeAdapter(); expect(adapter.getContextWindow()).toBe(0); }); it('config.contextWindow(llm.contextWindow 注入)→ 返回配置值', () => { const adapter = makeAdapter('qwen3', { contextWindow: 32_768 }); expect(adapter.getContextWindow()).toBe(32_768); }); it('/api/show 探测不再缓存窗口数值(num_ctx 由引擎 contextLength 下发)', async () => { mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ parameters: 'num_ctx 32768', template: '', capabilities: [] }), } as unknown as Response); const adapter = makeAdapter(); // 探测完成(含失败路径)后窗口仍为 0 —— 探测只服务于能力门控 await new Promise((r) => setTimeout(r, 20)); expect(adapter.getContextWindow()).toBe(0); }); it('探测失败(网络错误)→ 仍返回配置值/0,不抛错不阻塞', async () => { mockFetch.mockRejectedValue(new Error('down')); const adapter = makeAdapter(); await new Promise((r) => setTimeout(r, 20)); expect(adapter.getContextWindow()).toBe(0); }); });