From da907b880ee658519565c0aa7da9ca0b4b831aef Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 25 Jul 2026 14:54:57 +0200 Subject: [PATCH 1/7] Fix terminal resize bug Fix resize handling in TerminalView component and update related tests --- .../features/terminals/TerminalView.test.tsx | 63 ++++++++++++++--- .../src/features/terminals/TerminalView.tsx | 69 +++++++++++++++---- .../src/features/web/WebAgentCell.test.tsx | 10 ++- .../features/web/WebWorkspaceLive.test.tsx | 7 +- 4 files changed, 117 insertions(+), 32 deletions(-) diff --git a/frontend/src/features/terminals/TerminalView.test.tsx b/frontend/src/features/terminals/TerminalView.test.tsx index 2768842..3b8b8f0 100644 --- a/frontend/src/features/terminals/TerminalView.test.tsx +++ b/frontend/src/features/terminals/TerminalView.test.tsx @@ -246,6 +246,13 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () = globalThis.ResizeObserver = savedResizeObserver; }); + function setTerminalBoxSize(width: number, height: number) { + const container = screen.getByTestId("terminal-xterm-container"); + Object.defineProperty(container, "clientWidth", { value: width, configurable: true }); + Object.defineProperty(container, "clientHeight", { value: height, configurable: true }); + return container; + } + it("sanity: with the polyfills xterm mounts and the opener runs", async () => { // Guards the premise of the tests below: if this fails, the opener never // fired and the error assertions would be vacuous. @@ -292,6 +299,48 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () = expect(screen.queryByTestId("terminal-error")).toBeNull(); }); + it("keeps a boot placeholder while the terminal box has no usable size", async () => { + const handle = makeHandle({ sessionId: "boot-zero-1" }); + const open = vi.fn(async () => handle); + + renderView(new MockTerminalGateway(), "/cwd", { open, refitSignal: 1 }); + await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => requestAnimationFrame(resolve)); + + expect(screen.getByTestId("terminal-boot-placeholder")).toBeTruthy(); + expect(screen.getByTestId("terminal-xterm-container").style.visibility).toBe( + "hidden", + ); + expect(handle.resize).not.toHaveBeenCalled(); + }); + + it("reveals xterm and resizes the handle after the first useful fit", async () => { + const handle = makeHandle({ sessionId: "boot-ready-1" }); + const open = vi.fn(async () => handle); + + const { rerender } = renderView(new MockTerminalGateway(), "/cwd", { + open, + refitSignal: 1, + }); + await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); + expect(screen.getByTestId("terminal-boot-placeholder")).toBeTruthy(); + + setTerminalBoxSize(480, 240); + rerender( + + + , + ); + + await waitFor(() => + expect(screen.queryByTestId("terminal-boot-placeholder")).toBeNull(), + ); + expect(screen.getByTestId("terminal-xterm-container").style.visibility).toBe( + "visible", + ); + await waitFor(() => expect(handle.resize).toHaveBeenCalled()); + }); + describe("refitSignal (ticket #61 — refit after split/merge)", () => { it("refits WITHOUT reopening the terminal when refitSignal changes", async () => { // Simulates LayoutGrid bumping `useLayout`'s layout version after a @@ -305,18 +354,14 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () = refitSignal: 1, }); await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); - - const fitCallsAtMount = fitSpy.mock.calls.length; - expect(fitCallsAtMount).toBeGreaterThan(0); + fitSpy.mockClear(); // jsdom reports a zero-size layout box, which the coalesced refit // deliberately skips (the same guard that protects the resize-observer // path from fitting to a transient zero size). Stub a real size on the // inner xterm container so the refit triggered below actually reaches // `fit.fit()` instead of bailing on the zero-size guard. - const container = screen.getByTestId("terminal-view").firstElementChild as HTMLElement; - Object.defineProperty(container, "clientWidth", { value: 400, configurable: true }); - Object.defineProperty(container, "clientHeight", { value: 200, configurable: true }); + setTerminalBoxSize(400, 200); rerender( @@ -324,9 +369,7 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () = , ); - await waitFor(() => - expect(fitSpy.mock.calls.length).toBeGreaterThan(fitCallsAtMount), - ); + await waitFor(() => expect(fitSpy).toHaveBeenCalled()); // The structural-mutation refit must never reopen the PTY. expect(open).toHaveBeenCalledTimes(1); @@ -348,7 +391,7 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () = await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); fitSpy.mockClear(); - const container = screen.getByTestId("terminal-view").firstElementChild as HTMLElement; + const container = screen.getByTestId("terminal-xterm-container"); // jsdom's default layout box is 0x0 — exactly the transient-zero case: // left as-is, the container "hasn't settled" yet. diff --git a/frontend/src/features/terminals/TerminalView.tsx b/frontend/src/features/terminals/TerminalView.tsx index 47ef2df..6ef7b7f 100644 --- a/frontend/src/features/terminals/TerminalView.tsx +++ b/frontend/src/features/terminals/TerminalView.tsx @@ -153,6 +153,7 @@ export function TerminalView({ // buffer (which is invisible to assistive tech and absent when xterm can't // mount). `null` ⇒ no error. The cell stays mounted and IdeA stays usable. const [openError, setOpenError] = useState(null); + const [terminalReady, setTerminalReady] = useState(false); // The opener (`open` or the terminal gateway) is read through a ref so the // effect does NOT depend on its identity. Otherwise every parent re-render @@ -191,6 +192,7 @@ export function TerminalView({ // Fresh (re)mount: clear any prior failure banner before we try to open. setOpenError(null); + setTerminalReady(false); const term = new Terminal({ convertEol: false, @@ -209,15 +211,13 @@ export function TerminalView({ term.dispose(); return; } - try { - fit.fit(); - } catch { - /* container not laid out yet; a resize will retry */ - } - let disposed = false; let handle: TerminalHandle | null = null; const encoder = new TextEncoder(); + let rafId = 0; + let lastRows = term.rows; + let lastCols = term.cols; + let hasUsefulFit = false; // Keystroke → PTY path. The agent cell is a **native terminal** // (ARCHITECTURE §20): keystrokes reach the PTY exactly like a plain shell. @@ -259,6 +259,11 @@ export function TerminalView({ // Adopt a freshly-established handle: flush buffered keystrokes. If the view // was disposed before the promise resolved, just detach (NEVER close — the // PTY must survive a transient mount/unmount). + const resizeHandleToCurrentGeometry = () => { + if (!handle) return; + if (term.rows <= 0 || term.cols <= 0) return; + void handle.resize(term.rows, term.cols); + }; const adopt = (h: TerminalHandle) => { if (disposed) { h.detach(); @@ -272,6 +277,7 @@ export function TerminalView({ void h.write(encoder.encode(pending)); pending = ""; } + if (hasUsefulFit) resizeHandleToCurrentGeometry(); }; const onOpenError = (e: unknown) => { @@ -335,9 +341,6 @@ export function TerminalView({ // into a single `requestAnimationFrame` that runs after layout settles, // (2) skip fitting while the container has no real size, and (3) push a PTY // resize only when rows/cols actually change (avoids redundant reflows). - let rafId = 0; - let lastRows = term.rows; - let lastCols = term.cols; // A refit can land on a transient 0x0 container (mount, or a structural // layout mutation, before the box has actually settled). Previously this // just gave up — fine on desktop, where a later window resize always @@ -364,10 +367,20 @@ export function TerminalView({ } catch { return; } - if (handle && (term.rows !== lastRows || term.cols !== lastCols)) { + if (term.rows <= 0 || term.cols <= 0) return; + + const isFirstUsefulFit = !hasUsefulFit; + if (isFirstUsefulFit) { + hasUsefulFit = true; + setTerminalReady(true); + } + + if (term.rows !== lastRows || term.cols !== lastCols) { lastRows = term.rows; lastCols = term.cols; - void handle.resize(term.rows, term.cols); + resizeHandleToCurrentGeometry(); + } else if (isFirstUsefulFit) { + resizeHandleToCurrentGeometry(); } }; const scheduleRefit = () => { @@ -376,6 +389,7 @@ export function TerminalView({ }; const ro = new ResizeObserver(scheduleRefit); ro.observe(container); + scheduleRefit(); // Let the `refitSignal` effect below trigger the SAME coalesced refit after // a structural layout mutation (split/merge, ticket #61) — surviving cells // don't always get a timely useful ResizeObserver event from a sibling @@ -426,7 +440,38 @@ export function TerminalView({ > {/* xterm mounts into this inner node; the error banner is a sibling so React never fights xterm over the same subtree. */} -
+
+ {!terminalReady && !openError && ( +
+ Préparation du terminal… +
+ )} {openError && (
{ const { rerender } = renderCell(agent, seeded.id, { refitSignal: 1 }); await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1)); - - const fitCallsAtMount = fitSpy.mock.calls.length; - expect(fitCallsAtMount).toBeGreaterThan(0); + fitSpy.mockClear(); // jsdom reports a zero-size layout box, which the coalesced refit // deliberately skips — give the inner xterm container a real size so the // refit below actually reaches `fit.fit()`. const container = screen.getByTestId("web-agent-cell").querySelector( - '[data-testid="terminal-view"]', - )!.firstElementChild as HTMLElement; + '[data-testid="terminal-xterm-container"]', + ) as HTMLElement; Object.defineProperty(container, "clientWidth", { value: 400, configurable: true }); Object.defineProperty(container, "clientHeight", { value: 200, configurable: true }); @@ -130,7 +128,7 @@ describe("WebAgentCell — refitSignal (ticket #61 web regression)", () => { , ); - await waitFor(() => expect(fitSpy.mock.calls.length).toBeGreaterThan(fitCallsAtMount)); + await waitFor(() => expect(fitSpy).toHaveBeenCalled()); // The whole point: refit must never relaunch the agent's PTY. expect(launchSpy).toHaveBeenCalledTimes(1); diff --git a/frontend/src/features/web/WebWorkspaceLive.test.tsx b/frontend/src/features/web/WebWorkspaceLive.test.tsx index 0868ccc..a650521 100644 --- a/frontend/src/features/web/WebWorkspaceLive.test.tsx +++ b/frontend/src/features/web/WebWorkspaceLive.test.tsx @@ -384,8 +384,6 @@ describe("LiveProjectPanel — cellLayoutVersion (ticket #61 web regression)", ( const archiRow = screen.getByText("Archi").closest("li")!; fireEvent.click(within(archiRow).getByRole("button", { name: "Ouvrir" })); await screen.findByTestId("web-agent-cell"); - // First cell mounted: at least the one guaranteed fit-on-mount happened. - expect(fitSpy.mock.calls.length).toBeGreaterThan(0); // Switch to a different agent's cell — the panel shows one cell at a time, // so this replaces (unmounts A, mounts B) rather than adding a second. @@ -398,8 +396,9 @@ describe("LiveProjectPanel — cellLayoutVersion (ticket #61 web regression)", ( // jsdom reports a zero-size layout box, which the refit deliberately skips // — give the freshly-mounted cell's inner xterm container a real size so // the refitSignal-driven refit actually reaches `fit.fit()`. - const containerB = cellB.querySelector('[data-testid="terminal-view"]')! - .firstElementChild as HTMLElement; + const containerB = cellB.querySelector( + '[data-testid="terminal-xterm-container"]', + ) as HTMLElement; Object.defineProperty(containerB, "clientWidth", { value: 400, configurable: true }); Object.defineProperty(containerB, "clientHeight", { value: 200, configurable: true }); From ea7ea712308bde7de7f423b996094aef1fa02165 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 26 Jul 2026 14:56:26 +0200 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20finalise=20multi-profil=20Codex/Cla?= =?UTF-8?q?ude=20avec=20catalogue=20de=20mod=C3=A8les?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend : clone_profile_from_seed généralisé (non OpenCode) - Backend : catalogue static Claude/Codex (3 modèles chacun, 1 recommandé) - Backend : commandes Tauri list_claude_models/list_codex_models - Frontend : ProfilesSettings refonte en onglets Codex/Claude + create/duplicate/edit/delete - Frontend : ModelSelect searchable partagé + fallback saisie manuelle - Frontend : assignation agent nom · modèle - Tests QA : 4 profils modèles distincts (2 Claude, 2 Codex) assignés à agents --- .ideai/memory/MEMORY.md | 1 + ...le-codex-claude-model-catalogue-scoping.md | 50 +++ crates/app-tauri/src/commands.rs | 68 +++- crates/app-tauri/src/lib.rs | 3 + crates/app-tauri/tests/dto_profiles.rs | 47 ++- crates/application/src/agent/mod.rs | 18 +- .../application/src/agent/model_catalogue.rs | 183 +++++++++ crates/application/src/agent/usecases.rs | 86 ++++ crates/application/src/lib.rs | 22 +- crates/application/tests/profile_usecases.rs | 134 ++++++- crates/backend/src/dto.rs | 75 ++++ crates/backend/src/lib.rs | 39 +- .../adapters/http/requestResponseGateways.ts | 13 + frontend/src/adapters/mock/index.ts | 80 ++++ frontend/src/adapters/profile.ts | 20 + frontend/src/domain/index.ts | 14 + frontend/src/features/agents/AgentsPanel.tsx | 18 +- frontend/src/features/agents/agents.test.tsx | 36 ++ .../first-run/ProfilesSettings.test.tsx | 113 ++++++ .../features/first-run/ProfilesSettings.tsx | 367 +++++++++++++++--- frontend/src/ports/index.ts | 20 + 21 files changed, 1298 insertions(+), 109 deletions(-) create mode 100644 .ideai/memory/multi-profile-codex-claude-model-catalogue-scoping.md create mode 100644 crates/application/src/agent/model_catalogue.rs create mode 100644 frontend/src/features/first-run/ProfilesSettings.test.tsx diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 61b6865..9895848 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -70,3 +70,4 @@ - [ticket101-cross-talk-multi-project-rootcause](ticket101-cross-talk-multi-project-rootcause.md) — memory note ticket101-cross-talk-multi-project-rootcause - [ticket103-network-permission-ux-surface](ticket103-network-permission-ux-surface.md) — Stable UX convention for agent network permissions in IdeA. - [codex-network-access-config-fix](codex-network-access-config-fix.md) — memory note codex-network-access-config-fix +- [multi-profile-codex-claude-model-catalogue-scoping](multi-profile-codex-claude-model-catalogue-scoping.md) — memory note multi-profile-codex-claude-model-catalogue-scoping diff --git a/.ideai/memory/multi-profile-codex-claude-model-catalogue-scoping.md b/.ideai/memory/multi-profile-codex-claude-model-catalogue-scoping.md new file mode 100644 index 0000000..19f3b49 --- /dev/null +++ b/.ideai/memory/multi-profile-codex-claude-model-catalogue-scoping.md @@ -0,0 +1,50 @@ +--- +name: multi-profile-codex-claude-model-catalogue-scoping +description: memory note multi-profile-codex-claude-model-catalogue-scoping +metadata: + type: project +--- +# Cadrage : profils multiples Codex/Claude + catalogue de modèles + +Demande utilisateur : plusieurs profils Codex et Claude, chacun avec son modèle, assignables aux +agents ; lister les modèles plutôt que saisie manuelle quand possible. + +## État vérifié de l'existant (2026-07-26) + +Le backend est déjà générique multi-profils, contrairement à ce qu'on pourrait croire à la lecture +seule des mémoires F35/F36 (qui documentaient le cas OpenCode) : + +- `AgentProfile` (crates/domain/src/profile.rs) porte déjà `model: Option` (ticket #99, + explicitement prévu pour Codex/Claude), et `profiles.json` (FsProfileStore) est une **liste** + indexée par `id`, pas un slot singleton par provider. +- Commandes déjà câblées : `list_profiles`, `save_profile` (upsert générique par id), + `delete_profile`, `reference_profiles`, `detect_profiles`. +- Ce qui existe **seulement pour OpenCode** : `clone_opencode_profile_from_seed` (alloue un id + frais via IdGenerator) et `save_opencode_provider_profile`, plus un vrai catalogue de modèles + (`crates/application/src/agent/provider_catalogue.rs`, lit le cache models.dev d'OpenCode avec + repli statique). +- `catalogue.rs` : un seul profil de référence Claude et un seul Codex, aucun `.with_model(...)`. +- Frontend `ProfilesSettings.tsx` : simple list+delete, pas de create/duplicate/edit inline ; + toute édition rouvre `FirstRunWizard`. + +## Gaps identifiés (pas de migration de schéma nécessaire) + +1. Backend : généraliser le pattern `CloneOpenCodeProfileFromSeed` (fresh_profile_id via + IdGenerator) en un use case `CloneProfileFromSeed` non spécifique à OpenCode, pour dupliquer un + profil Claude/Codex avec un nom + `model` en override. Ne PAS laisser le frontend miner l'id + (romprait la discipline IdGenerator déjà en place). +2. Backend : catalogue de modèles Claude/Codex — aucune API fiable côté CLI, donc liste statique + curée (même esprit que `static_fallback_catalogue()` d'OpenCode), exposée par commande Tauri + infaillible (`list_claude_models`/`list_codex_models` ou générique par `structuredAdapter`). + Le frontend garde toujours un champ de saisie manuelle en repli (liste jamais garantie + exhaustive). +3. Frontend : refonte `ProfilesSettings.tsx` en onglets Codex/Claude/OpenCode avec + create/duplicate/edit/delete par onglet + `ModelSelect` searchable partagé ; simplifier + `FirstRunWizard` pour ne créer qu'un profil par défaut par provider détecté, avec renvoi vers + Settings pour en ajouter d'autres. + +## Découpage de livraison +DevBackend (use case + catalogues + commandes, petit lot, zéro migration) → DevFrontend (refonte +Settings + first-run simplifié) → QA (créer 2 profils Claude modèles différents + 2 Codex, assigner +à des agents distincts, vérifier le bon modèle atteint la CLI au lancement, non-régression +OpenCode) → Git (branche feature unique, lot petit et couplé). \ No newline at end of file diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index dd9d93d..c14d689 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -41,22 +41,23 @@ use crate::dto::{ AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto, - ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, - CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, - CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, - DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto, - DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto, - EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, - FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, - GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto, - HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto, - LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, - MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, - ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, - PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, - ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto, - ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto, - ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, + CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, + CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, + CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto, + CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, + DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, + EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, + ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, + GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, + GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, + InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, + LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, + ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto, + OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto, + ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, + ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, + ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto, + ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, @@ -1188,6 +1189,22 @@ pub async fn list_opencode_providers( Ok(state.list_opencode_providers.execute().into()) } +/// `list_claude_models` — static curated Claude model catalogue. +#[tauri::command] +pub async fn list_claude_models( + state: State<'_, AppState>, +) -> Result { + Ok(state.list_claude_models.execute().into()) +} + +/// `list_codex_models` — static curated Codex model catalogue. +#[tauri::command] +pub async fn list_codex_models( + state: State<'_, AppState>, +) -> Result { + Ok(state.list_codex_models.execute().into()) +} + /// `save_opencode_provider_profile` — create or replace an OpenCode profile /// backed by a cloud provider (ticket #92, lot B3). The literal API key is /// sealed into the `SecretStore`, never persisted in `profiles.json`. @@ -1227,6 +1244,25 @@ pub async fn clone_opencode_profile_from_seed( .map_err(ErrorDto::from) } +/// `clone_profile_from_seed` — create a new profile instance from a +/// persisted/reference seed, with optional name/model overrides. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`NOT_FOUND` for an unknown seed, `STORE` on profiles +/// I/O failure, `INVALID` for a blank requested name/model). +#[tauri::command] +pub async fn clone_profile_from_seed( + request: CloneProfileFromSeedRequestDto, + state: State<'_, AppState>, +) -> Result { + state + .clone_profile_from_seed + .execute(request.into()) + .await + .map(ProfileDto::from) + .map_err(ErrorDto::from) +} + /// `delete_profile` — delete a profile by id. /// /// # Errors diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 19c8ab1..f760220 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -255,6 +255,9 @@ pub fn run() { commands::save_profile, commands::save_opencode_provider_profile, commands::list_opencode_providers, + commands::list_claude_models, + commands::list_codex_models, + commands::clone_profile_from_seed, commands::clone_opencode_profile_from_seed, commands::delete_profile, commands::configure_profiles, diff --git a/crates/app-tauri/tests/dto_profiles.rs b/crates/app-tauri/tests/dto_profiles.rs index 1615d28..81cb35e 100644 --- a/crates/app-tauri/tests/dto_profiles.rs +++ b/crates/app-tauri/tests/dto_profiles.rs @@ -4,15 +4,17 @@ use app_tauri_lib::dto::{ parse_delete_profile, parse_profile_id, CloneOpenCodeProfileFromSeedRequestDto, - ConfigureProfilesRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, - FirstRunStateDto, ProfileListDto, SaveProfileRequestDto, + CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, DetectProfilesRequestDto, + DetectProfilesResponseDto, FirstRunStateDto, ProfileListDto, ProfileModelCatalogDto, + SaveProfileRequestDto, }; use application::{ - CloneOpenCodeProfileFromSeedInput, ConfigureProfilesInput, DetectProfilesInput, - DetectProfilesOutput, FirstRunStateOutput, ProfileAvailability, SaveProfileInput, + CloneOpenCodeProfileFromSeedInput, CloneProfileFromSeedInput, ConfigureProfilesInput, + DetectProfilesInput, DetectProfilesOutput, FirstRunStateOutput, ProfileAvailability, + SaveProfileInput, }; use domain::ids::{LocalModelServerId, ProfileId}; -use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig}; +use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter}; use serde_json::json; use uuid::Uuid; @@ -121,6 +123,41 @@ fn clone_opencode_profile_from_seed_request_deserialises_camelcase_config() { assert_eq!(opencode.local_model_server_id, Some(server_id)); } +#[test] +fn clone_profile_from_seed_request_deserialises_camelcase_overrides() { + let seed_id = Uuid::from_u128(42); + let raw = json!({ + "seedProfileId": seed_id.to_string(), + "name": "Codex GPT-5", + "model": "gpt-5-codex" + }); + + let dto: CloneProfileFromSeedRequestDto = serde_json::from_value(raw).unwrap(); + let input: CloneProfileFromSeedInput = dto.into(); + assert_eq!(input.seed_profile_id, ProfileId::from_uuid(seed_id)); + assert_eq!(input.name.as_deref(), Some("Codex GPT-5")); + assert_eq!(input.model.as_deref(), Some("gpt-5-codex")); +} + +#[test] +fn profile_model_catalogue_dto_serialises_searchable_camelcase_entries() { + let dto = ProfileModelCatalogDto(vec![app_tauri_lib::dto::ProfileModelCatalogEntryDto { + adapter: StructuredAdapter::Codex, + model_id: "gpt-5-codex".to_owned(), + display_name: "GPT-5 Codex".to_owned(), + aliases: vec!["codex".to_owned()], + recommended: true, + }]); + + let value = serde_json::to_value(&dto).unwrap(); + let arr = value.as_array().expect("transparent array"); + assert_eq!(arr[0]["adapter"], "codex"); + assert_eq!(arr[0]["modelId"], "gpt-5-codex"); + assert_eq!(arr[0]["displayName"], "GPT-5 Codex"); + assert_eq!(arr[0]["aliases"], json!(["codex"])); + assert_eq!(arr[0]["recommended"], true); +} + #[test] fn opencode_config_dto_omits_local_model_server_id_when_none() { let config = OpenCodeConfig::new( diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 9035c68..5dbb49f 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -9,6 +9,7 @@ mod catalogue; mod inspect; mod lifecycle; +mod model_catalogue; mod provider_catalogue; mod resume; mod session_limit; @@ -39,6 +40,10 @@ pub use lifecycle::{ StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX, }; +pub use model_catalogue::{ + claude_model_catalogue, codex_model_catalogue, ListClaudeModels, ListClaudeModelsOutput, + ListCodexModels, ListCodexModelsOutput, ProfileModelCatalogEntry, +}; pub use provider_catalogue::{ opencode_models_cache_path, opencode_provider_catalogue, ListOpenCodeProviders, ListOpenCodeProvidersOutput, OpenCodeProviderCatalogEntry, @@ -48,10 +53,11 @@ pub use resume::{ }; pub use usecases::{ CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput, - CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, - ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles, - DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles, - ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput, - SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, - SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput, + CloneOpenCodeProfileFromSeedOutput, CloneProfileFromSeed, CloneProfileFromSeedInput, + CloneProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, + DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, + FirstRunState, FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability, + ReferenceProfiles, ReferenceProfilesOutput, SaveOpenCodeProviderProfile, + SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfile, + SaveProfileInput, SaveProfileOutput, }; diff --git a/crates/application/src/agent/model_catalogue.rs b/crates/application/src/agent/model_catalogue.rs new file mode 100644 index 0000000..f4d2fb1 --- /dev/null +++ b/crates/application/src/agent/model_catalogue.rs @@ -0,0 +1,183 @@ +//! Static curated model catalogues for structured Claude/Codex profiles. +//! +//! The CLIs do not expose a stable machine-readable model catalogue. These lists +//! are therefore intentionally small, static and infallible; the UI must still +//! keep manual entry as a fallback for models not listed here. + +use domain::profile::StructuredAdapter; + +/// One searchable model entry for a structured profile adapter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProfileModelCatalogEntry { + /// Structured adapter this model belongs to. + pub adapter: StructuredAdapter, + /// Exact model identifier to persist on [`domain::profile::AgentProfile::model`]. + pub model_id: String, + /// Human-readable label for picker display. + pub display_name: String, + /// Extra search tokens useful to the frontend. + pub aliases: Vec, + /// Whether this entry is the conservative default suggestion. + pub recommended: bool, +} + +fn entry( + adapter: StructuredAdapter, + model_id: &str, + display_name: &str, + aliases: &[&str], + recommended: bool, +) -> ProfileModelCatalogEntry { + ProfileModelCatalogEntry { + adapter, + model_id: model_id.to_owned(), + display_name: display_name.to_owned(), + aliases: aliases.iter().map(|alias| (*alias).to_owned()).collect(), + recommended, + } +} + +/// Static Claude Code model catalogue. +#[must_use] +pub fn claude_model_catalogue() -> Vec { + vec![ + entry( + StructuredAdapter::Claude, + "claude-sonnet-5", + "Claude Sonnet 5", + &["sonnet"], + true, + ), + entry( + StructuredAdapter::Claude, + "claude-opus-4-8", + "Claude Opus 4.8", + &["opus"], + false, + ), + entry( + StructuredAdapter::Claude, + "claude-haiku-4-5-20251001", + "Claude Haiku 4.5", + &["haiku"], + false, + ), + ] +} + +/// Static OpenAI Codex CLI model catalogue. +#[must_use] +pub fn codex_model_catalogue() -> Vec { + vec![ + entry( + StructuredAdapter::Codex, + "gpt-5-codex", + "GPT-5 Codex", + &["codex"], + true, + ), + entry( + StructuredAdapter::Codex, + "gpt-5", + "GPT-5", + &["general"], + false, + ), + entry( + StructuredAdapter::Codex, + "gpt-5-mini", + "GPT-5 mini", + &["mini", "fast"], + false, + ), + ] +} + +/// Use case exposing the static Claude model catalogue. +pub struct ListClaudeModels; + +/// Output of [`ListClaudeModels::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListClaudeModelsOutput { + /// The catalogue entries. + pub models: Vec, +} + +impl ListClaudeModels { + /// Builds the use case (stateless, no ports to inject). + #[must_use] + pub const fn new() -> Self { + Self + } + + /// Lists curated Claude models. Infallible. + #[must_use] + pub fn execute(&self) -> ListClaudeModelsOutput { + ListClaudeModelsOutput { + models: claude_model_catalogue(), + } + } +} + +impl Default for ListClaudeModels { + fn default() -> Self { + Self::new() + } +} + +/// Use case exposing the static Codex model catalogue. +pub struct ListCodexModels; + +/// Output of [`ListCodexModels::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListCodexModelsOutput { + /// The catalogue entries. + pub models: Vec, +} + +impl ListCodexModels { + /// Builds the use case (stateless, no ports to inject). + #[must_use] + pub const fn new() -> Self { + Self + } + + /// Lists curated Codex models. Infallible. + #[must_use] + pub fn execute(&self) -> ListCodexModelsOutput { + ListCodexModelsOutput { + models: codex_model_catalogue(), + } + } +} + +impl Default for ListCodexModels { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn static_catalogues_are_non_empty_searchable_and_have_one_default() { + for (adapter, models) in [ + (StructuredAdapter::Claude, claude_model_catalogue()), + (StructuredAdapter::Codex, codex_model_catalogue()), + ] { + assert!(!models.is_empty()); + assert_eq!( + models.iter().filter(|model| model.recommended).count(), + 1, + "{adapter:?} should expose one default suggestion" + ); + for model in models { + assert_eq!(model.adapter, adapter); + assert!(!model.model_id.trim().is_empty()); + assert!(!model.display_name.trim().is_empty()); + } + } + } +} diff --git a/crates/application/src/agent/usecases.rs b/crates/application/src/agent/usecases.rs index 5cfd809..8cd82fa 100644 --- a/crates/application/src/agent/usecases.rs +++ b/crates/application/src/agent/usecases.rs @@ -146,6 +146,92 @@ pub struct SaveProfileOutput { pub profile: AgentProfile, } +// --------------------------------------------------------------------------- +// CloneProfileFromSeed +// --------------------------------------------------------------------------- + +/// Input for [`CloneProfileFromSeed::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CloneProfileFromSeedInput { + /// Id of the persisted or reference profile to clone. + pub seed_profile_id: ProfileId, + /// Optional display name for the cloned profile. When absent, a copy label is + /// derived from the seed name. + pub name: Option, + /// Optional model override. When absent, the seed model is copied as-is. + pub model: Option, +} + +/// Output of [`CloneProfileFromSeed::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CloneProfileFromSeedOutput { + /// The newly persisted profile. + pub profile: AgentProfile, +} + +/// Creates a new profile instance from an existing persisted/reference seed. +/// +/// Persisted profiles are preferred over reference seeds so user edits to the +/// seed are preserved. The clone always receives a fresh [`ProfileId`] from the +/// backend [`IdGenerator`]; callers can override the display name and model +/// without minting ids client-side. +pub struct CloneProfileFromSeed { + store: Arc, + ids: Arc, +} + +impl CloneProfileFromSeed { + /// Builds the use case from the profile store and id generator ports. + #[must_use] + pub fn new(store: Arc, ids: Arc) -> Self { + Self { store, ids } + } + + /// Clones the requested seed into a new persisted profile. + /// + /// # Errors + /// [`AppError::NotFound`] if no persisted/reference profile has the seed id, + /// [`AppError::Invalid`] if `name` or `model` is blank, [`AppError::Store`] + /// on persistence failure. + pub async fn execute( + &self, + input: CloneProfileFromSeedInput, + ) -> Result { + let existing = self.store.list().await?; + let seed = existing + .iter() + .find(|profile| profile.id == input.seed_profile_id) + .cloned() + .or_else(|| { + reference_profiles() + .into_iter() + .find(|profile| profile.id == input.seed_profile_id) + }) + .ok_or(AppError::NotFound("profile seed not found".into()))?; + + let mut profile = seed; + profile.id = fresh_profile_id(&*self.ids, &existing)?; + profile.name = match input.name { + Some(name) => { + if name.trim().is_empty() { + return Err(AppError::Invalid("profile.name must not be empty".into())); + } + name + } + None => format!("{} copy", profile.name), + }; + if let Some(model) = input.model { + if model.trim().is_empty() { + return Err(AppError::Invalid("profile.model must not be empty".into())); + } + profile.model = Some(model); + } + + self.store.save(&profile).await?; + Ok(CloneProfileFromSeedOutput { profile }) + } +} + // --------------------------------------------------------------------------- // CloneOpenCodeProfileFromSeed // --------------------------------------------------------------------------- diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 851181c..307c821 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -45,16 +45,18 @@ pub use agent::{ reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer, AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput, - CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, - ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, - DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles, - DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider, - InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput, - LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, - ListAgentsOutput, ListOpenCodeProviders, ListOpenCodeProvidersOutput, ListProfiles, - ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, - LiveStateLeanProvider, McpRuntime, OpenCodeProviderCatalogEntry, PermissionProjectorRegistry, - ProfileAvailability, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, + CloneOpenCodeProfileFromSeedOutput, CloneProfileFromSeed, CloneProfileFromSeedInput, + CloneProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, + CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, + DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, + FirstRunState, FirstRunStateOutput, HandoffProvider, InjectedLiveRow, InspectConversation, + InspectConversationInput, InspectConversationOutput, LaunchAgent, LaunchAgentInput, + LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListClaudeModels, + ListClaudeModelsOutput, ListCodexModels, ListCodexModelsOutput, ListOpenCodeProviders, + ListOpenCodeProvidersOutput, ListProfiles, ListProfilesOutput, ListResumableAgents, + ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime, + OpenCodeProviderCatalogEntry, PermissionProjectorRegistry, ProfileAvailability, + ProfileModelCatalogEntry, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput, diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index 9db2a6c..0986e9f 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -24,9 +24,10 @@ use domain::profile::{ use domain::project::ProjectPath; use application::{ - reference_profile_id, reference_profiles, CloneOpenCodeProfileFromSeed, - CloneOpenCodeProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile, - DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, ListProfiles, + reference_profile_id, reference_profiles, AppError, CloneOpenCodeProfileFromSeed, + CloneOpenCodeProfileFromSeedInput, CloneProfileFromSeed, CloneProfileFromSeedInput, + ConfigureProfiles, ConfigureProfilesInput, DeleteProfile, DeleteProfileInput, DetectProfiles, + DetectProfilesInput, FirstRunState, ListClaudeModels, ListCodexModels, ListProfiles, ReferenceProfiles, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS, }; @@ -869,6 +870,114 @@ async fn clone_opencode_profile_falls_back_to_catalogue_when_persisted_seed_is_n assert_eq!(out.profile.name, "OpenCode + llama.cpp copy"); } +#[tokio::test] +async fn clone_profile_from_seed_creates_codex_profile_with_fresh_id_and_model_override() { + let store = FakeProfileStore::default(); + let clone = CloneProfileFromSeed::new( + Arc::new(store.clone()), + Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3801)])), + ); + + let out = clone + .execute(CloneProfileFromSeedInput { + seed_profile_id: reference_profile_id("codex"), + name: Some("Codex GPT-5".to_owned()), + model: Some("gpt-5-codex".to_owned()), + }) + .await + .unwrap(); + + assert_eq!( + out.profile.id, + ProfileId::from_uuid(uuid::Uuid::from_u128(3801)) + ); + assert_eq!(out.profile.name, "Codex GPT-5"); + assert_eq!(out.profile.model.as_deref(), Some("gpt-5-codex")); + assert_eq!( + out.profile.structured_adapter, + Some(StructuredAdapter::Codex) + ); + assert_eq!(store.0.lock().unwrap().profiles, vec![out.profile]); +} + +#[tokio::test] +async fn clone_profile_from_seed_prefers_persisted_seed_and_preserves_model_by_default() { + let store = FakeProfileStore::default(); + let persisted = reference_profiles() + .into_iter() + .find(|profile| profile.id == reference_profile_id("claude")) + .expect("seed exists") + .with_model("claude-opus-4-8"); + SaveProfile::new(Arc::new(store.clone())) + .execute(SaveProfileInput { + profile: persisted.clone(), + }) + .await + .unwrap(); + + let clone = CloneProfileFromSeed::new( + Arc::new(store.clone()), + Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3802)])), + ); + let out = clone + .execute(CloneProfileFromSeedInput { + seed_profile_id: persisted.id, + name: None, + model: None, + }) + .await + .unwrap(); + + assert_eq!(out.profile.name, "Claude Code copy"); + assert_eq!(out.profile.model.as_deref(), Some("claude-opus-4-8")); + assert_eq!( + out.profile.structured_adapter, + Some(StructuredAdapter::Claude) + ); + assert_ne!(out.profile.id, persisted.id); + assert_eq!(store.0.lock().unwrap().profiles.len(), 2); +} + +#[tokio::test] +async fn clone_profile_from_seed_rejects_blank_model_override() { + let store = FakeProfileStore::default(); + let clone = CloneProfileFromSeed::new( + Arc::new(store), + Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3803)])), + ); + + let err = clone + .execute(CloneProfileFromSeedInput { + seed_profile_id: reference_profile_id("claude"), + name: Some("Claude blank".to_owned()), + model: Some(" ".to_owned()), + }) + .await + .unwrap_err(); + + assert!(matches!(err, AppError::Invalid(_))); +} + +#[tokio::test] +async fn clone_profile_from_seed_rejects_blank_name_override() { + let store = FakeProfileStore::default(); + let clone = CloneProfileFromSeed::new( + Arc::new(store), + Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(3804)])), + ); + + let err = clone + .execute(CloneProfileFromSeedInput { + seed_profile_id: reference_profile_id("codex"), + name: Some(" ".to_owned()), + model: Some("gpt-5-codex".to_owned()), + }) + .await + .unwrap_err(); + + assert!(matches!(err, AppError::Invalid(_))); +} + // --------------------------------------------------------------------------- // ReferenceProfiles / catalogue // --------------------------------------------------------------------------- @@ -1049,3 +1158,22 @@ fn catalogue_gemini_and_aider_stay_pty_without_adapter() { assert_eq!(by_command["gemini"].structured_adapter, None); assert_eq!(by_command["aider"].structured_adapter, None); } + +#[test] +fn claude_and_codex_model_catalogues_are_static_and_searchable() { + let claude = ListClaudeModels::new().execute().models; + let codex = ListCodexModels::new().execute().models; + + assert!(claude + .iter() + .any(|model| model.model_id == "claude-sonnet-5" && model.recommended)); + assert!(codex + .iter() + .any(|model| model.model_id == "gpt-5-codex" && model.recommended)); + assert!(claude + .iter() + .all(|model| model.adapter == StructuredAdapter::Claude && !model.display_name.is_empty())); + assert!(codex + .iter() + .all(|model| model.adapter == StructuredAdapter::Codex && !model.display_name.is_empty())); +} diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 258065a..5da2b89 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -1047,6 +1047,57 @@ impl From for ProfileDto { } } +impl From for ProfileDto { + fn from(out: application::CloneProfileFromSeedOutput) -> Self { + Self(out.profile) + } +} + +/// One entry of a curated structured-profile model catalogue. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileModelCatalogEntryDto { + /// Structured adapter this model belongs to. + pub adapter: domain::profile::StructuredAdapter, + /// Exact model identifier to persist on `AgentProfile.model`. + pub model_id: String, + /// Human-readable label for picker display. + pub display_name: String, + /// Extra search tokens useful to the frontend. + pub aliases: Vec, + /// Whether this entry is the conservative default suggestion. + pub recommended: bool, +} + +impl From for ProfileModelCatalogEntryDto { + fn from(entry: application::ProfileModelCatalogEntry) -> Self { + Self { + adapter: entry.adapter, + model_id: entry.model_id, + display_name: entry.display_name, + aliases: entry.aliases, + recommended: entry.recommended, + } + } +} + +/// A list of curated structured-profile models. +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct ProfileModelCatalogDto(pub Vec); + +impl From for ProfileModelCatalogDto { + fn from(out: application::ListClaudeModelsOutput) -> Self { + Self(out.models.into_iter().map(Into::into).collect()) + } +} + +impl From for ProfileModelCatalogDto { + fn from(out: application::ListCodexModelsOutput) -> Self { + Self(out.models.into_iter().map(Into::into).collect()) + } +} + /// One entry of the static OpenCode cloud-provider catalogue (ticket #92, lot B3). #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -1202,6 +1253,30 @@ impl From for CloneOpenCodeProfileFromSe } } +/// Request DTO for `clone_profile_from_seed`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloneProfileFromSeedRequestDto { + /// Id of the persisted or reference profile to clone. + pub seed_profile_id: domain::ids::ProfileId, + /// Optional display name for the new profile. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Optional model override. When omitted, the seed model is copied. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +impl From for application::CloneProfileFromSeedInput { + fn from(dto: CloneProfileFromSeedRequestDto) -> Self { + Self { + seed_profile_id: dto.seed_profile_id, + name: dto.name, + model: dto.model, + } + } +} + /// Request DTO for `configure_profiles` (closes the first run). #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 5526b71..95f4711 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -15,24 +15,24 @@ use application::{ AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent, AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive, CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion, - CloneOpenCodeProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant, - ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, - CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint, - CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory, - DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, + CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal, + CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, + CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, + CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, + DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, - LinkIssues, ListAgents, ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues, - ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders, - ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents, - ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, - LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue, - MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, - OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, + LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices, + ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, + ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, + ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, + LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, + McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, + OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, @@ -940,6 +940,12 @@ pub struct BackendCore { pub save_opencode_provider_profile: Arc, /// Static catalogue of OpenCode cloud providers (ticket #92, lot B3). pub list_opencode_providers: Arc, + /// Static curated Claude model catalogue. + pub list_claude_models: Arc, + /// Static curated Codex model catalogue. + pub list_codex_models: Arc, + /// Create a new profile instance from a persisted/reference seed. + pub clone_profile_from_seed: Arc, /// Create a new OpenCode profile instance from the canonical seed. pub clone_opencode_profile_from_seed: Arc, /// Delete a profile. @@ -1468,6 +1474,12 @@ impl BackendCore { Arc::clone(&ids) as Arc, )); let list_opencode_providers = Arc::new(ListOpenCodeProviders::new()); + let list_claude_models = Arc::new(ListClaudeModels::new()); + let list_codex_models = Arc::new(ListCodexModels::new()); + let clone_profile_from_seed = Arc::new(CloneProfileFromSeed::new( + Arc::clone(&profile_store_port), + Arc::clone(&ids) as Arc, + )); let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new( Arc::clone(&profile_store_port), Arc::clone(&ids) as Arc, @@ -2661,6 +2673,9 @@ impl BackendCore { save_profile, save_opencode_provider_profile, list_opencode_providers, + list_claude_models, + list_codex_models, + clone_profile_from_seed, clone_opencode_profile_from_seed, delete_profile, configure_profiles, diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index c2c7dfc..c44c5e0 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -42,6 +42,7 @@ import type { ProjectWorkState, ProjectSystemPermissions, ProfileAvailability, + ProfileModelCatalogEntry, ResolvedAgentSystemPermissions, SystemPermissionSet, Skill, @@ -50,6 +51,7 @@ import type { TurnPage, } from "@/domain"; import type { + CloneProfileFromSeedInput, CloneOpenCodeProfileFromSeedInput, ConversationGateway, ConversationPageRequest, @@ -176,6 +178,17 @@ export class HttpProfileGateway implements ProfileGateway { async deleteProfile(profileId: string): Promise { await this.http.invoke("delete_profile", { profileId }); } + cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise { + return this.http.invoke("clone_profile_from_seed", { + request: { seedProfileId: input.seedProfileId, name: input.name, model: input.model }, + }); + } + listClaudeModels(): Promise { + return this.http.invoke("list_claude_models"); + } + listCodexModels(): Promise { + return this.http.invoke("list_codex_models"); + } configureProfiles(profiles: AgentProfile[]): Promise { return this.http.invoke("configure_profiles", { request: { profiles } }); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 153a036..31b62c7 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -35,6 +35,7 @@ import type { McpToolCatalogue, McpToolPolicy, OpenCodeProviderCatalogEntry, + ProfileModelCatalogEntry, EffectivePermissions, PairedDevice, PairingCode, @@ -80,6 +81,7 @@ import type { ConversationGateway, ConversationPageRequest, ConversationDetails, + CloneProfileFromSeedInput, CloneOpenCodeProfileFromSeedInput, CreateAgentInput, CreateMemoryInput, @@ -1293,6 +1295,54 @@ const MOCK_OPENCODE_PROVIDERS: OpenCodeProviderCatalogEntry[] = [ }, ]; +const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [ + { + adapter: "claude", + modelId: "claude-sonnet-5", + displayName: "Claude Sonnet 5", + aliases: ["sonnet"], + recommended: true, + }, + { + adapter: "claude", + modelId: "claude-opus-4-8", + displayName: "Claude Opus 4.8", + aliases: ["opus"], + recommended: false, + }, + { + adapter: "claude", + modelId: "claude-haiku-4-5-20251001", + displayName: "Claude Haiku 4.5", + aliases: ["haiku"], + recommended: false, + }, +]; + +const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [ + { + adapter: "codex", + modelId: "gpt-5-codex", + displayName: "GPT-5 Codex", + aliases: ["codex"], + recommended: true, + }, + { + adapter: "codex", + modelId: "gpt-5", + displayName: "GPT-5", + aliases: ["general"], + recommended: false, + }, + { + adapter: "codex", + modelId: "gpt-5-mini", + displayName: "GPT-5 mini", + aliases: ["mini", "fast"], + recommended: false, + }, +]; + /** * In-memory profiles gateway. Tracks configured profiles and a first-run flag so * the wizard can be driven and tested fully offline. By default it reports the @@ -1348,6 +1398,36 @@ export class MockProfileGateway implements ProfileGateway { return structuredClone(profiles); } + async cloneProfileFromSeed( + input: CloneProfileFromSeedInput, + ): Promise { + const seed = [...this.profiles, ...MOCK_REFERENCE_PROFILES].find( + (p) => p.id === input.seedProfileId, + ); + if (!seed) throw new Error(`unknown profile seed: ${input.seedProfileId}`); + this.cloneCounter += 1; + const cloned: AgentProfile = { + ...structuredClone(seed), + id: `mock-profile-clone-${this.cloneCounter}`, + name: input.name ?? `${seed.name} copy`, + model: + input.model !== undefined && input.model.trim() !== "" + ? input.model + : seed.model, + }; + this.profiles.push(cloned); + this.configured = true; + return structuredClone(cloned); + } + + async listClaudeModels(): Promise { + return structuredClone(MOCK_CLAUDE_MODELS); + } + + async listCodexModels(): Promise { + return structuredClone(MOCK_CODEX_MODELS); + } + async cloneOpenCodeProfileFromSeed( input: CloneOpenCodeProfileFromSeedInput = {}, ): Promise { diff --git a/frontend/src/adapters/profile.ts b/frontend/src/adapters/profile.ts index ea355ed..b68cee5 100644 --- a/frontend/src/adapters/profile.ts +++ b/frontend/src/adapters/profile.ts @@ -12,9 +12,11 @@ import type { AgentProfile, FirstRunState, OpenCodeProviderCatalogEntry, + ProfileModelCatalogEntry, ProfileAvailability, } from "@/domain"; import type { + CloneProfileFromSeedInput, CloneOpenCodeProfileFromSeedInput, ProfileGateway, SaveOpenCodeProviderProfileInput, @@ -47,6 +49,24 @@ export class TauriProfileGateway implements ProfileGateway { await invoke("delete_profile", { profileId }); } + cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise { + return invoke("clone_profile_from_seed", { + request: { + seedProfileId: input.seedProfileId, + name: input.name, + model: input.model, + }, + }); + } + + listClaudeModels(): Promise { + return invoke("list_claude_models"); + } + + listCodexModels(): Promise { + return invoke("list_codex_models"); + } + configureProfiles(profiles: AgentProfile[]): Promise { return invoke("configure_profiles", { request: { profiles }, diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 936164a..7e69149 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1096,6 +1096,20 @@ export interface OpenCodeProviderCatalogEntry { models: string[]; } +/** One searchable model from the Codex/Claude structured-profile catalogues. */ +export interface ProfileModelCatalogEntry { + /** Structured adapter this model belongs to. */ + adapter: "claude" | "codex"; + /** Exact model identifier to persist on `AgentProfile.model`. */ + modelId: string; + /** Human-readable label for picker display. */ + displayName: string; + /** Extra search tokens useful to the frontend. */ + aliases: string[]; + /** Whether this entry is the conservative default suggestion. */ + recommended: boolean; +} + /** * A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a * UUID string; `detect` is the optional detection command line. diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index 1c12268..c9c78d8 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -237,6 +237,15 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { // Determine if a template is chosen → profile selector is hidden (template imposes it). const hasTemplate = newTemplateId !== ""; + const profileLabel = (profile: import("@/domain").AgentProfile): string => { + const model = + profile.model ?? + profile.opencode?.model ?? + profile.opencodeProvider?.model ?? + profile.chatHttp?.model; + return model ? `${profile.name} · ${model}` : profile.name; + }; + return ( {vm.error && ( @@ -326,7 +335,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { {vm.profiles.map((p) => ( ))} @@ -366,7 +375,10 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { const isRunning = a.id === activeAgentId; const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id); const profileName = - vm.profiles.find((p) => p.id === a.profileId)?.name ?? + (() => { + const p = vm.profiles.find((p) => p.id === a.profileId); + return p ? profileLabel(p) : null; + })() ?? a.profileId; const agentDrift = drift.driftByAgentId.get(a.id); // Source of this agent's last orchestration delegation (mcp vs @@ -478,7 +490,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { )} {vm.profiles.map((p) => ( ))} diff --git a/frontend/src/features/agents/agents.test.tsx b/frontend/src/features/agents/agents.test.tsx index c7f1232..f1d4338 100644 --- a/frontend/src/features/agents/agents.test.tsx +++ b/frontend/src/features/agents/agents.test.tsx @@ -141,6 +141,42 @@ describe("AgentsPanel (with MockAgentGateway)", () => { expect((btn as HTMLButtonElement).disabled).toBe(true); }); + it("shows profile names with their model in the assignment selector", async () => { + const profile = new MockProfileGateway(); + await profile.saveProfile({ + id: "codex-fast", + name: "Codex fast", + command: "codex", + args: [], + contextInjection: { strategy: "conventionFile", target: "AGENTS.md" }, + detect: "codex --version", + cwdTemplate: "{projectRoot}", + structuredAdapter: "codex", + model: "gpt-5-mini", + }); + await profile.saveProfile({ + id: "claude-opus", + name: "Claude deep", + command: "claude", + args: [], + contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" }, + detect: "claude --version", + cwdTemplate: "{projectRoot}", + structuredAdapter: "claude", + model: "claude-opus-4-8", + }); + + renderPanel(new MockAgentGateway(), profile); + await waitForIdle(); + + const labels = Array.from( + screen.getByLabelText("agent profile").querySelectorAll("option"), + ).map((option) => option.textContent); + + expect(labels).toContain("Codex fast · gpt-5-mini"); + expect(labels).toContain("Claude deep · claude-opus-4-8"); + }); + it("selecting an agent displays its context", async () => { const agent = new MockAgentGateway(); // Pre-seed an agent with initial content. diff --git a/frontend/src/features/first-run/ProfilesSettings.test.tsx b/frontend/src/features/first-run/ProfilesSettings.test.tsx new file mode 100644 index 0000000..75a50fb --- /dev/null +++ b/frontend/src/features/first-run/ProfilesSettings.test.tsx @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; + +import { DIProvider } from "@/app/di"; +import { MockProfileGateway } from "@/adapters/mock"; +import type { Gateways } from "@/ports"; +import type { ProfileModelCatalogEntry } from "@/domain"; +import { ProfilesSettings } from "./ProfilesSettings"; + +function renderSettings(profile: MockProfileGateway = new MockProfileGateway()) { + return { + profile, + ...render( + + + , + ), + }; +} + +async function waitReady() { + await waitFor(() => + expect( + (screen.getByRole("button", { name: "Creer un profil" }) as HTMLButtonElement) + .disabled, + ).toBe(false), + ); +} + +async function createProfile() { + const before = screen.queryAllByRole("listitem").length; + fireEvent.click(screen.getByRole("button", { name: "Creer un profil" })); + await waitFor(() => expect(screen.getAllByRole("listitem")).toHaveLength(before + 1)); +} + +describe("ProfilesSettings", () => { + it("creates multiple named Codex and Claude profiles with different models", async () => { + const { profile } = renderSettings(); + await waitReady(); + + await createProfile(); + await createProfile(); + let rows = screen.getAllByRole("listitem"); + fireEvent.change(within(rows[1]).getByLabelText(/nom du profil/), { + target: { value: "Codex mini" }, + }); + fireEvent.change(within(rows[1]).getByLabelText(/modele du profil/), { + target: { value: "gpt-5-mini" }, + }); + fireEvent.click(within(rows[1]).getByRole("button", { name: "Enregistrer" })); + + fireEvent.click(screen.getByRole("tab", { name: "Claude" })); + await waitReady(); + await createProfile(); + await createProfile(); + rows = screen.getAllByRole("listitem"); + fireEvent.change(within(rows[1]).getByLabelText(/nom du profil/), { + target: { value: "Claude Opus" }, + }); + fireEvent.change(within(rows[1]).getByLabelText(/modele du profil/), { + target: { value: "claude-opus-4-8" }, + }); + fireEvent.click(within(rows[1]).getByRole("button", { name: "Enregistrer" })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + expect(saved.filter((p) => p.structuredAdapter === "codex")).toHaveLength(2); + expect(saved.filter((p) => p.structuredAdapter === "claude")).toHaveLength(2); + expect(saved.map((p) => p.model)).toEqual( + expect.arrayContaining([ + "gpt-5-codex", + "gpt-5-mini", + "claude-sonnet-5", + "claude-opus-4-8", + ]), + ); + }); + }); + + it("duplicates from an existing profile with ' copy' and preserves the model", async () => { + const { profile } = renderSettings(); + await waitReady(); + await createProfile(); + + const row = screen.getAllByRole("listitem")[0]; + fireEvent.click(within(row).getByRole("button", { name: "Dupliquer" })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + expect(saved.some((p) => p.name === "OpenAI Codex CLI copy copy")).toBe(true); + expect(saved.filter((p) => p.model === "gpt-5-codex")).toHaveLength(2); + }); + }); + + it("keeps manual model entry available when the catalogue fails", async () => { + class CatalogueDownProfileGateway extends MockProfileGateway { + listCodexModels(): Promise { + return Promise.reject(new Error("catalogue down")); + } + } + + renderSettings(new CatalogueDownProfileGateway()); + await waitReady(); + expect(await screen.findByText(/saisie manuelle active/)).toBeTruthy(); + + await createProfile(); + const model = within(screen.getAllByRole("listitem")[0]).getByLabelText( + /modele du profil/, + ) as HTMLInputElement; + fireEvent.change(model, { target: { value: "future-codex-model" } }); + expect(model.value).toBe("future-codex-model"); + }); +}); diff --git a/frontend/src/features/first-run/ProfilesSettings.tsx b/frontend/src/features/first-run/ProfilesSettings.tsx index 7a2d1d9..ab601d0 100644 --- a/frontend/src/features/first-run/ProfilesSettings.tsx +++ b/frontend/src/features/first-run/ProfilesSettings.tsx @@ -1,34 +1,94 @@ /** - * Minimal "Settings → AI Profiles" panel (L5). An always-available entry point - * to review the configured profiles and re-run the setup wizard after the first - * run. Kept intentionally small; richer per-profile editing reuses the wizard. - * - * Pure presentation over the {@link ProfileGateway} port (no `invoke()`). + * Settings -> AI Profiles. This is the durable CRUD surface for named runtime + * profiles; first-run stays a small default-profile bootstrap. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; -import type { AgentProfile, GatewayError } from "@/domain"; +import type { + AgentProfile, + GatewayError, + ProfileModelCatalogEntry, +} from "@/domain"; import { useGateways } from "@/app/di"; -import { Button, Panel } from "@/shared"; -import { FirstRunWizard } from "./FirstRunWizard"; +import { Button, Input, Panel, cn } from "@/shared"; + +type ProfileTab = "codex" | "claude" | "openCode"; + +const TABS: Array<{ id: ProfileTab; label: string }> = [ + { id: "codex", label: "Codex" }, + { id: "claude", label: "Claude" }, + { id: "openCode", label: "OpenCode-local" }, +]; + +const EMPTY_CATALOGUE: Record<"codex" | "claude", ProfileModelCatalogEntry[]> = { + codex: [], + claude: [], +}; + +function describe(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as GatewayError).message); + } + return String(e); +} + +function tabFor(profile: AgentProfile): ProfileTab | null { + if (profile.structuredAdapter === "codex") return "codex"; + if (profile.structuredAdapter === "claude") return "claude"; + if (profile.structuredAdapter === "openCode" && profile.opencode) { + return "openCode"; + } + return null; +} + +function modelOf(profile: AgentProfile): string { + if (profile.structuredAdapter === "openCode") { + return profile.opencode?.model ?? profile.opencodeProvider?.model ?? ""; + } + return profile.model ?? ""; +} + +function withModel(profile: AgentProfile, model: string): AgentProfile { + const nextModel = model.trim() || undefined; + if (profile.structuredAdapter === "openCode" && profile.opencode) { + return { + ...profile, + opencode: { ...profile.opencode, model: model.trim() }, + }; + } + return { ...profile, model: nextModel }; +} + +function optionLabel(entry: ProfileModelCatalogEntry): string { + return entry.recommended + ? `${entry.displayName} (${entry.modelId}, recommande)` + : `${entry.displayName} (${entry.modelId})`; +} export function ProfilesSettings() { const { profile } = useGateways(); const [profiles, setProfiles] = useState([]); + const [references, setReferences] = useState([]); + const [catalogue, setCatalogue] = useState(EMPTY_CATALOGUE); + const [activeTab, setActiveTab] = useState("codex"); + const [drafts, setDrafts] = useState>({}); const [error, setError] = useState(null); - const [editing, setEditing] = useState(false); + const [catalogueWarning, setCatalogueWarning] = useState(null); + const [busy, setBusy] = useState(false); const refresh = useCallback(async () => { setError(null); try { - setProfiles(await profile.listProfiles()); + const [saved, refs] = await Promise.all([ + profile.listProfiles(), + profile.referenceProfiles(), + ]); + setProfiles(saved); + setReferences(refs); + setDrafts(Object.fromEntries(saved.map((p) => [p.id, p]))); } catch (e) { - setError( - e && typeof e === "object" && "message" in e - ? String((e as GatewayError).message) - : String(e), - ); + setError(describe(e)); } }, [profile]); @@ -36,64 +96,263 @@ export function ProfilesSettings() { void refresh(); }, [refresh]); - async function del(id: string) { - await profile.deleteProfile(id); - await refresh(); + useEffect(() => { + let cancelled = false; + async function loadCatalogue() { + setCatalogueWarning(null); + const [codex, claude] = await Promise.allSettled([ + profile.listCodexModels(), + profile.listClaudeModels(), + ]); + if (cancelled) return; + setCatalogue({ + codex: codex.status === "fulfilled" ? codex.value : [], + claude: claude.status === "fulfilled" ? claude.value : [], + }); + if (codex.status === "rejected" || claude.status === "rejected") { + setCatalogueWarning( + "Catalogue de modeles indisponible: saisie manuelle active.", + ); + } + } + void loadCatalogue(); + return () => { + cancelled = true; + }; + }, [profile]); + + const visibleProfiles = useMemo( + () => profiles.filter((p) => tabFor(p) === activeTab), + [profiles, activeTab], + ); + + const seed = useMemo( + () => references.find((p) => tabFor(p) === activeTab) ?? null, + [references, activeTab], + ); + + function updateDraft(id: string, updater: (profile: AgentProfile) => AgentProfile) { + setDrafts((prev) => { + const current = prev[id] ?? profiles.find((p) => p.id === id); + if (!current) return prev; + return { ...prev, [id]: updater(current) }; + }); } - if (editing) { - // Reopened after the first run, so force the wizard to render. - return ( - { - setEditing(false); - void refresh(); - }} - /> - ); + async function createFromSeed() { + if (!seed) return; + setBusy(true); + setError(null); + try { + const models = + activeTab === "codex" || activeTab === "claude" + ? catalogue[activeTab] + : []; + const recommended = models.find((m) => m.recommended)?.modelId; + await profile.cloneProfileFromSeed({ + seedProfileId: seed.id, + name: `${seed.name} copy`, + model: recommended, + }); + await refresh(); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } } + async function save(id: string) { + const draft = drafts[id]; + if (!draft) return; + setBusy(true); + setError(null); + try { + await profile.saveProfile(draft); + await refresh(); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + } + + async function duplicate(source: AgentProfile) { + setBusy(true); + setError(null); + try { + await profile.cloneProfileFromSeed({ + seedProfileId: source.id, + name: `${source.name} copy`, + model: + source.structuredAdapter === "codex" || + source.structuredAdapter === "claude" + ? modelOf(source) || undefined + : undefined, + }); + await refresh(); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + } + + async function del(source: AgentProfile) { + setBusy(true); + setError(null); + try { + await profile.deleteProfile(source.id); + await refresh(); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + } + + const modelOptions = + activeTab === "codex" || activeTab === "claude" ? catalogue[activeTab] : []; + return ( setEditing(true)}> - Configurer les profils + } > -
+
+
+ {TABS.map((tab) => ( + + ))} +
+ {error && (

{error}

)} + {catalogueWarning && ( +

{catalogueWarning}

+ )} - {profiles.length === 0 ? ( -

Aucun profil configuré.

+ + {modelOptions.map((entry) => ( + + ))} + + + {visibleProfiles.length === 0 ? ( +

+ Aucun profil {TABS.find((tab) => tab.id === activeTab)?.label} configure. +

) : ( -
    - {profiles.map((p) => ( -
  • - - {p.name} - {p.command} - - -
  • - ))} +
    + + + +
    + +
    + + {draft.command} + {model ? ` · ${model}` : ""} + + + + + + +
    + + ); + })}
)}
diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 790742c..cc4815f 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -37,6 +37,7 @@ import type { McpToolPolicy, OpenCodeConfig, OpenCodeProviderCatalogEntry, + ProfileModelCatalogEntry, EffectivePermissions, PairedDevice, PairingCode, @@ -664,6 +665,15 @@ export interface ProfileGateway { saveProfile(profile: AgentProfile): Promise; /** Deletes a profile by id. */ deleteProfile(profileId: string): Promise; + /** + * Clones a persisted or reference profile seed and saves the fresh profile. + * Used by Settings duplication for Codex/Claude/OpenCode identity copies. + */ + cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise; + /** Curated Claude Code model catalogue. Manual model entry remains supported. */ + listClaudeModels(): Promise; + /** Curated Codex CLI model catalogue. Manual model entry remains supported. */ + listCodexModels(): Promise; /** Persists the batch of chosen profiles, closing the first run. */ configureProfiles(profiles: AgentProfile[]): Promise; /** @@ -701,6 +711,16 @@ export interface CloneOpenCodeProfileFromSeedInput { opencode?: OpenCodeConfig; } +/** Input for {@link ProfileGateway.cloneProfileFromSeed}. */ +export interface CloneProfileFromSeedInput { + /** Id of the persisted or reference profile to clone. */ + seedProfileId: string; + /** Optional display name for the new profile. */ + name?: string; + /** Optional model override. When omitted, the seed model is copied. */ + model?: string; +} + /** Input for {@link ProfileGateway.saveOpenCodeProviderProfile}. */ export interface SaveOpenCodeProviderProfileInput { /** The profile to create or replace (by id). */ From e7bf1d366644114ad7bfaf8bca6841b18f52018c Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 26 Jul 2026 15:12:14 +0200 Subject: [PATCH 3/7] frontend: suppress network banner params unneeded after runtime lock refactor --- .../src/features/terminals/TerminalView.tsx | 43 ------------------- 1 file changed, 43 deletions(-) diff --git a/frontend/src/features/terminals/TerminalView.tsx b/frontend/src/features/terminals/TerminalView.tsx index 75b2eeb..6ef7b7f 100644 --- a/frontend/src/features/terminals/TerminalView.tsx +++ b/frontend/src/features/terminals/TerminalView.tsx @@ -39,7 +39,6 @@ import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; import { useGateways } from "@/app/di"; -import type { ResolvedAgentSystemPermissions } from "@/domain"; import type { OpenTerminalOptions, ReattachResult, @@ -114,8 +113,6 @@ interface TerminalViewProps { * it never remounts/reopens the terminal. */ refitSignal?: number; - /** Optional resolved system permissions for this agent/cell. */ - systemPermissions?: ResolvedAgentSystemPermissions | null; } /** @@ -147,7 +144,6 @@ export function TerminalView({ portal, onReady, refitSignal, - systemPermissions, }: TerminalViewProps) { const { terminal } = useGateways(); const containerRef = useRef(null); @@ -432,15 +428,6 @@ export function TerminalView({ refitRef.current?.(); }, [refitSignal]); - const showNetworkBanner = - systemPermissions != null && - (systemPermissions.runtimeLock.state === "locked" || - systemPermissions.effective === "deny"); - const networkReason = - systemPermissions?.runtimeLock.reason ?? - systemPermissions?.control.reason ?? - "Le réseau est interdit pour cette cellule."; - return (
{/* xterm mounts into this inner node; the error banner is a sibling so React never fights xterm over the same subtree. */} -<<<<<<< HEAD -
- {showNetworkBanner && ( -
- {systemPermissions.runtimeLock.state === "locked" - ? "Réseau verrouillé par le runtime." - : "Réseau interdit pour cet agent."}{" "} - {networkReason} -=======
Préparation du terminal… ->>>>>>> main
)} {openError && ( From ca70ec75f4acc061fbbf6e6c1b0dfadcc41f9e56 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 26 Jul 2026 16:09:10 +0200 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20catalogue=20dynamique=20mod=C3=A8le?= =?UTF-8?q?s=20Codex/Claude=20avec=20compatibilit=C3=A9=20CLI=20locale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout du catalogue enrichi pour les modèles Codex et Claude avec: - Compatibilité estimée avec la version CLI locale détectée - Source d'origine (catalogue/Provider) pour chaque entrée - Support du catalogue Provider API externe - Matrice de compatibilité embarquée dans l'application Frontend: - UI de configuration des modèles avec affichage des états de compatibilité - Suggestions dynamiques avec badges de compatibilité - Messages d'aide contextuels (compatible/unknown/likelyTooRecent) - Alertes non-bloquantes pour les modèles trop récents - Gestion des échecs de catalogue avec saisie manuelle conservée Backend: - Ports CliVersionReader, ProviderModelCatalogue, CompatibilityMatrixSource - Implémentations: ProcessCliVersionReader, HttpProviderModelCatalogue, EmbeddedCompatibilityMatrix - Enrichissement des DTOs avec compatibility, cli_version, warnings - Tests unitaires complets pour le resolver de catalogue --- .ideai/memory/MEMORY.md | 1 + .../memory/model-catalogue-compat-cadrage.md | 25 ++ crates/app-tauri/src/commands.rs | 8 +- crates/app-tauri/tests/dto_profiles.rs | 35 +- .../application/src/agent/model_catalogue.rs | 369 ++++++++++++++++-- crates/application/src/lib.rs | 10 +- crates/application/tests/profile_usecases.rs | 16 +- crates/backend/src/dto.rs | 31 +- crates/backend/src/lib.rs | 44 ++- crates/domain/src/lib.rs | 27 +- crates/domain/src/model_catalogue.rs | 216 ++++++++++ crates/domain/src/ports.rs | 34 +- crates/infrastructure/src/lib.rs | 4 + crates/infrastructure/src/model_catalogue.rs | 331 ++++++++++++++++ .../src/model_compatibility_matrix.json | 13 + crates/web-server/src/lib.rs | 38 +- .../adapters/http/requestResponseGateways.ts | 11 +- frontend/src/adapters/mock/index.ts | 29 +- frontend/src/adapters/profile.ts | 11 +- frontend/src/adapters/profileCatalog.test.ts | 56 +++ frontend/src/adapters/profileCatalog.ts | 63 +++ frontend/src/domain/index.ts | 22 ++ .../first-run/ProfilesSettings.test.tsx | 55 ++- .../features/first-run/ProfilesSettings.tsx | 290 ++++++++++++-- frontend/src/ports/index.ts | 10 +- 25 files changed, 1582 insertions(+), 167 deletions(-) create mode 100644 .ideai/memory/model-catalogue-compat-cadrage.md create mode 100644 crates/domain/src/model_catalogue.rs create mode 100644 crates/infrastructure/src/model_catalogue.rs create mode 100644 crates/infrastructure/src/model_compatibility_matrix.json create mode 100644 frontend/src/adapters/profileCatalog.test.ts create mode 100644 frontend/src/adapters/profileCatalog.ts diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 9895848..5d8a66b 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -71,3 +71,4 @@ - [ticket103-network-permission-ux-surface](ticket103-network-permission-ux-surface.md) — Stable UX convention for agent network permissions in IdeA. - [codex-network-access-config-fix](codex-network-access-config-fix.md) — memory note codex-network-access-config-fix - [multi-profile-codex-claude-model-catalogue-scoping](multi-profile-codex-claude-model-catalogue-scoping.md) — memory note multi-profile-codex-claude-model-catalogue-scoping +- [model-catalogue-compat-cadrage](model-catalogue-compat-cadrage.md) — Frontières hexagonales, ports, DTO, fallback et matrice de compatibilité versionnée pour l'évolution du catalogue de modèles des profils structurés Codex/Claude. diff --git a/.ideai/memory/model-catalogue-compat-cadrage.md b/.ideai/memory/model-catalogue-compat-cadrage.md new file mode 100644 index 0000000..6aa85c9 --- /dev/null +++ b/.ideai/memory/model-catalogue-compat-cadrage.md @@ -0,0 +1,25 @@ +--- +name: model-catalogue-compat-cadrage +description: Frontières hexagonales, ports, DTO, fallback et matrice de compatibilité versionnée pour l'évolution du catalogue de modèles des profils structurés Codex/Claude. +metadata: + type: reference +--- +Évolution de `ListClaudeModels`/`ListCodexModels` (catalogue statique `application/src/agent/model_catalogue.rs`) vers un catalogue enrichi par compatibilité CLI. + +**Décisions tranchées :** +- JAMAIS scraper les TUI `/model`, JAMAIS exécuter les CLIs pour énumérer les modèles. Seule exécution CLI autorisée : ` --version` (pattern existant `infrastructure/runtime::detection_spec` + port `ProcessSpawner`). Saisie libre toujours ouverte. +- 3 sources non bloquantes à dégradation indépendante : API provider `/v1/models` (best-effort, uniquement si clé env/SecretStore présente, sinon skip), catalogue statique seed, matrice de compat. + +**Hexagonal :** +- Domaine (pur) : VO `CliVersion` (Ord), enum `ModelCompatibility {Compatible|Unknown|LikelyTooRecent}` (miroir des 3 états produit), VO `CompatibilityMatrix` (forme seule), fonction pure `evaluate_compatibility(matrix, adapter, model_id, Option)` — version None ⇒ Unknown, modèle absent ⇒ Unknown, min<=ver ⇒ Compatible, min>ver ⇒ LikelyTooRecent. +- Nouveaux ports : `CliVersionReader`, `ProviderModelCatalogue` (Ok(vec![]) si pas de clé), `CompatibilityMatrixSource` (infaillible). +- Application : use case unique `ResolveModelCatalogue{adapter}` async, jamais de hard-error sur échec source (warnings + fallback). `ListClaude/CodexModels` deviennent des façades. +- Infra : `ProcessCliVersionReader`, `HttpProviderModelCatalogue` (reqwest), `EmbeddedCompatibilityMatrix`. + +**Matrice de compat = DONNÉE, pas code** : JSON versionné maintenu dans IdeA, bundlé via `include_str!` (seed infaillible) + override optionnel `app_data_dir/IdeA/model-compat.json`. Ajouter un modèle = éditer le JSON, zéro code (Open/Closed). + +**DTO (rupture front)** : `ProfileModelCatalogDto` passe de `transparent Vec` à `{ models:[{...,compatibility,source}], cliVersion:string|null, warnings:string[] }`. Répercuter ports TS + 2 adapters + mock + ProfilesSettings. + +**Découpage** : B1 domaine pur, B2 use case ports mockés, B3 infra ; F1 contrat, F2 ProfilesSettings 3 badges. UX passe avant F2 (libellés des 3 états, warnings, cliVersion null). + +**Point ouvert produit** : récup clé provider — proposé best-effort sur clé env/SecretStore existante, pas de prompt dédié. \ No newline at end of file diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index c14d689..82fe758 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -1189,20 +1189,20 @@ pub async fn list_opencode_providers( Ok(state.list_opencode_providers.execute().into()) } -/// `list_claude_models` — static curated Claude model catalogue. +/// `list_claude_models` — enriched Claude model catalogue. #[tauri::command] pub async fn list_claude_models( state: State<'_, AppState>, ) -> Result { - Ok(state.list_claude_models.execute().into()) + Ok(state.list_claude_models.execute().await.into()) } -/// `list_codex_models` — static curated Codex model catalogue. +/// `list_codex_models` — enriched Codex model catalogue. #[tauri::command] pub async fn list_codex_models( state: State<'_, AppState>, ) -> Result { - Ok(state.list_codex_models.execute().into()) + Ok(state.list_codex_models.execute().await.into()) } /// `save_opencode_provider_profile` — create or replace an OpenCode profile diff --git a/crates/app-tauri/tests/dto_profiles.rs b/crates/app-tauri/tests/dto_profiles.rs index 81cb35e..7d583e2 100644 --- a/crates/app-tauri/tests/dto_profiles.rs +++ b/crates/app-tauri/tests/dto_profiles.rs @@ -141,21 +141,30 @@ fn clone_profile_from_seed_request_deserialises_camelcase_overrides() { #[test] fn profile_model_catalogue_dto_serialises_searchable_camelcase_entries() { - let dto = ProfileModelCatalogDto(vec![app_tauri_lib::dto::ProfileModelCatalogEntryDto { - adapter: StructuredAdapter::Codex, - model_id: "gpt-5-codex".to_owned(), - display_name: "GPT-5 Codex".to_owned(), - aliases: vec!["codex".to_owned()], - recommended: true, - }]); + let dto = ProfileModelCatalogDto { + models: vec![app_tauri_lib::dto::ProfileModelCatalogEntryDto { + adapter: StructuredAdapter::Codex, + model_id: "gpt-5-codex".to_owned(), + display_name: "GPT-5 Codex".to_owned(), + aliases: vec!["codex".to_owned()], + recommended: true, + compatibility: domain::ModelCompatibility::Compatible, + source: domain::ModelCatalogSource::Catalogue, + }], + cli_version: Some("0.45.1".to_owned()), + warnings: vec!["provider unavailable".to_owned()], + }; let value = serde_json::to_value(&dto).unwrap(); - let arr = value.as_array().expect("transparent array"); - assert_eq!(arr[0]["adapter"], "codex"); - assert_eq!(arr[0]["modelId"], "gpt-5-codex"); - assert_eq!(arr[0]["displayName"], "GPT-5 Codex"); - assert_eq!(arr[0]["aliases"], json!(["codex"])); - assert_eq!(arr[0]["recommended"], true); + assert_eq!(value["cliVersion"], "0.45.1"); + assert_eq!(value["warnings"], json!(["provider unavailable"])); + assert_eq!(value["models"][0]["adapter"], "codex"); + assert_eq!(value["models"][0]["modelId"], "gpt-5-codex"); + assert_eq!(value["models"][0]["displayName"], "GPT-5 Codex"); + assert_eq!(value["models"][0]["aliases"], json!(["codex"])); + assert_eq!(value["models"][0]["recommended"], true); + assert_eq!(value["models"][0]["compatibility"], "compatible"); + assert_eq!(value["models"][0]["source"], "catalogue"); } #[test] diff --git a/crates/application/src/agent/model_catalogue.rs b/crates/application/src/agent/model_catalogue.rs index f4d2fb1..e5399b8 100644 --- a/crates/application/src/agent/model_catalogue.rs +++ b/crates/application/src/agent/model_catalogue.rs @@ -1,10 +1,17 @@ -//! Static curated model catalogues for structured Claude/Codex profiles. +//! Curated and best-effort model catalogues for structured Claude/Codex profiles. //! -//! The CLIs do not expose a stable machine-readable model catalogue. These lists -//! are therefore intentionally small, static and infallible; the UI must still -//! keep manual entry as a fallback for models not listed here. +//! The CLIs do not expose a stable machine-readable model catalogue and must not +//! be asked to enumerate models. The only local probe allowed here is +//! ` --version`; provider APIs are optional and failures degrade to the +//! static seed plus warnings. +use std::collections::BTreeSet; +use std::sync::Arc; + +use domain::model_catalogue::{evaluate_compatibility, CliVersion}; +use domain::ports::{CliVersionReader, CompatibilityMatrixSource, ProviderModelCatalogue}; use domain::profile::StructuredAdapter; +use domain::{ModelCatalogSource, ModelCompatibility}; /// One searchable model entry for a structured profile adapter. #[derive(Debug, Clone, PartialEq, Eq)] @@ -19,6 +26,10 @@ pub struct ProfileModelCatalogEntry { pub aliases: Vec, /// Whether this entry is the conservative default suggestion. pub recommended: bool, + /// Compatibility state against the locally detected CLI version. + pub compatibility: ModelCompatibility, + /// Source that contributed the model entry. + pub source: ModelCatalogSource, } fn entry( @@ -34,6 +45,20 @@ fn entry( display_name: display_name.to_owned(), aliases: aliases.iter().map(|alias| (*alias).to_owned()).collect(), recommended, + compatibility: ModelCompatibility::Unknown, + source: ModelCatalogSource::Catalogue, + } +} + +fn provider_entry(adapter: StructuredAdapter, model_id: String) -> ProfileModelCatalogEntry { + ProfileModelCatalogEntry { + adapter, + display_name: model_id.clone(), + model_id, + aliases: Vec::new(), + recommended: false, + compatibility: ModelCompatibility::Unknown, + source: ModelCatalogSource::Provider, } } @@ -93,73 +118,254 @@ pub fn codex_model_catalogue() -> Vec { ] } -/// Use case exposing the static Claude model catalogue. -pub struct ListClaudeModels; +/// Output of structured model-catalogue resolution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListModelsOutput { + /// The catalogue entries. + pub models: Vec, + /// Best-effort local CLI version. + pub cli_version: Option, + /// Non-fatal fallback/degradation warnings. + pub warnings: Vec, +} /// Output of [`ListClaudeModels::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListClaudeModelsOutput { /// The catalogue entries. pub models: Vec, + /// Best-effort local CLI version. + pub cli_version: Option, + /// Non-fatal fallback/degradation warnings. + pub warnings: Vec, } -impl ListClaudeModels { - /// Builds the use case (stateless, no ports to inject). - #[must_use] - pub const fn new() -> Self { - Self - } - - /// Lists curated Claude models. Infallible. - #[must_use] - pub fn execute(&self) -> ListClaudeModelsOutput { - ListClaudeModelsOutput { - models: claude_model_catalogue(), +impl From for ListClaudeModelsOutput { + fn from(out: ListModelsOutput) -> Self { + Self { + models: out.models, + cli_version: out.cli_version, + warnings: out.warnings, } } } -impl Default for ListClaudeModels { - fn default() -> Self { - Self::new() - } -} - -/// Use case exposing the static Codex model catalogue. -pub struct ListCodexModels; - /// Output of [`ListCodexModels::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListCodexModelsOutput { /// The catalogue entries. pub models: Vec, + /// Best-effort local CLI version. + pub cli_version: Option, + /// Non-fatal fallback/degradation warnings. + pub warnings: Vec, } -impl ListCodexModels { - /// Builds the use case (stateless, no ports to inject). - #[must_use] - pub const fn new() -> Self { - Self - } - - /// Lists curated Codex models. Infallible. - #[must_use] - pub fn execute(&self) -> ListCodexModelsOutput { - ListCodexModelsOutput { - models: codex_model_catalogue(), +impl From for ListCodexModelsOutput { + fn from(out: ListModelsOutput) -> Self { + Self { + models: out.models, + cli_version: out.cli_version, + warnings: out.warnings, } } } -impl Default for ListCodexModels { - fn default() -> Self { - Self::new() +/// Use case resolving an enriched structured model catalogue. +pub struct ResolveModelCatalogue { + cli_versions: Arc, + provider_catalogue: Arc, + matrix_source: Arc, +} + +impl ResolveModelCatalogue { + /// Builds the use case from hexagonal ports. + #[must_use] + pub fn new( + cli_versions: Arc, + provider_catalogue: Arc, + matrix_source: Arc, + ) -> Self { + Self { + cli_versions, + provider_catalogue, + matrix_source, + } + } + + /// Resolves models for one structured adapter. Infallible by design. + pub async fn execute(&self, adapter: StructuredAdapter) -> ListModelsOutput { + let mut warnings = Vec::new(); + let (matrix, matrix_warnings) = self.matrix_source.compatibility_matrix(); + warnings.extend(matrix_warnings); + + let cli_version = match self.cli_versions.read_cli_version(adapter).await { + Ok(version) => version, + Err(warning) => { + warnings.push(warning); + None + } + }; + + let mut models = match adapter { + StructuredAdapter::Claude => claude_model_catalogue(), + StructuredAdapter::Codex => codex_model_catalogue(), + StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => Vec::new(), + }; + + match self.provider_catalogue.list_provider_models(adapter).await { + Ok(provider_models) => { + let existing = models + .iter() + .map(|model| model.model_id.clone()) + .collect::>(); + models.extend( + provider_models + .into_iter() + .filter(|model_id| !existing.contains(model_id)) + .map(|model_id| provider_entry(adapter, model_id)), + ); + } + Err(warning) => warnings.push(warning), + } + + for model in &mut models { + model.compatibility = + evaluate_compatibility(&matrix, adapter, &model.model_id, cli_version.as_ref()); + } + + models.sort_by(|a, b| { + b.recommended + .cmp(&a.recommended) + .then_with(|| a.display_name.cmp(&b.display_name)) + .then_with(|| a.model_id.cmp(&b.model_id)) + }); + + ListModelsOutput { + models, + cli_version, + warnings, + } + } +} + +/// Use case exposing the Claude model catalogue. +pub struct ListClaudeModels { + resolver: ResolveModelCatalogue, +} + +impl ListClaudeModels { + /// Builds the use case. + #[must_use] + pub fn new( + cli_versions: Arc, + provider_catalogue: Arc, + matrix_source: Arc, + ) -> Self { + Self { + resolver: ResolveModelCatalogue::new(cli_versions, provider_catalogue, matrix_source), + } + } + + /// Lists Claude models. Infallible. + pub async fn execute(&self) -> ListClaudeModelsOutput { + self.resolver + .execute(StructuredAdapter::Claude) + .await + .into() + } +} + +/// Use case exposing the Codex model catalogue. +pub struct ListCodexModels { + resolver: ResolveModelCatalogue, +} + +impl ListCodexModels { + /// Builds the use case. + #[must_use] + pub fn new( + cli_versions: Arc, + provider_catalogue: Arc, + matrix_source: Arc, + ) -> Self { + Self { + resolver: ResolveModelCatalogue::new(cli_versions, provider_catalogue, matrix_source), + } + } + + /// Lists Codex models. Infallible. + pub async fn execute(&self) -> ListCodexModelsOutput { + self.resolver.execute(StructuredAdapter::Codex).await.into() } } #[cfg(test)] mod tests { use super::*; + use async_trait::async_trait; + use domain::{CliVersion, CompatibilityMatrix}; + use std::collections::HashMap; + + struct FakeCliVersionReader(Option, String>>); + + #[async_trait] + impl CliVersionReader for FakeCliVersionReader { + async fn read_cli_version( + &self, + _adapter: StructuredAdapter, + ) -> Result, String> { + self.0 + .clone() + .unwrap_or_else(|| Ok(Some(CliVersion::parse("1.0.0").unwrap()))) + } + } + + struct FakeProvider(Vec, Option); + + #[async_trait] + impl ProviderModelCatalogue for FakeProvider { + async fn list_provider_models( + &self, + _adapter: StructuredAdapter, + ) -> Result, String> { + if let Some(warning) = &self.1 { + Err(warning.clone()) + } else { + Ok(self.0.clone()) + } + } + } + + struct FakeMatrixSource(CompatibilityMatrix, Vec); + + impl CompatibilityMatrixSource for FakeMatrixSource { + fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec) { + (self.0.clone(), self.1.clone()) + } + } + + fn resolver( + version: Option, String>>, + provider: Vec, + provider_warning: Option, + ) -> ResolveModelCatalogue { + ResolveModelCatalogue::new( + Arc::new(FakeCliVersionReader(version)), + Arc::new(FakeProvider(provider, provider_warning)), + Arc::new(FakeMatrixSource( + CompatibilityMatrix { + version: 1, + claude: HashMap::from([ + ("claude-sonnet-5".to_owned(), "1.0.0".to_owned()), + ("claude-opus-4-8".to_owned(), "2.0.0".to_owned()), + ]), + codex: HashMap::from([("gpt-5-codex".to_owned(), "1.0.0".to_owned())]), + }, + vec![], + )), + ) + } #[test] fn static_catalogues_are_non_empty_searchable_and_have_one_default() { @@ -177,7 +383,86 @@ mod tests { assert_eq!(model.adapter, adapter); assert!(!model.model_id.trim().is_empty()); assert!(!model.display_name.trim().is_empty()); + assert_eq!(model.compatibility, ModelCompatibility::Unknown); + assert_eq!(model.source, ModelCatalogSource::Catalogue); } } } + + #[tokio::test] + async fn resolver_enriches_static_catalogue_with_cli_compatibility() { + let out = resolver( + Some(Ok(Some(CliVersion::parse("1.0.0").unwrap()))), + vec![], + None, + ) + .execute(StructuredAdapter::Claude) + .await; + + let sonnet = out + .models + .iter() + .find(|model| model.model_id == "claude-sonnet-5") + .unwrap(); + let opus = out + .models + .iter() + .find(|model| model.model_id == "claude-opus-4-8") + .unwrap(); + assert_eq!(sonnet.compatibility, ModelCompatibility::Compatible); + assert_eq!(opus.compatibility, ModelCompatibility::LikelyTooRecent); + assert_eq!(out.cli_version.unwrap().raw, "1.0.0"); + assert!(out.warnings.is_empty()); + } + + #[tokio::test] + async fn resolver_keeps_provider_and_cli_failures_non_blocking() { + let out = resolver( + Some(Err("codex version unavailable".to_owned())), + vec![], + Some("provider unavailable".to_owned()), + ) + .execute(StructuredAdapter::Codex) + .await; + + assert!(!out.models.is_empty()); + assert_eq!(out.cli_version, None); + assert_eq!( + out.warnings, + vec![ + "codex version unavailable".to_owned(), + "provider unavailable".to_owned() + ] + ); + assert!(out + .models + .iter() + .all(|model| model.compatibility == ModelCompatibility::Unknown)); + } + + #[tokio::test] + async fn resolver_adds_provider_only_models_without_duplicate_seed_entries() { + let out = resolver( + None, + vec!["gpt-5-codex".to_owned(), "gpt-5-provider".to_owned()], + None, + ) + .execute(StructuredAdapter::Codex) + .await; + + assert_eq!( + out.models + .iter() + .filter(|model| model.model_id == "gpt-5-codex") + .count(), + 1 + ); + let provider = out + .models + .iter() + .find(|model| model.model_id == "gpt-5-provider") + .unwrap(); + assert_eq!(provider.source, ModelCatalogSource::Provider); + assert_eq!(provider.compatibility, ModelCompatibility::Unknown); + } } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 307c821..43800f3 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -40,11 +40,11 @@ pub mod window; pub mod workstate; pub use agent::{ - drain_reply_stream_with_readiness, drain_with_readiness, - drain_with_readiness_and_announcements, drain_with_readiness_outcome, reference_profile_id, - reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer, - AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, - CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput, + claude_model_catalogue, codex_model_catalogue, drain_reply_stream_with_readiness, + drain_with_readiness, drain_with_readiness_and_announcements, drain_with_readiness_outcome, + reference_profile_id, reference_profiles, selectable_reference_profiles, send_blocking, + AgentResumer, AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, + ChangeAgentProfileOutput, CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, CloneProfileFromSeed, CloneProfileFromSeedInput, CloneProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index 0986e9f..cf64afc 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -24,12 +24,12 @@ use domain::profile::{ use domain::project::ProjectPath; use application::{ - reference_profile_id, reference_profiles, AppError, CloneOpenCodeProfileFromSeed, - CloneOpenCodeProfileFromSeedInput, CloneProfileFromSeed, CloneProfileFromSeedInput, - ConfigureProfiles, ConfigureProfilesInput, DeleteProfile, DeleteProfileInput, DetectProfiles, - DetectProfilesInput, FirstRunState, ListClaudeModels, ListCodexModels, ListProfiles, - ReferenceProfiles, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, SaveProfile, - SaveProfileInput, CODEX_SUBMIT_DELAY_MS, + claude_model_catalogue, codex_model_catalogue, reference_profile_id, reference_profiles, + AppError, CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput, + CloneProfileFromSeed, CloneProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput, + DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, + ListProfiles, ReferenceProfiles, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, + SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS, }; // --------------------------------------------------------------------------- @@ -1161,8 +1161,8 @@ fn catalogue_gemini_and_aider_stay_pty_without_adapter() { #[test] fn claude_and_codex_model_catalogues_are_static_and_searchable() { - let claude = ListClaudeModels::new().execute().models; - let codex = ListCodexModels::new().execute().models; + let claude = claude_model_catalogue(); + let codex = codex_model_catalogue(); assert!(claude .iter() diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 5da2b89..50b844b 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -1067,6 +1067,10 @@ pub struct ProfileModelCatalogEntryDto { pub aliases: Vec, /// Whether this entry is the conservative default suggestion. pub recommended: bool, + /// Compatibility state against the locally detected CLI version. + pub compatibility: domain::ModelCompatibility, + /// Source that contributed the model entry. + pub source: domain::ModelCatalogSource, } impl From for ProfileModelCatalogEntryDto { @@ -1077,24 +1081,41 @@ impl From for ProfileModelCatalogEntryDto display_name: entry.display_name, aliases: entry.aliases, recommended: entry.recommended, + compatibility: entry.compatibility, + source: entry.source, } } } -/// A list of curated structured-profile models. +/// Enriched structured-profile model catalogue. #[derive(Debug, Clone, Serialize)] -#[serde(transparent)] -pub struct ProfileModelCatalogDto(pub Vec); +#[serde(rename_all = "camelCase")] +pub struct ProfileModelCatalogDto { + /// The catalogue entries. + pub models: Vec, + /// Best-effort local CLI version. + pub cli_version: Option, + /// Non-fatal fallback/degradation warnings. + pub warnings: Vec, +} impl From for ProfileModelCatalogDto { fn from(out: application::ListClaudeModelsOutput) -> Self { - Self(out.models.into_iter().map(Into::into).collect()) + Self { + models: out.models.into_iter().map(Into::into).collect(), + cli_version: out.cli_version.map(|version| version.raw), + warnings: out.warnings, + } } } impl From for ProfileModelCatalogDto { fn from(out: application::ListCodexModelsOutput) -> Self { - Self(out.models.into_iter().map(Into::into).collect()) + Self { + models: out.models.into_iter().map(Into::into).collect(), + cli_version: out.cli_version.map(|version| version.raw), + warnings: out.warnings, + } } } diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 95f4711..7a9a199 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -80,19 +80,20 @@ use uuid::Uuid; use infrastructure::{ embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink, BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector, - CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe, - ExternalMcpPluginSupervisor, FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, - FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, - FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, - FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, - FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore, - FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore, - FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, - HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, IdeaiContextStore, - InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime, - LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, - NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, ReadOnlyRuntimePermissionProbe, - RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider, + CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, + EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor, + FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore, + FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, + FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, + FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, + FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, + FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository, + HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, + HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, + InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, + LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, + PortablePtyAdapter, ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, + StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, @@ -1474,8 +1475,21 @@ impl BackendCore { Arc::clone(&ids) as Arc, )); let list_opencode_providers = Arc::new(ListOpenCodeProviders::new()); - let list_claude_models = Arc::new(ListClaudeModels::new()); - let list_codex_models = Arc::new(ListCodexModels::new()); + let cli_version_reader = Arc::new(ProcessCliVersionReader::new(Arc::clone(&spawner_port))); + let provider_model_catalogue = Arc::new(HttpProviderModelCatalogue::new()); + let compatibility_matrix = Arc::new(EmbeddedCompatibilityMatrix::with_app_data_dir( + app_data_dir.clone(), + )); + let list_claude_models = Arc::new(ListClaudeModels::new( + Arc::clone(&cli_version_reader) as Arc, + Arc::clone(&provider_model_catalogue) as Arc, + Arc::clone(&compatibility_matrix) as Arc, + )); + let list_codex_models = Arc::new(ListCodexModels::new( + cli_version_reader as Arc, + provider_model_catalogue as Arc, + compatibility_matrix as Arc, + )); let clone_profile_from_seed = Arc::new(CloneProfileFromSeed::new( Arc::clone(&profile_store_port), Arc::clone(&ids) as Arc, diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index d3c5a4e..fe3c8f5 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -51,6 +51,7 @@ pub mod markdown; pub mod mcp_tool_permissions; pub mod memory; pub mod memory_harvest; +pub mod model_catalogue; pub mod model_server; pub mod orchestrator; pub mod permission; @@ -167,6 +168,11 @@ pub use memory_harvest::{ MAX_BLOCK_BYTES, MAX_DESCRIPTION_CHARS, }; +pub use model_catalogue::{ + evaluate_compatibility, CliVersion, CompatibilityMatrix, ModelCatalogSource, + ModelCatalogueError, ModelCompatibility, +}; + pub use model_server::{ validate_free_args, ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath, ModelServerEndpoint, @@ -226,16 +232,17 @@ pub use ports::{ AgentContextStore, AgentRuntime, AgentToolPolicyStore, AssistantContextError, AssistantContextProvider, BackgroundCompletionStream, BackgroundTaskCompletion, BackgroundTaskHandle, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec, - BackgroundTaskStore, Clock, ContextInjectionPlan, DirEntry, Embedder, EmbedderEnvInspector, - EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal, - EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo, - GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore, - IssueStoreError, LiveStateStore, LocalPath, McpToolPermissionStore, MemoryError, MemoryQuery, - MemoryRecall, MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, - ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes, - PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, - PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError, - PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, + BackgroundTaskStore, CliVersionReader, Clock, CompatibilityMatrixSource, ContextInjectionPlan, + DirEntry, Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, + EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, + ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, + IdGenerator, IssueNumberAllocator, IssueStore, IssueStoreError, LiveStateStore, LocalPath, + McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, + ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution, + Output, OutputStream, PermissionStore, PluginManifestBytes, PluginManifestError, + PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore, + PluginRegistryError, PluginRegistryStore, PluginStoreError, PreparedContext, ProcessError, + ProcessSpawner, ProfileStore, ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore, diff --git a/crates/domain/src/model_catalogue.rs b/crates/domain/src/model_catalogue.rs new file mode 100644 index 0000000..88bee67 --- /dev/null +++ b/crates/domain/src/model_catalogue.rs @@ -0,0 +1,216 @@ +//! Pure model-catalogue compatibility types. + +use core::cmp::Ordering; +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::profile::StructuredAdapter; + +/// Parsed CLI version used for local compatibility checks. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CliVersion { + /// Original version string reported by the CLI. + pub raw: String, + parts: Vec, +} + +impl CliVersion { + /// Parses a version from a string containing at least one digit. + /// + /// # Errors + /// Returns [`ModelCatalogueError::InvalidVersion`] when no numeric version + /// segment can be found. + pub fn parse(raw: impl Into) -> Result { + let raw = raw.into(); + let start = raw + .char_indices() + .find_map(|(idx, ch)| ch.is_ascii_digit().then_some(idx)) + .ok_or_else(|| ModelCatalogueError::InvalidVersion(raw.clone()))?; + let version = raw[start..] + .chars() + .take_while(|ch| ch.is_ascii_digit() || *ch == '.') + .collect::(); + let parts = version + .split('.') + .filter(|part| !part.is_empty()) + .map(str::parse::) + .collect::, _>>() + .map_err(|_| ModelCatalogueError::InvalidVersion(raw.clone()))?; + if parts.is_empty() { + return Err(ModelCatalogueError::InvalidVersion(raw)); + } + Ok(Self { raw, parts }) + } +} + +impl PartialOrd for CliVersion { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for CliVersion { + fn cmp(&self, other: &Self) -> Ordering { + let max_len = self.parts.len().max(other.parts.len()); + for idx in 0..max_len { + match self + .parts + .get(idx) + .copied() + .unwrap_or(0) + .cmp(&other.parts.get(idx).copied().unwrap_or(0)) + { + Ordering::Equal => {} + ordering => return ordering, + } + } + Ordering::Equal + } +} + +/// Compatibility state between a local CLI version and a model id. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ModelCompatibility { + /// The known minimum CLI version is satisfied. + Compatible, + /// The CLI version is absent, or the model is not covered by the matrix. + Unknown, + /// The model is covered by the matrix but appears newer than the local CLI. + LikelyTooRecent, +} + +/// Origin of a model-catalogue entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ModelCatalogSource { + /// Curated static seed maintained by IdeA. + Catalogue, + /// Best-effort provider API discovery. + Provider, +} + +/// Matrix mapping adapter/model ids to their minimum known CLI version. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompatibilityMatrix { + /// Matrix schema/data version. + pub version: u32, + /// Claude Code entries keyed by model id. + #[serde(default)] + pub claude: HashMap, + /// Codex CLI entries keyed by model id. + #[serde(default)] + pub codex: HashMap, +} + +impl CompatibilityMatrix { + /// Looks up the minimum CLI version for an adapter/model pair. + #[must_use] + pub fn minimum_version(&self, adapter: StructuredAdapter, model_id: &str) -> Option<&str> { + match adapter { + StructuredAdapter::Claude => self.claude.get(model_id).map(String::as_str), + StructuredAdapter::Codex => self.codex.get(model_id).map(String::as_str), + StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => None, + } + } +} + +/// Evaluates local compatibility using only pure matrix data. +#[must_use] +pub fn evaluate_compatibility( + matrix: &CompatibilityMatrix, + adapter: StructuredAdapter, + model_id: &str, + cli_version: Option<&CliVersion>, +) -> ModelCompatibility { + let Some(cli_version) = cli_version else { + return ModelCompatibility::Unknown; + }; + let Some(minimum) = matrix.minimum_version(adapter, model_id) else { + return ModelCompatibility::Unknown; + }; + let Ok(minimum) = CliVersion::parse(minimum.to_owned()) else { + return ModelCompatibility::Unknown; + }; + if &minimum <= cli_version { + ModelCompatibility::Compatible + } else { + ModelCompatibility::LikelyTooRecent + } +} + +/// Errors from pure model-catalogue parsing. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ModelCatalogueError { + /// Version strings must contain at least one numeric segment. + #[error("invalid CLI version: {0}")] + InvalidVersion(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + fn matrix() -> CompatibilityMatrix { + CompatibilityMatrix { + version: 1, + claude: HashMap::from([("claude-sonnet-5".to_owned(), "1.2.0".to_owned())]), + codex: HashMap::from([("gpt-5-codex".to_owned(), "0.44.0".to_owned())]), + } + } + + #[test] + fn cli_versions_compare_by_numeric_parts() { + assert!(CliVersion::parse("codex 0.10.0").unwrap() > CliVersion::parse("0.9.9").unwrap()); + assert_eq!( + CliVersion::parse("1.2") + .unwrap() + .cmp(&CliVersion::parse("1.2.0").unwrap()), + Ordering::Equal + ); + } + + #[test] + fn compatibility_is_unknown_without_version_or_matrix_entry() { + let matrix = matrix(); + assert_eq!( + evaluate_compatibility(&matrix, StructuredAdapter::Codex, "gpt-5-codex", None), + ModelCompatibility::Unknown + ); + assert_eq!( + evaluate_compatibility( + &matrix, + StructuredAdapter::Codex, + "future-model", + Some(&CliVersion::parse("999.0.0").unwrap()) + ), + ModelCompatibility::Unknown + ); + } + + #[test] + fn compatibility_detects_supported_and_too_recent_models() { + let matrix = matrix(); + assert_eq!( + evaluate_compatibility( + &matrix, + StructuredAdapter::Codex, + "gpt-5-codex", + Some(&CliVersion::parse("0.44.0").unwrap()) + ), + ModelCompatibility::Compatible + ); + assert_eq!( + evaluate_compatibility( + &matrix, + StructuredAdapter::Claude, + "claude-sonnet-5", + Some(&CliVersion::parse("1.1.9").unwrap()) + ), + ModelCompatibility::LikelyTooRecent + ); + } +} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index d4e5214..69f8ba4 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -45,6 +45,7 @@ use crate::issue::{ use crate::markdown::MarkdownDoc; use crate::mcp_tool_permissions::ProjectMcpToolPermissions; use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug}; +use crate::model_catalogue::{CliVersion, CompatibilityMatrix}; use crate::model_server::{ HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus, }; @@ -54,7 +55,7 @@ use crate::plugin::{ PluginMcpStatusSet, PluginPackageRef, PluginRegistry, RelativePath, RemovalOutcome, StagedPluginPackage, }; -use crate::profile::{AgentProfile, EmbedderProfile}; +use crate::profile::{AgentProfile, EmbedderProfile, StructuredAdapter}; use crate::project::{Project, ProjectPath}; use crate::remote::RemoteKind; use crate::skill::{Skill, SkillScope}; @@ -1245,6 +1246,37 @@ pub trait ProcessSpawner: Send + Sync { async fn run(&self, spec: SpawnSpec) -> Result; } +/// Read a local structured CLI version using only the allowed `--version` probe. +#[async_trait] +pub trait CliVersionReader: Send + Sync { + /// Best-effort local CLI version lookup. + /// + /// # Errors + /// Returns a string suitable for non-blocking catalogue warnings. + async fn read_cli_version( + &self, + adapter: StructuredAdapter, + ) -> Result, String>; +} + +/// Best-effort provider API model catalogue. +#[async_trait] +pub trait ProviderModelCatalogue: Send + Sync { + /// Lists provider model ids for an adapter. `Ok(Vec::new())` means no key or + /// unsupported provider and is not a warning-worthy failure. + /// + /// # Errors + /// Returns a string suitable for non-blocking catalogue warnings. + async fn list_provider_models(&self, adapter: StructuredAdapter) + -> Result, String>; +} + +/// Source of the versioned compatibility matrix. +pub trait CompatibilityMatrixSource: Send + Sync { + /// Returns matrix data and any fallback warnings. + fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec); +} + /// Probe readiness of an OpenAI-compatible model server. #[async_trait] pub trait ModelServerProbe: Send + Sync { diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 18963df..eb89443 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -26,6 +26,7 @@ pub mod input; pub mod inspector; pub mod issues; pub mod mailbox; +pub mod model_catalogue; pub mod model_server; pub mod orchestrator; pub mod pair_attempt_limiter; @@ -68,6 +69,9 @@ pub use inspector::{ }; pub use issues::{FsIssueNumberAllocator, FsIssueStore}; pub use mailbox::InMemoryMailbox; +pub use model_catalogue::{ + EmbeddedCompatibilityMatrix, HttpProviderModelCatalogue, ProcessCliVersionReader, +}; pub use model_server::{ FsModelServerRegistry, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, LlamaCppRuntime, LocalManagedProcess, diff --git a/crates/infrastructure/src/model_catalogue.rs b/crates/infrastructure/src/model_catalogue.rs new file mode 100644 index 0000000..8759f62 --- /dev/null +++ b/crates/infrastructure/src/model_catalogue.rs @@ -0,0 +1,331 @@ +//! Concrete adapters for structured model-catalogue enrichment. + +use std::collections::BTreeSet; +use std::env; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use domain::model_catalogue::{CliVersion, CompatibilityMatrix}; +use domain::ports::{ + CliVersionReader, CompatibilityMatrixSource, ProcessSpawner, ProviderModelCatalogue, SpawnSpec, +}; +use domain::profile::StructuredAdapter; +use domain::project::ProjectPath; +use serde::Deserialize; + +const VERSION_TIMEOUT: Duration = Duration::from_millis(800); +const PROVIDER_TIMEOUT: Duration = Duration::from_millis(1_500); +const EMBEDDED_MATRIX: &str = include_str!("model_compatibility_matrix.json"); + +/// Reads local CLI versions through `codex --version` / `claude --version`. +#[derive(Clone)] +pub struct ProcessCliVersionReader { + spawner: Arc, +} + +impl ProcessCliVersionReader { + /// Builds the adapter from the process-spawner port. + #[must_use] + pub fn new(spawner: Arc) -> Self { + Self { spawner } + } + + fn spec(adapter: StructuredAdapter) -> Option { + let command = match adapter { + StructuredAdapter::Claude => "claude", + StructuredAdapter::Codex => "codex", + StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => return None, + }; + Some(SpawnSpec { + command: command.to_owned(), + args: vec!["--version".to_owned()], + cwd: ProjectPath::new("/").expect("root project path is valid"), + env: Vec::new(), + context_plan: None, + sandbox: None, + }) + } +} + +#[async_trait] +impl CliVersionReader for ProcessCliVersionReader { + async fn read_cli_version( + &self, + adapter: StructuredAdapter, + ) -> Result, String> { + let Some(spec) = Self::spec(adapter) else { + return Ok(None); + }; + let command = spec.command.clone(); + let output = tokio::time::timeout(VERSION_TIMEOUT, self.spawner.run(spec)) + .await + .map_err(|_| format!("{command} --version timed out"))? + .map_err(|e| format!("{command} --version failed: {e}"))?; + if output.status.code != Some(0) { + return Err(format!( + "{command} --version exited with {:?}", + output.status.code + )); + } + let text = String::from_utf8_lossy(&output.stdout) + .trim() + .to_owned() + .if_empty_then(|| String::from_utf8_lossy(&output.stderr).trim().to_owned()); + if text.is_empty() { + return Ok(None); + } + CliVersion::parse(text) + .map(Some) + .map_err(|e| format!("{command} --version was not parseable: {e}")) + } +} + +trait EmptyStringExt { + fn if_empty_then(self, fallback: impl FnOnce() -> String) -> String; +} + +impl EmptyStringExt for String { + fn if_empty_then(self, fallback: impl FnOnce() -> String) -> String { + if self.is_empty() { + fallback() + } else { + self + } + } +} + +/// Provider HTTP catalogue using existing API keys from the process environment. +#[derive(Clone)] +pub struct HttpProviderModelCatalogue { + client: reqwest::Client, +} + +impl HttpProviderModelCatalogue { + /// Builds the adapter. + #[must_use] + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + } + } + + async fn list_openai(&self, key: String) -> Result, String> { + #[derive(Deserialize)] + struct Response { + data: Vec, + } + #[derive(Deserialize)] + struct Model { + id: String, + } + + let response = tokio::time::timeout( + PROVIDER_TIMEOUT, + self.client + .get("https://api.openai.com/v1/models") + .bearer_auth(key) + .send(), + ) + .await + .map_err(|_| "OpenAI model catalogue timed out".to_owned())? + .map_err(|e| format!("OpenAI model catalogue unavailable: {e}"))?; + if !response.status().is_success() { + return Err(format!( + "OpenAI model catalogue returned HTTP {}", + response.status() + )); + } + let parsed = response + .json::() + .await + .map_err(|e| format!("OpenAI model catalogue parse failed: {e}"))?; + Ok(dedup_non_empty( + parsed.data.into_iter().map(|model| model.id), + )) + } + + async fn list_anthropic(&self, key: String) -> Result, String> { + #[derive(Deserialize)] + struct Response { + data: Vec, + } + #[derive(Deserialize)] + struct Model { + id: String, + } + + let response = tokio::time::timeout( + PROVIDER_TIMEOUT, + self.client + .get("https://api.anthropic.com/v1/models") + .header("x-api-key", key) + .header("anthropic-version", "2023-06-01") + .send(), + ) + .await + .map_err(|_| "Anthropic model catalogue timed out".to_owned())? + .map_err(|e| format!("Anthropic model catalogue unavailable: {e}"))?; + if !response.status().is_success() { + return Err(format!( + "Anthropic model catalogue returned HTTP {}", + response.status() + )); + } + let parsed = response + .json::() + .await + .map_err(|e| format!("Anthropic model catalogue parse failed: {e}"))?; + Ok(dedup_non_empty( + parsed.data.into_iter().map(|model| model.id), + )) + } +} + +impl Default for HttpProviderModelCatalogue { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl ProviderModelCatalogue for HttpProviderModelCatalogue { + async fn list_provider_models( + &self, + adapter: StructuredAdapter, + ) -> Result, String> { + match adapter { + StructuredAdapter::Codex => match env::var("OPENAI_API_KEY") { + Ok(key) if !key.trim().is_empty() => self.list_openai(key).await, + _ => Ok(Vec::new()), + }, + StructuredAdapter::Claude => match env::var("ANTHROPIC_API_KEY") { + Ok(key) if !key.trim().is_empty() => self.list_anthropic(key).await, + _ => Ok(Vec::new()), + }, + StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => Ok(Vec::new()), + } + } +} + +fn dedup_non_empty(values: impl Iterator) -> Vec { + values + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + .collect::>() + .into_iter() + .collect() +} + +/// Compatibility matrix source backed by an embedded JSON seed and optional +/// app-data override. +#[derive(Debug, Clone)] +pub struct EmbeddedCompatibilityMatrix { + override_path: Option, +} + +impl EmbeddedCompatibilityMatrix { + /// Builds a matrix source with no override. + #[must_use] + pub const fn new() -> Self { + Self { + override_path: None, + } + } + + /// Builds a matrix source reading `model-compat.json` from the app data dir + /// before falling back to the embedded seed. + #[must_use] + pub fn with_app_data_dir(app_data_dir: impl Into) -> Self { + Self { + override_path: Some(app_data_dir.into().join("model-compat.json")), + } + } + + fn embedded() -> CompatibilityMatrix { + serde_json::from_str(EMBEDDED_MATRIX).expect("embedded compatibility matrix is valid") + } +} + +impl Default for EmbeddedCompatibilityMatrix { + fn default() -> Self { + Self::new() + } +} + +impl CompatibilityMatrixSource for EmbeddedCompatibilityMatrix { + fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec) { + let Some(path) = &self.override_path else { + return (Self::embedded(), Vec::new()); + }; + match std::fs::read_to_string(path) { + Ok(raw) => match serde_json::from_str::(&raw) { + Ok(matrix) => (matrix, Vec::new()), + Err(e) => ( + Self::embedded(), + vec![format!( + "model compatibility override ignored because it is invalid: {e}" + )], + ), + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => (Self::embedded(), Vec::new()), + Err(e) => ( + Self::embedded(), + vec![format!( + "model compatibility override ignored because it is unreadable: {e}" + )], + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::ports::{ExitStatus, Output, ProcessError}; + use std::sync::Mutex; + + struct RecordingSpawner { + specs: Mutex>, + } + + #[async_trait] + impl ProcessSpawner for RecordingSpawner { + async fn run(&self, spec: SpawnSpec) -> Result { + self.specs.lock().unwrap().push(spec); + Ok(Output { + status: ExitStatus { code: Some(0) }, + stdout: b"codex-cli 0.45.1\n".to_vec(), + stderr: Vec::new(), + }) + } + } + + #[tokio::test] + async fn cli_version_reader_runs_only_version_probe() { + let spawner = Arc::new(RecordingSpawner { + specs: Mutex::new(Vec::new()), + }); + let reader = ProcessCliVersionReader::new(spawner.clone()); + let version = reader + .read_cli_version(StructuredAdapter::Codex) + .await + .unwrap() + .unwrap(); + + assert_eq!(version.raw, "codex-cli 0.45.1"); + let specs = spawner.specs.lock().unwrap(); + assert_eq!(specs.len(), 1); + assert_eq!(specs[0].command, "codex"); + assert_eq!(specs[0].args, vec!["--version"]); + } + + #[test] + fn embedded_matrix_is_valid() { + let (matrix, warnings) = EmbeddedCompatibilityMatrix::new().compatibility_matrix(); + assert!(warnings.is_empty()); + assert!(matrix.codex.contains_key("gpt-5-codex")); + assert!(matrix.claude.contains_key("claude-sonnet-5")); + } +} diff --git a/crates/infrastructure/src/model_compatibility_matrix.json b/crates/infrastructure/src/model_compatibility_matrix.json new file mode 100644 index 0000000..a0af0a8 --- /dev/null +++ b/crates/infrastructure/src/model_compatibility_matrix.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "claude": { + "claude-sonnet-5": "1.0.0", + "claude-opus-4-8": "1.0.0", + "claude-haiku-4-5-20251001": "1.0.0" + }, + "codex": { + "gpt-5-codex": "0.1.0", + "gpt-5": "0.1.0", + "gpt-5-mini": "0.1.0" + } +} diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index dc79106..e14ca7c 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -79,18 +79,18 @@ use backend::dto::{ GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, - ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, - ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, - ReadConversationPageRequestDto, RecallMemoryRequestDto, ResolveAgentPermissionsRequestDto, - ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, - ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto, - SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, - SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto, - SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, - SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, - TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto, TicketCreateRequestDto, - TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput, - TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto, + ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, + ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, + ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto, + ResolveAgentPermissionsRequestDto, ResolveAgentSystemPermissionsRequestDto, + ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, + SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SkillDto, SkillListDto, + SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto, + SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto, + StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, + TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto, + TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, + TicketListPageInput, TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto, TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto, TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, @@ -2353,6 +2353,8 @@ async fn invoke( "list_profiles" => invoke_list_profiles(&state.app).await, "save_profile" => invoke_save_profile(&request.args, &state.app).await, "list_opencode_providers" => invoke_list_opencode_providers(&state.app), + "list_claude_models" => invoke_list_claude_models(&state.app).await, + "list_codex_models" => invoke_list_codex_models(&state.app).await, "save_opencode_provider_profile" => { invoke_save_opencode_provider_profile(&request.args, &state.app).await } @@ -2606,6 +2608,16 @@ fn invoke_list_opencode_providers(state: &BackendCore) -> Result Result { + let output: ProfileModelCatalogDto = state.list_claude_models.execute().await.into(); + serde_json::to_value(output).map_err(serialization_error) +} + +async fn invoke_list_codex_models(state: &BackendCore) -> Result { + let output: ProfileModelCatalogDto = state.list_codex_models.execute().await.into(); + serde_json::to_value(output).map_err(serialization_error) +} + async fn invoke_save_opencode_provider_profile( args: &Value, state: &BackendCore, @@ -7653,6 +7665,8 @@ mod tests { "list_profiles", "save_profile", "list_opencode_providers", + "list_claude_models", + "list_codex_models", "save_opencode_provider_profile", "delete_profile", "configure_profiles", diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index c44c5e0..5fca750 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -42,7 +42,7 @@ import type { ProjectWorkState, ProjectSystemPermissions, ProfileAvailability, - ProfileModelCatalogEntry, + ProfileModelCatalog, ResolvedAgentSystemPermissions, SystemPermissionSet, Skill, @@ -74,6 +74,7 @@ import type { } from "@/ports"; import { normalizeProjectWorkState } from "../workStateNormalization"; import { normalizeTurnPage } from "../conversationNormalization"; +import { normalizeProfileModelCatalog } from "../profileCatalog"; import type { HttpInvoker } from "./httpInvoker"; export class HttpProjectGateway implements ProjectGateway { @@ -183,11 +184,11 @@ export class HttpProfileGateway implements ProfileGateway { request: { seedProfileId: input.seedProfileId, name: input.name, model: input.model }, }); } - listClaudeModels(): Promise { - return this.http.invoke("list_claude_models"); + async listClaudeModels(): Promise { + return normalizeProfileModelCatalog(await this.http.invoke("list_claude_models")); } - listCodexModels(): Promise { - return this.http.invoke("list_codex_models"); + async listCodexModels(): Promise { + return normalizeProfileModelCatalog(await this.http.invoke("list_codex_models")); } configureProfiles(profiles: AgentProfile[]): Promise { return this.http.invoke("configure_profiles", { request: { profiles } }); diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 31b62c7..88f2a27 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -35,6 +35,7 @@ import type { McpToolCatalogue, McpToolPolicy, OpenCodeProviderCatalogEntry, + ProfileModelCatalog, ProfileModelCatalogEntry, EffectivePermissions, PairedDevice, @@ -1302,6 +1303,8 @@ const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [ displayName: "Claude Sonnet 5", aliases: ["sonnet"], recommended: true, + compatibility: "compatible", + source: "catalogue", }, { adapter: "claude", @@ -1309,6 +1312,8 @@ const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [ displayName: "Claude Opus 4.8", aliases: ["opus"], recommended: false, + compatibility: "unknown", + source: "catalogue", }, { adapter: "claude", @@ -1316,6 +1321,8 @@ const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [ displayName: "Claude Haiku 4.5", aliases: ["haiku"], recommended: false, + compatibility: "likelyTooRecent", + source: "provider", }, ]; @@ -1326,6 +1333,8 @@ const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [ displayName: "GPT-5 Codex", aliases: ["codex"], recommended: true, + compatibility: "compatible", + source: "catalogue", }, { adapter: "codex", @@ -1333,6 +1342,8 @@ const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [ displayName: "GPT-5", aliases: ["general"], recommended: false, + compatibility: "unknown", + source: "catalogue", }, { adapter: "codex", @@ -1340,6 +1351,8 @@ const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [ displayName: "GPT-5 mini", aliases: ["mini", "fast"], recommended: false, + compatibility: "likelyTooRecent", + source: "provider", }, ]; @@ -1420,12 +1433,20 @@ export class MockProfileGateway implements ProfileGateway { return structuredClone(cloned); } - async listClaudeModels(): Promise { - return structuredClone(MOCK_CLAUDE_MODELS); + async listClaudeModels(): Promise { + return { + models: structuredClone(MOCK_CLAUDE_MODELS), + cliVersion: "2.1.220", + warnings: [], + }; } - async listCodexModels(): Promise { - return structuredClone(MOCK_CODEX_MODELS); + async listCodexModels(): Promise { + return { + models: structuredClone(MOCK_CODEX_MODELS), + cliVersion: "0.145.0", + warnings: ["Catalogue provider partiellement estime depuis les donnees locales."], + }; } async cloneOpenCodeProfileFromSeed( diff --git a/frontend/src/adapters/profile.ts b/frontend/src/adapters/profile.ts index b68cee5..f5b4be1 100644 --- a/frontend/src/adapters/profile.ts +++ b/frontend/src/adapters/profile.ts @@ -12,7 +12,7 @@ import type { AgentProfile, FirstRunState, OpenCodeProviderCatalogEntry, - ProfileModelCatalogEntry, + ProfileModelCatalog, ProfileAvailability, } from "@/domain"; import type { @@ -21,6 +21,7 @@ import type { ProfileGateway, SaveOpenCodeProviderProfileInput, } from "@/ports"; +import { normalizeProfileModelCatalog } from "./profileCatalog"; export class TauriProfileGateway implements ProfileGateway { firstRunState(): Promise { @@ -59,12 +60,12 @@ export class TauriProfileGateway implements ProfileGateway { }); } - listClaudeModels(): Promise { - return invoke("list_claude_models"); + async listClaudeModels(): Promise { + return normalizeProfileModelCatalog(await invoke("list_claude_models")); } - listCodexModels(): Promise { - return invoke("list_codex_models"); + async listCodexModels(): Promise { + return normalizeProfileModelCatalog(await invoke("list_codex_models")); } configureProfiles(profiles: AgentProfile[]): Promise { diff --git a/frontend/src/adapters/profileCatalog.test.ts b/frontend/src/adapters/profileCatalog.test.ts new file mode 100644 index 0000000..3c9066e --- /dev/null +++ b/frontend/src/adapters/profileCatalog.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeProfileModelCatalog } from "./profileCatalog"; + +describe("normalizeProfileModelCatalog", () => { + it("wraps the legacy bare array response with unknown compatibility", () => { + expect( + normalizeProfileModelCatalog([ + { + adapter: "codex", + modelId: "gpt-5-codex", + displayName: "GPT-5 Codex", + aliases: ["codex"], + recommended: true, + }, + ]), + ).toEqual({ + models: [ + { + adapter: "codex", + modelId: "gpt-5-codex", + displayName: "GPT-5 Codex", + aliases: ["codex"], + recommended: true, + compatibility: "unknown", + source: "catalogue", + }, + ], + cliVersion: null, + warnings: [], + }); + }); + + it("defaults missing enriched fields without dropping warnings", () => { + expect( + normalizeProfileModelCatalog({ + models: [{ adapter: "claude", modelId: "claude-sonnet-5" }], + warnings: ["version inconnue"], + }), + ).toEqual({ + models: [ + { + adapter: "claude", + modelId: "claude-sonnet-5", + displayName: "claude-sonnet-5", + aliases: [], + recommended: false, + compatibility: "unknown", + source: "catalogue", + }, + ], + cliVersion: null, + warnings: ["version inconnue"], + }); + }); +}); diff --git a/frontend/src/adapters/profileCatalog.ts b/frontend/src/adapters/profileCatalog.ts new file mode 100644 index 0000000..42adf9e --- /dev/null +++ b/frontend/src/adapters/profileCatalog.ts @@ -0,0 +1,63 @@ +import type { + ModelCatalogSource, + ModelCompatibility, + ProfileModelCatalog, + ProfileModelCatalogEntry, +} from "@/domain"; + +type PartialCatalogEntry = Partial & { + adapter?: "claude" | "codex"; + modelId?: string; + displayName?: string; +}; + +function compatibilityOf(value: unknown): ModelCompatibility { + return value === "compatible" || + value === "unknown" || + value === "likelyTooRecent" + ? value + : "unknown"; +} + +function sourceOf(value: unknown): ModelCatalogSource { + return value === "provider" ? "provider" : "catalogue"; +} + +function normalizeEntry(raw: PartialCatalogEntry): ProfileModelCatalogEntry { + const modelId = raw.modelId ?? ""; + return { + adapter: raw.adapter ?? "codex", + modelId, + displayName: raw.displayName ?? modelId, + aliases: Array.isArray(raw.aliases) ? raw.aliases : [], + recommended: Boolean(raw.recommended), + compatibility: compatibilityOf(raw.compatibility), + source: sourceOf(raw.source), + }; +} + +export function normalizeProfileModelCatalog(raw: unknown): ProfileModelCatalog { + if (Array.isArray(raw)) { + return { + models: raw.map((entry) => normalizeEntry(entry as PartialCatalogEntry)), + cliVersion: null, + warnings: [], + }; + } + + const catalog = + raw && typeof raw === "object" + ? (raw as Partial) + : {}; + + return { + models: Array.isArray(catalog.models) + ? catalog.models.map((entry) => normalizeEntry(entry as PartialCatalogEntry)) + : [], + cliVersion: + typeof catalog.cliVersion === "string" ? catalog.cliVersion : null, + warnings: Array.isArray(catalog.warnings) + ? catalog.warnings.map(String) + : [], + }; +} diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 7e69149..2f14f34 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1096,6 +1096,15 @@ export interface OpenCodeProviderCatalogEntry { models: string[]; } +/** Estimated compatibility for a Codex/Claude model against the detected local CLI. */ +export type ModelCompatibility = + | "compatible" + | "unknown" + | "likelyTooRecent"; + +/** Origin of a model catalogue entry. */ +export type ModelCatalogSource = "catalogue" | "provider"; + /** One searchable model from the Codex/Claude structured-profile catalogues. */ export interface ProfileModelCatalogEntry { /** Structured adapter this model belongs to. */ @@ -1108,6 +1117,19 @@ export interface ProfileModelCatalogEntry { aliases: string[]; /** Whether this entry is the conservative default suggestion. */ recommended: boolean; + /** Best-effort compatibility estimate for the locally detected CLI version. */ + compatibility: ModelCompatibility; + /** Whether the entry comes from IdeA's catalogue or a provider-derived source. */ + source: ModelCatalogSource; +} + +/** Enriched Codex/Claude model catalogue. Manual model entry remains supported. */ +export interface ProfileModelCatalog { + models: ProfileModelCatalogEntry[]; + /** Detected local CLI version, or null when unavailable. */ + cliVersion: string | null; + /** Non-fatal catalogue/version diagnostics. */ + warnings: string[]; } /** diff --git a/frontend/src/features/first-run/ProfilesSettings.test.tsx b/frontend/src/features/first-run/ProfilesSettings.test.tsx index 75a50fb..9bbf952 100644 --- a/frontend/src/features/first-run/ProfilesSettings.test.tsx +++ b/frontend/src/features/first-run/ProfilesSettings.test.tsx @@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea import { DIProvider } from "@/app/di"; import { MockProfileGateway } from "@/adapters/mock"; import type { Gateways } from "@/ports"; -import type { ProfileModelCatalogEntry } from "@/domain"; +import type { ProfileModelCatalog } from "@/domain"; import { ProfilesSettings } from "./ProfilesSettings"; function renderSettings(profile: MockProfileGateway = new MockProfileGateway()) { @@ -94,14 +94,14 @@ describe("ProfilesSettings", () => { it("keeps manual model entry available when the catalogue fails", async () => { class CatalogueDownProfileGateway extends MockProfileGateway { - listCodexModels(): Promise { + listCodexModels(): Promise { return Promise.reject(new Error("catalogue down")); } } renderSettings(new CatalogueDownProfileGateway()); await waitReady(); - expect(await screen.findByText(/saisie manuelle active/)).toBeTruthy(); + expect(await screen.findByText(/Catalogue provider indisponible/)).toBeTruthy(); await createProfile(); const model = within(screen.getAllByRole("listitem")[0]).getByLabelText( @@ -109,5 +109,54 @@ describe("ProfilesSettings", () => { ) as HTMLInputElement; fireEvent.change(model, { target: { value: "future-codex-model" } }); expect(model.value).toBe("future-codex-model"); + expect(screen.getAllByText(/Catalogue provider indisponible/).length).toBeGreaterThan(0); + }); + + it("shows compatibility states in suggestions and contextual help", async () => { + renderSettings(); + await waitReady(); + await createProfile(); + + const row = screen.getAllByRole("listitem")[0]; + const model = within(row).getByLabelText(/modele du profil/) as HTMLInputElement; + fireEvent.focus(model); + + expect(await within(row).findByText("Compatible")).toBeTruthy(); + expect( + within(row).getByText( + /Compatible avec Codex CLI 0\.145\.0 d'après le catalogue local IdeA\./, + ), + ).toBeTruthy(); + + fireEvent.change(model, { target: { value: "" } }); + expect(within(row).getAllByText("Inconnu").length).toBeGreaterThan(0); + expect(within(row).getByText("Probablement trop récent")).toBeTruthy(); + + fireEvent.change(model, { target: { value: "future-codex-model" } }); + expect(within(row).getByText("Inconnu")).toBeTruthy(); + expect( + within(row).getByText( + /Compatibilité non connue pour Codex CLI 0\.145\.0 ; la saisie reste autorisée\./, + ), + ).toBeTruthy(); + }); + + it("saves likely-too-recent models and shows a non-blocking warning", async () => { + const { profile } = renderSettings(); + await waitReady(); + await createProfile(); + + const row = screen.getAllByRole("listitem")[0]; + const model = within(row).getByLabelText(/modele du profil/) as HTMLInputElement; + fireEvent.change(model, { target: { value: "gpt-5-mini" } }); + fireEvent.click(within(row).getByRole("button", { name: "Enregistrer" })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + expect(saved.some((p) => p.model === "gpt-5-mini")).toBe(true); + }); + expect( + await screen.findByText(/Ce modèle semble plus récent que votre Codex CLI 0\.145\.0/), + ).toBeTruthy(); }); }); diff --git a/frontend/src/features/first-run/ProfilesSettings.tsx b/frontend/src/features/first-run/ProfilesSettings.tsx index ab601d0..d3ad78d 100644 --- a/frontend/src/features/first-run/ProfilesSettings.tsx +++ b/frontend/src/features/first-run/ProfilesSettings.tsx @@ -8,12 +8,16 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { AgentProfile, GatewayError, + ModelCompatibility, + ProfileModelCatalog, ProfileModelCatalogEntry, } from "@/domain"; import { useGateways } from "@/app/di"; import { Button, Input, Panel, cn } from "@/shared"; type ProfileTab = "codex" | "claude" | "openCode"; +type ModelTab = "codex" | "claude"; +type CatalogState = Record; const TABS: Array<{ id: ProfileTab; label: string }> = [ { id: "codex", label: "Codex" }, @@ -21,9 +25,16 @@ const TABS: Array<{ id: ProfileTab; label: string }> = [ { id: "openCode", label: "OpenCode-local" }, ]; -const EMPTY_CATALOGUE: Record<"codex" | "claude", ProfileModelCatalogEntry[]> = { - codex: [], - claude: [], +const EMPTY_MODEL_CATALOG: ProfileModelCatalog & { unavailable: boolean } = { + models: [], + cliVersion: null, + warnings: [], + unavailable: false, +}; + +const EMPTY_CATALOGUE: CatalogState = { + codex: EMPTY_MODEL_CATALOG, + claude: EMPTY_MODEL_CATALOG, }; function describe(e: unknown): string { @@ -66,6 +77,185 @@ function optionLabel(entry: ProfileModelCatalogEntry): string { : `${entry.displayName} (${entry.modelId})`; } +function compatibilityLabel(compatibility: ModelCompatibility): string { + if (compatibility === "compatible") return "Compatible"; + if (compatibility === "likelyTooRecent") return "Probablement trop récent"; + return "Inconnu"; +} + +function engineLabel(tab: ModelTab): string { + return tab === "codex" ? "Codex CLI" : "Claude CLI"; +} + +function attentionBadge( + compatibility: ModelCompatibility, + cliVersion: string | null, +): string | null { + if (!cliVersion) return "CLI non détecté"; + if (compatibility === "compatible") return null; + return compatibilityLabel(compatibility); +} + +function catalogEntryFor( + model: string, + models: ProfileModelCatalogEntry[], +): ProfileModelCatalogEntry | null { + const normalized = model.trim().toLowerCase(); + if (!normalized) return null; + return models.find((entry) => entry.modelId.toLowerCase() === normalized) ?? null; +} + +function compatibilityFor( + model: string, + models: ProfileModelCatalogEntry[], +): ModelCompatibility { + return catalogEntryFor(model, models)?.compatibility ?? "unknown"; +} + +function modelHelp( + tab: ModelTab, + model: string, + catalog: ProfileModelCatalog & { unavailable: boolean }, +): string { + const cli = engineLabel(tab); + if (catalog.unavailable) { + return "Catalogue provider indisponible ; vous pouvez saisir le modèle manuellement."; + } + if (!catalog.cliVersion) { + return `Version du ${cli} non détectée ; IdeA ne peut pas estimer la compatibilité.`; + } + const compatibility = compatibilityFor(model, catalog.models); + if (compatibility === "compatible") { + return `Compatible avec ${cli} ${catalog.cliVersion} d'après le catalogue local IdeA.`; + } + if (compatibility === "likelyTooRecent") { + return `Probablement trop récent pour ${cli} ${catalog.cliVersion} ; mettez à jour le CLI si le lancement échoue.`; + } + return `Compatibilité non connue pour ${cli} ${catalog.cliVersion} ; la saisie reste autorisée.`; +} + +function saveWarningText(tab: ModelTab, cliVersion: string | null): string { + const cli = engineLabel(tab); + const version = cliVersion ? ` ${cliVersion}` : ""; + return `Ce modèle semble plus récent que votre ${cli}${version}. Le profil peut être enregistré, mais l'agent pourrait échouer au lancement tant que le CLI n'est pas mis à jour.`; +} + +function matchingSuggestions( + model: string, + models: ProfileModelCatalogEntry[], +): ProfileModelCatalogEntry[] { + const q = model.trim().toLowerCase(); + const filtered = q + ? models.filter((entry) => + [entry.modelId, entry.displayName, ...entry.aliases] + .join(" ") + .toLowerCase() + .includes(q), + ) + : models; + return filtered.slice(0, 5); +} + +function ModelField({ + profileId, + profileName, + tab, + model, + catalog, + onChange, +}: { + profileId: string; + profileName: string; + tab: ModelTab; + model: string; + catalog: ProfileModelCatalog & { unavailable: boolean }; + onChange: (model: string) => void; +}) { + const [focused, setFocused] = useState(false); + const inputId = `profile-model-${tab}-${profileId}`; + const compatibility = compatibilityFor(model, catalog.models); + const badge = attentionBadge(compatibility, catalog.cliVersion); + const suggestions = matchingSuggestions(model, catalog.models); + + return ( +
+ + 0 + ? "Choisir ou saisir un modèle" + : "Saisir un modèle" + } + value={model} + onFocus={() => setFocused(true)} + onBlur={() => window.setTimeout(() => setFocused(false), 120)} + onChange={(e) => onChange(e.target.value)} + /> + {modelHelp(tab, model, catalog)} + {focused && suggestions.length > 0 && ( +
+ {suggestions.map((entry) => ( + + ))} +
+ )} +
+ ); +} + export function ProfilesSettings() { const { profile } = useGateways(); const [profiles, setProfiles] = useState([]); @@ -75,6 +265,7 @@ export function ProfilesSettings() { const [drafts, setDrafts] = useState>({}); const [error, setError] = useState(null); const [catalogueWarning, setCatalogueWarning] = useState(null); + const [saveWarnings, setSaveWarnings] = useState>({}); const [busy, setBusy] = useState(false); const refresh = useCallback(async () => { @@ -106,12 +297,18 @@ export function ProfilesSettings() { ]); if (cancelled) return; setCatalogue({ - codex: codex.status === "fulfilled" ? codex.value : [], - claude: claude.status === "fulfilled" ? claude.value : [], + codex: + codex.status === "fulfilled" + ? { ...codex.value, unavailable: false } + : { ...EMPTY_MODEL_CATALOG, unavailable: true }, + claude: + claude.status === "fulfilled" + ? { ...claude.value, unavailable: false } + : { ...EMPTY_MODEL_CATALOG, unavailable: true }, }); if (codex.status === "rejected" || claude.status === "rejected") { setCatalogueWarning( - "Catalogue de modeles indisponible: saisie manuelle active.", + "Catalogue provider indisponible ; vous pouvez saisir le modèle manuellement.", ); } } @@ -146,7 +343,7 @@ export function ProfilesSettings() { try { const models = activeTab === "codex" || activeTab === "claude" - ? catalogue[activeTab] + ? catalogue[activeTab].models : []; const recommended = models.find((m) => m.recommended)?.modelId; await profile.cloneProfileFromSeed({ @@ -167,9 +364,24 @@ export function ProfilesSettings() { if (!draft) return; setBusy(true); setError(null); + setSaveWarnings((prev) => { + const { [id]: _ignored, ...rest } = prev; + return rest; + }); try { + const tab = tabFor(draft); + const warning = + tab === "codex" || tab === "claude" + ? compatibilityFor(modelOf(draft), catalogue[tab].models) === + "likelyTooRecent" + ? saveWarningText(tab, catalogue[tab].cliVersion) + : null + : null; await profile.saveProfile(draft); await refresh(); + if (warning) { + setSaveWarnings((prev) => ({ ...prev, [id]: warning })); + } } catch (e) { setError(describe(e)); } finally { @@ -212,7 +424,9 @@ export function ProfilesSettings() { } const modelOptions = - activeTab === "codex" || activeTab === "claude" ? catalogue[activeTab] : []; + activeTab === "codex" || activeTab === "claude" + ? catalogue[activeTab].models + : []; return ( {catalogueWarning}

)} - - {modelOptions.map((entry) => ( - + {(activeTab === "codex" || activeTab === "claude") && + catalogue[activeTab].warnings.map((warning) => ( +

+ {warning} +

))} -
{visibleProfiles.length === 0 ? (

@@ -296,25 +509,42 @@ export function ProfilesSettings() { /> - + ) : ( + + )}

+ {saveWarnings[saved.id] && ( +

+ {saveWarnings[saved.id]} +

+ )}
diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index cc4815f..4a1af1c 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -37,7 +37,7 @@ import type { McpToolPolicy, OpenCodeConfig, OpenCodeProviderCatalogEntry, - ProfileModelCatalogEntry, + ProfileModelCatalog, EffectivePermissions, PairedDevice, PairingCode, @@ -670,10 +670,10 @@ export interface ProfileGateway { * Used by Settings duplication for Codex/Claude/OpenCode identity copies. */ cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise; - /** Curated Claude Code model catalogue. Manual model entry remains supported. */ - listClaudeModels(): Promise; - /** Curated Codex CLI model catalogue. Manual model entry remains supported. */ - listCodexModels(): Promise; + /** Enriched Claude Code model catalogue. Manual model entry remains supported. */ + listClaudeModels(): Promise; + /** Enriched Codex CLI model catalogue. Manual model entry remains supported. */ + listCodexModels(): Promise; /** Persists the batch of chosen profiles, closing the first run. */ configureProfiles(profiles: AgentProfile[]): Promise; /** From 4321d048acb329b54910190aa1821910d866b4e8 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 26 Jul 2026 16:52:23 +0200 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20correction=20de=20r=C3=A9gression=20?= =?UTF-8?q?UI=20wizard=20first-run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/first-run/FirstRunWizard.tsx | 630 +----------------- .../features/first-run/OpenCodeModeFields.tsx | 629 +++++++++++++++++ .../first-run/ProfilesSettings.test.tsx | 70 +- .../features/first-run/ProfilesSettings.tsx | 52 +- 4 files changed, 728 insertions(+), 653 deletions(-) create mode 100644 frontend/src/features/first-run/OpenCodeModeFields.tsx diff --git a/frontend/src/features/first-run/FirstRunWizard.tsx b/frontend/src/features/first-run/FirstRunWizard.tsx index 0c95f8a..edfcb7b 100644 --- a/frontend/src/features/first-run/FirstRunWizard.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.tsx @@ -15,85 +15,34 @@ * `./profile`. */ -import { useCallback, useEffect, useState } from "react"; - import type { AgentProfile, - GatewayError, HttpChatConfig, LocalModelServerConfig, - OpenCodeConfig, - OpenCodeProviderCatalogEntry, } from "@/domain"; -import { useGateways } from "@/app/di"; import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared"; import { ModelServersPanel, - ModelServerSelect, useModelServers, } from "@/features/model-servers"; import { useFirstRun, type WizardEntry } from "./useFirstRun"; import { defaultHttpChatConfig, - defaultOpenCodeConfig, parseArgs, validateProfile, type ProfileErrors, } from "./profile"; +import { + OpenCodeModeFields, + useOpenCodeProviderCatalog, + type OpenCodeProviderCatalog, +} from "./OpenCodeModeFields"; /** A small caption above a control. */ function Caption({ children }: { children: React.ReactNode }) { return {children}; } -function describeError(e: unknown): string { - if (e && typeof e === "object" && "message" in e) { - return String((e as GatewayError).message); - } - return String(e); -} - -/** View-model for the OpenCode cloud-provider catalogue (ticket #92). */ -interface OpenCodeProviderCatalog { - providers: OpenCodeProviderCatalogEntry[] | null; - loading: boolean; - error: string | null; - reload: () => void; -} - -/** - * Loads the static OpenCode cloud-provider catalogue once for the whole - * wizard (every Cloud row shares it), so the provider/model pickers can be - * populated. Exposes a `reload` for the blocking "Réessayer" state. - */ -function useOpenCodeProviderCatalog(): OpenCodeProviderCatalog { - const { profile } = useGateways(); - const [providers, setProviders] = useState( - null, - ); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { - setProviders(await profile.listOpenCodeProviders()); - } catch (e) { - setProviders(null); - setError(describeError(e)); - } finally { - setLoading(false); - } - }, [profile]); - - useEffect(() => { - void load(); - }, [load]); - - return { providers, loading, error, reload: () => void load() }; -} - /** * Renders the wizard when it is the first run. Calls `onDone` once the user * finishes (so the host can drop the wizard and show the normal UI). Returns @@ -339,467 +288,6 @@ function ProfileRow({ ); } -/** - * Segmented control (F — ticket #92) choosing whether an OpenCode profile runs - * against the local `llama.cpp` endpoint or a cloud provider from the OpenCode - * registry, and renders the matching sub-form. The two sub-forms never overlap; - * switching segments keeps the inactive one's draft in memory (component-local - * state) without touching `profile` until it is actually submitted. - */ -function OpenCodeModeFields({ - profile, - errors, - servers, - providerCatalog, - onChange, -}: { - profile: AgentProfile; - errors: ProfileErrors; - servers: LocalModelServerConfig[]; - providerCatalog: OpenCodeProviderCatalog; - onChange: (p: AgentProfile) => void; -}) { - const [mode, setMode] = useState<"local" | "cloud">( - profile.opencodeProvider ? "cloud" : "local", - ); - - return ( -
-
- {( - [ - { id: "local", label: "Local (llama.cpp)" }, - { id: "cloud", label: "Provider cloud" }, - ] as const - ).map((seg) => { - const active = mode === seg.id; - return ( - - ); - })} -
- - {mode === "local" && ( - - )} - - {mode === "cloud" && ( - - )} -
- ); -} - -/** Field-keyed validation errors for the Cloud sub-form, surfaced on submit. */ -interface CloudFieldErrors { - providerId?: string; - model?: string; - apiKey?: string; - npm?: string; - baseUrl?: string; -} - -/** Sentinel `
+ + {draft.structuredAdapter === "openCode" && ( + + updateDraft(saved.id, () => next) + } + /> + )} + {saveWarnings[saved.id] && (

{saveWarnings[saved.id]} From 02603441c141f1028980a10e53aac27d0c57ca39 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 26 Jul 2026 19:42:06 +0200 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20livrable=20tickets=20#70=20#100=20#1?= =?UTF-8?q?02=20=E2=80=94=20UX=20surface=20scoping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #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 --- .ideai/memory/MEMORY.md | 1 + .../tickets-70-100-102-ux-surface-scoping.md | 51 +++ .ideai/tickets/100/issue.md | 10 +- .ideai/tickets/102/issue.md | 10 +- .ideai/tickets/70/issue.md | 10 +- .ideai/tickets/counter.json | 2 +- .ideai/tickets/index.json | 14 +- crates/app-tauri/src/commands.rs | 21 + crates/app-tauri/src/lib.rs | 1 + crates/app-tauri/tests/dto_model_servers.rs | 1 + crates/application/src/lib.rs | 10 +- crates/application/src/model_server.rs | 277 +++++++++++- crates/application/src/terminal/registry.rs | 84 ++-- crates/application/tests/model_server.rs | 419 +++++++++++++++++- crates/backend/src/dto.rs | 58 ++- crates/backend/src/lib.rs | 57 ++- crates/domain/src/ports.rs | 33 ++ crates/infrastructure/src/model_server/mod.rs | 109 ++++- crates/infrastructure/tests/model_server.rs | 42 +- .../adapters/http/requestResponseGateways.ts | 3 + frontend/src/adapters/mock/index.ts | 30 ++ frontend/src/adapters/modelServer.ts | 4 + frontend/src/domain/index.ts | 13 + frontend/src/features/git/git.test.tsx | 4 +- .../model-servers/ModelServersPanel.tsx | 138 ++++-- .../model-servers/modelServers.test.tsx | 91 +++- .../features/model-servers/useModelServers.ts | 61 ++- .../TerminalView.scrollback.test.tsx | 67 +++ .../features/terminals/TerminalView.test.tsx | 21 + .../src/features/terminals/TerminalView.tsx | 90 +++- frontend/src/ports/index.ts | 9 +- 31 files changed, 1589 insertions(+), 152 deletions(-) create mode 100644 .ideai/memory/tickets-70-100-102-ux-surface-scoping.md create mode 100644 frontend/src/features/terminals/TerminalView.scrollback.test.tsx diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 5d8a66b..e4d95b9 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -72,3 +72,4 @@ - [codex-network-access-config-fix](codex-network-access-config-fix.md) — memory note codex-network-access-config-fix - [multi-profile-codex-claude-model-catalogue-scoping](multi-profile-codex-claude-model-catalogue-scoping.md) — memory note multi-profile-codex-claude-model-catalogue-scoping - [model-catalogue-compat-cadrage](model-catalogue-compat-cadrage.md) — Frontières hexagonales, ports, DTO, fallback et matrice de compatibilité versionnée pour l'évolution du catalogue de modèles des profils structurés Codex/Claude. +- [tickets-70-100-102-ux-surface-scoping](tickets-70-100-102-ux-surface-scoping.md) — memory note tickets-70-100-102-ux-surface-scoping diff --git a/.ideai/memory/tickets-70-100-102-ux-surface-scoping.md b/.ideai/memory/tickets-70-100-102-ux-surface-scoping.md new file mode 100644 index 0000000..ed0310f --- /dev/null +++ b/.ideai/memory/tickets-70-100-102-ux-surface-scoping.md @@ -0,0 +1,51 @@ +--- +name: tickets-70-100-102-ux-surface-scoping +description: memory note tickets-70-100-102-ux-surface-scoping +metadata: + type: project +--- +--- +title: Cadrage UX tickets #70 #100 #102 — modèles locaux et bugs terminal +type: design +description: Surface utilisateur attendue pour supprimer les modèles llama.cpp téléchargés, et règle UX pour les bugs de scroll/fit OpenCode qui doivent être corrigés sans nouvelle UI. +--- + +# Cadrage UX tickets #70 #100 #102 + +## #70 — Gestion des modèles locaux téléchargés + +Ajouter une affordance humaine de suppression des artefacts de modèles téléchargés par les serveurs locaux llama.cpp, dans la surface existante `Local model servers` / configuration OpenCode locale. + +Principes visibles : +- La suppression d'un serveur déclaré et la suppression du fichier modèle téléchargé sont deux actions distinctes. +- Une action destructive sur le fichier modèle doit être explicite, confirmée, et impossible pendant un usage actif/téléchargement du même artefact. +- Le libellé doit parler de `modèle téléchargé`, pas de cache interne ou chemin technique en premier niveau. +- Les modèles issus d'un `localPath` utilisateur ne doivent jamais être proposés à la suppression comme s'ils appartenaient à IdeA. + +États attendus par serveur : +- Aucun modèle téléchargé connu : aucun bouton de suppression de modèle, ou bouton désactivé avec tooltip `Aucun modèle téléchargé par IdeA`. +- Modèle téléchargé disponible : bouton secondaire/destructif `Supprimer le modèle téléchargé`. +- Téléchargement/préparation en cours : action désactivée, texte `Téléchargement en cours`. +- Serveur/agent utilisant ce modèle : action désactivée, texte `Modèle utilisé par un agent en cours`. +- Suppression en cours : ligne locale occupée, action désactivée, message `Suppression du modèle...`. +- Succès : toast/status non bloquant `Modèle téléchargé supprimé` ; la configuration serveur reste présente. +- Échec : alerte inline `Impossible de supprimer le modèle téléchargé : `. + +Confirmation : +- Titre : `Supprimer le modèle téléchargé ?` +- Corps : `Le serveur local restera configuré, mais IdeA devra retélécharger ce modèle au prochain lancement.` +- Si la taille est connue : ajouter `Espace libéré : .` +- Action principale destructive : `Supprimer le modèle` +- Action secondaire : `Annuler` + +## #100 — Scroll OpenCode + +Pas de décision UX spécifique. Le comportement attendu est celui d'une cellule terminal native : l'utilisateur peut remonter dans le scrollback OpenCode jusqu'à la limite de rétention disponible, avec molette, trackpad, scrollbar et clavier, sans blocage prématuré propre à OpenCode. + +Ne pas ajouter de bouton, message ou mode spécial OpenCode. QA doit valider le comportement visible dans une cellule OpenCode longue. + +## #102 — Fit TUI après switch/layout/ajout cellule + +Pas de nouvelle surface UX spécifique. Le terminal doit s'afficher correctement automatiquement après switch de projet, switch de layout, ajout/suppression/split/resize de cellules et rattachement d'une session existante. + +Ne pas afficher de message demandant à l'utilisateur de redimensionner. Éviter tout flash durable vide/noir ; un voile technique transitoire n'est acceptable que s'il reste très bref et non bloquant. \ No newline at end of file diff --git a/.ideai/tickets/100/issue.md b/.ideai/tickets/100/issue.md index 65569e5..0da3257 100644 --- a/.ideai/tickets/100/issue.md +++ b/.ideai/tickets/100/issue.md @@ -2,15 +2,15 @@ id: "4709958c-5082-44fd-a1fd-d6bad85f9361" number: 100 title: "[Bug] Problème sur le scroll des agents OpenCode" -status: "open" +status: "closed" priority: "high" sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2" links: [] agentRefs: [] createdBy: {"kind":"user"} -updatedBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdAt: 1784992615586 -updatedAt: 1784993984569 -version: 3 +updatedAt: 1785083912470 +version: 4 --- -Quand un agent est un agent opencode, je ne peux aps scroll très haut dans sa cellule. \ No newline at end of file +Quand un agent est un agent opencode, je ne peux aps scroll très haut dans sa cellule. diff --git a/.ideai/tickets/102/issue.md b/.ideai/tickets/102/issue.md index 9f6c347..a13e871 100644 --- a/.ideai/tickets/102/issue.md +++ b/.ideai/tickets/102/issue.md @@ -2,15 +2,15 @@ id: "e91fd358-da94-4382-aa70-6a3fe5a63840" number: 102 title: "[Bug] Devoir resize les cellule pour afficher la TUI d'un agent" -status: "open" +status: "closed" priority: "medium" sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2" links: [] agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdAt: 1784993319700 -updatedAt: 1784993980505 -version: 4 +updatedAt: 1785083912470 +version: 5 --- -J'ai toujours un soucis qui fait que quand je switch de projet IdeA ou de layout ou que j'ajoute des cellules ou autres, je suis obligé de resize un coup la cellule pour que son constenu s'affiche correctement \ No newline at end of file +J'ai toujours un soucis qui fait que quand je switch de projet IdeA ou de layout ou que j'ajoute des cellules ou autres, je suis obligé de resize un coup la cellule pour que son constenu s'affiche correctement diff --git a/.ideai/tickets/70/issue.md b/.ideai/tickets/70/issue.md index 945c969..0587f78 100644 --- a/.ideai/tickets/70/issue.md +++ b/.ideai/tickets/70/issue.md @@ -2,15 +2,15 @@ id: "e73ed25f-8e22-48a5-84ea-521212ea80b5" number: 70 title: "Pouvoir gérer les models AI locaux téléchargés via les serveur llamacpp" -status: "open" +status: "closed" priority: "low" sprint: null links: [] agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}] createdBy: {"kind":"user"} -updatedBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdAt: 1784194016529 -updatedAt: 1784194049447 -version: 4 +updatedAt: 1785083912470 +version: 5 --- -Avec les serveurs de models locaux llamacpp, on télécharge des modeles, j'aimerais aussi pouvoir les supprimer \ No newline at end of file +Avec les serveurs de models locaux llamacpp, on télécharge des modeles, j'aimerais aussi pouvoir les supprimer diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 053f7df..5c590ed 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 104 + "nextNumber": 107 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 462a4ec..be21241 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -731,13 +731,13 @@ "issueRef": "#70", "path": "70", "title": "Pouvoir gérer les models AI locaux téléchargés via les serveur llamacpp", - "status": "open", + "status": "closed", "priority": "low", "sprint": null, "assignedAgentIds": [ "a6ced819-b893-4213-b003-9e9dc79b9641" ], - "updatedAt": 1784194049447 + "updatedAt": 1785083912470 }, { "issueRef": "#71", @@ -1035,11 +1035,11 @@ "issueRef": "#100", "path": "100", "title": "[Bug] Problème sur le scroll des agents OpenCode", - "status": "open", + "status": "closed", "priority": "high", "sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2", "assignedAgentIds": [], - "updatedAt": 1784993984569 + "updatedAt": 1785083912470 }, { "issueRef": "#101", @@ -1057,13 +1057,13 @@ "issueRef": "#102", "path": "102", "title": "[Bug] Devoir resize les cellule pour afficher la TUI d'un agent", - "status": "open", + "status": "closed", "priority": "medium", "sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2", "assignedAgentIds": [ "a6ced819-b893-4213-b003-9e9dc79b9641" ], - "updatedAt": 1784993980505 + "updatedAt": 1785083912470 }, { "issueRef": "#103", @@ -1078,4 +1078,4 @@ "updatedAt": 1785013507979 } ] -} \ No newline at end of file +} diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 82fe758..469e797 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -1339,6 +1339,7 @@ pub async fn save_model_server( .map_err(ErrorDto::from)? .servers .into_iter() + .map(|item| item.config) .find(|config| config.id == server_id); let input = save_model_server_input(request, existing.as_ref())?; state @@ -1388,6 +1389,26 @@ pub async fn delete_model_server( .map_err(model_server_command_error) } +/// `delete_model_artifact` — delete a managed downloaded model artifact while +/// keeping the local model-server config. +/// +/// # Errors +/// Returns `invalid` for non-managed `localPath` sources, `model_server_in_use` +/// when a download or live agent blocks deletion, and model-server errors for +/// cache I/O failures. +#[tauri::command] +pub async fn delete_model_artifact( + server_id: String, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let server_id = parse_model_server_id(&server_id)?; + state + .delete_model_artifact + .execute(application::DeleteModelArtifactInput { server_id }) + .await + .map_err(model_server_command_error) +} + fn model_server_command_error(err: AppError) -> ErrorDto { match err { AppError::ModelServer { code, message } => ErrorDto { code, message }, diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index f760220..a11990f 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -265,6 +265,7 @@ pub fn run() { commands::save_model_server, commands::preview_model_server_command, commands::delete_model_server, + commands::delete_model_artifact, commands::list_embedder_profiles, commands::save_embedder_profile, commands::delete_embedder_profile, diff --git a/crates/app-tauri/tests/dto_model_servers.rs b/crates/app-tauri/tests/dto_model_servers.rs index 4b79093..87f93a7 100644 --- a/crates/app-tauri/tests/dto_model_servers.rs +++ b/crates/app-tauri/tests/dto_model_servers.rs @@ -132,6 +132,7 @@ fn model_server_dto_preserves_existing_internal_model_id_on_upsert() { auto_start: true, stop_policy: StopPolicyDto::StopOnAppExit, warmup_deadline_secs: Some(900), + artifact: Default::default(), }; let config = dto.into_domain(Some(&existing)).unwrap(); diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 43800f3..426b1b6 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -127,10 +127,12 @@ pub use memory::{ UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, }; pub use model_server::{ - model_server_error_code, DeleteModelServer, DeleteModelServerInput, EnsureLocalModelServer, - EnsureLocalModelServerInput, EnsureLocalModelServerOutput, ListModelServers, - ListModelServersOutput, ReadinessPolicy as ModelServerReadinessPolicy, SaveModelServer, - SaveModelServerInput, SaveModelServerOutput, + model_server_error_code, DeleteModelArtifact, DeleteModelArtifactInput, DeleteModelServer, + DeleteModelServerInput, EnsureLocalModelServer, EnsureLocalModelServerInput, + EnsureLocalModelServerOutput, ListModelServers, ListModelServersOutput, + ModelArtifactDownloadTracker, ModelArtifactView, ModelServerListItem, + ReadinessPolicy as ModelServerReadinessPolicy, SaveModelServer, SaveModelServerInput, + SaveModelServerOutput, }; pub use orchestrator::{ resolve_rendezvous_ceiling, resolve_rendezvous_window, run_inactivity_watchdog, diff --git a/crates/application/src/model_server.rs b/crates/application/src/model_server.rs index 1aa9eba..26b83c5 100644 --- a/crates/application/src/model_server.rs +++ b/crates/application/src/model_server.rs @@ -11,33 +11,101 @@ use domain::model_server::{ ModelSource, }; use domain::ports::{ - EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, ModelArtifactCancel, - ModelArtifactDownloader, ModelArtifactProgress, ModelServerError, ModelServerProbe, - ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore, RemotePath, + AgentContextStore, EventBus, FileSystem, ManagedProcess, ManagedProcessHandle, + ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactState, + ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, + ProfileStore, ProjectStore, RemotePath, }; use domain::{LocalModelServerId, StopPolicy}; use tokio::sync::{Mutex as AsyncMutex, Notify}; use tokio::time::Instant; use crate::error::AppError; +use crate::terminal::LiveAgentRegistry; + +/// Artifact cache state exposed by model-server list use cases. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModelArtifactView { + /// The configured source is not managed by IdeA's downloader. + NotManaged, + /// The configured source is managed but not present in cache. + Missing, + /// A download/prepare operation is currently running for this server. + Downloading, + /// The configured source is present in cache. + Downloaded { + /// Local artifact path used by llama.cpp. + path: String, + /// Total on-disk size when known. + size_bytes: Option, + }, +} + +impl From for ModelArtifactView { + fn from(state: ModelArtifactState) -> Self { + match state { + ModelArtifactState::NotManaged => Self::NotManaged, + ModelArtifactState::Missing => Self::Missing, + ModelArtifactState::Downloaded { path, size_bytes } => Self::Downloaded { + path: path.as_str().to_owned(), + size_bytes, + }, + } + } +} + +/// A configured local model server plus derived artifact state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelServerListItem { + /// Persisted local model-server config. + pub config: LocalModelServerConfig, + /// Derived artifact cache state. + pub artifact: ModelArtifactView, +} /// Output of [`ListModelServers::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListModelServersOutput { - /// Persisted local model-server configs. - pub servers: Vec, + /// Persisted local model-server configs enriched with artifact state. + pub servers: Vec, } /// Lists local model-server configurations. pub struct ListModelServers { registry: Arc, + downloader: Option>, + downloads: Option>, } impl ListModelServers { /// Builds the use case. #[must_use] pub fn new(registry: Arc) -> Self { - Self { registry } + Self { + registry, + downloader: None, + downloads: None, + } + } + + /// Enables artifact state enrichment for Hugging Face-backed servers. + #[must_use] + pub fn with_model_artifact_downloader( + mut self, + downloader: Arc, + ) -> Self { + self.downloader = Some(downloader); + self + } + + /// Enables in-flight download state enrichment. + #[must_use] + pub fn with_download_tracker( + mut self, + downloads: Arc, + ) -> Self { + self.downloads = Some(downloads); + self } /// Lists configs. @@ -45,10 +113,44 @@ impl ListModelServers { /// # Errors /// [`AppError::ModelServer`] on registry failure. pub async fn execute(&self) -> Result { - Ok(ListModelServersOutput { - servers: self.registry.list().await?, - }) + let configs = self.registry.list().await?; + let mut servers = Vec::with_capacity(configs.len()); + for config in configs { + let artifact = self.artifact_view(&config).await?; + servers.push(ModelServerListItem { config, artifact }); + } + Ok(ListModelServersOutput { servers }) } + + async fn artifact_view( + &self, + config: &LocalModelServerConfig, + ) -> Result { + if self + .downloads + .as_ref() + .is_some_and(|downloads| downloads.is_model_artifact_download_in_progress(config.id)) + { + return Ok(ModelArtifactView::Downloading); + } + let Some(ModelSource::HuggingFace { repo }) = config.model.source.as_ref() else { + return Ok(ModelArtifactView::NotManaged); + }; + let Some(downloader) = self.downloader.as_ref() else { + return Ok(ModelArtifactView::Missing); + }; + downloader + .hf_model_state(repo) + .await + .map(ModelArtifactView::from) + .map_err(AppError::from) + } +} + +/// Read-only in-flight download state shared by list/delete use cases. +pub trait ModelArtifactDownloadTracker: Send + Sync { + /// Whether the model artifact for `server_id` is currently being resolved/downloaded. + fn is_model_artifact_download_in_progress(&self, server_id: LocalModelServerId) -> bool; } /// Input for [`SaveModelServer::execute`]. @@ -132,6 +234,154 @@ impl DeleteModelServer { } } +/// Input for [`DeleteModelArtifact::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeleteModelArtifactInput { + /// Config id whose managed artifact cache should be deleted. + pub server_id: LocalModelServerId, +} + +/// Deletes a downloaded model artifact without deleting the server config. +pub struct DeleteModelArtifact { + registry: Arc, + probe: Arc, + downloader: Arc, + downloads: Arc, + profiles: Arc, + projects: Arc, + contexts: Arc, + live: Arc, +} + +impl DeleteModelArtifact { + /// Builds the use case. + #[allow(clippy::too_many_arguments)] + #[must_use] + pub fn new( + registry: Arc, + probe: Arc, + downloader: Arc, + downloads: Arc, + profiles: Arc, + projects: Arc, + contexts: Arc, + live: Arc, + ) -> Self { + Self { + registry, + probe, + downloader, + downloads, + profiles, + projects, + contexts, + live, + } + } + + /// Deletes a managed Hugging Face artifact after safety checks. + /// + /// # Errors + /// [`AppError::ModelServer`] when the server is missing, the source is not + /// deletable, a download is active, or a live agent uses the server. + pub async fn execute(&self, input: DeleteModelArtifactInput) -> Result<(), AppError> { + let config = self + .registry + .get(&input.server_id) + .await? + .ok_or(ModelServerError::NotConfigured)?; + let Some(ModelSource::HuggingFace { repo }) = config.model.source.as_ref() else { + return Err(ModelServerError::Invalid( + "only managed Hugging Face model artifacts can be deleted".to_owned(), + ) + .into()); + }; + if self + .downloads + .is_model_artifact_download_in_progress(input.server_id) + { + return Err(ModelServerError::InUse(format!( + "model artifact download in progress for {}", + input.server_id + )) + .into()); + } + self.ensure_server_not_reachable(&config).await?; + self.ensure_not_used_by_live_agent(input.server_id).await?; + self.downloader.delete_hf_model(repo).await?; + Ok(()) + } + + async fn ensure_server_not_reachable( + &self, + config: &LocalModelServerConfig, + ) -> Result<(), AppError> { + match self.probe.probe(&config.endpoint).await? { + ModelServerStatus::Unreachable => Ok(()), + ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted => { + Err(ModelServerError::InUse(format!( + "model server {} is currently reachable", + config.id + )) + .into()) + } + } + } + + async fn ensure_not_used_by_live_agent( + &self, + server_id: LocalModelServerId, + ) -> Result<(), AppError> { + let profiles = self.profiles.list().await?; + let profile_server: HashMap<_, _> = profiles + .iter() + .filter_map(|profile| { + profile + .opencode + .as_ref() + .and_then(|opencode| opencode.local_model_server_id) + .map(|id| (profile.id, id)) + }) + .collect(); + + let mut agents_by_project = HashMap::new(); + for snapshot in self.live.live_agent_snapshots() { + let agents = if let Some(agents) = agents_by_project.get(&snapshot.project_id) { + agents + } else { + let project = self.projects.load_project(snapshot.project_id).await?; + let manifest = self.contexts.load_manifest(&project).await?; + agents_by_project.insert( + snapshot.project_id, + manifest + .entries + .iter() + .map(|entry| { + entry + .to_agent() + .map_err(|err| AppError::Invalid(err.to_string())) + }) + .collect::, _>>()?, + ); + agents_by_project + .get(&snapshot.project_id) + .expect("project agents inserted") + }; + let Some(agent) = agents.iter().find(|agent| agent.id == snapshot.agent_id) else { + continue; + }; + if profile_server.get(&agent.profile_id) == Some(&server_id) { + return Err(ModelServerError::InUse(format!( + "model server {server_id} is used by live agent {}", + agent.id + )) + .into()); + } + } + Ok(()) + } +} + /// Input for [`EnsureLocalModelServer::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct EnsureLocalModelServerInput { @@ -655,6 +905,15 @@ impl EnsureLocalModelServer { } } +impl ModelArtifactDownloadTracker for EnsureLocalModelServer { + fn is_model_artifact_download_in_progress(&self, server_id: LocalModelServerId) -> bool { + self.download_cancels + .lock() + .unwrap() + .contains_key(&server_id) + } +} + fn ready(config: &LocalModelServerConfig, status: ModelServerStatus) -> ModelServerReady { ModelServerReady { base_url: config.endpoint.base_url.clone(), diff --git a/crates/application/src/terminal/registry.rs b/crates/application/src/terminal/registry.rs index 7ffff67..9dcf356 100644 --- a/crates/application/src/terminal/registry.rs +++ b/crates/application/src/terminal/registry.rs @@ -64,6 +64,11 @@ pub trait LiveAgentRegistry: Send + Sync { /// be keyed on the hosting node, not the agent (otherwise a duplicate leaf /// would be wrongly marked as still running). fn is_node_live(&self, node_id: &NodeId) -> bool; + + /// Snapshots every live agent session currently known by this registry. + fn live_agent_snapshots(&self) -> Vec { + Vec::new() + } } /// In-memory registry of active terminal sessions. @@ -89,6 +94,26 @@ impl LiveAgentRegistry for TerminalSessions { .map(|m| m.values().any(|e| e.session.node_id == *node_id)) .unwrap_or(false) } + + fn live_agent_snapshots(&self) -> Vec { + self.entries + .lock() + .map(|m| { + m.values() + .filter_map(|e| match e.session.kind { + SessionKind::Agent { agent_id } => Some(LiveSessionSnapshot { + project_id: e.project_id, + agent_id, + node_id: e.session.node_id, + session_id: e.session.id, + kind: LiveSessionKind::Pty, + }), + SessionKind::Plain => None, + }) + .collect() + }) + .unwrap_or_default() + } } impl TerminalSessions { @@ -426,6 +451,23 @@ impl LiveAgentRegistry for StructuredSessions { .map(|m| m.values().any(|e| e.node_id == *node_id)) .unwrap_or(false) } + + fn live_agent_snapshots(&self) -> Vec { + self.entries + .lock() + .map(|m| { + m.values() + .map(|e| LiveSessionSnapshot { + project_id: e.project_id, + agent_id: e.agent_id, + node_id: e.node_id, + session_id: e.session.id(), + kind: LiveSessionKind::Structured, + }) + .collect() + }) + .unwrap_or_default() + } } impl StructuredSessions { @@ -819,42 +861,8 @@ impl LiveSessions { /// Tous les agents vivants avec le type de registre source (PTY puis structuré). #[must_use] pub fn live_agent_snapshots(&self) -> Vec { - let mut all: Vec = self - .pty - .entries - .lock() - .map(|m| { - m.values() - .filter_map(|e| match e.session.kind { - SessionKind::Agent { agent_id } => Some(LiveSessionSnapshot { - project_id: e.project_id, - agent_id, - node_id: e.session.node_id, - session_id: e.session.id, - kind: LiveSessionKind::Pty, - }), - SessionKind::Plain => None, - }) - .collect() - }) - .unwrap_or_default(); - all.extend( - self.structured - .entries - .lock() - .map(|m| { - m.values() - .map(|e| LiveSessionSnapshot { - project_id: e.project_id, - agent_id: e.agent_id, - node_id: e.node_id, - session_id: e.session.id(), - kind: LiveSessionKind::Structured, - }) - .collect::>() - }) - .unwrap_or_default(), - ); + let mut all = self.pty.live_agent_snapshots(); + all.extend(self.structured.live_agent_snapshots()); all } } @@ -868,4 +876,8 @@ impl LiveAgentRegistry for LiveSessions { fn is_node_live(&self, node_id: &NodeId) -> bool { self.pty.is_node_live(node_id) || self.structured.is_node_live(node_id) } + + fn live_agent_snapshots(&self) -> Vec { + LiveSessions::live_agent_snapshots(self) + } } diff --git a/crates/application/tests/model_server.rs b/crates/application/tests/model_server.rs index 4dcffbd..00edd64 100644 --- a/crates/application/tests/model_server.rs +++ b/crates/application/tests/model_server.rs @@ -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, + deleted: Mutex>, } 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 { + 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>, +} + +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, +} + +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 { + 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, StoreError> { + Ok(Vec::new()) + } + + async fn load_project(&self, id: ProjectId) -> Result { + 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 { + 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 { + 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 { + 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) } +fn delete_artifact_usecase( + registry: Arc, + downloader: Arc, + tracker: Arc, + profiles: Arc, +) -> 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, + downloader: Arc, + tracker: Arc, + profiles: Arc, + probe: Arc, + projects: Arc, + contexts: Arc, + live: Arc, +) -> DeleteModelArtifact { + DeleteModelArtifact::new( + registry as Arc, + probe as Arc, + downloader as Arc, + tracker as Arc, + profiles as Arc, + projects as Arc, + contexts as Arc, + live as Arc, + ) +} + fn progress(downloaded: Option, total: Option) -> 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(®istry), + 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(®istry), + 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(®istry), + 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(®istry), + 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(®istry), + 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()); +} diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 50b844b..599fda8 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -1343,7 +1343,10 @@ impl From for FirstRunStateDto { // Local model servers (B35) // --------------------------------------------------------------------------- -use application::{ListModelServersOutput, SaveModelServerInput, SaveModelServerOutput}; +use application::{ + ListModelServersOutput, ModelArtifactView, ModelServerListItem, SaveModelServerInput, + SaveModelServerOutput, +}; use domain::model_server::{ ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource, @@ -1492,6 +1495,9 @@ pub struct ModelServerConfigDto { /// Optional readiness warmup deadline override in seconds. #[serde(default, skip_serializing_if = "Option::is_none")] pub warmup_deadline_secs: Option, + /// Derived local artifact cache state. + #[serde(default)] + pub artifact: ModelArtifactDto, } impl ModelServerConfigDto { @@ -1516,6 +1522,7 @@ impl ModelServerConfigDto { auto_start: config.auto_start, stop_policy: config.stop_policy.into(), warmup_deadline_secs: config.warmup_deadline_secs, + artifact: ModelArtifactDto::NotManaged, } } @@ -1563,6 +1570,53 @@ impl ModelServerConfigDto { } } +/// Local model artifact cache state on the IPC wire. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "state")] +pub enum ModelArtifactDto { + /// The configured source is not managed by IdeA's downloader. + NotManaged, + /// The configured source is managed but not present in cache. + Missing, + /// A download/prepare operation is currently running for this server. + Downloading, + /// The configured source is present in cache. + Downloaded { + /// Local artifact path. + path: String, + /// Total on-disk size when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + size_bytes: Option, + }, +} + +impl Default for ModelArtifactDto { + fn default() -> Self { + Self::NotManaged + } +} + +impl From for ModelArtifactDto { + fn from(view: ModelArtifactView) -> Self { + match view { + ModelArtifactView::NotManaged => Self::NotManaged, + ModelArtifactView::Missing => Self::Missing, + ModelArtifactView::Downloading => Self::Downloading, + ModelArtifactView::Downloaded { path, size_bytes } => { + Self::Downloaded { path, size_bytes } + } + } + } +} + +impl From for ModelServerConfigDto { + fn from(item: ModelServerListItem) -> Self { + let mut dto = Self::from_domain(item.config); + dto.artifact = item.artifact.into(); + dto + } +} + /// Response DTO for `preview_model_server_command`. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] @@ -1585,7 +1639,7 @@ impl From for ModelServerConfigListDto { Self( out.servers .into_iter() - .map(ModelServerConfigDto::from_domain) + .map(ModelServerConfigDto::from) .collect(), ) } diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 7a9a199..e63426f 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -19,15 +19,15 @@ use application::{ CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, - DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, - DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, - EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, - GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, GitBranches, - GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, - HarvestMemoryFromTurn, HealthUseCase, InspectConversation, InstallPluginFromArchive, - InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, - LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices, - ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, + DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, + DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, + DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, + GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions, + GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, + GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, + InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, + LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, + ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, @@ -59,8 +59,8 @@ use domain::ports::{ BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore, - PermissionStore, PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore, - PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, + ModelArtifactDownloader, PermissionStore, PluginManifestValidator, PluginMcpSupervisor, + PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker, WakeError, WakeReason, WindowStateStore, @@ -965,6 +965,8 @@ pub struct BackendCore { pub save_model_server: Arc, /// Deletes local model server configurations when unused. pub delete_model_server: Arc, + /// Deletes managed local model artifacts without deleting server configs. + pub delete_model_artifact: Arc, /// The local PTY adapter, kept port-typed so driving adapters can subscribe /// output and route it through their own transport bridge. pub pty_port: Arc, @@ -1513,25 +1515,31 @@ impl BackendCore { let model_artifact_downloader = Arc::new(HfModelArtifactDownloader::new( app_data_dir.join("hf-model-artifacts"), )); + let model_server_probe_port = Arc::new(HttpOpenAiCompatibleProbe::default()) + as Arc; let ensure_local_model_server = Arc::new( EnsureLocalModelServer::new( Arc::clone(&model_server_registry) as Arc, - Arc::new(HttpOpenAiCompatibleProbe::default()) - as Arc, + Arc::clone(&model_server_probe_port), Arc::new(LocalManagedProcess::new()) as Arc, Arc::new(LlamaCppRuntime::new()) as Arc, Arc::clone(&fs_port), Arc::clone(&events_port), ) - .with_model_artifact_downloader( - model_artifact_downloader as Arc, - ), + .with_model_artifact_downloader(Arc::clone(&model_artifact_downloader) + as Arc), ); let model_server_registry_port = Arc::clone(&model_server_registry) as Arc; - let list_model_servers = Arc::new(ListModelServers::new(Arc::clone( - &model_server_registry_port, - ))); + let model_artifact_downloader_port = + Arc::clone(&model_artifact_downloader) as Arc; + let model_artifact_download_tracker = Arc::clone(&ensure_local_model_server) + as Arc; + let list_model_servers = Arc::new( + ListModelServers::new(Arc::clone(&model_server_registry_port)) + .with_model_artifact_downloader(Arc::clone(&model_artifact_downloader_port)) + .with_download_tracker(Arc::clone(&model_artifact_download_tracker)), + ); let save_model_server = Arc::new(SaveModelServer::new(Arc::clone( &model_server_registry_port, ))); @@ -2387,6 +2395,16 @@ impl BackendCore { Arc::clone(&terminal_sessions), Arc::clone(&structured_sessions), )); + let delete_model_artifact = Arc::new(DeleteModelArtifact::new( + Arc::clone(&model_server_registry_port), + Arc::clone(&model_server_probe_port), + Arc::clone(&model_artifact_downloader_port), + Arc::clone(&model_artifact_download_tracker), + Arc::clone(&profile_store_port), + Arc::clone(&store_port), + Arc::clone(&contexts_port), + Arc::clone(&live_sessions) as Arc, + )); // Réconciliation du live-state au reboot : repasse en `idle` les lignes // fantômes (working/waiting/blocked) dont la session n'est plus vivante, // selon le MÊME registre de liveness que `GetProjectWorkState`. Provider @@ -2699,6 +2717,7 @@ impl BackendCore { list_model_servers, save_model_server, delete_model_server, + delete_model_artifact, pty_port, terminal_sessions, event_bus, diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 69f8ba4..587961f 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -1311,6 +1311,22 @@ pub struct ModelArtifactResolution { pub cache_hit: bool, } +/// Cache state for a model artifact managed by IdeA. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModelArtifactState { + /// Artifact source is not managed by the downloader. + NotManaged, + /// Managed artifact is not present in cache. + Missing, + /// Managed artifact is present in cache. + Downloaded { + /// Local path used to launch the model. + path: ModelPath, + /// Total on-disk bytes when known. + size_bytes: Option, + }, +} + /// Cooperative cancellation token for model artifact resolution. #[derive(Debug, Clone, Default)] pub struct ModelArtifactCancel { @@ -1339,6 +1355,15 @@ impl ModelArtifactCancel { /// Resolves or downloads a model artifact before starting a model server. #[async_trait] pub trait ModelArtifactDownloader: Send + Sync { + /// Returns the current cache state for a Hugging Face model reference. + /// + /// # Errors + /// [`ModelServerError`] when the cache cannot be inspected. + async fn hf_model_state( + &self, + repo: &HfModelRef, + ) -> Result; + /// Resolves a Hugging Face model to a local artifact path. /// /// # Errors @@ -1349,6 +1374,14 @@ pub trait ModelArtifactDownloader: Send + Sync { progress: Arc, cancel: ModelArtifactCancel, ) -> Result; + + /// Deletes the cached artifact for a Hugging Face model reference. + /// + /// Deleting a missing artifact is a successful no-op. + /// + /// # Errors + /// [`ModelServerError`] when deletion fails. + async fn delete_hf_model(&self, repo: &HfModelRef) -> Result<(), ModelServerError>; } /// Manages local long-lived child processes. diff --git a/crates/infrastructure/src/model_server/mod.rs b/crates/infrastructure/src/model_server/mod.rs index 209dea5..bffbeb6 100644 --- a/crates/infrastructure/src/model_server/mod.rs +++ b/crates/infrastructure/src/model_server/mod.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use std::time::Duration; use async_trait::async_trait; @@ -10,6 +10,7 @@ use futures_util::StreamExt; use serde::{Deserialize, Serialize}; use tokio::io::AsyncWriteExt; use tokio::process::{Child, Command}; +use tokio::sync::Mutex as AsyncMutex; use domain::model_server::{ ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig, @@ -17,9 +18,9 @@ use domain::model_server::{ }; use domain::ports::{ FileSystem, ManagedProcess, ManagedProcessHandle, ModelArtifactCancel, ModelArtifactDownloader, - ModelArtifactProgress, ModelArtifactResolution, ModelServerArgv, ModelServerError, - ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, RemotePath, - SpawnSpec, + ModelArtifactProgress, ModelArtifactResolution, ModelArtifactState, ModelServerArgv, + ModelServerError, ModelServerProbe, ModelServerRegistry, ModelServerRuntime, ProcessStatus, + RemotePath, SpawnSpec, }; use domain::{LocalModelServerId, ProjectPath, StopPolicy}; @@ -76,6 +77,7 @@ fn is_ready(result: Result) -> bool { pub struct HfModelArtifactDownloader { cache_dir: PathBuf, client: reqwest::Client, + repo_locks: Arc>>>>, } impl HfModelArtifactDownloader { @@ -85,6 +87,7 @@ impl HfModelArtifactDownloader { Self { cache_dir: cache_dir.into(), client: reqwest::Client::new(), + repo_locks: Arc::new(Mutex::new(HashMap::new())), } } @@ -149,6 +152,62 @@ impl HfModelArtifactDownloader { std::fs::write(manifest_path, json) } + fn lock_for(&self, repo: &HfModelRef) -> Arc> { + let mut locks = self.repo_locks.lock().expect("repo locks mutex poisoned"); + Arc::clone( + locks + .entry(repo.as_str().to_owned()) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))), + ) + } + + fn cached_state(&self, repo: &HfModelRef) -> Result { + let merged_path = self.cache_path_for(repo); + if merged_path.is_file() { + return Ok(ModelArtifactState::Downloaded { + size_bytes: Some(file_size(&merged_path)?), + path: model_path_from_pathbuf(merged_path)?, + }); + } + if let Some(paths) = self.cached_shard_set(repo) { + if let Some(first) = paths.first() { + return Ok(ModelArtifactState::Downloaded { + size_bytes: Some(paths_size(&paths)?), + path: model_path_from_pathbuf(first.clone())?, + }); + } + } + Ok(ModelArtifactState::Missing) + } + + fn delete_cached(&self, repo: &HfModelRef) -> Result<(), ModelServerError> { + let merged_path = self.cache_path_for(repo); + if merged_path.is_file() { + remove_file_if_exists(&merged_path)?; + } + + let manifest_path = self.manifest_path_for(repo); + if let Ok(raw) = std::fs::read(&manifest_path) { + let manifest: ShardManifest = + serde_json::from_slice(&raw).map_err(|e| ModelServerError::Store(e.to_string()))?; + let dir = self.cache_dir_for(repo); + for filename in manifest.files { + remove_file_if_exists(&dir.join(filename))?; + } + remove_file_if_exists(&manifest_path)?; + } + + let repo_dir = self.cache_dir_for(repo); + if repo_dir.is_dir() + && std::fs::read_dir(&repo_dir) + .map(is_empty_dir) + .unwrap_or(false) + { + std::fs::remove_dir(&repo_dir).map_err(|e| ModelServerError::Store(e.to_string()))?; + } + Ok(()) + } + async fn resolve_remote_filenames( &self, repo: &HfModelRef, @@ -189,12 +248,23 @@ struct ShardManifest { #[async_trait] impl ModelArtifactDownloader for HfModelArtifactDownloader { + async fn hf_model_state( + &self, + repo: &HfModelRef, + ) -> Result { + let lock = self.lock_for(repo); + let _guard = lock.lock().await; + self.cached_state(repo) + } + async fn resolve_hf_model( &self, repo: &HfModelRef, progress: std::sync::Arc, cancel: ModelArtifactCancel, ) -> Result { + let lock = self.lock_for(repo); + let _guard = lock.lock().await; if cancel.is_cancelled() { return Err(ModelServerError::Cancelled); } @@ -300,6 +370,12 @@ impl ModelArtifactDownloader for HfModelArtifactDownloader { cache_hit: false, }) } + + async fn delete_hf_model(&self, repo: &HfModelRef) -> Result<(), ModelServerError> { + let lock = self.lock_for(repo); + let _guard = lock.lock().await; + self.delete_cached(repo) + } } #[derive(Debug, Deserialize)] @@ -409,6 +485,31 @@ fn model_path_from_pathbuf(path: PathBuf) -> Result .map_err(|e| ModelServerError::Invalid(e.to_string())) } +fn file_size(path: &Path) -> Result { + std::fs::metadata(path) + .map(|metadata| metadata.len()) + .map_err(|e| ModelServerError::Store(e.to_string())) +} + +fn paths_size(paths: &[PathBuf]) -> Result { + paths + .iter() + .map(|path| file_size(path)) + .try_fold(0_u64, |acc, size| size.map(|size| acc.saturating_add(size))) +} + +fn remove_file_if_exists(path: &Path) -> Result<(), ModelServerError> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(ModelServerError::Store(err.to_string())), + } +} + +fn is_empty_dir(entries: std::fs::ReadDir) -> bool { + entries.into_iter().next().is_none() +} + /// Builds `llama-server` argv without shell interpolation. #[derive(Debug, Default, Clone, Copy)] pub struct LlamaCppRuntime; diff --git a/crates/infrastructure/tests/model_server.rs b/crates/infrastructure/tests/model_server.rs index e5de42e..949509b 100644 --- a/crates/infrastructure/tests/model_server.rs +++ b/crates/infrastructure/tests/model_server.rs @@ -8,8 +8,8 @@ use domain::model_server::{ LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource, StopPolicy, }; use domain::ports::{ - FileSystem, ModelArtifactCancel, ModelArtifactDownloader, ModelServerRegistry, - ModelServerRuntime, RemotePath, + FileSystem, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactState, + ModelServerRegistry, ModelServerRuntime, RemotePath, }; use domain::LocalModelServerId; use infrastructure::{ @@ -227,3 +227,41 @@ async fn hf_model_artifact_downloader_resolves_deterministic_local_cache_hit_wit std::path::Path::new("Qwen--Qwen3-Coder").join("Q4_K_M.gguf") ); } + +#[tokio::test] +async fn hf_model_artifact_downloader_reports_downloaded_cache_state() { + let tmp = TempDir::new(); + let downloader = HfModelArtifactDownloader::new(tmp.path()); + let repo = HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap(); + let path = downloader.cache_path_for(&repo); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"gguf").unwrap(); + + let state = downloader.hf_model_state(&repo).await.unwrap(); + + assert_eq!( + state, + ModelArtifactState::Downloaded { + path: ModelPath::new(path.to_string_lossy()).unwrap(), + size_bytes: Some(4), + } + ); +} + +#[tokio::test] +async fn hf_model_artifact_downloader_deletes_merged_cache_without_config_side_effects() { + let tmp = TempDir::new(); + let downloader = HfModelArtifactDownloader::new(tmp.path()); + let repo = HfModelRef::new("Qwen/Qwen3-Coder:Q4_K_M").unwrap(); + let path = downloader.cache_path_for(&repo); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"gguf").unwrap(); + + downloader.delete_hf_model(&repo).await.unwrap(); + + assert!(!path.exists()); + assert_eq!( + downloader.hf_model_state(&repo).await.unwrap(), + ModelArtifactState::Missing + ); +} diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index 5fca750..9382dbb 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -229,6 +229,9 @@ export class HttpModelServerGateway implements ModelServerGateway { async deleteModelServer(serverId: string): Promise { await this.http.invoke("delete_model_server", { serverId }); } + async deleteModelArtifact(serverId: string): Promise { + await this.http.invoke("delete_model_artifact", { serverId }); + } previewModelServerCommand(config: LocalModelServerConfig): Promise { return this.http.invoke("preview_model_server_command", { config }); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 88f2a27..888b6d2 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -1525,6 +1525,36 @@ export class MockModelServerGateway implements ModelServerGateway { this.servers = this.servers.filter((s) => s.id !== serverId); } + async deleteModelArtifact(serverId: string): Promise { + const i = this.servers.findIndex((s) => s.id === serverId); + if (i < 0) { + const err: GatewayError = { + code: "not_configured", + message: "model server is not configured", + }; + throw err; + } + const server = this.servers[i]; + if (server.modelSource?.type !== "huggingFace") { + const err: GatewayError = { + code: "invalid", + message: "only managed Hugging Face model artifacts can be deleted", + }; + throw err; + } + if (server.artifact?.state === "downloading" || this.inUse.has(serverId)) { + const err: GatewayError = { + code: "model_server_in_use", + message: "model artifact cannot be deleted while in use", + }; + throw err; + } + this.servers[i] = { + ...server, + artifact: { state: "missing" }, + }; + } + async previewModelServerCommand( config: LocalModelServerConfig, ): Promise { diff --git a/frontend/src/adapters/modelServer.ts b/frontend/src/adapters/modelServer.ts index 72a1d75..8905b19 100644 --- a/frontend/src/adapters/modelServer.ts +++ b/frontend/src/adapters/modelServer.ts @@ -32,6 +32,10 @@ export class TauriModelServerGateway implements ModelServerGateway { await invoke("delete_model_server", { serverId }); } + async deleteModelArtifact(serverId: string): Promise { + await invoke("delete_model_artifact", { serverId }); + } + previewModelServerCommand( config: LocalModelServerConfig, ): Promise { diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 2f14f34..8ff6e81 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -99,6 +99,17 @@ export type ModelSource = | { type: "localPath"; path: string } | { type: "huggingFace"; repo: string }; +/** + * Derived state of an IdeA-managed local model artifact cache (#70, mirror of + * `ModelArtifactDto`, tagged on `state`). Only Hugging Face sources are managed; + * local `.gguf` paths stay `notManaged`. + */ +export type ModelArtifact = + | { state: "notManaged" } + | { state: "missing" } + | { state: "downloading" } + | { state: "downloaded"; path: string; sizeBytes?: number }; + /** * A declared local model server (F35, mirror of the backend flat * `LocalModelServerConfigDto`, camelCase wire). Global to IdeA (not project @@ -138,6 +149,8 @@ export interface LocalModelServerConfig { args: string[]; autoStart: boolean; stopPolicy: StopPolicy; + /** Derived managed artifact cache state. Present on list results. */ + artifact?: ModelArtifact; } /** diff --git a/frontend/src/features/git/git.test.tsx b/frontend/src/features/git/git.test.tsx index b7dd1a7..33eee13 100644 --- a/frontend/src/features/git/git.test.tsx +++ b/frontend/src/features/git/git.test.tsx @@ -59,8 +59,8 @@ describe("GitPanel (with MockGitGateway)", () => { renderPanel(git); await waitForPanel(); - expect(screen.getByText("Staged")).toBeTruthy(); - expect(screen.getByText("Unstaged")).toBeTruthy(); + await screen.findByText("Staged"); + await screen.findByText("Unstaged"); // src/main.rs is staged → Unstage button exists expect( screen.getByRole("button", { name: "unstage src/main.rs" }), diff --git a/frontend/src/features/model-servers/ModelServersPanel.tsx b/frontend/src/features/model-servers/ModelServersPanel.tsx index c24116b..ce7eb6d 100644 --- a/frontend/src/features/model-servers/ModelServersPanel.tsx +++ b/frontend/src/features/model-servers/ModelServersPanel.tsx @@ -15,6 +15,7 @@ import { useEffect, useRef, useState } from "react"; import type { LocalModelServerConfig, + ModelArtifact, ModelServerCommandPreview, ModelSource, StopPolicy, @@ -47,6 +48,35 @@ const STOP_POLICIES: { value: StopPolicy; label: string }[] = [ { value: "stopWhenUnused", label: "Stop when unused" }, ]; +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return ""; + const units = ["B", "KB", "MB", "GB", "TB"] as const; + let value = bytes; + let unit = 0; + while (value >= 1000 && unit < units.length - 1) { + value /= 1000; + unit += 1; + } + const digits = unit === 0 || value >= 10 ? 0 : 1; + return `${value.toFixed(digits)} ${units[unit]}`; +} + +function deleteArtifactConfirmation( + server: LocalModelServerConfig, + artifact: Extract, +): string { + const size = + artifact.sizeBytes == null ? "" : `Espace libéré : ${formatBytes(artifact.sizeBytes)}.`; + return [ + "Supprimer le modèle téléchargé ?", + `Serveur : ${server.name}`, + "Le serveur local restera configuré, mais IdeA devra retélécharger ce modèle au prochain lancement.", + size, + ] + .filter(Boolean) + .join("\n"); +} + export interface ModelServersPanelProps { /** The model-server registry view-model (from `useModelServers`). */ vm: ModelServersViewModel; @@ -73,6 +103,14 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) { if (saved) setDraft(null); } + async function confirmDeleteArtifact( + server: LocalModelServerConfig, + artifact: Extract, + ) { + if (!window.confirm(deleteArtifactConfirmation(server, artifact))) return; + await vm.deleteArtifact(server.id); + } + return ( )} + {vm.notice && ( +

+ {vm.notice} +

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

@@ -107,40 +150,69 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) { )}

    - {vm.servers.map((server) => ( -
  • - - - {server.name} - - - {server.baseURL} · {server.servedModelName} - {server.autoStart ? " · auto-start" : ""} + {vm.servers.map((server) => { + const artifact = server.artifact; + const downloaded = + artifact?.state === "downloaded" ? artifact : undefined; + const deletingArtifact = vm.deletingArtifactId === server.id; + const downloadingArtifact = artifact?.state === "downloading"; + return ( +
  • + + + {server.name} + + + {server.baseURL} · {server.servedModelName} + {server.autoStart ? " · auto-start" : ""} + - - - - void vm.remove(server.id)} - disabled={vm.busy} - > - × - - -
  • - ))} + + {downloadingArtifact && ( + + )} + {downloaded && ( + + )} + + void vm.remove(server.id)} + disabled={vm.busy} + > + × + + + + ); + })}
{draft && ( diff --git a/frontend/src/features/model-servers/modelServers.test.tsx b/frontend/src/features/model-servers/modelServers.test.tsx index f5fdf67..750d392 100644 --- a/frontend/src/features/model-servers/modelServers.test.tsx +++ b/frontend/src/features/model-servers/modelServers.test.tsx @@ -6,7 +6,7 @@ * {@link ModelServerSelect} binding dropdown. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { act, render, renderHook, screen, fireEvent, waitFor } from "@testing-library/react"; import type { Gateways } from "@/ports"; @@ -312,6 +312,95 @@ describe("ModelServersPanel wizard (F35 V2)", () => { expect(screen.getByLabelText("edit Local A")).toBeTruthy(); }); + it("deletes a downloaded managed model artifact after confirmation without deleting the server config", async () => { + const modelServer = new MockModelServerGateway(); + await modelServer.saveModelServer({ + ...SERVER, + artifact: { + state: "downloaded", + path: "/cache/unsloth/Qwen3.5-9B-GGUF/model.gguf", + sizeBytes: 1_500_000_000, + }, + }); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(true); + renderPanel(modelServer); + + fireEvent.click(await screen.findByLabelText("delete downloaded model Local A")); + + await waitFor(() => { + expect(confirm).toHaveBeenCalledWith(expect.stringMatching(/1\.5 GB/)); + }); + await waitFor(async () => { + const [server] = await modelServer.listModelServers(); + expect(server).toMatchObject({ + id: SERVER.id, + artifact: { state: "missing" }, + }); + }); + expect(await screen.findByText(/Modèle téléchargé supprimé/i)).toBeTruthy(); + expect(screen.getByLabelText("edit Local A")).toBeTruthy(); + expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull(); + + confirm.mockRestore(); + }); + + it("does not offer model artifact deletion for local .gguf or missing managed artifacts", async () => { + const modelServer = new MockModelServerGateway(); + await modelServer.saveModelServer({ + ...SERVER, + artifact: { state: "missing" }, + }); + await modelServer.saveModelServer({ + ...SERVER, + id: "550e8400-e29b-41d4-a716-446655440001", + name: "Local file", + modelSource: { type: "localPath", path: "/models/qwen.gguf" }, + artifact: { state: "notManaged" }, + }); + renderPanel(modelServer); + + await screen.findByLabelText("edit Local A"); + expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull(); + expect(screen.queryByLabelText("delete downloaded model Local file")).toBeNull(); + }); + + it("shows a disabled downloading state instead of a delete action while an artifact is in progress", async () => { + const modelServer = new MockModelServerGateway(); + await modelServer.saveModelServer({ + ...SERVER, + artifact: { state: "downloading" }, + }); + renderPanel(modelServer); + + const downloading = await screen.findByLabelText("download in progress Local A"); + expect((downloading as HTMLButtonElement).disabled).toBe(true); + expect(downloading.textContent).toContain("Téléchargement en cours"); + expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull(); + }); + + it("shows a short inline error when artifact deletion is blocked", async () => { + const modelServer = new MockModelServerGateway(); + await modelServer.saveModelServer({ + ...SERVER, + artifact: { + state: "downloaded", + path: "/cache/model.gguf", + }, + }); + modelServer.markInUse(SERVER.id); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(true); + renderPanel(modelServer); + + fireEvent.click(await screen.findByLabelText("delete downloaded model Local A")); + + expect((await screen.findByRole("alert")).textContent).toMatch( + /téléchargement en cours ou agent actif/i, + ); + expect(screen.getByLabelText("delete downloaded model Local A")).toBeTruthy(); + + confirm.mockRestore(); + }); + it("edits an existing server's served model name", async () => { const modelServer = new MockModelServerGateway(); await modelServer.saveModelServer(SERVER); diff --git a/frontend/src/features/model-servers/useModelServers.ts b/frontend/src/features/model-servers/useModelServers.ts index b1bc6b9..f44e668 100644 --- a/frontend/src/features/model-servers/useModelServers.ts +++ b/frontend/src/features/model-servers/useModelServers.ts @@ -24,14 +24,20 @@ export interface ModelServersViewModel { servers: LocalModelServerConfig[]; /** Last error message, or `null`. */ error: string | null; + /** Last non-blocking success message, or `null`. */ + notice: string | null; /** Whether a request is in flight. */ busy: boolean; + /** Server id whose managed artifact is currently being deleted, or `null`. */ + deletingArtifactId: string | null; /** Reloads the server list. */ reload: () => Promise; /** Creates or updates a server; returns the persisted config (or `null` on error). */ save: (config: LocalModelServerConfig) => Promise; /** Deletes a server by id; returns `true` on success. */ remove: (serverId: string) => Promise; + /** Deletes only the managed downloaded model artifact; returns `true` on success. */ + deleteArtifact: (serverId: string) => Promise; /** * Asks the backend to build the `llama-server` command line for a draft * (never reconstructed client-side). Returns `null` when the draft is @@ -62,11 +68,14 @@ export function useModelServers(): ModelServersViewModel { const { modelServer } = useGateways(); const [servers, setServers] = useState([]); const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); const [busy, setBusy] = useState(false); + const [deletingArtifactId, setDeletingArtifactId] = useState(null); const reload = useCallback(async () => { setBusy(true); setError(null); + setNotice(null); try { setServers(await modelServer.listModelServers()); } catch (e) { @@ -84,6 +93,7 @@ export function useModelServers(): ModelServersViewModel { async (config: LocalModelServerConfig) => { setBusy(true); setError(null); + setNotice(null); try { const saved = await modelServer.saveModelServer(config); setServers((prev) => { @@ -110,6 +120,7 @@ export function useModelServers(): ModelServersViewModel { async (serverId: string) => { setBusy(true); setError(null); + setNotice(null); try { await modelServer.deleteModelServer(serverId); setServers((prev) => prev.filter((s) => s.id !== serverId)); @@ -132,6 +143,37 @@ export function useModelServers(): ModelServersViewModel { [modelServer], ); + const deleteArtifact = useCallback( + async (serverId: string) => { + setBusy(true); + setDeletingArtifactId(serverId); + setError(null); + setNotice(null); + try { + await modelServer.deleteModelArtifact(serverId); + setServers(await modelServer.listModelServers()); + setNotice("Modèle téléchargé supprimé. Le serveur reste configuré."); + return true; + } catch (e) { + const code = codeOf(e); + if (code === "model_server_in_use") { + setError("Impossible de supprimer ce modèle : téléchargement en cours ou agent actif."); + } else if (code === "invalid") { + setError("Aucun modèle téléchargé géré à supprimer."); + } else if (code === "not_configured") { + setError("Serveur introuvable."); + } else { + setError(describe(e)); + } + return false; + } finally { + setDeletingArtifactId(null); + setBusy(false); + } + }, + [modelServer], + ); + const preview = useCallback( async (config: LocalModelServerConfig) => { try { @@ -145,7 +187,22 @@ export function useModelServers(): ModelServersViewModel { [modelServer], ); - const clearError = useCallback(() => setError(null), []); + const clearError = useCallback(() => { + setError(null); + setNotice(null); + }, []); - return { servers, error, busy, reload, save, remove, preview, clearError }; + return { + servers, + error, + notice, + busy, + deletingArtifactId, + reload, + save, + remove, + deleteArtifact, + preview, + clearError, + }; } diff --git a/frontend/src/features/terminals/TerminalView.scrollback.test.tsx b/frontend/src/features/terminals/TerminalView.scrollback.test.tsx new file mode 100644 index 0000000..2341f90 --- /dev/null +++ b/frontend/src/features/terminals/TerminalView.scrollback.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from "vitest"; +import { render } from "@testing-library/react"; + +import type { Gateways } from "@/ports"; +import { MockTerminalGateway } from "@/adapters/mock"; +import { DIProvider } from "@/app/di"; + +const terminalOptions: unknown[] = []; + +vi.mock("@xterm/xterm", () => ({ + Terminal: class { + readonly rows = 24; + readonly cols = 80; + + constructor(options: unknown) { + terminalOptions.push(options); + } + + loadAddon() {} + open() {} + onData() { + return { dispose() {} }; + } + write() {} + input() {} + focus() {} + dispose() {} + }, +})); + +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 { + TerminalView, + TERMINAL_SCROLLBACK_LINES, +} from "./TerminalView"; + +describe("TerminalView scrollback", () => { + it("configures xterm with a deep scrollback for chatty OpenCode agents", async () => { + const gateways = { terminal: new MockTerminalGateway() } as unknown as Gateways; + + render( + + + , + ); + + expect(terminalOptions[0]).toMatchObject({ + scrollback: TERMINAL_SCROLLBACK_LINES, + }); + expect(TERMINAL_SCROLLBACK_LINES).toBeGreaterThan(1_000); + }); +}); diff --git a/frontend/src/features/terminals/TerminalView.test.tsx b/frontend/src/features/terminals/TerminalView.test.tsx index 01fcf1b..5ba23e4 100644 --- a/frontend/src/features/terminals/TerminalView.test.tsx +++ b/frontend/src/features/terminals/TerminalView.test.tsx @@ -434,6 +434,27 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () = fitSpy.mockRestore(); }); + it("refits after window restore/focus without a new refitSignal", async () => { + const fitSpy = vi.spyOn(FitAddon.prototype, "fit"); + const open = vi.fn(async () => makeHandle({ sessionId: "restore-1" })); + + renderView(new MockTerminalGateway(), "/cwd", { + open, + refitSignal: 1, + }); + await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); + setTerminalBoxSize(400, 200); + await waitFor(() => expect(fitSpy).toHaveBeenCalled()); + fitSpy.mockClear(); + + window.dispatchEvent(new Event("focus")); + + await waitFor(() => expect(fitSpy).toHaveBeenCalled()); + expect(open).toHaveBeenCalledTimes(1); + + fitSpy.mockRestore(); + }); + it("does not refit when refitSignal is left undefined (no-op for callers that don't pass it)", async () => { const fitSpy = vi.spyOn(FitAddon.prototype, "fit"); const open = vi.fn(async () => makeHandle({ sessionId: "no-signal-1" })); diff --git a/frontend/src/features/terminals/TerminalView.tsx b/frontend/src/features/terminals/TerminalView.tsx index 6ef7b7f..015067f 100644 --- a/frontend/src/features/terminals/TerminalView.tsx +++ b/frontend/src/features/terminals/TerminalView.tsx @@ -39,6 +39,7 @@ import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; import { useGateways } from "@/app/di"; +import type { ResolvedAgentSystemPermissions } from "@/domain"; import type { OpenTerminalOptions, ReattachResult, @@ -46,6 +47,12 @@ import type { WritePortal, } from "@/ports"; +// The backend PTY retains a bounded byte tail for reattach (~100 KB today), but +// xterm also has its own viewport history. Its default is too shallow for chatty +// OpenCode TUIs, which made the visible cell stop scrolling long before the +// retained terminal output was exhausted. +export const TERMINAL_SCROLLBACK_LINES = 10_000; + interface TerminalViewProps { /** Working directory the shell opens in (typically the project root). */ cwd: string; @@ -113,6 +120,8 @@ interface TerminalViewProps { * it never remounts/reopens the terminal. */ refitSignal?: number; + /** Optional resolved system permissions for this agent/cell. */ + systemPermissions?: ResolvedAgentSystemPermissions | null; } /** @@ -144,6 +153,7 @@ export function TerminalView({ portal, onReady, refitSignal, + systemPermissions, }: TerminalViewProps) { const { terminal } = useGateways(); const containerRef = useRef(null); @@ -181,7 +191,7 @@ export function TerminalView({ // Holds the mounted instance's `refit` closure so the `refitSignal` effect // below (a separate effect, since it must NOT re-run/reopen the terminal on // every parent render) can trigger it without depending on `cwd`'s effect. - const refitRef = useRef<(() => void) | null>(null); + const refitRef = useRef<((settleFrames?: number) => void) | null>(null); useEffect(() => { const container = containerRef.current; @@ -200,6 +210,7 @@ export function TerminalView({ fontSize: 13, fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', + scrollback: TERMINAL_SCROLLBACK_LINES, }); const fit = new FitAddon(); term.loadAddon(fit); @@ -218,6 +229,7 @@ export function TerminalView({ let lastRows = term.rows; let lastCols = term.cols; let hasUsefulFit = false; + let settleFramesRemaining = 0; // Keystroke → PTY path. The agent cell is a **native terminal** // (ARCHITECTURE §20): keystrokes reach the PTY exactly like a plain shell. @@ -349,6 +361,13 @@ export function TerminalView({ // now reschedules on the next few frames instead of abandoning — bounded, // so a container that is genuinely never laid out (e.g. headless tests) // doesn't spin forever. + // A successful fit can also land on a non-zero but still intermediate box + // during project/layout switches, split/merge commits, re-attach, and OS + // minimize/restore. Keep a small coalesced tail of fits on following frames + // so the final settled geometry is pushed automatically without requiring a + // manual resize. This stays bounded and preserves the rows/cols-changed + // guard before touching the PTY. + const SETTLE_REFIT_FRAMES = 4; const MAX_ZERO_SIZE_RETRIES = 8; let zeroSizeRetries = 0; const refit = () => { @@ -382,14 +401,29 @@ export function TerminalView({ } else if (isFirstUsefulFit) { resizeHandleToCurrentGeometry(); } + + if (settleFramesRemaining > 0) { + settleFramesRemaining -= 1; + rafId = requestAnimationFrame(refit); + } }; - const scheduleRefit = () => { - if (rafId) cancelAnimationFrame(rafId); - rafId = requestAnimationFrame(refit); + const scheduleRefit = (settleFrames = 0) => { + settleFramesRemaining = Math.max(settleFramesRemaining, settleFrames); + if (!rafId) rafId = requestAnimationFrame(refit); }; - const ro = new ResizeObserver(scheduleRefit); + const scheduleSettledRefit = () => scheduleRefit(SETTLE_REFIT_FRAMES); + const scheduleVisibleRefit = () => { + if (document.visibilityState === "hidden") return; + scheduleSettledRefit(); + }; + const ro = new ResizeObserver(() => scheduleRefit()); ro.observe(container); - scheduleRefit(); + scheduleSettledRefit(); + window.addEventListener("resize", scheduleSettledRefit); + window.addEventListener("focus", scheduleSettledRefit); + window.addEventListener("pageshow", scheduleSettledRefit); + document.addEventListener("visibilitychange", scheduleVisibleRefit); + window.visualViewport?.addEventListener("resize", scheduleSettledRefit); // Let the `refitSignal` effect below trigger the SAME coalesced refit after // a structural layout mutation (split/merge, ticket #61) — surviving cells // don't always get a timely useful ResizeObserver event from a sibling @@ -401,6 +435,11 @@ export function TerminalView({ refitRef.current = null; if (rafId) cancelAnimationFrame(rafId); ro.disconnect(); + window.removeEventListener("resize", scheduleSettledRefit); + window.removeEventListener("focus", scheduleSettledRefit); + window.removeEventListener("pageshow", scheduleSettledRefit); + document.removeEventListener("visibilitychange", scheduleVisibleRefit); + window.visualViewport?.removeEventListener("resize", scheduleSettledRefit); onKey.dispose(); portalRef.current?.unbindHandle(); // DETACH, never close: tearing the view down (navigation / layout change) @@ -425,9 +464,18 @@ export function TerminalView({ // logic. useEffect(() => { if (refitSignal === undefined) return; - refitRef.current?.(); + refitRef.current?.(4); }, [refitSignal]); + const showNetworkBanner = + systemPermissions != null && + (systemPermissions.runtimeLock.state === "locked" || + systemPermissions.effective === "deny"); + const networkReason = + systemPermissions?.runtimeLock.reason ?? + systemPermissions?.control.reason ?? + "Le réseau est interdit pour cette cellule."; + return (
+ {showNetworkBanner && ( +
+ {systemPermissions.runtimeLock.state === "locked" + ? "Réseau verrouillé par le runtime." + : "Réseau interdit pour cet agent."}{" "} + {networkReason} +
+ )} {!terminalReady && !openError && (
; + /** + * Deletes the IdeA-managed downloaded model artifact for a server while + * keeping the server config. Rejects with `invalid` for local `.gguf` paths, + * `model_server_in_use` while downloading or used by a live agent, and + * `not_configured` when the server no longer exists. + */ + deleteModelArtifact(serverId: string): Promise; /** * Builds the `llama-server` command line the backend would launch for the * draft config, without persisting it. The backend is the sole authority on From 038e90ecece118be59489c8837d3dcbac551a681 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 27 Jul 2026 09:05:34 +0200 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20livrable=20ticket=20#91=20=E2=80=94?= =?UTF-8?q?=20notification=20fin=20BackgroundTask=20enrichie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: enrichissement HeadlessRendezvous avec requester/target/conversationId - Frontend: toast 'Requester -> Target completed/...' explicite - Clic ouvrant viewer de conversation pour visualiser l'échange --- .../application/src/orchestrator/service.rs | 66 +++++++++ crates/application/src/workstate/mod.rs | 33 +++++ crates/backend/src/dto.rs | 121 +++++++++++++++- crates/backend/src/events.rs | 113 ++++++++++++++- crates/domain/src/events.rs | 20 ++- crates/domain/src/lib.rs | 1 + crates/web-server/src/lib.rs | 1 + .../src/adapters/workStateNormalization.ts | 12 ++ frontend/src/domain/index.ts | 6 + .../projects/ProjectsView.ls7.test.tsx | 84 ++++++++++- .../src/features/projects/ProjectsView.tsx | 134 ++++++++++++++---- .../src/features/workstate/workstate.test.tsx | 7 + 12 files changed, 563 insertions(+), 35 deletions(-) diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 7a85fb3..e6f0239 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -127,6 +127,22 @@ fn resolve_background_cwd( ProjectPath::new(path).map_err(|_| AppError::Invalid("invalid background task cwd".to_owned())) } +fn rendezvous_context_for_task(task: &BackgroundTask) -> Option { + match &task.kind { + BackgroundTaskKind::HeadlessRendezvous { + requester_agent_id, + target_agent_id, + conversation_id, + .. + } => Some(domain::RendezvousContext { + requester_agent_id: *requester_agent_id, + target_agent_id: *target_agent_id, + conversation_id: *conversation_id, + }), + _ => None, + } +} + fn normalize_path_no_parent(path: &Path) -> Result { let mut out = PathBuf::new(); for component in path.components() { @@ -847,22 +863,26 @@ impl OrchestratorService { if task.is_terminal() { return Ok(()); } + let rendezvous = rendezvous_context_for_task(&task); let event = match &result { BackgroundTaskResult::Success { .. } => DomainEvent::BackgroundTaskCompleted { project_id: project.id, task_id, owner_agent_id, + rendezvous, }, BackgroundTaskResult::Failure { .. } => DomainEvent::BackgroundTaskFailed { project_id: project.id, task_id, owner_agent_id, + rendezvous, }, BackgroundTaskResult::Cancelled { .. } | BackgroundTaskResult::Expired { .. } => { DomainEvent::BackgroundTaskCancelled { project_id: project.id, task_id, owner_agent_id, + rendezvous, } } }; @@ -2967,6 +2987,52 @@ mod tests { assert_eq!(submit.delay_ms, Some(CODEX_SUBMIT_DELAY_MS)); } + #[test] + fn rendezvous_context_is_extracted_from_headless_background_task_kind() { + let requester = aid(1); + let target = aid(2); + let conversation_id = domain::ConversationId::from_uuid(uuid::Uuid::from_u128(3)); + let task = BackgroundTask::new( + TaskId::from_uuid(uuid::Uuid::from_u128(4)), + domain::ProjectId::from_uuid(uuid::Uuid::from_u128(5)), + target, + BackgroundTaskKind::HeadlessRendezvous { + requester_agent_id: Some(requester), + target_agent_id: target, + ticket_id: TicketId::from_uuid(uuid::Uuid::from_u128(6)), + conversation_id, + }, + BackgroundTaskWakePolicy::RecordOnly, + 100, + None, + ) + .unwrap(); + + let context = rendezvous_context_for_task(&task).expect("rendezvous context"); + + assert_eq!(context.requester_agent_id, Some(requester)); + assert_eq!(context.target_agent_id, target); + assert_eq!(context.conversation_id, conversation_id); + } + + #[test] + fn rendezvous_context_is_absent_for_command_background_task() { + let task = BackgroundTask::new( + TaskId::from_uuid(uuid::Uuid::from_u128(7)), + domain::ProjectId::from_uuid(uuid::Uuid::from_u128(8)), + aid(9), + BackgroundTaskKind::Command { + label: "cargo test".to_owned(), + }, + BackgroundTaskWakePolicy::RecordOnly, + 100, + None, + ) + .unwrap(); + + assert_eq!(rendezvous_context_for_task(&task), None); + } + #[test] fn explicit_profile_submit_delay_is_preserved() { let p = profile(3, "OpenAI Codex CLI", "codex") diff --git a/crates/application/src/workstate/mod.rs b/crates/application/src/workstate/mod.rs index b75a150..184650a 100644 --- a/crates/application/src/workstate/mod.rs +++ b/crates/application/src/workstate/mod.rs @@ -231,6 +231,12 @@ pub struct AgentBackgroundTaskState { pub stdout_tail: Option, /// Bounded stderr tail. pub stderr_tail: Option, + /// Agent that requested a headless rendezvous, when this task is one. + pub requester_agent_id: Option, + /// Target agent for a headless rendezvous, when this task is one. + pub target_agent_id: Option, + /// Conversation opened by a headless rendezvous, when this task is one. + pub conversation_id: Option, /// Creation timestamp, epoch milliseconds. pub created_at_ms: u64, /// Last update timestamp, epoch milliseconds. @@ -592,6 +598,8 @@ impl GetProjectWorkState { impl From for AgentBackgroundTaskState { fn from(task: BackgroundTask) -> Self { let (exit_code, summary, stdout_tail, stderr_tail) = flatten_background_result(&task); + let (requester_agent_id, target_agent_id, conversation_id) = + flatten_background_rendezvous_context(&task.kind); Self { task_id: task.id, kind: BackgroundTaskKindLabel::from(&task.kind), @@ -600,12 +608,37 @@ impl From for AgentBackgroundTaskState { summary, stdout_tail, stderr_tail, + requester_agent_id, + target_agent_id, + conversation_id, created_at_ms: task.created_at_ms, updated_at_ms: task.updated_at_ms, } } } +fn flatten_background_rendezvous_context( + kind: &BackgroundTaskKind, +) -> ( + Option, + Option, + Option, +) { + match kind { + BackgroundTaskKind::HeadlessRendezvous { + requester_agent_id, + target_agent_id, + conversation_id, + .. + } => ( + *requester_agent_id, + Some(*target_agent_id), + Some(*conversation_id), + ), + _ => (None, None, None), + } +} + impl From<&BackgroundTaskKind> for BackgroundTaskKindLabel { fn from(kind: &BackgroundTaskKind) -> Self { match kind { diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 599fda8..ec07b76 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -2611,6 +2611,15 @@ pub struct AgentBackgroundTaskStateDto { /// Bounded stderr tail. #[serde(skip_serializing_if = "Option::is_none")] pub stderr_tail: Option, + /// Agent that requested a headless rendezvous, when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub requester_agent_id: Option, + /// Target agent for a headless rendezvous. + #[serde(skip_serializing_if = "Option::is_none")] + pub target_agent_id: Option, + /// Conversation opened by a headless rendezvous. + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, /// Creation timestamp, epoch milliseconds. pub created_at_ms: u64, /// Last update timestamp, epoch milliseconds. @@ -2627,6 +2636,9 @@ impl From for AgentBackgroundTaskStateDto { summary: task.summary, stdout_tail: task.stdout_tail, stderr_tail: task.stderr_tail, + requester_agent_id: task.requester_agent_id.map(|id| id.to_string()), + target_agent_id: task.target_agent_id.map(|id| id.to_string()), + conversation_id: task.conversation_id.map(|id| id.to_string()), created_at_ms: task.created_at_ms, updated_at_ms: task.updated_at_ms, } @@ -4052,6 +4064,15 @@ pub struct BackgroundTaskDto { /// Bounded stderr tail (unset for PTY-backed commands, which merge streams). #[serde(skip_serializing_if = "Option::is_none")] pub stderr_tail: Option, + /// Agent that requested a headless rendezvous, when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub requester_agent_id: Option, + /// Target agent for a headless rendezvous. + #[serde(skip_serializing_if = "Option::is_none")] + pub target_agent_id: Option, + /// Conversation opened by a headless rendezvous. + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, /// Creation timestamp, epoch milliseconds. pub created_at_ms: u64, /// Last update timestamp, epoch milliseconds. @@ -4093,6 +4114,8 @@ fn background_state_label(state: BackgroundTaskState) -> &'static str { impl From for BackgroundTaskDto { fn from(task: BackgroundTask) -> Self { + let (requester_agent_id, target_agent_id, conversation_id) = + background_rendezvous_context_labels(&task.kind); let (exit_code, summary, stdout_tail, stderr_tail) = match &task.result { Some(BackgroundTaskResult::Success { exit_code, @@ -4134,12 +4157,33 @@ impl From for BackgroundTaskDto { summary, stdout_tail, stderr_tail, + requester_agent_id, + target_agent_id, + conversation_id, created_at_ms: task.created_at_ms, updated_at_ms: task.updated_at_ms, } } } +fn background_rendezvous_context_labels( + kind: &BackgroundTaskKind, +) -> (Option, Option, Option) { + match kind { + BackgroundTaskKind::HeadlessRendezvous { + requester_agent_id, + target_agent_id, + conversation_id, + .. + } => ( + requester_agent_id.map(|id| id.to_string()), + Some(target_agent_id.to_string()), + Some(conversation_id.to_string()), + ), + _ => (None, None, None), + } +} + /// Parses a task-id string (UUID) coming from the frontend. /// /// # Errors @@ -4188,7 +4232,8 @@ pub struct SpawnBackgroundCommandRequestDto { #[cfg(test)] mod tests { use application::McpToolPermissionCatalogue; - use domain::{AgentId, ProjectMcpToolPermissions}; + use domain::mailbox::TicketId; + use domain::{AgentId, ConversationId, ProjectMcpToolPermissions}; use serde_json::json; use uuid::Uuid; @@ -4239,4 +4284,78 @@ mod tests { }) ); } + + #[test] + fn background_task_dto_exposes_rendezvous_context_only_for_headless_rendezvous() { + let project_id = ProjectId::from_uuid(Uuid::from_u128(1)); + let owner = AgentId::from_uuid(Uuid::from_u128(2)); + let requester = AgentId::from_uuid(Uuid::from_u128(3)); + let conversation_id = ConversationId::from_uuid(Uuid::from_u128(4)); + let task = BackgroundTask::new( + TaskId::from_uuid(Uuid::from_u128(5)), + project_id, + owner, + BackgroundTaskKind::HeadlessRendezvous { + requester_agent_id: Some(requester), + target_agent_id: owner, + ticket_id: TicketId::from_uuid(Uuid::from_u128(6)), + conversation_id, + }, + domain::BackgroundTaskWakePolicy::RecordOnly, + 100, + None, + ) + .unwrap(); + + let json = serde_json::to_value(BackgroundTaskDto::from(task)).unwrap(); + + assert_eq!(json["kind"], "headlessRendezvous"); + assert_eq!(json["requesterAgentId"], requester.to_string()); + assert_eq!(json["targetAgentId"], owner.to_string()); + assert_eq!(json["conversationId"], conversation_id.to_string()); + + let command = BackgroundTask::new( + TaskId::from_uuid(Uuid::from_u128(7)), + project_id, + owner, + BackgroundTaskKind::Command { + label: "cargo test".to_owned(), + }, + domain::BackgroundTaskWakePolicy::RecordOnly, + 100, + None, + ) + .unwrap(); + let json = serde_json::to_value(BackgroundTaskDto::from(command)).unwrap(); + assert!(json.get("requesterAgentId").is_none()); + assert!(json.get("targetAgentId").is_none()); + assert!(json.get("conversationId").is_none()); + } + + #[test] + fn agent_background_task_state_dto_exposes_rendezvous_context() { + let requester = AgentId::from_uuid(Uuid::from_u128(11)); + let target = AgentId::from_uuid(Uuid::from_u128(12)); + let conversation_id = ConversationId::from_uuid(Uuid::from_u128(13)); + let state = AgentBackgroundTaskState { + task_id: TaskId::from_uuid(Uuid::from_u128(14)), + kind: BackgroundTaskKindLabel::HeadlessRendezvous, + state: BackgroundTaskState::Completed, + exit_code: None, + summary: Some("ok".to_owned()), + stdout_tail: None, + stderr_tail: None, + requester_agent_id: Some(requester), + target_agent_id: Some(target), + conversation_id: Some(conversation_id), + created_at_ms: 100, + updated_at_ms: 200, + }; + + let json = serde_json::to_value(AgentBackgroundTaskStateDto::from(state)).unwrap(); + + assert_eq!(json["requesterAgentId"], requester.to_string()); + assert_eq!(json["targetAgentId"], target.to_string()); + assert_eq!(json["conversationId"], conversation_id.to_string()); + } } diff --git a/crates/backend/src/events.rs b/crates/backend/src/events.rs index 060c0e4..68864bd 100644 --- a/crates/backend/src/events.rs +++ b/crates/backend/src/events.rs @@ -6,7 +6,7 @@ use serde::Serialize; use domain::conversation::ConversationParty; -use domain::events::{DomainEvent, OrchestrationSource}; +use domain::events::{DomainEvent, OrchestrationSource, RendezvousContext}; use domain::input::AgentLiveness; use domain::model_server::ModelServerLifecycleStatus; use domain::{IssueLinkKind, IssuePriority, IssueStatus}; @@ -298,6 +298,15 @@ pub enum DomainEventDto { agent_id: String, /// Lightweight event/state label. state: String, + /// Agent that requested a headless rendezvous, when known. + #[serde(skip_serializing_if = "Option::is_none")] + requester_agent_id: Option, + /// Target agent for a headless rendezvous. + #[serde(skip_serializing_if = "Option::is_none")] + target_agent_id: Option, + /// Conversation opened by a headless rendezvous. + #[serde(skip_serializing_if = "Option::is_none")] + conversation_id: Option, }, /// An agent inbox queue depth changed. #[serde(rename_all = "camelCase")] @@ -725,6 +734,24 @@ fn conversation_party_wire(party: ConversationParty) -> String { } } +fn rendezvous_requester_agent_id(rendezvous: &Option) -> Option { + rendezvous + .as_ref() + .and_then(|ctx| ctx.requester_agent_id.map(|id| id.to_string())) +} + +fn rendezvous_target_agent_id(rendezvous: &Option) -> Option { + rendezvous + .as_ref() + .map(|ctx| ctx.target_agent_id.to_string()) +} + +fn rendezvous_conversation_id(rendezvous: &Option) -> Option { + rendezvous + .as_ref() + .map(|ctx| ctx.conversation_id.to_string()) +} + impl From<&DomainEvent> for DomainEventDto { fn from(e: &DomainEvent) -> Self { match e { @@ -862,6 +889,9 @@ impl From<&DomainEvent> for DomainEventDto { task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: "started".to_owned(), + requester_agent_id: None, + target_agent_id: None, + conversation_id: None, }, DomainEvent::BackgroundTaskStateChanged { project_id, @@ -873,36 +903,51 @@ impl From<&DomainEvent> for DomainEventDto { task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: format!("{state:?}"), + requester_agent_id: None, + target_agent_id: None, + conversation_id: None, }, DomainEvent::BackgroundTaskCompleted { project_id, task_id, owner_agent_id, + rendezvous, } => Self::BackgroundTaskChanged { project_id: project_id.to_string(), task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: "completed".to_owned(), + requester_agent_id: rendezvous_requester_agent_id(rendezvous), + target_agent_id: rendezvous_target_agent_id(rendezvous), + conversation_id: rendezvous_conversation_id(rendezvous), }, DomainEvent::BackgroundTaskFailed { project_id, task_id, owner_agent_id, + rendezvous, } => Self::BackgroundTaskChanged { project_id: project_id.to_string(), task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: "failed".to_owned(), + requester_agent_id: rendezvous_requester_agent_id(rendezvous), + target_agent_id: rendezvous_target_agent_id(rendezvous), + conversation_id: rendezvous_conversation_id(rendezvous), }, DomainEvent::BackgroundTaskCancelled { project_id, task_id, owner_agent_id, + rendezvous, } => Self::BackgroundTaskChanged { project_id: project_id.to_string(), task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: "cancelled".to_owned(), + requester_agent_id: rendezvous_requester_agent_id(rendezvous), + target_agent_id: rendezvous_target_agent_id(rendezvous), + conversation_id: rendezvous_conversation_id(rendezvous), }, DomainEvent::BackgroundTaskCompletionDeliveryPending { project_id, @@ -913,6 +958,9 @@ impl From<&DomainEvent> for DomainEventDto { task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: "deliveryPending".to_owned(), + requester_agent_id: None, + target_agent_id: None, + conversation_id: None, }, DomainEvent::BackgroundTaskCompletionDelivered { project_id, @@ -923,6 +971,9 @@ impl From<&DomainEvent> for DomainEventDto { task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: "delivered".to_owned(), + requester_agent_id: None, + target_agent_id: None, + conversation_id: None, }, DomainEvent::AgentInboxQueued { agent_id, depth } => Self::AgentInboxChanged { agent_id: agent_id.to_string(), @@ -1205,7 +1256,7 @@ mod tests { use super::*; use domain::ids::AgentId; use domain::mailbox::TicketId; - use domain::{LocalModelServerId, ProjectId}; + use domain::{ConversationId, LocalModelServerId, ProjectId, TaskId}; use serde_json::json; fn agent(n: u128) -> AgentId { @@ -1216,6 +1267,14 @@ mod tests { LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n)) } + fn task(n: u128) -> TaskId { + TaskId::from_uuid(uuid::Uuid::from_u128(n)) + } + + fn conversation(n: u128) -> ConversationId { + ConversationId::from_uuid(uuid::Uuid::from_u128(n)) + } + #[test] fn model_server_status_changed_relays_ready_to_dto_and_wire() { let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged { @@ -1348,6 +1407,56 @@ mod tests { ); } + #[test] + fn background_completion_relays_rendezvous_context_to_wire() { + let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1)); + let task_id = task(2); + let requester = agent(3); + let target = agent(4); + let conversation_id = conversation(5); + + let dto = DomainEventDto::from(&DomainEvent::BackgroundTaskCompleted { + project_id, + task_id, + owner_agent_id: target, + rendezvous: Some(RendezvousContext { + requester_agent_id: Some(requester), + target_agent_id: target, + conversation_id, + }), + }); + + assert_eq!( + serde_json::to_value(&dto).unwrap(), + json!({ + "type": "backgroundTaskChanged", + "projectId": project_id.to_string(), + "taskId": task_id.to_string(), + "agentId": target.to_string(), + "state": "completed", + "requesterAgentId": requester.to_string(), + "targetAgentId": target.to_string(), + "conversationId": conversation_id.to_string(), + }) + ); + } + + #[test] + fn background_failure_without_rendezvous_omits_context_fields() { + let dto = DomainEventDto::from(&DomainEvent::BackgroundTaskFailed { + project_id: ProjectId::from_uuid(uuid::Uuid::from_u128(1)), + task_id: task(2), + owner_agent_id: agent(3), + rendezvous: None, + }); + + let json = serde_json::to_value(&dto).unwrap(); + assert_eq!(json["type"], "backgroundTaskChanged"); + assert!(json.get("requesterAgentId").is_none()); + assert!(json.get("targetAgentId").is_none()); + assert!(json.get("conversationId").is_none()); + } + /// LS6 : un `AgentRateLimited` du domaine se relaie en DTO portant le même agent /// et l'heure de reset (époche-ms), et se sérialise en `"agentRateLimited"` avec /// `resetsAtMs` — le fait neutre que le front badge « limité jusqu'à HH:MM ». diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 9e44c52..ce1a283 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -1,7 +1,7 @@ //! Domain events published on the [`crate::ports::EventBus`] and relayed to the //! presentation layer (ARCHITECTURE §3.2). -use crate::conversation::ConversationParty; +use crate::conversation::{ConversationId, ConversationParty}; use crate::device::DeviceId; use crate::ids::{ AgentId, IssueId, LocalModelServerId, ProfileId, ProjectId, SessionId, SkillId, SprintId, @@ -14,6 +14,18 @@ use crate::plugin::{PluginId, PluginMcpServerId, PluginVersion}; use crate::sprint::{SprintOrder, SprintVersion}; use crate::template::TemplateVersion; +/// Context carried by terminal background-task events when they come from a +/// headless inter-agent rendezvous. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RendezvousContext { + /// Agent that requested the rendezvous, when known. + pub requester_agent_id: Option, + /// Target agent that owns the rendezvous conversation. + pub target_agent_id: AgentId, + /// Conversation opened by the target turn. + pub conversation_id: ConversationId, +} + /// Which entry door a processed orchestration request arrived through. /// /// IdeA exposes the *same* [`crate::OrchestratorService::dispatch`] behind two @@ -142,6 +154,8 @@ pub enum DomainEvent { task_id: TaskId, /// The agent that owns completion delivery. owner_agent_id: AgentId, + /// Rendezvous context when this terminal task is a headless ask. + rendezvous: Option, }, /// A first-class background task failed. BackgroundTaskFailed { @@ -151,6 +165,8 @@ pub enum DomainEvent { task_id: TaskId, /// The agent that owns completion delivery. owner_agent_id: AgentId, + /// Rendezvous context when this terminal task is a headless ask. + rendezvous: Option, }, /// A first-class background task was cancelled. BackgroundTaskCancelled { @@ -160,6 +176,8 @@ pub enum DomainEvent { task_id: TaskId, /// The agent that owns completion delivery. owner_agent_id: AgentId, + /// Rendezvous context when this terminal task is a headless ask. + rendezvous: Option, }, /// A first-class background task has a terminal result not yet delivered. BackgroundTaskCompletionDeliveryPending { diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index fe3c8f5..f6cd8f0 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -76,6 +76,7 @@ mod validation; // --------------------------------------------------------------------------- pub use error::DomainError; +pub use events::RendezvousContext; pub use ids::{ AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, RuntimeAgentKey, diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index e14ca7c..6853d1f 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -5874,6 +5874,7 @@ mod tests { project_id, task_id, owner_agent_id: owner, + rendezvous: None, }); let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv()) .await diff --git a/frontend/src/adapters/workStateNormalization.ts b/frontend/src/adapters/workStateNormalization.ts index 295c73d..3d76e09 100644 --- a/frontend/src/adapters/workStateNormalization.ts +++ b/frontend/src/adapters/workStateNormalization.ts @@ -100,6 +100,15 @@ function normalizeBackgroundTask( fallbackAgentId: string, ): BackgroundCompletion { const task = isRecord(value) ? value : {}; + const requesterAgentId = optionalString( + task.requesterAgentId ?? task.requester_agent_id, + ); + const targetAgentId = optionalString( + task.targetAgentId ?? task.target_agent_id, + ); + const conversationId = optionalString( + task.conversationId ?? task.conversation_id, + ); return { taskId: stringValue(task.taskId, `legacy-task-${index}`), ownerAgentId: stringValue(task.ownerAgentId, fallbackAgentId), @@ -110,6 +119,9 @@ function normalizeBackgroundTask( summary: nullableString(task.summary), stdoutTail: nullableString(task.stdoutTail), stderrTail: nullableString(task.stderrTail), + ...(requesterAgentId ? { requesterAgentId } : {}), + ...(targetAgentId ? { targetAgentId } : {}), + ...(conversationId ? { conversationId } : {}), updatedAtMs: numberValue(task.updatedAtMs), }; } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 8ff6e81..70df616 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -300,6 +300,9 @@ export type DomainEvent = taskId: string; agentId: string; state: string; + requesterAgentId?: string; + targetAgentId?: string; + conversationId?: string; } | { type: "agentInboxChanged"; @@ -608,6 +611,9 @@ export interface BackgroundCompletion { summary: string | null; stdoutTail: string | null; stderrTail: string | null; + requesterAgentId?: string; + targetAgentId?: string; + conversationId?: string; /** Last-update timestamp (epoch ms); chronological ordering key. */ updatedAtMs: number; } diff --git a/frontend/src/features/projects/ProjectsView.ls7.test.tsx b/frontend/src/features/projects/ProjectsView.ls7.test.tsx index efed359..331acd5 100644 --- a/frontend/src/features/projects/ProjectsView.ls7.test.tsx +++ b/frontend/src/features/projects/ProjectsView.ls7.test.tsx @@ -90,10 +90,17 @@ function fixedConversation(): ConversationGateway { }; } -function renderView(project: MockProjectGateway) { - const agent = new MockAgentGateway(); +function renderView( + project: MockProjectGateway, + overrides: { + agent?: MockAgentGateway; + system?: MockSystemGateway; + } = {}, +) { + const agent = overrides.agent ?? new MockAgentGateway(); + const system = overrides.system ?? new MockSystemGateway(); const gateways = { - system: new MockSystemGateway(), + system, project, agent, profile: new MockProfileGateway(), @@ -224,4 +231,75 @@ describe("ProjectsView — LS7 conversation viewer integration", () => { screen.queryByRole("button", { name: "← Retour aux terminaux" }), ).toBeNull(); }); + + it("labels rendezvous completion toasts with requester and target, then opens the conversation", async () => { + const project = new MockProjectGateway(); + const created = await project.createProject("alpha", "/p/a"); + const agent = new MockAgentGateway(); + const system = new MockSystemGateway(); + const requester = await agent.createAgent(created.id, { + name: "Main", + profileId: "codex", + }); + const target = await agent.createAgent(created.id, { + name: "DevBackend", + profileId: "codex", + }); + renderView(project, { agent, system }); + + await openProjectAndWorkTab("/p/a"); + system.emit({ + type: "backgroundTaskChanged", + projectId: created.id, + agentId: target.id, + taskId: "task-rendezvous-91", + state: "completed", + requesterAgentId: requester.id, + targetAgentId: target.id, + conversationId: CONV_ID, + }); + + const toast = await screen.findByRole("button", { + name: /Main -> DevBackend completed/i, + }); + expect(within(toast).getByText("Task task-ren")).toBeTruthy(); + + fireEvent.click(toast); + + expect( + await screen.findByRole("button", { name: "← Retour aux terminaux" }), + ).toBeTruthy(); + expect(await screen.findByText("contenu du fil ouvert")).toBeTruthy(); + expect( + screen.queryByRole("button", { name: /Main -> DevBackend completed/i }), + ).toBeNull(); + }); + + it("keeps the generic background-task toast when rendezvous agent ids are absent", async () => { + const project = new MockProjectGateway(); + const created = await project.createProject("alpha", "/p/a"); + const system = new MockSystemGateway(); + renderView(project, { system }); + + await openProjectAndWorkTab("/p/a"); + system.emit({ + type: "backgroundTaskChanged", + projectId: created.id, + agentId: "agent-generic-1", + taskId: "task-generic-1", + state: "failed", + }); + + const toast = await screen.findByRole("button", { + name: /Background task failed/i, + }); + expect(within(toast).getByText("agent-ge · task-gen")).toBeTruthy(); + + fireEvent.click(toast); + + expect( + screen.queryByRole("button", { name: "← Retour aux terminaux" }), + ).toBeNull(); + expect(await screen.findByText("Work")).toBeTruthy(); + }); }); diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 6ee10b8..8ac1f08 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -35,7 +35,7 @@ import { useEffect, useState, type ReactNode } from "react"; -import type { DomainEvent, LayoutInfo } from "@/domain"; +import type { Agent, DomainEvent, LayoutInfo } from "@/domain"; import { LayoutGrid, LayoutTabs } from "@/features/layout"; import { ConversationViewer } from "@/features/conversations"; import { @@ -98,6 +98,14 @@ interface BackgroundTaskToast { agentId: string; taskId: string; state: string; + title: string; + subtitle: string; + conversationId?: string; +} + +interface PendingConversationOpen { + projectId: string; + conversationId: string; } function isTerminalBackgroundTaskEvent( @@ -112,9 +120,30 @@ function isTerminalBackgroundTaskEvent( ); } +function shortTaskId(id: string): string { + return id.slice(0, 8); +} + +function fallbackAgentLabel(id: string): string { + return shortTaskId(id); +} + +function agentLabel(agents: Agent[], agentId: string): string { + return ( + agents.find((candidate) => candidate.id === agentId)?.name ?? + fallbackAgentLabel(agentId) + ); +} + export function ProjectsView() { const vm = useProjects(); - const { system, window: windowGateway, focusedProject, git } = useGateways(); + const { + system, + window: windowGateway, + focusedProject, + git, + agent, + } = useGateways(); const [name, setName] = useState(""); const [root, setRoot] = useState(""); // Placement of every open view (#22): each panel is "closed" (absent), @@ -142,6 +171,8 @@ export function ProjectsView() { const [viewerConversationId, setViewerConversationId] = useState< string | null >(null); + const [pendingConversationOpen, setPendingConversationOpen] = + useState(null); const [taskToasts, setTaskToasts] = useState([]); const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null; @@ -195,6 +226,14 @@ export function ProjectsView() { setViewerConversationId(null); }, [active?.id]); + useEffect(() => { + if (!active || pendingConversationOpen?.projectId !== active.id) return; + setSettingsSection(null); + setViewerConversationId(pendingConversationOpen.conversationId); + dismissFloating(); + setPendingConversationOpen(null); + }, [active?.id, pendingConversationOpen]); + // Publish the focused project (#47) so detached panel-only windows follow the // main window: they render this project, or an "open a project" shell when // none is active. Publish on EVERY change of `active` — including @@ -211,17 +250,44 @@ export function ProjectsView() { let cancelled = false; void system.onDomainEvent((event) => { if (!isTerminalBackgroundTaskEvent(event)) return; - const toast: BackgroundTaskToast = { - id: `${event.taskId}-${event.state}-${Date.now()}`, - projectId: event.projectId, - agentId: event.agentId, - taskId: event.taskId, - state: event.state, - }; - setTaskToasts((prev) => [...prev.slice(-2), toast]); - window.setTimeout(() => { - setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id)); - }, 7000); + void (async () => { + const hasRendezvousAgents = Boolean( + event.requesterAgentId && event.targetAgentId, + ); + const labels = hasRendezvousAgents + ? await agent + .listAgents(event.projectId) + .then((agents) => ({ + requester: agentLabel(agents, event.requesterAgentId!), + target: agentLabel(agents, event.targetAgentId!), + })) + .catch(() => ({ + requester: fallbackAgentLabel(event.requesterAgentId!), + target: fallbackAgentLabel(event.targetAgentId!), + })) + : null; + if (cancelled) return; + const toast: BackgroundTaskToast = { + id: `${event.taskId}-${event.state}-${Date.now()}`, + projectId: event.projectId, + agentId: event.agentId, + taskId: event.taskId, + state: event.state, + title: labels + ? `${labels.requester} -> ${labels.target} ${event.state}` + : `Background task ${event.state}`, + subtitle: labels + ? `Task ${shortTaskId(event.taskId)}` + : `${shortTaskId(event.agentId)} · ${shortTaskId(event.taskId)}`, + ...(event.conversationId + ? { conversationId: event.conversationId } + : {}), + }; + setTaskToasts((prev) => [...prev.slice(-2), toast]); + window.setTimeout(() => { + setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id)); + }, 7000); + })(); }).then((u) => { if (cancelled) u(); else unsubscribe = u; @@ -230,7 +296,7 @@ export function ProjectsView() { cancelled = true; unsubscribe?.(); }; - }, [system]); + }, [agent, system]); const activeLayoutKind = activeLayout?.kind ?? "terminal"; @@ -367,6 +433,29 @@ export function ProjectsView() { dismissFloating(); } + async function handleTaskToastClick(toast: BackgroundTaskToast) { + setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id)); + const projectOpen = vm.openTabs.some((tab) => tab.id === toast.projectId); + + if (toast.conversationId) { + setPendingConversationOpen({ + projectId: toast.projectId, + conversationId: toast.conversationId, + }); + if (projectOpen) { + vm.activateTab(toast.projectId); + } else { + await vm.openProject(toast.projectId); + } + return; + } + + if (projectOpen) vm.activateTab(toast.projectId); + setSettingsSection(null); + setViewerConversationId(null); + setPlacement("work", "floating"); + } + // ── Menus (#26) ───────────────────────────────────────────────────────── // A single « Panneaux » menu: one entry per panel, each opening a submenu with // the placement actions directly (closed / floating / docked left/right / @@ -804,24 +893,13 @@ export function ProjectsView() { key={toast.id} type="button" className="rounded-md border border-border bg-surface px-3 py-2 text-left shadow-lg hover:border-border-strong" - onClick={() => { - const projectOpen = vm.openTabs.some( - (tab) => tab.id === toast.projectId, - ); - if (projectOpen) vm.activateTab(toast.projectId); - setSettingsSection(null); - setViewerConversationId(null); - setPlacement("work", "floating"); - setTaskToasts((prev) => - prev.filter((item) => item.id !== toast.id), - ); - }} + onClick={() => void handleTaskToastClick(toast)} > - Background task {toast.state} + {toast.title} - {toast.agentId.slice(0, 8)} · {toast.taskId.slice(0, 8)} + {toast.subtitle} ))} diff --git a/frontend/src/features/workstate/workstate.test.tsx b/frontend/src/features/workstate/workstate.test.tsx index a16253a..0773008 100644 --- a/frontend/src/features/workstate/workstate.test.tsx +++ b/frontend/src/features/workstate/workstate.test.tsx @@ -180,6 +180,7 @@ describe("ProjectWorkStatePanel", () => { // Mirrors AgentBackgroundTaskStateDto (crates/app-tauri/src/dto.rs): camelCase, // `state` (not `status`), optional exitCode/summary/stdoutTail/stderrTail omitted // when absent, createdAtMs/updatedAtMs present, NO ownerAgentId/projectId/finishedAtMs. + // Headless rendezvous tasks may carry requester/target/conversation context. const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { agents: [ @@ -200,6 +201,9 @@ describe("ProjectWorkStatePanel", () => { taskId: "task-queued-1", kind: "headlessRendezvous", state: "queued", + requesterAgentId: "agent-main", + targetAgentId: "agent-bg", + conversationId: "conversation-rdv", createdAtMs: 12, updatedAtMs: 13, }, @@ -241,6 +245,9 @@ describe("ProjectWorkStatePanel", () => { expect(tasks[2]?.stderrTail).toBe("boom"); // `summary` (ticket #5) is carried through when present. expect(tasks[2]?.summary).toBe("process failed"); + expect(tasks[1]?.requesterAgentId).toBe("agent-main"); + expect(tasks[1]?.targetAgentId).toBe("agent-bg"); + expect(tasks[1]?.conversationId).toBe("conversation-rdv"); // updatedAtMs (backend-emitted) is carried through for chronological ordering. expect(tasks.map((t) => t.updatedAtMs)).toEqual([11, 13, 15, 17]);