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:
@ -23,6 +23,7 @@ import { useDrift } from "@/features/templates/useDrift";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useAgents } from "./useAgents";
|
||||
import { AgentLimitBadge } from "./AgentLimitBadge";
|
||||
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
||||
|
||||
export interface AgentsPanelProps {
|
||||
/** The project whose agents to manage. */
|
||||
@ -331,6 +332,15 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
const delegationSource = vm.delegationSourceByRequester[a.id];
|
||||
// Session-limit state (ARCHITECTURE §21), if the agent is limited.
|
||||
const limitState = vm.limitByAgent[a.id];
|
||||
// F35 — local model server launch state, correlated from the
|
||||
// agent's profile binding (`opencode.localModelServerId`), plus the
|
||||
// agent's last launch failure (if any).
|
||||
const boundServerId = vm.profiles.find((p) => p.id === a.profileId)
|
||||
?.opencode?.localModelServerId;
|
||||
const modelServerStatus = boundServerId
|
||||
? vm.modelServerStatusByServer[boundServerId]
|
||||
: undefined;
|
||||
const launchFailure = vm.launchFailureByAgent[a.id];
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
@ -388,6 +398,12 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* F35 — local model server launch state / actionable failure. */}
|
||||
<ModelServerLaunchBadge
|
||||
status={modelServerStatus}
|
||||
failure={launchFailure}
|
||||
/>
|
||||
|
||||
{/* F2 (ticket #4): announcements this agent is waiting on while
|
||||
it talks to another agent (requester == this row's id). Sits
|
||||
just above the agent drop-list. */}
|
||||
|
||||
100
frontend/src/features/agents/ModelServerLaunchBadge.test.tsx
Normal file
100
frontend/src/features/agents/ModelServerLaunchBadge.test.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* F35.1 — presentational tests for {@link ModelServerLaunchBadge}. Pure render
|
||||
* of the folded launch state: lifecycle labels, the reused/fresh distinction,
|
||||
* the actionable failure (with a details toggle that hides the code by default),
|
||||
* and the "render nothing" cases for unmanaged rows.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
||||
|
||||
describe("ModelServerLaunchBadge (F35.1)", () => {
|
||||
it("renders nothing without a status or a failure", () => {
|
||||
const { container } = render(<ModelServerLaunchBadge />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing for a notConfigured status (unmanaged endpoint)", () => {
|
||||
const { container } = render(
|
||||
<ModelServerLaunchBadge status={{ state: "notConfigured" }} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the probing and starting lifecycle labels", () => {
|
||||
const { rerender } = render(
|
||||
<ModelServerLaunchBadge status={{ state: "probing" }} />,
|
||||
);
|
||||
expect(screen.getByLabelText("model server status").textContent).toMatch(
|
||||
/vérification/i,
|
||||
);
|
||||
rerender(<ModelServerLaunchBadge status={{ state: "starting" }} />);
|
||||
expect(screen.getByLabelText("model server status").textContent).toMatch(
|
||||
/démarrage/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("distinguishes a reused server from a freshly started one", () => {
|
||||
const { rerender } = render(
|
||||
<ModelServerLaunchBadge status={{ state: "ready", reused: true }} />,
|
||||
);
|
||||
expect(screen.getByLabelText("model server status").textContent).toMatch(
|
||||
/réutilisé/i,
|
||||
);
|
||||
rerender(
|
||||
<ModelServerLaunchBadge status={{ state: "ready", reused: false }} />,
|
||||
);
|
||||
const label = screen.getByLabelText("model server status").textContent ?? "";
|
||||
expect(label).toMatch(/prêt/i);
|
||||
expect(label).not.toMatch(/réutilisé/i);
|
||||
});
|
||||
|
||||
it("shows an actionable failure message, not a bare 'agent failed'", () => {
|
||||
render(
|
||||
<ModelServerLaunchBadge
|
||||
failure={{
|
||||
cause: "model_server",
|
||||
code: "SERVER_UNREACHABLE",
|
||||
message: "llama-server did not become ready on :8080",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.textContent).toMatch(/did not become ready on :8080/);
|
||||
// The code is not exposed by default (logs/detail stay hidden).
|
||||
expect(screen.queryByLabelText("launch failure detail")).toBeNull();
|
||||
});
|
||||
|
||||
it("reveals the stable code/cause only when Détails is toggled", () => {
|
||||
render(
|
||||
<ModelServerLaunchBadge
|
||||
failure={{ cause: "model_server", code: "SPAWN", message: "boom" }}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("toggle launch failure detail"));
|
||||
const detail = screen.getByLabelText("launch failure detail");
|
||||
expect(detail.textContent).toMatch(/SPAWN/);
|
||||
expect(detail.textContent).toMatch(/model_server/);
|
||||
});
|
||||
|
||||
it("prefers the agent-scoped failure over a failed status", () => {
|
||||
render(
|
||||
<ModelServerLaunchBadge
|
||||
status={{ state: "failed", code: "S1", message: "status-level" }}
|
||||
failure={{ cause: "model_server", code: "A1", message: "agent-level" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).toMatch(/agent-level/);
|
||||
});
|
||||
|
||||
it("falls back to a failed status when there is no agent failure", () => {
|
||||
render(
|
||||
<ModelServerLaunchBadge
|
||||
status={{ state: "failed", code: "S1", message: "status-level boom" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).toMatch(/status-level boom/);
|
||||
});
|
||||
});
|
||||
115
frontend/src/features/agents/ModelServerLaunchBadge.tsx
Normal file
115
frontend/src/features/agents/ModelServerLaunchBadge.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
/**
|
||||
* `ModelServerLaunchBadge` — presentational launch-state indicator for an agent
|
||||
* whose OpenCode profile binds a local model server (F35.1). All state is folded
|
||||
* in {@link useAgents} (`modelServerStatusByServer` + `launchFailureByAgent`);
|
||||
* this component only renders it.
|
||||
*
|
||||
* It shows, in order of severity:
|
||||
* - a launch **failure** with its actionable message (not a bare "agent failed")
|
||||
* — the full logs are never shown by default; a "Détails" toggle reveals the
|
||||
* stable code/cause only;
|
||||
* - otherwise the model-server lifecycle: `probing`, `starting`, `ready`
|
||||
* (distinguishing a reused server from a freshly-started one).
|
||||
*
|
||||
* Renders nothing when there is neither a status to show nor a failure, so a
|
||||
* plain (non-local-model) agent row stays untouched.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { cn } from "@/shared";
|
||||
import type { ModelServerStatus } from "@/domain";
|
||||
import type { AgentLaunchFailure } from "./useAgents";
|
||||
|
||||
export interface ModelServerLaunchBadgeProps {
|
||||
/** The bound server's lifecycle status, when one has been observed. */
|
||||
status?: ModelServerStatus;
|
||||
/** The agent's last launch failure, when one occurred. */
|
||||
failure?: AgentLaunchFailure;
|
||||
}
|
||||
|
||||
/** Human label + tone for a non-failed lifecycle state. */
|
||||
function describeStatus(
|
||||
status: ModelServerStatus,
|
||||
): { label: string; tone: string; role?: "status" } | null {
|
||||
switch (status.state) {
|
||||
case "notConfigured":
|
||||
return null; // Nothing to surface for an unmanaged endpoint.
|
||||
case "probing":
|
||||
return { label: "vérification du serveur…", tone: "bg-muted/20 text-muted", role: "status" };
|
||||
case "starting":
|
||||
return { label: "démarrage du serveur…", tone: "bg-warning/20 text-warning", role: "status" };
|
||||
case "ready":
|
||||
return status.reused
|
||||
? { label: "serveur prêt (réutilisé)", tone: "bg-success/20 text-success" }
|
||||
: { label: "serveur prêt", tone: "bg-success/20 text-success" };
|
||||
case "failed":
|
||||
// Handled by the failure branch below (richer, actionable).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ModelServerLaunchBadge({
|
||||
status,
|
||||
failure,
|
||||
}: ModelServerLaunchBadgeProps) {
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
|
||||
// A launch failure (from `agentLaunchFailed`) or a `failed` status both mean
|
||||
// the launch could not complete — prefer the agent-scoped failure (it carries
|
||||
// the actionable message); fall back to the server `failed` status.
|
||||
const failed: { code: string; message: string; cause?: string } | null =
|
||||
failure
|
||||
? failure
|
||||
: status?.state === "failed"
|
||||
? { code: status.code, message: status.message }
|
||||
: null;
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span
|
||||
role="alert"
|
||||
aria-label="model server launch failed"
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
"bg-danger/20 text-danger",
|
||||
)}
|
||||
>
|
||||
Échec du lancement : {failed.message}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="toggle launch failure detail"
|
||||
onClick={() => setShowDetail((v) => !v)}
|
||||
className="text-xs text-muted underline hover:text-content"
|
||||
>
|
||||
Détails
|
||||
</button>
|
||||
{showDetail && (
|
||||
<span aria-label="launch failure detail" className="text-xs text-faint">
|
||||
code {failed.code}
|
||||
{failed.cause ? ` · cause ${failed.cause}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) return null;
|
||||
const described = describeStatus(status);
|
||||
if (!described) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
role={described.role}
|
||||
aria-label="model server status"
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
described.tone,
|
||||
)}
|
||||
>
|
||||
{described.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -5,7 +5,9 @@
|
||||
export { AgentsPanel } from "./AgentsPanel";
|
||||
export type { AgentsPanelProps } from "./AgentsPanel";
|
||||
export { useAgents } from "./useAgents";
|
||||
export type { AgentsViewModel } from "./useAgents";
|
||||
export type { AgentsViewModel, AgentLaunchFailure } from "./useAgents";
|
||||
export { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
||||
export type { ModelServerLaunchBadgeProps } from "./ModelServerLaunchBadge";
|
||||
export { ResumeProjectPanel } from "./ResumeProjectPanel";
|
||||
export type { ResumeProjectPanelProps } from "./ResumeProjectPanel";
|
||||
export { useResumeProject } from "./useResumeProject";
|
||||
|
||||
@ -13,6 +13,7 @@ import type {
|
||||
Agent,
|
||||
AgentProfile,
|
||||
GatewayError,
|
||||
ModelServerStatus,
|
||||
TerminalSession,
|
||||
} from "@/domain";
|
||||
import type { LiveAgent, OpenTerminalOptions, TerminalHandle } from "@/ports";
|
||||
@ -41,6 +42,17 @@ export interface AgentLimitState {
|
||||
suspected?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent-launch failure surfaced to the UI (F35), from `agentLaunchFailed`.
|
||||
* `cause` namespaces the origin (e.g. `"model_server"`); `code`/`message` are
|
||||
* the stable, actionable failure details.
|
||||
*/
|
||||
export interface AgentLaunchFailure {
|
||||
cause: string;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** What the agents UI needs from this hook. */
|
||||
export interface AgentsViewModel {
|
||||
/** All agents known to the project. */
|
||||
@ -67,6 +79,19 @@ export interface AgentsViewModel {
|
||||
* absent from the map is unlimited.
|
||||
*/
|
||||
limitByAgent: Record<string, AgentLimitState>;
|
||||
/**
|
||||
* Local model-server lifecycle status keyed by `serverId` (F35), folded from
|
||||
* `modelServerStatusChanged`. Correlate an agent to its server through the
|
||||
* profile's `opencode.localModelServerId`. A server absent from the map has no
|
||||
* observed status yet.
|
||||
*/
|
||||
modelServerStatusByServer: Record<string, ModelServerStatus>;
|
||||
/**
|
||||
* Last agent-launch failure keyed by agent id (F35), folded from
|
||||
* `agentLaunchFailed`. Carries an actionable `message`/`code`/`cause`. Cleared
|
||||
* for an agent as soon as it launches successfully (`agentLaunched`).
|
||||
*/
|
||||
launchFailureByAgent: Record<string, AgentLaunchFailure>;
|
||||
/** Last error message, or `null`. */
|
||||
error: string | null;
|
||||
/** Whether a request is in flight. */
|
||||
@ -154,6 +179,12 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
const [limitByAgent, setLimitByAgent] = useState<
|
||||
Record<string, AgentLimitState>
|
||||
>({});
|
||||
const [modelServerStatusByServer, setModelServerStatusByServer] = useState<
|
||||
Record<string, ModelServerStatus>
|
||||
>({});
|
||||
const [launchFailureByAgent, setLaunchFailureByAgent] = useState<
|
||||
Record<string, AgentLaunchFailure>
|
||||
>({});
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
@ -208,6 +239,15 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
void refresh();
|
||||
void refreshLiveAgents();
|
||||
}
|
||||
// A successful launch supersedes any prior launch failure for that agent
|
||||
// (F35): drop its stale error banner.
|
||||
if (event.type === "agentLaunched") {
|
||||
setLaunchFailureByAgent((prev) => {
|
||||
if (!(event.agentId in prev)) return prev;
|
||||
const { [event.agentId]: _cleared, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
// Record which door a delegation came through (mcp vs file). Absent
|
||||
// `source` (older backend) leaves the map untouched → no badge.
|
||||
if (
|
||||
@ -269,6 +309,24 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
},
|
||||
}));
|
||||
break;
|
||||
// F35 — local model server lifecycle during launch, keyed by server id.
|
||||
case "modelServerStatusChanged":
|
||||
setModelServerStatusByServer((prev) => ({
|
||||
...prev,
|
||||
[event.serverId]: event.status,
|
||||
}));
|
||||
break;
|
||||
// F35 — a launch failed with an actionable cause/message, keyed by agent.
|
||||
case "agentLaunchFailed":
|
||||
setLaunchFailureByAgent((prev) => ({
|
||||
...prev,
|
||||
[event.agentId]: {
|
||||
cause: event.cause,
|
||||
code: event.code,
|
||||
message: event.message,
|
||||
},
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@ -475,6 +533,8 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
liveAgents,
|
||||
delegationSourceByRequester,
|
||||
limitByAgent,
|
||||
modelServerStatusByServer,
|
||||
launchFailureByAgent,
|
||||
error,
|
||||
busy,
|
||||
runningAgentId,
|
||||
|
||||
129
frontend/src/features/agents/useAgentsModelServer.test.tsx
Normal file
129
frontend/src/features/agents/useAgentsModelServer.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user