import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; /** * Unit tests for DIS-153: model selection per agent. * * cross-spawn is mocked so no real process is launched. * Each test inspects the args passed to spawn to verify --model is set correctly. */ // --------------------------------------------------------------------------- // Spy on the args cross-spawn receives // --------------------------------------------------------------------------- let capturedArgs: string[] = []; vi.mock("cross-spawn", () => { function makeMockChild(args: string[]) { capturedArgs = args; const stdoutListeners: Record void>> = {}; const stdoutStream = { setEncoding: () => {}, on: (event: string, cb: (...a: unknown[]) => void) => { if (!stdoutListeners[event]) stdoutListeners[event] = []; stdoutListeners[event].push(cb); if (event === "data") { Promise.resolve().then(() => { for (const handler of stdoutListeners["data"] ?? []) { handler(JSON.stringify({ result: "ok" })); } }); } }, }; const listeners: Record void>> = {}; const emit = (event: string, ...a: unknown[]) => { for (const cb of listeners[event] ?? []) cb(...a); }; const child = { stdout: stdoutStream, stderr: { setEncoding: () => {}, on: () => {} }, stdin: { end: () => {} }, on: (event: string, cb: (...a: unknown[]) => void) => { if (!listeners[event]) listeners[event] = []; listeners[event].push(cb); if (event === "close") { Promise.resolve() .then(() => Promise.resolve()) .then(() => emit("close", 0)); } }, kill: () => {}, }; return child; } return { default: (_cmd: string, args: string[]) => makeMockChild(args), }; }); import { runAgent, _resetSemaphore } from "../../src/agent/runner"; import { _resetResolveClaudeCache } from "../../src/runtime/resolve-claude"; import { AgentYamlSchema } from "../../src/agent/schema"; // --------------------------------------------------------------------------- // Shared workspace fixture // --------------------------------------------------------------------------- describe("runner model selection (DIS-153)", () => { let workspaceDir: string; const originalEnv = { ...process.env }; beforeAll(() => { workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "disclaw-model-test-")); fs.writeFileSync( path.join(workspaceDir, "CLAUDE.md"), "# Model Test Agent\nYou are a test agent.\n", "utf-8" ); }); beforeEach(() => { capturedArgs = []; _resetResolveClaudeCache(); _resetSemaphore(4); process.env.CLAUDE_PATH = "/fake/claude"; }); afterEach(() => { process.env = { ...originalEnv }; _resetResolveClaudeCache(); }); afterAll(() => { try { fs.rmSync(workspaceDir, { recursive: true, force: true }); } catch { // best-effort cleanup } }); // ------------------------------------------------------------------------- // Test 1: --model is passed when model is set in agent.yaml // ------------------------------------------------------------------------- it("passes --model claude-sonnet-4-6 when model is set in agent.yaml", async () => { fs.writeFileSync( path.join(workspaceDir, "agent.yaml"), `name: model-agent\ndisplay_name: Model Agent\nrole: Testing\nchannel_id: "111"\nmodel: claude-sonnet-4-6\n`, "utf-8" ); await runAgent({ workspacePath: workspaceDir, channelName: "test", userMessage: "hello", conversationHistory: [], }); const modelIdx = capturedArgs.indexOf("--model"); expect(modelIdx).toBeGreaterThan(-1); expect(capturedArgs[modelIdx + 1]).toBe("claude-sonnet-4-6"); }); // ------------------------------------------------------------------------- // Test 2: Default model is used when model field is absent from agent.yaml // ------------------------------------------------------------------------- it("passes default model when model field is absent from agent.yaml", async () => { fs.writeFileSync( path.join(workspaceDir, "agent.yaml"), `name: model-agent\ndisplay_name: Model Agent\nrole: Testing\nchannel_id: "222"\n`, "utf-8" ); await runAgent({ workspacePath: workspaceDir, channelName: "test", userMessage: "hello", conversationHistory: [], }); const modelIdx = capturedArgs.indexOf("--model"); expect(modelIdx).toBeGreaterThan(-1); // DEFAULT_MODEL is claude-sonnet-4-6 expect(capturedArgs[modelIdx + 1]).toBe("claude-sonnet-4-6"); }); // ------------------------------------------------------------------------- // Test 3: Invalid model in agent.yaml → Zod validation throws // ------------------------------------------------------------------------- it("throws when agent.yaml contains an invalid model value", () => { const rawYaml = `name: bad-model-agent\ndisplay_name: Bad\nrole: Testing\nchannel_id: "333"\nmodel: gpt-4-turbo\n`; const parsed = { name: "bad-model-agent", display_name: "Bad", role: "Testing", channel_id: "333", model: "gpt-4-turbo" }; expect(() => AgentYamlSchema.parse(parsed)).toThrow(); }); });