From fe7ed0aa2026ea0961405cd4435065b2c0fd95ae Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 13 Jul 2026 23:14:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(model-server):=20afficher=20le=20t=C3=A9l?= =?UTF-8?q?=C3=A9chargement=20du=20mod=C3=A8le=20llamacpp=20au=20d=C3=A9ma?= =?UTF-8?q?rrage=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute un handle du téléchargement des modèles lors du démarrage de llamacpp : le domaine et l'application émettent la progression de téléchargement du modèle, relayée en événement côté app-tauri, et l'UI l'affiche via un badge de lancement et un overlay de cellule pendant que le serveur de modèle démarre. Backend (B1) : progression de téléchargement dans domain/application, relais d'événement app-tauri, couverture de tests. Frontend (F1) : modelServerLaunch, badge et overlay LayoutGrid, tests. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/events.rs | 48 +++ crates/application/src/model_server.rs | 126 +++++++- crates/application/tests/model_server.rs | 222 +++++++++++++- crates/domain/src/events.rs | 2 +- crates/domain/src/model_server.rs | 13 +- frontend/src/domain/index.ts | 12 + frontend/src/features/agents/AgentsPanel.tsx | 11 +- .../agents/ModelServerLaunchBadge.tsx | 2 + frontend/src/features/agents/index.ts | 6 + .../src/features/agents/modelServerLaunch.ts | 141 +++++++++ .../LayoutGrid.modelServerOverlay.test.tsx | 279 ++++++++++++++++++ frontend/src/features/layout/LayoutGrid.tsx | 58 +++- 12 files changed, 891 insertions(+), 29 deletions(-) create mode 100644 frontend/src/features/agents/modelServerLaunch.ts create mode 100644 frontend/src/features/layout/LayoutGrid.modelServerOverlay.test.tsx diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index 6a33d92..caf0478 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -36,6 +36,18 @@ pub enum ModelServerStatusDto { Probing, /// IdeA is starting the process. Starting, + /// Server is downloading/preparing a remote model. + #[serde(rename_all = "camelCase")] + Downloading { + /// Downloaded bytes when known. + downloaded_bytes: Option, + /// Total bytes when known. + total_bytes: Option, + /// Completion percentage when known. + percent: Option, + /// Remote model source. + source: Option, + }, /// Server is ready. #[serde(rename_all = "camelCase")] Ready { @@ -58,6 +70,17 @@ impl From<&ModelServerLifecycleStatus> for ModelServerStatusDto { ModelServerLifecycleStatus::NotConfigured => Self::NotConfigured, ModelServerLifecycleStatus::Probing => Self::Probing, ModelServerLifecycleStatus::Starting => Self::Starting, + ModelServerLifecycleStatus::Downloading { + downloaded_bytes, + total_bytes, + percent, + source, + } => Self::Downloading { + downloaded_bytes: *downloaded_bytes, + total_bytes: *total_bytes, + percent: *percent, + source: source.clone(), + }, ModelServerLifecycleStatus::Ready { reused } => Self::Ready { reused: *reused }, ModelServerLifecycleStatus::Failed { code, message } => Self::Failed { code: code.clone(), @@ -1168,6 +1191,31 @@ mod tests { assert_eq!(json["status"]["reused"], true); } + #[test] + fn model_server_status_changed_relays_downloading_to_dto_and_wire() { + let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged { + server_id: server(37), + status: ModelServerLifecycleStatus::Downloading { + downloaded_bytes: None, + total_bytes: None, + percent: None, + source: Some("Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF".to_owned()), + }, + }); + + let json = serde_json::to_value(&dto).expect("serialisable"); + assert_eq!(json["type"], "modelServerStatusChanged"); + assert_eq!(json["serverId"], server(37).to_string()); + assert_eq!(json["status"]["state"], "downloading"); + assert_eq!(json["status"]["downloadedBytes"], serde_json::Value::Null); + assert_eq!(json["status"]["totalBytes"], serde_json::Value::Null); + assert_eq!(json["status"]["percent"], serde_json::Value::Null); + assert_eq!( + json["status"]["source"], + "Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF" + ); + } + #[test] fn agent_launch_failed_relays_model_server_cause() { let dto = DomainEventDto::from(&DomainEvent::AgentLaunchFailed { diff --git a/crates/application/src/model_server.rs b/crates/application/src/model_server.rs index f9abbf4..8b8fe04 100644 --- a/crates/application/src/model_server.rs +++ b/crates/application/src/model_server.rs @@ -15,6 +15,7 @@ use domain::ports::{ }; use domain::{LocalModelServerId, StopPolicy}; use tokio::sync::{Mutex as AsyncMutex, Notify}; +use tokio::time::Instant; use crate::error::AppError; @@ -161,6 +162,13 @@ impl Default for ReadinessPolicy { } } +/// Default upper bound for Hugging Face model download/preparation. +/// +/// llama.cpp may download multi-GB models before binding the OpenAI-compatible +/// endpoint. The short readiness window remains for local files; HF sources use +/// this separate deadline while the process is still alive. +pub const DEFAULT_HF_DOWNLOAD_DEADLINE: Duration = Duration::from_secs(30 * 60); + #[derive(Debug, Clone)] struct ActiveServer { handle: ManagedProcessHandle, @@ -208,6 +216,7 @@ pub struct EnsureLocalModelServer { active: Mutex>, inflight: AsyncMutex>>, readiness: ReadinessPolicy, + hf_download_deadline: Duration, } impl EnsureLocalModelServer { @@ -232,6 +241,7 @@ impl EnsureLocalModelServer { active: Mutex::new(HashMap::new()), inflight: AsyncMutex::new(HashMap::new()), readiness: ReadinessPolicy::default(), + hf_download_deadline: DEFAULT_HF_DOWNLOAD_DEADLINE, } } @@ -242,6 +252,13 @@ impl EnsureLocalModelServer { self } + /// Overrides the long Hugging Face download/preparation deadline. + #[must_use] + pub fn with_hf_download_deadline(mut self, deadline: Duration) -> Self { + self.hf_download_deadline = deadline; + self + } + /// Ensures the server is reachable. /// /// # Errors @@ -333,10 +350,21 @@ impl EnsureLocalModelServer { }, ); + let hf_source = hf_source(&config); + self.wait_for_started_server(&config, &handle, hf_source) + .await + } + + async fn wait_for_started_server( + &self, + config: &LocalModelServerConfig, + handle: &ManagedProcessHandle, + hf_source: Option, + ) -> Result { for attempt in 0..self.readiness.attempts { match self.probe.probe(&config.endpoint).await { Err(err) => { - self.stop_started_server(config.id, &handle).await; + self.stop_started_server(config.id, handle).await; return self.fail(config.id, err); } Ok(ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted) => { @@ -345,7 +373,7 @@ impl EnsureLocalModelServer { ModelServerLifecycleStatus::Ready { reused: false }, ); return Ok(EnsureLocalModelServerOutput { - ready: ready(&config, ModelServerStatus::ReadyStarted), + ready: ready(config, ModelServerStatus::ReadyStarted), }); } Ok(ModelServerStatus::Unreachable) => { @@ -356,9 +384,83 @@ impl EnsureLocalModelServer { } } - let err = ModelServerError::Timeout; - self.stop_started_server(config.id, &handle).await; - self.fail(config.id, err) + let Some(source) = hf_source else { + let err = ModelServerError::Timeout; + self.stop_started_server(config.id, handle).await; + return self.fail(config.id, err); + }; + + match self.process.status(handle).await { + Ok(ProcessStatus::Running) => { + self.publish( + config.id, + ModelServerLifecycleStatus::Downloading { + downloaded_bytes: None, + total_bytes: None, + percent: None, + source: Some(source.clone()), + }, + ); + } + Ok(ProcessStatus::Exited { code }) => { + self.active.lock().unwrap().remove(&config.id); + return self.fail(config.id, premature_exit_error(code)); + } + Ok(ProcessStatus::Unknown) => { + self.active.lock().unwrap().remove(&config.id); + return self.fail( + config.id, + ModelServerError::Process("process status unknown".to_owned()), + ); + } + Err(err) => return self.fail(config.id, err), + } + + let deadline = Instant::now() + self.hf_download_deadline; + loop { + if Instant::now() >= deadline { + let err = ModelServerError::Timeout; + self.stop_started_server(config.id, handle).await; + return self.fail(config.id, err); + } + match self.probe.probe(&config.endpoint).await { + Err(err) => { + self.stop_started_server(config.id, handle).await; + return self.fail(config.id, err); + } + Ok(ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted) => { + self.publish( + config.id, + ModelServerLifecycleStatus::Ready { reused: false }, + ); + return Ok(EnsureLocalModelServerOutput { + ready: ready(config, ModelServerStatus::ReadyStarted), + }); + } + Ok(ModelServerStatus::Unreachable) => {} + } + match self.process.status(handle).await { + Ok(ProcessStatus::Running) => { + if !self.readiness.backoff.is_zero() { + tokio::time::sleep(self.readiness.backoff).await; + } else { + tokio::task::yield_now().await; + } + } + Ok(ProcessStatus::Exited { code }) => { + self.active.lock().unwrap().remove(&config.id); + return self.fail(config.id, premature_exit_error(code)); + } + Ok(ProcessStatus::Unknown) => { + self.active.lock().unwrap().remove(&config.id); + return self.fail( + config.id, + ModelServerError::Process("process status unknown".to_owned()), + ); + } + Err(err) => return self.fail(config.id, err), + } + } } /// Stops active servers whose policy is [`StopPolicy::StopOnAppExit`]. @@ -497,6 +599,20 @@ fn ready(config: &LocalModelServerConfig, status: ModelServerStatus) -> ModelSer } } +fn hf_source(config: &LocalModelServerConfig) -> Option { + match config.model.source.as_ref()? { + ModelSource::HuggingFace { repo } => Some(repo.as_str().to_owned()), + ModelSource::LocalPath { .. } => None, + } +} + +fn premature_exit_error(code: Option) -> ModelServerError { + ModelServerError::Process(match code { + Some(code) => format!("model server exited before readiness with code {code}"), + None => "model server exited before readiness".to_owned(), + }) +} + /// Stable model-server error code for event/DTO mapping. #[must_use] pub fn model_server_error_code(err: &ModelServerError) -> &'static str { diff --git a/crates/application/tests/model_server.rs b/crates/application/tests/model_server.rs index 0abb431..535aeff 100644 --- a/crates/application/tests/model_server.rs +++ b/crates/application/tests/model_server.rs @@ -12,8 +12,9 @@ use application::{ }; use domain::events::DomainEvent; use domain::model_server::{ - ExecutablePath, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, - ModelPath, ModelServerEndpoint, ModelServerStatus, ModelSource, StopPolicy, + ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, + LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelServerLifecycleStatus, + ModelServerStatus, ModelSource, StopPolicy, }; use domain::ports::{ DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle, @@ -56,6 +57,30 @@ fn config( .unwrap() } +fn hf_config(id: LocalModelServerId, port: u16, repo: &str) -> LocalModelServerConfig { + LocalModelServerConfig::new( + id, + LocalModelServerKind::LlamaCpp, + "llama.cpp", + ModelServerEndpoint::new(format!("http://localhost:{port}"), port).unwrap(), + LocalModelRef::new( + "qwen", + "Qwen", + Some(ModelSource::HuggingFace { + repo: HfModelRef::new(repo).unwrap(), + }), + "qwen3-coder-30b", + ) + .unwrap(), + Some(ExecutablePath::new("llama-server").unwrap()), + LlamaCppOptions::default(), + Vec::new(), + true, + StopPolicy::StopOnAppExit, + ) + .unwrap() +} + #[derive(Default)] struct FakeRegistry(Mutex>); @@ -158,6 +183,7 @@ struct FakeProcess { spawns: Mutex>, kills: Mutex>, statuses: Mutex>, + status_sequence: Mutex>, spawn_delay: Duration, } @@ -187,6 +213,9 @@ impl ManagedProcess for FakeProcess { &self, handle: &ManagedProcessHandle, ) -> Result { + if let Some(status) = self.status_sequence.lock().unwrap().pop_front() { + return Ok(status); + } Ok(*self .statuses .lock() @@ -203,25 +232,35 @@ impl ModelServerRuntime for FakeRuntime { &self, config: &LocalModelServerConfig, ) -> Result { - let Some(ModelSource::LocalPath { path }) = config.model.source.as_ref() else { - return Err(ModelServerError::PathNotAccessible( - "model.source missing".to_owned(), - )); - }; + let mut args = Vec::new(); + match config + .model + .source + .as_ref() + .ok_or_else(|| ModelServerError::PathNotAccessible("model.source missing".to_owned()))? + { + ModelSource::LocalPath { path } => { + args.push("--model".to_owned()); + args.push(path.as_str().to_owned()); + } + ModelSource::HuggingFace { repo } => { + args.push("-hf".to_owned()); + args.push(repo.as_str().to_owned()); + } + } + args.extend([ + "--port".to_owned(), + config.endpoint.port.to_string(), + "--host".to_owned(), + config.options.host.clone(), + ]); Ok(ModelServerArgv { command: config .binary .as_ref() .map(|binary| binary.as_str().to_owned()) .unwrap_or_else(|| "llama-server".to_owned()), - args: vec![ - "--model".to_owned(), - path.as_str().to_owned(), - "--port".to_owned(), - config.endpoint.port.to_string(), - "--host".to_owned(), - config.options.host.clone(), - ], + args, }) } @@ -303,6 +342,7 @@ fn ensure( attempts: 2, backoff: Duration::ZERO, }) + .with_hf_download_deadline(Duration::from_secs(5)) } #[tokio::test] @@ -546,6 +586,158 @@ async fn readiness_timeout_kills_started_process() { assert_eq!(process.kills.lock().unwrap().as_slice(), ["h1"]); } +#[tokio::test] +async fn hf_unreachable_alive_publishes_downloading_without_short_timeout_kill() { + let registry = Arc::new(FakeRegistry::default()); + registry + .save(hf_config( + sid(11), + 8091, + "Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF", + )) + .await + .unwrap(); + let process = Arc::new(FakeProcess::default()); + let events = Arc::new(FakeEvents::default()); + let usecase = ensure( + Arc::clone(®istry), + Arc::new(FakeProbe::new(vec![ + ModelServerStatus::Unreachable, + ModelServerStatus::Unreachable, + ModelServerStatus::Unreachable, + ModelServerStatus::ReadyReused, + ])), + Arc::clone(&process), + Arc::new(FakeFs::default()), + Arc::clone(&events), + ); + + let out = usecase + .execute(EnsureLocalModelServerInput { server_id: sid(11) }) + .await + .unwrap(); + + assert_eq!(out.ready.status, ModelServerStatus::ReadyStarted); + assert!(process.kills.lock().unwrap().is_empty()); + assert!(events.0.lock().unwrap().iter().any(|event| { + matches!( + event, + DomainEvent::ModelServerStatusChanged { + server_id, + status: ModelServerLifecycleStatus::Downloading { + downloaded_bytes: None, + total_bytes: None, + percent: None, + source: Some(source), + }, + } if *server_id == sid(11) + && source == "Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF" + ) + })); +} + +#[tokio::test] +async fn hf_download_phase_reports_ready_when_probe_becomes_ok() { + let registry = Arc::new(FakeRegistry::default()); + registry + .save(hf_config( + sid(12), + 8092, + "Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF", + )) + .await + .unwrap(); + let process = Arc::new(FakeProcess::default()); + let events = Arc::new(FakeEvents::default()); + let usecase = ensure( + Arc::clone(®istry), + Arc::new(FakeProbe::new(vec![ + ModelServerStatus::Unreachable, + ModelServerStatus::Unreachable, + ModelServerStatus::Unreachable, + ModelServerStatus::ReadyStarted, + ])), + Arc::clone(&process), + Arc::new(FakeFs::default()), + Arc::clone(&events), + ); + + let out = usecase + .execute(EnsureLocalModelServerInput { server_id: sid(12) }) + .await + .unwrap(); + + assert_eq!(out.ready.status, ModelServerStatus::ReadyStarted); + let statuses: Vec = events + .0 + .lock() + .unwrap() + .iter() + .filter_map(|event| match event { + DomainEvent::ModelServerStatusChanged { status, .. } => Some(status.clone()), + _ => None, + }) + .collect(); + assert!(matches!( + statuses.as_slice(), + [ + ModelServerLifecycleStatus::Probing, + ModelServerLifecycleStatus::Starting, + ModelServerLifecycleStatus::Downloading { .. }, + ModelServerLifecycleStatus::Ready { reused: false }, + ] + )); +} + +#[tokio::test] +async fn hf_download_phase_fails_when_process_exits() { + let registry = Arc::new(FakeRegistry::default()); + registry + .save(hf_config( + sid(13), + 8093, + "Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF", + )) + .await + .unwrap(); + let process = Arc::new(FakeProcess::default()); + process + .status_sequence + .lock() + .unwrap() + .push_back(ProcessStatus::Exited { code: Some(42) }); + let events = Arc::new(FakeEvents::default()); + let usecase = ensure( + Arc::clone(®istry), + Arc::new(FakeProbe::new(vec![ + ModelServerStatus::Unreachable, + ModelServerStatus::Unreachable, + ModelServerStatus::Unreachable, + ])), + Arc::clone(&process), + Arc::new(FakeFs::default()), + Arc::clone(&events), + ); + + let err = usecase + .execute(EnsureLocalModelServerInput { server_id: sid(13) }) + .await + .unwrap_err(); + + assert!(err.to_string().contains("process")); + assert!(err.to_string().contains("42")); + assert!(process.kills.lock().unwrap().is_empty()); + assert!(events.0.lock().unwrap().iter().any(|event| { + matches!( + event, + DomainEvent::ModelServerStatusChanged { + status: ModelServerLifecycleStatus::Failed { code, .. }, + .. + } if code == "process" + ) + })); +} + #[tokio::test] async fn missing_registry_entry_is_model_server_not_configured() { let usecase = ensure( diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 36e61a9..107014e 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -33,7 +33,7 @@ pub enum OrchestrationSource { /// concern relayed to IPC by an infrastructure adapter, which owns the wire /// format. `PtyOutput` in particular is usually short-circuited to a Tauri /// channel rather than serialised here. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub enum DomainEvent { /// A project was created. ProjectCreated { diff --git a/crates/domain/src/model_server.rs b/crates/domain/src/model_server.rs index f749eb1..ef34c61 100644 --- a/crates/domain/src/model_server.rs +++ b/crates/domain/src/model_server.rs @@ -494,7 +494,7 @@ pub struct ModelServerReady { } /// Lifecycle status emitted for presentation. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub enum ModelServerLifecycleStatus { /// No local model server is configured for the profile. NotConfigured, @@ -502,6 +502,17 @@ pub enum ModelServerLifecycleStatus { Probing, /// IdeA is starting the server. Starting, + /// The server process is alive but still downloading/preparing a remote model. + Downloading { + /// Downloaded bytes when known. + downloaded_bytes: Option, + /// Total bytes when known. + total_bytes: Option, + /// Completion percentage when known. + percent: Option, + /// Remote model source, for example a Hugging Face repository id. + source: Option, + }, /// The server is ready. Ready { /// `true` when an already-running server was reused. diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 6e9a902..11cd490 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -27,6 +27,18 @@ export type ModelServerStatus = | { state: "notConfigured" } | { state: "probing" } | { state: "starting" } + | { + /** + * The server is downloading/preparing a remote model. Progress fields are + * optional (all `null` in the F1 MVP; the bar/% display is the F2 stretch): + * the UI only needs `downloading` to raise the cell overlay. + */ + state: "downloading"; + downloadedBytes: number | null; + totalBytes: number | null; + percent: number | null; + source: string | null; + } | { state: "ready"; reused: boolean } | { state: "failed"; code: string; message: string }; diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 6eb865e..2a9440c 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -22,6 +22,7 @@ import { AnnouncementsPreview } from "@/features/announcements"; import { useDrift } from "@/features/templates/useDrift"; import { useGateways } from "@/app/di"; import { useAgents } from "./useAgents"; +import { correlateModelServerStatus } from "./modelServerLaunch"; import { AgentLimitBadge } from "./AgentLimitBadge"; import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge"; @@ -335,11 +336,11 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { // 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 modelServerStatus = correlateModelServerStatus( + a, + vm.profiles, + vm.modelServerStatusByServer, + ); const launchFailure = vm.launchFailureByAgent[a.id]; return (
  • | undefined, + profiles: AgentProfile[], + statusByServer: Record, +): ModelServerStatus | undefined { + if (!agent) return undefined; + const serverId = profiles.find((p) => p.id === agent.profileId)?.opencode + ?.localModelServerId; + return serverId ? statusByServer[serverId] : undefined; +} + +/** + * The full-cell overlay text for a model server that is still preparing, or + * `null` when no overlay should show. Only the "not yet usable" states raise a + * veil — `ready`/`failed`/`notConfigured` (and an absent status) return `null`: + * - `downloading` → "Téléchargement du modèle…", + * - `starting`/`probing` → "Chargement du serveur…". + * + * F1 MVP ignores the progress fields (bytes/percent) — the bar/% is the F2 + * stretch. This is the single predicate that decides whether the launch overlay + * covers a cell. + */ +export function modelServerOverlayText( + status: ModelServerStatus | undefined, +): string | null { + if (!status) return null; + switch (status.state) { + case "downloading": + return "Téléchargement du modèle…"; + case "starting": + case "probing": + return "Chargement du serveur…"; + default: + return null; + } +} + +/** What {@link useModelServerLaunchState} exposes to a consumer. */ +export interface ModelServerLaunchState { + /** + * The correlated model-server status for a pinned agent (or `undefined` when + * it has no bound server / no status yet). Stable across renders unless the + * profiles or the per-server status map change. + */ + statusForAgent: ( + agent: Pick | undefined, + ) => ModelServerStatus | undefined; +} + +/** + * Self-contained correlation source for consumers that do NOT already hold the + * `useAgents` view-model (e.g. `LeafView`). Loads the profiles once and folds + * every `modelServerStatusChanged` event into a per-server status map, exactly + * like `useAgents` does — reusing {@link correlateModelServerStatus} for the + * lookup. A profile change (which may rebind the server) re-pulls the profiles. + * + * `profile`/`system` may be absent in unit tests that inject a partial gateway + * set; both are guarded, in which case the status is simply never found. + */ +export function useModelServerLaunchState( + _projectId: string, +): ModelServerLaunchState { + const { profile, system } = useGateways(); + const [profiles, setProfiles] = useState([]); + const [statusByServer, setStatusByServer] = useState< + Record + >({}); + + // Load (and reload) the profiles: the `profileId → localModelServerId` + // binding lives on the profile, so we need the current list to correlate. + const reloadProfiles = useCallback(() => { + if (!profile) return; + profile + .listProfiles() + .then(setProfiles) + .catch(() => { + /* ignore — no binding resolvable, overlay stays off */ + }); + }, [profile]); + + useEffect(() => { + reloadProfiles(); + }, [reloadProfiles]); + + // Fold the model-server lifecycle stream, keyed by server id — the same + // reduction `useAgents` performs. A profile change may rebind the server, so + // re-pull the profiles too. + useEffect(() => { + if (!system) return; + let unsubscribe: (() => void) | undefined; + let cancelled = false; + void system + .onDomainEvent((event) => { + if (event.type === "modelServerStatusChanged") { + setStatusByServer((prev) => ({ + ...prev, + [event.serverId]: event.status, + })); + } else if (event.type === "agentProfileChanged") { + reloadProfiles(); + } + }) + .then((un) => { + if (cancelled) un(); + else unsubscribe = un; + }); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, [system, reloadProfiles]); + + const statusForAgent = useCallback( + (agent: Pick | undefined) => + correlateModelServerStatus(agent, profiles, statusByServer), + [profiles, statusByServer], + ); + + return { statusForAgent }; +} diff --git a/frontend/src/features/layout/LayoutGrid.modelServerOverlay.test.tsx b/frontend/src/features/layout/LayoutGrid.modelServerOverlay.test.tsx new file mode 100644 index 0000000..beb0e23 --- /dev/null +++ b/frontend/src/features/layout/LayoutGrid.modelServerOverlay.test.tsx @@ -0,0 +1,279 @@ +/** + * Ticket #54 F1 — model-server download overlay on agent cells. + * + * When a pinned agent's OpenCode profile binds a local model server and that + * server emits `downloading` / `starting` / `probing` (via the existing + * `modelServerStatusChanged` stream), a full-cell overlay covers the cell — so + * the boot no longer looks like a hung/timed-out empty terminal. The overlay + * retracts at `ready`/`failed`. + * + * Correlation is `agentId → profileId → opencode.localModelServerId → serverId`: + * two cells whose agents share the same bound server are BOTH veiled by a single + * event on that server. xterm is stubbed so the terminal mounts under jsdom. + */ +import { beforeEach, describe, it, expect, vi } from "vitest"; +import { render, screen, waitFor, act } from "@testing-library/react"; + +vi.mock("@xterm/xterm", () => ({ + Terminal: class { + loadAddon() {} + open() {} + onData() { + return { dispose() {} }; + } + onResize() { + return { dispose() {} }; + } + write() {} + dispose() {} + get cols() { + return 80; + } + get rows() { + return 24; + } + }, +})); +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: class { + fit() {} + }, +})); +vi.mock("@xterm/xterm/css/xterm.css", () => ({})); + +if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +} + +import type { AgentProfile, ModelServerStatus } from "@/domain"; +import type { Gateways } from "@/ports"; +import { + MockAgentGateway, + MockInputGateway, + MockLayoutGateway, + MockProfileGateway, + MockSystemGateway, + MockTerminalGateway, +} from "@/adapters/mock"; +import { DIProvider } from "@/app/di"; +import { leaves } from "./layout"; +import { LayoutGrid } from "./LayoutGrid"; + +const SERVER_ID = "srv-local-1"; + +/** A profile that binds the given local model server through OpenCode. */ +function localModelProfile(id: string): AgentProfile { + return { + id, + name: "OpenCode + llama.cpp", + command: "opencode", + args: [], + contextInjection: { strategy: "conventionFile", target: "AGENTS.md" }, + detect: null, + cwdTemplate: "{projectRoot}", + structuredAdapter: "openCode", + opencode: { + baseURL: "http://localhost:8080/v1", + apiKey: "sk-no-key", + model: "qwen3-coder-30b", + localModelServerId: SERVER_ID, + }, + }; +} + +function makeGateways( + agentGateway: MockAgentGateway, + profileGateway: MockProfileGateway, + systemGateway: MockSystemGateway, +): Gateways { + return { + layout: new MockLayoutGateway(), + agent: agentGateway, + profile: profileGateway, + terminal: new MockTerminalGateway(), + system: systemGateway, + input: new MockInputGateway(), + } as unknown as Gateways; +} + +function renderGrid(gateways: Gateways) { + return render( + + + , + ); +} + +async function emit( + systemGateway: MockSystemGateway, + status: ModelServerStatus, +): Promise { + await act(async () => { + systemGateway.emit({ + type: "modelServerStatusChanged", + serverId: SERVER_ID, + status, + }); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("LayoutGrid — model-server launch overlay (ticket #54)", () => { + it("veils the cell while downloading, then retracts at ready", async () => { + const profileGateway = new MockProfileGateway(); + await profileGateway.saveProfile(localModelProfile("opencode-local")); + const agentGateway = new MockAgentGateway(); + const agent = await agentGateway.createAgent("p1", { + name: "Worker", + profileId: "opencode-local", + }); + const systemGateway = new MockSystemGateway(); + const gateways = makeGateways(agentGateway, profileGateway, systemGateway); + + // Pin the agent onto the single default cell. + const layout = gateways.layout as MockLayoutGateway; + const leafId = leaves(await layout.loadLayout("p1"))[0].id; + await layout.mutateLayout("p1", { + type: "setCellAgent", + target: leafId, + agent: agent.id, + }); + + renderGrid(gateways); + await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy()); + + // No overlay before any status. + expect(screen.queryByTestId("model-server-overlay")).toBeNull(); + + // Downloading → overlay with the download wording. + await emit(systemGateway, { + state: "downloading", + downloadedBytes: null, + totalBytes: null, + percent: null, + source: null, + }); + await waitFor(() => + expect(screen.getByTestId("model-server-overlay")).toBeTruthy(), + ); + expect(screen.getByTestId("model-server-overlay").textContent).toContain( + "Téléchargement du modèle", + ); + + // Ready → overlay gone. + await emit(systemGateway, { state: "ready", reused: false }); + await waitFor(() => + expect(screen.queryByTestId("model-server-overlay")).toBeNull(), + ); + }); + + it("shows 'Chargement du serveur…' for starting/probing and hides at failed", async () => { + const profileGateway = new MockProfileGateway(); + await profileGateway.saveProfile(localModelProfile("opencode-local")); + const agentGateway = new MockAgentGateway(); + const agent = await agentGateway.createAgent("p1", { + name: "Worker", + profileId: "opencode-local", + }); + const systemGateway = new MockSystemGateway(); + const gateways = makeGateways(agentGateway, profileGateway, systemGateway); + + const layout = gateways.layout as MockLayoutGateway; + const leafId = leaves(await layout.loadLayout("p1"))[0].id; + await layout.mutateLayout("p1", { + type: "setCellAgent", + target: leafId, + agent: agent.id, + }); + + renderGrid(gateways); + await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy()); + + await emit(systemGateway, { state: "probing" }); + await waitFor(() => + expect( + screen.getByTestId("model-server-overlay").textContent, + ).toContain("Chargement du serveur"), + ); + + await emit(systemGateway, { state: "starting" }); + expect(screen.getByTestId("model-server-overlay").textContent).toContain( + "Chargement du serveur", + ); + + await emit(systemGateway, { + state: "failed", + code: "spawn", + message: "boom", + }); + await waitFor(() => + expect(screen.queryByTestId("model-server-overlay")).toBeNull(), + ); + }); + + it("veils EVERY cell whose agent references the downloading server", async () => { + const profileGateway = new MockProfileGateway(); + await profileGateway.saveProfile(localModelProfile("opencode-local")); + const agentGateway = new MockAgentGateway(); + const agentA = await agentGateway.createAgent("p1", { + name: "A", + profileId: "opencode-local", + }); + const agentB = await agentGateway.createAgent("p1", { + name: "B", + profileId: "opencode-local", + }); + const systemGateway = new MockSystemGateway(); + const gateways = makeGateways(agentGateway, profileGateway, systemGateway); + + // Split the root into two cells, pin one agent onto each. + const layout = gateways.layout as MockLayoutGateway; + const rootLeaf = leaves(await layout.loadLayout("p1"))[0].id; + await layout.mutateLayout("p1", { + type: "split", + target: rootLeaf, + direction: "row", + newLeaf: "cell-b", + container: "split-c", + }); + await layout.mutateLayout("p1", { + type: "setCellAgent", + target: rootLeaf, + agent: agentA.id, + }); + await layout.mutateLayout("p1", { + type: "setCellAgent", + target: "cell-b", + agent: agentB.id, + }); + + renderGrid(gateways); + await waitFor(() => + expect(screen.getAllByTestId("layout-leaf").length).toBe(2), + ); + + await emit(systemGateway, { + state: "downloading", + downloadedBytes: null, + totalBytes: null, + percent: null, + source: null, + }); + // Both cells carry the overlay from a single server event. + await waitFor(() => + expect(screen.getAllByTestId("model-server-overlay").length).toBe(2), + ); + + await emit(systemGateway, { state: "ready", reused: true }); + await waitFor(() => + expect(screen.queryAllByTestId("model-server-overlay").length).toBe(0), + ); + }); +}); diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index e390f94..730dd61 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -38,6 +38,10 @@ import { TargetAnnouncementsOverlay, useTargetAnnouncements, } from "@/features/announcements"; +import { + modelServerOverlayText, + useModelServerLaunchState, +} from "@/features/agents"; import { leaves, normalizeWeights, resizeAdjacent } from "./layout"; import { useLayout, type LayoutViewModel } from "./useLayout"; @@ -367,6 +371,18 @@ function LeafView({ // write-portal veil can be mutually excluded with the F3 overlay (exactly one // full-cell veil at a time, F3 first). Same source the overlay itself reads. const { active: busyActive } = useTargetAnnouncements(agentId ?? ""); + // Ticket #54 — local model-server launch state for this cell's pinned agent. + // Correlated `agentId → profileId → opencode.localModelServerId → status` via + // the shared hook (same reduction as `useAgents`). While the bound server is + // still preparing (downloading a model / starting / probing), a full-cell + // overlay replaces the timeout/error the empty terminal used to show. It has + // the HIGHEST veil priority: nothing else in the cell is usable until the + // server is ready, so it suppresses the write-portal and F3 veils. + const { statusForAgent } = useModelServerLaunchState(projectId); + const pinnedAgent = agentId + ? agents.find((a) => a.id === agentId) + : undefined; + const modelServerOverlay = modelServerOverlayText(statusForAgent(pinnedAgent)); const agentWork = agentId ? workState?.agents.find((row) => row.agentId === agentId) : undefined; @@ -894,7 +910,8 @@ function LeafView({ is being injected into the agent's PTY, a grey veil with a centred message sits above the terminal. Only ever shown for an agent cell — and never together with the F3 overlay (exactly one veil, F3 first). */} - {shouldShowWritePortalVeil(agentId != null, Boolean(overlay), busyActive) && ( + {!modelServerOverlay && + shouldShowWritePortalVeil(agentId != null, Boolean(overlay), busyActive) && (
    )} + {/* Ticket #54 — model-server launch veil. Top-priority full-cell overlay + (suppresses the write-portal + F3 veils above): while the pinned + agent's bound local server is downloading its model / starting / + probing, the empty terminal is covered rather than left to time out. + Retracts at `ready`/`failed` (the predicate returns null then). */} + {modelServerOverlay && ( +
    + + {modelServerOverlay} + +
    + )}
    {busyNotice && (