import { describe, it, expect } from 'vitest'; import { fileURLToPath } from 'url'; import path from 'path'; import { runClaudeTurn } from '../../lib/ai/claude_cli.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const FAKE_CLAUDE = path.resolve(__dirname, '../fixtures/fake-claude.js'); // --------------------------------------------------------------------------- // Hermetic tests: fake-claude.js emits known stream-json lines; we assert the // driver normalises them correctly. NO real claude, NO network. // --------------------------------------------------------------------------- describe('runClaudeTurn', () => { it('normalises text deltas, tool events, and tool_result from fake-claude output', async () => { const collected = []; const onEvent = (ev) => collected.push(ev); const result = await runClaudeTurn({ claudeExe: FAKE_CLAUDE, sessionId: 'test-session-uuid-0001', systemPrompt: 'You are a test assistant.', userText: 'hi', onEvent, timeoutMs: 10_000, }); // --- Collected event assertions --- // delta events whose texts concat to 'Hello' const deltas = collected.filter(e => e.type === 'delta'); expect(deltas.length).toBeGreaterThanOrEqual(1); expect(deltas.map(e => e.text).join('')).toBe('Hello'); // tool event for propose_change const toolEvents = collected.filter(e => e.type === 'tool'); expect(toolEvents.length).toBeGreaterThanOrEqual(1); expect(toolEvents[0].tool).toBe('propose_change'); // tool_result event const toolResults = collected.filter(e => e.type === 'tool_result'); expect(toolResults.length).toBe(1); expect(toolResults[0].name).toBe('propose_change'); expect(toolResults[0].result).toBeDefined(); // result event const resultEvents = collected.filter(e => e.type === 'result'); expect(resultEvents.length).toBe(1); expect(resultEvents[0].usage).toBeDefined(); expect(typeof resultEvents[0].cost).toBe('number'); // no error events const errorEvents = collected.filter(e => e.type === 'error'); expect(errorEvents).toHaveLength(0); // --- Return value assertions --- expect(result.text).toBe('Hello'); expect(result.usage).toBeDefined(); expect(result.usage.input_tokens).toBe(100); // toolTrace must include propose_change expect(result.toolTrace).toBeDefined(); const proposeEntry = result.toolTrace.find(t => t.tool === 'propose_change'); expect(proposeEntry).toBeDefined(); }); it('resolves cleanly on non-zero exit (emits error event, does not throw)', async () => { // Use a fake script that exits 1 immediately const collected = []; const result = await runClaudeTurn({ claudeExe: 'node', // Pass a tiny inline script that exits 1. We override claudeExe='node' // and prepend the inline arg via a wrapper... but the API doesn't support // extra args. Instead we'll use a shell -c workaround via /bin/sh. // Simpler: just test via sessionId, and use a real bad path. // Actually: claudeExe itself is the executable; let's just use a path // that doesn't exist to trigger spawn error. sessionId: 'bad-session', systemPrompt: 'x', userText: 'hi', onEvent: (ev) => collected.push(ev), timeoutMs: 5_000, }); // Should resolve (not throw) and include an error event const errorEvents = collected.filter(e => e.type === 'error'); expect(errorEvents.length).toBeGreaterThanOrEqual(1); // result.text may be empty string on error expect(typeof result.text).toBe('string'); }); });