From 8309896e7d0897f866e978e466df2b84207e24c2 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Wed, 11 Mar 2026 03:56:05 +0100 Subject: [PATCH] test(providers): add Codex and Ollama message adapter tests --- v2/src/lib/adapters/codex-messages.test.ts | 249 ++++++++++++++++++++ v2/src/lib/adapters/ollama-messages.test.ts | 153 ++++++++++++ 2 files changed, 402 insertions(+) create mode 100644 v2/src/lib/adapters/codex-messages.test.ts create mode 100644 v2/src/lib/adapters/ollama-messages.test.ts diff --git a/v2/src/lib/adapters/codex-messages.test.ts b/v2/src/lib/adapters/codex-messages.test.ts new file mode 100644 index 0000000..e3ac559 --- /dev/null +++ b/v2/src/lib/adapters/codex-messages.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect } from 'vitest'; +import { adaptCodexMessage } from './codex-messages'; + +describe('adaptCodexMessage', () => { + describe('thread.started', () => { + it('maps to init message with thread_id as sessionId', () => { + const result = adaptCodexMessage({ + type: 'thread.started', + thread_id: 'thread-abc-123', + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('init'); + expect((result[0].content as any).sessionId).toBe('thread-abc-123'); + }); + }); + + describe('turn.started', () => { + it('maps to status message', () => { + const result = adaptCodexMessage({ type: 'turn.started' }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('status'); + expect((result[0].content as any).subtype).toBe('turn_started'); + }); + }); + + describe('turn.completed', () => { + it('maps to cost message with token usage', () => { + const result = adaptCodexMessage({ + type: 'turn.completed', + usage: { input_tokens: 1000, output_tokens: 200, cached_input_tokens: 800 }, + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('cost'); + const content = result[0].content as any; + expect(content.inputTokens).toBe(1000); + expect(content.outputTokens).toBe(200); + expect(content.totalCostUsd).toBe(0); + }); + }); + + describe('turn.failed', () => { + it('maps to error message', () => { + const result = adaptCodexMessage({ + type: 'turn.failed', + error: { message: 'Rate limit exceeded' }, + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('error'); + expect((result[0].content as any).message).toBe('Rate limit exceeded'); + }); + }); + + describe('item.completed — agent_message', () => { + it('maps to text message', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { id: 'item_3', type: 'agent_message', text: 'Done. I updated foo.ts.' }, + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('text'); + expect((result[0].content as any).text).toBe('Done. I updated foo.ts.'); + }); + + it('ignores item.started for agent_message', () => { + const result = adaptCodexMessage({ + type: 'item.started', + item: { type: 'agent_message', text: '' }, + }); + expect(result).toHaveLength(0); + }); + }); + + describe('item.completed — reasoning', () => { + it('maps to thinking message', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { type: 'reasoning', text: 'Let me think about this...' }, + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('thinking'); + expect((result[0].content as any).text).toBe('Let me think about this...'); + }); + }); + + describe('item — command_execution', () => { + it('maps item.started to tool_call', () => { + const result = adaptCodexMessage({ + type: 'item.started', + item: { id: 'item_1', type: 'command_execution', command: 'ls -la', status: 'in_progress' }, + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('tool_call'); + expect((result[0].content as any).name).toBe('Bash'); + expect((result[0].content as any).input.command).toBe('ls -la'); + }); + + it('maps item.completed to tool_call + tool_result pair', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { + id: 'item_1', + type: 'command_execution', + command: 'ls -la', + aggregated_output: 'total 48\ndrwxr-xr-x', + exit_code: 0, + status: 'completed', + }, + }); + expect(result).toHaveLength(2); + expect(result[0].type).toBe('tool_call'); + expect(result[1].type).toBe('tool_result'); + expect((result[1].content as any).output).toBe('total 48\ndrwxr-xr-x'); + }); + + it('ignores item.updated for command_execution', () => { + const result = adaptCodexMessage({ + type: 'item.updated', + item: { type: 'command_execution', command: 'ls', status: 'in_progress' }, + }); + expect(result).toHaveLength(0); + }); + }); + + describe('item.completed — file_change', () => { + it('maps file changes to tool_call + tool_result pairs', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { + type: 'file_change', + changes: [ + { path: 'src/foo.ts', kind: 'update' }, + { path: 'src/bar.ts', kind: 'add' }, + ], + status: 'completed', + }, + }); + expect(result).toHaveLength(4); + expect(result[0].type).toBe('tool_call'); + expect((result[0].content as any).name).toBe('Edit'); + expect(result[1].type).toBe('tool_result'); + expect(result[2].type).toBe('tool_call'); + expect((result[2].content as any).name).toBe('Write'); + }); + + it('maps delete to Bash tool name', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { + type: 'file_change', + changes: [{ path: 'old.ts', kind: 'delete' }], + status: 'completed', + }, + }); + expect(result).toHaveLength(2); + expect((result[0].content as any).name).toBe('Bash'); + }); + + it('returns empty for no changes', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { type: 'file_change', changes: [], status: 'completed' }, + }); + expect(result).toHaveLength(0); + }); + }); + + describe('item.completed — mcp_tool_call', () => { + it('maps to tool_call + tool_result with server:tool name', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { + id: 'mcp_1', + type: 'mcp_tool_call', + server: 'filesystem', + tool: 'read_file', + arguments: { path: '/tmp/test.txt' }, + result: { content: 'file contents' }, + status: 'completed', + }, + }); + expect(result).toHaveLength(2); + expect((result[0].content as any).name).toBe('filesystem:read_file'); + expect((result[0].content as any).input.path).toBe('/tmp/test.txt'); + }); + + it('maps error result to error message in tool_result', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { + id: 'mcp_2', + type: 'mcp_tool_call', + server: 'fs', + tool: 'write', + arguments: {}, + error: { message: 'Permission denied' }, + status: 'completed', + }, + }); + expect(result).toHaveLength(2); + expect((result[1].content as any).output).toBe('Permission denied'); + }); + }); + + describe('item.completed — web_search', () => { + it('maps to WebSearch tool_call', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { id: 'ws_1', type: 'web_search', query: 'ollama api docs' }, + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('tool_call'); + expect((result[0].content as any).name).toBe('WebSearch'); + expect((result[0].content as any).input.query).toBe('ollama api docs'); + }); + }); + + describe('item — error', () => { + it('maps to error message', () => { + const result = adaptCodexMessage({ + type: 'item.completed', + item: { type: 'error', message: 'Sandbox violation' }, + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('error'); + expect((result[0].content as any).message).toBe('Sandbox violation'); + }); + }); + + describe('top-level error', () => { + it('maps to error message', () => { + const result = adaptCodexMessage({ + type: 'error', + message: 'Connection lost', + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('error'); + expect((result[0].content as any).message).toBe('Connection lost'); + }); + }); + + describe('unknown event type', () => { + it('maps to unknown message preserving raw data', () => { + const result = adaptCodexMessage({ type: 'custom.event', data: 42 }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('unknown'); + expect((result[0].content as any).data).toBe(42); + }); + }); +}); diff --git a/v2/src/lib/adapters/ollama-messages.test.ts b/v2/src/lib/adapters/ollama-messages.test.ts new file mode 100644 index 0000000..65d4719 --- /dev/null +++ b/v2/src/lib/adapters/ollama-messages.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from 'vitest'; +import { adaptOllamaMessage } from './ollama-messages'; + +describe('adaptOllamaMessage', () => { + describe('system init', () => { + it('maps to init message', () => { + const result = adaptOllamaMessage({ + type: 'system', + subtype: 'init', + session_id: 'sess-123', + model: 'qwen3:8b', + cwd: '/home/user/project', + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('init'); + const content = result[0].content as any; + expect(content.sessionId).toBe('sess-123'); + expect(content.model).toBe('qwen3:8b'); + expect(content.cwd).toBe('/home/user/project'); + }); + }); + + describe('system status', () => { + it('maps non-init subtypes to status message', () => { + const result = adaptOllamaMessage({ + type: 'system', + subtype: 'model_loaded', + status: 'Model loaded successfully', + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('status'); + expect((result[0].content as any).subtype).toBe('model_loaded'); + expect((result[0].content as any).message).toBe('Model loaded successfully'); + }); + }); + + describe('chunk — text content', () => { + it('maps streaming text to text message', () => { + const result = adaptOllamaMessage({ + type: 'chunk', + message: { role: 'assistant', content: 'Hello world' }, + done: false, + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('text'); + expect((result[0].content as any).text).toBe('Hello world'); + }); + + it('ignores empty content', () => { + const result = adaptOllamaMessage({ + type: 'chunk', + message: { role: 'assistant', content: '' }, + done: false, + }); + expect(result).toHaveLength(0); + }); + }); + + describe('chunk — thinking content', () => { + it('maps thinking field to thinking message', () => { + const result = adaptOllamaMessage({ + type: 'chunk', + message: { role: 'assistant', content: '', thinking: 'Let me reason about this...' }, + done: false, + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('thinking'); + expect((result[0].content as any).text).toBe('Let me reason about this...'); + }); + + it('emits both thinking and text when both present', () => { + const result = adaptOllamaMessage({ + type: 'chunk', + message: { role: 'assistant', content: 'Answer', thinking: 'Reasoning' }, + done: false, + }); + expect(result).toHaveLength(2); + expect(result[0].type).toBe('thinking'); + expect(result[1].type).toBe('text'); + }); + }); + + describe('chunk — done with token counts', () => { + it('maps final chunk to cost message', () => { + const result = adaptOllamaMessage({ + type: 'chunk', + message: { role: 'assistant', content: '' }, + done: true, + done_reason: 'stop', + prompt_eval_count: 500, + eval_count: 120, + eval_duration: 2_000_000_000, + total_duration: 3_000_000_000, + }); + // Should have cost message (no text since content is empty) + const costMsg = result.find(m => m.type === 'cost'); + expect(costMsg).toBeDefined(); + const content = costMsg!.content as any; + expect(content.inputTokens).toBe(500); + expect(content.outputTokens).toBe(120); + expect(content.durationMs).toBe(2000); + expect(content.totalCostUsd).toBe(0); + expect(content.isError).toBe(false); + }); + + it('marks error done_reason as isError', () => { + const result = adaptOllamaMessage({ + type: 'chunk', + message: { role: 'assistant', content: '' }, + done: true, + done_reason: 'error', + prompt_eval_count: 0, + eval_count: 0, + }); + const costMsg = result.find(m => m.type === 'cost'); + expect(costMsg).toBeDefined(); + expect((costMsg!.content as any).isError).toBe(true); + }); + + it('includes text + cost when final chunk has content', () => { + const result = adaptOllamaMessage({ + type: 'chunk', + message: { role: 'assistant', content: '.' }, + done: true, + done_reason: 'stop', + prompt_eval_count: 10, + eval_count: 5, + }); + expect(result.some(m => m.type === 'text')).toBe(true); + expect(result.some(m => m.type === 'cost')).toBe(true); + }); + }); + + describe('error event', () => { + it('maps to error message', () => { + const result = adaptOllamaMessage({ + type: 'error', + message: 'model not found', + }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('error'); + expect((result[0].content as any).message).toBe('model not found'); + }); + }); + + describe('unknown event type', () => { + it('maps to unknown message preserving raw data', () => { + const result = adaptOllamaMessage({ type: 'something_else', data: 'test' }); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('unknown'); + }); + }); +});