/** * 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' }); }); });