feat(model-server): afficher le téléchargement du modèle llamacpp au démarrage (#54)
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 <noreply@anthropic.com>
This commit is contained in:
@ -36,6 +36,18 @@ pub enum ModelServerStatusDto {
|
|||||||
Probing,
|
Probing,
|
||||||
/// IdeA is starting the process.
|
/// IdeA is starting the process.
|
||||||
Starting,
|
Starting,
|
||||||
|
/// Server is downloading/preparing a remote model.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
Downloading {
|
||||||
|
/// Downloaded bytes when known.
|
||||||
|
downloaded_bytes: Option<u64>,
|
||||||
|
/// Total bytes when known.
|
||||||
|
total_bytes: Option<u64>,
|
||||||
|
/// Completion percentage when known.
|
||||||
|
percent: Option<f32>,
|
||||||
|
/// Remote model source.
|
||||||
|
source: Option<String>,
|
||||||
|
},
|
||||||
/// Server is ready.
|
/// Server is ready.
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
Ready {
|
Ready {
|
||||||
@ -58,6 +70,17 @@ impl From<&ModelServerLifecycleStatus> for ModelServerStatusDto {
|
|||||||
ModelServerLifecycleStatus::NotConfigured => Self::NotConfigured,
|
ModelServerLifecycleStatus::NotConfigured => Self::NotConfigured,
|
||||||
ModelServerLifecycleStatus::Probing => Self::Probing,
|
ModelServerLifecycleStatus::Probing => Self::Probing,
|
||||||
ModelServerLifecycleStatus::Starting => Self::Starting,
|
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::Ready { reused } => Self::Ready { reused: *reused },
|
||||||
ModelServerLifecycleStatus::Failed { code, message } => Self::Failed {
|
ModelServerLifecycleStatus::Failed { code, message } => Self::Failed {
|
||||||
code: code.clone(),
|
code: code.clone(),
|
||||||
@ -1168,6 +1191,31 @@ mod tests {
|
|||||||
assert_eq!(json["status"]["reused"], true);
|
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]
|
#[test]
|
||||||
fn agent_launch_failed_relays_model_server_cause() {
|
fn agent_launch_failed_relays_model_server_cause() {
|
||||||
let dto = DomainEventDto::from(&DomainEvent::AgentLaunchFailed {
|
let dto = DomainEventDto::from(&DomainEvent::AgentLaunchFailed {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ use domain::ports::{
|
|||||||
};
|
};
|
||||||
use domain::{LocalModelServerId, StopPolicy};
|
use domain::{LocalModelServerId, StopPolicy};
|
||||||
use tokio::sync::{Mutex as AsyncMutex, Notify};
|
use tokio::sync::{Mutex as AsyncMutex, Notify};
|
||||||
|
use tokio::time::Instant;
|
||||||
|
|
||||||
use crate::error::AppError;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
struct ActiveServer {
|
struct ActiveServer {
|
||||||
handle: ManagedProcessHandle,
|
handle: ManagedProcessHandle,
|
||||||
@ -208,6 +216,7 @@ pub struct EnsureLocalModelServer {
|
|||||||
active: Mutex<HashMap<LocalModelServerId, ActiveServer>>,
|
active: Mutex<HashMap<LocalModelServerId, ActiveServer>>,
|
||||||
inflight: AsyncMutex<HashMap<LocalModelServerId, Arc<InflightEnsure>>>,
|
inflight: AsyncMutex<HashMap<LocalModelServerId, Arc<InflightEnsure>>>,
|
||||||
readiness: ReadinessPolicy,
|
readiness: ReadinessPolicy,
|
||||||
|
hf_download_deadline: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EnsureLocalModelServer {
|
impl EnsureLocalModelServer {
|
||||||
@ -232,6 +241,7 @@ impl EnsureLocalModelServer {
|
|||||||
active: Mutex::new(HashMap::new()),
|
active: Mutex::new(HashMap::new()),
|
||||||
inflight: AsyncMutex::new(HashMap::new()),
|
inflight: AsyncMutex::new(HashMap::new()),
|
||||||
readiness: ReadinessPolicy::default(),
|
readiness: ReadinessPolicy::default(),
|
||||||
|
hf_download_deadline: DEFAULT_HF_DOWNLOAD_DEADLINE,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -242,6 +252,13 @@ impl EnsureLocalModelServer {
|
|||||||
self
|
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.
|
/// Ensures the server is reachable.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # 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<String>,
|
||||||
|
) -> Result<EnsureLocalModelServerOutput, AppError> {
|
||||||
for attempt in 0..self.readiness.attempts {
|
for attempt in 0..self.readiness.attempts {
|
||||||
match self.probe.probe(&config.endpoint).await {
|
match self.probe.probe(&config.endpoint).await {
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
self.stop_started_server(config.id, &handle).await;
|
self.stop_started_server(config.id, handle).await;
|
||||||
return self.fail(config.id, err);
|
return self.fail(config.id, err);
|
||||||
}
|
}
|
||||||
Ok(ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted) => {
|
Ok(ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted) => {
|
||||||
@ -345,7 +373,7 @@ impl EnsureLocalModelServer {
|
|||||||
ModelServerLifecycleStatus::Ready { reused: false },
|
ModelServerLifecycleStatus::Ready { reused: false },
|
||||||
);
|
);
|
||||||
return Ok(EnsureLocalModelServerOutput {
|
return Ok(EnsureLocalModelServerOutput {
|
||||||
ready: ready(&config, ModelServerStatus::ReadyStarted),
|
ready: ready(config, ModelServerStatus::ReadyStarted),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(ModelServerStatus::Unreachable) => {
|
Ok(ModelServerStatus::Unreachable) => {
|
||||||
@ -356,9 +384,83 @@ impl EnsureLocalModelServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let Some(source) = hf_source else {
|
||||||
let err = ModelServerError::Timeout;
|
let err = ModelServerError::Timeout;
|
||||||
self.stop_started_server(config.id, &handle).await;
|
self.stop_started_server(config.id, handle).await;
|
||||||
self.fail(config.id, err)
|
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`].
|
/// 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<String> {
|
||||||
|
match config.model.source.as_ref()? {
|
||||||
|
ModelSource::HuggingFace { repo } => Some(repo.as_str().to_owned()),
|
||||||
|
ModelSource::LocalPath { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn premature_exit_error(code: Option<i32>) -> 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.
|
/// Stable model-server error code for event/DTO mapping.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn model_server_error_code(err: &ModelServerError) -> &'static str {
|
pub fn model_server_error_code(err: &ModelServerError) -> &'static str {
|
||||||
|
|||||||
@ -12,8 +12,9 @@ use application::{
|
|||||||
};
|
};
|
||||||
use domain::events::DomainEvent;
|
use domain::events::DomainEvent;
|
||||||
use domain::model_server::{
|
use domain::model_server::{
|
||||||
ExecutablePath, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind,
|
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
|
||||||
ModelPath, ModelServerEndpoint, ModelServerStatus, ModelSource, StopPolicy,
|
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelServerLifecycleStatus,
|
||||||
|
ModelServerStatus, ModelSource, StopPolicy,
|
||||||
};
|
};
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
|
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
|
||||||
@ -56,6 +57,30 @@ fn config(
|
|||||||
.unwrap()
|
.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)]
|
#[derive(Default)]
|
||||||
struct FakeRegistry(Mutex<HashMap<LocalModelServerId, LocalModelServerConfig>>);
|
struct FakeRegistry(Mutex<HashMap<LocalModelServerId, LocalModelServerConfig>>);
|
||||||
|
|
||||||
@ -158,6 +183,7 @@ struct FakeProcess {
|
|||||||
spawns: Mutex<Vec<SpawnSpec>>,
|
spawns: Mutex<Vec<SpawnSpec>>,
|
||||||
kills: Mutex<Vec<String>>,
|
kills: Mutex<Vec<String>>,
|
||||||
statuses: Mutex<HashMap<String, ProcessStatus>>,
|
statuses: Mutex<HashMap<String, ProcessStatus>>,
|
||||||
|
status_sequence: Mutex<VecDeque<ProcessStatus>>,
|
||||||
spawn_delay: Duration,
|
spawn_delay: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,6 +213,9 @@ impl ManagedProcess for FakeProcess {
|
|||||||
&self,
|
&self,
|
||||||
handle: &ManagedProcessHandle,
|
handle: &ManagedProcessHandle,
|
||||||
) -> Result<ProcessStatus, ModelServerError> {
|
) -> Result<ProcessStatus, ModelServerError> {
|
||||||
|
if let Some(status) = self.status_sequence.lock().unwrap().pop_front() {
|
||||||
|
return Ok(status);
|
||||||
|
}
|
||||||
Ok(*self
|
Ok(*self
|
||||||
.statuses
|
.statuses
|
||||||
.lock()
|
.lock()
|
||||||
@ -203,25 +232,35 @@ impl ModelServerRuntime for FakeRuntime {
|
|||||||
&self,
|
&self,
|
||||||
config: &LocalModelServerConfig,
|
config: &LocalModelServerConfig,
|
||||||
) -> Result<ModelServerArgv, ModelServerError> {
|
) -> Result<ModelServerArgv, ModelServerError> {
|
||||||
let Some(ModelSource::LocalPath { path }) = config.model.source.as_ref() else {
|
let mut args = Vec::new();
|
||||||
return Err(ModelServerError::PathNotAccessible(
|
match config
|
||||||
"model.source missing".to_owned(),
|
.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 {
|
Ok(ModelServerArgv {
|
||||||
command: config
|
command: config
|
||||||
.binary
|
.binary
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|binary| binary.as_str().to_owned())
|
.map(|binary| binary.as_str().to_owned())
|
||||||
.unwrap_or_else(|| "llama-server".to_owned()),
|
.unwrap_or_else(|| "llama-server".to_owned()),
|
||||||
args: vec![
|
args,
|
||||||
"--model".to_owned(),
|
|
||||||
path.as_str().to_owned(),
|
|
||||||
"--port".to_owned(),
|
|
||||||
config.endpoint.port.to_string(),
|
|
||||||
"--host".to_owned(),
|
|
||||||
config.options.host.clone(),
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -303,6 +342,7 @@ fn ensure(
|
|||||||
attempts: 2,
|
attempts: 2,
|
||||||
backoff: Duration::ZERO,
|
backoff: Duration::ZERO,
|
||||||
})
|
})
|
||||||
|
.with_hf_download_deadline(Duration::from_secs(5))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -546,6 +586,158 @@ async fn readiness_timeout_kills_started_process() {
|
|||||||
assert_eq!(process.kills.lock().unwrap().as_slice(), ["h1"]);
|
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<ModelServerLifecycleStatus> = 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]
|
#[tokio::test]
|
||||||
async fn missing_registry_entry_is_model_server_not_configured() {
|
async fn missing_registry_entry_is_model_server_not_configured() {
|
||||||
let usecase = ensure(
|
let usecase = ensure(
|
||||||
|
|||||||
@ -33,7 +33,7 @@ pub enum OrchestrationSource {
|
|||||||
/// concern relayed to IPC by an infrastructure adapter, which owns the wire
|
/// concern relayed to IPC by an infrastructure adapter, which owns the wire
|
||||||
/// format. `PtyOutput` in particular is usually short-circuited to a Tauri
|
/// format. `PtyOutput` in particular is usually short-circuited to a Tauri
|
||||||
/// channel rather than serialised here.
|
/// channel rather than serialised here.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum DomainEvent {
|
pub enum DomainEvent {
|
||||||
/// A project was created.
|
/// A project was created.
|
||||||
ProjectCreated {
|
ProjectCreated {
|
||||||
|
|||||||
@ -494,7 +494,7 @@ pub struct ModelServerReady {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Lifecycle status emitted for presentation.
|
/// Lifecycle status emitted for presentation.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum ModelServerLifecycleStatus {
|
pub enum ModelServerLifecycleStatus {
|
||||||
/// No local model server is configured for the profile.
|
/// No local model server is configured for the profile.
|
||||||
NotConfigured,
|
NotConfigured,
|
||||||
@ -502,6 +502,17 @@ pub enum ModelServerLifecycleStatus {
|
|||||||
Probing,
|
Probing,
|
||||||
/// IdeA is starting the server.
|
/// IdeA is starting the server.
|
||||||
Starting,
|
Starting,
|
||||||
|
/// The server process is alive but still downloading/preparing a remote model.
|
||||||
|
Downloading {
|
||||||
|
/// Downloaded bytes when known.
|
||||||
|
downloaded_bytes: Option<u64>,
|
||||||
|
/// Total bytes when known.
|
||||||
|
total_bytes: Option<u64>,
|
||||||
|
/// Completion percentage when known.
|
||||||
|
percent: Option<f32>,
|
||||||
|
/// Remote model source, for example a Hugging Face repository id.
|
||||||
|
source: Option<String>,
|
||||||
|
},
|
||||||
/// The server is ready.
|
/// The server is ready.
|
||||||
Ready {
|
Ready {
|
||||||
/// `true` when an already-running server was reused.
|
/// `true` when an already-running server was reused.
|
||||||
|
|||||||
@ -27,6 +27,18 @@ export type ModelServerStatus =
|
|||||||
| { state: "notConfigured" }
|
| { state: "notConfigured" }
|
||||||
| { state: "probing" }
|
| { state: "probing" }
|
||||||
| { state: "starting" }
|
| { 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: "ready"; reused: boolean }
|
||||||
| { state: "failed"; code: string; message: string };
|
| { state: "failed"; code: string; message: string };
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { AnnouncementsPreview } from "@/features/announcements";
|
|||||||
import { useDrift } from "@/features/templates/useDrift";
|
import { useDrift } from "@/features/templates/useDrift";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { useAgents } from "./useAgents";
|
import { useAgents } from "./useAgents";
|
||||||
|
import { correlateModelServerStatus } from "./modelServerLaunch";
|
||||||
import { AgentLimitBadge } from "./AgentLimitBadge";
|
import { AgentLimitBadge } from "./AgentLimitBadge";
|
||||||
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
||||||
|
|
||||||
@ -335,11 +336,11 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
// F35 — local model server launch state, correlated from the
|
// F35 — local model server launch state, correlated from the
|
||||||
// agent's profile binding (`opencode.localModelServerId`), plus the
|
// agent's profile binding (`opencode.localModelServerId`), plus the
|
||||||
// agent's last launch failure (if any).
|
// agent's last launch failure (if any).
|
||||||
const boundServerId = vm.profiles.find((p) => p.id === a.profileId)
|
const modelServerStatus = correlateModelServerStatus(
|
||||||
?.opencode?.localModelServerId;
|
a,
|
||||||
const modelServerStatus = boundServerId
|
vm.profiles,
|
||||||
? vm.modelServerStatusByServer[boundServerId]
|
vm.modelServerStatusByServer,
|
||||||
: undefined;
|
);
|
||||||
const launchFailure = vm.launchFailureByAgent[a.id];
|
const launchFailure = vm.launchFailureByAgent[a.id];
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
|
|||||||
@ -39,6 +39,8 @@ function describeStatus(
|
|||||||
return { label: "vérification du serveur…", tone: "bg-muted/20 text-muted", role: "status" };
|
return { label: "vérification du serveur…", tone: "bg-muted/20 text-muted", role: "status" };
|
||||||
case "starting":
|
case "starting":
|
||||||
return { label: "démarrage du serveur…", tone: "bg-warning/20 text-warning", role: "status" };
|
return { label: "démarrage du serveur…", tone: "bg-warning/20 text-warning", role: "status" };
|
||||||
|
case "downloading":
|
||||||
|
return { label: "téléchargement du modèle…", tone: "bg-warning/20 text-warning", role: "status" };
|
||||||
case "ready":
|
case "ready":
|
||||||
return status.reused
|
return status.reused
|
||||||
? { label: "serveur prêt (réutilisé)", tone: "bg-success/20 text-success" }
|
? { label: "serveur prêt (réutilisé)", tone: "bg-success/20 text-success" }
|
||||||
|
|||||||
@ -8,6 +8,12 @@ export { useAgents } from "./useAgents";
|
|||||||
export type { AgentsViewModel, AgentLaunchFailure } from "./useAgents";
|
export type { AgentsViewModel, AgentLaunchFailure } from "./useAgents";
|
||||||
export { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
export { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
||||||
export type { ModelServerLaunchBadgeProps } from "./ModelServerLaunchBadge";
|
export type { ModelServerLaunchBadgeProps } from "./ModelServerLaunchBadge";
|
||||||
|
export {
|
||||||
|
correlateModelServerStatus,
|
||||||
|
modelServerOverlayText,
|
||||||
|
useModelServerLaunchState,
|
||||||
|
} from "./modelServerLaunch";
|
||||||
|
export type { ModelServerLaunchState } from "./modelServerLaunch";
|
||||||
export { ResumeProjectPanel } from "./ResumeProjectPanel";
|
export { ResumeProjectPanel } from "./ResumeProjectPanel";
|
||||||
export type { ResumeProjectPanelProps } from "./ResumeProjectPanel";
|
export type { ResumeProjectPanelProps } from "./ResumeProjectPanel";
|
||||||
export { useResumeProject } from "./useResumeProject";
|
export { useResumeProject } from "./useResumeProject";
|
||||||
|
|||||||
141
frontend/src/features/agents/modelServerLaunch.ts
Normal file
141
frontend/src/features/agents/modelServerLaunch.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* Shared correlation of an agent to its local model server's launch state
|
||||||
|
* (F35 / ticket #54). One place owns the chain
|
||||||
|
* `agentId → profileId → opencode.localModelServerId → statusByServer[serverId]`
|
||||||
|
* so both the {@link AgentsPanel} badge and the full-cell overlay in
|
||||||
|
* `LayoutGrid`/`LeafView` read the same truth instead of duplicating it.
|
||||||
|
*
|
||||||
|
* Pure data + a thin `useGateways`-backed hook: no Tauri names here (the event
|
||||||
|
* stream and profile list come through the gateways/adapters).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { Agent, AgentProfile, ModelServerStatus } from "@/domain";
|
||||||
|
import { useGateways } from "@/app/di";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correlate a pinned agent to its model server's current lifecycle status, if
|
||||||
|
* any. Returns `undefined` when the agent's profile binds no local model server
|
||||||
|
* or when that server has emitted no status yet.
|
||||||
|
*/
|
||||||
|
export function correlateModelServerStatus(
|
||||||
|
agent: Pick<Agent, "profileId"> | undefined,
|
||||||
|
profiles: AgentProfile[],
|
||||||
|
statusByServer: Record<string, ModelServerStatus>,
|
||||||
|
): 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<Agent, "profileId"> | 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<AgentProfile[]>([]);
|
||||||
|
const [statusByServer, setStatusByServer] = useState<
|
||||||
|
Record<string, ModelServerStatus>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
// 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<Agent, "profileId"> | undefined) =>
|
||||||
|
correlateModelServerStatus(agent, profiles, statusByServer),
|
||||||
|
[profiles, statusByServer],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { statusForAgent };
|
||||||
|
}
|
||||||
@ -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(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emit(
|
||||||
|
systemGateway: MockSystemGateway,
|
||||||
|
status: ModelServerStatus,
|
||||||
|
): Promise<void> {
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -38,6 +38,10 @@ import {
|
|||||||
TargetAnnouncementsOverlay,
|
TargetAnnouncementsOverlay,
|
||||||
useTargetAnnouncements,
|
useTargetAnnouncements,
|
||||||
} from "@/features/announcements";
|
} from "@/features/announcements";
|
||||||
|
import {
|
||||||
|
modelServerOverlayText,
|
||||||
|
useModelServerLaunchState,
|
||||||
|
} from "@/features/agents";
|
||||||
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
|
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
|
||||||
import { useLayout, type LayoutViewModel } from "./useLayout";
|
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
|
// 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.
|
// full-cell veil at a time, F3 first). Same source the overlay itself reads.
|
||||||
const { active: busyActive } = useTargetAnnouncements(agentId ?? "");
|
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
|
const agentWork = agentId
|
||||||
? workState?.agents.find((row) => row.agentId === agentId)
|
? workState?.agents.find((row) => row.agentId === agentId)
|
||||||
: undefined;
|
: undefined;
|
||||||
@ -894,7 +910,8 @@ function LeafView({
|
|||||||
is being injected into the agent's PTY, a grey veil with a centred
|
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 —
|
message sits above the terminal. Only ever shown for an agent cell —
|
||||||
and never together with the F3 overlay (exactly one veil, F3 first). */}
|
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) && (
|
||||||
<div
|
<div
|
||||||
data-testid="write-portal-overlay"
|
data-testid="write-portal-overlay"
|
||||||
role="status"
|
role="status"
|
||||||
@ -931,9 +948,46 @@ function LeafView({
|
|||||||
busy state (agentBusyChanged + read-model hydration), never by the raw
|
busy state (agentBusyChanged + read-model hydration), never by the raw
|
||||||
PTY — it retracts at idle even when a turn ends without a completion
|
PTY — it retracts at idle even when a turn ends without a completion
|
||||||
event. Self-guards on `active` (busy) and renders null otherwise. */}
|
event. Self-guards on `active` (busy) and renders null otherwise. */}
|
||||||
{agentId != null && (
|
{!modelServerOverlay && agentId != null && (
|
||||||
<TargetAnnouncementsOverlay projectId={projectId} agentId={agentId} />
|
<TargetAnnouncementsOverlay projectId={projectId} agentId={agentId} />
|
||||||
)}
|
)}
|
||||||
|
{/* 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 && (
|
||||||
|
<div
|
||||||
|
data-testid="model-server-overlay"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label="model server launch"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: CELL_Z.veil,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "rgba(0, 0, 0, 0.55)",
|
||||||
|
color: "var(--color-content, #e0e0e0)",
|
||||||
|
fontSize: 13,
|
||||||
|
pointerEvents: "none",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: "var(--color-surface, #1e1e1e)",
|
||||||
|
border: "1px solid var(--color-border, #3a3a3a)",
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "6px 12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{modelServerOverlay}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{busyNotice && (
|
{busyNotice && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user