/** * MockLLMClient — IAIService 의 Mock 구현체. * * 의도: 회사 모드 dispatcher / ChunkedWriter / ceoPlanner 등 LLM 을 호출하는 코드 * 경로를 *CI 환경에서도 테스트* 가능하게. 실제 Ollama / LM Studio 없이도 응답을 * 미리 정의하거나 동적으로 생성 가능. * * 사용 예: * const ai = new MockLLMClient(); * ai.setNextResponse('plan generated'); * const result = await ai.chat({ user: 'do this' }); * * // 또는 동적 응답: * ai.setResponder((req) => req.user.includes('analyze') ? 'analysis...' : 'ok'); * * // 호출 이력 검증: * expect(ai.calls).toHaveLength(2); * expect(ai.calls[0].user).toBe('do this'); */ import type { IAIService, AIChatRequest, AIChatResult, } from '../../src/core/services'; export interface RecordedCall { user: string; system?: string; model?: string; timeoutMs?: number; signalAborted?: boolean; } type Responder = (req: AIChatRequest) => string | { content: string; empty?: boolean }; export class MockLLMClient implements IAIService { /** 모든 chat / call 호출의 입력 인자가 시간 순서로 누적. */ public readonly calls: RecordedCall[] = []; /** FIFO 큐 — setNextResponse 로 push, chat 호출마다 shift. */ private readonly queued: string[] = []; /** 큐가 비었을 때 사용할 fallback. setResponder 로 정의 가능. */ private responder: Responder | null = null; /** queued / responder 모두 없으면 이 값을 그대로. */ private defaultResponse = 'mock response — set via setNextResponse / setResponder'; /** * 다음 호출(들) 에 사용할 응답을 FIFO 큐에 push. * 여러 번 push 하면 순차적으로 소비. */ setNextResponse(text: string): void { this.queued.push(text); } /** * 모든 호출에 대해 동적으로 응답 생성. setNextResponse 큐가 우선. */ setResponder(fn: Responder): void { this.responder = fn; } /** 모든 큐 / responder / 이력 초기화. test setup 사이에 reset 용. */ reset(): void { this.calls.length = 0; this.queued.length = 0; this.responder = null; } async call(prompt: string): Promise { const result = await this.chat({ user: prompt }); return result.content; } async chat(req: AIChatRequest): Promise { this.calls.push({ user: req.user, system: req.system, model: req.model, timeoutMs: req.timeoutMs, signalAborted: req.signal?.aborted, }); // signal 이 이미 aborted 면 AbortError 던짐 — 실제 fetch 동작 모방. if (req.signal?.aborted) { const err = new Error('AbortError'); err.name = 'AbortError'; throw err; } let content: string; let empty = false; if (this.queued.length > 0) { content = this.queued.shift()!; } else if (this.responder) { const out = this.responder(req); if (typeof out === 'string') { content = out; } else { content = out.content; empty = !!out.empty; } } else { content = this.defaultResponse; } return { content, engine: 'lmstudio', model: req.model || 'mock-model', empty: empty || !content, }; } }