feat(model-server): frontend modèles locaux — badge de statut & CRUD serveurs (#35) et wizard multi-profils OpenCode (#36)

Sprint « Modeles locaux », couche frontend.

#35 :
- F35.1 badge de statut de lancement du serveur local
  (ModelServerLaunchBadge + useAgentsModelServer).
- F35.2 feature model-servers : CRUD (ModelServersPanel / useModelServers /
  gateway modelServer) et ModelServerSelect.

#36 :
- Liste multi-profils OpenCode dans le wizard de premier lancement,
  gateway de clonage (clone_opencode_profile_from_seed).

Tests verts (exécution réelle) : tsc propre, vitest 608/608.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 15:50:18 +02:00
parent b82ac76f8b
commit 72476a650a
23 changed files with 1893 additions and 15 deletions

View File

@ -0,0 +1,129 @@
/**
* F35.1 — local model server launch state in {@link useAgents}.
*
* Drives the hook behind the real {@link DIProvider} with an in-memory
* {@link MockSystemGateway} to emit the two F35 domain events, and verifies they
* fold into `modelServerStatusByServer` (keyed by serverId) and
* `launchFailureByAgent` (keyed by agentId).
*
* Cases:
* - `modelServerStatusChanged` → status by server id (probing → starting → ready)
* - `agentLaunchFailed` → actionable failure by agent id
* - `agentLaunched` clears a prior launch failure for that agent
* - a `failed` status is retained per server (independent of the agent map)
*/
import { describe, it, expect } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type { DomainEvent } from "@/domain";
import type { Gateways } from "@/ports";
import { MockSystemGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { useAgents } from "./useAgents";
const PROJECT_ID = "proj-modelserver-001";
const AGENT = "agent-oc";
const SERVER = "srv-42";
function setup() {
const system = new MockSystemGateway();
const agent = {
listAgents: async () => [],
listLiveAgents: async () => [],
};
const profile = { listProfiles: async () => [] };
const gateways = { system, agent, profile } as unknown as Gateways;
const wrapper = ({ children }: { children: React.ReactNode }) => (
<DIProvider gateways={gateways}>{children}</DIProvider>
);
const view = renderHook(() => useAgents(PROJECT_ID), { wrapper });
return { system, view };
}
async function emit(system: MockSystemGateway, event: DomainEvent) {
await act(async () => {
system.emit(event);
await Promise.resolve();
});
}
describe("useAgents — local model server launch state (F35.1)", () => {
it("folds modelServerStatusChanged into modelServerStatusByServer by serverId", async () => {
const { system, view } = setup();
await emit(system, {
type: "modelServerStatusChanged",
serverId: SERVER,
status: { state: "probing" },
});
expect(view.result.current.modelServerStatusByServer[SERVER]).toEqual({
state: "probing",
});
await emit(system, {
type: "modelServerStatusChanged",
serverId: SERVER,
status: { state: "starting" },
});
await emit(system, {
type: "modelServerStatusChanged",
serverId: SERVER,
status: { state: "ready", reused: true },
});
expect(view.result.current.modelServerStatusByServer[SERVER]).toEqual({
state: "ready",
reused: true,
});
});
it("folds agentLaunchFailed into launchFailureByAgent by agentId", async () => {
const { system, view } = setup();
await emit(system, {
type: "agentLaunchFailed",
agentId: AGENT,
cause: "model_server",
code: "SERVER_UNREACHABLE",
message: "llama-server did not become ready on :8080",
});
expect(view.result.current.launchFailureByAgent[AGENT]).toEqual({
cause: "model_server",
code: "SERVER_UNREACHABLE",
message: "llama-server did not become ready on :8080",
});
});
it("a successful agentLaunched clears the agent's prior launch failure", async () => {
const { system, view } = setup();
await emit(system, {
type: "agentLaunchFailed",
agentId: AGENT,
cause: "model_server",
code: "SERVER_UNREACHABLE",
message: "boom",
});
expect(view.result.current.launchFailureByAgent[AGENT]).toBeTruthy();
await emit(system, {
type: "agentLaunched",
agentId: AGENT,
sessionId: "sess-1",
});
expect(view.result.current.launchFailureByAgent[AGENT]).toBeUndefined();
});
it("keeps a per-server failed status independent of the agent failure map", async () => {
const { system, view } = setup();
await emit(system, {
type: "modelServerStatusChanged",
serverId: SERVER,
status: { state: "failed", code: "SPAWN", message: "binary not found" },
});
expect(view.result.current.modelServerStatusByServer[SERVER]).toEqual({
state: "failed",
code: "SPAWN",
message: "binary not found",
});
expect(view.result.current.launchFailureByAgent[AGENT]).toBeUndefined();
});
});