fix: livrable tickets #70 #100 #102 — UX surface scoping

- #70: implémentation suppression modèles locaux téléchargés
- #100: correction scroll OpenCode
- #102: correction fit TUI après switch/layout
- memory note scoping UX
This commit is contained in:
2026-07-26 19:42:06 +02:00
parent 4321d048ac
commit 02603441c1
31 changed files with 1589 additions and 152 deletions

View File

@ -7,28 +7,52 @@ use std::time::Duration;
use async_trait::async_trait;
use application::{
DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer, EnsureLocalModelServerInput,
ModelServerReadinessPolicy,
DeleteModelArtifact, DeleteModelArtifactInput, DeleteModelServer, DeleteModelServerInput,
EnsureLocalModelServer, EnsureLocalModelServerInput, LiveAgentRegistry, LiveSessionKind,
LiveSessionSnapshot, ModelArtifactDownloadTracker, ModelServerReadinessPolicy,
};
use domain::events::DomainEvent;
use domain::layout::Workspace;
use domain::markdown::MarkdownDoc;
use domain::model_server::{
ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelServerLifecycleStatus,
ModelServerStatus, ModelSource, StopPolicy,
};
use domain::ports::{
DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess, ManagedProcessHandle,
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution,
ModelServerArgv, ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime,
ProcessStatus, ProfileStore, RemotePath, SpawnSpec, StoreError,
AgentContextStore, DirEntry, EventBus, EventStream, FileSystem, FsError, ManagedProcess,
ManagedProcessHandle, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
ModelArtifactResolution, ModelArtifactState, ModelServerArgv, ModelServerError,
ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore,
ProjectStore, RemotePath, SpawnSpec, StoreError,
};
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
use domain::{LocalModelServerId, ProfileId, ProjectPath};
use domain::project::Project;
use domain::{
AgentId, AgentManifest, LocalModelServerId, ManifestEntry, NodeId, ProfileId, ProjectId,
ProjectPath, RemoteRef, SessionId,
};
fn sid(n: u128) -> LocalModelServerId {
LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
}
fn aid(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn pid(n: u128) -> ProjectId {
ProjectId::from_uuid(uuid::Uuid::from_u128(n))
}
fn nid(n: u128) -> NodeId {
NodeId::from_uuid(uuid::Uuid::from_u128(n))
}
fn sess(n: u128) -> SessionId {
SessionId::from_uuid(uuid::Uuid::from_u128(n))
}
fn config(
id: LocalModelServerId,
port: u16,
@ -294,18 +318,27 @@ enum FakeDownloadOutcome {
struct FakeModelArtifactDownloader {
outcome: Mutex<FakeDownloadOutcome>,
deleted: Mutex<Vec<String>>,
}
impl FakeModelArtifactDownloader {
fn new(outcome: FakeDownloadOutcome) -> Self {
Self {
outcome: Mutex::new(outcome),
deleted: Mutex::new(Vec::new()),
}
}
}
#[async_trait]
impl ModelArtifactDownloader for FakeModelArtifactDownloader {
async fn hf_model_state(
&self,
_repo: &HfModelRef,
) -> Result<ModelArtifactState, ModelServerError> {
Ok(ModelArtifactState::Missing)
}
async fn resolve_hf_model(
&self,
repo: &HfModelRef,
@ -347,6 +380,123 @@ impl ModelArtifactDownloader for FakeModelArtifactDownloader {
}
}
}
async fn delete_hf_model(&self, repo: &HfModelRef) -> Result<(), ModelServerError> {
self.deleted.lock().unwrap().push(repo.as_str().to_owned());
Ok(())
}
}
#[derive(Default)]
struct FakeDownloadTracker {
in_progress: Mutex<Vec<LocalModelServerId>>,
}
impl ModelArtifactDownloadTracker for FakeDownloadTracker {
fn is_model_artifact_download_in_progress(&self, server_id: LocalModelServerId) -> bool {
self.in_progress.lock().unwrap().contains(&server_id)
}
}
#[derive(Default)]
struct FakeLive {
snapshots: Vec<LiveSessionSnapshot>,
}
impl LiveAgentRegistry for FakeLive {
fn is_agent_live(&self, _project_id: ProjectId, _agent_id: &AgentId) -> bool {
false
}
fn is_node_live(&self, _node_id: &NodeId) -> bool {
false
}
fn live_agent_snapshots(&self) -> Vec<application::LiveSessionSnapshot> {
self.snapshots.clone()
}
}
struct FakeProjects {
project_id: ProjectId,
}
impl Default for FakeProjects {
fn default() -> Self {
Self { project_id: pid(1) }
}
}
#[async_trait]
impl ProjectStore for FakeProjects {
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
Ok(Vec::new())
}
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
if id != self.project_id {
return Err(StoreError::NotFound);
}
Project::new(
id,
"Test",
ProjectPath::new("/tmp/unused").unwrap(),
RemoteRef::Local,
0,
)
.map_err(|err| StoreError::Invalid(err.to_string()))
}
async fn save_project(&self, _project: &Project) -> Result<(), StoreError> {
Ok(())
}
async fn save_workspace(&self, _workspace: &Workspace) -> Result<(), StoreError> {
Ok(())
}
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
Ok(Workspace {
windows: Vec::new(),
})
}
}
#[derive(Default)]
struct FakeContexts {
manifest: AgentManifest,
}
#[async_trait]
impl AgentContextStore for FakeContexts {
async fn read_context(
&self,
_project: &Project,
_agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
Ok(MarkdownDoc::new(""))
}
async fn write_context(
&self,
_project: &Project,
_agent: &AgentId,
_md: &MarkdownDoc,
) -> Result<(), StoreError> {
Ok(())
}
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
Ok(self.manifest.clone())
}
async fn save_manifest(
&self,
_project: &Project,
_manifest: &AgentManifest,
) -> Result<(), StoreError> {
Ok(())
}
}
#[derive(Default)]
@ -427,6 +577,46 @@ fn ensure_with_downloader(
.with_model_artifact_downloader(downloader as Arc<dyn ModelArtifactDownloader>)
}
fn delete_artifact_usecase(
registry: Arc<FakeRegistry>,
downloader: Arc<FakeModelArtifactDownloader>,
tracker: Arc<FakeDownloadTracker>,
profiles: Arc<FakeProfiles>,
) -> DeleteModelArtifact {
delete_artifact_usecase_with_live(
registry,
downloader,
tracker,
profiles,
Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])),
Arc::new(FakeProjects::default()),
Arc::new(FakeContexts::default()),
Arc::new(FakeLive::default()),
)
}
fn delete_artifact_usecase_with_live(
registry: Arc<FakeRegistry>,
downloader: Arc<FakeModelArtifactDownloader>,
tracker: Arc<FakeDownloadTracker>,
profiles: Arc<FakeProfiles>,
probe: Arc<FakeProbe>,
projects: Arc<FakeProjects>,
contexts: Arc<FakeContexts>,
live: Arc<FakeLive>,
) -> DeleteModelArtifact {
DeleteModelArtifact::new(
registry as Arc<dyn ModelServerRegistry>,
probe as Arc<dyn ModelServerProbe>,
downloader as Arc<dyn ModelArtifactDownloader>,
tracker as Arc<dyn ModelArtifactDownloadTracker>,
profiles as Arc<dyn ProfileStore>,
projects as Arc<dyn ProjectStore>,
contexts as Arc<dyn AgentContextStore>,
live as Arc<dyn LiveAgentRegistry>,
)
}
fn progress(downloaded: Option<u64>, total: Option<u64>) -> ModelArtifactProgress {
ModelArtifactProgress {
downloaded_bytes: downloaded,
@ -1325,3 +1515,218 @@ async fn delete_model_server_removes_unused_config() {
assert!(registry.get(&sid(9)).await.unwrap().is_none());
}
#[tokio::test]
async fn delete_model_artifact_refuses_local_path_source() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(config(sid(25), 8105, "/models/qwen.gguf", false))
.await
.unwrap();
let downloader = Arc::new(FakeModelArtifactDownloader::new(
FakeDownloadOutcome::Resolve {
progress: Vec::new(),
path: "/cache/model.gguf",
cache_hit: true,
},
));
let usecase = delete_artifact_usecase(
Arc::clone(&registry),
Arc::clone(&downloader),
Arc::new(FakeDownloadTracker::default()),
Arc::new(FakeProfiles::default()),
);
let err = usecase
.execute(DeleteModelArtifactInput { server_id: sid(25) })
.await
.unwrap_err();
match err {
application::AppError::ModelServer { code, .. } => assert_eq!(code, "invalid"),
other => panic!("unexpected error: {other}"),
}
assert!(downloader.deleted.lock().unwrap().is_empty());
assert!(registry.get(&sid(25)).await.unwrap().is_some());
}
#[tokio::test]
async fn delete_model_artifact_refuses_download_in_progress() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(hf_config(sid(26), 8106, "Qwen/Qwen3-Coder:Q4_K_M"))
.await
.unwrap();
let tracker = Arc::new(FakeDownloadTracker::default());
tracker.in_progress.lock().unwrap().push(sid(26));
let downloader = Arc::new(FakeModelArtifactDownloader::new(
FakeDownloadOutcome::Resolve {
progress: Vec::new(),
path: "/cache/q4.gguf",
cache_hit: true,
},
));
let usecase = delete_artifact_usecase(
Arc::clone(&registry),
Arc::clone(&downloader),
tracker,
Arc::new(FakeProfiles::default()),
);
let err = usecase
.execute(DeleteModelArtifactInput { server_id: sid(26) })
.await
.unwrap_err();
match err {
application::AppError::ModelServer { code, .. } => {
assert_eq!(code, "model_server_in_use");
}
other => panic!("unexpected error: {other}"),
}
assert!(downloader.deleted.lock().unwrap().is_empty());
}
#[tokio::test]
async fn delete_model_artifact_refuses_live_agent_using_server_profile() {
let server_id = sid(27);
let project_id = pid(27);
let agent_id = aid(27);
let profile_id = ProfileId::from_uuid(uuid::Uuid::from_u128(270));
let registry = Arc::new(FakeRegistry::default());
registry
.save(hf_config(server_id, 8107, "Qwen/Qwen3-Coder:Q4_K_M"))
.await
.unwrap();
let profiles = Arc::new(FakeProfiles(Mutex::new(vec![opencode_profile(
profile_id.as_uuid().as_u128(),
server_id,
)])));
let contexts = Arc::new(FakeContexts {
manifest: AgentManifest::new(
1,
vec![ManifestEntry::new(
agent_id,
"Local Agent",
"agents/local.md",
profile_id,
None,
false,
None,
)
.unwrap()],
)
.unwrap(),
});
let live = Arc::new(FakeLive {
snapshots: vec![LiveSessionSnapshot {
project_id,
agent_id,
node_id: nid(27),
session_id: sess(27),
kind: LiveSessionKind::Pty,
}],
});
let downloader = Arc::new(FakeModelArtifactDownloader::new(
FakeDownloadOutcome::Resolve {
progress: Vec::new(),
path: "/cache/q4.gguf",
cache_hit: true,
},
));
let usecase = delete_artifact_usecase_with_live(
Arc::clone(&registry),
Arc::clone(&downloader),
Arc::new(FakeDownloadTracker::default()),
profiles,
Arc::new(FakeProbe::new(vec![ModelServerStatus::Unreachable])),
Arc::new(FakeProjects { project_id }),
contexts,
live,
);
let err = usecase
.execute(DeleteModelArtifactInput { server_id })
.await
.unwrap_err();
match err {
application::AppError::ModelServer { code, .. } => {
assert_eq!(code, "model_server_in_use");
}
other => panic!("unexpected error: {other}"),
}
assert!(downloader.deleted.lock().unwrap().is_empty());
}
#[tokio::test]
async fn delete_model_artifact_refuses_reachable_server_endpoint() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(hf_config(sid(28), 8108, "Qwen/Qwen3-Coder:Q4_K_M"))
.await
.unwrap();
let downloader = Arc::new(FakeModelArtifactDownloader::new(
FakeDownloadOutcome::Resolve {
progress: Vec::new(),
path: "/cache/q4.gguf",
cache_hit: true,
},
));
let usecase = delete_artifact_usecase_with_live(
Arc::clone(&registry),
Arc::clone(&downloader),
Arc::new(FakeDownloadTracker::default()),
Arc::new(FakeProfiles::default()),
Arc::new(FakeProbe::new(vec![ModelServerStatus::ReadyReused])),
Arc::new(FakeProjects::default()),
Arc::new(FakeContexts::default()),
Arc::new(FakeLive::default()),
);
let err = usecase
.execute(DeleteModelArtifactInput { server_id: sid(28) })
.await
.unwrap_err();
match err {
application::AppError::ModelServer { code, .. } => {
assert_eq!(code, "model_server_in_use");
}
other => panic!("unexpected error: {other}"),
}
assert!(downloader.deleted.lock().unwrap().is_empty());
}
#[tokio::test]
async fn delete_model_artifact_deletes_hf_cache_without_deleting_config() {
let registry = Arc::new(FakeRegistry::default());
registry
.save(hf_config(sid(27), 8107, "Qwen/Qwen3-Coder:Q4_K_M"))
.await
.unwrap();
let downloader = Arc::new(FakeModelArtifactDownloader::new(
FakeDownloadOutcome::Resolve {
progress: Vec::new(),
path: "/cache/q4.gguf",
cache_hit: true,
},
));
let usecase = delete_artifact_usecase(
Arc::clone(&registry),
Arc::clone(&downloader),
Arc::new(FakeDownloadTracker::default()),
Arc::new(FakeProfiles::default()),
);
usecase
.execute(DeleteModelArtifactInput { server_id: sid(27) })
.await
.unwrap();
assert_eq!(
downloader.deleted.lock().unwrap().as_slice(),
["Qwen/Qwen3-Coder:Q4_K_M"]
);
assert!(registry.get(&sid(27)).await.unwrap().is_some());
}