fix(ai-roles): validate chatModel + guard driver-enum drift (#52)

chatModel was a free string accepted with empty/garbage values, failing only at
runtime as a provider 503; tighten it (trim + non-empty + max 200). Driver was
already @IsIn(AI_DRIVERS). Collapse the client driver list to one AI_DRIVER_VALUES
source and add a contract test that reads the server AI_DRIVERS and fails on
client/server drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-21 03:28:58 +03:00
parent c0d312d8f5
commit 342bb47b30
4 changed files with 163 additions and 8 deletions

View File

@@ -0,0 +1,53 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
AI_DRIVER_VALUES,
DRIVER_OPTIONS,
} from "./ai-agent-role-form";
/**
* Drift guard: the client's hardcoded driver list must stay in sync with the
* server `AI_DRIVERS`. Client and server are separate build targets and Vite
* refuses to import a module from outside the client root, so instead of an
* `import` we read the server `ai.types.ts` source and parse out the AI_DRIVERS
* literal. This contract test fails loudly if the two lists ever diverge
* (order-independent).
*/
function readServerAiDrivers(): string[] {
const here = path.dirname(fileURLToPath(import.meta.url));
// apps/client/src/.../components -> repo apps/server/src/integrations/ai
const serverTypesPath = path.resolve(
here,
"../../../../../../../server/src/integrations/ai/ai.types.ts",
);
const source = readFileSync(serverTypesPath, "utf8");
const match = source.match(/AI_DRIVERS\s*:\s*AiDriver\[\]\s*=\s*\[([^\]]*)\]/);
if (!match) {
throw new Error(
`Could not locate the AI_DRIVERS literal in ${serverTypesPath}`,
);
}
return match[1]
.split(",")
.map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
.filter((s) => s.length > 0);
}
describe("ai-agent-role-form driver drift guard", () => {
it("mirrors the server AI_DRIVERS list exactly", () => {
const serverDrivers = readServerAiDrivers();
expect([...AI_DRIVER_VALUES].sort()).toEqual([...serverDrivers].sort());
});
it("exposes one Select option per server driver plus a workspace-default", () => {
const serverDrivers = readServerAiDrivers();
const driverOptionValues = DRIVER_OPTIONS.map((o) => o.value).filter(
(v) => v !== "",
);
expect(driverOptionValues.sort()).toEqual([...serverDrivers].sort());
// Exactly one empty-value option for the "Workspace default" choice.
expect(DRIVER_OPTIONS.filter((o) => o.value === "")).toHaveLength(1);
});
});

View File

@@ -23,13 +23,25 @@ import {
IAiRoleUpdate,
} from "@/features/ai-chat/types/ai-chat.types.ts";
// Supported drivers for the optional model override (mirrors server AI_DRIVERS).
// "" => use the workspace default driver/model.
const DRIVER_OPTIONS = [
// Source of truth: the server `AI_DRIVERS` list in
// apps/server/src/integrations/ai/ai.types.ts. The client cannot import that
// constant at build time (separate build target), so it is mirrored here and a
// drift contract test (ai-agent-role-form.drivers.test.ts) fails if the two
// lists diverge. Keep this in sync when adding/removing a server driver.
export const AI_DRIVER_VALUES = ["openai", "gemini", "ollama"] as const;
export type AiDriverValue = (typeof AI_DRIVER_VALUES)[number];
const DRIVER_LABELS: Record<AiDriverValue, string> = {
openai: "OpenAI",
gemini: "Gemini",
ollama: "Ollama",
};
// Select options for the optional model override. "" => use the workspace
// default driver/model.
export const DRIVER_OPTIONS = [
{ value: "", label: "Workspace default" },
{ value: "openai", label: "OpenAI" },
{ value: "gemini", label: "Gemini" },
{ value: "ollama", label: "Ollama" },
...AI_DRIVER_VALUES.map((value) => ({ value, label: DRIVER_LABELS[value] })),
];
const formSchema = z.object({
@@ -38,7 +50,7 @@ const formSchema = z.object({
description: z.string(),
instructions: z.string().min(1),
// "" => no driver override (use the workspace driver).
driver: z.enum(["", "openai", "gemini", "ollama"]),
driver: z.enum(["", ...AI_DRIVER_VALUES]),
chatModel: z.string(),
enabled: z.boolean(),
});