feat(ui): profils locaux/LAN OpenAI-compatible dans le first-run et les terminaux (#14)
Câble la surface frontend des profils IA locaux/LAN OpenAI-compatible,
en parité avec l'adapter backend additif (aab4bca).
- domain: types de profil OpenAI-compatible
- first-run: édition/validation du profil dans le FirstRunWizard
- adapters/mock: mock de profil pour les tests
- terminals: rendu des round-trips et erreurs endpoint (role=alert)
Validé QA (frontend GO): typecheck exit 0, vitest 59 fichiers / 566 tests,
0 echec ; couverture timeouts round-trip + erreur endpoint role=alert
prouvees ; pas de regression sur les tests de bail headless.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -173,6 +173,134 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FirstRunWizard — OpenAI-compatible local/LAN profile (ticket #14)", () => {
|
||||
const OLLAMA = "Ollama / OpenAI-compatible local model";
|
||||
|
||||
it("offers the local model as a selectable, pre-filled HTTP config row", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
expect(screen.getByText(OLLAMA)).toBeTruthy();
|
||||
// The structured HTTP fields are rendered, pre-filled from the reference.
|
||||
expect(
|
||||
(screen.getByLabelText(`${OLLAMA} endpoint`) as HTMLInputElement).value,
|
||||
).toBe("http://localhost:11434/v1");
|
||||
expect(
|
||||
(screen.getByLabelText(`${OLLAMA} model`) as HTMLInputElement).value,
|
||||
).toBe("qwen2.5-coder");
|
||||
// Claude/Codex rows must NOT grow HTTP fields (additive, no regression).
|
||||
expect(screen.queryByLabelText("Claude Code endpoint")).toBeNull();
|
||||
});
|
||||
|
||||
it("labels the API key field as an env var NAME and flags a raw key", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
const apiKey = screen.getByLabelText(`${OLLAMA} api key env`) as HTMLInputElement;
|
||||
// The label/placeholder make the env-var-name intent explicit.
|
||||
expect(apiKey.placeholder.toLowerCase()).toContain("variable name");
|
||||
|
||||
// A raw secret is not a valid env var identifier ⇒ inline error.
|
||||
fireEvent.change(apiKey, { target: { value: "sk-secret-123" } });
|
||||
expect(
|
||||
screen.getByText(/valid env var name \(not the key itself\)/i),
|
||||
).toBeTruthy();
|
||||
|
||||
// A proper env var name clears the error.
|
||||
fireEvent.change(apiKey, { target: { value: "OPENAI_API_KEY" } });
|
||||
expect(
|
||||
screen.queryByText(/valid env var name \(not the key itself\)/i),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("flags an invalid endpoint scheme inline", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
const endpoint = screen.getByLabelText(`${OLLAMA} endpoint`);
|
||||
fireEvent.change(endpoint, { target: { value: "ftp://oops" } });
|
||||
expect(screen.getByText(/must start with http:\/\/ or https:\/\//i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("exposes the timeout / tool-guard fields pre-filled from the reference", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
expect(
|
||||
(screen.getByLabelText(`${OLLAMA} request timeout`) as HTMLInputElement).value,
|
||||
).toBe("120000");
|
||||
expect(
|
||||
(screen.getByLabelText(`${OLLAMA} connect timeout`) as HTMLInputElement).value,
|
||||
).toBe("5000");
|
||||
expect(
|
||||
(screen.getByLabelText(`${OLLAMA} max tool iterations`) as HTMLInputElement)
|
||||
.value,
|
||||
).toBe("16");
|
||||
});
|
||||
|
||||
it("persists edited timeouts / tool-guard round-trip into chatHttp", async () => {
|
||||
const { profile } = renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(`use ${OLLAMA}`));
|
||||
fireEvent.change(screen.getByLabelText(`${OLLAMA} request timeout`), {
|
||||
target: { value: "90000" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(`${OLLAMA} connect timeout`), {
|
||||
target: { value: "3000" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(`${OLLAMA} max tool iterations`), {
|
||||
target: { value: "8" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const ollama = saved.find((p) => p.command === "openai-compatible");
|
||||
expect(ollama?.chatHttp?.requestTimeoutMs).toBe(90000);
|
||||
expect(ollama?.chatHttp?.connectTimeoutMs).toBe(3000);
|
||||
expect(ollama?.chatHttp?.maxToolIterations).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
it("flags a zero timeout inline (mirror of the backend guard)", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(`${OLLAMA} request timeout`), {
|
||||
target: { value: "0" },
|
||||
});
|
||||
expect(screen.getByText(/positive integer \(ms\)/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("persists the edited HTTP config round-trip when selected and saved", async () => {
|
||||
const { profile } = renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
// Select the local model and edit its model name.
|
||||
fireEvent.click(screen.getByLabelText(`use ${OLLAMA}`));
|
||||
fireEvent.change(screen.getByLabelText(`${OLLAMA} model`), {
|
||||
target: { value: "qwen3" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(`${OLLAMA} api key env`), {
|
||||
target: { value: "LAN_KEY" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const ollama = saved.find((p) => p.command === "openai-compatible");
|
||||
expect(ollama?.structuredAdapter).toBe("openAiCompatible");
|
||||
expect(ollama?.chatHttp?.model).toBe("qwen3");
|
||||
expect(ollama?.chatHttp?.apiKeyEnv).toBe("LAN_KEY");
|
||||
// The endpoint round-trips untouched.
|
||||
expect(ollama?.chatHttp?.endpoint).toBe("http://localhost:11434/v1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("FirstRunWizard reopening after the first run (forceOpen)", () => {
|
||||
/** A gateway whose first run is already done (profiles configured). */
|
||||
async function configuredGateway() {
|
||||
|
||||
@ -15,10 +15,15 @@
|
||||
* `./profile`.
|
||||
*/
|
||||
|
||||
import type { AgentProfile } from "@/domain";
|
||||
import type { AgentProfile, HttpChatConfig } from "@/domain";
|
||||
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
|
||||
import { useFirstRun, type WizardEntry } from "./useFirstRun";
|
||||
import { parseArgs, validateProfile } from "./profile";
|
||||
import {
|
||||
defaultHttpChatConfig,
|
||||
parseArgs,
|
||||
validateProfile,
|
||||
type ProfileErrors,
|
||||
} from "./profile";
|
||||
|
||||
/** A small caption above a control. */
|
||||
function Caption({ children }: { children: React.ReactNode }) {
|
||||
@ -174,6 +179,133 @@ function ProfileRow({
|
||||
onChange={(e) => onChange({ ...profile, args: parseArgs(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{profile.structuredAdapter === "openAiCompatible" && (
|
||||
<HttpChatFields profile={profile} errors={errors} onChange={onChange} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Parses a number field: blank ⇒ `undefined` (backend applies its default). */
|
||||
function parseOptInt(raw: string): number | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) return undefined;
|
||||
const n = Number(trimmed);
|
||||
return Number.isFinite(n) ? n : NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* The OpenAI-compatible (local/LAN) HTTP config section: endpoint, model, the
|
||||
* *name* of the API-key env var (never the key), and optional timeouts / tool
|
||||
* guard. Rendered only for a `openAiCompatible` profile; edits flow back through
|
||||
* `onChange` as a patched `chatHttp`.
|
||||
*/
|
||||
function HttpChatFields({
|
||||
profile,
|
||||
errors,
|
||||
onChange,
|
||||
}: {
|
||||
profile: AgentProfile;
|
||||
errors: ProfileErrors;
|
||||
onChange: (p: AgentProfile) => void;
|
||||
}) {
|
||||
const http = profile.chatHttp ?? defaultHttpChatConfig();
|
||||
const patch = (next: Partial<HttpChatConfig>) =>
|
||||
onChange({ ...profile, chatHttp: { ...http, ...next } });
|
||||
|
||||
return (
|
||||
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
|
||||
<legend className="px-1 text-xs font-medium text-muted">
|
||||
Local / LAN model (OpenAI-compatible)
|
||||
</legend>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Endpoint</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} endpoint`}
|
||||
value={http.endpoint}
|
||||
placeholder="http://localhost:11434/v1"
|
||||
invalid={Boolean(errors.endpoint)}
|
||||
onChange={(e) => patch({ endpoint: e.target.value })}
|
||||
/>
|
||||
{errors.endpoint && (
|
||||
<small className="text-xs text-danger">{errors.endpoint}</small>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Model</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} model`}
|
||||
value={http.model}
|
||||
placeholder="qwen2.5-coder"
|
||||
invalid={Boolean(errors.model)}
|
||||
onChange={(e) => patch({ model: e.target.value })}
|
||||
/>
|
||||
{errors.model && <small className="text-xs text-danger">{errors.model}</small>}
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>API key — environment variable name (never the key itself)</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} api key env`}
|
||||
value={http.apiKeyEnv ?? ""}
|
||||
placeholder="e.g. OPENAI_API_KEY (variable name, not the secret)"
|
||||
invalid={Boolean(errors.apiKeyEnv)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
patch({ apiKeyEnv: v.length === 0 ? undefined : v });
|
||||
}}
|
||||
/>
|
||||
{errors.apiKeyEnv && (
|
||||
<small className="text-xs text-danger">{errors.apiKeyEnv}</small>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||||
<Caption>Request timeout (ms)</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} request timeout`}
|
||||
value={http.requestTimeoutMs?.toString() ?? ""}
|
||||
inputMode="numeric"
|
||||
invalid={Boolean(errors.requestTimeoutMs)}
|
||||
onChange={(e) => patch({ requestTimeoutMs: parseOptInt(e.target.value) })}
|
||||
/>
|
||||
{errors.requestTimeoutMs && (
|
||||
<small className="text-xs text-danger">{errors.requestTimeoutMs}</small>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||||
<Caption>Connect timeout (ms)</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} connect timeout`}
|
||||
value={http.connectTimeoutMs?.toString() ?? ""}
|
||||
inputMode="numeric"
|
||||
invalid={Boolean(errors.connectTimeoutMs)}
|
||||
onChange={(e) => patch({ connectTimeoutMs: parseOptInt(e.target.value) })}
|
||||
/>
|
||||
{errors.connectTimeoutMs && (
|
||||
<small className="text-xs text-danger">{errors.connectTimeoutMs}</small>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
|
||||
<Caption>Max tool iterations</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} max tool iterations`}
|
||||
value={http.maxToolIterations?.toString() ?? ""}
|
||||
inputMode="numeric"
|
||||
invalid={Boolean(errors.maxToolIterations)}
|
||||
onChange={(e) => patch({ maxToolIterations: parseOptInt(e.target.value) })}
|
||||
/>
|
||||
{errors.maxToolIterations && (
|
||||
<small className="text-xs text-danger">{errors.maxToolIterations}</small>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,12 +7,15 @@ import { describe, it, expect } from "vitest";
|
||||
|
||||
import type { AgentProfile } from "@/domain";
|
||||
import {
|
||||
defaultHttpChatConfig,
|
||||
defaultInjection,
|
||||
emptyCustomProfile,
|
||||
isProfileValid,
|
||||
isRelativeSafe,
|
||||
isValidEnvVar,
|
||||
isValidHttpUrl,
|
||||
parseArgs,
|
||||
validateHttpChatConfig,
|
||||
validateProfile,
|
||||
} from "./profile";
|
||||
|
||||
@ -93,6 +96,99 @@ describe("validateProfile / isProfileValid", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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("defaultInjection", () => {
|
||||
it("produces a sensible default per strategy", () => {
|
||||
expect(defaultInjection("conventionFile")).toEqual({
|
||||
|
||||
@ -7,12 +7,26 @@
|
||||
import type {
|
||||
AgentProfile,
|
||||
ContextInjection,
|
||||
HttpChatConfig,
|
||||
InjectionStrategy,
|
||||
} from "@/domain";
|
||||
|
||||
/** A field-keyed validation error map (empty ⇒ valid). */
|
||||
export type ProfileErrors = Partial<
|
||||
Record<"name" | "command" | "target" | "flag" | "var", string>
|
||||
Record<
|
||||
| "name"
|
||||
| "command"
|
||||
| "target"
|
||||
| "flag"
|
||||
| "var"
|
||||
| "endpoint"
|
||||
| "model"
|
||||
| "apiKeyEnv"
|
||||
| "requestTimeoutMs"
|
||||
| "connectTimeoutMs"
|
||||
| "maxToolIterations",
|
||||
string
|
||||
>
|
||||
>;
|
||||
|
||||
/** Whether a path is a relative, traversal-free file name (mirror of backend). */
|
||||
@ -29,6 +43,58 @@ export function isValidEnvVar(v: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a string is a valid `http://`/`https://` endpoint (mirror of the
|
||||
* backend `HttpChatConfig::new`: non-empty once trimmed, and the raw value
|
||||
* starts with `http://` or `https://`).
|
||||
*/
|
||||
export function isValidHttpUrl(v: string): boolean {
|
||||
if (v.trim().length === 0) return false;
|
||||
return v.startsWith("http://") || v.startsWith("https://");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a value is an acceptable positive-integer timeout/guard (mirror of the
|
||||
* backend, which rejects `0` and only ever holds `u32`/`u16`). `undefined` means
|
||||
* "left blank" and is valid (the backend applies its own default).
|
||||
*/
|
||||
function isValidPositiveInt(value: number | undefined, max: number): boolean {
|
||||
if (value === undefined) return true;
|
||||
return Number.isInteger(value) && value > 0 && value <= max;
|
||||
}
|
||||
|
||||
const MAX_U32 = 0xffff_ffff;
|
||||
const MAX_U16 = 0xffff;
|
||||
|
||||
/**
|
||||
* Validates an {@link HttpChatConfig} draft the way the backend
|
||||
* `HttpChatConfig::new` would, so the wizard can surface errors before any
|
||||
* `invoke`. Returns an empty object when the draft is valid.
|
||||
*/
|
||||
export function validateHttpChatConfig(c: HttpChatConfig): ProfileErrors {
|
||||
const errors: ProfileErrors = {};
|
||||
if (!isValidHttpUrl(c.endpoint)) {
|
||||
errors.endpoint = "Endpoint must start with http:// or https://.";
|
||||
}
|
||||
if (c.model.trim().length === 0) {
|
||||
errors.model = "Model is required.";
|
||||
}
|
||||
// The API key field holds the NAME of an environment variable, never a key.
|
||||
if (c.apiKeyEnv !== undefined && c.apiKeyEnv.length > 0 && !isValidEnvVar(c.apiKeyEnv)) {
|
||||
errors.apiKeyEnv = "Must be a valid env var name (not the key itself).";
|
||||
}
|
||||
if (!isValidPositiveInt(c.requestTimeoutMs, MAX_U32)) {
|
||||
errors.requestTimeoutMs = "Must be a positive integer (ms).";
|
||||
}
|
||||
if (!isValidPositiveInt(c.connectTimeoutMs, MAX_U32)) {
|
||||
errors.connectTimeoutMs = "Must be a positive integer (ms).";
|
||||
}
|
||||
if (!isValidPositiveInt(c.maxToolIterations, MAX_U16)) {
|
||||
errors.maxToolIterations = "Must be a positive integer.";
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a profile draft the way the backend would, so the wizard can surface
|
||||
* errors before any `invoke`. Returns an empty object when the draft is valid.
|
||||
@ -48,6 +114,17 @@ export function validateProfile(p: AgentProfile): ProfileErrors {
|
||||
} else if (ci.strategy === "env") {
|
||||
if (!isValidEnvVar(ci.var)) errors.var = "Must be a valid env var identifier.";
|
||||
}
|
||||
|
||||
// An OpenAI-compatible profile additionally carries an HTTP chat config; the
|
||||
// backend refuses to persist it unless it is well-formed, so mirror that here.
|
||||
if (p.structuredAdapter === "openAiCompatible") {
|
||||
if (!p.chatHttp) {
|
||||
errors.endpoint = "Endpoint must start with http:// or https://.";
|
||||
errors.model = "Model is required.";
|
||||
} else {
|
||||
Object.assign(errors, validateHttpChatConfig(p.chatHttp));
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
@ -73,6 +150,21 @@ export function defaultInjection(strategy: InjectionStrategy): ContextInjection
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A sensible default {@link HttpChatConfig} for a fresh OpenAI-compatible
|
||||
* profile draft — pre-fills a local Ollama endpoint so the form starts valid.
|
||||
* Mirrors the backend reference profile (`http://localhost:11434/v1`).
|
||||
*/
|
||||
export function defaultHttpChatConfig(): HttpChatConfig {
|
||||
return {
|
||||
endpoint: "http://localhost:11434/v1",
|
||||
model: "qwen2.5-coder",
|
||||
requestTimeoutMs: 120_000,
|
||||
connectTimeoutMs: 5_000,
|
||||
maxToolIterations: 16,
|
||||
};
|
||||
}
|
||||
|
||||
/** A fresh, empty custom-profile draft (id minted client-side). */
|
||||
export function emptyCustomProfile(): AgentProfile {
|
||||
return {
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
* layout change) must **detach**, NEVER **close** — the backend PTY must survive
|
||||
* so a running AI isn't cut off. Re-mounting with a known session re-attaches.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import type {
|
||||
@ -205,3 +205,88 @@ describe("TerminalView (with MockTerminalGateway)", () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// The launch-error surface (ticket #14 F3) can only be observed when xterm
|
||||
// actually mounts — otherwise the effect bails before ever calling the opener.
|
||||
// jsdom lacks `matchMedia`/`ResizeObserver`, which is exactly what makes
|
||||
// `term.open` throw. This block installs minimal polyfills so xterm mounts and
|
||||
// the opener genuinely runs (and rejects), letting us assert the real rendered
|
||||
// error. Scoped + torn down so the rest of the file keeps its headless
|
||||
// bail-graceful contract untouched.
|
||||
describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () => {
|
||||
const w = window as unknown as {
|
||||
matchMedia?: (q: string) => MediaQueryList;
|
||||
};
|
||||
const savedMatchMedia = w.matchMedia;
|
||||
const savedResizeObserver = globalThis.ResizeObserver;
|
||||
|
||||
beforeAll(() => {
|
||||
w.matchMedia = (query: string) =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList;
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
w.matchMedia = savedMatchMedia;
|
||||
globalThis.ResizeObserver = savedResizeObserver;
|
||||
});
|
||||
|
||||
it("sanity: with the polyfills xterm mounts and the opener runs", async () => {
|
||||
// Guards the premise of the tests below: if this fails, the opener never
|
||||
// fired and the error assertions would be vacuous.
|
||||
const open = vi.fn(async () => makeHandle({ sessionId: "up-1" }));
|
||||
renderView(new MockTerminalGateway(), "/cwd", { open });
|
||||
await waitFor(() => expect(open).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("renders an 'endpoint unavailable' launch failure as a visible alert without blocking the UI", async () => {
|
||||
// An OpenAI-compatible agent whose endpoint is down rejects the launch with a
|
||||
// Start error. The cell must show a visible, accessible error message — never
|
||||
// throw, never crash — so IdeA stays usable and other cells keep working.
|
||||
const open = vi.fn(async () => {
|
||||
throw {
|
||||
code: "AGENT_SESSION_START",
|
||||
message: "endpoint indisponible: http://localhost:11434/v1",
|
||||
};
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
renderView(new MockTerminalGateway(), "/cwd", { open }),
|
||||
).not.toThrow();
|
||||
|
||||
// The rejection is actually exercised (the opener was invoked and threw).
|
||||
await waitFor(() => expect(open).toHaveBeenCalled());
|
||||
|
||||
// A real, accessible error message is rendered to the user (role="alert"),
|
||||
// carrying the endpoint diagnostic — not just painted into the xterm buffer.
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toMatch(/Échec du lancement de l'agent/);
|
||||
expect(alert.textContent).toMatch(/endpoint indisponible/);
|
||||
|
||||
// The cell stays mounted and interactive (not a blank/blocked screen).
|
||||
expect(screen.getByTestId("terminal-view")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows NO failure banner on a successful launch", async () => {
|
||||
// Guard: the alert is strictly a failure surface — a healthy cell has none.
|
||||
const open = vi.fn(async () => makeHandle({ sessionId: "ok-1" }));
|
||||
renderView(new MockTerminalGateway(), "/cwd", { open });
|
||||
|
||||
await waitFor(() => expect(open).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(screen.queryByTestId("terminal-error")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -32,7 +32,7 @@
|
||||
* fresh one. If the session is gone (was explicitly closed), it opens fresh.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
@ -108,6 +108,12 @@ export function TerminalView({
|
||||
}: TerminalViewProps) {
|
||||
const { terminal } = useGateways();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
// A user-visible launch failure (e.g. an OpenAI-compatible endpoint that is
|
||||
// unreachable). Rendered as a DOM `role="alert"` banner over the cell so the
|
||||
// failure is always visible and accessible — not only painted into the xterm
|
||||
// buffer (which is invisible to assistive tech and absent when xterm can't
|
||||
// mount). `null` ⇒ no error. The cell stays mounted and IdeA stays usable.
|
||||
const [openError, setOpenError] = useState<string | null>(null);
|
||||
|
||||
// The opener (`open` or the terminal gateway) is read through a ref so the
|
||||
// effect does NOT depend on its identity. Otherwise every parent re-render
|
||||
@ -138,6 +144,9 @@ export function TerminalView({
|
||||
const reattacher = reattachRef.current ?? tgw?.reattach.bind(tgw);
|
||||
if (!container || !opener) return;
|
||||
|
||||
// Fresh (re)mount: clear any prior failure banner before we try to open.
|
||||
setOpenError(null);
|
||||
|
||||
const term = new Terminal({
|
||||
convertEol: false,
|
||||
cursorBlink: true,
|
||||
@ -218,6 +227,10 @@ export function TerminalView({
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A genuine launch failure (unreachable endpoint, model missing, network
|
||||
// drop…). Surface it as an accessible DOM banner AND as a red line in the
|
||||
// buffer. The cell renders as failed; IdeA stays usable (other cells work).
|
||||
setOpenError(`Échec du lancement de l'agent : ${describe(e)}`);
|
||||
term.write(
|
||||
`\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
|
||||
);
|
||||
@ -307,10 +320,41 @@ export function TerminalView({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid="terminal-view"
|
||||
style={{ width: "100%", height: "100%", minHeight: "16rem" }}
|
||||
/>
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: "16rem",
|
||||
}}
|
||||
>
|
||||
{/* xterm mounts into this inner node; the error banner is a sibling so
|
||||
React never fights xterm over the same subtree. */}
|
||||
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
||||
{openError && (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="terminal-error"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: "0.5rem 0.75rem",
|
||||
background: "rgba(120, 20, 20, 0.92)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
zIndex: 3,
|
||||
}}
|
||||
>
|
||||
{openError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user