Le tool-calling local ne fonctionnait jamais via Ollama. Refonte du support local d'OpenCode autour de llama.cpp: profil, catalogue, matérialisation de la config OpenCode et surface first-run alignés sur llama-server (backend + frontend). QA vert (commandes réelles): domain 244, application 81+64, infra 263 (10 échecs = bind-port sandbox identiques sur develop, non-régression), frontend 574, tsc propre. Réserve E2E live non bloquante faute de llama-server joignable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
282 lines
9.4 KiB
TypeScript
282 lines
9.4 KiB
TypeScript
/**
|
|
* L5 — pure first-run profile logic: validation mirrors of the backend invariants
|
|
* (`validateProfile`/`isProfileValid`), `isRelativeSafe`, `isValidEnvVar`,
|
|
* `defaultInjection`, `emptyCustomProfile`, and `parseArgs`.
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
import type { AgentProfile } from "@/domain";
|
|
import {
|
|
defaultHttpChatConfig,
|
|
defaultInjection,
|
|
defaultOpenCodeConfig,
|
|
emptyCustomProfile,
|
|
isProfileValid,
|
|
isRelativeSafe,
|
|
isValidEnvVar,
|
|
isValidHttpUrl,
|
|
parseArgs,
|
|
validateHttpChatConfig,
|
|
validateOpenCodeConfig,
|
|
validateProfile,
|
|
} from "./profile";
|
|
|
|
function base(overrides: Partial<AgentProfile> = {}): AgentProfile {
|
|
return {
|
|
id: "p1",
|
|
name: "Claude",
|
|
command: "claude",
|
|
args: [],
|
|
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
|
|
detect: null,
|
|
cwdTemplate: "{projectRoot}",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("isRelativeSafe", () => {
|
|
it("accepts a plain relative file name", () => {
|
|
expect(isRelativeSafe("CLAUDE.md")).toBe(true);
|
|
expect(isRelativeSafe("docs/AGENTS.md")).toBe(true);
|
|
});
|
|
it("rejects empty, absolute, traversal and drive paths", () => {
|
|
expect(isRelativeSafe("")).toBe(false);
|
|
expect(isRelativeSafe("/etc/passwd")).toBe(false);
|
|
expect(isRelativeSafe("\\windows")).toBe(false);
|
|
expect(isRelativeSafe("../secret")).toBe(false);
|
|
expect(isRelativeSafe("a/../b")).toBe(false);
|
|
expect(isRelativeSafe("C:/tmp")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isValidEnvVar", () => {
|
|
it("accepts valid identifiers", () => {
|
|
expect(isValidEnvVar("AGENT_CONTEXT")).toBe(true);
|
|
expect(isValidEnvVar("_x1")).toBe(true);
|
|
});
|
|
it("rejects invalid identifiers", () => {
|
|
expect(isValidEnvVar("")).toBe(false);
|
|
expect(isValidEnvVar("1ABC")).toBe(false);
|
|
expect(isValidEnvVar("HAS-DASH")).toBe(false);
|
|
expect(isValidEnvVar("has space")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("validateProfile / isProfileValid", () => {
|
|
it("a well-formed profile is valid", () => {
|
|
expect(validateProfile(base())).toEqual({});
|
|
expect(isProfileValid(base())).toBe(true);
|
|
});
|
|
it("blank name and command are reported", () => {
|
|
const errors = validateProfile(base({ name: " ", command: "" }));
|
|
expect(errors.name).toBeDefined();
|
|
expect(errors.command).toBeDefined();
|
|
expect(isProfileValid(base({ name: "" }))).toBe(false);
|
|
});
|
|
it("convention file target must be relative-safe", () => {
|
|
const errors = validateProfile(
|
|
base({ contextInjection: { strategy: "conventionFile", target: "../x" } }),
|
|
);
|
|
expect(errors.target).toBeDefined();
|
|
});
|
|
it("flag must be non-empty", () => {
|
|
const errors = validateProfile(
|
|
base({ contextInjection: { strategy: "flag", flag: " " } }),
|
|
);
|
|
expect(errors.flag).toBeDefined();
|
|
});
|
|
it("env var must be a valid identifier", () => {
|
|
const errors = validateProfile(
|
|
base({ contextInjection: { strategy: "env", var: "1bad" } }),
|
|
);
|
|
expect(errors.var).toBeDefined();
|
|
});
|
|
it("stdin needs no extra field", () => {
|
|
expect(
|
|
isProfileValid(base({ contextInjection: { strategy: "stdin" } })),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("isValidHttpUrl", () => {
|
|
it("accepts http/https endpoints", () => {
|
|
expect(isValidHttpUrl("http://localhost:11434/v1")).toBe(true);
|
|
expect(isValidHttpUrl("https://lan.host:8080/v1")).toBe(true);
|
|
});
|
|
it("rejects empty, non-http and other schemes", () => {
|
|
expect(isValidHttpUrl("")).toBe(false);
|
|
expect(isValidHttpUrl(" ")).toBe(false);
|
|
expect(isValidHttpUrl("ftp://host")).toBe(false);
|
|
expect(isValidHttpUrl("localhost:11434")).toBe(false);
|
|
// Leading whitespace fails the raw prefix check, mirroring the backend.
|
|
expect(isValidHttpUrl(" http://host")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("validateHttpChatConfig", () => {
|
|
it("a well-formed local model config is valid", () => {
|
|
expect(validateHttpChatConfig(defaultHttpChatConfig())).toEqual({});
|
|
});
|
|
it("reports a bad endpoint scheme and an empty model", () => {
|
|
const errors = validateHttpChatConfig({ endpoint: "ftp://x", model: " " });
|
|
expect(errors.endpoint).toBeDefined();
|
|
expect(errors.model).toBeDefined();
|
|
});
|
|
it("apiKeyEnv must be an env var NAME, never a raw key", () => {
|
|
// A plausible raw key contains characters illegal in an env var identifier.
|
|
const withKey = validateHttpChatConfig({
|
|
endpoint: "http://host",
|
|
model: "m",
|
|
apiKeyEnv: "sk-abc123",
|
|
});
|
|
expect(withKey.apiKeyEnv).toBeDefined();
|
|
// A valid identifier passes; blank/undefined is allowed (optional).
|
|
expect(
|
|
validateHttpChatConfig({
|
|
endpoint: "http://host",
|
|
model: "m",
|
|
apiKeyEnv: "OPENAI_API_KEY",
|
|
}).apiKeyEnv,
|
|
).toBeUndefined();
|
|
expect(
|
|
validateHttpChatConfig({ endpoint: "http://host", model: "m" }).apiKeyEnv,
|
|
).toBeUndefined();
|
|
});
|
|
it("rejects zero / non-integer timeouts and tool-iteration guard", () => {
|
|
const errors = validateHttpChatConfig({
|
|
endpoint: "http://host",
|
|
model: "m",
|
|
requestTimeoutMs: 0,
|
|
connectTimeoutMs: -1,
|
|
maxToolIterations: 1.5,
|
|
});
|
|
expect(errors.requestTimeoutMs).toBeDefined();
|
|
expect(errors.connectTimeoutMs).toBeDefined();
|
|
expect(errors.maxToolIterations).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("validateProfile for openAiCompatible profiles", () => {
|
|
function ollama(overrides: Partial<AgentProfile> = {}): AgentProfile {
|
|
return base({
|
|
name: "Ollama",
|
|
command: "openai-compatible",
|
|
structuredAdapter: "openAiCompatible",
|
|
chatHttp: defaultHttpChatConfig(),
|
|
...overrides,
|
|
});
|
|
}
|
|
it("the reference local-model profile is valid", () => {
|
|
expect(validateProfile(ollama())).toEqual({});
|
|
expect(isProfileValid(ollama())).toBe(true);
|
|
});
|
|
it("surfaces chatHttp field errors through the profile validator", () => {
|
|
const errors = validateProfile(
|
|
ollama({ chatHttp: { endpoint: "nope", model: "" } }),
|
|
);
|
|
expect(errors.endpoint).toBeDefined();
|
|
expect(errors.model).toBeDefined();
|
|
expect(isProfileValid(ollama({ chatHttp: { endpoint: "nope", model: "m" } }))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
it("a missing chatHttp on an openAiCompatible profile is invalid", () => {
|
|
const errors = validateProfile(ollama({ chatHttp: undefined }));
|
|
expect(errors.endpoint).toBeDefined();
|
|
expect(errors.model).toBeDefined();
|
|
});
|
|
it("does not touch validation of Claude/Codex (non-structured-http) profiles", () => {
|
|
// A plain profile with no structuredAdapter never triggers chatHttp checks.
|
|
expect(validateProfile(base())).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("validateOpenCodeConfig", () => {
|
|
it("a well-formed llama.cpp config is valid", () => {
|
|
expect(validateOpenCodeConfig(defaultOpenCodeConfig())).toEqual({});
|
|
});
|
|
it("reports a bad base URL scheme and an empty model", () => {
|
|
const errors = validateOpenCodeConfig({ baseURL: "ftp://x", model: " " });
|
|
expect(errors.baseURL).toBeDefined();
|
|
expect(errors.model).toBeDefined();
|
|
});
|
|
it("an optional API key is free-form (no env-var-name constraint)", () => {
|
|
// Unlike the openAiCompatible adapter, OpenCode carries the key itself.
|
|
expect(
|
|
validateOpenCodeConfig({
|
|
baseURL: "http://localhost:8080/v1",
|
|
apiKey: "sk-secret-123",
|
|
model: "qwen3-coder-30b",
|
|
}),
|
|
).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("validateProfile for openCode profiles", () => {
|
|
function opencode(overrides: Partial<AgentProfile> = {}): AgentProfile {
|
|
return base({
|
|
name: "OpenCode + llama.cpp",
|
|
command: "opencode",
|
|
structuredAdapter: "openCode",
|
|
opencode: defaultOpenCodeConfig(),
|
|
...overrides,
|
|
});
|
|
}
|
|
it("the reference llama.cpp profile is valid", () => {
|
|
expect(validateProfile(opencode())).toEqual({});
|
|
expect(isProfileValid(opencode())).toBe(true);
|
|
});
|
|
it("surfaces opencode field errors through the profile validator", () => {
|
|
const errors = validateProfile(
|
|
opencode({ opencode: { baseURL: "nope", model: "" } }),
|
|
);
|
|
expect(errors.baseURL).toBeDefined();
|
|
expect(errors.model).toBeDefined();
|
|
expect(
|
|
isProfileValid(opencode({ opencode: { baseURL: "nope", model: "m" } })),
|
|
).toBe(false);
|
|
});
|
|
it("a missing opencode config on an openCode profile is invalid", () => {
|
|
const errors = validateProfile(opencode({ opencode: undefined }));
|
|
expect(errors.baseURL).toBeDefined();
|
|
expect(errors.model).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("defaultInjection", () => {
|
|
it("produces a sensible default per strategy", () => {
|
|
expect(defaultInjection("conventionFile")).toEqual({
|
|
strategy: "conventionFile",
|
|
target: "CONTEXT.md",
|
|
});
|
|
expect(defaultInjection("flag")).toEqual({
|
|
strategy: "flag",
|
|
flag: "--context-file {path}",
|
|
});
|
|
expect(defaultInjection("env")).toEqual({
|
|
strategy: "env",
|
|
var: "AGENT_CONTEXT_FILE",
|
|
});
|
|
expect(defaultInjection("stdin")).toEqual({ strategy: "stdin" });
|
|
});
|
|
});
|
|
|
|
describe("emptyCustomProfile", () => {
|
|
it("is a blank, invalid draft with a fresh id", () => {
|
|
const a = emptyCustomProfile();
|
|
const b = emptyCustomProfile();
|
|
expect(a.name).toBe("");
|
|
expect(a.command).toBe("");
|
|
expect(isProfileValid(a)).toBe(false);
|
|
expect(a.id).not.toEqual(b.id);
|
|
});
|
|
});
|
|
|
|
describe("parseArgs", () => {
|
|
it("splits on whitespace, trims and drops empties", () => {
|
|
expect(parseArgs(" --foo bar\t baz ")).toEqual(["--foo", "bar", "baz"]);
|
|
expect(parseArgs("")).toEqual([]);
|
|
expect(parseArgs(" ")).toEqual([]);
|
|
});
|
|
});
|