Adds per-agent model configuration: ALLOWED_MODELS allowlist in src/config/models.ts, model field in AgentYamlSchema (Zod default: claude-sonnet-4-6), --model flag forwarded to the Claude CLI in runner.ts, and a StringSelectMenuBuilder step in the /new-agent command flow. Three unit tests cover model passthrough, default fallback, and invalid-model rejection. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
165 lines
5.4 KiB
TypeScript
165 lines
5.4 KiB
TypeScript
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<string, Array<(...a: unknown[]) => 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<string, Array<(...a: unknown[]) => 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();
|
|
});
|
|
});
|