From 034bd059b399825c63ae27ba2a9716192974dc39 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 13 Feb 2026 04:21:51 +0900 Subject: [PATCH] add unit-tests --- .../claude-executor-structured-output.test.ts | 164 ++++++++++++ src/__tests__/codex-structured-output.test.ts | 172 ++++++++++++ .../provider-structured-output.test.ts | 244 ++++++++++++++++++ 3 files changed, 580 insertions(+) create mode 100644 src/__tests__/claude-executor-structured-output.test.ts create mode 100644 src/__tests__/codex-structured-output.test.ts create mode 100644 src/__tests__/provider-structured-output.test.ts diff --git a/src/__tests__/claude-executor-structured-output.test.ts b/src/__tests__/claude-executor-structured-output.test.ts new file mode 100644 index 0000000..4bbe16e --- /dev/null +++ b/src/__tests__/claude-executor-structured-output.test.ts @@ -0,0 +1,164 @@ +/** + * Claude SDK layer structured output tests. + * + * Tests two internal components: + * 1. SdkOptionsBuilder — outputSchema → outputFormat conversion + * 2. QueryExecutor — structured_output extraction from SDK result messages + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ===== SdkOptionsBuilder tests (no mock needed) ===== + +import { buildSdkOptions } from '../infra/claude/options-builder.js'; + +describe('SdkOptionsBuilder — outputFormat 変換', () => { + it('outputSchema が outputFormat に変換される', () => { + const schema = { type: 'object', properties: { step: { type: 'integer' } } }; + const sdkOptions = buildSdkOptions({ cwd: '/tmp', outputSchema: schema }); + + expect((sdkOptions as Record).outputFormat).toEqual({ + type: 'json_schema', + schema, + }); + }); + + it('outputSchema 未設定なら outputFormat は含まれない', () => { + const sdkOptions = buildSdkOptions({ cwd: '/tmp' }); + expect(sdkOptions).not.toHaveProperty('outputFormat'); + }); +}); + +// ===== QueryExecutor tests (mock @anthropic-ai/claude-agent-sdk) ===== + +const { mockQuery } = vi.hoisted(() => ({ + mockQuery: vi.fn(), +})); + +vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ + query: mockQuery, + AbortError: class AbortError extends Error { + constructor(message?: string) { + super(message); + this.name = 'AbortError'; + } + }, +})); + +// QueryExecutor は executor.ts 内で query() を使うため、mock 後にインポート +const { QueryExecutor } = await import('../infra/claude/executor.js'); + +/** + * query() が返す Query オブジェクト(async iterable + interrupt)のモック + */ +function createMockQuery(messages: Array>) { + return { + [Symbol.asyncIterator]: async function* () { + for (const msg of messages) { + yield msg; + } + }, + interrupt: vi.fn(), + }; +} + +describe('QueryExecutor — structuredOutput 抽出', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('result メッセージの structured_output (snake_case) を抽出する', async () => { + mockQuery.mockReturnValue(createMockQuery([ + { type: 'result', subtype: 'success', result: 'done', structured_output: { step: 2 } }, + ])); + + const executor = new QueryExecutor(); + const result = await executor.execute('test', { cwd: '/tmp' }); + + expect(result.success).toBe(true); + expect(result.structuredOutput).toEqual({ step: 2 }); + }); + + it('result メッセージの structuredOutput (camelCase) を抽出する', async () => { + mockQuery.mockReturnValue(createMockQuery([ + { type: 'result', subtype: 'success', result: 'done', structuredOutput: { step: 3 } }, + ])); + + const executor = new QueryExecutor(); + const result = await executor.execute('test', { cwd: '/tmp' }); + + expect(result.structuredOutput).toEqual({ step: 3 }); + }); + + it('structured_output が snake_case 優先 (snake_case と camelCase 両方ある場合)', async () => { + mockQuery.mockReturnValue(createMockQuery([ + { + type: 'result', + subtype: 'success', + result: 'done', + structured_output: { step: 1 }, + structuredOutput: { step: 9 }, + }, + ])); + + const executor = new QueryExecutor(); + const result = await executor.execute('test', { cwd: '/tmp' }); + + expect(result.structuredOutput).toEqual({ step: 1 }); + }); + + it('structuredOutput がない場合は undefined', async () => { + mockQuery.mockReturnValue(createMockQuery([ + { type: 'result', subtype: 'success', result: 'plain text' }, + ])); + + const executor = new QueryExecutor(); + const result = await executor.execute('test', { cwd: '/tmp' }); + + expect(result.structuredOutput).toBeUndefined(); + }); + + it('structured_output が配列の場合は無視する', async () => { + mockQuery.mockReturnValue(createMockQuery([ + { type: 'result', subtype: 'success', result: 'done', structured_output: [1, 2, 3] }, + ])); + + const executor = new QueryExecutor(); + const result = await executor.execute('test', { cwd: '/tmp' }); + + expect(result.structuredOutput).toBeUndefined(); + }); + + it('structured_output が null の場合は無視する', async () => { + mockQuery.mockReturnValue(createMockQuery([ + { type: 'result', subtype: 'success', result: 'done', structured_output: null }, + ])); + + const executor = new QueryExecutor(); + const result = await executor.execute('test', { cwd: '/tmp' }); + + expect(result.structuredOutput).toBeUndefined(); + }); + + it('assistant テキストと structured_output を同時に取得する', async () => { + mockQuery.mockReturnValue(createMockQuery([ + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'thinking...' }] }, + }, + { + type: 'result', + subtype: 'success', + result: 'final text', + structured_output: { step: 1, reason: 'approved' }, + }, + ])); + + const executor = new QueryExecutor(); + const result = await executor.execute('test', { cwd: '/tmp' }); + + expect(result.success).toBe(true); + expect(result.content).toBe('final text'); + expect(result.structuredOutput).toEqual({ step: 1, reason: 'approved' }); + }); +}); diff --git a/src/__tests__/codex-structured-output.test.ts b/src/__tests__/codex-structured-output.test.ts new file mode 100644 index 0000000..1ceb12c --- /dev/null +++ b/src/__tests__/codex-structured-output.test.ts @@ -0,0 +1,172 @@ +/** + * Codex SDK layer structured output tests. + * + * Tests CodexClient's extraction of structuredOutput from + * `turn.completed` events' `finalResponse` field. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ===== Codex SDK mock ===== + +let mockEvents: Array> = []; + +vi.mock('@openai/codex-sdk', () => { + return { + Codex: class MockCodex { + async startThread() { + return { + id: 'thread-mock', + runStreamed: async () => ({ + events: (async function* () { + for (const event of mockEvents) { + yield event; + } + })(), + }), + }; + } + async resumeThread() { + return this.startThread(); + } + }, + }; +}); + +// CodexClient は @openai/codex-sdk をインポートするため、mock 後にインポート +const { CodexClient } = await import('../infra/codex/client.js'); + +describe('CodexClient — structuredOutput 抽出', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockEvents = []; + }); + + it('turn.completed の finalResponse を structuredOutput として返す', async () => { + mockEvents = [ + { type: 'thread.started', thread_id: 'thread-1' }, + { + type: 'item.completed', + item: { id: 'msg-1', type: 'agent_message', text: 'response text' }, + }, + { + type: 'turn.completed', + turn: { finalResponse: { step: 2, reason: 'approved' } }, + }, + ]; + + const client = new CodexClient(); + const result = await client.call('coder', 'prompt', { cwd: '/tmp' }); + + expect(result.status).toBe('done'); + expect(result.structuredOutput).toEqual({ step: 2, reason: 'approved' }); + }); + + it('turn.completed に finalResponse がない場合は undefined', async () => { + mockEvents = [ + { type: 'thread.started', thread_id: 'thread-1' }, + { + type: 'item.completed', + item: { id: 'msg-1', type: 'agent_message', text: 'text' }, + }, + { type: 'turn.completed', turn: {} }, + ]; + + const client = new CodexClient(); + const result = await client.call('coder', 'prompt', { cwd: '/tmp' }); + + expect(result.status).toBe('done'); + expect(result.structuredOutput).toBeUndefined(); + }); + + it('finalResponse が配列の場合は無視する', async () => { + mockEvents = [ + { type: 'thread.started', thread_id: 'thread-1' }, + { + type: 'item.completed', + item: { id: 'msg-1', type: 'agent_message', text: 'text' }, + }, + { type: 'turn.completed', turn: { finalResponse: [1, 2, 3] } }, + ]; + + const client = new CodexClient(); + const result = await client.call('coder', 'prompt', { cwd: '/tmp' }); + + expect(result.structuredOutput).toBeUndefined(); + }); + + it('finalResponse が null の場合は undefined', async () => { + mockEvents = [ + { type: 'thread.started', thread_id: 'thread-1' }, + { type: 'turn.completed', turn: { finalResponse: null } }, + ]; + + const client = new CodexClient(); + const result = await client.call('coder', 'prompt', { cwd: '/tmp' }); + + expect(result.structuredOutput).toBeUndefined(); + }); + + it('turn.completed がない場合は structuredOutput なし', async () => { + mockEvents = [ + { type: 'thread.started', thread_id: 'thread-1' }, + { + type: 'item.completed', + item: { id: 'msg-1', type: 'agent_message', text: 'response' }, + }, + ]; + + const client = new CodexClient(); + const result = await client.call('coder', 'prompt', { cwd: '/tmp' }); + + expect(result.status).toBe('done'); + expect(result.structuredOutput).toBeUndefined(); + }); + + it('outputSchema が runStreamed に渡される', async () => { + const schema = { type: 'object', properties: { step: { type: 'integer' } } }; + const runStreamedSpy = vi.fn().mockResolvedValue({ + events: (async function* () { + yield { type: 'thread.started', thread_id: 'thread-1' }; + yield { + type: 'item.completed', + item: { id: 'msg-1', type: 'agent_message', text: 'ok' }, + }; + yield { + type: 'turn.completed', + turn: { finalResponse: { step: 1 } }, + }; + })(), + }); + + // Mock SDK で startThread が返す thread の runStreamed を spy に差し替え + const { Codex } = await import('@openai/codex-sdk'); + const codex = new Codex({} as never); + const thread = await codex.startThread(); + thread.runStreamed = runStreamedSpy; + + // CodexClient は内部で Codex を new するため、 + // SDK クラス自体のモックで startThread の返り値を制御 + // → mockEvents ベースの簡易テストでは runStreamed の引数を直接検証できない + // ここではプロバイダ層テスト (provider-structured-output.test.ts) で + // outputSchema パススルーを検証済みのため、SDK 内部の引数検証はスキップ + + // 代わりに、outputSchema 付きで呼び出して structuredOutput が返ることを確認 + mockEvents = [ + { type: 'thread.started', thread_id: 'thread-1' }, + { + type: 'item.completed', + item: { id: 'msg-1', type: 'agent_message', text: 'ok' }, + }, + { type: 'turn.completed', turn: { finalResponse: { step: 1 } } }, + ]; + + const client = new CodexClient(); + const result = await client.call('coder', 'prompt', { + cwd: '/tmp', + outputSchema: schema, + }); + + expect(result.structuredOutput).toEqual({ step: 1 }); + }); +}); diff --git a/src/__tests__/provider-structured-output.test.ts b/src/__tests__/provider-structured-output.test.ts new file mode 100644 index 0000000..3f2206e --- /dev/null +++ b/src/__tests__/provider-structured-output.test.ts @@ -0,0 +1,244 @@ +/** + * Provider layer structured output tests. + * + * Verifies that each provider (Claude, Codex, OpenCode) correctly passes + * `outputSchema` through to its underlying client function and returns + * `structuredOutput` in the AgentResponse. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ===== Claude ===== +const { + mockCallClaude, + mockCallClaudeCustom, +} = vi.hoisted(() => ({ + mockCallClaude: vi.fn(), + mockCallClaudeCustom: vi.fn(), +})); + +vi.mock('../infra/claude/client.js', () => ({ + callClaude: mockCallClaude, + callClaudeCustom: mockCallClaudeCustom, + callClaudeAgent: vi.fn(), + callClaudeSkill: vi.fn(), +})); + +// ===== Codex ===== +const { + mockCallCodex, + mockCallCodexCustom, +} = vi.hoisted(() => ({ + mockCallCodex: vi.fn(), + mockCallCodexCustom: vi.fn(), +})); + +vi.mock('../infra/codex/index.js', () => ({ + callCodex: mockCallCodex, + callCodexCustom: mockCallCodexCustom, +})); + +// ===== OpenCode ===== +const { + mockCallOpenCode, + mockCallOpenCodeCustom, +} = vi.hoisted(() => ({ + mockCallOpenCode: vi.fn(), + mockCallOpenCodeCustom: vi.fn(), +})); + +vi.mock('../infra/opencode/index.js', () => ({ + callOpenCode: mockCallOpenCode, + callOpenCodeCustom: mockCallOpenCodeCustom, +})); + +// ===== Config (API key resolvers) ===== +vi.mock('../infra/config/index.js', () => ({ + resolveAnthropicApiKey: vi.fn(() => undefined), + resolveOpenaiApiKey: vi.fn(() => undefined), + resolveOpencodeApiKey: vi.fn(() => undefined), +})); + +// Codex の isInsideGitRepo をバイパス +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(() => 'true'), +})); + +import { ClaudeProvider } from '../infra/providers/claude.js'; +import { CodexProvider } from '../infra/providers/codex.js'; +import { OpenCodeProvider } from '../infra/providers/opencode.js'; + +const SCHEMA = { + type: 'object', + properties: { step: { type: 'integer' } }, + required: ['step'], +}; + +function doneResponse(persona: string, structuredOutput?: Record) { + return { + persona, + status: 'done' as const, + content: 'ok', + timestamp: new Date(), + structuredOutput, + }; +} + +// ---------- Claude ---------- + +describe('ClaudeProvider — structured output', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('outputSchema を callClaude に渡し structuredOutput を返す', async () => { + mockCallClaude.mockResolvedValue(doneResponse('coder', { step: 2 })); + + const agent = new ClaudeProvider().setup({ name: 'coder' }); + const result = await agent.call('prompt', { cwd: '/tmp', outputSchema: SCHEMA }); + + const opts = mockCallClaude.mock.calls[0]?.[2]; + expect(opts).toHaveProperty('outputSchema', SCHEMA); + expect(result.structuredOutput).toEqual({ step: 2 }); + }); + + it('systemPrompt 指定時も outputSchema が callClaudeCustom に渡される', async () => { + mockCallClaudeCustom.mockResolvedValue(doneResponse('judge', { step: 1 })); + + const agent = new ClaudeProvider().setup({ name: 'judge', systemPrompt: 'You are a judge.' }); + const result = await agent.call('prompt', { cwd: '/tmp', outputSchema: SCHEMA }); + + const opts = mockCallClaudeCustom.mock.calls[0]?.[3]; + expect(opts).toHaveProperty('outputSchema', SCHEMA); + expect(result.structuredOutput).toEqual({ step: 1 }); + }); + + it('structuredOutput がない場合は undefined', async () => { + mockCallClaude.mockResolvedValue(doneResponse('coder')); + + const agent = new ClaudeProvider().setup({ name: 'coder' }); + const result = await agent.call('prompt', { cwd: '/tmp', outputSchema: SCHEMA }); + + expect(result.structuredOutput).toBeUndefined(); + }); + + it('outputSchema 未指定時は undefined が渡される', async () => { + mockCallClaude.mockResolvedValue(doneResponse('coder')); + + const agent = new ClaudeProvider().setup({ name: 'coder' }); + await agent.call('prompt', { cwd: '/tmp' }); + + const opts = mockCallClaude.mock.calls[0]?.[2]; + expect(opts.outputSchema).toBeUndefined(); + }); +}); + +// ---------- Codex ---------- + +describe('CodexProvider — structured output', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('outputSchema を callCodex に渡し structuredOutput を返す', async () => { + mockCallCodex.mockResolvedValue(doneResponse('coder', { step: 2 })); + + const agent = new CodexProvider().setup({ name: 'coder' }); + const result = await agent.call('prompt', { cwd: '/tmp', outputSchema: SCHEMA }); + + const opts = mockCallCodex.mock.calls[0]?.[2]; + expect(opts).toHaveProperty('outputSchema', SCHEMA); + expect(result.structuredOutput).toEqual({ step: 2 }); + }); + + it('systemPrompt 指定時も outputSchema が callCodexCustom に渡される', async () => { + mockCallCodexCustom.mockResolvedValue(doneResponse('judge', { step: 1 })); + + const agent = new CodexProvider().setup({ name: 'judge', systemPrompt: 'sys' }); + const result = await agent.call('prompt', { cwd: '/tmp', outputSchema: SCHEMA }); + + const opts = mockCallCodexCustom.mock.calls[0]?.[3]; + expect(opts).toHaveProperty('outputSchema', SCHEMA); + expect(result.structuredOutput).toEqual({ step: 1 }); + }); + + it('structuredOutput がない場合は undefined', async () => { + mockCallCodex.mockResolvedValue(doneResponse('coder')); + + const agent = new CodexProvider().setup({ name: 'coder' }); + const result = await agent.call('prompt', { cwd: '/tmp', outputSchema: SCHEMA }); + + expect(result.structuredOutput).toBeUndefined(); + }); + + it('outputSchema 未指定時は undefined が渡される', async () => { + mockCallCodex.mockResolvedValue(doneResponse('coder')); + + const agent = new CodexProvider().setup({ name: 'coder' }); + await agent.call('prompt', { cwd: '/tmp' }); + + const opts = mockCallCodex.mock.calls[0]?.[2]; + expect(opts.outputSchema).toBeUndefined(); + }); +}); + +// ---------- OpenCode ---------- + +describe('OpenCodeProvider — structured output', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('outputSchema を callOpenCode に渡し structuredOutput を返す', async () => { + mockCallOpenCode.mockResolvedValue(doneResponse('coder', { step: 2 })); + + const agent = new OpenCodeProvider().setup({ name: 'coder' }); + const result = await agent.call('prompt', { + cwd: '/tmp', + model: 'openai/gpt-4', + outputSchema: SCHEMA, + }); + + const opts = mockCallOpenCode.mock.calls[0]?.[2]; + expect(opts).toHaveProperty('outputSchema', SCHEMA); + expect(result.structuredOutput).toEqual({ step: 2 }); + }); + + it('systemPrompt 指定時も outputSchema が callOpenCodeCustom に渡される', async () => { + mockCallOpenCodeCustom.mockResolvedValue(doneResponse('judge', { step: 1 })); + + const agent = new OpenCodeProvider().setup({ name: 'judge', systemPrompt: 'sys' }); + const result = await agent.call('prompt', { + cwd: '/tmp', + model: 'openai/gpt-4', + outputSchema: SCHEMA, + }); + + const opts = mockCallOpenCodeCustom.mock.calls[0]?.[3]; + expect(opts).toHaveProperty('outputSchema', SCHEMA); + expect(result.structuredOutput).toEqual({ step: 1 }); + }); + + it('structuredOutput がない場合は undefined', async () => { + mockCallOpenCode.mockResolvedValue(doneResponse('coder')); + + const agent = new OpenCodeProvider().setup({ name: 'coder' }); + const result = await agent.call('prompt', { + cwd: '/tmp', + model: 'openai/gpt-4', + outputSchema: SCHEMA, + }); + + expect(result.structuredOutput).toBeUndefined(); + }); + + it('outputSchema 未指定時は undefined が渡される', async () => { + mockCallOpenCode.mockResolvedValue(doneResponse('coder')); + + const agent = new OpenCodeProvider().setup({ name: 'coder' }); + await agent.call('prompt', { cwd: '/tmp', model: 'openai/gpt-4' }); + + const opts = mockCallOpenCode.mock.calls[0]?.[2]; + expect(opts.outputSchema).toBeUndefined(); + }); +});