diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 4fa7745..240e05a 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -19,6 +19,7 @@ import { TauriProjectGateway } from "./project"; import { TauriTerminalGateway } from "./terminal"; import { TauriLayoutGateway } from "./layout"; import { TauriProfileGateway } from "./profile"; +import { TauriModelServerGateway } from "./modelServer"; import { TauriTemplateGateway } from "./template"; import { TauriSkillGateway } from "./skill"; import { TauriMemoryGateway } from "./memory"; @@ -56,6 +57,7 @@ export function createTauriGateways(): Gateways { git: new TauriGitGateway(), remote: new TauriRemoteGateway(), profile: new TauriProfileGateway(), + modelServer: new TauriModelServerGateway(), template: new TauriTemplateGateway(), skill: new TauriSkillGateway(), memory: new TauriMemoryGateway(), @@ -76,6 +78,7 @@ export { TauriTerminalGateway, TauriLayoutGateway, TauriProfileGateway, + TauriModelServerGateway, TauriTemplateGateway, TauriSkillGateway, TauriMemoryGateway, diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 68c53f3..9ff1d29 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -23,6 +23,7 @@ import type { LayoutList, LayoutOperation, LayoutTree, + LocalModelServerConfig, Memory, MemoryIndexEntry, MemoryLink, @@ -57,6 +58,7 @@ import type { ConversationGateway, ConversationPageRequest, ConversationDetails, + CloneOpenCodeProfileFromSeedInput, CreateAgentInput, CreateMemoryInput, CreateSkillInput, @@ -68,6 +70,7 @@ import type { MemoryGateway, GitGateway, LayoutGateway, + ModelServerGateway, OpenTerminalOptions, ProfileGateway, ProjectGateway, @@ -1185,6 +1188,8 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [ export class MockProfileGateway implements ProfileGateway { private profiles: AgentProfile[] = []; private configured = false; + /** Monotonic suffix so cloned OpenCode profiles get distinct ids/names. */ + private cloneCounter = 0; async firstRunState(): Promise { return { @@ -1228,6 +1233,61 @@ export class MockProfileGateway implements ProfileGateway { this.configured = true; return structuredClone(profiles); } + + async cloneOpenCodeProfileFromSeed( + input: CloneOpenCodeProfileFromSeedInput = {}, + ): Promise { + const seed = MOCK_REFERENCE_PROFILES.find( + (p) => p.structuredAdapter === "openCode", + ); + if (!seed) throw new Error("no OpenCode seed profile"); + this.cloneCounter += 1; + return structuredClone({ + ...seed, + id: `mock-opencode-clone-${this.cloneCounter}`, + name: input.name ?? `${seed.name} (copy ${this.cloneCounter})`, + opencode: input.opencode ?? seed.opencode, + }); + } +} + +/** + * In-memory local model server registry (F35). Tracks declared servers and can + * be told which ids are still referenced by a profile, so `deleteModelServer` + * reproduces the backend `model_server_in_use` rejection offline. + */ +export class MockModelServerGateway implements ModelServerGateway { + private servers: LocalModelServerConfig[] = []; + private readonly inUse = new Set(); + + async listModelServers(): Promise { + return structuredClone(this.servers); + } + + async saveModelServer( + config: LocalModelServerConfig, + ): Promise { + const i = this.servers.findIndex((s) => s.id === config.id); + if (i >= 0) this.servers[i] = structuredClone(config); + else this.servers.push(structuredClone(config)); + return structuredClone(config); + } + + async deleteModelServer(serverId: string): Promise { + if (this.inUse.has(serverId)) { + const err: GatewayError = { + code: "model_server_in_use", + message: "A profile still references this server.", + }; + throw err; + } + this.servers = this.servers.filter((s) => s.id !== serverId); + } + + /** Test hook: mark a server id as still referenced (delete then rejects). */ + markInUse(serverId: string): void { + this.inUse.add(serverId); + } } /** @@ -2454,6 +2514,7 @@ export function createMockGateways(): Gateways { git: new MockGitGateway(), remote: new MockRemoteGateway(), profile: new MockProfileGateway(), + modelServer: new MockModelServerGateway(), template: new MockTemplateGateway(agentGateway), skill: new MockSkillGateway(agentGateway), memory: new MockMemoryGateway(), diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index 7566d56..162ec31 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -21,6 +21,7 @@ describe("createMockGateways", () => { "input", "layout", "memory", + "modelServer", "permission", "profile", "project", diff --git a/frontend/src/adapters/mock/profile.test.ts b/frontend/src/adapters/mock/profile.test.ts index b9a6f15..74d4bbe 100644 --- a/frontend/src/adapters/mock/profile.test.ts +++ b/frontend/src/adapters/mock/profile.test.ts @@ -92,4 +92,33 @@ describe("MockProfileGateway", () => { await gw.configureProfiles([]); expect((await gw.firstRunState()).isFirstRun).toBe(false); }); + + it("cloneOpenCodeProfileFromSeed mints distinct ids and copies the seed config (F36)", async () => { + const gw = new MockProfileGateway(); + + const a = await gw.cloneOpenCodeProfileFromSeed(); + const b = await gw.cloneOpenCodeProfileFromSeed(); + + expect(a.structuredAdapter).toBe("openCode"); + expect(a.opencode?.baseURL).toBe("http://localhost:8080/v1"); + // Identity is the id — two clones must never collide. + expect(a.id).not.toBe(b.id); + }); + + it("cloneOpenCodeProfileFromSeed honours name/config overrides (Duplicate path)", async () => { + const gw = new MockProfileGateway(); + const cloned = await gw.cloneOpenCodeProfileFromSeed({ + name: "My local model", + opencode: { + baseURL: "http://localhost:9999/v1", + model: "custom-model", + localModelServerId: "srv-7", + }, + }); + + expect(cloned.name).toBe("My local model"); + expect(cloned.opencode?.baseURL).toBe("http://localhost:9999/v1"); + expect(cloned.opencode?.model).toBe("custom-model"); + expect(cloned.opencode?.localModelServerId).toBe("srv-7"); + }); }); diff --git a/frontend/src/adapters/modelServer.ts b/frontend/src/adapters/modelServer.ts new file mode 100644 index 0000000..b2123b4 --- /dev/null +++ b/frontend/src/adapters/modelServer.ts @@ -0,0 +1,31 @@ +/** + * Tauri adapter for {@link ModelServerGateway} (F35). One of the only places that + * calls `invoke()`; features reach it exclusively through the port. + * + * Commands and payload keys are camelCase, matching the backend DTO convention. + * The flat `LocalModelServerConfig` wire shape is the DTO frozen by Architect — + * no nesting. + */ + +import { invoke } from "@tauri-apps/api/core"; + +import type { LocalModelServerConfig } from "@/domain"; +import type { ModelServerGateway } from "@/ports"; + +export class TauriModelServerGateway implements ModelServerGateway { + listModelServers(): Promise { + return invoke("list_model_servers"); + } + + saveModelServer( + config: LocalModelServerConfig, + ): Promise { + return invoke("save_model_server", { + request: { config }, + }); + } + + async deleteModelServer(serverId: string): Promise { + await invoke("delete_model_server", { serverId }); + } +} diff --git a/frontend/src/adapters/profile.ts b/frontend/src/adapters/profile.ts index da1edef..f15f038 100644 --- a/frontend/src/adapters/profile.ts +++ b/frontend/src/adapters/profile.ts @@ -9,7 +9,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { AgentProfile, FirstRunState, ProfileAvailability } from "@/domain"; -import type { ProfileGateway } from "@/ports"; +import type { CloneOpenCodeProfileFromSeedInput, ProfileGateway } from "@/ports"; export class TauriProfileGateway implements ProfileGateway { firstRunState(): Promise { @@ -43,4 +43,12 @@ export class TauriProfileGateway implements ProfileGateway { request: { profiles }, }); } + + cloneOpenCodeProfileFromSeed( + input: CloneOpenCodeProfileFromSeedInput = {}, + ): Promise { + return invoke("clone_opencode_profile_from_seed", { + request: { name: input.name, opencode: input.opencode }, + }); + } } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 1b48846..a74bc36 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -13,11 +13,92 @@ export interface HealthReport { note: string | null; } +/** + * Lifecycle status of a local model server during an agent launch (F35, mirror + * of the backend `ModelServerStatusDto`, tagged on `state`, camelCase wire). + * + * Contract note — the backend event carries the `serverId` on the *envelope* + * ({@link DomainEvent} `modelServerStatusChanged`), not inside the status, and it + * does NOT emit `baseURL`/`model` in the status payload: the UI reads what the + * backend actually sends. `reused` (on `ready`) tells apart a freshly-started + * server from a reused running one. + */ +export type ModelServerStatus = + | { state: "notConfigured" } + | { state: "probing" } + | { state: "starting" } + | { state: "ready"; reused: boolean } + | { state: "failed"; code: string; message: string }; + +/** + * Stop policy of a managed local model server (mirror of the backend + * `StopPolicyDto`, camelCase wire): + * - `keepAlive`: leave the process running, + * - `stopOnAppExit`: stop when IdeA exits (the effective default), + * - `stopWhenUnused`: stop when no profile references it. + */ +export type StopPolicy = "keepAlive" | "stopOnAppExit" | "stopWhenUnused"; + +/** + * A declared local model server (F35, mirror of the backend flat + * `LocalModelServerConfigDto`, camelCase wire). Global to IdeA (not project + * scoped). Referenced by an OpenCode profile through + * `OpenCodeConfig.localModelServerId` (= this `id`). + * + * Contract notes: + * - `id` is a UUID **minted client-side** on create (the backend requires a valid + * UUID; it does not generate the server id, only an internal model id it hides). + * - `servedModelName` is what the user configures and what is sent to OpenCode as + * the `model` — the backend's internal `model.id`/`model.label` are not exposed. + * - `modelPath` is required by the backend when `autoStart` is `true`. + */ +export interface LocalModelServerConfig { + id: string; + /** Only `llamaCpp` is supported today (camelCase, not "llamacpp"). */ + kind: "llamaCpp"; + name: string; + /** OpenAI-compatible base URL served by `llama-server` (normalised to `/v1`). */ + baseURL: string; + port: number; + /** Absolute `.gguf` path. Required when `autoStart` is `true`. */ + modelPath?: string; + /** Model name exposed by the server and sent to OpenCode as `model`. */ + servedModelName: string; + /** Explicit `llama-server` executable path/command (optional). */ + binaryPath?: string; + args: string[]; + autoStart: boolean; + stopPolicy: StopPolicy; +} + /** A domain event relayed from the backend (tagged union on `type`). */ export type DomainEvent = | { type: "projectCreated"; projectId: string } | { type: "agentLaunched"; agentId: string; sessionId: string } | { type: "agentExited"; agentId: string; code: number } + | { + /** + * A local model server changed lifecycle state while (re)launching an + * agent whose OpenCode profile binds `localModelServerId` (F35). Keyed by + * `serverId`; the frontend correlates it back to the agent through the + * profile's `opencode.localModelServerId`. + */ + type: "modelServerStatusChanged"; + serverId: string; + status: ModelServerStatus; + } + | { + /** + * An agent launch failed before a runtime session was created (F35). Unlike + * a generic "agent failed", this carries an actionable `message` and a + * stable `code`; `cause` namespaces the failure (e.g. `"model_server"`). + */ + type: "agentLaunchFailed"; + agentId: string; + cause: string; + code: string; + message: string; + } | { type: "agentProfileChanged"; agentId: string; profileId: string } | { type: "agentBusyChanged"; agentId: string; busy: boolean } | { @@ -687,6 +768,13 @@ export interface OpenCodeConfig { reasoning?: boolean; /** Enables attachments. Effective default: `false`. */ attachment?: boolean; + /** + * Optional id of a managed local model server (F35) this profile binds to. + * When set, IdeA can start/stop the referenced `llama-server` for the profile; + * omitted when the profile points at an externally-managed endpoint (mirrors + * the backend `skip_serializing_if`). Opaque string id on the wire. + */ + localModelServerId?: string; } /** diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 0416f2e..6eb865e 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -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 (
  • )} + {/* F35 — local model server launch state / actionable failure. */} + + {/* 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. */} diff --git a/frontend/src/features/agents/ModelServerLaunchBadge.test.tsx b/frontend/src/features/agents/ModelServerLaunchBadge.test.tsx new file mode 100644 index 0000000..9e92b57 --- /dev/null +++ b/frontend/src/features/agents/ModelServerLaunchBadge.test.tsx @@ -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(); + expect(container.firstChild).toBeNull(); + }); + + it("renders nothing for a notConfigured status (unmanaged endpoint)", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("shows the probing and starting lifecycle labels", () => { + const { rerender } = render( + , + ); + expect(screen.getByLabelText("model server status").textContent).toMatch( + /vérification/i, + ); + rerender(); + expect(screen.getByLabelText("model server status").textContent).toMatch( + /démarrage/i, + ); + }); + + it("distinguishes a reused server from a freshly started one", () => { + const { rerender } = render( + , + ); + expect(screen.getByLabelText("model server status").textContent).toMatch( + /réutilisé/i, + ); + rerender( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(screen.getByRole("alert").textContent).toMatch(/agent-level/); + }); + + it("falls back to a failed status when there is no agent failure", () => { + render( + , + ); + expect(screen.getByRole("alert").textContent).toMatch(/status-level boom/); + }); +}); diff --git a/frontend/src/features/agents/ModelServerLaunchBadge.tsx b/frontend/src/features/agents/ModelServerLaunchBadge.tsx new file mode 100644 index 0000000..5a3bfa1 --- /dev/null +++ b/frontend/src/features/agents/ModelServerLaunchBadge.tsx @@ -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 ( + + + Échec du lancement : {failed.message} + + + {showDetail && ( + + code {failed.code} + {failed.cause ? ` · cause ${failed.cause}` : ""} + + )} + + ); + } + + if (!status) return null; + const described = describeStatus(status); + if (!described) return null; + + return ( + + {described.label} + + ); +} diff --git a/frontend/src/features/agents/index.ts b/frontend/src/features/agents/index.ts index 35d7a69..929f070 100644 --- a/frontend/src/features/agents/index.ts +++ b/frontend/src/features/agents/index.ts @@ -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"; diff --git a/frontend/src/features/agents/useAgents.ts b/frontend/src/features/agents/useAgents.ts index 4e2a56e..d8234df 100644 --- a/frontend/src/features/agents/useAgents.ts +++ b/frontend/src/features/agents/useAgents.ts @@ -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; + /** + * 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; + /** + * 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; /** 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 >({}); + const [modelServerStatusByServer, setModelServerStatusByServer] = useState< + Record + >({}); + const [launchFailureByAgent, setLaunchFailureByAgent] = useState< + Record + >({}); 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, diff --git a/frontend/src/features/agents/useAgentsModelServer.test.tsx b/frontend/src/features/agents/useAgentsModelServer.test.tsx new file mode 100644 index 0000000..9ff8ec9 --- /dev/null +++ b/frontend/src/features/agents/useAgentsModelServer.test.tsx @@ -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 }) => ( + {children} + ); + 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(); + }); +}); diff --git a/frontend/src/features/first-run/FirstRunWizard.test.tsx b/frontend/src/features/first-run/FirstRunWizard.test.tsx index 22657f2..893ec28 100644 --- a/frontend/src/features/first-run/FirstRunWizard.test.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.test.tsx @@ -13,8 +13,8 @@ import { fireEvent, } from "@testing-library/react"; -import { MockProfileGateway } from "@/adapters/mock"; -import type { ProfileAvailability } from "@/domain"; +import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock"; +import type { LocalModelServerConfig, ProfileAvailability } from "@/domain"; import type { Gateways } from "@/ports"; import { DIProvider } from "@/app/di"; import { FirstRunWizard } from "./FirstRunWizard"; @@ -23,10 +23,12 @@ import { DETECT_TIMEOUT_MS } from "./useFirstRun"; function renderWizard( profile: MockProfileGateway = new MockProfileGateway(), onDone = vi.fn(), + modelServer: MockModelServerGateway = new MockModelServerGateway(), ) { - const gateways = { profile } as unknown as Gateways; + const gateways = { profile, modelServer } as unknown as Gateways; return { profile, + modelServer, onDone, ...render( @@ -277,6 +279,182 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => { }); }); +describe("FirstRunWizard — several local OpenCode profiles (F36)", () => { + const OPENCODE = "OpenCode + llama.cpp"; + const CLONE1 = `${OPENCODE} (copy 1)`; + + it('"Add OpenCode profile" clones the seed into a new, editable, pre-selected row', async () => { + renderWizard(); + await waitForLoaded(); + + // Only one OpenCode row to begin with (the seed reference). + expect(screen.getAllByText(OPENCODE).length).toBe(1); + + fireEvent.click( + screen.getByRole("button", { name: "Add OpenCode profile" }), + ); + + // A second OpenCode row appears, pre-filled from the seed and pre-selected. + const added = await screen.findByLabelText(`use ${CLONE1}`); + expect((added as HTMLInputElement).checked).toBe(true); + expect( + (screen.getByLabelText(`${CLONE1} base url`) as HTMLInputElement).value, + ).toBe("http://localhost:8080/v1"); + // Its name is editable per profile (identity is the id, not the name). + expect(screen.getByLabelText(`${CLONE1} name`)).toBeTruthy(); + }); + + it("persists two distinct OpenCode profiles when both are selected", async () => { + const { profile } = renderWizard(); + await waitForLoaded(); + + // Select the seed row and edit it. + fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`)); + fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), { + target: { value: "qwen3-coder-14b" }, + }); + + // Add a second one and give it a different endpoint. + fireEvent.click( + screen.getByRole("button", { name: "Add OpenCode profile" }), + ); + await screen.findByLabelText(`use ${CLONE1}`); + fireEvent.change(screen.getByLabelText(`${CLONE1} base url`), { + target: { value: "http://localhost:9191/v1" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + const opencode = saved.filter((p) => p.structuredAdapter === "openCode"); + expect(opencode.length).toBe(2); + // Distinct ids (identity) and distinct endpoints. + expect(new Set(opencode.map((p) => p.id)).size).toBe(2); + expect(opencode.map((p) => p.opencode?.baseURL).sort()).toEqual([ + "http://localhost:8080/v1", + "http://localhost:9191/v1", + ]); + }); + }); + + it("Duplicate clones a row carrying its current config over", async () => { + const { profile } = renderWizard(); + await waitForLoaded(); + + // Edit the seed row, then duplicate it. + fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`)); + fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), { + target: { value: "qwen3-coder-7b" }, + }); + fireEvent.click( + screen.getByRole("button", { name: `duplicate ${OPENCODE}` }), + ); + + const dupName = `${OPENCODE} (copy)`; + await screen.findByLabelText(`use ${dupName}`); + // The duplicate inherits the edited model. + expect( + (screen.getByLabelText(`${dupName} model`) as HTMLInputElement).value, + ).toBe("qwen3-coder-7b"); + + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + await waitFor(async () => { + const saved = await profile.listProfiles(); + const models = saved + .filter((p) => p.structuredAdapter === "openCode") + .map((p) => p.opencode?.model); + expect(models).toEqual(["qwen3-coder-7b", "qwen3-coder-7b"]); + }); + }); + + it("editing the name round-trips on save", async () => { + const { profile } = renderWizard(); + await waitForLoaded(); + + fireEvent.click( + screen.getByRole("button", { name: "Add OpenCode profile" }), + ); + await screen.findByLabelText(`use ${CLONE1}`); + fireEvent.change(screen.getByLabelText(`${CLONE1} name`), { + target: { value: "Fast local model" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + await waitFor(async () => { + const saved = await profile.listProfiles(); + const oc = saved.find((p) => p.structuredAdapter === "openCode"); + expect(oc?.name).toBe("Fast local model"); + }); + }); + + it("reasoning/attachment round-trip on save (F36)", async () => { + const { profile } = renderWizard(); + await waitForLoaded(); + + fireEvent.click( + screen.getByRole("button", { name: "Add OpenCode profile" }), + ); + await screen.findByLabelText(`use ${CLONE1}`); + + // Reasoning defaults on (backend effective default true); turn it off. + const reasoning = screen.getByLabelText( + `${CLONE1} reasoning`, + ) as HTMLInputElement; + expect(reasoning.checked).toBe(true); + fireEvent.click(reasoning); + // Attachments default off; turn them on. + fireEvent.click(screen.getByLabelText(`${CLONE1} attachment`)); + + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + await waitFor(async () => { + const saved = await profile.listProfiles(); + const oc = saved.find((p) => p.structuredAdapter === "openCode"); + expect(oc?.opencode?.reasoning).toBe(false); + expect(oc?.opencode?.attachment).toBe(true); + }); + }); + + it("binds localModelServerId via the server dropdown (F35.2), replacing free text", async () => { + // Seed a declared server so the dropdown offers it. + const modelServer = new MockModelServerGateway(); + const server: LocalModelServerConfig = { + id: "550e8400-e29b-41d4-a716-446655440000", + kind: "llamaCpp", + name: "Local A", + baseURL: "http://localhost:8080/v1", + port: 8080, + servedModelName: "qwen3-coder-30b", + args: [], + autoStart: false, + stopPolicy: "stopOnAppExit", + }; + await modelServer.saveModelServer(server); + + const { profile } = renderWizard(new MockProfileGateway(), vi.fn(), modelServer); + await waitForLoaded(); + + fireEvent.click( + screen.getByRole("button", { name: "Add OpenCode profile" }), + ); + await screen.findByLabelText(`use ${CLONE1}`); + + // The old free-text field is gone; a dropdown replaces it. + expect( + screen.queryByLabelText(`${CLONE1} local model server id`), + ).toBeNull(); + const select = await screen.findByLabelText(`${CLONE1} local model server`); + fireEvent.change(select, { target: { value: server.id } }); + + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + await waitFor(async () => { + const saved = await profile.listProfiles(); + const oc = saved.find((p) => p.structuredAdapter === "openCode"); + expect(oc?.opencode?.localModelServerId).toBe(server.id); + }); + }); +}); + // Ticket #28 — the wizard must survive a detection that never answers. When the // backend `detect_profiles` command panics, the `invoke` promise is neither // resolved nor rejected: nothing after `await detectProfiles(...)` ever runs. diff --git a/frontend/src/features/first-run/FirstRunWizard.tsx b/frontend/src/features/first-run/FirstRunWizard.tsx index e7ed7a1..3d89460 100644 --- a/frontend/src/features/first-run/FirstRunWizard.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.tsx @@ -15,8 +15,18 @@ * `./profile`. */ -import type { AgentProfile, HttpChatConfig, OpenCodeConfig } from "@/domain"; +import type { + AgentProfile, + HttpChatConfig, + LocalModelServerConfig, + OpenCodeConfig, +} from "@/domain"; import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared"; +import { + ModelServersPanel, + ModelServerSelect, + useModelServers, +} from "@/features/model-servers"; import { useFirstRun, type WizardEntry } from "./useFirstRun"; import { defaultHttpChatConfig, @@ -48,6 +58,7 @@ export function FirstRunWizard({ forceOpen?: boolean; }) { const vm = useFirstRun(); + const modelServers = useModelServers(); if (vm.isFirstRun === null) return null; if (!forceOpen && vm.isFirstRun === false) return null; @@ -93,16 +104,40 @@ export function FirstRunWizard({ Detecting… )} + {/* F36: declare several local OpenCode profiles. Each click clones the + canonical `opencode-llamacpp` seed into a new, editable row. */} + + {/* F35.2 — declare/edit/delete the local llama.cpp servers an OpenCode + profile can bind to. Sits above the profile list so a server exists + before it is picked in the OpenCode dropdown. */} + +
      {vm.entries.map((entry) => ( vm.toggle(entry.profile.id)} onChange={(p) => vm.updateProfile(entry.profile.id, p)} onRemove={() => vm.remove(entry.profile.id)} + onDuplicate={ + entry.profile.structuredAdapter === "openCode" + ? () => + vm.addOpenCodeProfile({ + name: `${entry.profile.name} (copy)`, + opencode: entry.profile.opencode, + }) + : undefined + } /> ))}
    @@ -120,17 +155,24 @@ export function FirstRunWizard({ /** One editable candidate row: select, edit command/args, see availability. */ function ProfileRow({ entry, + servers, onToggle, onChange, onRemove, + onDuplicate, }: { entry: WizardEntry; + /** Declared local model servers (F35.2), for the OpenCode binding dropdown. */ + servers: LocalModelServerConfig[]; onToggle: () => void; onChange: (p: AgentProfile) => void; onRemove: () => void; + /** Present only for OpenCode rows: clone this row into a new profile (F36). */ + onDuplicate?: () => void; }) { const { profile, selected, available } = entry; const errors = validateProfile(profile); + const isOpenCode = profile.structuredAdapter === "openCode"; return (
  • @@ -158,16 +200,41 @@ function ProfileRow({ > {available === null ? "—" : available ? "✓ installed" : "✗ not found"} - - × - +
    + {onDuplicate && ( + + )} + + × + +
    + {/* F36: an OpenCode profile's name is identity-neutral but user-facing, so + it is editable per profile (several local models coexist). */} + {isOpenCode && ( + + )} +
  • ); @@ -208,10 +280,13 @@ function ProfileRow({ function OpenCodeFields({ profile, errors, + servers, onChange, }: { profile: AgentProfile; errors: ProfileErrors; + /** Declared local model servers (F35.2), for the binding dropdown. */ + servers: LocalModelServerConfig[]; onChange: (p: AgentProfile) => void; }) { const opencode = profile.opencode ?? defaultOpenCodeConfig(); @@ -262,6 +337,44 @@ function OpenCodeFields({ }} /> + +
    + + + +
    + + ); } diff --git a/frontend/src/features/first-run/useFirstRun.ts b/frontend/src/features/first-run/useFirstRun.ts index ff7b9eb..0cc1f8a 100644 --- a/frontend/src/features/first-run/useFirstRun.ts +++ b/frontend/src/features/first-run/useFirstRun.ts @@ -19,6 +19,7 @@ import type { AgentProfile, FirstRunState, GatewayError, + OpenCodeConfig, ProfileAvailability, } from "@/domain"; import { useGateways } from "@/app/di"; @@ -50,6 +51,15 @@ export interface FirstRunViewModel { toggle: (id: string) => void; /** Replaces a candidate's profile (edited command/args/injection). */ updateProfile: (id: string, profile: AgentProfile) => void; + /** + * Mints a new OpenCode profile from the seed (F36 — several local OpenCode + * profiles) and appends it, pre-selected, to the rows. Optionally overrides the + * name/config (used by "Duplicate" to carry a row's endpoint over). + */ + addOpenCodeProfile: (input?: { + name?: string; + opencode?: OpenCodeConfig; + }) => Promise; /** Removes a candidate by id. */ remove: (id: string) => void; /** Runs detection over all candidates, filling availability. */ @@ -187,6 +197,24 @@ export function useFirstRun(): FirstRunViewModel { ); }, []); + const addOpenCodeProfile = useCallback( + async (input?: { name?: string; opencode?: OpenCodeConfig }) => { + setError(null); + try { + const created = await profile.cloneOpenCodeProfileFromSeed(input); + // Freshly cloned profiles start selected (the user opted in by adding + // one) and available=null (detection re-probes them on demand). + setEntries((prev) => [ + ...prev, + { profile: created, selected: true, available: null }, + ]); + } catch (e) { + setError(describe(e)); + } + }, + [profile], + ); + const remove = useCallback((id: string) => { setEntries((prev) => prev.filter((e) => e.profile.id !== id)); }, []); @@ -221,6 +249,7 @@ export function useFirstRun(): FirstRunViewModel { detecting, toggle, updateProfile, + addOpenCodeProfile, remove, detect, finish, diff --git a/frontend/src/features/model-servers/ModelServerSelect.tsx b/frontend/src/features/model-servers/ModelServerSelect.tsx new file mode 100644 index 0000000..e36295a --- /dev/null +++ b/frontend/src/features/model-servers/ModelServerSelect.tsx @@ -0,0 +1,57 @@ +/** + * `ModelServerSelect` (F35.2) — a dropdown to bind an OpenCode profile to one of + * the declared local model servers, or to none ("external endpoint"). Replaces + * the free-text `localModelServerId` input posed in F36. Presentational: the + * server list and the current value are passed in. + */ + +import type { LocalModelServerConfig } from "@/domain"; +import { cn } from "@/shared"; + +/** The sentinel option value standing for "no managed server". */ +const NONE = ""; + +export interface ModelServerSelectProps { + /** The declared servers to choose from. */ + servers: LocalModelServerConfig[]; + /** The currently-bound server id, or `undefined` for none. */ + value?: string; + /** Called with the new server id, or `undefined` when "none" is chosen. */ + onChange: (serverId: string | undefined) => void; + /** Accessible label (defaults to a generic one). */ + ariaLabel?: string; +} + +export function ModelServerSelect({ + servers, + value, + onChange, + ariaLabel = "local model server", +}: ModelServerSelectProps) { + // A previously-bound server that no longer exists (deleted): keep it visible + // so the binding isn't silently dropped from the UI. + const missing = + value !== undefined && !servers.some((s) => s.id === value); + + return ( + + ); +} diff --git a/frontend/src/features/model-servers/ModelServersPanel.tsx b/frontend/src/features/model-servers/ModelServersPanel.tsx new file mode 100644 index 0000000..2c3b968 --- /dev/null +++ b/frontend/src/features/model-servers/ModelServersPanel.tsx @@ -0,0 +1,323 @@ +/** + * `ModelServersPanel` (F35.2) — declare / edit / delete the local `llama.cpp` + * servers an OpenCode profile can bind to. Presentational: all I/O state comes + * from a {@link ModelServersViewModel} passed in (`useModelServers`), so the + * panel renders and is testable in isolation. + * + * A single editable draft is shown at a time (add or edit); the list underneath + * offers Edit / Delete per server. The `model_server_in_use` rejection surfaces + * as the vm's error banner (a referenced server can't be removed). + */ + +import { useState } from "react"; + +import type { LocalModelServerConfig, StopPolicy } from "@/domain"; +import { Button, IconButton, Input, Panel, cn } from "@/shared"; +import type { ModelServersViewModel } from "./useModelServers"; +import { + emptyModelServer, + parseArgs, + validateModelServer, + type ModelServerErrors, +} from "./modelServer"; + +/** Small caption above a control. */ +function Caption({ children }: { children: React.ReactNode }) { + return {children}; +} + +const STOP_POLICIES: { value: StopPolicy; label: string }[] = [ + { value: "stopOnAppExit", label: "Stop on app exit" }, + { value: "keepAlive", label: "Keep alive" }, + { value: "stopWhenUnused", label: "Stop when unused" }, +]; + +export interface ModelServersPanelProps { + /** The model-server registry view-model (from `useModelServers`). */ + vm: ModelServersViewModel; +} + +export function ModelServersPanel({ vm }: ModelServersPanelProps) { + // The server being edited, or a fresh draft when "Add" is clicked. `null` ⇒ + // no editor open (list-only). + const [draft, setDraft] = useState(null); + + function startAdd() { + vm.clearError(); + setDraft(emptyModelServer()); + } + + function startEdit(server: LocalModelServerConfig) { + vm.clearError(); + setDraft({ ...server }); + } + + async function submit() { + if (!draft) return; + const saved = await vm.save(draft); + if (saved) setDraft(null); + } + + return ( + +

    + Local model servers +

    + + + } + > +
    + {vm.error && ( +

    + {vm.error} +

    + )} + + {vm.servers.length === 0 && !draft && ( +

    + No local server declared yet. Add one to auto-manage a `llama-server`, + or keep using an external endpoint. +

    + )} + +
      + {vm.servers.map((server) => ( +
    • + + + {server.name} + + + {server.baseURL} · {server.servedModelName} + {server.autoStart ? " · auto-start" : ""} + + + + + void vm.remove(server.id)} + disabled={vm.busy} + > + × + + +
    • + ))} +
    + + {draft && ( + { + vm.clearError(); + setDraft(null); + }} + onSubmit={() => void submit()} + /> + )} +
    +
    + ); +} + +/** The add/edit form for one server draft. */ +function ServerEditor({ + draft, + busy, + onChange, + onCancel, + onSubmit, +}: { + draft: LocalModelServerConfig; + busy: boolean; + onChange: (next: LocalModelServerConfig) => void; + onCancel: () => void; + onSubmit: () => void; +}) { + const errors: ModelServerErrors = validateModelServer(draft); + const valid = Object.keys(errors).length === 0; + const patch = (next: Partial) => + onChange({ ...draft, ...next }); + + return ( +
    + + {draft.name.trim().length > 0 ? draft.name : "New server"} + + + + +
    + + + +
    + + + + + + + + + +
    + + + +
    + +
    + + +
    +
    + ); +} diff --git a/frontend/src/features/model-servers/index.ts b/frontend/src/features/model-servers/index.ts new file mode 100644 index 0000000..f308348 --- /dev/null +++ b/frontend/src/features/model-servers/index.ts @@ -0,0 +1,19 @@ +/** + * Local model servers feature — public surface (F35.2). + */ + +export { useModelServers } from "./useModelServers"; +export type { ModelServersViewModel } from "./useModelServers"; +export { ModelServersPanel } from "./ModelServersPanel"; +export type { ModelServersPanelProps } from "./ModelServersPanel"; +export { ModelServerSelect } from "./ModelServerSelect"; +export type { ModelServerSelectProps } from "./ModelServerSelect"; +export { + emptyModelServer, + newModelServerId, + validateModelServer, + isModelServerValid, + isAbsolutePath, + parseArgs, + type ModelServerErrors, +} from "./modelServer"; diff --git a/frontend/src/features/model-servers/modelServer.ts b/frontend/src/features/model-servers/modelServer.ts new file mode 100644 index 0000000..75614c6 --- /dev/null +++ b/frontend/src/features/model-servers/modelServer.ts @@ -0,0 +1,97 @@ +/** + * Pure, framework-free helpers for the local model server feature (F35). Draft + * validation mirrors the backend invariants (`ModelServerEndpoint::new`, + * `LocalModelRef::new`, `LocalModelServerConfig::new`) so the form can surface + * errors before any `invoke`. No React, no Tauri here. + */ + +import type { LocalModelServerConfig } from "@/domain"; + +/** A field-keyed validation error map (empty ⇒ valid). */ +export type ModelServerErrors = Partial< + Record< + "name" | "baseURL" | "port" | "servedModelName" | "modelPath", + string + > +>; + +/** Whether a string is a valid `http://`/`https://` URL (mirror of backend). */ +function isValidHttpUrl(v: string): boolean { + if (v.trim().length === 0) return false; + return v.startsWith("http://") || v.startsWith("https://"); +} + +/** + * Whether a path looks absolute (mirror of the backend `is_absolute_path`: + * POSIX `/…` or a Windows drive/UNC). Blank is handled by the caller. + */ +export function isAbsolutePath(path: string): boolean { + if (path.startsWith("/") || path.startsWith("\\")) return true; + // Windows drive `C:/` or `C:\`. + return /^[a-zA-Z]:[/\\]/.test(path); +} + +/** + * Validates a {@link LocalModelServerConfig} draft the way the backend would. + * Returns an empty object when the draft is valid. + */ +export function validateModelServer(c: LocalModelServerConfig): ModelServerErrors { + const errors: ModelServerErrors = {}; + if (c.name.trim().length === 0) errors.name = "Name is required."; + if (!isValidHttpUrl(c.baseURL)) { + errors.baseURL = "Base URL must start with http:// or https://."; + } + if (!Number.isInteger(c.port) || c.port < 1 || c.port > 65_535) { + errors.port = "Port must be an integer between 1 and 65535."; + } + if (c.servedModelName.trim().length === 0) { + errors.servedModelName = "Served model name is required."; + } + const modelPath = c.modelPath?.trim() ?? ""; + if (modelPath.length > 0 && !isAbsolutePath(modelPath)) { + errors.modelPath = "Model path must be absolute."; + } + // The backend rejects auto-start without an absolute model path. + if (c.autoStart && modelPath.length === 0) { + errors.modelPath = "Auto-start requires an absolute model path."; + } + return errors; +} + +/** Whether a draft is valid (no errors). */ +export function isModelServerValid(c: LocalModelServerConfig): boolean { + return Object.keys(validateModelServer(c)).length === 0; +} + +/** Generates a UUID for a client-created server (crypto when available). */ +export function newModelServerId(): string { + const c = globalThis.crypto as Crypto | undefined; + if (c && typeof c.randomUUID === "function") return c.randomUUID(); + return `srv-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * A fresh model-server draft (id minted client-side) pre-filled with a sensible + * local `llama-server` endpoint, so the form starts valid apart from the name. + */ +export function emptyModelServer(): LocalModelServerConfig { + return { + id: newModelServerId(), + kind: "llamaCpp", + name: "", + baseURL: "http://localhost:8080/v1", + port: 8080, + servedModelName: "qwen3-coder-30b", + args: [], + autoStart: false, + stopPolicy: "stopOnAppExit", + }; +} + +/** Parses a whitespace-separated args string into a trimmed, non-empty list. */ +export function parseArgs(raw: string): string[] { + return raw + .split(/\s+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} diff --git a/frontend/src/features/model-servers/modelServers.test.tsx b/frontend/src/features/model-servers/modelServers.test.tsx new file mode 100644 index 0000000..1da2adc --- /dev/null +++ b/frontend/src/features/model-servers/modelServers.test.tsx @@ -0,0 +1,251 @@ +/** + * F35.2 — local model server registry: the pure validation helpers, the + * {@link useModelServers} hook behind the real DIProvider + mock gateway, the + * {@link ModelServersPanel} CRUD UI (incl. the `model_server_in_use` rejection), + * and the {@link ModelServerSelect} binding dropdown. + */ + +import { describe, it, expect } from "vitest"; +import { act, render, renderHook, screen, fireEvent, waitFor } from "@testing-library/react"; + +import type { Gateways } from "@/ports"; +import type { LocalModelServerConfig } from "@/domain"; +import { MockModelServerGateway } from "@/adapters/mock"; +import { DIProvider } from "@/app/di"; +import { useModelServers } from "./useModelServers"; +import { ModelServersPanel } from "./ModelServersPanel"; +import { ModelServerSelect } from "./ModelServerSelect"; +import { + emptyModelServer, + isAbsolutePath, + validateModelServer, +} from "./modelServer"; + +const SERVER: LocalModelServerConfig = { + id: "550e8400-e29b-41d4-a716-446655440000", + kind: "llamaCpp", + name: "Local A", + baseURL: "http://localhost:8080/v1", + port: 8080, + servedModelName: "qwen3-coder-30b", + args: [], + autoStart: false, + stopPolicy: "stopOnAppExit", +}; + +function setup(modelServer = new MockModelServerGateway()) { + const gateways = { modelServer } as unknown as Gateways; + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const view = renderHook(() => useModelServers(), { wrapper }); + return { modelServer, view }; +} + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +describe("modelServer helpers (F35.2)", () => { + it("emptyModelServer starts valid apart from the name", () => { + const draft = emptyModelServer(); + expect(validateModelServer(draft)).toEqual({ name: "Name is required." }); + }); + + it("flags a non-http base URL and an out-of-range port", () => { + const errors = validateModelServer({ + ...SERVER, + baseURL: "ftp://oops", + port: 70_000, + }); + expect(errors.baseURL).toBeTruthy(); + expect(errors.port).toBeTruthy(); + }); + + it("requires an absolute model path, and requires it when auto-start is on", () => { + expect(validateModelServer({ ...SERVER, modelPath: "relative/x.gguf" }).modelPath).toBeTruthy(); + expect(validateModelServer({ ...SERVER, autoStart: true }).modelPath).toMatch( + /auto-start/i, + ); + expect( + validateModelServer({ ...SERVER, autoStart: true, modelPath: "/m/x.gguf" }).modelPath, + ).toBeUndefined(); + }); + + it("recognises absolute POSIX and Windows paths", () => { + expect(isAbsolutePath("/models/x.gguf")).toBe(true); + expect(isAbsolutePath("C:/models/x.gguf")).toBe(true); + expect(isAbsolutePath("models/x.gguf")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +describe("useModelServers (F35.2)", () => { + it("saves and lists servers", async () => { + const { view } = setup(); + await act(async () => { + await view.result.current.save(SERVER); + }); + expect(view.result.current.servers).toHaveLength(1); + expect(view.result.current.servers[0].name).toBe("Local A"); + }); + + it("removes a server", async () => { + const { view } = setup(); + await act(async () => { + await view.result.current.save(SERVER); + }); + await act(async () => { + const ok = await view.result.current.remove(SERVER.id); + expect(ok).toBe(true); + }); + expect(view.result.current.servers).toHaveLength(0); + }); + + it("surfaces model_server_in_use as an actionable error and keeps the server", async () => { + const modelServer = new MockModelServerGateway(); + const { view } = setup(modelServer); + await act(async () => { + await view.result.current.save(SERVER); + }); + modelServer.markInUse(SERVER.id); + await act(async () => { + const ok = await view.result.current.remove(SERVER.id); + expect(ok).toBe(false); + }); + expect(view.result.current.error).toMatch(/encore utilisé par un profil/i); + expect(view.result.current.servers).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// Panel +// --------------------------------------------------------------------------- + +function renderPanel(modelServer = new MockModelServerGateway()) { + const gateways = { modelServer } as unknown as Gateways; + function Harness() { + const vm = useModelServers(); + return ; + } + return { + modelServer, + ...render( + + + , + ), + }; +} + +describe("ModelServersPanel (F35.2)", () => { + it("declares a new server end-to-end", async () => { + const { modelServer } = renderPanel(); + fireEvent.click(screen.getByLabelText("add model server")); + + fireEvent.change(screen.getByLabelText("server name"), { + target: { value: "Local A" }, + }); + // A valid draft (pre-filled endpoint + servedModelName) enables Save once + // the initial passive load has settled. + const save = screen.getByLabelText("save model server") as HTMLButtonElement; + await waitFor(() => expect(save.disabled).toBe(false)); + fireEvent.click(save); + + await waitFor(async () => { + const saved = await modelServer.listModelServers(); + expect(saved.map((s) => s.name)).toEqual(["Local A"]); + }); + // The editor closed and the server shows in the list. + expect(screen.getByLabelText("edit Local A")).toBeTruthy(); + }); + + it("blocks Save while the draft is invalid (blank name)", () => { + renderPanel(); + fireEvent.click(screen.getByLabelText("add model server")); + // Invalid draft (blank name) keeps Save disabled regardless of busy. + expect( + (screen.getByLabelText("save model server") as HTMLButtonElement).disabled, + ).toBe(true); + }); + + it("shows an actionable banner when deleting a referenced server", async () => { + const modelServer = new MockModelServerGateway(); + await modelServer.saveModelServer(SERVER); + modelServer.markInUse(SERVER.id); + renderPanel(modelServer); + + await screen.findByLabelText("edit Local A"); + fireEvent.click(screen.getByLabelText("delete Local A")); + + expect((await screen.findByRole("alert")).textContent).toMatch( + /encore utilisé par un profil/i, + ); + // The server is still listed (delete was refused). + expect(screen.getByLabelText("edit Local A")).toBeTruthy(); + }); + + it("edits an existing server's served model name", async () => { + const modelServer = new MockModelServerGateway(); + await modelServer.saveModelServer(SERVER); + renderPanel(modelServer); + + fireEvent.click(await screen.findByLabelText("edit Local A")); + fireEvent.change(screen.getByLabelText("server served model name"), { + target: { value: "qwen3-coder-7b" }, + }); + fireEvent.click(screen.getByLabelText("save model server")); + + await waitFor(async () => { + const saved = await modelServer.listModelServers(); + expect(saved[0].servedModelName).toBe("qwen3-coder-7b"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Select +// --------------------------------------------------------------------------- + +describe("ModelServerSelect (F35.2)", () => { + it("offers a 'none' option plus each declared server, and emits the id", () => { + let picked: string | undefined = "sentinel"; + render( + (picked = id)} + />, + ); + const select = screen.getByLabelText("local model server") as HTMLSelectElement; + // "None" is selected when value is undefined. + expect(select.value).toBe(""); + fireEvent.change(select, { target: { value: SERVER.id } }); + expect(picked).toBe(SERVER.id); + }); + + it("emits undefined when 'none' is chosen", () => { + let picked: string | undefined = SERVER.id; + render( + (picked = id)} + />, + ); + fireEvent.change(screen.getByLabelText("local model server"), { + target: { value: "" }, + }); + expect(picked).toBeUndefined(); + }); + + it("keeps a removed/unknown bound id visible rather than dropping it", () => { + render( + {}} />, + ); + expect(screen.getByText(/ghost-id \(unknown \/ removed\)/)).toBeTruthy(); + }); +}); diff --git a/frontend/src/features/model-servers/useModelServers.ts b/frontend/src/features/model-servers/useModelServers.ts new file mode 100644 index 0000000..7f4f561 --- /dev/null +++ b/frontend/src/features/model-servers/useModelServers.ts @@ -0,0 +1,126 @@ +/** + * `useModelServers` — view-model for the local model server registry (F35.2). + * Owns the declared-servers list and the CRUD actions, consuming the + * {@link ModelServerGateway} exclusively (never `invoke()`), so the UI is + * testable with a mock. + * + * Delete surfaces the backend `model_server_in_use` rejection as a dedicated, + * human-readable message (a server still referenced by a profile cannot be + * removed). + */ + +import { useCallback, useEffect, useState } from "react"; + +import type { GatewayError, LocalModelServerConfig } from "@/domain"; +import { useGateways } from "@/app/di"; + +/** What the model-server UI needs from this hook. */ +export interface ModelServersViewModel { + /** The declared local model servers. */ + servers: LocalModelServerConfig[]; + /** Last error message, or `null`. */ + error: string | null; + /** Whether a request is in flight. */ + busy: boolean; + /** Reloads the server list. */ + reload: () => Promise; + /** Creates or updates a server; returns the persisted config (or `null` on error). */ + save: (config: LocalModelServerConfig) => Promise; + /** Deletes a server by id; returns `true` on success. */ + remove: (serverId: string) => Promise; + /** Clears the current error banner. */ + clearError: () => void; +} + +function describe(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as GatewayError).message); + } + return String(e); +} + +function codeOf(e: unknown): string | undefined { + if (e && typeof e === "object" && "code" in e) { + return String((e as GatewayError).code); + } + return undefined; +} + +export function useModelServers(): ModelServersViewModel { + const { modelServer } = useGateways(); + const [servers, setServers] = useState([]); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const reload = useCallback(async () => { + setBusy(true); + setError(null); + try { + setServers(await modelServer.listModelServers()); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + }, [modelServer]); + + useEffect(() => { + void reload(); + }, [reload]); + + const save = useCallback( + async (config: LocalModelServerConfig) => { + setBusy(true); + setError(null); + try { + const saved = await modelServer.saveModelServer(config); + setServers((prev) => { + const i = prev.findIndex((s) => s.id === saved.id); + if (i >= 0) { + const next = prev.slice(); + next[i] = saved; + return next; + } + return [...prev, saved]; + }); + return saved; + } catch (e) { + setError(describe(e)); + return null; + } finally { + setBusy(false); + } + }, + [modelServer], + ); + + const remove = useCallback( + async (serverId: string) => { + setBusy(true); + setError(null); + try { + await modelServer.deleteModelServer(serverId); + setServers((prev) => prev.filter((s) => s.id !== serverId)); + return true; + } catch (e) { + // A referenced server cannot be deleted — turn the stable backend code + // into an actionable message rather than a raw error string. + if (codeOf(e) === "model_server_in_use") { + setError( + "Ce serveur est encore utilisé par un profil : détache-le d'abord (choisis « aucun » ou un autre serveur) avant de le supprimer.", + ); + } else { + setError(describe(e)); + } + return false; + } finally { + setBusy(false); + } + }, + [modelServer], + ); + + const clearError = useCallback(() => setError(null), []); + + return { servers, error, busy, reload, save, remove, clearError }; +} diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index a319482..7cf7464 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -25,10 +25,12 @@ import type { LayoutList, LayoutOperation, LayoutTree, + LocalModelServerConfig, Memory, MemoryIndexEntry, MemoryLink, MemoryType, + OpenCodeConfig, EffectivePermissions, PermissionSet, PageDirection, @@ -624,6 +626,45 @@ export interface ProfileGateway { deleteProfile(profileId: string): Promise; /** Persists the batch of chosen profiles, closing the first run. */ configureProfiles(profiles: AgentProfile[]): Promise; + /** + * Mints a new OpenCode profile from the canonical `opencode-llamacpp` seed + * (F36 — several local OpenCode profiles per project, identity = id). `name` + * overrides the seed display name; `opencode` overrides the seed config (used + * by "Duplicate" to carry an existing row's endpoint). Returns the new, + * freshly-id'd profile — it is not persisted until the batch is saved. + */ + cloneOpenCodeProfileFromSeed( + input?: CloneOpenCodeProfileFromSeedInput, + ): Promise; +} + +/** Input for {@link ProfileGateway.cloneOpenCodeProfileFromSeed}. */ +export interface CloneOpenCodeProfileFromSeedInput { + /** Optional display name for the new profile. */ + name?: string; + /** Optional OpenCode config override; when omitted, the seed config is copied. */ + opencode?: OpenCodeConfig; +} + +/** + * Local model servers (F35). CRUD over the global registry of declared + * `llama.cpp` servers an OpenCode profile can bind to via + * `OpenCodeConfig.localModelServerId`. Mirrors the three backend commands + * (`list_model_servers`, `save_model_server`, `delete_model_server`). + */ +export interface ModelServerGateway { + /** Lists the declared local model servers. */ + listModelServers(): Promise; + /** + * Upserts a server config (create when `id` is a fresh client-minted UUID, + * update otherwise); returns the persisted config. + */ + saveModelServer(config: LocalModelServerConfig): Promise; + /** + * Deletes a server by id. Rejects with a `GatewayError` whose `code` is + * `model_server_in_use` when a profile still references it. + */ + deleteModelServer(serverId: string): Promise; } /** Input for {@link EmbedderGateway.saveEmbedderProfile}. */ @@ -933,6 +974,7 @@ export interface Gateways { git: GitGateway; remote: RemoteGateway; profile: ProfileGateway; + modelServer: ModelServerGateway; template: TemplateGateway; skill: SkillGateway; memory: MemoryGateway;