From 785e9935fd55f5f70840cccbc2d5acafbc088ec4 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 9 Jun 2026 09:24:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(memory):=20config=20embedders=20(LOT=20C2)?= =?UTF-8?q?=20+=20suggestion=20contextuelle=20(LOT=20C3)=20+=20contexte=20?= =?UTF-8?q?projet=20partag=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés, environnement local détecté, stratégies compilées). UI EmbedderSettings. - LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la mémoire dépasse le budget de recall sans embedder configuré (event EmbedderSuggested, anti-spam 1×/session, « ne plus demander »). - Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les agents/profils au lancement, avant la persona. UI ProjectContextPanel. Tests : backend workspace vert (0 échec) ; frontend 306/306. Co-Authored-By: Claude Opus 4.8 --- ARCHITECTURE.md | 122 ++++- crates/app-tauri/src/commands.rs | 250 +++++++-- crates/app-tauri/src/dto.rs | 241 ++++++++- crates/app-tauri/src/events.rs | 34 +- crates/app-tauri/src/lib.rs | 8 + crates/app-tauri/src/state.rs | 153 +++++- crates/app-tauri/tests/dto.rs | 27 +- crates/app-tauri/tests/dto_agents.rs | 26 +- crates/app-tauri/tests/dto_git.rs | 4 +- crates/app-tauri/tests/dto_layouts.rs | 26 +- crates/app-tauri/tests/dto_templates.rs | 23 +- crates/app-tauri/tests/dto_window.rs | 5 +- crates/app-tauri/tests/orchestrator_wiring.rs | 5 +- crates/app-tauri/tests/pty_bridge.rs | 22 +- crates/application/src/agent/lifecycle.rs | 182 +++++-- crates/application/src/agent/mod.rs | 10 +- crates/application/src/embedder/mod.rs | 24 + crates/application/src/embedder/suggestion.rs | 269 ++++++++++ crates/application/src/embedder/usecases.rs | 245 +++++++++ crates/application/src/git/mod.rs | 6 +- crates/application/src/health.rs | 12 +- crates/application/src/layout/mod.rs | 6 +- crates/application/src/layout/store.rs | 3 +- crates/application/src/layout/usecases.rs | 14 +- crates/application/src/lib.rs | 50 +- crates/application/src/memory/mod.rs | 8 +- crates/application/src/memory/usecases.rs | 10 +- .../application/src/orchestrator/service.rs | 55 +- crates/application/src/project/context.rs | 115 +++++ crates/application/src/project/mod.rs | 9 +- crates/application/src/project/open.rs | 4 +- crates/application/src/skill/usecases.rs | 22 +- crates/application/src/template/usecases.rs | 8 +- crates/application/src/terminal/registry.rs | 32 +- crates/application/src/terminal/usecases.rs | 5 +- crates/application/tests/agent_inspect.rs | 34 +- crates/application/tests/agent_lifecycle.rs | 282 +++++++--- crates/application/tests/embedder_usecases.rs | 256 ++++++++++ crates/application/tests/error_codes.rs | 10 +- crates/application/tests/git_usecases.rs | 19 +- crates/application/tests/layout_usecases.rs | 155 ++++-- crates/application/tests/memory_usecases.rs | 18 +- .../application/tests/orchestrator_service.rs | 90 +++- crates/application/tests/profile_usecases.rs | 14 +- crates/application/tests/project_usecases.rs | 58 ++- crates/application/tests/remote_usecases.rs | 6 +- crates/application/tests/skill_usecases.rs | 16 +- .../tests/snapshot_running_agents.rs | 45 +- crates/application/tests/template_usecases.rs | 101 ++-- crates/application/tests/terminal_usecases.rs | 12 +- crates/domain/src/events.rs | 19 + crates/domain/src/layout.rs | 61 ++- crates/domain/src/lib.rs | 20 +- crates/domain/src/memory.rs | 4 +- crates/domain/src/orchestrator.rs | 211 +++++++- crates/domain/src/ports.rs | 93 +++- crates/domain/tests/embedder_profile.rs | 27 +- crates/domain/tests/entities.rs | 105 +++- crates/domain/tests/helpers/mod.rs | 4 +- crates/domain/tests/layout.rs | 36 +- crates/domain/tests/memory.rs | 13 +- crates/domain/tests/serde_roundtrip.rs | 76 ++- crates/domain/tests/window.rs | 4 +- crates/infrastructure/src/fs/mod.rs | 10 +- crates/infrastructure/src/git/mod.rs | 6 +- crates/infrastructure/src/inspector/claude.rs | 9 +- crates/infrastructure/src/lib.rs | 18 +- crates/infrastructure/src/orchestrator/mod.rs | 20 +- crates/infrastructure/src/pty/mod.rs | 9 +- crates/infrastructure/src/runtime/mod.rs | 13 +- crates/infrastructure/src/store/context.rs | 5 +- crates/infrastructure/src/store/embedder.rs | 220 +++++++- crates/infrastructure/src/store/mod.rs | 13 +- crates/infrastructure/src/store/profile.rs | 26 +- crates/infrastructure/src/store/project.rs | 5 +- crates/infrastructure/src/store/skill.rs | 9 +- crates/infrastructure/src/store/vector.rs | 5 +- crates/infrastructure/tests/agent_runtime.rs | 86 +++- crates/infrastructure/tests/context_store.rs | 10 +- .../infrastructure/tests/embedder_config.rs | 236 +++++++++ crates/infrastructure/tests/git_repository.rs | 9 +- crates/infrastructure/tests/http_embedder.rs | 27 +- crates/infrastructure/tests/memory_recall.rs | 40 +- crates/infrastructure/tests/memory_store.rs | 18 +- crates/infrastructure/tests/onnx_embedder.rs | 24 +- .../tests/orchestrator_watcher.rs | 59 ++- crates/infrastructure/tests/project_store.rs | 5 +- crates/infrastructure/tests/pty_adapter.rs | 15 +- crates/infrastructure/tests/remote_host.rs | 10 +- crates/infrastructure/tests/skill_store.rs | 30 +- crates/infrastructure/tests/template_store.rs | 10 +- crates/infrastructure/tests/vector_recall.rs | 110 +++- frontend/src/adapters/agent.test.ts | 10 + frontend/src/adapters/agent.ts | 16 + frontend/src/adapters/embedder.ts | 35 ++ frontend/src/adapters/index.ts | 3 + frontend/src/adapters/mock/index.ts | 150 +++++- frontend/src/adapters/mock/mock.test.ts | 3 +- frontend/src/adapters/project.test.ts | 27 + frontend/src/adapters/project.ts | 10 + frontend/src/domain/index.ts | 59 +++ frontend/src/features/agents/AgentsPanel.tsx | 25 +- frontend/src/features/agents/useAgents.ts | 59 ++- .../features/embedder/EmbedderSettings.tsx | 481 ++++++++++++++++++ .../src/features/embedder/embedder.test.tsx | 237 +++++++++ frontend/src/features/embedder/index.ts | 4 + frontend/src/features/embedder/useEmbedder.ts | 118 +++++ .../src/features/layout/LayoutGrid.test.tsx | 19 +- frontend/src/features/layout/LayoutGrid.tsx | 137 ++++- .../src/features/layout/setCellAgent.test.tsx | 6 +- .../features/layout/singletonAgent.test.tsx | 72 ++- frontend/src/features/layout/useLayout.ts | 67 ++- frontend/src/features/memory/MemoryPanel.tsx | 18 + frontend/src/features/memory/memory.test.tsx | 7 +- .../features/projects/ProjectContextPanel.tsx | 115 +++++ .../src/features/projects/ProjectsView.tsx | 19 +- .../src/features/projects/projects.test.tsx | 32 ++ frontend/src/ports/index.ts | 49 ++ 118 files changed, 5793 insertions(+), 866 deletions(-) create mode 100644 crates/application/src/embedder/mod.rs create mode 100644 crates/application/src/embedder/suggestion.rs create mode 100644 crates/application/src/embedder/usecases.rs create mode 100644 crates/application/src/project/context.rs create mode 100644 crates/application/tests/embedder_usecases.rs create mode 100644 crates/infrastructure/tests/embedder_config.rs create mode 100644 frontend/src/adapters/embedder.ts create mode 100644 frontend/src/adapters/project.test.ts create mode 100644 frontend/src/features/embedder/EmbedderSettings.tsx create mode 100644 frontend/src/features/embedder/embedder.test.tsx create mode 100644 frontend/src/features/embedder/index.ts create mode 100644 frontend/src/features/embedder/useEmbedder.ts create mode 100644 frontend/src/features/projects/ProjectContextPanel.tsx diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a3ca9ee..4d9073d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -114,7 +114,9 @@ Workspace 1───* Window 1───* Tab 1───1 Project (LayoutNode récursif) ├──1 RemoteHost (Local | Ssh | Wsl) │ feuilles └──1 AgentManifest (.ideai/agents.json) ▼ - TerminalSession 1───? Agent (si lancé par un agent) + TerminalSession 1───? AgentSession 1───1 Agent + │ + └──? CellBinding (0 ou 1 cellule visible) ``` ### 3.2 Entités & Value Objects (avec invariants) @@ -131,6 +133,18 @@ Workspace 1───* Window 1───* Tab 1───1 Project - Champs : `id`, `name`, `context: AgentContextRef` (chemin du `.md` dans `.ideai/`), `profile_id: ProfileId`, `origin: AgentOrigin` (`Scratch` | `FromTemplate { template_id, synced_version }`), `synchronized: bool`. - Invariants : `synchronized == true` ⇒ `origin == FromTemplate{..}` (on ne peut pas synchroniser un agent créé from scratch). `context` doit exister à l'activation. `profile_id` doit référencer un `AgentProfile` connu. +**`AgentSession`** (entité — exécution active d'un agent IdeA) +- Champs : `id`, `agent_id`, `profile_id`, `terminal_session_id`, `requested_by: AgentRequester` (`User` | `Agent { agent_id, session_id? }`), `task: Option`, `visibility: AgentVisibility`, `status` (`Starting|Running|WaitingForUser|Failed|Stopped|Exited{code}`). +- Invariants : une session active référence **un seul** `Agent` et **un seul** `AgentProfile` résolu au lancement ; elle peut être visible dans **0 ou 1** cellule. Le profil de l'agent demandeur ne contraint jamais le profil de l'agent cible. + +**`AgentVisibility` / `CellBinding`** (VO) +``` +AgentVisibility = + | Background + | Visible { node_id: NodeId } +``` +- Invariants : une cellule peut afficher 0 ou 1 `AgentSession` active ; une `AgentSession` active peut être attachée à 0 ou 1 cellule visible. Fermer une cellule **détache** la session (`Visible` → `Background`) sans arrêter le process. Ouvrir une cellule sur une session déjà active **réattache** la session et affiche le travail en cours. + **`AgentTemplate`** (entité, store global) - Champs : `id`, `name`, `content_md: MarkdownDoc`, `version: TemplateVersion`, `default_profile_id`. - Invariants : `version` **monotone croissante** ; toute modification du `content_md` ⇒ `version + 1` (voir §8). @@ -150,8 +164,8 @@ ContextInjection = - Invariants : `ConventionFile.target` est un nom de fichier relatif (pas de `..`, pas absolu) ; `Env.var` est un identifiant d'env valide ; `Flag.flag` non vide. **`TerminalSession`** (entité) -- Champs : `id`, `node_id` (cellule du layout qui l'héberge), `cwd: ProjectPath`, `kind: SessionKind` (`Plain` | `Agent { agent_id }`), `pty_size: PtySize { rows, cols }`, `status` (`Starting|Running|Exited{code}`). -- Invariants : une cellule (feuille de layout) héberge **au plus une** `TerminalSession` active. `pty_size.rows>0 && cols>0`. +- Champs : `id`, `node_id: Option` (cellule visible qui l'héberge, absente si arrière-plan), `cwd: ProjectPath`, `kind: SessionKind` (`Plain` | `Agent { session_id }`), `pty_size: PtySize { rows, cols }`, `status` (`Starting|Running|Exited{code}`). +- Invariants : une cellule (feuille de layout) héberge **au plus une** `TerminalSession` active. Une session agent peut conserver son PTY sans cellule visible. `pty_size.rows>0 && cols>0`. **`LayoutNode` / `LayoutTree`** (VO récursif — voir §7 pour le détail complet) - Invariants : poids relatifs strictement positifs ; somme normalisable ; pas de fusion qui chevauche deux conteneurs distincts ; un `Leaf` référence 0 ou 1 `SessionId`. @@ -184,7 +198,7 @@ RemoteRef = - Invariants : `name` non vide ; `content_md` non vide. - Un agent référence 0..N skills (dans l'`AgentManifest`). Les skills assignés sont injectés dans son convention file à l'activation. -**`DomainEvent`** (enum) — `ProjectCreated`, `AgentLaunched`, `AgentExited`, `TemplateUpdated`, `AgentDriftDetected`, `SkillAssigned`, `LayoutChanged`, `RemoteConnected`, `GitStateChanged`, `PtyOutput{session_id, bytes}`, `OrchestratorRequest{requester_id, action}` (ce dernier souvent court-circuité vers un Channel). +**`DomainEvent`** (enum) — `ProjectCreated`, `AgentLaunched`, `AgentSessionAttached`, `AgentSessionDetached`, `AgentExited`, `TemplateUpdated`, `AgentDriftDetected`, `SkillAssigned`, `LayoutChanged`, `RemoteConnected`, `GitStateChanged`, `PtyOutput{session_id, bytes}`, `OrchestratorRequest{requester_id, action}` (ce dernier souvent court-circuité vers un Channel). --- @@ -344,7 +358,11 @@ RemoteRef = | `UpdateTemplate` | Modifie un template, **bump version**, signale drift aux agents liés. | `TemplateStore`, `EventBus` | | `DetectAgentDrift` | Compare `synced_template_version` vs `template.version`. | `TemplateStore`, `AgentContextStore` | | `SyncAgentWithTemplate` | Applique la MAJ template→agent si `synchronized`. | `TemplateStore`, `AgentContextStore`, `EventBus` | -| `LaunchAgent` | Résout profil+contexte, prépare injection, ouvre cellule PTY au bon `cwd`, spawn CLI. | `AgentRuntime`, `AgentContextStore`, `RemoteHost`→`PtyPort`/`FileSystem`, `EventBus` | +| `RequestAgentWork` | Demande structurée utilisateur/agent pour faire travailler un agent IdeA cible, sans subagent natif fournisseur. | `AgentContextStore`, `AgentSessionStore`, `AgentRequestQueue`, `EventBus` | +| `LaunchAgentSession` / `LaunchAgent` | Résout profil+contexte+mémoire, prépare injection, crée ou reprend une session agent, spawn CLI si nécessaire. | `AgentRuntime`, `AgentContextStore`, `AgentSessionStore`, `RemoteHost`→`PtyPort`/`FileSystem`, `EventBus` | +| `AttachAgentSessionToCell` / `DetachAgentSessionFromCell` / `MoveAgentSessionToCell` | Rend visible, détache ou déplace une session active dans la grille sans tuer le process. | `AgentSessionStore`, `ProjectStore`, `EventBus` | +| `ListAgentSessions` / `ObserveAgentSession` | Liste les sessions visibles/arrière-plan et observe leur état/logs. | `AgentSessionStore`, `EventBus` | +| `StopAgentSession` | Arrête explicitement une session agent et son PTY. | `AgentSessionStore`, `PtyPort`, `EventBus` | | `OpenTerminal` | Ouvre un PTY simple dans une cellule. | `RemoteHost`→`PtyPort`, `EventBus` | | `WriteToTerminal` / `ResizeTerminal` / `CloseTerminal` | I/O PTY. | `PtyPort` | | `MutateLayout` (split/merge/resize/move) | Applique une opération sur le `LayoutTree` (logique **pure** dans le domaine, persistée ici). | `ProjectStore` (persistance) | @@ -402,6 +420,7 @@ struct GridCell { - Tous les `weight > 0`. Les poids sont **relatifs** (l'UI normalise pour le rendu). - Dans un `GridContainer` : aucune superposition de spans ; toute la surface couverte ; `row+row_span ≤ rows`, `col+col_span ≤ cols`. - Un `SessionId` n'apparaît que dans **un seul** `Leaf`. +- Pour une session agent, retirer le `SessionId` d'un `Leaf` détache l'affichage seulement ; l'arrêt du process passe par `StopAgentSession`/`CloseTerminal`, jamais par la fermeture visuelle de cellule. - Les opérations `split`, `merge`, `resize`, `move` sont des **fonctions pures** `LayoutTree -> Result` (immutabilité ⇒ testabilité, undo/redo facile). ### 7.3 Sérialisation & persistance @@ -458,6 +477,11 @@ drift(agent) = │ ├── agents.json # AgentManifest (mapping md ↔ template ↔ sync ↔ version) │ ├── layout.json # LayoutTree de l'onglet (sérialisé) │ ├── project.json # méta projet local (nom, profil par défaut, remote ref) +│ ├── CONTEXT.md # contexte projet partagé, injecté à tous les agents/profils +│ ├── memory/ +│ │ ├── MEMORY.md # index dérivé de rappel mémoire +│ │ ├── .md # notes mémoire, source de vérité +│ │ └── .index/ # index vectoriel dérivé/reconstructible │ ├── agents/ │ │ ├── reviewer.md # contexte d'un agent de projet │ │ ├── backend-dev.md @@ -470,9 +494,16 @@ drift(agent) = │ ├── / # cwd isolé par agent actif (créé à l'activation) │ │ └── CLAUDE.md # fichier de convention généré par IdeA (profil-dépendant) │ └── ... -└── (aucun CLAUDE.md/AGENTS.md/GEMINI.md à la racine — jamais — voir §14.1) +└── (aucun CONTEXT.md/CLAUDE.md/AGENTS.md/GEMINI.md utilisé par IdeA à la racine — voir §14.1) ``` +**Règle de désinstallation projet** : tout artefact projet utilisé par IdeA vit +sous `.ideai/`. Si l'utilisateur ne veut plus utiliser IdeA sur un projet, il +supprime `.ideai/` : contexte projet, agents, skills projet, mémoire, layouts, +requêtes d'orchestration et dossiers d'exécution disparaissent ensemble. Les +stores machine-locaux (profils globaux, templates globaux, registre des projets +récents) ne sont pas des artefacts du projet. + **Schéma `agents.json`** : ```json { @@ -599,7 +630,7 @@ IdeA/ | L10 | **Fenêtres & multi-window** | `Workspace`/`Window`/`Tab`, `MoveTabToNewWindow`, drag d'onglet → nouvelle fenêtre OS Tauri. | `application`, `app-tauri`, `frontend/app` | | L11 | **Packaging & livraison** | Tauri bundle : NSIS `setup.exe`, **AppImage** multi-distro, CI Linux+Windows. | `app-tauri`, CI | | L12 | **Skills** | Entité `Skill`, `SkillStore`, CRUD skills global+projet, assignation agent↔skills, injection dans convention file à l'activation. UI onglet Skills. | `domain/skill`, `application/skill`, `infrastructure/store`, `frontend/features/skills` | -| L13 | **OrchestratorApi** | File-watcher `.ideai/requests/`, port `OrchestratorApi`, adapter `FsOrchestratorAdapter`, actions `spawn_agent`/`stop_agent`/`update_agent_context`. | `infrastructure/orchestrator`, `application/agent`, `app-tauri` | +| L13 | **OrchestratorApi** | File-watcher `.ideai/requests/`, port `OrchestratorApi`, adapter `FsOrchestratorAdapter`, protocole `agent.run`/`agent.stop`/`agent.attach`/`agent.detach`/`agent.message`, sessions agent visibles ou arrière-plan. | `domain/agent`, `application/agent`, `infrastructure/orchestrator`, `app-tauri`, `frontend/features/agents` | | L14 | **Mémoire** | Base de connaissance projet model-agnostic. Modèle 2 étages : `.md` source de vérité (port `MemoryStore`), rappel adaptatif (port `MemoryRecall`), embeddings déclaratifs (port `Embedder`), bascule auto sur seuil. Découpé en sous-lots **A/B/C** (voir §14.5). | `domain/memory`, `application/memory`, `infrastructure/store`, `frontend/features/memory` | --- @@ -613,10 +644,12 @@ IdeA/ **Décision** : le cwd du PTY d'un agent n'est **jamais** le project root. C'est `.ideai/run//`, un dossier créé par IdeA à l'activation et nettoyé à la fermeture. **Convention file généré par IdeA** : IdeA écrit dans ce dossier le fichier conventionnel attendu par le profil (`CLAUDE.md`, `AGENTS.md`, etc.). Ce fichier contient : -1. La **persona/rôle** de l'agent (son `.md` dans `.ideai/agents/`). -2. Le **chemin absolu du project root** (pour que l'agent sache où opérer). -3. Les **skills actifs** assignés à cet agent (voir §14.2). -4. Le **rappel mémoire** du projet (index/hooks), si présent (voir §14.5.4). +1. Le **chemin absolu du project root** (pour que l'agent sache où opérer). +2. Le contrat d'**orchestration IdeA** (délégation via `.ideai/requests`, pas via les subagents natifs du fournisseur). +3. Le **contexte projet partagé** (`.ideai/CONTEXT.md`), si présent. +4. La **persona/rôle** de l'agent (son `.md` dans `.ideai/agents/`). +5. Les **skills actifs** assignés à cet agent (voir §14.2). +6. Le **rappel mémoire** du projet (index/hooks), si présent (voir §14.5.4). **Avantages** : - Zéro collision entre agents, même N instances du même profil. @@ -645,26 +678,65 @@ IdeA/ --- -### 14.3 OrchestratorApi — spawn d'agents depuis un agent ou depuis l'UI +### 14.3 OrchestratorApi — IdeA orchestre les agents, pas les CLIs fournisseurs -**Objectif** : qu'un agent orchestrateur puisse demander à IdeA de créer un nouvel agent (visible dans la grille et dans l'onglet Agents), exactement comme le ferait l'utilisateur via l'UI. +**Objectif** : qu'un agent ou l'utilisateur puisse demander à IdeA de faire travailler un autre agent IdeA, visible dans la grille ou en arrière-plan, sans passer par les subagents natifs d'un fournisseur IA. -**Mécanisme** : file-watching sur `.ideai/requests//`. L'orchestrateur écrit un fichier JSON de requête : -```json -{ "action": "spawn_agent", "name": "dev-backend", "profile": "claude-code", "context": "agents/dev-backend.md" } +**Décision** : IdeA est l'**orchestrateur unique** du cycle de vie des agents. Un agent Claude, Codex, Gemini ou custom ne lance jamais directement un subagent natif de son fournisseur. Il écrit une demande d'orchestration IdeA ; IdeA résout l'agent cible, son `AgentProfile`, son contexte, ses skills et sa mémoire, puis lance ou réattache la session via le runtime adapté au profil cible. + +Conséquence : le profil IA de l'agent demandeur ne contraint pas le profil IA de l'agent cible. Exemple valide : +```text +Main = Codex +Architect = Claude +DevBackend = Codex +Ask = Gemini ``` -IdeA détecte le fichier, exécute la même logique que `LaunchAgent` déclenché depuis l'UI, crée la cellule terminal, inscrit l'agent dans l'onglet Agents, puis supprime le fichier et écrit une réponse. +Si `Main` demande à `Architect` de travailler, IdeA lance/réattache `Architect` avec son profil Claude. `Main` ne connaît ni la commande Claude ni son format de contexte. -**Règle** : l'orchestrateur ne spawne jamais lui-même un process CLI — il **délègue à IdeA**. IdeA reste l'unique source de vérité du cycle de vie des agents. - -**Port `OrchestratorApi`** (adapter entrant, driven by file-watcher) : surveille `.ideai/requests/`, désérialise les commandes, les traduit en appels de use cases (`LaunchAgent`, `StopAgent`…). Implémenté dans `infrastructure/orchestrator`. - -**Actions supportées (v1)** : `spawn_agent`, `stop_agent`, `update_agent_context`, `create_skill`. - -`create_skill` permet à un orchestrateur de créer un skill réutilisable exactement comme l'UI (use case `CreateSkill`). Champs : `name`, `context` (corps Markdown du skill), `scope` optionnel (`"global"` | `"project"`, défaut `project`). Exemple : +**Mécanisme** : file-watching sur `.ideai/requests//`. L'agent écrit un fichier JSON de requête stable, consommé par IdeA : ```json -{ "action": "create_skill", "name": "deploy", "context": "# Étapes de déploiement…", "scope": "project" } +{ + "type": "agent.run", + "requestedBy": "Main", + "targetAgent": "Architect", + "task": "Analyser la décision d'architecture multi-agents", + "visibility": "background", + "attachToCell": null +} ``` +IdeA détecte le fichier, exécute les use cases d'orchestration, écrit une réponse, puis archive ou supprime la requête traitée. Le même chemin applicatif est utilisé depuis l'UI. + +**Contrat session/cellule** : +- `Agent` = définition stable (contexte `.ideai/agents/.md`, profil IA, origine template). +- `AgentSession` = exécution active d'un agent. +- `CellBinding` = affichage optionnel d'une session dans une cellule. + +Invariants : +- une `AgentSession` active peut être attachée à **0 ou 1** cellule visible ; +- une cellule peut afficher **0 ou 1** session active ; +- fermer une cellule détache la session (`Visible` → `Background`) et ne tue jamais l'agent ; +- ouvrir une cellule sur une session déjà active réattache la cellule à cette session et montre le travail en cours ; +- arrêter une session est une action explicite (`agent.stop`), distincte de la fermeture UI. + +**Port `OrchestratorApi`** (adapter entrant, driven by file-watcher) : surveille `.ideai/requests/`, désérialise les commandes, les traduit en appels de use cases (`RequestAgentWork`, `LaunchAgentSession`, `AttachAgentSessionToCell`, `DetachAgentSessionFromCell`, `StopAgentSession`…). Implémenté dans `infrastructure/orchestrator`. + +**Actions supportées (v2 cible)** : +- `agent.run` : lance ou reprend une session pour un agent cible ; `visibility` vaut `background` ou `visible`. +- `agent.stop` : arrête explicitement une session agent et son PTY. +- `agent.attach` : attache une session active à une cellule. +- `agent.detach` : détache une session active vers l'arrière-plan. +- `agent.message` : transmet une tâche/message à une session existante. +- `agent.update_context` : demande une mise à jour de contexte via les use cases IdeA, pas par écriture sauvage hors store. +- `skill.create` : crée un skill réutilisable comme l'UI (use case `CreateSkill`). + +Exemple `skill.create` : +```json +{ "type": "skill.create", "name": "deploy", "context": "# Étapes de déploiement…", "scope": "project" } +``` + +**Instruction injectée aux agents** : les convention files générés (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`…) doivent contenir une règle explicite : pour déléguer une tâche, l'agent utilise le protocole IdeA `.ideai/requests` et n'utilise pas les subagents natifs du fournisseur. Cela garantit que l'IDE garde l'identité des agents, leur mémoire, leur contexte et leur observabilité UI. + +**Impact UI/UX** : le layout n'est pas la source de vérité du travail agent. La grille affiche des vues attachées aux sessions. L'UI doit exposer un registre des sessions visibles et arrière-plan, avec actions ouvrir dans une cellule, détacher, déplacer, arrêter, et afficher la relation `requestedBy`/`targetAgent` quand elle existe. --- diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 02bfe54..774a011 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -7,45 +7,47 @@ use tauri::ipc::Channel; use tauri::State; +use crate::dto::DismissEmbedderSuggestionRequestDto; use application::{ - AppError, CloseProjectInput, CreateAgentInput, CreateLayoutInput, DeleteAgentInput, - DeleteLayoutInput, DeleteTemplateInput, DetectAgentDriftInput, GitBranchesInput, - GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, - GitStatusInput, InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, LoadLayoutInput, - MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, RenameLayoutInput, - SetActiveLayoutInput, SnapshotRunningAgentsInput, SyncAgentWithTemplateInput, - UpdateAgentContextInput, - AssignSkillToAgentInput, CreateSkillInput, DeleteSkillInput, ListSkillsInput, - UnassignSkillFromAgentInput, UpdateSkillInput, - CreateMemoryInput, DeleteMemoryInput, GetMemoryInput, ListMemoriesInput, ReadMemoryIndexInput, - RecallMemoryInput, ResolveMemoryLinksInput, UpdateMemoryInput, + AppError, AssignSkillToAgentInput, CloseProjectInput, CreateAgentInput, CreateLayoutInput, + CreateMemoryInput, CreateSkillInput, DeleteAgentInput, DeleteEmbedderProfileInput, + DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput, DeleteTemplateInput, + DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, + GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, + InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, + ListMemoriesInput, ListSkillsInput, LoadLayoutInput, MutateLayoutInput, OpenProjectInput, + ReadAgentContextInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, + RenameLayoutInput, ResolveMemoryLinksInput, SetActiveLayoutInput, SnapshotRunningAgentsInput, + SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput, + UpdateMemoryInput, UpdateProjectContextInput, UpdateSkillInput, }; use domain::ports::PtyHandle; use crate::dto::{ - parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_node_id, - parse_profile_id, LiveAgentListDto, - parse_project_id, parse_session_id, parse_template_id, AgentDriftListDto, AgentDto, - AgentListDto, ConfigureProfilesRequestDto, CreateAgentFromTemplateRequestDto, - CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateProjectRequestDto, + parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_memory_slug, + parse_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id, + parse_template_id, AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto, + AttachLiveAgentRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, + CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, + CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, - DetectProfilesRequestDto, DetectProfilesResponseDto, ErrorDto, FirstRunStateDto, - GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, - GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, - ConversationDetailsDto, InspectConversationRequestDto, - LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, OpenTerminalRequestDto, - ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, - ReattachResultDto, RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto, - SetActiveLayoutRequestDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, - TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto, - UpdateTemplateRequestDto, WriteTerminalRequestDto, parse_skill_id, AssignSkillRequestDto, - CreateSkillRequestDto, SkillDto, SkillListDto, UnassignSkillRequestDto, UpdateSkillRequestDto, - parse_memory_slug, CreateMemoryRequestDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, - MemoryListDto, RecallMemoryRequestDto, UpdateMemoryRequestDto, + DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto, + EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto, + GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, + GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, + LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, + MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto, + ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, ReattachResultDto, + RecallMemoryRequestDto, RenameLayoutRequestDto, ResizeTerminalRequestDto, + SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto, + SkillListDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, + TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, + UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateSkillRequestDto, + UpdateTemplateRequestDto, WriteTerminalRequestDto, }; -use domain::{SkillRef, SkillScope}; use crate::pty::{PtyBridge, PtyChunk}; use crate::state::AppState; +use domain::{SkillRef, SkillScope}; /// `health` — trivial command validating the full IPC pipeline /// (frontend gateway → invoke → command → use case → ports → event relay). @@ -112,10 +114,7 @@ pub async fn open_project( /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `STORE` on failure). #[tauri::command] -pub async fn close_project( - project_id: String, - state: State<'_, AppState>, -) -> Result<(), ErrorDto> { +pub async fn close_project(project_id: String, state: State<'_, AppState>) -> Result<(), ErrorDto> { let id = parse_project_id(&project_id)?; // T5: freeze `agent_was_running` on every agent leaf BEFORE any PTY release, // reading the live registry as it stands now. Best-effort: a snapshot failure @@ -152,6 +151,49 @@ pub async fn list_projects(state: State<'_, AppState>) -> Result, +) -> Result { + let project = resolve_project(&project_id, &state).await?; + state + .read_project_context + .execute(ReadProjectContextInput { project }) + .await + .map(|out| out.content) + .map_err(ErrorDto::from) +} + +/// `update_project_context` — overwrite `.ideai/CONTEXT.md` for a project. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the +/// project is unknown, `FILESYSTEM` on write failure). +#[tauri::command] +pub async fn update_project_context( + request: UpdateProjectContextRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let project = resolve_project(&request.project_id, &state).await?; + state + .update_project_context + .execute(UpdateProjectContextInput { + project, + content: request.content, + }) + .await + .map_err(ErrorDto::from) +} + // --------------------------------------------------------------------------- // Terminals (L3) // --------------------------------------------------------------------------- @@ -224,10 +266,7 @@ pub fn write_terminal( state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let input = request.into_input()?; - state - .write_terminal - .execute(input) - .map_err(ErrorDto::from) + state.write_terminal.execute(input).map_err(ErrorDto::from) } /// `resize_terminal` — resize a live PTY. @@ -241,10 +280,7 @@ pub fn resize_terminal( state: State<'_, AppState>, ) -> Result<(), ErrorDto> { let input = request.into_input()?; - state - .resize_terminal - .execute(input) - .map_err(ErrorDto::from) + state.resize_terminal.execute(input).map_err(ErrorDto::from) } /// `close_terminal` — kill a live PTY and tear down its channel. @@ -629,6 +665,99 @@ pub async fn configure_profiles( .map_err(ErrorDto::from) } +// --------------------------------------------------------------------------- +// Embedder profiles & engines (LOT C2 — §14.5.3) +// --------------------------------------------------------------------------- + +/// `list_embedder_profiles` — list the configured embedder profiles (empty when +/// none configured ⇒ the default `none` posture). +/// +/// # Errors +/// Returns an [`ErrorDto`] (`STORE` on `embedder.json` I/O failure). +#[tauri::command] +pub async fn list_embedder_profiles( + state: State<'_, AppState>, +) -> Result { + state + .list_embedder_profiles + .execute() + .await + .map(EmbedderProfileListDto::from) + .map_err(ErrorDto::from) +} + +/// `save_embedder_profile` — create or replace (by id) a single embedder profile. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for empty id/name or zero dimension, `STORE` +/// on I/O failure). +#[tauri::command] +pub async fn save_embedder_profile( + request: SaveEmbedderProfileRequestDto, + state: State<'_, AppState>, +) -> Result { + state + .save_embedder_profile + .execute(request.into()) + .await + .map(EmbedderProfileDto::from) + .map_err(ErrorDto::from) +} + +/// `delete_embedder_profile` — delete an embedder profile by id. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`NOT_FOUND` if absent, `STORE` on I/O failure). +#[tauri::command] +pub async fn delete_embedder_profile( + embedder_id: String, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + state + .delete_embedder_profile + .execute(DeleteEmbedderProfileInput { id: embedder_id }) + .await + .map_err(ErrorDto::from) +} + +/// `describe_embedder_engines` — describe the engines available to the +/// "configure an embedder?" UI: the recommended ONNX catalogue, a best-effort +/// snapshot of the local environment, and which strategies are compiled in. +/// +/// # Errors +/// Returns an [`ErrorDto`] — in practice never (the environment probe is best-effort). +#[tauri::command] +pub async fn describe_embedder_engines( + state: State<'_, AppState>, +) -> Result { + state + .describe_embedder_engines + .execute() + .await + .map(EmbedderEnginesDto::from) + .map_err(ErrorDto::from) +} + +/// `dismiss_embedder_suggestion` — persist the user's response to the one-time +/// embedder suggestion (LOT C3 — §14.5.5): `later` (re-proposable next session) or +/// `never` (silenced for good). Resolves the project root from `project_id`. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the project +/// is unknown, `STORE` on `.embedder-prompt.json` I/O failure). +#[tauri::command] +pub async fn dismiss_embedder_suggestion( + request: DismissEmbedderSuggestionRequestDto, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + let project = resolve_project(&request.project_id, &state).await?; + state + .dismiss_embedder_suggestion + .execute(request.into_input(project.root)) + .await + .map_err(ErrorDto::from) +} + // --------------------------------------------------------------------------- // Agents (L6) // --------------------------------------------------------------------------- @@ -714,6 +843,36 @@ pub fn list_live_agents( )) } +/// `attach_live_agent` — rebind an already-running agent session to a visible +/// layout cell without respawning the CLI process. +/// +/// This is the backend side of "a cell is a view": a closed cell can leave an +/// agent running in the background, and opening the agent in a new cell updates +/// the session's host node while preserving the PTY/session/scrollback. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the +/// project/agent/live session is unknown). +#[tauri::command] +pub async fn attach_live_agent( + request: AttachLiveAgentRequestDto, + state: State<'_, AppState>, +) -> Result { + let _project = resolve_project(&request.project_id, &state).await?; + let agent_id = parse_agent_id(&request.agent_id)?; + let node_id = parse_node_id(&request.node_id)?; + let session = state + .terminal_sessions + .rebind_agent_node(&agent_id, node_id) + .ok_or_else(|| AppError::NotFound(format!("running session for agent {agent_id}")))?; + + Ok(LiveAgentListDto::from_pairs(vec![( + agent_id, + session.node_id, + session.id, + )])) +} + /// `read_agent_context` — read an agent's Markdown context. /// /// # Errors @@ -1190,10 +1349,7 @@ pub async fn git_log( /// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on /// failure). #[tauri::command] -pub async fn git_init( - project_id: String, - state: State<'_, AppState>, -) -> Result<(), ErrorDto> { +pub async fn git_init(project_id: String, state: State<'_, AppState>) -> Result<(), ErrorDto> { let project = resolve_project(&project_id, &state).await?; let root = project.root.as_str().to_owned(); state @@ -1231,8 +1387,8 @@ pub async fn git_graph( // Windows (L10) // --------------------------------------------------------------------------- -use application::MoveTabToNewWindowInput; use crate::dto::{parse_tab_id, MoveTabResultDto}; +use application::MoveTabToNewWindowInput; /// `move_tab_to_new_window` — detach a tab into a brand-new OS window. /// diff --git a/crates/app-tauri/src/dto.rs b/crates/app-tauri/src/dto.rs index 197a70d..fb06a98 100644 --- a/crates/app-tauri/src/dto.rs +++ b/crates/app-tauri/src/dto.rs @@ -827,6 +827,183 @@ impl From for FirstRunStateDto { } } +// --------------------------------------------------------------------------- +// Embedder profiles & engines (LOT C2 — §14.5.3) +// --------------------------------------------------------------------------- + +use application::{ + DismissChoice, DismissEmbedderSuggestionInput, EmbedderEnginesView, ListEmbedderProfilesOutput, + OnnxModelView, SaveEmbedderProfileInput, SaveEmbedderProfileOutput, +}; +use domain::profile::EmbedderProfile; + +/// An embedder profile crossing the wire. [`EmbedderProfile`] already serialises +/// camelCase (`id, name, strategy, model?, endpoint?, apiKeyEnv?, dimension`), so we +/// embed it directly — the TS mirror matches this shape. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct EmbedderProfileDto(pub EmbedderProfile); + +/// A list of embedder profiles (camelCase array on the wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct EmbedderProfileListDto(pub Vec); + +impl From> for EmbedderProfileListDto { + fn from(v: Vec) -> Self { + Self(v.into_iter().map(EmbedderProfileDto).collect()) + } +} + +impl From for EmbedderProfileListDto { + fn from(out: ListEmbedderProfilesOutput) -> Self { + out.profiles.into() + } +} + +impl From for EmbedderProfileDto { + fn from(out: SaveEmbedderProfileOutput) -> Self { + Self(out.profile) + } +} + +/// Request DTO for `save_embedder_profile`: the embedder profile to upsert. +/// +/// Carries the [`EmbedderProfile`] fields directly (the entity validates them in the +/// use case). Deserialised camelCase to match the persisted/domain shape. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveEmbedderProfileRequestDto { + /// The embedder profile to upsert. + pub profile: EmbedderProfile, +} + +impl From for SaveEmbedderProfileInput { + fn from(dto: SaveEmbedderProfileRequestDto) -> Self { + let EmbedderProfile { + id, + name, + strategy, + model, + endpoint, + api_key_env, + dimension, + } = dto.profile; + Self { + id, + name, + strategy, + model, + endpoint, + api_key_env, + dimension, + } + } +} + +/// One recommendable local ONNX model on the wire (camelCase). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OnnxModelInfoDto { + /// Stable model id accepted by a `localOnnx` profile's `model` field. + pub id: String, + /// Human-readable name for the UI. + pub display_name: String, + /// Length of the vectors this model produces. + pub dimension: usize, + /// Approximate download/disk size in megabytes. + pub approx_size_mb: u32, + /// Whether this is the recommended default model. + pub recommended: bool, +} + +impl From for OnnxModelInfoDto { + fn from(m: OnnxModelView) -> Self { + Self { + id: m.id, + display_name: m.display_name, + dimension: m.dimension, + approx_size_mb: m.approx_size_mb, + recommended: m.recommended, + } + } +} + +/// Response DTO for `describe_embedder_engines` (drives the "configure an embedder?" +/// UI). All-camelCase wire shape. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EmbedderEnginesDto { + /// The curated catalogue of recommendable local ONNX models. + pub recommended_onnx: Vec, + /// Whether an Ollama-style local embedding server was detected (best-effort). + pub ollama_detected: bool, + /// Ids of the recommended ONNX models already present in the local cache. + pub onnx_cached_models: Vec, + /// Whether the HTTP capability (`localServer`/`api`) is compiled into this binary. + pub vector_http_enabled: bool, + /// Whether the in-process ONNX capability (`localOnnx`) is compiled into this binary. + pub vector_onnx_enabled: bool, +} + +impl From for EmbedderEnginesDto { + fn from(v: EmbedderEnginesView) -> Self { + Self { + recommended_onnx: v.recommended_onnx.into_iter().map(Into::into).collect(), + ollama_detected: v.ollama_detected, + onnx_cached_models: v.onnx_cached_models, + vector_http_enabled: v.vector_http_enabled, + vector_onnx_enabled: v.vector_onnx_enabled, + } + } +} + +// --------------------------------------------------------------------------- +// Embedder suggestion (LOT C3 — §14.5.5) +// --------------------------------------------------------------------------- + +/// The user's response to the embedder suggestion, on the wire (camelCase: +/// `"later"` | `"never"`). +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DismissChoiceDto { + /// "Plus tard" — re-proposable next session. + Later, + /// "Ne plus demander" — never again. + Never, +} + +impl From for DismissChoice { + fn from(c: DismissChoiceDto) -> Self { + match c { + DismissChoiceDto::Later => Self::Later, + DismissChoiceDto::Never => Self::Never, + } + } +} + +/// Request DTO for `dismiss_embedder_suggestion`. The `project_id` is resolved to a +/// project root by the command; `choice` is the user's dismissal. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DismissEmbedderSuggestionRequestDto { + /// The project the suggestion concerned (UUID string). + pub project_id: String, + /// The user's choice. + pub choice: DismissChoiceDto, +} + +impl DismissEmbedderSuggestionRequestDto { + /// Builds the use-case input from a resolved project root + the DTO choice. + #[must_use] + pub fn into_input(self, project_root: domain::ProjectPath) -> DismissEmbedderSuggestionInput { + DismissEmbedderSuggestionInput { + project_root, + choice: self.choice.into(), + } + } +} + /// Builds a [`DeleteProfileInput`] from a raw profile-id string. /// /// # Errors @@ -927,6 +1104,16 @@ pub struct UpdateAgentContextRequestDto { pub content: String, } +/// Request DTO for `update_project_context`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateProjectContextRequestDto { + /// Id of the owning project. + pub project_id: String, + /// New project-level Markdown context, stored under `.ideai/CONTEXT.md`. + pub content: String, +} + /// Request DTO for `launch_agent`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1015,6 +1202,9 @@ pub struct LiveAgentDto { pub agent_id: String, /// The hosting layout leaf's node id (UUID string). pub node_id: String, + /// The live PTY session id, used to reattach a newly-opened cell without + /// respawning the agent. + pub session_id: String, } /// Response DTO for `list_live_agents` (transparent array on the wire). @@ -1023,21 +1213,35 @@ pub struct LiveAgentDto { pub struct LiveAgentListDto(pub Vec); impl LiveAgentListDto { - /// Builds the wire list from the registry's `(AgentId, NodeId)` pairs. + /// Builds the wire list from the registry's `(AgentId, NodeId, SessionId)` tuples. #[must_use] - pub fn from_pairs(pairs: Vec<(AgentId, NodeId)>) -> Self { + pub fn from_pairs(pairs: Vec<(AgentId, NodeId, domain::SessionId)>) -> Self { Self( pairs .into_iter() - .map(|(agent_id, node_id)| LiveAgentDto { + .map(|(agent_id, node_id, session_id)| LiveAgentDto { agent_id: agent_id.to_string(), node_id: node_id.to_string(), + session_id: session_id.to_string(), }) .collect(), ) } } +/// Request DTO for `attach_live_agent`: bind an already-running agent session to +/// a visible layout cell without spawning a new process. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachLiveAgentRequestDto { + /// Id of the owning project (validated for symmetry and future scoping). + pub project_id: String, + /// Id of the already-running agent. + pub agent_id: String, + /// Layout leaf that should display the live session. + pub node_id: String, +} + /// Parses an agent-id string (UUID) coming from the frontend. /// /// # Errors @@ -1056,9 +1260,9 @@ pub fn parse_agent_id(raw: &str) -> Result { // --------------------------------------------------------------------------- use application::{ - AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput, - CreateTemplateOutput, DetectAgentDriftOutput, ListTemplatesOutput, SyncAgentWithTemplateOutput, - UpdateTemplateInput, UpdateTemplateOutput, + AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput, CreateTemplateOutput, + DetectAgentDriftOutput, ListTemplatesOutput, SyncAgentWithTemplateOutput, UpdateTemplateInput, + UpdateTemplateOutput, }; use domain::{AgentTemplate, TemplateId}; @@ -1253,9 +1457,7 @@ pub struct SyncAgentWithTemplateRequestDto { // Git (L8) // --------------------------------------------------------------------------- -use application::{ - GitBranchesOutput, GitCommitOutput, GitLogOutput, GitStatusOutput, -}; +use application::{GitBranchesOutput, GitCommitOutput, GitLogOutput, GitStatusOutput}; use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit}; /// One changed path returned by `git_status`. @@ -1284,7 +1486,12 @@ pub struct GitStatusListDto(pub Vec); impl From for GitStatusListDto { fn from(out: GitStatusOutput) -> Self { - Self(out.entries.into_iter().map(GitFileStatusDto::from).collect()) + Self( + out.entries + .into_iter() + .map(GitFileStatusDto::from) + .collect(), + ) } } @@ -1655,13 +1862,23 @@ pub struct MemoryIndexDto(pub Vec); impl From for MemoryIndexDto { fn from(out: ReadMemoryIndexOutput) -> Self { - Self(out.entries.into_iter().map(MemoryIndexEntryDto::from).collect()) + Self( + out.entries + .into_iter() + .map(MemoryIndexEntryDto::from) + .collect(), + ) } } impl From for MemoryIndexDto { fn from(out: RecallMemoryOutput) -> Self { - Self(out.entries.into_iter().map(MemoryIndexEntryDto::from).collect()) + Self( + out.entries + .into_iter() + .map(MemoryIndexEntryDto::from) + .collect(), + ) } } diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index 7ac92fb..148f5f7 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -129,6 +129,21 @@ pub enum DomainEventDto { /// Project id. project_id: String, }, + /// A project's memory crossed the recall budget while no embedder is configured + /// (LOT C3 — §14.5.5): a one-time, dismissible "configure an embedder?" hint. + #[serde(rename_all = "camelCase")] + EmbedderSuggested { + /// Project id. + project_id: String, + /// Whether a local Ollama-style embedding server was detected. + ollama_detected: bool, + /// Ids of recommended ONNX models already present in the local cache. + onnx_cached: Vec, + /// Whether the HTTP capability is compiled in. + vector_http_enabled: bool, + /// Whether the in-process ONNX capability is compiled in. + vector_onnx_enabled: bool, + }, /// Raw PTY output (normally routed to a per-session channel, not here). #[serde(rename_all = "camelCase")] PtyOutput { @@ -163,11 +178,7 @@ impl From<&DomainEvent> for DomainEventDto { template_id: template_id.to_string(), version: version.get(), }, - DomainEvent::AgentDriftDetected { - agent_id, - from, - to, - } => Self::AgentDriftDetected { + DomainEvent::AgentDriftDetected { agent_id, from, to } => Self::AgentDriftDetected { agent_id: agent_id.to_string(), from: from.get(), to: to.get(), @@ -212,6 +223,19 @@ impl From<&DomainEvent> for DomainEventDto { DomainEvent::MemoryIndexRebuilt { project_id } => Self::MemoryIndexRebuilt { project_id: project_id.to_string(), }, + DomainEvent::EmbedderSuggested { + project_id, + ollama_detected, + onnx_cached, + vector_http_enabled, + vector_onnx_enabled, + } => Self::EmbedderSuggested { + project_id: project_id.to_string(), + ollama_detected: *ollama_detected, + onnx_cached: onnx_cached.clone(), + vector_http_enabled: *vector_http_enabled, + vector_onnx_enabled: *vector_onnx_enabled, + }, DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput { session_id: session_id.to_string(), bytes: bytes.clone(), diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 0d343b2..d5c8060 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -92,6 +92,8 @@ pub fn run() { commands::open_project, commands::close_project, commands::list_projects, + commands::read_project_context, + commands::update_project_context, commands::open_terminal, commands::write_terminal, commands::resize_terminal, @@ -111,9 +113,15 @@ pub fn run() { commands::save_profile, commands::delete_profile, commands::configure_profiles, + commands::list_embedder_profiles, + commands::save_embedder_profile, + commands::delete_embedder_profile, + commands::describe_embedder_engines, + commands::dismiss_embedder_suggestion, commands::create_agent, commands::list_agents, commands::list_live_agents, + commands::attach_live_agent, commands::read_agent_context, commands::update_agent_context, commands::delete_agent, diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index bea724a..21e3f50 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -10,32 +10,37 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; use application::{ - CloseProject, CloseTab, CloseTerminal, ConfigureProfiles, CreateAgentFromScratch, - CreateAgentFromTemplate, CreateLayout, CreateProject, CreateTemplate, DeleteAgent, - DeleteLayout, DeleteProfile, DeleteTemplate, DetectAgentDrift, DetectProfiles, FirstRunState, - GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, - HealthUseCase, InspectConversation, LaunchAgent, ListAgents, ListLayouts, ListProfiles, ListProjects, ListSkills, - ListTemplates, LiveAgentRegistry, LoadLayout, MoveTabToNewWindow, MutateLayout, OpenProject, - OpenTerminal, OrchestratorService, ReadAgentContext, ReferenceProfiles, RenameLayout, - ResizeTerminal, SaveProfile, SetActiveLayout, SnapshotRunningAgents, - AssignSkillToAgent, CreateSkill, DeleteSkill, UnassignSkillFromAgent, UpdateSkill, - CreateMemory, DeleteMemory, GetMemory, ListMemories, ReadMemoryIndex, RecallMemory, - ResolveMemoryLinks, UpdateMemory, - SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal, + AssignSkillToAgent, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal, + ConfigureProfiles, CreateAgentFromScratch, CreateAgentFromTemplate, CreateLayout, CreateMemory, + CreateProject, CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, + DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate, DescribeEmbedderEngines, + DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetMemory, + GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, + GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, ListAgents, ListEmbedderProfiles, + ListLayouts, ListMemories, ListProfiles, ListProjects, ListSkills, ListTemplates, + LiveAgentRegistry, LoadLayout, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, + OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext, + RecallMemory, ReferenceProfiles, RenameLayout, ResizeTerminal, ResolveMemoryLinks, + SaveEmbedderProfile, SaveProfile, SetActiveLayout, SnapshotRunningAgents, SuggestedThisSession, + SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext, + UpdateMemory, UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal, + AGENT_MEMORY_RECALL_BUDGET, }; use domain::ports::{ - AgentContextStore, AgentRuntime, Clock, Embedder, EventBus, FileSystem, GitPort, IdGenerator, - MemoryRecall, MemoryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, - TemplateStore, + AgentContextStore, AgentRuntime, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore, + EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, MemoryRecall, MemoryStore, + ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, TemplateStore, }; use domain::{DomainEvent, EmbedderProfile, Project, ProjectId}; use infrastructure::{ embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime, - FsEmbedderProfileStore, FsMemoryStore, FsOrchestratorWatcher, FsProfileStore, FsProjectStore, - FsSkillStore, FsTemplateStore, Git2Repository, IdeaiContextStore, LocalFileSystem, - LocalProcessSpawner, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, - SystemClock, TokioBroadcastEventBus, UuidGenerator, VectorMemoryRecall, ONNX_CACHE_SUBDIR, + EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, FsMemoryStore, + FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, + Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, NaiveMemoryRecall, + OrchestratorWatchHandle, PortablePtyAdapter, SystemClock, TokioBroadcastEventBus, + UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, + RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; use crate::pty::PtyBridge; @@ -59,6 +64,10 @@ pub struct AppState { pub close_tab: Arc, /// List known projects. pub list_projects: Arc, + /// Read `.ideai/CONTEXT.md`. + pub read_project_context: Arc, + /// Overwrite `.ideai/CONTEXT.md`. + pub update_project_context: Arc, /// Open a terminal (spawn PTY, register session). pub open_terminal: Arc, /// Write keystrokes to a terminal. @@ -195,6 +204,19 @@ pub struct AppState { /// Recall the most relevant memory entries for a query within a budget /// (LOT B — §14.5.2). pub recall_memory: Arc, + // --- Embedder config (LOT C2 — §14.5.3) --- + /// List the configured embedder profiles (`embedder.json`). + pub list_embedder_profiles: Arc, + /// Save (upsert, validating) an embedder profile. + pub save_embedder_profile: Arc, + /// Delete an embedder profile by id. + pub delete_embedder_profile: Arc, + /// Describe the embedding engines available to the configuration UI (catalogue + /// + detected local environment + compiled-in capabilities). + pub describe_embedder_engines: Arc, + // --- Embedder suggestion (LOT C3 — §14.5.5) --- + /// Persist the user's response to the embedder suggestion (`later`/`never`). + pub dismiss_embedder_suggestion: Arc, // --- Orchestrator (§14.3) --- /// Dispatches validated orchestrator requests to the agent/skill use cases. /// Shared by every per-project filesystem watcher. @@ -254,6 +276,8 @@ impl AppState { let close_project = Arc::new(CloseProject::new(Arc::clone(&store_port))); let close_tab = Arc::new(CloseTab::new(Arc::clone(&store_port))); let list_projects = Arc::new(ListProjects::new(Arc::clone(&store_port))); + let read_project_context = Arc::new(ReadProjectContext::new(Arc::clone(&fs_port))); + let update_project_context = Arc::new(UpdateProjectContext::new(Arc::clone(&fs_port))); // --- PTY adapter + terminal use cases (L3) --- let pty = Arc::new(PortablePtyAdapter::new()); @@ -372,10 +396,11 @@ impl AppState { // Load the configured embedder profile (mono-profile for now: take the last // listed, fall back to `none`). A multi-profile selector is a follow-up; the // `none` default keeps recall strictly naïve and dependency-free. - let embedder_store = FsEmbedderProfileStore::new( + let embedder_store = Arc::new(FsEmbedderProfileStore::new( Arc::clone(&fs_port), app_data_dir.to_string_lossy().into_owned(), - ); + )); + let embedder_store_port = Arc::clone(&embedder_store) as Arc; // `build` may run inside an ambient async runtime (Tauri's `setup`, or // `#[tokio::test]`), so blocking the current thread on a future panics. // Drive the one-shot load on a dedicated thread with its own runtime. @@ -401,6 +426,66 @@ impl AppState { &onnx_cache_dir, ); + // --- Embedder configuration use cases (LOT C2 — §14.5.3) --- + // CRUD over `embedder.json` (port-typed store, built above) + a read-only + // description of the available engines. The environment probe shares the SAME + // `onnx_cache_dir` the recall uses, so "is this model cached?" stays coherent. + // The static ONNX catalogue and the compiled-capability flags are owned by + // infrastructure and injected here (the application stays infra-free, DIP). + let env_inspector = Arc::new(EmbedderEnvProbe::new( + onnx_cache_dir.clone(), + DEFAULT_OLLAMA_BASE_URL, + )) as Arc; + // Cloned for the suggestion check below (the original is moved into + // `describe_embedder_engines`). Both share the same probe behaviour. + let env_inspector_for_suggestion = Arc::clone(&env_inspector); + let recommended_onnx: Vec = RECOMMENDED_ONNX_MODELS + .iter() + .map(|m| OnnxModelView { + id: m.id.to_owned(), + display_name: m.display_name.to_owned(), + dimension: m.dimension, + approx_size_mb: m.approx_size_mb, + recommended: m.recommended, + }) + .collect(); + let list_embedder_profiles = + Arc::new(ListEmbedderProfiles::new(Arc::clone(&embedder_store_port))); + let save_embedder_profile = + Arc::new(SaveEmbedderProfile::new(Arc::clone(&embedder_store_port))); + let delete_embedder_profile = + Arc::new(DeleteEmbedderProfile::new(Arc::clone(&embedder_store_port))); + let describe_embedder_engines = Arc::new(DescribeEmbedderEngines::new( + env_inspector, + recommended_onnx, + VECTOR_HTTP_ENABLED, + VECTOR_ONNX_ENABLED, + )); + + // --- Embedder suggestion (LOT C3 — §14.5.5) --- + // Per-project dismissal state (`.ideai/memory/.embedder-prompt.json`) + + // the in-memory "already suggested this session" guard, shared with the + // launcher's best-effort check. The check publishes EmbedderSuggested at + // most once per session per project, only while strategy is `none` and the + // memory has outgrown the recall budget. + let prompt_store = Arc::new(FsEmbedderPromptStore::new(Arc::clone(&fs_port))); + let prompt_store_port = Arc::clone(&prompt_store) as Arc; + let suggested_this_session: SuggestedThisSession = SuggestedThisSession::default(); + let check_embedder_suggestion = Arc::new(CheckEmbedderSuggestion::new( + Arc::clone(&embedder_store_port), + Arc::clone(&memory_store_port), + Arc::clone(&prompt_store_port), + env_inspector_for_suggestion, + Arc::clone(&events_port), + Arc::clone(&suggested_this_session), + AGENT_MEMORY_RECALL_BUDGET, + VECTOR_HTTP_ENABLED, + VECTOR_ONNX_ENABLED, + )); + let dismiss_embedder_suggestion = Arc::new(DismissEmbedderSuggestion::new(Arc::clone( + &prompt_store_port, + ))); + let create_agent = Arc::new(CreateAgentFromScratch::new( Arc::clone(&contexts_port), Arc::clone(&ids) as Arc, @@ -426,6 +511,7 @@ impl AppState { Arc::clone(&events_port), Arc::clone(&ids) as Arc, Arc::clone(&memory_recall_port), + Some(Arc::clone(&check_embedder_suggestion)), )); // --- Conversation inspection (T7) --- @@ -437,11 +523,9 @@ impl AppState { let home_dir = std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE")) .unwrap_or_default(); - let inspectors: Vec> = - vec![Arc::new(ClaudeTranscriptInspector::new( - Arc::clone(&fs_port), - home_dir, - ))]; + let inspectors: Vec> = vec![Arc::new( + ClaudeTranscriptInspector::new(Arc::clone(&fs_port), home_dir), + )]; let inspect_conversation = Arc::new(InspectConversation::new( Arc::clone(&contexts_port), Arc::clone(&profile_store_port), @@ -491,14 +575,20 @@ impl AppState { let git_status = Arc::new(GitStatus::new(Arc::clone(&git_port))); let git_stage = Arc::new(GitStage::new(Arc::clone(&git_port))); let git_unstage = Arc::new(GitUnstage::new(Arc::clone(&git_port))); - let git_commit = Arc::new(GitCommit::new(Arc::clone(&git_port), Arc::clone(&events_port))); + let git_commit = Arc::new(GitCommit::new( + Arc::clone(&git_port), + Arc::clone(&events_port), + )); let git_branches = Arc::new(GitBranches::new(Arc::clone(&git_port))); let git_checkout = Arc::new(GitCheckout::new( Arc::clone(&git_port), Arc::clone(&events_port), )); let git_log = Arc::new(GitLog::new(Arc::clone(&git_port))); - let git_init = Arc::new(GitInit::new(Arc::clone(&git_port), Arc::clone(&events_port))); + let git_init = Arc::new(GitInit::new( + Arc::clone(&git_port), + Arc::clone(&events_port), + )); let git_graph = Arc::new(GitGraph::new(Arc::clone(&git_port))); // --- Skill use cases (L12) --- @@ -578,6 +668,8 @@ impl AppState { close_project, close_tab, list_projects, + read_project_context, + update_project_context, open_terminal, write_terminal, resize_terminal, @@ -639,6 +731,11 @@ impl AppState { read_memory_index, resolve_memory_links, recall_memory, + list_embedder_profiles, + save_embedder_profile, + delete_embedder_profile, + describe_embedder_engines, + dismiss_embedder_suggestion, orchestrator_service, orchestrator_watchers: Mutex::new(HashMap::new()), move_tab, diff --git a/crates/app-tauri/tests/dto.rs b/crates/app-tauri/tests/dto.rs index ffa9723..bd204d7 100644 --- a/crates/app-tauri/tests/dto.rs +++ b/crates/app-tauri/tests/dto.rs @@ -7,16 +7,16 @@ use app_tauri_lib::dto::{ LayoutOperationDto, OpenTerminalRequestDto, ReattachResultDto, ResizeTerminalRequestDto, TerminalClosedDto, WriteTerminalRequestDto, }; +use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT}; use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput}; use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId}; -use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT}; use application::{AppError, HealthInput}; use domain::events::DomainEvent; use domain::ids::{AgentId, SessionId}; use domain::ProjectId; -use domain::TemplateVersion; use domain::TemplateId; +use domain::TemplateVersion; use serde_json::json; use uuid::Uuid; @@ -188,7 +188,10 @@ fn terminal_closed_dto_serialises_code_camel_case() { // Signalled (None) round-trips as null. let none = TerminalClosedDto::from(CloseTerminalOutput { code: None }); - assert_eq!(serde_json::to_value(&none).unwrap(), json!({ "code": null })); + assert_eq!( + serde_json::to_value(&none).unwrap(), + json!({ "code": null }) + ); } #[test] @@ -198,7 +201,10 @@ fn reattach_result_dto_serialises_camel_case() { scrollback: vec![104, 105], }; let v = serde_json::to_value(&dto).unwrap(); - assert_eq!(v, json!({ "sessionId": "sess-1", "scrollback": [104, 105] })); + assert_eq!( + v, + json!({ "sessionId": "sess-1", "scrollback": [104, 105] }) + ); } #[test] @@ -327,7 +333,18 @@ fn layout_dto_round_trips_a_split_tree_shape() { conversation_id: None, agent_was_running: false, }) - .split(nid(1), Direction::Column, LeafCell { id: nid(2), session: None, agent: None, conversation_id: None, agent_was_running: false }, nid(9)) + .split( + nid(1), + Direction::Column, + LeafCell { + id: nid(2), + session: None, + agent: None, + conversation_id: None, + agent_was_running: false, + }, + nid(9), + ) .unwrap(); let dto = LayoutDto::from(LoadLayoutOutput { layout_id: domain::LayoutId::new_random(), diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs index aa171c3..cf70a80 100644 --- a/crates/app-tauri/tests/dto_agents.rs +++ b/crates/app-tauri/tests/dto_agents.rs @@ -11,8 +11,8 @@ use application::AppError; use application::{ CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, ListAgentsOutput, }; -use domain::ports::ConversationDetails; use domain::ids::{AgentId, NodeId, ProfileId, SessionId}; +use domain::ports::ConversationDetails; use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession}; use domain::{Agent, AgentOrigin, ProjectPath}; use serde_json::json; @@ -44,7 +44,10 @@ fn agent_dto_serialises_camelcase() { assert_eq!(v["id"], agent.id.to_string()); assert_eq!(v["name"], "My Agent"); assert_eq!(v["contextPath"], "agents/my-agent.md", "camelCase key"); - assert_eq!(v["profileId"], ProfileId::from_uuid(Uuid::from_u128(2)).to_string()); + assert_eq!( + v["profileId"], + ProfileId::from_uuid(Uuid::from_u128(2)).to_string() + ); assert_eq!(v["synchronized"], false); // origin: tagged `{ "type": "scratch" }` assert_eq!(v["origin"]["type"], "scratch"); @@ -68,7 +71,9 @@ fn agent_list_dto_is_transparent_array() { #[test] fn create_agent_output_maps_to_agent_dto() { let agent = make_agent(5, 6); - let out = CreateAgentOutput { agent: agent.clone() }; + let out = CreateAgentOutput { + agent: agent.clone(), + }; let dto = AgentDto::from(out); assert_eq!(dto.0.id, agent.id); } @@ -168,19 +173,25 @@ fn agent_already_running_error_code_is_stable() { // Option A: a stable code + a text message (the node is NOT enriched into the // ErrorDto — the frontend branches on the code alone). assert_eq!(dto.code, "AGENT_ALREADY_RUNNING"); - assert!(dto.message.contains("already running"), "message: {}", dto.message); + assert!( + dto.message.contains("already running"), + "message: {}", + dto.message + ); } #[test] fn live_agent_list_dto_serialises_camelcase_array() { let agent_a = AgentId::from_uuid(Uuid::from_u128(11)); let node_a = NodeId::from_uuid(Uuid::from_u128(21)); - let dto = LiveAgentListDto::from_pairs(vec![(agent_a, node_a)]); + let session_a = domain::SessionId::from_uuid(Uuid::from_u128(31)); + let dto = LiveAgentListDto::from_pairs(vec![(agent_a, node_a, session_a)]); let v = serde_json::to_value(&dto).unwrap(); let arr = v.as_array().expect("transparent array"); assert_eq!(arr.len(), 1); assert_eq!(arr[0]["agentId"], agent_a.to_string()); assert_eq!(arr[0]["nodeId"], node_a.to_string()); + assert_eq!(arr[0]["sessionId"], session_a.to_string()); // No snake_case leak. assert!(arr[0].get("agent_id").is_none()); assert!(arr[0].get("node_id").is_none()); @@ -246,7 +257,10 @@ fn conversation_details_dto_omits_fields_when_none() { let v = serde_json::to_value(&dto).unwrap(); // Both optional fields are omitted from the wire (absent, not null). assert!(v.get("lastTopic").is_none(), "absent topic ⇒ field omitted"); - assert!(v.get("tokenCount").is_none(), "absent tokens ⇒ field omitted"); + assert!( + v.get("tokenCount").is_none(), + "absent tokens ⇒ field omitted" + ); assert_eq!(v, json!({}), "fully degraded ⇒ empty object"); } diff --git a/crates/app-tauri/tests/dto_git.rs b/crates/app-tauri/tests/dto_git.rs index db6c37e..8337916 100644 --- a/crates/app-tauri/tests/dto_git.rs +++ b/crates/app-tauri/tests/dto_git.rs @@ -6,7 +6,9 @@ use app_tauri_lib::dto::{ GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitFileStatusDto, GitStageRequestDto, GitStatusListDto, GraphCommitDto, GraphCommitListDto, }; -use application::{GitBranchesOutput, GitCommitOutput, GitGraphOutput, GitLogOutput, GitStatusOutput}; +use application::{ + GitBranchesOutput, GitCommitOutput, GitGraphOutput, GitLogOutput, GitStatusOutput, +}; use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit}; use serde_json::json; use uuid::Uuid; diff --git a/crates/app-tauri/tests/dto_layouts.rs b/crates/app-tauri/tests/dto_layouts.rs index d9b3355..2d55020 100644 --- a/crates/app-tauri/tests/dto_layouts.rs +++ b/crates/app-tauri/tests/dto_layouts.rs @@ -5,8 +5,8 @@ use app_tauri_lib::dto::{ parse_layout_id, CreateLayoutRequestDto, CreateLayoutResultDto, DeleteLayoutRequestDto, - DeleteLayoutResultDto, LayoutInfoDto, LayoutOperationDto, ListLayoutsDto, RenameLayoutRequestDto, - SetActiveLayoutRequestDto, + DeleteLayoutResultDto, LayoutInfoDto, LayoutOperationDto, ListLayoutsDto, + RenameLayoutRequestDto, SetActiveLayoutRequestDto, }; use application::{ CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutKind, ListLayoutsOutput, @@ -63,8 +63,16 @@ fn layout_info_dto_git_graph_kind() { fn list_layouts_dto_from_output() { let out = ListLayoutsOutput { layouts: vec![ - LayoutInfo { id: lid(1), name: "Default".to_owned(), kind: LayoutKind::Terminal }, - LayoutInfo { id: lid(2), name: "Backend".to_owned(), kind: LayoutKind::GitGraph }, + LayoutInfo { + id: lid(1), + name: "Default".to_owned(), + kind: LayoutKind::Terminal, + }, + LayoutInfo { + id: lid(2), + name: "Backend".to_owned(), + kind: LayoutKind::GitGraph, + }, ], active_id: lid(1), }; @@ -204,7 +212,10 @@ fn set_cell_agent_op_deserialises_with_agent() { let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap(); let op = dto.into_operation().unwrap(); match op { - application::LayoutOperation::SetCellAgent { target: t, agent: a } => { + application::LayoutOperation::SetCellAgent { + target: t, + agent: a, + } => { assert_eq!(t, target); assert_eq!(a, Some(agent)); } @@ -223,7 +234,10 @@ fn set_cell_agent_op_deserialises_with_null_agent() { let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap(); let op = dto.into_operation().unwrap(); match op { - application::LayoutOperation::SetCellAgent { target: t, agent: None } => { + application::LayoutOperation::SetCellAgent { + target: t, + agent: None, + } => { assert_eq!(t, target); } _ => panic!("expected SetCellAgent with None agent"), diff --git a/crates/app-tauri/tests/dto_templates.rs b/crates/app-tauri/tests/dto_templates.rs index 8e43d3a..626a85e 100644 --- a/crates/app-tauri/tests/dto_templates.rs +++ b/crates/app-tauri/tests/dto_templates.rs @@ -53,8 +53,14 @@ fn template_dto_serialises_camelcase() { ProfileId::from_uuid(Uuid::from_u128(2)).to_string() ); // no snake_case leak - assert!(v.get("content_md").is_none(), "no snake_case leak for contentMd"); - assert!(v.get("default_profile_id").is_none(), "no snake_case leak for defaultProfileId"); + assert!( + v.get("content_md").is_none(), + "no snake_case leak for contentMd" + ); + assert!( + v.get("default_profile_id").is_none(), + "no snake_case leak for defaultProfileId" + ); } #[test] @@ -98,7 +104,9 @@ fn template_list_dto_empty() { #[test] fn create_template_output_maps_to_template_dto() { let tmpl = make_template(5, 6); - let out = CreateTemplateOutput { template: tmpl.clone() }; + let out = CreateTemplateOutput { + template: tmpl.clone(), + }; let dto = TemplateDto::from(out); assert_eq!(dto.0.id, tmpl.id); } @@ -107,7 +115,9 @@ fn create_template_output_maps_to_template_dto() { fn update_template_output_maps_to_template_dto() { let tmpl = make_template(7, 8); let bumped = tmpl.with_updated_content(MarkdownDoc::new("# Updated".to_owned())); - let out = UpdateTemplateOutput { template: bumped.clone() }; + let out = UpdateTemplateOutput { + template: bumped.clone(), + }; let dto = TemplateDto::from(out); assert_eq!(dto.0.version, TemplateVersion(2)); assert_eq!(dto.0.id, bumped.id); @@ -132,7 +142,10 @@ fn agent_drift_dto_serialises_camelcase() { assert_eq!(v["from"], 1u64); assert_eq!(v["to"], 3u64); // no snake_case leak - assert!(v.get("agent_id").is_none(), "no snake_case leak for agentId"); + assert!( + v.get("agent_id").is_none(), + "no snake_case leak for agentId" + ); } #[test] diff --git a/crates/app-tauri/tests/dto_window.rs b/crates/app-tauri/tests/dto_window.rs index bb5205c..7ccf345 100644 --- a/crates/app-tauri/tests/dto_window.rs +++ b/crates/app-tauri/tests/dto_window.rs @@ -15,7 +15,10 @@ fn move_tab_result_serializes_new_window_id_camel_case() { let dto = MoveTabResultDto::from(out); let json = serde_json::to_string(&dto).unwrap(); assert!(json.contains("\"newWindowId\""), "json was {json}"); - assert!(!json.contains("new_window_id"), "no snake_case leak: {json}"); + assert!( + !json.contains("new_window_id"), + "no snake_case leak: {json}" + ); } #[test] diff --git a/crates/app-tauri/tests/orchestrator_wiring.rs b/crates/app-tauri/tests/orchestrator_wiring.rs index 7635d17..3ed6d0d 100644 --- a/crates/app-tauri/tests/orchestrator_wiring.rs +++ b/crates/app-tauri/tests/orchestrator_wiring.rs @@ -72,7 +72,10 @@ async fn stop_watch_unregisters_the_watcher() { assert!(has_watcher(&state, &project.id)); state.stop_orchestrator_watch(&project.id); - assert!(!has_watcher(&state, &project.id), "watcher removed on close"); + assert!( + !has_watcher(&state, &project.id), + "watcher removed on close" + ); assert_eq!(watcher_count(&state), 0); // Stopping an unknown project is a no-op (does not panic). diff --git a/crates/app-tauri/tests/pty_bridge.rs b/crates/app-tauri/tests/pty_bridge.rs index f5f77d3..87c7fd9 100644 --- a/crates/app-tauri/tests/pty_bridge.rs +++ b/crates/app-tauri/tests/pty_bridge.rs @@ -47,7 +47,10 @@ fn send_output_delivers_bytes_to_registered_channel() { bridge.register(session, capturing_channel(Arc::clone(&sink))); let delivered = bridge.send_output(&session, vec![104, 105]); - assert!(delivered, "send_output should return true for a live session"); + assert!( + delivered, + "send_output should return true for a live session" + ); let captured = sink.lock().unwrap(); assert_eq!(captured.as_slice(), &[vec![104, 105]]); @@ -82,10 +85,17 @@ fn register_same_session_twice_replaces_channel() { bridge.register(session, capturing_channel(Arc::clone(&first))); bridge.register(session, capturing_channel(Arc::clone(&second))); - assert_eq!(bridge.active_sessions(), 1, "same id is replaced, not added"); + assert_eq!( + bridge.active_sessions(), + 1, + "same id is replaced, not added" + ); bridge.send_output(&session, vec![9]); - assert!(first.lock().unwrap().is_empty(), "old channel no longer used"); + assert!( + first.lock().unwrap().is_empty(), + "old channel no longer used" + ); assert_eq!(second.lock().unwrap().as_slice(), &[vec![9]]); } @@ -116,7 +126,11 @@ fn unregister_if_is_a_noop_for_a_superseded_generation() { bridge.unregister_if(&session, old_gen); // The current channel survives and still delivers — no duplication, no drop. - assert_eq!(bridge.active_sessions(), 1, "live re-attach must not be removed"); + assert_eq!( + bridge.active_sessions(), + 1, + "live re-attach must not be removed" + ); assert!(bridge.send_output(&session, vec![7])); assert_eq!(new.lock().unwrap().as_slice(), &[vec![7]]); } diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 66830c6..a883833 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -14,17 +14,18 @@ use std::sync::Arc; use domain::ports::{ - AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, IdGenerator, - MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, PtyPort, RemotePath, SessionPlan, - SkillStore, SpawnSpec, StoreError, + AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, FsError, + IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, PtyPort, RemotePath, + SessionPlan, SkillStore, SpawnSpec, StoreError, }; use domain::{ Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent, - ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, Project, ProfileId, + ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project, ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession, }; use crate::error::AppError; +use crate::project::project_context_path; use crate::terminal::TerminalSessions; /// Directory (relative to `.ideai/`) under which agent contexts are written. @@ -35,7 +36,7 @@ const AGENTS_SUBDIR: &str = "agents"; /// (étage 1) handed to the agent. Internal and intentionally **not yet exposed in /// config**: it may later become a per-project setting without changing the /// contract. -const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048; +pub const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048; // --------------------------------------------------------------------------- // CreateAgentFromScratch @@ -115,7 +116,9 @@ impl CreateAgentFromScratch { entries.push(ManifestEntry::from_agent(&agent)); let manifest = AgentManifest::new(manifest.version, entries) .map_err(|e| AppError::Invalid(e.to_string()))?; - self.contexts.save_manifest(&input.project, &manifest).await?; + self.contexts + .save_manifest(&input.project, &manifest) + .await?; // Now the path resolves: write the initial context. let md = MarkdownDoc::new(input.initial_content.unwrap_or_default()); @@ -171,7 +174,10 @@ impl ListAgents { let agents = manifest .entries .iter() - .map(|e| e.to_agent().map_err(|err| AppError::Invalid(err.to_string()))) + .map(|e| { + e.to_agent() + .map_err(|err| AppError::Invalid(err.to_string())) + }) .collect::, _>>()?; Ok(ListAgentsOutput { agents }) } @@ -311,7 +317,9 @@ impl DeleteAgent { } let manifest = AgentManifest::new(manifest.version, entries) .map_err(|e| AppError::Invalid(e.to_string()))?; - self.contexts.save_manifest(&input.project, &manifest).await?; + self.contexts + .save_manifest(&input.project, &manifest) + .await?; self.events.publish(DomainEvent::LayoutChanged { project_id: input.project.id, }); @@ -380,6 +388,11 @@ pub struct LaunchAgent { /// file at activation (ARCHITECTURE §14.5.4). Best-effort by contract: an absent /// or empty memory yields an empty list, never blocking a launch. recall: Arc, + /// Optional contextual embedder-suggestion check (LOT C3, §14.5.5), run + /// best-effort right after the memory recall at activation — the moment an agent + /// reads the project memory. `None` keeps the launcher independent of it (legacy + /// wiring / tests). A failure here never affects the launch. + embedder_suggestion: Option>, } impl LaunchAgent { @@ -397,6 +410,7 @@ impl LaunchAgent { events: Arc, ids: Arc, recall: Arc, + embedder_suggestion: Option>, ) -> Self { Self { contexts, @@ -409,6 +423,7 @@ impl LaunchAgent { events, ids, recall, + embedder_suggestion, } } @@ -426,7 +441,11 @@ impl LaunchAgent { ) -> Result, AppError> { let mut out = Vec::with_capacity(agent.skills.len()); for skill_ref in &agent.skills { - match self.skills.get(skill_ref.scope, root, skill_ref.skill_id).await { + match self + .skills + .get(skill_ref.scope, root, skill_ref.skill_id) + .await + { Ok(skill) => out.push(skill), Err(StoreError::NotFound) => {} Err(e) => return Err(e.into()), @@ -453,6 +472,19 @@ impl LaunchAgent { self.recall.recall(root, &query).await.unwrap_or_default() } + /// Reads the shared project context from `.ideai/CONTEXT.md`. + /// + /// A missing file is normal for existing projects and simply omits the + /// project-context section from the generated model context. + async fn resolve_project_context(&self, project: &Project) -> Result { + match self.fs.read(&project_context_path(project)).await { + Ok(bytes) => String::from_utf8(bytes) + .map_err(|e| AppError::Store(format!("project context is not UTF-8: {e}"))), + Err(FsError::NotFound(_)) => Ok(String::new()), + Err(e) => Err(AppError::FileSystem(e.to_string())), + } + } + /// Executes the launch. /// /// Step order is contractually significant (and unit-tested): resolve the @@ -486,26 +518,22 @@ impl LaunchAgent { // runs AFTER the NotFound resolution above (so an unknown agent still // errors NotFound) but BEFORE any I/O (run dir, seed, spawn). If the // agent already owns a live session: - // - on a *different* (or unspecified) node → refuse the launch with - // `AgentAlreadyRunning`, pointing at the existing host cell; - // - on the *same* node → idempotent (a concurrent double-launch of the - // very same cell): return the existing session without respawning, - // assigning no new conversation id. + // - with a requested node → rebind the live session to that cell and + // return it without respawning; + // - without a requested node → idempotent background/no-op launch: + // return the existing session without respawning. // The resume path (agent dead ⇒ no live session) is unaffected. if let Some(existing_id) = self.sessions.session_for_agent(&input.agent_id) { - let host_node = self - .sessions - .node_for_agent(&input.agent_id) - .expect("a live session always has a host node"); - let same_node = input.node_id.as_ref() == Some(&host_node); - if !same_node { - return Err(AppError::AgentAlreadyRunning { - agent_id: input.agent_id, - node_id: host_node, - }); + if let Some(node_id) = input.node_id { + if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id) { + return Ok(LaunchAgentOutput { + session, + assigned_conversation_id: None, + }); + } } - // Same node: idempotent — hand back the already-registered session, - // no respawn, nothing new to persist. + // Idempotent — hand back the already-registered session, no respawn, + // nothing new to persist. if let Some(session) = self.sessions.session(&existing_id) { return Ok(LaunchAgentOutput { session, @@ -525,9 +553,7 @@ impl LaunchAgent { .await? .into_iter() .find(|p| p.id == agent.profile_id) - .ok_or_else(|| { - AppError::NotFound(format!("profile {} for agent", agent.profile_id)) - })?; + .ok_or_else(|| AppError::NotFound(format!("profile {} for agent", agent.profile_id)))?; // 3. Compute and create the agent's isolated run directory // `/.ideai/run//` (ARCHITECTURE §14.1). The PTY cwd is @@ -571,13 +597,27 @@ impl LaunchAgent { // 5. Resolve the agent's assigned skills (their `.md` bodies), then apply // the injection plan side effects *before* spawning. let skills = self.resolve_skills(&agent, &input.project.root).await?; + let project_context = self.resolve_project_context(&input.project).await?; let memory = self .resolve_memory(&input.project.root, content.as_str()) .await; + // Best-effort contextual embedder suggestion (LOT C3, §14.5.5): the agent + // has just read the project memory, so this is the moment to check whether a + // semantic embedder would now help. Fully isolated from the launch outcome — + // an error or absence of the check never affects activation. + if let Some(check) = &self.embedder_suggestion { + let _ = check + .execute(crate::embedder::CheckEmbedderSuggestionInput { + project_id: input.project.id, + project_root: input.project.root.clone(), + }) + .await; + } self.apply_injection( &input.project, &agent.context_path, &content, + &project_context, &skills, &memory, &mut spec, @@ -598,9 +638,7 @@ impl LaunchAgent { session_id, node_id, spec.cwd.clone(), - SessionKind::Agent { - agent_id: agent.id, - }, + SessionKind::Agent { agent_id: agent.id }, size, ); session.status = SessionStatus::Running; @@ -718,6 +756,7 @@ impl LaunchAgent { project: &Project, context_rel_path: &str, content: &MarkdownDoc, + project_context: &str, skills: &[Skill], memory: &[MemoryIndexEntry], spec: &mut SpawnSpec, @@ -733,6 +772,7 @@ impl LaunchAgent { // persona `.md`, then the bodies of its assigned skills (§14.2). let document = compose_convention_file( project.root.as_str(), + project_context, content.as_str(), skills, memory, @@ -769,7 +809,10 @@ fn join(base: &ProjectPath, rel: &str) -> String { /// # Errors /// Propagates [`DomainError`](domain::error::DomainError) if the joined path is /// not a valid [`ProjectPath`] (should not happen for an absolute project root). -pub(crate) fn agent_run_dir(root: &ProjectPath, agent_id: &AgentId) -> Result { +pub(crate) fn agent_run_dir( + root: &ProjectPath, + agent_id: &AgentId, +) -> Result { ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}"))) } @@ -841,8 +884,9 @@ fn json_escape(s: &str) -> String { /// Composes the convention file IdeA writes into an agent's run directory: an /// absolute project-root header (the agent's cwd is the run dir, *not* the root, -/// so it must be told where to work), the agent's persona `.md`, then the bodies -/// of its assigned `skills` under a `# Skills` section (ARCHITECTURE §14.2). +/// so it must be told where to work), the IdeA orchestration contract, the +/// agent's persona `.md`, then the bodies of its assigned `skills` under a +/// `# Skills` section (ARCHITECTURE §14.2). /// /// Skills are emitted in the order given (the caller passes them in manifest /// order, making the output deterministic); each is introduced by a `##` header @@ -858,6 +902,7 @@ fn json_escape(s: &str) -> String { #[must_use] pub(crate) fn compose_convention_file( project_root: &str, + project_context: &str, agent_md: &str, skills: &[Skill], memory: &[MemoryIndexEntry], @@ -871,6 +916,21 @@ pub(crate) fn compose_convention_file( opère sur le project root, pas sur ce dossier.\n\n", ); out.push_str("---\n\n"); + out.push_str("# Orchestration IdeA\n\n"); + out.push_str( + "Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \ + natifs du fournisseur IA. Écris une requête d'orchestration IdeA dans \ + `.ideai/requests//` ; IdeA lancera ou réattachera l'agent cible \ + avec son propre AI Profile, son contexte et sa mémoire.\n\n", + ); + out.push_str("---\n\n"); + + if !project_context.trim().is_empty() { + out.push_str("# Contexte projet\n\n"); + out.push_str(project_context.trim()); + out.push_str("\n\n---\n\n"); + } + out.push_str(agent_md); if !skills.is_empty() { @@ -918,7 +978,11 @@ fn memory_type_label(kind: MemoryType) -> &'static str { /// suffix when needed. Shared with the template-driven agent creation (L7). pub(crate) fn unique_md_path(name: &str, manifest: &AgentManifest) -> String { let slug = slugify(name); - let base = if slug.is_empty() { "agent".to_owned() } else { slug }; + let base = if slug.is_empty() { + "agent".to_owned() + } else { + slug + }; let mut candidate = format!("{AGENTS_SUBDIR}/{base}.md"); let mut n = 2; while manifest.entries.iter().any(|e| e.md_path == candidate) { @@ -967,7 +1031,7 @@ mod tests { #[test] fn compose_convention_file_carries_root_then_persona() { let doc = - compose_convention_file("/abs/project/root", "# Persona\n\nDo things.", &[], &[]); + compose_convention_file("/abs/project/root", "", "# Persona\n\nDo things.", &[], &[]); // Absolute project root present. assert!(doc.contains("/abs/project/root")); @@ -982,6 +1046,26 @@ mod tests { assert!(!doc.contains("# Skills")); } + #[test] + fn compose_convention_file_includes_project_context_before_persona() { + let doc = compose_convention_file( + "/root", + "# Shared project context\n\nUse pnpm.", + "# Persona\n\nDo X.", + &[], + &[], + ); + + assert!(doc.contains("# Contexte projet")); + assert!(doc.contains("Use pnpm.")); + let project_context_at = doc.find("# Contexte projet").unwrap(); + let persona_at = doc.find("# Persona").unwrap(); + assert!( + project_context_at < persona_at, + "shared project context must precede agent persona" + ); + } + #[test] fn compose_convention_file_appends_assigned_skills_in_order() { let s = |n: u128, name: &str, body: &str| { @@ -995,8 +1079,12 @@ mod tests { }; let doc = compose_convention_file( "/root", + "", "# Persona", - &[s(1, "refactor", "REFAC_BODY"), s(2, "review", "REVIEW_BODY")], + &[ + s(1, "refactor", "REFAC_BODY"), + s(2, "review", "REVIEW_BODY"), + ], &[], ); @@ -1028,7 +1116,7 @@ mod tests { fn compose_convention_file_empty_memory_is_identical_to_no_memory() { // An empty `memory` must yield exactly the previous document: no section, // byte-for-byte identical to the no-skills/no-memory composition. - let with_empty = compose_convention_file("/root", "# Persona\n\nDo X.", &[], &[]); + let with_empty = compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[]); assert!( !with_empty.contains("# Mémoire projet"), "no memory ⇒ no memory section" @@ -1037,7 +1125,7 @@ mod tests { // 4-arg call; this pins the omission contract explicitly). assert_eq!( with_empty, - compose_convention_file("/root", "# Persona\n\nDo X.", &[], &[]) + compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[]) ); } @@ -1045,11 +1133,17 @@ mod tests { fn compose_convention_file_appends_memory_entries_in_order() { let doc = compose_convention_file( "/root", + "", "# Persona", &[], &[ mem("alpha-note", "Alpha", "the first hook", MemoryType::User), - mem("beta-note", "Beta", "the second hook", MemoryType::Reference), + mem( + "beta-note", + "Beta", + "the second hook", + MemoryType::Reference, + ), ], ); @@ -1066,7 +1160,10 @@ mod tests { // Deterministic order: first entry precedes the second. let alpha_at = doc.find("[Alpha]").unwrap(); let beta_at = doc.find("[Beta]").unwrap(); - assert!(alpha_at < beta_at, "memory entries emitted in the given order"); + assert!( + alpha_at < beta_at, + "memory entries emitted in the given order" + ); } #[test] @@ -1080,6 +1177,7 @@ mod tests { .unwrap(); let doc = compose_convention_file( "/root", + "", "# Persona", std::slice::from_ref(&skill), &[mem("note", "Note", "a hook", MemoryType::Project)], diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index bbd0061..ef3abb0 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -14,14 +14,12 @@ mod usecases; pub(crate) use lifecycle::unique_md_path; pub use catalogue::{reference_profile_id, reference_profiles}; -pub use inspect::{ - InspectConversation, InspectConversationInput, InspectConversationOutput, -}; +pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput}; pub use lifecycle::{ CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, - LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, - ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, UpdateAgentContext, - UpdateAgentContextInput, + LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, + ListAgentsOutput, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, + UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, }; pub use usecases::{ ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile, diff --git a/crates/application/src/embedder/mod.rs b/crates/application/src/embedder/mod.rs new file mode 100644 index 0000000..368aa72 --- /dev/null +++ b/crates/application/src/embedder/mod.rs @@ -0,0 +1,24 @@ +//! Embedder configuration use cases (LOT C2 — §14.5.3). +//! +//! CRUD over the declarative [`domain::profile::EmbedderProfile`]s +//! ([`ListEmbedderProfiles`], [`SaveEmbedderProfile`], [`DeleteEmbedderProfile`]) +//! plus a read-only description of the available engines ([`DescribeEmbedderEngines`]): +//! the recommended local ONNX models, the detected local environment, and which +//! strategies are actually compiled into this binary. +//! +//! A changed embedder takes effect at the **next IDE start** (the composition root +//! freezes the recall for the session): these use cases only persist/inspect config, +//! they never reconfigure a live recall. + +mod suggestion; +mod usecases; + +pub use suggestion::{ + CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput, + DismissChoice, DismissEmbedderSuggestion, DismissEmbedderSuggestionInput, SuggestedThisSession, +}; +pub use usecases::{ + DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines, + EmbedderEnginesView, ListEmbedderProfiles, ListEmbedderProfilesOutput, OnnxModelView, + SaveEmbedderProfile, SaveEmbedderProfileInput, SaveEmbedderProfileOutput, +}; diff --git a/crates/application/src/embedder/suggestion.rs b/crates/application/src/embedder/suggestion.rs new file mode 100644 index 0000000..2fda331 --- /dev/null +++ b/crates/application/src/embedder/suggestion.rs @@ -0,0 +1,269 @@ +//! Contextual embedder-suggestion use cases (LOT C3, ARCHITECTURE §14.5.5). +//! +//! - [`CheckEmbedderSuggestion`] — the **best-effort** check run when an agent +//! reads the project memory at activation: the *first* time a project's memory +//! outgrows the recall budget while no embedder is configured (strategy `none`), +//! it publishes a one-time [`DomainEvent::EmbedderSuggested`]. Anti-spam: at most +//! once per session per project, and never again once the user chose +//! [`EmbedderPromptDismissal::Never`]. +//! - [`DismissEmbedderSuggestion`] — persists the user's "plus tard" / "ne plus +//! demander" response. +//! +//! By construction this never blocks the activation flow: every error degrades to +//! "no suggestion", and a `none`-strategy/under-budget project is a cheap no-op. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; + +use domain::ports::{ + EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, + EventBus, MemoryStore, +}; +use domain::profile::EmbedderStrategy; +use domain::{DomainEvent, ProjectId, ProjectPath}; + +use crate::error::AppError; + +/// In-memory "already suggested this session" guard, shared with the composition +/// root (held in `AppState`). One [`ProjectId`] per project that has already seen +/// the suggestion since the IDE started. Plain std types so the application stays +/// dependency-free and the guard is trivially testable. +pub type SuggestedThisSession = Arc>>; + +/// Sums the approximate token size of the index, mirroring the infrastructure +/// `index_token_size` so the application does not depend on the infra crate. One +/// entry's payload is its rendered index line (title + hook + slug), and the rule +/// only needs a *monotone* measure consistent with the recall's budget unit +/// (~1 token / 4 chars), so we count characters / 4 (min 1 per entry). +fn index_token_size(entries: &[domain::MemoryIndexEntry]) -> usize { + entries + .iter() + .map(|e| { + let chars = e.title.len() + e.hook.len() + e.slug.as_str().len(); + (chars / 4).max(1) + }) + .sum() +} + +// --------------------------------------------------------------------------- +// CheckEmbedderSuggestion +// --------------------------------------------------------------------------- + +/// Input for [`CheckEmbedderSuggestion::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CheckEmbedderSuggestionInput { + /// The project whose memory is being read at agent activation. + pub project_id: ProjectId, + /// That project's root (where its `.ideai/memory/` lives). + pub project_root: ProjectPath, +} + +/// Output of [`CheckEmbedderSuggestion::execute`]: whether a suggestion event was +/// published on this call (useful for tests; the caller ignores it — the flow is +/// best-effort). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CheckEmbedderSuggestionOutput { + /// `true` iff an [`DomainEvent::EmbedderSuggested`] was published. + pub suggested: bool, +} + +/// Publishes a one-time embedder suggestion the first time a project's memory +/// outgrows the recall budget while no embedder is configured (LOT C3). +/// +/// Composes — each port only for its slice (Interface Segregation): +/// - [`EmbedderProfileStore`] to read the active strategy (no non-`none` profile ⇒ +/// strategy `none`), +/// - [`MemoryStore`] to measure the memory index size, +/// - [`EmbedderPromptStore`] for the persistent dismissal (`never` ⇒ silence), +/// - [`EmbedderEnvInspector`] to enrich the event with the detected environment, +/// - [`EventBus`] to publish, plus the shared [`SuggestedThisSession`] guard. +/// +/// **Never blocks the caller**: any port error degrades to "no suggestion". +pub struct CheckEmbedderSuggestion { + profiles: Arc, + memories: Arc, + prompts: Arc, + inspector: Arc, + events: Arc, + suggested_this_session: SuggestedThisSession, + /// Recall token budget the memory must exceed (injected so it stays aligned + /// with [`crate::AGENT_MEMORY_RECALL_BUDGET`] without re-hardcoding it). + budget: usize, + /// Compiled-in HTTP capability flag (carried into the event). + vector_http_enabled: bool, + /// Compiled-in ONNX capability flag (carried into the event). + vector_onnx_enabled: bool, +} + +impl CheckEmbedderSuggestion { + /// Builds the use case from its ports + the session guard + the budget and + /// compiled-capability flags (the latter injected at the composition root, the + /// application stays infra-free). + #[must_use] + #[allow(clippy::too_many_arguments)] + pub fn new( + profiles: Arc, + memories: Arc, + prompts: Arc, + inspector: Arc, + events: Arc, + suggested_this_session: SuggestedThisSession, + budget: usize, + vector_http_enabled: bool, + vector_onnx_enabled: bool, + ) -> Self { + Self { + profiles, + memories, + prompts, + inspector, + events, + suggested_this_session, + budget, + vector_http_enabled, + vector_onnx_enabled, + } + } + + /// Whether any configured profile selects a non-`none` strategy. No profile (or + /// a store error) ⇒ `none` (the dependency-free default posture). + async fn strategy_is_none(&self) -> bool { + match self.profiles.list().await { + Ok(profiles) => !profiles + .iter() + .any(|p| p.strategy != EmbedderStrategy::None), + // A store failure must not turn into a suggestion: behave as configured + // (i.e. *not* `none`) so we stay silent. + Err(_) => false, + } + } + + /// Runs the check best-effort. + /// + /// Order (each step short-circuits to "no suggestion"): + /// 1. already suggested this session ⇒ stop (no I/O beyond the guard); + /// 2. a non-`none` strategy is configured ⇒ stop; + /// 3. the persisted dismissal is `never` ⇒ stop; + /// 4. the memory index size does not exceed the budget ⇒ stop; + /// 5. otherwise mark the session guard, probe the environment, and publish + /// [`DomainEvent::EmbedderSuggested`]. + /// + /// # Errors + /// Never in practice — the signature keeps a uniform `Result` with the other + /// use cases; every failing port read degrades to `Ok(suggested: false)`. + pub async fn execute( + &self, + input: CheckEmbedderSuggestionInput, + ) -> Result { + let not_suggested = Ok(CheckEmbedderSuggestionOutput { suggested: false }); + + // 1. Once per session per project (cheap guard check before any I/O). + if self + .suggested_this_session + .lock() + .map(|set| set.contains(&input.project_id)) + .unwrap_or(true) + { + return not_suggested; + } + + // 2. Only when no embedder is configured (strategy `none`). + if !self.strategy_is_none().await { + return not_suggested; + } + + // 3. Persistent "ne plus demander" silences the suggestion forever. + if matches!( + self.prompts.read(&input.project_root).await, + Ok(Some(EmbedderPromptDismissal::Never)) + ) { + return not_suggested; + } + + // 4. Memory must have outgrown the recall budget (a fresh project has 0). + let size = match self.memories.read_index(&input.project_root).await { + Ok(entries) => index_token_size(&entries), + Err(_) => return not_suggested, + }; + if size <= self.budget { + return not_suggested; + } + + // 5. Mark the guard *before* publishing so a concurrent activation cannot + // double-fire; if another thread won the race, stay silent. + match self.suggested_this_session.lock() { + Ok(mut set) => { + if !set.insert(input.project_id) { + return not_suggested; + } + } + Err(_) => return not_suggested, + } + + let report = self.inspector.inspect().await; + self.events.publish(DomainEvent::EmbedderSuggested { + project_id: input.project_id, + ollama_detected: report.ollama_detected, + onnx_cached: report.onnx_cached_models, + vector_http_enabled: self.vector_http_enabled, + vector_onnx_enabled: self.vector_onnx_enabled, + }); + Ok(CheckEmbedderSuggestionOutput { suggested: true }) + } +} + +// --------------------------------------------------------------------------- +// DismissEmbedderSuggestion +// --------------------------------------------------------------------------- + +/// The user's response to the embedder suggestion popup. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DismissChoice { + /// "Plus tard" — re-proposable on a future session. + Later, + /// "Ne plus demander" — never again (persistent). + Never, +} + +impl From for EmbedderPromptDismissal { + fn from(c: DismissChoice) -> Self { + match c { + DismissChoice::Later => Self::Later, + DismissChoice::Never => Self::Never, + } + } +} + +/// Input for [`DismissEmbedderSuggestion::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DismissEmbedderSuggestionInput { + /// The project the suggestion concerned. + pub project_root: ProjectPath, + /// The user's choice. + pub choice: DismissChoice, +} + +/// Persists the user's dismissal of the embedder suggestion (LOT C3). +pub struct DismissEmbedderSuggestion { + prompts: Arc, +} + +impl DismissEmbedderSuggestion { + /// Builds the use case from the [`EmbedderPromptStore`] port. + #[must_use] + pub fn new(prompts: Arc) -> Self { + Self { prompts } + } + + /// Writes the dismissal state. A `later` keeps the suggestion re-proposable on + /// a future session; a `never` silences it for good. + /// + /// # Errors + /// [`AppError::Store`] on a persistence failure. + pub async fn execute(&self, input: DismissEmbedderSuggestionInput) -> Result<(), AppError> { + self.prompts + .write(&input.project_root, input.choice.into()) + .await?; + Ok(()) + } +} diff --git a/crates/application/src/embedder/usecases.rs b/crates/application/src/embedder/usecases.rs new file mode 100644 index 0000000..7182bf8 --- /dev/null +++ b/crates/application/src/embedder/usecases.rs @@ -0,0 +1,245 @@ +//! Embedder configuration use cases (LOT C2). Each is a single-responsibility +//! struct carrying its ports as `Arc` and exposing one `execute`. +//! +//! - [`ListEmbedderProfiles`] / [`SaveEmbedderProfile`] / [`DeleteEmbedderProfile`] +//! — CRUD over the persisted [`EmbedderProfile`]s through the +//! [`EmbedderProfileStore`] port. +//! - [`DescribeEmbedderEngines`] — a read-only view of the engines available to the +//! "configure an embedder?" UI: the recommended ONNX model catalogue, a best-effort +//! snapshot of the local environment ([`EmbedderEnvInspector`]), and which strategies +//! are actually compiled into this binary. +//! +//! Hexagonal boundary: the application depends on the domain ports and on plain +//! data only. The static engine catalogue and the compiled-capability flags are +//! **injected** at the composition root (the infrastructure owns `reqwest`/`fastembed`, +//! never the application). + +use std::sync::Arc; + +use domain::ports::{EmbedderEnvInspector, EmbedderProfileStore}; +use domain::profile::{EmbedderProfile, EmbedderStrategy}; + +use crate::error::AppError; + +// --------------------------------------------------------------------------- +// ListEmbedderProfiles +// --------------------------------------------------------------------------- + +/// Output of [`ListEmbedderProfiles::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListEmbedderProfilesOutput { + /// All configured embedder profiles (empty when none configured ⇒ `none`). + pub profiles: Vec, +} + +/// Lists the configured embedder profiles from the store. +pub struct ListEmbedderProfiles { + store: Arc, +} + +impl ListEmbedderProfiles { + /// Builds the use case from the [`EmbedderProfileStore`] port. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Lists configured embedder profiles. + /// + /// # Errors + /// [`AppError::Store`] on persistence failure. + pub async fn execute(&self) -> Result { + Ok(ListEmbedderProfilesOutput { + profiles: self.store.list().await?, + }) + } +} + +// --------------------------------------------------------------------------- +// SaveEmbedderProfile +// --------------------------------------------------------------------------- + +/// Input for [`SaveEmbedderProfile::execute`]: the raw fields of the profile to +/// upsert (validated into an [`EmbedderProfile`] entity). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SaveEmbedderProfileInput { + /// Stable identifier (e.g. `"local-onnx-minilm"`). Non-empty. + pub id: String, + /// Display name. Non-empty. + pub name: String, + /// Embedding strategy driving which concrete adapter is used. + pub strategy: EmbedderStrategy, + /// Model identifier, when the strategy needs one. + pub model: Option, + /// Endpoint URL for a server/API strategy. + pub endpoint: Option, + /// Name of the env var carrying the API key (never the key itself). + pub api_key_env: Option, + /// Length of the vectors this engine produces. Non-zero. + pub dimension: usize, +} + +/// Output of [`SaveEmbedderProfile::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SaveEmbedderProfileOutput { + /// The saved (validated) profile, echoed back. + pub profile: EmbedderProfile, +} + +/// Persists (creates or replaces by id) a single embedder profile, after building +/// and validating the entity. +pub struct SaveEmbedderProfile { + store: Arc, +} + +impl SaveEmbedderProfile { + /// Builds the use case from the [`EmbedderProfileStore`] port. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Validates then saves the profile. + /// + /// # Errors + /// - [`AppError::Invalid`] if the profile's invariants are violated (empty + /// `id`/`name`, zero `dimension`), + /// - [`AppError::Store`] on persistence failure. + pub async fn execute( + &self, + input: SaveEmbedderProfileInput, + ) -> Result { + let profile = EmbedderProfile::new( + input.id, + input.name, + input.strategy, + input.model, + input.endpoint, + input.api_key_env, + input.dimension, + ) + .map_err(|e| AppError::Invalid(e.to_string()))?; + self.store.save(&profile).await?; + Ok(SaveEmbedderProfileOutput { profile }) + } +} + +// --------------------------------------------------------------------------- +// DeleteEmbedderProfile +// --------------------------------------------------------------------------- + +/// Input for [`DeleteEmbedderProfile::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeleteEmbedderProfileInput { + /// Id of the embedder profile to delete. + pub id: String, +} + +/// Deletes an embedder profile by id. +pub struct DeleteEmbedderProfile { + store: Arc, +} + +impl DeleteEmbedderProfile { + /// Builds the use case from the [`EmbedderProfileStore`] port. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Deletes the profile. + /// + /// # Errors + /// [`AppError::NotFound`] if the id is unknown, [`AppError::Store`] on + /// persistence failure. + pub async fn execute(&self, input: DeleteEmbedderProfileInput) -> Result<(), AppError> { + self.store.delete(&input.id).await?; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// DescribeEmbedderEngines +// --------------------------------------------------------------------------- + +/// A recommendable local ONNX model, as a plain application value (mirrors the +/// infrastructure `OnnxModelInfo` data, injected at the composition root so the +/// application never depends on the infrastructure crate). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OnnxModelView { + /// Stable model id accepted by a `localOnnx` profile's `model` field. + pub id: String, + /// Human-readable name for the UI. + pub display_name: String, + /// Length of the vectors this model produces. + pub dimension: usize, + /// Approximate download/disk size in megabytes. + pub approx_size_mb: u32, + /// Whether this is the recommended default model. + pub recommended: bool, +} + +/// A read-only description of the embedding engines available to the +/// "configure an embedder?" UI (C2/C3). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmbedderEnginesView { + /// The curated catalogue of recommendable local ONNX models. + pub recommended_onnx: Vec, + /// Whether an Ollama-style local embedding server was detected (best-effort). + pub ollama_detected: bool, + /// Ids of the recommended ONNX models already present in the local cache. + pub onnx_cached_models: Vec, + /// Whether the HTTP capability (`localServer`/`api`) is compiled into this binary. + pub vector_http_enabled: bool, + /// Whether the in-process ONNX capability (`localOnnx`) is compiled into this binary. + pub vector_onnx_enabled: bool, +} + +/// Describes the engines available to the embedder-configuration UI: the static +/// ONNX catalogue + compiled-capability flags (injected at construction), enriched +/// with a best-effort live snapshot of the local environment via the +/// [`EmbedderEnvInspector`] port. Read-only — emits no event, never fails on the +/// environment probe (the port is best-effort by contract). +pub struct DescribeEmbedderEngines { + inspector: Arc, + recommended_onnx: Vec, + vector_http_enabled: bool, + vector_onnx_enabled: bool, +} + +impl DescribeEmbedderEngines { + /// Builds the use case from the [`EmbedderEnvInspector`] port plus the static + /// engine catalogue and compiled-capability flags (data owned by infrastructure + /// and injected at the composition root, so the application stays infra-free). + #[must_use] + pub fn new( + inspector: Arc, + recommended_onnx: Vec, + vector_http_enabled: bool, + vector_onnx_enabled: bool, + ) -> Self { + Self { + inspector, + recommended_onnx, + vector_http_enabled, + vector_onnx_enabled, + } + } + + /// Returns the engines view. Infallible in practice: the environment probe is + /// best-effort and degrades to "nothing detected". + /// + /// # Errors + /// Never; the `Result` keeps the call site uniform with the other use cases. + #[allow(clippy::unused_async)] + pub async fn execute(&self) -> Result { + let report = self.inspector.inspect().await; + Ok(EmbedderEnginesView { + recommended_onnx: self.recommended_onnx.clone(), + ollama_detected: report.ollama_detected, + onnx_cached_models: report.onnx_cached_models, + vector_http_enabled: self.vector_http_enabled, + vector_onnx_enabled: self.vector_onnx_enabled, + }) + } +} diff --git a/crates/application/src/git/mod.rs b/crates/application/src/git/mod.rs index 1a9c174..365d57b 100644 --- a/crates/application/src/git/mod.rs +++ b/crates/application/src/git/mod.rs @@ -6,7 +6,7 @@ mod usecases; pub use usecases::{ GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit, - GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput, - GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput, - GitStatusOutput, GitUnstage, + GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, + GitInitInput, GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, + GitStatusInput, GitStatusOutput, GitUnstage, }; diff --git a/crates/application/src/health.rs b/crates/application/src/health.rs index c927382..4250776 100644 --- a/crates/application/src/health.rs +++ b/crates/application/src/health.rs @@ -47,12 +47,12 @@ pub struct HealthUseCase { impl HealthUseCase { /// Builds the use case from its injected ports. #[must_use] - pub fn new(clock: Arc, ids: Arc, events: Arc) -> Self { - Self { - clock, - ids, - events, - } + pub fn new( + clock: Arc, + ids: Arc, + events: Arc, + ) -> Self { + Self { clock, ids, events } } /// Executes the health check. diff --git a/crates/application/src/layout/mod.rs b/crates/application/src/layout/mod.rs index d18bd9f..33c0980 100644 --- a/crates/application/src/layout/mod.rs +++ b/crates/application/src/layout/mod.rs @@ -25,14 +25,14 @@ mod snapshot; mod store; mod usecases; -pub use snapshot::{ - SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, -}; pub use management::{ CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput, DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout, RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, }; +pub use snapshot::{ + SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, +}; pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE}; pub use usecases::{ LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout, diff --git a/crates/application/src/layout/store.rs b/crates/application/src/layout/store.rs index 93dd050..65be50f 100644 --- a/crates/application/src/layout/store.rs +++ b/crates/application/src/layout/store.rs @@ -151,7 +151,8 @@ pub async fn persist_doc( ) -> Result<(), AppError> { let ideai_dir = RemotePath::new(join_root(&project.root, IDEAI_DIR)); fs.create_dir_all(&ideai_dir).await?; - fs.write(&layouts_path(project), &to_json_bytes(doc)?).await?; + fs.write(&layouts_path(project), &to_json_bytes(doc)?) + .await?; Ok(()) } diff --git a/crates/application/src/layout/usecases.rs b/crates/application/src/layout/usecases.rs index 34c86e4..7f5cc14 100644 --- a/crates/application/src/layout/usecases.rs +++ b/crates/application/src/layout/usecases.rs @@ -8,7 +8,10 @@ use std::sync::Arc; use domain::ports::{EventBus, FileSystem, ProjectStore}; -use domain::{AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId, ProjectId, SessionId}; +use domain::{ + AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId, + ProjectId, SessionId, +}; use crate::error::AppError; @@ -69,6 +72,14 @@ pub enum LayoutOperation { /// Session to host, or `None` to clear. session: Option, }, + /// Attach an existing live/background session to a leaf, clearing any + /// previous visible host of the same session. + AttachSession { + /// The hosting leaf. + target: NodeId, + /// Session to make visible in this leaf. + session: SessionId, + }, /// Attach or detach an agent to/from a leaf (per-cell agent, feature #3). SetCellAgent { /// The hosting leaf. @@ -117,6 +128,7 @@ impl LayoutOperation { Self::Resize { container, weights } => tree.resize(*container, weights), Self::Move { from, to } => tree.move_session(*from, *to), Self::SetSession { target, session } => tree.set_session(*target, *session), + Self::AttachSession { target, session } => tree.attach_session(*target, *session), Self::SetCellAgent { target, agent } => tree.set_cell_agent(*target, *agent), Self::SetCellConversation { target, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 5f4e9c3..a613c82 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -12,6 +12,7 @@ #![warn(missing_docs)] pub mod agent; +pub mod embedder; pub mod error; pub mod git; pub mod health; @@ -35,17 +36,23 @@ pub use agent::{ ListProfiles, ListProfilesOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, SaveProfile, SaveProfileInput, SaveProfileOutput, UpdateAgentContext, UpdateAgentContextInput, + AGENT_MEMORY_RECALL_BUDGET, +}; +pub use embedder::{ + CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput, + DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines, DismissChoice, + DismissEmbedderSuggestion, DismissEmbedderSuggestionInput, EmbedderEnginesView, + ListEmbedderProfiles, ListEmbedderProfilesOutput, OnnxModelView, SaveEmbedderProfile, + SaveEmbedderProfileInput, SaveEmbedderProfileOutput, SuggestedThisSession, }; pub use error::AppError; -pub use orchestrator::{OrchestratorOutcome, OrchestratorService}; pub use git::{ GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit, - GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput, - GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput, - GitStatusOutput, GitUnstage, + GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, + GitInitInput, GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, + GitStatusInput, GitStatusOutput, GitUnstage, }; pub use health::{HealthInput, HealthReport, HealthUseCase}; -pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput}; pub use layout::{ CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput, DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts, @@ -56,15 +63,25 @@ pub use layout::{ }; pub use memory::{ CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput, - GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput, ListMemoriesOutput, - ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory, RecallMemoryInput, - RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput, - UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, + GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput, + ListMemoriesOutput, ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory, + RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, + ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, }; +pub use orchestrator::{OrchestratorOutcome, OrchestratorService}; pub use project::{ CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject, CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject, - OpenProjectInput, OpenProjectOutput, ProjectMeta, + OpenProjectInput, OpenProjectOutput, ProjectMeta, ReadProjectContext, ReadProjectContextInput, + ReadProjectContextOutput, UpdateProjectContext, UpdateProjectContextInput, + PROJECT_CONTEXT_FILE, +}; +pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput}; +pub use skill::{ + AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput, + DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput, + UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput, + UpdateSkillOutput, }; pub use template::{ AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput, @@ -74,16 +91,9 @@ pub use template::{ SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput, UpdateTemplateOutput, }; -pub use skill::{ - AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput, - DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput, - UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput, - UpdateSkillOutput, -}; pub use terminal::{ - LiveAgentRegistry, - CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput, - OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, WriteToTerminal, - WriteToTerminalInput, + CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, OpenTerminal, + OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, + WriteToTerminal, WriteToTerminalInput, }; pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput}; diff --git a/crates/application/src/memory/mod.rs b/crates/application/src/memory/mod.rs index 5252cfa..fa8996f 100644 --- a/crates/application/src/memory/mod.rs +++ b/crates/application/src/memory/mod.rs @@ -16,8 +16,8 @@ mod usecases; pub use usecases::{ CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput, - GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput, ListMemoriesOutput, - ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory, RecallMemoryInput, - RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput, - UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, + GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput, + ListMemoriesOutput, ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory, + RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, + ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, }; diff --git a/crates/application/src/memory/usecases.rs b/crates/application/src/memory/usecases.rs index 3fba5cc..26a6518 100644 --- a/crates/application/src/memory/usecases.rs +++ b/crates/application/src/memory/usecases.rs @@ -136,7 +136,8 @@ impl UpdateMemory { let memory = Memory::new(frontmatter, MarkdownDoc::new(input.content)) .map_err(|e| AppError::Invalid(e.to_string()))?; self.memories.save(&input.project_root, &memory).await?; - self.events.publish(DomainEvent::MemorySaved { slug: input.slug }); + self.events + .publish(DomainEvent::MemorySaved { slug: input.slug }); Ok(UpdateMemoryOutput { memory }) } } @@ -262,8 +263,11 @@ impl DeleteMemory { /// - [`AppError::NotFound`] if no note carries that slug, /// - [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: DeleteMemoryInput) -> Result<(), AppError> { - self.memories.delete(&input.project_root, &input.slug).await?; - self.events.publish(DomainEvent::MemoryDeleted { slug: input.slug }); + self.memories + .delete(&input.project_root, &input.slug) + .await?; + self.events + .publish(DomainEvent::MemoryDeleted { slug: input.slug }); Ok(()) } } diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index b61ec6b..f646d0b 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use domain::ports::ProfileStore; -use domain::{OrchestratorCommand, Project, ProfileId}; +use domain::{OrchestratorCommand, OrchestratorVisibility, ProfileId, Project}; use crate::agent::{ CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, @@ -95,7 +95,11 @@ impl OrchestratorService { name, profile, context, - } => self.spawn_agent(project, name, profile, context).await, + visibility, + } => { + self.spawn_agent(project, name, profile, context, visibility) + .await + } OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await, OrchestratorCommand::UpdateAgentContext { name, context } => { self.update_agent_context(project, name, context).await @@ -115,15 +119,19 @@ impl OrchestratorService { &self, project: &Project, name: String, - profile: String, + profile: Option, context: Option, + visibility: OrchestratorVisibility, ) -> Result { let existing = self.find_agent_id_by_name(project, &name).await?; let agent_id = match existing { Some(id) => id, None => { - let profile_id = self.resolve_profile(&profile).await?; + let profile = profile.as_deref().ok_or_else(|| { + AppError::Invalid("profile is required to create an agent".to_owned()) + })?; + let profile_id = self.resolve_profile(profile).await?; let created = self .create_agent .execute(CreateAgentInput { @@ -137,19 +145,54 @@ impl OrchestratorService { } }; + if let Some(session_id) = self.sessions.session_for_agent(&agent_id) { + match visibility { + OrchestratorVisibility::Background => { + return Ok(OrchestratorOutcome { + detail: format!("agent {name} already running in background"), + }); + } + OrchestratorVisibility::Visible { node_id } => { + let session = self + .sessions + .rebind_agent_node(&agent_id, node_id) + .ok_or_else(|| { + AppError::NotFound(format!( + "running session {session_id} for agent {name}" + )) + })?; + return Ok(OrchestratorOutcome { + detail: format!("attached agent {name} to cell {}", session.node_id), + }); + } + } + } + + let node_id = match visibility { + OrchestratorVisibility::Background => None, + OrchestratorVisibility::Visible { node_id } => Some(node_id), + }; + self.launch_agent .execute(LaunchAgentInput { project: project.clone(), agent_id, rows: DEFAULT_ROWS, cols: DEFAULT_COLS, - node_id: None, + node_id, conversation_id: None, }) .await?; Ok(OrchestratorOutcome { - detail: format!("launched agent {name}"), + detail: match visibility { + OrchestratorVisibility::Background => { + format!("launched agent {name} in background") + } + OrchestratorVisibility::Visible { node_id } => { + format!("launched agent {name} in cell {node_id}") + } + }, }) } diff --git a/crates/application/src/project/context.rs b/crates/application/src/project/context.rs new file mode 100644 index 0000000..f2a1407 --- /dev/null +++ b/crates/application/src/project/context.rs @@ -0,0 +1,115 @@ +//! Project-level context stored under `.ideai/CONTEXT.md`. +//! +//! This context is model-agnostic and shared by every agent/profile launch. It is +//! deliberately project-local: deleting `.ideai/` removes the context together +//! with every other IdeA artefact for that project. + +use std::sync::Arc; + +use domain::ports::{FileSystem, FsError, RemotePath}; +use domain::Project; + +use crate::error::AppError; + +use super::meta::{join_root, IDEAI_DIR}; + +/// Project-context file name inside `.ideai/`. +pub const PROJECT_CONTEXT_FILE: &str = "CONTEXT.md"; + +/// Input for [`ReadProjectContext::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadProjectContextInput { + /// Project whose `.ideai/CONTEXT.md` should be read. + pub project: Project, +} + +/// Output of [`ReadProjectContext::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadProjectContextOutput { + /// Markdown content. Empty when the file does not exist yet. + pub content: String, +} + +/// Reads `.ideai/CONTEXT.md` tolerantly. +pub struct ReadProjectContext { + fs: Arc, +} + +impl ReadProjectContext { + /// Builds the use case. + #[must_use] + pub fn new(fs: Arc) -> Self { + Self { fs } + } + + /// Executes the read. + /// + /// # Errors + /// Returns [`AppError::FileSystem`] on non-missing filesystem failures, and + /// [`AppError::Store`] if the file is not valid UTF-8. + pub async fn execute( + &self, + input: ReadProjectContextInput, + ) -> Result { + match self.fs.read(&project_context_path(&input.project)).await { + Ok(bytes) => { + let content = String::from_utf8(bytes) + .map_err(|e| AppError::Store(format!("project context is not UTF-8: {e}")))?; + Ok(ReadProjectContextOutput { content }) + } + Err(FsError::NotFound(_)) => Ok(ReadProjectContextOutput { + content: String::new(), + }), + Err(e) => Err(AppError::FileSystem(e.to_string())), + } + } +} + +/// Input for [`UpdateProjectContext::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateProjectContextInput { + /// Project whose `.ideai/CONTEXT.md` should be overwritten. + pub project: Project, + /// New Markdown content. + pub content: String, +} + +/// Overwrites `.ideai/CONTEXT.md`. +pub struct UpdateProjectContext { + fs: Arc, +} + +impl UpdateProjectContext { + /// Builds the use case. + #[must_use] + pub fn new(fs: Arc) -> Self { + Self { fs } + } + + /// Executes the update. + /// + /// # Errors + /// Returns [`AppError::FileSystem`] if `.ideai/` cannot be created or the + /// context file cannot be written. + pub async fn execute(&self, input: UpdateProjectContextInput) -> Result<(), AppError> { + self.fs + .create_dir_all(&RemotePath::new(join_root(&input.project.root, IDEAI_DIR))) + .await?; + self.fs + .write( + &project_context_path(&input.project), + input.content.as_bytes(), + ) + .await?; + Ok(()) + } +} + +/// Absolute path to `/.ideai/CONTEXT.md`. +#[must_use] +pub fn project_context_path(project: &Project) -> RemotePath { + RemotePath::new(join_root( + &project.root, + &format!("{IDEAI_DIR}/{PROJECT_CONTEXT_FILE}"), + )) +} diff --git a/crates/application/src/project/mod.rs b/crates/application/src/project/mod.rs index f6c38ca..bd38323 100644 --- a/crates/application/src/project/mod.rs +++ b/crates/application/src/project/mod.rs @@ -14,11 +14,18 @@ //! - [`ListProjects`] — list known projects from the registry. mod close; +mod context; mod create; pub(crate) mod meta; mod open; pub use close::{CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput}; +pub use context::{ + project_context_path, ReadProjectContext, ReadProjectContextInput, ReadProjectContextOutput, + UpdateProjectContext, UpdateProjectContextInput, PROJECT_CONTEXT_FILE, +}; pub use create::{CreateProject, CreateProjectInput, CreateProjectOutput}; pub use meta::ProjectMeta; -pub use open::{ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput}; +pub use open::{ + ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput, +}; diff --git a/crates/application/src/project/open.rs b/crates/application/src/project/open.rs index 128b290..ee3ff70 100644 --- a/crates/application/src/project/open.rs +++ b/crates/application/src/project/open.rs @@ -7,9 +7,7 @@ use domain::{AgentManifest, Project, ProjectId}; use crate::error::AppError; -use super::meta::{ - from_json_bytes, join_root, ProjectMeta, AGENTS_FILE, IDEAI_DIR, PROJECT_FILE, -}; +use super::meta::{from_json_bytes, join_root, ProjectMeta, AGENTS_FILE, IDEAI_DIR, PROJECT_FILE}; /// Input for [`OpenProject::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/application/src/skill/usecases.rs b/crates/application/src/skill/usecases.rs index da16374..a285f22 100644 --- a/crates/application/src/skill/usecases.rs +++ b/crates/application/src/skill/usecases.rs @@ -249,13 +249,21 @@ impl AssignSkillToAgent { // Mutate through the domain entity so the dedup invariant is enforced in // one place, then fold the result back into the manifest entry. - let mut agent = entry.to_agent().map_err(|e| AppError::Invalid(e.to_string()))?; + let mut agent = entry + .to_agent() + .map_err(|e| AppError::Invalid(e.to_string()))?; let changed = agent.assign_skill(input.skill); *entry = domain::ManifestEntry::from_agent(&agent); if changed { - self.persist_and_announce(&input.project, manifest, input.agent_id, input.skill.skill_id, true) - .await?; + self.persist_and_announce( + &input.project, + manifest, + input.agent_id, + input.skill.skill_id, + true, + ) + .await?; } Ok(()) } @@ -320,14 +328,18 @@ impl UnassignSkillFromAgent { .find(|e| e.agent_id == input.agent_id) .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; - let mut agent = entry.to_agent().map_err(|e| AppError::Invalid(e.to_string()))?; + let mut agent = entry + .to_agent() + .map_err(|e| AppError::Invalid(e.to_string()))?; let changed = agent.unassign_skill(input.skill_id); *entry = domain::ManifestEntry::from_agent(&agent); if changed { let manifest = AgentManifest::new(manifest.version, manifest.entries) .map_err(|e| AppError::Invalid(e.to_string()))?; - self.contexts.save_manifest(&input.project, &manifest).await?; + self.contexts + .save_manifest(&input.project, &manifest) + .await?; self.events.publish(DomainEvent::SkillAssigned { agent_id: input.agent_id, skill_id: input.skill_id, diff --git a/crates/application/src/template/usecases.rs b/crates/application/src/template/usecases.rs index d3378a6..4d34483 100644 --- a/crates/application/src/template/usecases.rs +++ b/crates/application/src/template/usecases.rs @@ -286,7 +286,9 @@ impl CreateAgentFromTemplate { entries.push(ManifestEntry::from_agent(&agent)); let manifest = AgentManifest::new(manifest.version, entries) .map_err(|e| AppError::Invalid(e.to_string()))?; - self.contexts.save_manifest(&input.project, &manifest).await?; + self.contexts + .save_manifest(&input.project, &manifest) + .await?; // Seed the agent context with the template content. self.contexts @@ -477,7 +479,9 @@ impl SyncAgentWithTemplate { // Persist the manifest (revalidated) and overwrite the agent context. let manifest = AgentManifest::new(manifest.version, manifest.entries) .map_err(|e| AppError::Invalid(e.to_string()))?; - self.contexts.save_manifest(&input.project, &manifest).await?; + self.contexts + .save_manifest(&input.project, &manifest) + .await?; self.contexts .write_context(&input.project, &input.agent_id, &template.content_md) .await?; diff --git a/crates/application/src/terminal/registry.rs b/crates/application/src/terminal/registry.rs index 31c73be..e4d245e 100644 --- a/crates/application/src/terminal/registry.rs +++ b/crates/application/src/terminal/registry.rs @@ -128,19 +128,21 @@ impl TerminalSessions { }) } - /// Lists every currently-live agent and the cell hosting it. + /// Lists every currently-live agent, its current host cell and session id. /// - /// One `(AgentId, NodeId)` pair per session tagged [`SessionKind::Agent`]. + /// One `(AgentId, NodeId, SessionId)` tuple per session tagged [`SessionKind::Agent`]. /// Used by the `list_live_agents` query so the UI can disable an agent that /// is already running elsewhere (it cannot be launched in a second cell). #[must_use] - pub fn live_agents(&self) -> Vec<(AgentId, NodeId)> { + pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> { self.entries .lock() .map(|m| { m.values() .filter_map(|e| match e.session.kind { - SessionKind::Agent { agent_id } => Some((agent_id, e.session.node_id)), + SessionKind::Agent { agent_id } => { + Some((agent_id, e.session.node_id, e.session.id)) + } SessionKind::Plain => None, }) .collect() @@ -148,6 +150,28 @@ impl TerminalSessions { .unwrap_or_default() } + /// Rebinds a live agent session to a new visible layout node without + /// respawning the CLI process. + /// + /// This is the application-level "cell is a view" operation: closing a cell + /// can leave an agent running in the background, and later opening that agent + /// in another cell updates only the view binding (`node_id`). The PTY handle, + /// session id, scrollback and process stay untouched. + #[must_use] + pub fn rebind_agent_node( + &self, + agent_id: &AgentId, + node_id: NodeId, + ) -> Option { + self.entries.lock().ok().and_then(|mut m| { + let entry = m.values_mut().find( + |e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id), + )?; + entry.session.node_id = node_id; + Some(entry.session.clone()) + }) + } + /// Returns the [`PtyHandle`]s of every currently-registered session. /// /// Used at application shutdown to kill all live PTYs cleanly (the diff --git a/crates/application/src/terminal/usecases.rs b/crates/application/src/terminal/usecases.rs index 132495d..c9b8e03 100644 --- a/crates/application/src/terminal/usecases.rs +++ b/crates/application/src/terminal/usecases.rs @@ -67,10 +67,7 @@ impl OpenTerminal { /// # Errors /// - [`AppError::Invalid`] for a non-absolute cwd or a zero-sized terminal, /// - [`AppError::Process`] if the PTY fails to spawn. - pub async fn execute( - &self, - input: OpenTerminalInput, - ) -> Result { + pub async fn execute(&self, input: OpenTerminalInput) -> Result { let cwd = ProjectPath::new(input.cwd).map_err(|e| AppError::Invalid(e.to_string()))?; let size = PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?; diff --git a/crates/application/tests/agent_inspect.rs b/crates/application/tests/agent_inspect.rs index 182b2a8..748d3bf 100644 --- a/crates/application/tests/agent_inspect.rs +++ b/crates/application/tests/agent_inspect.rs @@ -192,7 +192,15 @@ fn profile(id: ProfileId) -> AgentProfile { } fn agent(id: AgentId, profile_id: ProfileId) -> Agent { - Agent::new(id, "Bob", "agents/bob.md", profile_id, AgentOrigin::Scratch, false).unwrap() + Agent::new( + id, + "Bob", + "agents/bob.md", + profile_id, + AgentOrigin::Scratch, + false, + ) + .unwrap() } fn input(agent_id: AgentId) -> InspectConversationInput { @@ -228,7 +236,10 @@ async fn supporting_inspector_propagates_details() { ); let out = uc.execute(input(aid)).await.unwrap(); - assert_eq!(out.details.last_topic.as_deref(), Some("refactor the parser")); + assert_eq!( + out.details.last_topic.as_deref(), + Some("refactor the parser") + ); assert_eq!(out.details.token_count, Some(4242)); // Routed the conversation id and the agent's isolated run dir (not the root). @@ -270,8 +281,10 @@ async fn read_error_degrades_to_empty_details() { let aid = AgentId::from_uuid(Uuid::from_u128(42)); let a = agent(aid, pid); - let (inspector, _seen) = - FakeInspector::new(true, InspectResult::Err(InspectError::Read("boom".to_owned()))); + let (inspector, _seen) = FakeInspector::new( + true, + InspectResult::Err(InspectError::Read("boom".to_owned())), + ); let uc = InspectConversation::new( Arc::new(FakeContexts::with_agent(&a)), @@ -280,7 +293,13 @@ async fn read_error_degrades_to_empty_details() { ); let out = uc.execute(input(aid)).await.unwrap(); - assert_eq!(out.details, ConversationDetails { last_topic: None, token_count: None }); + assert_eq!( + out.details, + ConversationDetails { + last_topic: None, + token_count: None + } + ); } // --------------------------------------------------------------------------- @@ -344,7 +363,10 @@ async fn unknown_agent_errors() { let aid = AgentId::from_uuid(Uuid::from_u128(99)); let (inspector, _seen) = FakeInspector::new( true, - InspectResult::Ok(ConversationDetails { last_topic: None, token_count: None }), + InspectResult::Ok(ConversationDetails { + last_topic: None, + token_count: None, + }), ); let uc = InspectConversation::new( diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 38963ca..c32a9d0 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -29,16 +29,16 @@ use domain::ports::{ OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, }; -use domain::{MemoryIndexEntry, MemorySlug, MemoryType}; use domain::profile::{AgentProfile, ContextInjection, SessionStrategy}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::skill::{Skill, SkillScope}; +use domain::{MemoryIndexEntry, MemorySlug, MemoryType}; use domain::{PtySize, SessionId, SkillId, SkillRef}; use uuid::Uuid; use application::{ - AppError, CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent, + CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, ReadAgentContext, ReadAgentContextInput, TerminalSessions, UpdateAgentContext, UpdateAgentContextInput, }; @@ -84,7 +84,10 @@ impl FakeContexts { let me = Self::new(); { let mut inner = me.0.lock().unwrap(); - inner.manifest.entries.push(ManifestEntry::from_agent(agent)); + inner + .manifest + .entries + .push(ManifestEntry::from_agent(agent)); inner .contents .insert(agent.context_path.clone(), content.to_owned()); @@ -196,7 +199,12 @@ impl FakeSkills { #[async_trait] impl SkillStore for FakeSkills { async fn list(&self, scope: SkillScope, _root: &ProjectPath) -> Result, StoreError> { - Ok(self.0.iter().filter(|s| s.scope == scope).cloned().collect()) + Ok(self + .0 + .iter() + .filter(|s| s.scope == scope) + .cloned() + .collect()) } async fn get( &self, @@ -333,6 +341,7 @@ struct FakeFs { trace: Trace, writes: WriteLog, created_dirs: Arc>>, + project_context: Arc>>, } impl FakeFs { @@ -341,8 +350,12 @@ impl FakeFs { trace, writes: Arc::new(Mutex::new(Vec::new())), created_dirs: Arc::new(Mutex::new(Vec::new())), + project_context: Arc::new(Mutex::new(None)), } } + fn set_project_context(&self, content: &str) { + *self.project_context.lock().unwrap() = Some(content.to_owned()); + } fn writes(&self) -> Vec<(String, Vec)> { self.writes.lock().unwrap().clone() } @@ -376,6 +389,15 @@ impl FakeFs { #[async_trait] impl FileSystem for FakeFs { async fn read(&self, path: &RemotePath) -> Result, FsError> { + if path.as_str().ends_with("/.ideai/CONTEXT.md") { + return self + .project_context + .lock() + .unwrap() + .clone() + .map(|s| s.into_bytes()) + .ok_or_else(|| FsError::NotFound(path.as_str().to_owned())); + } Err(FsError::NotFound(path.as_str().to_owned())) } async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { @@ -648,7 +670,10 @@ async fn read_then_update_context_roundtrips() { .await .unwrap(); - assert_eq!(contexts.content("agents/backend.md").as_deref(), Some("edited")); + assert_eq!( + contexts.content("agents/backend.md").as_deref(), + Some("edited") + ); } #[tokio::test] @@ -696,7 +721,10 @@ type LaunchFixture = ( ); /// Wires a LaunchAgent over fakes for a given injection strategy/plan. -fn launch_fixture(injection: ContextInjection, plan: Option) -> LaunchFixture { +fn launch_fixture( + injection: ContextInjection, + plan: Option, +) -> LaunchFixture { launch_fixture_with_profile(profile(pid(9), injection), plan) } @@ -738,6 +766,7 @@ fn launch_fixture_with_profile_and_recall( Arc::new(bus.clone()), Arc::new(SeqIds::new()), Arc::new(recall), + None, ); (launch, agent, fs, pty, bus, sessions, tr, session_probe) } @@ -763,7 +792,10 @@ async fn launch_orders_prepare_then_injection_then_spawn() { }), ); - let out = launch.execute(launch_input(agent.id)).await.expect("launch"); + let out = launch + .execute(launch_input(agent.id)) + .await + .expect("launch"); // Ordering contract. The Claude permission seed is written first (right after // the run dir is created), then prepare → injection (convention file) → spawn. @@ -802,14 +834,18 @@ async fn launch_orders_prepare_then_injection_then_spawn() { assert_eq!(seeds.len(), 1); assert_eq!(seeds[0].0, format!("{run_dir}/.claude/settings.local.json")); let seed = String::from_utf8(seeds[0].1.clone()).unwrap(); - assert!(seed.contains("bypassPermissions"), "seed grants autonomy: {seed}"); - assert!(seed.contains("/home/me/proj"), "seed grants the project root"); + assert!( + seed.contains("bypassPermissions"), + "seed grants autonomy: {seed}" + ); + assert!( + seed.contains("/home/me/proj"), + "seed grants the project root" + ); // The run directory (and the seed's `.claude` subdir) were created before spawn. assert_eq!(fs.created_run_dirs(), vec![run_dir.clone()]); - assert!(fs - .created_dirs() - .contains(&format!("{run_dir}/.claude"))); + assert!(fs.created_dirs().contains(&format!("{run_dir}/.claude"))); // Spawn happened at the isolated run dir with the profile command. let spawns = pty.spawns(); @@ -835,6 +871,34 @@ async fn launch_orders_prepare_then_injection_then_spawn() { ); } +#[tokio::test] +async fn launch_injects_shared_project_context_from_ideai_context_md() { + let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture( + ContextInjection::convention_file("CLAUDE.md").unwrap(), + Some(ContextInjectionPlan::File { + target: "CLAUDE.md".to_owned(), + }), + ); + fs.set_project_context("# Shared project\n\nUse pnpm."); + + launch + .execute(launch_input(agent.id)) + .await + .expect("launch"); + + let writes = fs.context_writes(); + assert_eq!(writes.len(), 1); + let doc = String::from_utf8(writes[0].1.clone()).unwrap(); + assert!(doc.contains("# Contexte projet")); + assert!(doc.contains("Use pnpm.")); + let project_context_at = doc.find("# Contexte projet").unwrap(); + let persona_at = doc.find("# ctx body").unwrap(); + assert!( + project_context_at < persona_at, + "project context must precede the agent persona" + ); +} + /// Inserts a live agent session into the registry, simulating an already-running /// agent pinned on `node`. Returns the session id. fn seed_live_agent_session( @@ -860,11 +924,10 @@ fn nid(n: u128) -> domain::NodeId { } /// **Singleton invariant (T1)**: launching an agent that already owns a live -/// session in *another* cell is refused with `AgentAlreadyRunning`, pointing at -/// the existing host node. The registry is left untouched and the PTY is never -/// spawned. +/// session in another cell re-attaches the existing session to the requested +/// node. The PTY is never spawned a second time. #[tokio::test] -async fn launch_refuses_agent_already_running_elsewhere() { +async fn launch_reattaches_agent_already_running_elsewhere() { let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture( ContextInjection::convention_file("CLAUDE.md").unwrap(), Some(ContextInjectionPlan::File { @@ -877,28 +940,24 @@ async fn launch_refuses_agent_already_running_elsewhere() { seed_live_agent_session(&sessions, agent.id, host, sid(42)); let before = sessions.len(); - // Attempt to launch the same agent into a *different* cell B. + // Open the same running agent in a different cell B. let mut input = launch_input(agent.id); - input.node_id = Some(nid(2)); - let err = launch.execute(input).await.expect_err("must be refused"); + let target = nid(2); + input.node_id = Some(target); + let out = launch.execute(input).await.expect("reattach succeeds"); - match err { - AppError::AgentAlreadyRunning { agent_id, node_id } => { - assert_eq!(agent_id, agent.id); - assert_eq!(node_id, host, "reports the existing host cell"); - } - other => panic!("expected AgentAlreadyRunning, got {other:?}"), - } - assert_eq!(err.code(), "AGENT_ALREADY_RUNNING"); - // Invariant preserved: registry unchanged, no spawn happened. - assert_eq!(sessions.len(), before, "registry must be unchanged"); - assert!(pty.spawns().is_empty(), "no PTY spawn on a refused launch"); + assert_eq!(out.session.id, sid(42), "returns the existing session"); + assert_eq!(out.session.node_id, target, "session is rebound to target"); + assert_eq!(sessions.node_for_agent(&agent.id), Some(target)); + assert_eq!(sessions.len(), before, "registry size is unchanged"); + assert!(pty.spawns().is_empty(), "no PTY spawn on reattach"); } /// A launch with an unspecified node (`node_id: None`) for an already-live agent -/// is also refused — it cannot be the same cell. +/// is a background/idempotent no-op: return the existing session without +/// respawning or changing its current host. #[tokio::test] -async fn launch_refuses_already_running_agent_with_no_node() { +async fn launch_existing_agent_with_no_node_is_background_noop() { let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture( ContextInjection::convention_file("CLAUDE.md").unwrap(), Some(ContextInjectionPlan::File { @@ -908,11 +967,12 @@ async fn launch_refuses_already_running_agent_with_no_node() { seed_live_agent_session(&sessions, agent.id, nid(1), sid(42)); // node_id defaults to None in launch_input. - let err = launch + let out = launch .execute(launch_input(agent.id)) .await - .expect_err("must be refused"); - assert!(matches!(err, AppError::AgentAlreadyRunning { .. })); + .expect("background relaunch is idempotent"); + assert_eq!(out.session.id, sid(42)); + assert_eq!(out.session.node_id, nid(1)); assert!(pty.spawns().is_empty()); } @@ -937,7 +997,10 @@ async fn launch_same_node_is_idempotent_no_respawn() { let out = launch.execute(input).await.expect("idempotent launch"); assert_eq!(out.session.id, sid(42), "returns the existing session"); - assert!(out.assigned_conversation_id.is_none(), "nothing new assigned"); + assert!( + out.assigned_conversation_id.is_none(), + "nothing new assigned" + ); assert_eq!(sessions.len(), before, "no new session registered"); assert!(pty.spawns().is_empty(), "no respawn on same-node relaunch"); } @@ -963,7 +1026,11 @@ async fn launch_succeeds_after_session_removed() { let out = launch.execute(input).await.expect("relaunch must succeed"); assert_eq!(out.session.id, sid(777), "spawned a fresh PTY session"); - assert_eq!(pty.spawns().len(), 1, "exactly one spawn after the agent died"); + assert_eq!( + pty.spawns().len(), + 1, + "exactly one spawn after the agent died" + ); } /// **Anti-collision (ARCHITECTURE §14.1)**: two distinct agents of the *same* @@ -1009,6 +1076,7 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() { Arc::new(SpyBus::default()), Arc::new(SeqIds::new()), Arc::new(FakeRecall::default()), + None, ); launch.execute(launch_input(agent_a.id)).await.unwrap(); @@ -1016,7 +1084,10 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() { let dir_a = format!("/home/me/proj/.ideai/run/{}", agent_a.id); let dir_b = format!("/home/me/proj/.ideai/run/{}", agent_b.id); - assert_ne!(dir_a, dir_b, "the two agents must map to different run dirs"); + assert_ne!( + dir_a, dir_b, + "the two agents must map to different run dirs" + ); // Two distinct run dirs were created (ignoring each seed's `.claude` subdir). assert_eq!(fs.created_run_dirs(), vec![dir_a.clone(), dir_b.clone()]); @@ -1038,8 +1109,12 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() { assert!(writes.iter().all(|(p, _)| p != "/home/me/proj/CLAUDE.md")); // Each convention file carries its own persona. - assert!(String::from_utf8(writes[0].1.clone()).unwrap().contains("# alpha")); - assert!(String::from_utf8(writes[1].1.clone()).unwrap().contains("# bravo")); + assert!(String::from_utf8(writes[0].1.clone()) + .unwrap() + .contains("# alpha")); + assert!(String::from_utf8(writes[1].1.clone()) + .unwrap() + .contains("# bravo")); } #[tokio::test] @@ -1048,7 +1123,13 @@ async fn launch_conventionfile_injects_assigned_skills_in_order() { // carry both skill bodies, after the persona, in assignment order (§14.2). let skill_id = |n: u128| SkillId::from_uuid(Uuid::from_u128(n)); let skill = |n: u128, name: &str, body: &str| { - Skill::new(skill_id(n), name, MarkdownDoc::new(body), SkillScope::Global).unwrap() + Skill::new( + skill_id(n), + name, + MarkdownDoc::new(body), + SkillScope::Global, + ) + .unwrap() }; let mut agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); @@ -1083,6 +1164,7 @@ async fn launch_conventionfile_injects_assigned_skills_in_order() { Arc::new(SpyBus::default()), Arc::new(SeqIds::new()), Arc::new(FakeRecall::default()), + None, ); launch.execute(launch_input(agent.id)).await.unwrap(); @@ -1097,7 +1179,10 @@ async fn launch_conventionfile_injects_assigned_skills_in_order() { let persona_at = doc.find("# persona").unwrap(); let refac_at = doc.find("REFAC_BODY").unwrap(); let review_at = doc.find("REVIEW_BODY").unwrap(); - assert!(persona_at < refac_at && refac_at < review_at, "ordering: {doc}"); + assert!( + persona_at < refac_at && refac_at < review_at, + "ordering: {doc}" + ); } #[tokio::test] @@ -1106,12 +1191,25 @@ async fn launch_conventionfile_injects_project_memory_in_order() { // `# Mémoire projet` section with one line per entry, in the recalled order // and the exact `- [Title](slug.md) — hook (type)` format (§14.5.4). let recall = FakeRecall::returning(vec![ - mem_entry("git-optional", "Git optionnel", "git reste un simple tool", MemoryType::Project), - mem_entry("perm-archi", "Permissions", "sandbox OS + résumé injecté", MemoryType::Reference), + mem_entry( + "git-optional", + "Git optionnel", + "git reste un simple tool", + MemoryType::Project, + ), + mem_entry( + "perm-archi", + "Permissions", + "sandbox OS + résumé injecté", + MemoryType::Reference, + ), ]); let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile_and_recall( - profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap()), + profile( + pid(9), + ContextInjection::convention_file("CLAUDE.md").unwrap(), + ), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), @@ -1124,7 +1222,10 @@ async fn launch_conventionfile_injects_project_memory_in_order() { assert_eq!(writes.len(), 1); let doc = String::from_utf8(writes[0].1.clone()).unwrap(); - assert!(doc.contains("# Mémoire projet"), "memory section present: {doc}"); + assert!( + doc.contains("# Mémoire projet"), + "memory section present: {doc}" + ); assert!( doc.contains("- [Git optionnel](git-optional.md) — git reste un simple tool (project)"), "first memory line exact format: {doc}" @@ -1139,7 +1240,10 @@ async fn launch_conventionfile_injects_project_memory_in_order() { let first_at = doc.find("[Git optionnel]").unwrap(); let second_at = doc.find("[Permissions]").unwrap(); assert!(persona_at < section_at, "memory after persona: {doc}"); - assert!(first_at < second_at, "entries kept in recalled order: {doc}"); + assert!( + first_at < second_at, + "entries kept in recalled order: {doc}" + ); } #[tokio::test] @@ -1155,7 +1259,10 @@ async fn launch_conventionfile_without_memory_omits_section() { launch.execute(launch_input(agent.id)).await.unwrap(); let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap(); - assert!(!doc.contains("# Mémoire projet"), "no memory ⇒ no section: {doc}"); + assert!( + !doc.contains("# Mémoire projet"), + "no memory ⇒ no section: {doc}" + ); } #[tokio::test] @@ -1164,7 +1271,10 @@ async fn launch_recall_error_degrades_to_no_section_without_failing() { // succeeds and the convention file simply carries no memory section. let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile_and_recall( - profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap()), + profile( + pid(9), + ContextInjection::convention_file("CLAUDE.md").unwrap(), + ), Some(ContextInjectionPlan::File { target: "CLAUDE.md".to_owned(), }), @@ -1172,7 +1282,10 @@ async fn launch_recall_error_degrades_to_no_section_without_failing() { ); // Launch still succeeds despite the recall error. - launch.execute(launch_input(agent.id)).await.expect("launch must succeed"); + launch + .execute(launch_input(agent.id)) + .await + .expect("launch must succeed"); let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap(); assert!( @@ -1185,12 +1298,7 @@ async fn launch_recall_error_degrades_to_no_section_without_failing() { async fn launch_env_strategy_injects_no_memory_section() { // For the `env` strategy IdeA writes no convention file, so no memory section is // injected — aligned with how skills are only injected for `conventionFile`. - let recall = FakeRecall::returning(vec![mem_entry( - "note", - "Note", - "a hook", - MemoryType::User, - )]); + let recall = FakeRecall::returning(vec![mem_entry("note", "Note", "a hook", MemoryType::User)]); let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture_with_profile_and_recall( profile(pid(9), ContextInjection::env("IDEA_CONTEXT").unwrap()), @@ -1214,7 +1322,10 @@ async fn launch_skips_dangling_skill_ref_without_failing() { // The agent references a skill that no longer exists in the store: launch must // still succeed and simply omit it (no Skills section for a sole dangling ref). let mut agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); - agent.assign_skill(SkillRef::new(SkillId::from_uuid(Uuid::from_u128(99)), SkillScope::Global)); + agent.assign_skill(SkillRef::new( + SkillId::from_uuid(Uuid::from_u128(99)), + SkillScope::Global, + )); let contexts = FakeContexts::with_agent(&agent, "# persona"); let profiles = FakeProfiles::new(vec![profile( @@ -1240,11 +1351,18 @@ async fn launch_skips_dangling_skill_ref_without_failing() { Arc::new(SpyBus::default()), Arc::new(SeqIds::new()), Arc::new(FakeRecall::default()), + None, ); - launch.execute(launch_input(agent.id)).await.expect("launch must succeed"); + launch + .execute(launch_input(agent.id)) + .await + .expect("launch must succeed"); let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap(); - assert!(!doc.contains("# Skills"), "no Skills section for a dangling ref: {doc}"); + assert!( + !doc.contains("# Skills"), + "no Skills section for a dangling ref: {doc}" + ); } #[tokio::test] @@ -1281,7 +1399,10 @@ async fn launch_stdin_strategy_pipes_context_after_spawn() { // No file written for stdin; content is piped to the PTY post-spawn. assert!(fs.writes().is_empty(), "stdin must not write a file"); - assert_eq!(*tr.lock().unwrap(), vec!["prepare".to_owned(), "spawn".to_owned()]); + assert_eq!( + *tr.lock().unwrap(), + vec!["prepare".to_owned(), "spawn".to_owned()] + ); let writes = pty.writes(); assert_eq!(writes.len(), 1); assert_eq!(writes[0].0, sid(777)); @@ -1290,10 +1411,8 @@ async fn launch_stdin_strategy_pipes_context_after_spawn() { #[tokio::test] async fn launch_unknown_agent_is_not_found() { - let (launch, _agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture( - ContextInjection::stdin(), - Some(ContextInjectionPlan::Stdin), - ); + let (launch, _agent, _fs, pty, _bus, _sessions, _tr, _session) = + launch_fixture(ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin)); let err = launch.execute(launch_input(aid(404))).await.unwrap_err(); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); assert!(pty.spawns().is_empty(), "no spawn for unknown agent"); @@ -1310,7 +1429,10 @@ async fn launch_unknown_profile_is_not_found() { let launch = LaunchAgent::new( Arc::new(contexts), Arc::new(profiles), - Arc::new(FakeRuntime::new(Arc::clone(&tr), Some(ContextInjectionPlan::Stdin))), + Arc::new(FakeRuntime::new( + Arc::clone(&tr), + Some(ContextInjectionPlan::Stdin), + )), Arc::new(FakeFs::new(Arc::clone(&tr))), Arc::new(pty.clone()), Arc::new(FakeSkills::default()), @@ -1318,6 +1440,7 @@ async fn launch_unknown_profile_is_not_found() { Arc::new(SpyBus::default()), Arc::new(SeqIds::new()), Arc::new(FakeRecall::default()), + None, ); let err = launch.execute(launch_input(agent.id)).await.unwrap_err(); @@ -1358,18 +1481,26 @@ async fn launch_first_time_with_assign_flag_mints_and_exposes_conversation_id() Some(ContextInjectionPlan::Stdin), ); - let out = launch.execute(launch_input(agent.id)).await.expect("launch"); + let out = launch + .execute(launch_input(agent.id)) + .await + .expect("launch"); // SeqIds yields Uuid::from_u128(1) on its first call. let expected = Uuid::from_u128(1).to_string(); // The use case exposes the assigned id (caller persists it on the leaf). - assert_eq!(out.assigned_conversation_id.as_deref(), Some(expected.as_str())); + assert_eq!( + out.assigned_conversation_id.as_deref(), + Some(expected.as_str()) + ); // The runtime received an Assign plan carrying exactly that id. assert_eq!( *session.lock().unwrap(), - Some(SessionPlan::Assign { conversation_id: expected }), + Some(SessionPlan::Assign { + conversation_id: expected + }), ); } @@ -1403,7 +1534,10 @@ async fn launch_profile_without_session_block_passes_none() { let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) = launch_fixture(ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin)); - let out = launch.execute(launch_input(agent.id)).await.expect("launch"); + let out = launch + .execute(launch_input(agent.id)) + .await + .expect("launch"); assert_eq!(out.assigned_conversation_id, None); assert_eq!(*session.lock().unwrap(), Some(SessionPlan::None)); @@ -1418,8 +1552,14 @@ async fn launch_degraded_mode_without_assign_flag_first_launch_is_none() { Some(ContextInjectionPlan::Stdin), ); - let out = launch.execute(launch_input(agent.id)).await.expect("launch"); + let out = launch + .execute(launch_input(agent.id)) + .await + .expect("launch"); - assert_eq!(out.assigned_conversation_id, None, "degraded mode mints no id"); + assert_eq!( + out.assigned_conversation_id, None, + "degraded mode mints no id" + ); assert_eq!(*session.lock().unwrap(), Some(SessionPlan::None)); } diff --git a/crates/application/tests/embedder_usecases.rs b/crates/application/tests/embedder_usecases.rs new file mode 100644 index 0000000..60e3b56 --- /dev/null +++ b/crates/application/tests/embedder_usecases.rs @@ -0,0 +1,256 @@ +//! L5 tests for the embedder-configuration use cases (LOT C2). +//! +//! Ports are faked in-memory so the use cases run without any I/O: +//! - [`InMemoryEmbedderProfileStore`] — an [`EmbedderProfileStore`] backed by a +//! `Vec` with the same upsert/delete-NotFound semantics as the real +//! `FsEmbedderProfileStore`, +//! - [`FixedEnvInspector`] — an [`EmbedderEnvInspector`] returning a constant +//! [`EmbedderEnvReport`] (best-effort port, never errors). + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; + +use domain::ports::{EmbedderEnvInspector, EmbedderEnvReport, EmbedderProfileStore, StoreError}; +use domain::profile::{EmbedderProfile, EmbedderStrategy}; + +use application::{ + DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines, + ListEmbedderProfiles, OnnxModelView, SaveEmbedderProfile, SaveEmbedderProfileInput, +}; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// In-memory [`EmbedderProfileStore`] mirroring the real store's semantics: +/// upsert-by-id (no duplicate), delete-absent ⇒ [`StoreError::NotFound`]. +#[derive(Default, Clone)] +struct InMemoryEmbedderProfileStore(Arc>>); + +#[async_trait] +impl EmbedderProfileStore for InMemoryEmbedderProfileStore { + async fn list(&self) -> Result, StoreError> { + Ok(self.0.lock().unwrap().clone()) + } + + async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError> { + let mut v = self.0.lock().unwrap(); + if let Some(slot) = v.iter_mut().find(|p| p.id == profile.id) { + *slot = profile.clone(); + } else { + v.push(profile.clone()); + } + Ok(()) + } + + async fn delete(&self, id: &str) -> Result<(), StoreError> { + let mut v = self.0.lock().unwrap(); + let before = v.len(); + v.retain(|p| p.id != id); + if v.len() == before { + return Err(StoreError::NotFound); + } + Ok(()) + } +} + +/// An [`EmbedderEnvInspector`] returning a fixed report (best-effort, infallible). +struct FixedEnvInspector(EmbedderEnvReport); + +#[async_trait] +impl EmbedderEnvInspector for FixedEnvInspector { + async fn inspect(&self) -> EmbedderEnvReport { + self.0.clone() + } +} + +fn save_input(id: &str, name: &str, dimension: usize) -> SaveEmbedderProfileInput { + SaveEmbedderProfileInput { + id: id.to_owned(), + name: name.to_owned(), + strategy: EmbedderStrategy::LocalOnnx, + model: Some("multilingual-e5-small".to_owned()), + endpoint: None, + api_key_env: None, + dimension, + } +} + +// --------------------------------------------------------------------------- +// ListEmbedderProfiles / SaveEmbedderProfile +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn list_is_empty_then_contains_saved_profile() { + let store = InMemoryEmbedderProfileStore::default(); + let list = ListEmbedderProfiles::new(Arc::new(store.clone())); + let save = SaveEmbedderProfile::new(Arc::new(store.clone())); + + assert!( + list.execute().await.unwrap().profiles.is_empty(), + "no profile configured ⇒ empty list (default `none` posture)" + ); + + let saved = save + .execute(save_input("local-onnx", "Local ONNX", 384)) + .await + .unwrap(); + assert_eq!(saved.profile.id, "local-onnx"); + assert_eq!(saved.profile.dimension, 384); + + let listed = list.execute().await.unwrap().profiles; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0], saved.profile); +} + +#[tokio::test] +async fn save_upserts_by_id_without_duplicating() { + let store = InMemoryEmbedderProfileStore::default(); + let save = SaveEmbedderProfile::new(Arc::new(store.clone())); + let list = ListEmbedderProfiles::new(Arc::new(store.clone())); + + save.execute(save_input("e", "before", 384)).await.unwrap(); + let updated = save.execute(save_input("e", "after", 768)).await.unwrap(); + + let listed = list.execute().await.unwrap().profiles; + assert_eq!(listed.len(), 1, "same id replaces, no duplicate"); + assert_eq!(listed[0], updated.profile); + assert_eq!(listed[0].name, "after"); + assert_eq!(listed[0].dimension, 768); +} + +#[tokio::test] +async fn save_invalid_dimension_is_invalid_and_writes_nothing() { + let store = InMemoryEmbedderProfileStore::default(); + let save = SaveEmbedderProfile::new(Arc::new(store.clone())); + let list = ListEmbedderProfiles::new(Arc::new(store.clone())); + + let err = save + .execute(save_input("bad", "Bad", 0)) + .await + .expect_err("dimension 0 must be rejected"); + assert_eq!(err.code(), "INVALID", "got {err:?}"); + + assert!( + list.execute().await.unwrap().profiles.is_empty(), + "a rejected profile must not be persisted" + ); +} + +#[tokio::test] +async fn save_empty_name_is_invalid_and_writes_nothing() { + let store = InMemoryEmbedderProfileStore::default(); + let save = SaveEmbedderProfile::new(Arc::new(store.clone())); + let list = ListEmbedderProfiles::new(Arc::new(store.clone())); + + let err = save + .execute(save_input("id", "", 384)) + .await + .expect_err("empty name must be rejected"); + assert_eq!(err.code(), "INVALID", "got {err:?}"); + + assert!( + list.execute().await.unwrap().profiles.is_empty(), + "a rejected profile must not be persisted" + ); +} + +// --------------------------------------------------------------------------- +// DeleteEmbedderProfile +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn delete_removes_existing_profile() { + let store = InMemoryEmbedderProfileStore::default(); + let save = SaveEmbedderProfile::new(Arc::new(store.clone())); + let delete = DeleteEmbedderProfile::new(Arc::new(store.clone())); + let list = ListEmbedderProfiles::new(Arc::new(store.clone())); + + save.execute(save_input("a", "A", 384)).await.unwrap(); + save.execute(save_input("b", "B", 384)).await.unwrap(); + + delete + .execute(DeleteEmbedderProfileInput { id: "a".to_owned() }) + .await + .unwrap(); + + let listed = list.execute().await.unwrap().profiles; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, "b"); +} + +#[tokio::test] +async fn delete_unknown_id_is_not_found() { + let store = InMemoryEmbedderProfileStore::default(); + let delete = DeleteEmbedderProfile::new(Arc::new(store)); + + let err = delete + .execute(DeleteEmbedderProfileInput { + id: "ghost".to_owned(), + }) + .await + .expect_err("deleting an unknown id errors"); + assert_eq!( + err.code(), + "NOT_FOUND", + "StoreError::NotFound ⇒ AppError::NotFound, got {err:?}" + ); +} + +// --------------------------------------------------------------------------- +// DescribeEmbedderEngines +// --------------------------------------------------------------------------- + +fn onnx_view() -> OnnxModelView { + OnnxModelView { + id: "multilingual-e5-small".to_owned(), + display_name: "Multilingual E5 Small".to_owned(), + dimension: 384, + approx_size_mb: 118, + recommended: true, + } +} + +#[tokio::test] +async fn describe_engines_merges_catalogue_report_and_flags() { + let report = EmbedderEnvReport { + ollama_detected: true, + onnx_cached_models: vec!["multilingual-e5-small".to_owned()], + }; + let inspector = Arc::new(FixedEnvInspector(report)); + let describe = DescribeEmbedderEngines::new( + inspector, + vec![onnx_view()], + /* vector_http_enabled */ true, + /* vector_onnx_enabled */ false, + ); + + let view = describe.execute().await.unwrap(); + + // Catalogue injected verbatim. + assert_eq!(view.recommended_onnx, vec![onnx_view()]); + // Report fields surfaced. + assert!(view.ollama_detected); + assert_eq!(view.onnx_cached_models, vec!["multilingual-e5-small"]); + // Compiled-capability flags surfaced as injected. + assert!(view.vector_http_enabled); + assert!(!view.vector_onnx_enabled); +} + +#[tokio::test] +async fn describe_engines_with_nothing_detected() { + let inspector = Arc::new(FixedEnvInspector(EmbedderEnvReport { + ollama_detected: false, + onnx_cached_models: vec![], + })); + let describe = DescribeEmbedderEngines::new(inspector, vec![], false, true); + + let view = describe.execute().await.unwrap(); + + assert!(view.recommended_onnx.is_empty()); + assert!(!view.ollama_detected); + assert!(view.onnx_cached_models.is_empty()); + assert!(!view.vector_http_enabled); + assert!(view.vector_onnx_enabled); +} diff --git a/crates/application/tests/error_codes.rs b/crates/application/tests/error_codes.rs index 321fc7f..59b73bb 100644 --- a/crates/application/tests/error_codes.rs +++ b/crates/application/tests/error_codes.rs @@ -28,7 +28,10 @@ fn fs_not_found_maps_to_not_found_other_to_filesystem() { AppError::from(FsError::PermissionDenied("/tmp/x".into())).code(), "FILESYSTEM" ); - assert_eq!(AppError::from(FsError::Io("boom".into())).code(), "FILESYSTEM"); + assert_eq!( + AppError::from(FsError::Io("boom".into())).code(), + "FILESYSTEM" + ); } #[test] @@ -67,7 +70,10 @@ fn memory_errors_map_per_variant() { assert_eq!(invalid.code(), "INVALID"); assert_eq!(invalid, AppError::Invalid("bad yaml".to_owned())); // Io → Store. - assert_eq!(AppError::from(MemoryError::Io("disk full".into())).code(), "STORE"); + assert_eq!( + AppError::from(MemoryError::Io("disk full".into())).code(), + "STORE" + ); // Serialization → Store (the catch-all arm). assert_eq!( AppError::from(MemoryError::Serialization("bad index".into())).code(), diff --git a/crates/application/tests/git_usecases.rs b/crates/application/tests/git_usecases.rs index 11e24f1..a5fc6ca 100644 --- a/crates/application/tests/git_usecases.rs +++ b/crates/application/tests/git_usecases.rs @@ -82,11 +82,7 @@ impl GitPort for FakeGit { self.record(&format!("checkout:{branch}")); Ok(()) } - async fn log( - &self, - _r: &ProjectPath, - _limit: usize, - ) -> Result, GitError> { + async fn log(&self, _r: &ProjectPath, _limit: usize) -> Result, GitError> { self.record("log"); Ok(Vec::new()) } @@ -135,7 +131,9 @@ async fn status_passes_through_to_port() { staged: true, }]); let out = GitStatus::new(Arc::new(git.clone())) - .execute(GitStatusInput { root: ROOT.to_owned() }) + .execute(GitStatusInput { + root: ROOT.to_owned(), + }) .await .unwrap(); assert_eq!(out.entries.len(), 1); @@ -229,9 +227,14 @@ async fn checkout_publishes_event() { #[tokio::test] async fn branches_returns_list_and_current() { let git = FakeGit::default(); - git.set_branches(vec!["main".to_owned(), "dev".to_owned()], Some("main".to_owned())); + git.set_branches( + vec!["main".to_owned(), "dev".to_owned()], + Some("main".to_owned()), + ); let out = GitBranches::new(Arc::new(git.clone())) - .execute(GitBranchesInput { root: ROOT.to_owned() }) + .execute(GitBranchesInput { + root: ROOT.to_owned(), + }) .await .unwrap(); assert_eq!(out.branches, vec!["main", "dev"]); diff --git a/crates/application/tests/layout_usecases.rs b/crates/application/tests/layout_usecases.rs index 507f9e0..b1a17da 100644 --- a/crates/application/tests/layout_usecases.rs +++ b/crates/application/tests/layout_usecases.rs @@ -180,8 +180,14 @@ fn lid(n: u128) -> LayoutId { } async fn register_project(store: &FakeStore, id: ProjectId) -> ProjectId { - let project = - Project::new(id, "Demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::Local, 0).unwrap(); + let project = Project::new( + id, + "Demo", + ProjectPath::new(ROOT).unwrap(), + RemoteRef::Local, + 0, + ) + .unwrap(); store.save_project(&project).await.unwrap(); id } @@ -264,7 +270,10 @@ async fn load_migrates_a_legacy_layout_json() { let fs = FakeFs::default(); let id = register_project(&store, pid(2)).await; // Only the legacy single-layout file exists. - fs.put(LEGACY_PATH, &serde_json::to_vec(&single_leaf(nid(7))).unwrap()); + fs.put( + LEGACY_PATH, + &serde_json::to_vec(&single_leaf(nid(7))).unwrap(), + ); let load = LoadLayout::new(Arc::new(store), Arc::new(fs.clone())); let out = load @@ -300,7 +309,10 @@ async fn load_defaults_to_single_empty_leaf_when_absent() { _ => panic!("expected a single default leaf"), } // Default was written through; two loads are deterministic. - assert_eq!(root_leaf_id(&read_active_tree(&fs)), root_leaf_id(&out.layout)); + assert_eq!( + root_leaf_id(&read_active_tree(&fs)), + root_leaf_id(&out.layout) + ); assert!(fs.has_dir("/home/me/proj/.ideai")); } @@ -415,7 +427,10 @@ async fn mutate_split_persists_camelcase_layout_and_announces() { let tree = active_tree_json(&env.fs); assert_eq!(tree["root"]["type"], "split", "tagged on `type`"); assert_eq!(tree["root"]["node"]["direction"], "row"); - assert_eq!(tree["root"]["node"]["children"].as_array().unwrap().len(), 2); + assert_eq!( + tree["root"]["node"]["children"].as_array().unwrap().len(), + 2 + ); assert_eq!( env.bus.events(), @@ -499,6 +514,62 @@ async fn mutate_set_session_attaches_and_clears() { .is_none()); } +#[tokio::test] +async fn mutate_attach_session_moves_session_between_cells() { + let env = mut_env(pid(13)).await; + env.mutate + .execute(MutateLayoutInput { + project_id: env.project_id, + layout_id: None, + operation: LayoutOperation::Split { + target: nid(1), + direction: Direction::Row, + new_leaf: nid(2), + container: nid(9), + }, + }) + .await + .expect("split"); + env.mutate + .execute(MutateLayoutInput { + project_id: env.project_id, + layout_id: None, + operation: LayoutOperation::SetSession { + target: nid(1), + session: Some(sid(77)), + }, + }) + .await + .expect("set initial session"); + + let out = env + .mutate + .execute(MutateLayoutInput { + project_id: env.project_id, + layout_id: None, + operation: LayoutOperation::AttachSession { + target: nid(2), + session: sid(77), + }, + }) + .await + .expect("attach existing session"); + + match &out.layout.root { + LayoutNode::Split(split) => { + match &split.children[0].node { + LayoutNode::Leaf(leaf) => assert!(leaf.session.is_none()), + _ => panic!("expected first leaf"), + } + match &split.children[1].node { + LayoutNode::Leaf(leaf) => assert_eq!(leaf.session, Some(sid(77))), + _ => panic!("expected second leaf"), + } + } + _ => panic!("expected split root"), + } +} + #[tokio::test] async fn mutate_invalid_op_errors_and_does_not_persist() { let env = mut_env(pid(14)).await; @@ -744,7 +815,9 @@ async fn create_layout_appends_and_activates_it() { .unwrap(); let list = ListLayouts::new(Arc::new(store), Arc::new(fs)) - .execute(ListLayoutsInput { project_id: pid(30) }) + .execute(ListLayoutsInput { + project_id: pid(30), + }) .await .unwrap(); assert_eq!(list.layouts.len(), 2, "Default + Backend"); @@ -774,21 +847,19 @@ async fn create_layout_rejects_empty_name() { #[tokio::test] async fn rename_layout_changes_the_name() { let (store, fs, bus) = mgmt_env(pid(32)).await; - RenameLayout::new( - Arc::new(store.clone()), - Arc::new(fs.clone()), - Arc::new(bus), - ) - .execute(RenameLayoutInput { - project_id: pid(32), - layout_id: lid(1), - name: "Main".to_owned(), - }) - .await - .unwrap(); + RenameLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus)) + .execute(RenameLayoutInput { + project_id: pid(32), + layout_id: lid(1), + name: "Main".to_owned(), + }) + .await + .unwrap(); let list = ListLayouts::new(Arc::new(store), Arc::new(fs)) - .execute(ListLayoutsInput { project_id: pid(32) }) + .execute(ListLayoutsInput { + project_id: pid(32), + }) .await .unwrap(); assert_eq!(list.layouts[0].name, "Main"); @@ -825,21 +896,23 @@ async fn delete_active_layout_reassigns_active() { .await .unwrap(); - let out = DeleteLayout::new( - Arc::new(store.clone()), - Arc::new(fs.clone()), - Arc::new(bus), - ) - .execute(DeleteLayoutInput { - project_id: pid(34), - layout_id: created.layout_id, - }) - .await - .unwrap(); - assert_eq!(out.active_id, lid(1), "active fell back to the Default layout"); + let out = DeleteLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus)) + .execute(DeleteLayoutInput { + project_id: pid(34), + layout_id: created.layout_id, + }) + .await + .unwrap(); + assert_eq!( + out.active_id, + lid(1), + "active fell back to the Default layout" + ); let list = ListLayouts::new(Arc::new(store), Arc::new(fs)) - .execute(ListLayoutsInput { project_id: pid(34) }) + .execute(ListLayoutsInput { + project_id: pid(34), + }) .await .unwrap(); assert_eq!(list.layouts.len(), 1); @@ -863,17 +936,13 @@ async fn set_active_layout_switches_and_load_follows() { .unwrap(); // Switch back to the Default layout. - SetActiveLayout::new( - Arc::new(store.clone()), - Arc::new(fs.clone()), - Arc::new(bus), - ) - .execute(SetActiveLayoutInput { - project_id: pid(35), - layout_id: lid(1), - }) - .await - .unwrap(); + SetActiveLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus)) + .execute(SetActiveLayoutInput { + project_id: pid(35), + layout_id: lid(1), + }) + .await + .unwrap(); let loaded = LoadLayout::new(Arc::new(store), Arc::new(fs)) .execute(LoadLayoutInput { diff --git a/crates/application/tests/memory_usecases.rs b/crates/application/tests/memory_usecases.rs index c1a3e6f..8fd2eac 100644 --- a/crates/application/tests/memory_usecases.rs +++ b/crates/application/tests/memory_usecases.rs @@ -17,7 +17,8 @@ use domain::{ use application::{ CreateMemory, CreateMemoryInput, DeleteMemory, DeleteMemoryInput, GetMemory, GetMemoryInput, ListMemories, ListMemoriesInput, ReadMemoryIndex, ReadMemoryIndexInput, RecallMemory, - RecallMemoryInput, ResolveMemoryLinks, ResolveMemoryLinksInput, UpdateMemory, UpdateMemoryInput, + RecallMemoryInput, ResolveMemoryLinks, ResolveMemoryLinksInput, UpdateMemory, + UpdateMemoryInput, }; // --------------------------------------------------------------------------- @@ -65,10 +66,7 @@ impl MemoryStore for FakeMemories { Ok(()) } - async fn read_index( - &self, - _root: &ProjectPath, - ) -> Result, MemoryError> { + async fn read_index(&self, _root: &ProjectPath) -> Result, MemoryError> { Ok(self .0 .lock() @@ -292,7 +290,15 @@ async fn update_revalidates_invariants_and_rejects_empty_content() { assert_eq!(err.code(), "INVALID"); // Original note untouched, no event. - assert_eq!(store.get(&root(), &slug("note-a")).await.unwrap().body.as_str(), "v1"); + assert_eq!( + store + .get(&root(), &slug("note-a")) + .await + .unwrap() + .body + .as_str(), + "v1" + ); assert!(bus.events().is_empty()); } diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index 7bd851f..5d6b881 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -17,7 +17,8 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry}; use domain::events::DomainEvent; -use domain::ids::{AgentId, ProfileId, ProjectId}; +use domain::ids::SkillId; +use domain::ids::{AgentId, NodeId, ProfileId, ProjectId}; use domain::markdown::MarkdownDoc; use domain::ports::{ AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, @@ -25,7 +26,6 @@ use domain::ports::{ PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, }; -use domain::ids::SkillId; use domain::profile::{AgentProfile, ContextInjection}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; @@ -65,7 +65,10 @@ impl FakeContexts { let me = Self::new(); { let mut inner = me.0.lock().unwrap(); - inner.manifest.entries.push(ManifestEntry::from_agent(agent)); + inner + .manifest + .entries + .push(ManifestEntry::from_agent(agent)); inner .contents .insert(agent.context_path.clone(), content.to_owned()); @@ -160,7 +163,11 @@ impl ProfileStore for FakeProfiles { struct FakeSkills; #[async_trait] impl SkillStore for FakeSkills { - async fn list(&self, _scope: SkillScope, _root: &ProjectPath) -> Result, StoreError> { + async fn list( + &self, + _scope: SkillScope, + _root: &ProjectPath, + ) -> Result, StoreError> { Ok(Vec::new()) } async fn get( @@ -206,7 +213,11 @@ struct RecordingSkills(Arc>>); #[async_trait] impl SkillStore for RecordingSkills { - async fn list(&self, _scope: SkillScope, _root: &ProjectPath) -> Result, StoreError> { + async fn list( + &self, + _scope: SkillScope, + _root: &ProjectPath, + ) -> Result, StoreError> { Ok(self.0.lock().unwrap().clone()) } async fn get( @@ -287,14 +298,19 @@ impl FileSystem for FakeFs { struct FakePty { next_id: SessionId, kills: Arc>>, + spawns: Arc>>, } impl FakePty { fn new(next_id: SessionId) -> Self { Self { next_id, kills: Arc::new(Mutex::new(Vec::new())), + spawns: Arc::new(Mutex::new(Vec::new())), } } + fn spawns(&self) -> Vec { + self.spawns.lock().unwrap().clone() + } fn kills(&self) -> Vec { self.kills.lock().unwrap().clone() } @@ -302,6 +318,7 @@ impl FakePty { #[async_trait] impl PtyPort for FakePty { async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result { + self.spawns.lock().unwrap().push(self.next_id); Ok(PtyHandle { session_id: self.next_id, }) @@ -368,6 +385,9 @@ fn aid(n: u128) -> AgentId { fn sid(n: u128) -> SessionId { SessionId::from_uuid(Uuid::from_u128(n)) } +fn nid(n: u128) -> NodeId { + NodeId::from_uuid(Uuid::from_u128(n)) +} fn project() -> Project { Project::new( @@ -430,6 +450,7 @@ fn fixture(contexts: FakeContexts) -> Fixture { Arc::new(bus.clone()), Arc::new(SeqIds::new()), Arc::new(FakeRecall), + None, )); let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); let close = Arc::new(CloseTerminal::new( @@ -496,11 +517,9 @@ async fn spawn_unknown_agent_creates_then_launches() { assert_eq!(manifest.entries[0].name, "dev-backend"); assert!(fx.sessions.session(&sid(777)).is_some()); - let launched = fx - .bus - .events() - .into_iter() - .any(|e| matches!(e, DomainEvent::AgentLaunched { session_id, .. } if session_id == sid(777))); + let launched = fx.bus.events().into_iter().any( + |e| matches!(e, DomainEvent::AgentLaunched { session_id, .. } if session_id == sid(777)), + ); assert!(launched, "AgentLaunched must be published"); } @@ -523,6 +542,57 @@ async fn spawn_known_agent_launches_without_recreating() { assert!(fx.sessions.session(&sid(777)).is_some()); } +#[tokio::test] +async fn agent_run_existing_agent_does_not_require_profile() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = fixture(FakeContexts::with_agent(&agent, "# persona")); + + fx.service + .dispatch( + &project(), + cmd(r#"{ "type":"agent.run", "targetAgent":"architect", "task":"Analyse" }"#), + ) + .await + .expect("dispatch ok"); + + assert_eq!(fx.contexts.manifest().entries.len(), 1); + assert!(fx.sessions.session(&sid(777)).is_some()); +} + +#[tokio::test] +async fn agent_run_visible_reattaches_existing_session() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = fixture(FakeContexts::with_agent(&agent, "# persona")); + let first_cell = nid(10); + let next_cell = nid(20); + + fx.service + .dispatch( + &project(), + cmd(&format!( + r#"{{ "action":"spawn_agent", "name":"architect", "profile":"claude", "visibility":"visible", "nodeId":"{first_cell}" }}"# + )), + ) + .await + .expect("first launch ok"); + + let out = fx + .service + .dispatch( + &project(), + cmd(&format!( + r#"{{ "type":"agent.run", "targetAgent":"architect", "visibility":"visible", "attachToCell":"{next_cell}" }}"# + )), + ) + .await + .expect("reattach ok"); + + assert!(out.detail.contains("attached agent architect")); + let session = fx.sessions.session(&sid(777)).expect("live session"); + assert_eq!(session.node_id, next_cell); + assert_eq!(fx.pty.spawns().len(), 1, "reattach must not respawn"); +} + #[tokio::test] async fn stop_agent_kills_the_right_session() { let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md"); diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index fa3f425..edeb2a5 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -92,9 +92,7 @@ impl AgentRuntime for StubRuntime { match self.by_command.get(&profile.command) { Some(DetectResult::Available) => Ok(true), Some(DetectResult::Missing) | None => Ok(false), - Some(DetectResult::Error) => { - Err(RuntimeError::Detection("boom".to_owned())) - } + Some(DetectResult::Error) => Err(RuntimeError::Detection("boom".to_owned())), } } @@ -163,7 +161,10 @@ async fn detect_error_degrades_to_unavailable_not_hard_failure() { .await .expect("detection error must not fail the use case"); - assert!(!out.results[0].available, "errored detection ⇒ available:false"); + assert!( + !out.results[0].available, + "errored detection ⇒ available:false" + ); } // --------------------------------------------------------------------------- @@ -248,7 +249,10 @@ async fn save_then_list_then_delete() { assert_eq!(list.execute().await.unwrap().profiles, vec![p.clone()]); - delete.execute(DeleteProfileInput { id: p.id }).await.unwrap(); + delete + .execute(DeleteProfileInput { id: p.id }) + .await + .unwrap(); assert!(list.execute().await.unwrap().profiles.is_empty()); } diff --git a/crates/application/tests/project_usecases.rs b/crates/application/tests/project_usecases.rs index c254e26..c4d23f0 100644 --- a/crates/application/tests/project_usecases.rs +++ b/crates/application/tests/project_usecases.rs @@ -22,7 +22,8 @@ use domain::{Project, ProjectId, ProjectPath, RemoteRef}; use application::{ CloseProject, CloseProjectInput, CloseTab, CloseTabInput, CreateProject, CreateProjectInput, - ListProjects, OpenProject, OpenProjectInput, + ListProjects, OpenProject, OpenProjectInput, ReadProjectContext, ReadProjectContextInput, + UpdateProjectContext, UpdateProjectContextInput, }; // --------------------------------------------------------------------------- @@ -75,11 +76,7 @@ impl FileSystem for FakeFs { } async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> { - self.0 - .lock() - .unwrap() - .dirs - .insert(path.as_str().to_owned()); + self.0.lock().unwrap().dirs.insert(path.as_str().to_owned()); Ok(()) } @@ -221,6 +218,8 @@ struct Env { bus: SpyBus, create: CreateProject, open: OpenProject, + read_context: ReadProjectContext, + update_context: UpdateProjectContext, } fn env() -> Env { @@ -238,6 +237,8 @@ fn env() -> Env { Arc::new(bus.clone()), ); let open = OpenProject::new(Arc::new(store.clone()), Arc::new(fs.clone())); + let read_context = ReadProjectContext::new(Arc::new(fs.clone())); + let update_context = UpdateProjectContext::new(Arc::new(fs.clone())); Env { store, @@ -245,6 +246,8 @@ fn env() -> Env { bus, create, open, + read_context, + update_context, } } @@ -301,6 +304,44 @@ async fn create_registers_project_in_store() { assert_eq!(stored[0].name, "Demo"); } +#[tokio::test] +async fn project_context_lives_under_ideai_and_missing_reads_empty() { + let env = env(); + let out = env.create.execute(input("Demo", "/p")).await.unwrap(); + + let missing = env + .read_context + .execute(ReadProjectContextInput { + project: out.project.clone(), + }) + .await + .unwrap(); + assert_eq!(missing.content, ""); + + env.update_context + .execute(UpdateProjectContextInput { + project: out.project.clone(), + content: "# Shared\n\nUse pnpm.".to_owned(), + }) + .await + .unwrap(); + + let bytes = env + .fs + .read_file("/p/.ideai/CONTEXT.md") + .expect("project context written under .ideai"); + assert_eq!(String::from_utf8(bytes).unwrap(), "# Shared\n\nUse pnpm."); + + let read_back = env + .read_context + .execute(ReadProjectContextInput { + project: out.project, + }) + .await + .unwrap(); + assert_eq!(read_back.content, "# Shared\n\nUse pnpm."); +} + #[tokio::test] async fn create_publishes_project_created_event() { let env = env(); @@ -543,7 +584,10 @@ async fn close_without_workspace_skips_persistence() { .await .unwrap(); - assert!(store.saved_workspace().is_none(), "no persistence when None"); + assert!( + store.saved_workspace().is_none(), + "no persistence when None" + ); } #[tokio::test] diff --git a/crates/application/tests/remote_usecases.rs b/crates/application/tests/remote_usecases.rs index 8bf0b88..5adb604 100644 --- a/crates/application/tests/remote_usecases.rs +++ b/crates/application/tests/remote_usecases.rs @@ -50,7 +50,11 @@ struct FakeHost { fs: Arc, } impl FakeHost { - fn make(kind: RemoteKind, connect_ok: bool, existing_root: Option<&str>) -> Arc { + fn make( + kind: RemoteKind, + connect_ok: bool, + existing_root: Option<&str>, + ) -> Arc { Arc::new(Self { kind, connect_ok, diff --git a/crates/application/tests/skill_usecases.rs b/crates/application/tests/skill_usecases.rs index 8b0215d..3aa0b66 100644 --- a/crates/application/tests/skill_usecases.rs +++ b/crates/application/tests/skill_usecases.rs @@ -58,7 +58,10 @@ impl SkillStore for FakeSkills { } async fn save(&self, skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> { let mut v = self.0.lock().unwrap(); - if let Some(slot) = v.iter_mut().find(|s| s.scope == skill.scope && s.id == skill.id) { + if let Some(slot) = v + .iter_mut() + .find(|s| s.scope == skill.scope && s.id == skill.id) + { *slot = skill.clone(); } else { v.push(skill.clone()); @@ -85,7 +88,10 @@ impl SkillStore for FakeSkills { struct FakeContexts(Arc>); impl FakeContexts { fn new(entries: Vec) -> Self { - Self(Arc::new(Mutex::new(AgentManifest { version: 1, entries }))) + Self(Arc::new(Mutex::new(AgentManifest { + version: 1, + entries, + }))) } fn manifest(&self) -> AgentManifest { self.0.lock().unwrap().clone() @@ -197,7 +203,11 @@ async fn create_skill_persists_in_its_scope() { assert_eq!(out.skill.scope, SkillScope::Project); assert_eq!( - store.list(SkillScope::Project, &root()).await.unwrap().len(), + store + .list(SkillScope::Project, &root()) + .await + .unwrap() + .len(), 1 ); assert!(store diff --git a/crates/application/tests/snapshot_running_agents.rs b/crates/application/tests/snapshot_running_agents.rs index bec8986..32eec13 100644 --- a/crates/application/tests/snapshot_running_agents.rs +++ b/crates/application/tests/snapshot_running_agents.rs @@ -186,8 +186,14 @@ fn agent_leaf(node: NodeId, agent: Option) -> LeafCell { } async fn register_project(store: &FakeStore, id: ProjectId) { - let project = - Project::new(id, "Demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::Local, 0).unwrap(); + let project = Project::new( + id, + "Demo", + ProjectPath::new(ROOT).unwrap(), + RemoteRef::Local, + 0, + ) + .unwrap(); store.save_project(&project).await.unwrap(); } @@ -222,11 +228,7 @@ fn was_running(fs: &FakeFs, node: NodeId) -> Option { find(&tree.root, node) } -fn make_use_case( - store: &FakeStore, - fs: &FakeFs, - live: &FakeLive, -) -> SnapshotRunningAgents { +fn make_use_case(store: &FakeStore, fs: &FakeFs, live: &FakeLive) -> SnapshotRunningAgents { SnapshotRunningAgents::new( Arc::new(store.clone()) as Arc, Arc::new(fs.clone()) as Arc, @@ -247,7 +249,11 @@ async fn live_agent_is_marked_running() { let leaf = nid(10); let agent = aid(100); - seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent)))); + seed_layouts( + &fs, + lid(1), + &LayoutTree::single(agent_leaf(leaf, Some(agent))), + ); let live = FakeLive::with_nodes(&[leaf]); let uc = make_use_case(&store, &fs, &live); @@ -271,7 +277,11 @@ async fn stopped_agent_is_marked_not_running() { let leaf = nid(10); let agent = aid(100); - seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent)))); + seed_layouts( + &fs, + lid(1), + &LayoutTree::single(agent_leaf(leaf, Some(agent))), + ); // Registry is empty: the agent has no live session. let live = FakeLive::default(); @@ -347,7 +357,11 @@ async fn snapshot_reads_liveness_before_kill() { let leaf = nid(10); let agent = aid(100); - seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent)))); + seed_layouts( + &fs, + lid(1), + &LayoutTree::single(agent_leaf(leaf, Some(agent))), + ); let live = FakeLive::with_nodes(&[leaf]); let uc = make_use_case(&store, &fs, &live); @@ -365,7 +379,11 @@ async fn snapshot_reads_liveness_before_kill() { // Re-seed and demonstrate the opposite order yields `false` — proving the // flag is sensitive to registry state at call time (hence order matters). - seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent)))); + seed_layouts( + &fs, + lid(1), + &LayoutTree::single(agent_leaf(leaf, Some(agent))), + ); uc.execute(SnapshotRunningAgentsInput { project_id: pid(1) }) .await .unwrap(); @@ -443,7 +461,10 @@ async fn duplicate_leaves_same_agent_only_live_node_is_running() { .unwrap(); assert_eq!(out.running, 1, "only the live cell counts as running"); - assert_eq!(out.stopped, 1, "the duplicate (dead) cell counts as stopped"); + assert_eq!( + out.stopped, 1, + "the duplicate (dead) cell counts as stopped" + ); assert_eq!(was_running(&fs, leaf_a), Some(true)); assert_eq!( was_running(&fs, leaf_b), diff --git a/crates/application/tests/template_usecases.rs b/crates/application/tests/template_usecases.rs index ef17eef..d76e8aa 100644 --- a/crates/application/tests/template_usecases.rs +++ b/crates/application/tests/template_usecases.rs @@ -69,7 +69,10 @@ struct FakeContexts(Arc)>>); impl FakeContexts { fn new(entries: Vec) -> Self { Self(Arc::new(Mutex::new(( - AgentManifest { version: 1, entries }, + AgentManifest { + version: 1, + entries, + }, HashMap::new(), )))) } @@ -92,13 +95,11 @@ impl FakeContexts { } #[async_trait] impl AgentContextStore for FakeContexts { - async fn read_context( - &self, - _p: &Project, - agent: &AgentId, - ) -> Result { + async fn read_context(&self, _p: &Project, agent: &AgentId) -> Result { let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?; - self.content(&md).map(MarkdownDoc::new).ok_or(StoreError::NotFound) + self.content(&md) + .map(MarkdownDoc::new) + .ok_or(StoreError::NotFound) } async fn write_context( &self, @@ -107,7 +108,11 @@ impl AgentContextStore for FakeContexts { md: &MarkdownDoc, ) -> Result<(), StoreError> { let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?; - self.0.lock().unwrap().1.insert(path, md.as_str().to_owned()); + self.0 + .lock() + .unwrap() + .1 + .insert(path, md.as_str().to_owned()); Ok(()) } async fn load_manifest(&self, _p: &Project) -> Result { @@ -186,7 +191,16 @@ fn template(id: TemplateId, name: &str, content: &str, version: u64) -> AgentTem } /// A synchronized, template-backed manifest entry synced at `synced`. fn synced_entry(agent: AgentId, md: &str, template: TemplateId, synced: u64) -> ManifestEntry { - ManifestEntry::new(agent, "A", md, pid(1), Some(template), true, Some(v(synced))).unwrap() + ManifestEntry::new( + agent, + "A", + md, + pid(1), + Some(template), + true, + Some(v(synced)), + ) + .unwrap() } // --------------------------------------------------------------------------- @@ -234,13 +248,16 @@ async fn update_template_bumps_version_and_publishes_event() { #[tokio::test] async fn update_unknown_template_is_not_found() { - let err = UpdateTemplate::new(Arc::new(FakeTemplates::default()), Arc::new(SpyBus::default())) - .execute(UpdateTemplateInput { - template_id: tid(404), - content: "x".to_owned(), - }) - .await - .unwrap_err(); + let err = UpdateTemplate::new( + Arc::new(FakeTemplates::default()), + Arc::new(SpyBus::default()), + ) + .execute(UpdateTemplateInput { + template_id: tid(404), + content: "x".to_owned(), + }) + .await + .unwrap_err(); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); } @@ -279,7 +296,10 @@ async fn create_agent_from_template_links_origin_and_seeds_context() { } ); // Context seeded with the template content under the agent's md path. - assert_eq!(contexts.content(&out.agent.context_path).as_deref(), Some("# body")); + assert_eq!( + contexts.content(&out.agent.context_path).as_deref(), + Some("# body") + ); assert_eq!(contexts.manifest().entries.len(), 1); } @@ -295,8 +315,16 @@ async fn detect_drift_flags_only_synchronized_agents_behind() { // a2: synchronized, synced at v3 → up to date, no drift. // a3: from template but NOT synchronized → ignored. // a4: scratch (no template) → ignored. - let a3 = ManifestEntry::new(aid(3), "A3", "agents/a3.md", pid(1), Some(tid(1)), false, Some(v(1))) - .unwrap(); + let a3 = ManifestEntry::new( + aid(3), + "A3", + "agents/a3.md", + pid(1), + Some(tid(1)), + false, + Some(v(1)), + ) + .unwrap(); let a4 = ManifestEntry::new(aid(4), "A4", "agents/a4.md", pid(1), None, false, None).unwrap(); let contexts = FakeContexts::new(vec![ synced_entry(aid(1), "agents/a1.md", tid(1), 1), @@ -306,14 +334,10 @@ async fn detect_drift_flags_only_synchronized_agents_behind() { ]); let bus = SpyBus::default(); - let out = DetectAgentDrift::new( - Arc::new(store), - Arc::new(contexts), - Arc::new(bus.clone()), - ) - .execute(DetectAgentDriftInput { project: project() }) - .await - .unwrap(); + let out = DetectAgentDrift::new(Arc::new(store), Arc::new(contexts), Arc::new(bus.clone())) + .execute(DetectAgentDriftInput { project: project() }) + .await + .unwrap(); assert_eq!(out.drifts.len(), 1, "only a1 drifts"); assert_eq!(out.drifts[0].agent_id, aid(1)); @@ -375,7 +399,10 @@ async fn sync_applies_to_synchronized_and_updates_version_and_context() { assert!(out.synced); assert_eq!(out.version, Some(v(3))); // Context replaced by the template content. - assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("newest body")); + assert_eq!( + contexts.content("agents/a1.md").as_deref(), + Some("newest body") + ); // Manifest synced version advanced to 3. let entry = &contexts.manifest().entries[0]; assert_eq!(entry.synced_template_version, Some(v(3))); @@ -392,9 +419,16 @@ async fn sync_applies_to_synchronized_and_updates_version_and_context() { async fn sync_ignores_non_synchronized_agent() { let store = FakeTemplates::with(vec![template(tid(1), "T", "body", 3)]); // Non-synchronized agent from a template. - let entry = - ManifestEntry::new(aid(1), "A", "agents/a1.md", pid(1), Some(tid(1)), false, Some(v(1))) - .unwrap(); + let entry = ManifestEntry::new( + aid(1), + "A", + "agents/a1.md", + pid(1), + Some(tid(1)), + false, + Some(v(1)), + ) + .unwrap(); let contexts = FakeContexts::new(vec![entry]); contexts .write_context(&project(), &aid(1), &MarkdownDoc::new("keep me")) @@ -417,5 +451,8 @@ async fn sync_ignores_non_synchronized_agent() { assert!(!out.synced, "non-synchronized agent is left untouched"); assert_eq!(out.version, None); assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("keep me")); - assert!(bus.events().is_empty(), "no sync event for an ignored agent"); + assert!( + bus.events().is_empty(), + "no sync event for an ignored agent" + ); } diff --git a/crates/application/tests/terminal_usecases.rs b/crates/application/tests/terminal_usecases.rs index 27db7e7..f53d7d1 100644 --- a/crates/application/tests/terminal_usecases.rs +++ b/crates/application/tests/terminal_usecases.rs @@ -447,16 +447,16 @@ async fn close_kills_pty_removes_session_and_returns_code() { let close = CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions)); let out = close - .execute(CloseTerminalInput { - session_id: sid(5), - }) + .execute(CloseTerminalInput { session_id: sid(5) }) .await .expect("close succeeds"); assert_eq!(out.code, Some(0)); assert!(sessions.is_empty(), "session removed from registry"); assert!( - pty.calls().iter().any(|c| matches!(c, Call::Kill { id } if *id == sid(5))), + pty.calls() + .iter() + .any(|c| matches!(c, Call::Kill { id } if *id == sid(5))), "kill called for the session" ); } @@ -477,9 +477,7 @@ async fn close_surfaces_signal_exit_as_none_code() { let close = CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions)); let out = close - .execute(CloseTerminalInput { - session_id: sid(6), - }) + .execute(CloseTerminalInput { session_id: sid(6) }) .await .unwrap(); assert_eq!(out.code, None); diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index e943a8c..2b4eeb7 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -106,6 +106,25 @@ pub enum DomainEvent { /// The project whose index was rebuilt. project_id: ProjectId, }, + /// A project's memory grew past the recall budget while no embedder is + /// configured (strategy `none`): the first moment a semantic embedder would + /// help (ARCHITECTURE §14.5.5, LOT C3). Published **at most once per session per + /// project** (and never again once the user chose "ne plus demander"); the + /// frontend surfaces a one-time, dismissible suggestion. Carries the detected + /// local environment and the compiled-in capabilities so the UI never proposes + /// a strategy this binary cannot run. + EmbedderSuggested { + /// The project whose memory crossed the budget. + project_id: ProjectId, + /// Whether an Ollama-style local embedding server was detected (best-effort). + ollama_detected: bool, + /// Ids of the recommended ONNX models already present in the local cache. + onnx_cached: Vec, + /// Whether the HTTP capability (`localServer`/`api`) is compiled in. + vector_http_enabled: bool, + /// Whether the in-process ONNX capability (`localOnnx`) is compiled in. + vector_onnx_enabled: bool, + }, /// Raw PTY output (usually routed to a dedicated channel, not this bus). PtyOutput { /// The session. diff --git a/crates/domain/src/layout.rs b/crates/domain/src/layout.rs index 1cb4fc1..f134d88 100644 --- a/crates/domain/src/layout.rs +++ b/crates/domain/src/layout.rs @@ -429,6 +429,57 @@ impl LayoutTree { Ok(tree) } + /// Attaches an existing live [`SessionId`] to `target`, moving it away from + /// any other visible leaf first. + /// + /// This models the IdeA-first visible/background contract: a live agent + /// session may keep running while detached from the grid, and opening a cell + /// on that already-running session simply re-attaches its view. The session is + /// visible in at most one cell because any previous host is cleared before the + /// target is set. + /// + /// Pure: returns a new validated tree. + /// + /// # Errors + /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree, + /// - [`LayoutError::CrossContainer`] if `target` hosts a different session. + pub fn attach_session(&self, target: NodeId, session: SessionId) -> Result { + let target_session = self.session_in_leaf(target)?; + if let Some(existing) = target_session { + if existing == session { + return Ok(self.clone()); + } + return Err(LayoutError::CrossContainer); + } + + let root = map_node(&self.root, &mut |node| { + if let LayoutNode::Leaf(leaf) = node { + if leaf.session == Some(session) && leaf.id != target { + return LayoutNode::Leaf(LeafCell { + id: leaf.id, + session: None, + agent: leaf.agent, + conversation_id: leaf.conversation_id.clone(), + agent_was_running: leaf.agent_was_running, + }); + } + if leaf.id == target { + return LayoutNode::Leaf(LeafCell { + id: leaf.id, + session: Some(session), + agent: leaf.agent, + conversation_id: leaf.conversation_id.clone(), + agent_was_running: leaf.agent_was_running, + }); + } + } + node.clone() + }); + let tree = Self { root }; + tree.validate()?; + Ok(tree) + } + /// Attaches (or, with `None`, detaches) an [`AgentId`] to the leaf `target`. /// /// Records which agent should be auto-launched in the cell (feature #3). @@ -513,11 +564,7 @@ impl LayoutTree { /// /// # Errors /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree. - pub fn set_agent_running( - &self, - target: NodeId, - running: bool, - ) -> Result { + pub fn set_agent_running(&self, target: NodeId, running: bool) -> Result { let mut found = false; let root = map_node(&self.root, &mut |node| { if let LayoutNode::Leaf(leaf) = node { @@ -580,9 +627,7 @@ impl LayoutTree { match node { LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf.session), LayoutNode::Leaf(_) => None, - LayoutNode::Split(split) => { - split.children.iter().find_map(|c| find(&c.node, id)) - } + LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)), LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)), } } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 4518943..83de6b2 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -74,9 +74,7 @@ pub use profile::{ pub use markdown::MarkdownDoc; -pub use memory::{ - Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType, -}; +pub use memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType}; pub use remote::{RemoteKind, RemoteRef, SshAuth}; @@ -91,14 +89,16 @@ pub use layout::{ pub use events::DomainEvent; -pub use orchestrator::{OrchestratorCommand, OrchestratorError, OrchestratorRequest}; +pub use orchestrator::{ + OrchestratorCommand, OrchestratorError, OrchestratorRequest, OrchestratorVisibility, +}; pub use ports::{ AgentContextStore, AgentRuntime, Clock, ContextInjectionPlan, DirEntry, Embedder, - EmbedderError, EventBus, EventStream, - ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, - IdGenerator, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, - PreparedContext, ProcessError, - ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, - RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError, TemplateStore, + EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderProfileStore, + EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, + FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, + MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PreparedContext, + ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, + RemoteError, RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError, TemplateStore, }; diff --git a/crates/domain/src/memory.rs b/crates/domain/src/memory.rs index 6a2d366..8d11b03 100644 --- a/crates/domain/src/memory.rs +++ b/crates/domain/src/memory.rs @@ -36,9 +36,7 @@ impl MemorySlug { /// outside `[a-z0-9-]`. pub fn new(raw: impl Into) -> Result { let raw = raw.into(); - let invalid = || DomainError::InvalidSlug { - value: raw.clone(), - }; + let invalid = || DomainError::InvalidSlug { value: raw.clone() }; if raw.is_empty() { return Err(invalid()); } diff --git a/crates/domain/src/orchestrator.rs b/crates/domain/src/orchestrator.rs index bc9303c..cf52346 100644 --- a/crates/domain/src/orchestrator.rs +++ b/crates/domain/src/orchestrator.rs @@ -13,12 +13,13 @@ use serde::{Deserialize, Serialize}; +use crate::ids::NodeId; use crate::skill::SkillScope; /// Errors raised while validating a raw [`OrchestratorRequest`]. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum OrchestratorError { - /// The `action` field is not one of the supported v1 actions. + /// The requested action/type is not one of the supported actions. #[error("unknown orchestrator action: {0}")] UnknownAction(String), /// A field required by the chosen action is missing or empty. @@ -37,6 +38,14 @@ pub enum OrchestratorError { /// The offending scope value. scope: String, }, + /// The `visibility` field is present but not recognised. + #[error("unknown visibility `{visibility}` for action `{action}`")] + UnknownVisibility { + /// The action being validated. + action: String, + /// The offending visibility value. + visibility: String, + }, } /// The raw, wire-level orchestrator request as deserialised from a request file. @@ -48,11 +57,22 @@ pub enum OrchestratorError { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OrchestratorRequest { - /// The requested action (`spawn_agent`, `stop_agent`, `update_agent_context`). - pub action: String, + /// Legacy v1 action (`spawn_agent`, `stop_agent`, `update_agent_context`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action: Option, + /// V2 action type (`agent.run`, `agent.stop`, `agent.update_context`, + /// `skill.create`). `type` is reserved in Rust, so the field is renamed. + #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")] + pub request_type: Option, + /// Optional requester id/name, informational at this layer. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_by: Option, /// Target agent display name (required by every v1 action). #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, + /// V2 target agent display name (`agent.run`/`agent.stop`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_agent: Option, /// Runtime profile slug/name (required by `spawn_agent`). #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, @@ -61,6 +81,21 @@ pub struct OrchestratorRequest { /// `create_skill` this carries the **new Markdown body** to write. #[serde(default, skip_serializing_if = "Option::is_none")] pub context: Option, + /// Optional task/message carried by `agent.run` / future `agent.message`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task: Option, + /// Whether a spawned agent should stay in the background or attach to a cell. + /// + /// Accepted values are `"background"` (default) and `"visible"`. A visible + /// spawn must also provide [`Self::node_id`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub visibility: Option, + /// Target layout leaf for `visibility: "visible"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_id: Option, + /// V2 visible-cell target (`attachToCell`), equivalent to `nodeId`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attach_to_cell: Option, /// Skill scope for `create_skill` (`"global"` or `"project"`, case-insensitive). /// Optional — absent/empty defaults to [`SkillScope::Project`]. Ignored by the /// other actions. @@ -79,10 +114,14 @@ pub enum OrchestratorCommand { SpawnAgent { /// Target agent display name. name: String, - /// Profile slug/name to resolve against the configured profiles. - profile: String, + /// Profile slug/name to resolve against the configured profiles. Required + /// for legacy `spawn_agent`; optional for `agent.run` when the agent + /// already exists and its manifest owns the profile id. + profile: Option, /// Optional initial `.md` body for a freshly-created agent. context: Option, + /// Desired visibility for the launched session. + visibility: OrchestratorVisibility, }, /// Stop a running agent by killing its terminal session. StopAgent { @@ -107,6 +146,18 @@ pub enum OrchestratorCommand { }, } +/// Where IdeA should place a launched agent session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OrchestratorVisibility { + /// Launch or keep running without attaching to a layout cell. + Background, + /// Launch or re-attach visibly in one layout leaf. + Visible { + /// Target layout leaf. + node_id: NodeId, + }, +} + impl OrchestratorRequest { /// Validates the raw request into a well-formed [`OrchestratorCommand`]. /// @@ -120,25 +171,44 @@ impl OrchestratorRequest { /// [`OrchestratorError::UnknownAction`] for an unsupported action; /// [`OrchestratorError::MissingField`] when a required field is absent/empty. pub fn validate(&self) -> Result { - let action = self.action.trim(); + let action = self.action_name()?; match action { "spawn_agent" => Ok(OrchestratorCommand::SpawnAgent { name: self.require_name(action)?, - profile: self.require("profile", action, self.profile.as_deref())?, + profile: Some(self.require("profile", action, self.profile.as_deref())?), + context: self.context.as_ref().filter(|c| !c.is_empty()).cloned(), + visibility: self.parse_visibility(action)?, + }), + "agent.run" => Ok(OrchestratorCommand::SpawnAgent { + name: self.require_target_agent(action)?, + profile: self + .profile + .as_ref() + .filter(|p| !p.trim().is_empty()) + .cloned(), context: self .context .as_ref() + .or(self.task.as_ref()) .filter(|c| !c.is_empty()) .cloned(), + visibility: self.parse_visibility(action)?, }), "stop_agent" => Ok(OrchestratorCommand::StopAgent { name: self.require_name(action)?, }), + "agent.stop" => Ok(OrchestratorCommand::StopAgent { + name: self.require_target_agent(action)?, + }), "update_agent_context" => Ok(OrchestratorCommand::UpdateAgentContext { name: self.require_name(action)?, context: self.require("context", action, self.context.as_deref())?, }), - "create_skill" => Ok(OrchestratorCommand::CreateSkill { + "agent.update_context" => Ok(OrchestratorCommand::UpdateAgentContext { + name: self.require_target_agent(action)?, + context: self.require("context", action, self.context.as_deref())?, + }), + "create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill { name: self.require_name(action)?, content: self.require("context", action, self.context.as_deref())?, scope: self.parse_scope(action)?, @@ -165,11 +235,51 @@ impl OrchestratorRequest { } } + /// Parses `visibility` for `spawn_agent`, defaulting to background. + fn parse_visibility(&self, action: &str) -> Result { + match self.visibility.as_deref().map(str::trim) { + None | Some("") | Some("background") => Ok(OrchestratorVisibility::Background), + Some("visible") => { + let node_id = self.node_id.or(self.attach_to_cell).ok_or_else(|| { + OrchestratorError::MissingField { + action: action.to_owned(), + field: "nodeId".to_owned(), + } + })?; + Ok(OrchestratorVisibility::Visible { node_id }) + } + Some(other) => Err(OrchestratorError::UnknownVisibility { + action: action.to_owned(), + visibility: other.to_owned(), + }), + } + } + /// Requires a non-empty `name`, shared by all actions. fn require_name(&self, action: &str) -> Result { self.require("name", action, self.name.as_deref()) } + fn require_target_agent(&self, action: &str) -> Result { + self.require( + "targetAgent", + action, + self.target_agent.as_deref().or(self.name.as_deref()), + ) + } + + fn action_name(&self) -> Result<&str, OrchestratorError> { + self.request_type + .as_deref() + .or(self.action.as_deref()) + .map(str::trim) + .filter(|a| !a.is_empty()) + .ok_or_else(|| OrchestratorError::MissingField { + action: "".to_owned(), + field: "type".to_owned(), + }) + } + /// Requires `value` to be present and non-empty (after trimming), else a /// [`OrchestratorError::MissingField`] naming `field`/`action`. fn require( @@ -205,8 +315,9 @@ mod tests { r.validate().unwrap(), OrchestratorCommand::SpawnAgent { name: "dev-backend".to_owned(), - profile: "claude-code".to_owned(), + profile: Some("claude-code".to_owned()), context: Some("agents/dev-backend.md".to_owned()), + visibility: OrchestratorVisibility::Background, } ); } @@ -218,12 +329,43 @@ mod tests { r.validate().unwrap(), OrchestratorCommand::SpawnAgent { name: "a".to_owned(), - profile: "claude-code".to_owned(), + profile: Some("claude-code".to_owned()), context: None, + visibility: OrchestratorVisibility::Background, } ); } + #[test] + fn spawn_agent_visible_requires_and_carries_node_id() { + let node = uuid::Uuid::from_u128(42); + let r = req(&format!( + r#"{{ "action": "spawn_agent", "name": "a", "profile": "claude-code", "visibility": "visible", "nodeId": "{node}" }}"# + )); + assert_eq!( + r.validate().unwrap(), + OrchestratorCommand::SpawnAgent { + name: "a".to_owned(), + profile: Some("claude-code".to_owned()), + context: None, + visibility: OrchestratorVisibility::Visible { + node_id: NodeId::from_uuid(node) + }, + } + ); + + let missing = req( + r#"{ "action": "spawn_agent", "name": "a", "profile": "claude-code", "visibility": "visible" }"#, + ); + assert_eq!( + missing.validate(), + Err(OrchestratorError::MissingField { + action: "spawn_agent".to_owned(), + field: "nodeId".to_owned(), + }) + ); + } + #[test] fn spawn_agent_missing_profile_is_rejected() { let r = req(r#"{ "action": "spawn_agent", "name": "a" }"#); @@ -236,6 +378,41 @@ mod tests { ); } + #[test] + fn agent_run_accepts_type_target_agent_and_optional_profile() { + let r = req( + r#"{ "type": "agent.run", "requestedBy": "Main", "targetAgent": "Architect", "task": "Analyse", "visibility": "background" }"#, + ); + assert_eq!( + r.validate().unwrap(), + OrchestratorCommand::SpawnAgent { + name: "Architect".to_owned(), + profile: None, + context: Some("Analyse".to_owned()), + visibility: OrchestratorVisibility::Background, + } + ); + } + + #[test] + fn agent_run_visible_accepts_attach_to_cell() { + let node = uuid::Uuid::from_u128(77); + let r = req(&format!( + r#"{{ "type": "agent.run", "targetAgent": "Architect", "profile": "claude-code", "visibility": "visible", "attachToCell": "{node}" }}"# + )); + assert_eq!( + r.validate().unwrap(), + OrchestratorCommand::SpawnAgent { + name: "Architect".to_owned(), + profile: Some("claude-code".to_owned()), + context: None, + visibility: OrchestratorVisibility::Visible { + node_id: NodeId::from_uuid(node) + }, + } + ); + } + #[test] fn stop_agent_validates() { let r = req(r#"{ "action": "stop_agent", "name": "dev-backend" }"#); @@ -261,9 +438,8 @@ mod tests { #[test] fn update_context_requires_a_body() { - let ok = req( - r##"{ "action": "update_agent_context", "name": "a", "context": "# new body" }"##, - ); + let ok = + req(r##"{ "action": "update_agent_context", "name": "a", "context": "# new body" }"##); assert_eq!( ok.validate().unwrap(), OrchestratorCommand::UpdateAgentContext { @@ -284,9 +460,8 @@ mod tests { #[test] fn create_skill_defaults_to_project_scope() { - let r = req( - r##"{ "action": "create_skill", "name": "deploy", "context": "# Deploy steps" }"##, - ); + let r = + req(r##"{ "action": "create_skill", "name": "deploy", "context": "# Deploy steps" }"##); assert_eq!( r.validate().unwrap(), OrchestratorCommand::CreateSkill { @@ -343,7 +518,9 @@ mod tests { let r = req(r#"{ "action": "delete_everything", "name": "a" }"#); assert_eq!( r.validate(), - Err(OrchestratorError::UnknownAction("delete_everything".to_owned())) + Err(OrchestratorError::UnknownAction( + "delete_everything".to_owned() + )) ); } diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 04fc555..d87511b 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -32,7 +32,7 @@ use crate::events::DomainEvent; use crate::ids::{AgentId, SessionId}; use crate::markdown::MarkdownDoc; use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug}; -use crate::profile::AgentProfile; +use crate::profile::{AgentProfile, EmbedderProfile}; use crate::project::{Project, ProjectPath}; use crate::remote::RemoteKind; use crate::skill::{Skill, SkillScope}; @@ -798,6 +798,94 @@ pub trait ProfileStore: Send + Sync { async fn mark_configured(&self) -> Result<(), StoreError>; } +/// CRUD for the declarative [`EmbedderProfile`]s in the global IDE store +/// (`embedder.json`, LOT C, §14.5.3). Mirrors [`ProfileStore`] for the embedding +/// engines: adding an engine is *data* the user configures, not code. +/// +/// There is **no** `is_configured`/`mark_configured`: the default embedder posture +/// is `none` **by absence of the file** (an empty/missing `embedder.json` ⇒ recall +/// stays the dependency-free naïve étage 1). A configured embedder takes effect at +/// the **next IDE start** (the composition root freezes the recall for the session). +#[async_trait] +pub trait EmbedderProfileStore: Send + Sync { + /// Lists all configured embedder profiles (empty when none configured). + /// + /// # Errors + /// [`StoreError`] on failure. + async fn list(&self) -> Result, StoreError>; + + /// Saves (creates or replaces by id) an embedder profile. + /// + /// # Errors + /// [`StoreError`] on failure. + async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError>; + + /// Deletes an embedder profile by id. + /// + /// # Errors + /// [`StoreError::NotFound`] if no profile carries that id. + async fn delete(&self, id: &str) -> Result<(), StoreError>; +} + +/// Best-effort snapshot of the embedding *environment*: which local engines are +/// already installed/cached on this machine. Drives the "configure an embedder?" +/// UI (C2/C3) so it can detect an existing engine before suggesting a download. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EmbedderEnvReport { + /// Whether an Ollama-style local embedding server was detected (best-effort; + /// always `false` in a build without the HTTP capability). + pub ollama_detected: bool, + /// Ids of the recommended ONNX models already present in the local cache. + pub onnx_cached_models: Vec, +} + +/// Probes the local embedding environment (installed/cached engines). **Best-effort +/// by contract**: [`inspect`](Self::inspect) never errors — an unreachable host or +/// an unreadable cache simply yields a report with the corresponding fields unset. +#[async_trait] +pub trait EmbedderEnvInspector: Send + Sync { + /// Returns a best-effort snapshot of the local embedding environment. + async fn inspect(&self) -> EmbedderEnvReport; +} + +/// The persisted user response to the "configure an embedder?" suggestion +/// (ARCHITECTURE §14.5.5, LOT C3), stored per project under +/// `.ideai/memory/.embedder-prompt.json`. Absence of the file ⇒ never answered. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EmbedderPromptDismissal { + /// "Plus tard" — re-proposable on a future session (not this one). + Later, + /// "Ne plus demander" — never publish the suggestion again (persistent). + Never, +} + +/// Persistence of the per-project embedder-suggestion state +/// (`.ideai/memory/.embedder-prompt.json`, LOT C3). A fine-grained port (Interface +/// Segregation) so the suggestion use cases depend on this alone, not on a wider +/// store. **Best-effort reads**: a missing/malformed file is *not* an error — it +/// reads as "never answered" ([`None`]). +#[async_trait] +pub trait EmbedderPromptStore: Send + Sync { + /// Reads the recorded dismissal for `root`'s project, or `None` when the user + /// has never answered (no file / unreadable / malformed). + /// + /// # Errors + /// [`StoreError`] only on an unexpected I/O failure the adapter chooses to + /// surface; a plain missing file degrades to `Ok(None)`. + async fn read(&self, root: &ProjectPath) + -> Result, StoreError>; + + /// Records the user's dismissal choice for `root`'s project. + /// + /// # Errors + /// [`StoreError`] on a persistence failure. + async fn write( + &self, + root: &ProjectPath, + dismissal: EmbedderPromptDismissal, + ) -> Result<(), StoreError>; +} + /// Reads/writes agent `.md` contexts and the project manifest, within a project. #[async_trait] pub trait AgentContextStore: Send + Sync { @@ -895,8 +983,7 @@ pub trait GitPort: Send + Sync { /// /// # Errors /// [`GitError`] on failure. - async fn log(&self, root: &ProjectPath, limit: usize) - -> Result, GitError>; + async fn log(&self, root: &ProjectPath, limit: usize) -> Result, GitError>; /// Returns the commit graph (all local branches, topological + time sort). /// diff --git a/crates/domain/tests/embedder_profile.rs b/crates/domain/tests/embedder_profile.rs index 5d3b863..8954199 100644 --- a/crates/domain/tests/embedder_profile.rs +++ b/crates/domain/tests/embedder_profile.rs @@ -105,10 +105,19 @@ fn profile_uses_camel_case_keys_and_skips_none_options() { ) .unwrap(); let json = serde_json::to_string(&p).unwrap(); - assert!(json.contains("\"apiKeyEnv\":\"MY_KEY\""), "camelCase apiKeyEnv: {json}"); - assert!(json.contains("\"strategy\":\"api\""), "camelCase strategy: {json}"); + assert!( + json.contains("\"apiKeyEnv\":\"MY_KEY\""), + "camelCase apiKeyEnv: {json}" + ); + assert!( + json.contains("\"strategy\":\"api\""), + "camelCase strategy: {json}" + ); // `model` is None ⇒ skipped from the wire form. - assert!(!json.contains("\"model\""), "None model must be skipped: {json}"); + assert!( + !json.contains("\"model\""), + "None model must be skipped: {json}" + ); } #[test] @@ -138,7 +147,10 @@ fn none_profile_has_none_strategy() { let p = EmbedderProfile::none(); assert_eq!(p.strategy, EmbedderStrategy::None); assert_eq!(p.id, "none"); - assert!(p.dimension > 0, "even the none profile keeps a non-zero dimension"); + assert!( + p.dimension > 0, + "even the none profile keeps a non-zero dimension" + ); // And it round-trips like any other profile. roundtrip(&p); } @@ -151,7 +163,9 @@ fn none_profile_has_none_strategy() { fn new_rejects_empty_id() { assert!(matches!( EmbedderProfile::new("", "Name", EmbedderStrategy::None, None, None, None, 8), - Err(DomainError::EmptyField { field: "embedder.id" }) + Err(DomainError::EmptyField { + field: "embedder.id" + }) )); } @@ -177,7 +191,8 @@ fn new_rejects_zero_dimension() { #[test] fn new_accepts_valid_minimal_profile() { - let p = EmbedderProfile::new("id", "Name", EmbedderStrategy::None, None, None, None, 1).unwrap(); + let p = + EmbedderProfile::new("id", "Name", EmbedderStrategy::None, None, None, None, 1).unwrap(); assert_eq!(p.dimension, 1); assert_eq!(p.strategy, EmbedderStrategy::None); } diff --git a/crates/domain/tests/entities.rs b/crates/domain/tests/entities.rs index 78749c1..182dc5c 100644 --- a/crates/domain/tests/entities.rs +++ b/crates/domain/tests/entities.rs @@ -5,8 +5,8 @@ mod helpers; use domain::{ Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError, - ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, SessionStrategy, - Skill, SkillId, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion, + ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, + SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion, }; use helpers::{AtomicSeqIdGenerator, FixedClock}; use uuid::Uuid; @@ -203,8 +203,17 @@ fn profile_rejects_empty_command() { #[test] fn profile_rejects_empty_name() { - let err = AgentProfile::new(profile_id(), "", "claude", vec![], ci_stdin(), None, "{r}", None) - .unwrap_err(); + let err = AgentProfile::new( + profile_id(), + "", + "claude", + vec![], + ci_stdin(), + None, + "{r}", + None, + ) + .unwrap_err(); assert!(matches!(err, DomainError::EmptyField { field } if field == "profile.name")); } @@ -331,7 +340,8 @@ fn template_version_next_increments() { #[test] fn template_rejects_empty_name() { - let err = AgentTemplate::new(template_id(), "", MarkdownDoc::new(""), profile_id()).unwrap_err(); + let err = + AgentTemplate::new(template_id(), "", MarkdownDoc::new(""), profile_id()).unwrap_err(); assert!(matches!(err, DomainError::EmptyField { .. })); } @@ -345,9 +355,16 @@ fn agent_id(n: u128) -> domain::AgentId { #[test] fn manifest_entry_synchronized_requires_template_metadata() { - let err = - ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, true, None) - .unwrap_err(); + let err = ManifestEntry::new( + agent_id(1), + "A", + "agents/a.md", + profile_id(), + None, + true, + None, + ) + .unwrap_err(); assert!(matches!(err, DomainError::InconsistentManifest { .. })); // template id present but version missing → still rejected. @@ -366,9 +383,16 @@ fn manifest_entry_synchronized_requires_template_metadata() { #[test] fn manifest_entry_rejects_empty_name() { - let err = - ManifestEntry::new(agent_id(1), " ", "agents/a.md", profile_id(), None, false, None) - .unwrap_err(); + let err = ManifestEntry::new( + agent_id(1), + " ", + "agents/a.md", + profile_id(), + None, + false, + None, + ) + .unwrap_err(); assert!(matches!(err, DomainError::EmptyField { .. })); } @@ -416,24 +440,52 @@ fn manifest_entry_agent_roundtrip() { #[test] fn manifest_rejects_duplicate_md_path() { - let e1 = - ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, false, None) - .unwrap(); - let e2 = - ManifestEntry::new(agent_id(2), "B", "agents/a.md", profile_id(), None, false, None) - .unwrap(); + let e1 = ManifestEntry::new( + agent_id(1), + "A", + "agents/a.md", + profile_id(), + None, + false, + None, + ) + .unwrap(); + let e2 = ManifestEntry::new( + agent_id(2), + "B", + "agents/a.md", + profile_id(), + None, + false, + None, + ) + .unwrap(); let err = AgentManifest::new(1, vec![e1, e2]).unwrap_err(); assert!(matches!(err, DomainError::InconsistentManifest { .. })); } #[test] fn manifest_unique_md_paths_ok() { - let e1 = - ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, false, None) - .unwrap(); - let e2 = - ManifestEntry::new(agent_id(2), "B", "agents/b.md", profile_id(), None, false, None) - .unwrap(); + let e1 = ManifestEntry::new( + agent_id(1), + "A", + "agents/a.md", + profile_id(), + None, + false, + None, + ) + .unwrap(); + let e2 = ManifestEntry::new( + agent_id(2), + "B", + "agents/b.md", + profile_id(), + None, + false, + None, + ) + .unwrap(); assert!(AgentManifest::new(1, vec![e1, e2]).is_ok()); } @@ -465,7 +517,12 @@ fn skill_rejects_empty_name() { SkillScope::Project, ) .unwrap_err(); - assert_eq!(err, DomainError::EmptyField { field: "skill.name" }); + assert_eq!( + err, + DomainError::EmptyField { + field: "skill.name" + } + ); } #[test] diff --git a/crates/domain/tests/helpers/mod.rs b/crates/domain/tests/helpers/mod.rs index 4a320ae..2e55393 100644 --- a/crates/domain/tests/helpers/mod.rs +++ b/crates/domain/tests/helpers/mod.rs @@ -72,9 +72,7 @@ impl Default for AtomicSeqIdGenerator { impl IdGenerator for AtomicSeqIdGenerator { fn new_uuid(&self) -> Uuid { - let n = self - .next - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let n = self.next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); Uuid::from_u128(u128::from(n)) } } diff --git a/crates/domain/tests/layout.rs b/crates/domain/tests/layout.rs index 4ccfc70..528ba02 100644 --- a/crates/domain/tests/layout.rs +++ b/crates/domain/tests/layout.rs @@ -3,11 +3,11 @@ mod helpers; +use domain::ids::AgentId; use domain::{ Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell, SplitContainer, WeightedChild, }; -use domain::ids::AgentId; use helpers::{node, session}; fn agent_id(n: u128) -> AgentId { @@ -463,6 +463,36 @@ fn set_session_duplicate_across_leaves_rejected() { assert_eq!(err, LayoutError::DuplicateSession(session(100))); } +#[test] +fn attach_session_attaches_background_session_to_leaf() { + let tree = two_leaves(None, None); + let out = tree.attach_session(node(2), session(100)).unwrap(); + assert_eq!(session_for(&out, node(1)), None); + assert_eq!(session_for(&out, node(2)), Some(session(100))); +} + +#[test] +fn attach_session_moves_visible_session_to_target_leaf() { + let tree = two_leaves(Some(100), None); + let out = tree.attach_session(node(2), session(100)).unwrap(); + assert_eq!(session_for(&out, node(1)), None); + assert_eq!(session_for(&out, node(2)), Some(session(100))); +} + +#[test] +fn attach_session_same_leaf_is_idempotent() { + let tree = two_leaves(Some(100), None); + let out = tree.attach_session(node(1), session(100)).unwrap(); + assert_eq!(out, tree); +} + +#[test] +fn attach_session_rejects_target_occupied_by_other_session() { + let tree = two_leaves(Some(100), Some(200)); + let err = tree.attach_session(node(2), session(100)).unwrap_err(); + assert_eq!(err, LayoutError::CrossContainer); +} + // --------------------------------------------------------------------------- // set_cell_agent (#3: per-cell agent) // --------------------------------------------------------------------------- @@ -503,7 +533,9 @@ fn set_cell_agent_is_immutable_source_unchanged() { #[test] fn set_cell_agent_missing_leaf_is_node_not_found() { let tree = single(1, None); - let err = tree.set_cell_agent(node(404), Some(agent_id(1))).unwrap_err(); + let err = tree + .set_cell_agent(node(404), Some(agent_id(1))) + .unwrap_err(); assert_eq!(err, LayoutError::NodeNotFound(node(404))); } diff --git a/crates/domain/tests/memory.rs b/crates/domain/tests/memory.rs index acd0ebd..bc1d83e 100644 --- a/crates/domain/tests/memory.rs +++ b/crates/domain/tests/memory.rs @@ -2,9 +2,7 @@ //! [`MemorySlug`] validation, `[[slug]]` link scanning, frontmatter serde //! round-trip, and the derived index entry. -use domain::{ - DomainError, MarkdownDoc, Memory, MemoryFrontmatter, MemorySlug, MemoryType, -}; +use domain::{DomainError, MarkdownDoc, Memory, MemoryFrontmatter, MemorySlug, MemoryType}; fn fm(slug: &str, description: &str, ty: MemoryType) -> MemoryFrontmatter { MemoryFrontmatter { @@ -93,7 +91,10 @@ fn memory_rejects_empty_description() { #[test] fn memory_rejects_empty_body() { - let r = Memory::new(fm("ok-slug", "hook", MemoryType::User), MarkdownDoc::new("")); + let r = Memory::new( + fm("ok-slug", "hook", MemoryType::User), + MarkdownDoc::new(""), + ); assert!(matches!(r.unwrap_err(), DomainError::EmptyField { .. })); } @@ -103,7 +104,9 @@ fn memory_rejects_empty_body() { #[test] fn outgoing_links_none() { - assert!(note("n", "plain body, no links").outgoing_links().is_empty()); + assert!(note("n", "plain body, no links") + .outgoing_links() + .is_empty()); } #[test] diff --git a/crates/domain/tests/serde_roundtrip.rs b/crates/domain/tests/serde_roundtrip.rs index 7018e57..8593a90 100644 --- a/crates/domain/tests/serde_roundtrip.rs +++ b/crates/domain/tests/serde_roundtrip.rs @@ -6,8 +6,8 @@ mod helpers; use domain::{ Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction, LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef, - SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth, TemplateVersion, - WeightedChild, + SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth, + TemplateVersion, WeightedChild, }; use helpers::{node, session}; use uuid::Uuid; @@ -72,7 +72,14 @@ fn project_uses_camel_case_and_tagged_remote() { #[test] fn remote_ssh_roundtrip_and_tags() { - let r = RemoteRef::ssh("host", 2222, "me", SshAuth::Key { path: "/k".into() }, "/srv").unwrap(); + let r = RemoteRef::ssh( + "host", + 2222, + "me", + SshAuth::Key { path: "/k".into() }, + "/srv", + ) + .unwrap(); assert_eq!(roundtrip(&r), r); let json = serde_json::to_string(&r).unwrap(); assert!(json.contains("\"kind\":\"ssh\""), "json was {json}"); @@ -118,9 +125,12 @@ fn profile_roundtrip_all_injection_variants() { #[test] fn context_injection_strategy_tag_is_camel_case() { - let json = serde_json::to_string(&ContextInjection::convention_file("CLAUDE.md").unwrap()) - .unwrap(); - assert!(json.contains("\"strategy\":\"conventionFile\""), "json was {json}"); + let json = + serde_json::to_string(&ContextInjection::convention_file("CLAUDE.md").unwrap()).unwrap(); + assert!( + json.contains("\"strategy\":\"conventionFile\""), + "json was {json}" + ); let json = serde_json::to_string(&ContextInjection::stdin()).unwrap(); assert!(json.contains("\"strategy\":\"stdin\""), "json was {json}"); } @@ -203,7 +213,10 @@ fn session_assign_flag_omitted_when_none() { let session = SessionStrategy::new(None, "--continue").unwrap(); let json = serde_json::to_string(&session).unwrap(); assert!(!json.contains("assignFlag"), "json was {json}"); - assert!(json.contains("\"resumeFlag\":\"--continue\""), "json was {json}"); + assert!( + json.contains("\"resumeFlag\":\"--continue\""), + "json was {json}" + ); assert_eq!(roundtrip(&session), session); } @@ -244,13 +257,22 @@ fn agent_roundtrip_from_template() { let json = serde_json::to_string(&a).unwrap(); assert!(json.contains("\"contextPath\""), "json was {json}"); // AgentOrigin tagged with `type`, camelCased. - assert!(json.contains("\"type\":\"fromTemplate\""), "json was {json}"); + assert!( + json.contains("\"type\":\"fromTemplate\""), + "json was {json}" + ); // Inner fields must be camelCased per ARCHITECTURE §9.1: // { "type":"fromTemplate", "templateId":"...", "syncedTemplateVersion":N }. assert!(json.contains("\"templateId\""), "json was {json}"); - assert!(json.contains("\"syncedTemplateVersion\":4"), "json was {json}"); + assert!( + json.contains("\"syncedTemplateVersion\":4"), + "json was {json}" + ); assert!(!json.contains("\"template_id\""), "json was {json}"); - assert!(!json.contains("\"synced_template_version\""), "json was {json}"); + assert!( + !json.contains("\"synced_template_version\""), + "json was {json}" + ); assert!(!json.contains("\"synced_version\""), "json was {json}"); } @@ -285,14 +307,18 @@ fn manifest_roundtrip_and_camel_case() { Some(TemplateVersion(5)), ) .unwrap(); - let e2 = ManifestEntry::new(aid(3), "Beta", "agents/b.md", profid(9), None, false, None).unwrap(); + let e2 = + ManifestEntry::new(aid(3), "Beta", "agents/b.md", profid(9), None, false, None).unwrap(); let m = AgentManifest::new(1, vec![e1, e2]).unwrap(); assert_eq!(roundtrip(&m), m); let json = serde_json::to_string(&m).unwrap(); // entries are serialized under "agents". assert!(json.contains("\"agents\":["), "json was {json}"); assert!(json.contains("\"mdPath\""), "json was {json}"); - assert!(json.contains("\"syncedTemplateVersion\":5"), "json was {json}"); + assert!( + json.contains("\"syncedTemplateVersion\":5"), + "json was {json}" + ); // Non-synchronized entry omits optional template fields (skip_serializing_if). assert!(!json.contains("\"templateId\":null"), "json was {json}"); } @@ -307,13 +333,25 @@ fn sid(n: u128) -> SkillId { #[test] fn skill_roundtrip_and_camel_case_scope() { - let s = Skill::new(sid(1), "code-review", MarkdownDoc::new("body"), SkillScope::Global).unwrap(); + let s = Skill::new( + sid(1), + "code-review", + MarkdownDoc::new("body"), + SkillScope::Global, + ) + .unwrap(); assert_eq!(roundtrip(&s), s); let json = serde_json::to_string(&s).unwrap(); assert!(json.contains("\"scope\":\"global\""), "json was {json}"); assert!(json.contains("\"contentMd\""), "json was {json}"); - let p = Skill::new(sid(2), "simplify", MarkdownDoc::new("b"), SkillScope::Project).unwrap(); + let p = Skill::new( + sid(2), + "simplify", + MarkdownDoc::new("b"), + SkillScope::Project, + ) + .unwrap(); let pj = serde_json::to_string(&p).unwrap(); assert!(pj.contains("\"scope\":\"project\""), "json was {pj}"); } @@ -412,7 +450,10 @@ fn leaf_with_agent_roundtrip_and_omits_null() { } let json = serde_json::to_string(&tree).unwrap(); // agent present when set - assert!(json.contains("\"agent\""), "agent field should be present when set; json was {json}"); + assert!( + json.contains("\"agent\""), + "agent field should be present when set; json was {json}" + ); // null variant omitted let tree_no_agent = LayoutTree::new(LayoutNode::Leaf(LeafCell { id: node(2), @@ -422,5 +463,8 @@ fn leaf_with_agent_roundtrip_and_omits_null() { agent_was_running: false, })); let json2 = serde_json::to_string(&tree_no_agent).unwrap(); - assert!(!json2.contains("\"agent\""), "agent field should be omitted when None; json was {json2}"); + assert!( + !json2.contains("\"agent\""), + "agent field should be omitted when None; json was {json2}" + ); } diff --git a/crates/domain/tests/window.rs b/crates/domain/tests/window.rs index deec4f7..b88ade9 100644 --- a/crates/domain/tests/window.rs +++ b/crates/domain/tests/window.rs @@ -3,8 +3,8 @@ //! window is dropped; an active moved tab hands activity back to a sibling. use domain::{ - LayoutNode, LayoutTree, LayoutError, LeafCell, NodeId, ProjectId, Tab, TabId, Window, - WindowId, Workspace, + LayoutError, LayoutNode, LayoutTree, LeafCell, NodeId, ProjectId, Tab, TabId, Window, WindowId, + Workspace, }; use uuid::Uuid; diff --git a/crates/infrastructure/src/fs/mod.rs b/crates/infrastructure/src/fs/mod.rs index 126935c..5197cb0 100644 --- a/crates/infrastructure/src/fs/mod.rs +++ b/crates/infrastructure/src/fs/mod.rs @@ -37,9 +37,7 @@ fn map_io(path: &RemotePath, err: &io::Error) -> FsError { #[async_trait] impl FileSystem for LocalFileSystem { async fn read(&self, path: &RemotePath) -> Result, FsError> { - fs::read(path.as_str()) - .await - .map_err(|e| map_io(path, &e)) + fs::read(path.as_str()).await.map_err(|e| map_io(path, &e)) } async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { @@ -69,11 +67,7 @@ impl FileSystem for LocalFileSystem { .map_err(|e| map_io(path, &e))?; while let Some(entry) = read_dir.next_entry().await.map_err(|e| map_io(path, &e))? { - let is_dir = entry - .file_type() - .await - .map(|t| t.is_dir()) - .unwrap_or(false); + let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false); entries.push(DirEntry { name: entry.file_name().to_string_lossy().into_owned(), is_dir, diff --git a/crates/infrastructure/src/git/mod.rs b/crates/infrastructure/src/git/mod.rs index f8fef85..9300fad 100644 --- a/crates/infrastructure/src/git/mod.rs +++ b/crates/infrastructure/src/git/mod.rs @@ -177,11 +177,7 @@ impl GitPort for Git2Repository { Ok(()) } - async fn log( - &self, - root: &ProjectPath, - limit: usize, - ) -> Result, GitError> { + async fn log(&self, root: &ProjectPath, limit: usize) -> Result, GitError> { let repo = open(root)?; let mut revwalk = repo.revwalk().map_err(op)?; // No commits yet ⇒ nothing to walk. diff --git a/crates/infrastructure/src/inspector/claude.rs b/crates/infrastructure/src/inspector/claude.rs index f74115c..23e433f 100644 --- a/crates/infrastructure/src/inspector/claude.rs +++ b/crates/infrastructure/src/inspector/claude.rs @@ -224,9 +224,7 @@ fn parse_transcript(body: &[u8]) -> ConversationDetails { let output = usage.output_tokens.unwrap_or(0); if input != 0 || output != 0 { saw_usage = true; - token_total = token_total - .saturating_add(input) - .saturating_add(output); + token_total = token_total.saturating_add(input).saturating_add(output); } } } @@ -345,10 +343,7 @@ mod tests { #[test] fn parse_returns_none_when_no_topic_or_usage() { - let body = concat!( - r#"{"role":"assistant","content":"no usage here"}"#, - "\n", - ); + let body = concat!(r#"{"role":"assistant","content":"no usage here"}"#, "\n",); let d = parse_transcript(body.as_bytes()); assert_eq!(d.last_topic, None); assert_eq!(d.token_count, None); diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index ac4ccff..468b786 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -39,13 +39,15 @@ pub use process::LocalProcessSpawner; pub use pty::PortablePtyAdapter; pub use remote::{remote_host, LocalHost}; pub use runtime::CliAgentRuntime; -pub use store::{ - embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector, - AdaptiveMemoryRecall, FsEmbedderProfileStore, FsMemoryStore, FsProfileStore, FsProjectStore, - FsSkillStore, FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, - OnnxModelInfo, StubEmbedder, VectorMemoryRecall, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, -}; -#[cfg(feature = "vector-http")] -pub use store::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT}; #[cfg(feature = "vector-onnx")] pub use store::OnnxEmbedder; +#[cfg(feature = "vector-http")] +pub use store::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT}; +pub use store::{ + embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector, + AdaptiveMemoryRecall, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, + FsMemoryStore, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, HashEmbedder, + IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall, + DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, + VECTOR_ONNX_ENABLED, +}; diff --git a/crates/infrastructure/src/orchestrator/mod.rs b/crates/infrastructure/src/orchestrator/mod.rs index 3a74ec0..a50cdd6 100644 --- a/crates/infrastructure/src/orchestrator/mod.rs +++ b/crates/infrastructure/src/orchestrator/mod.rs @@ -205,7 +205,10 @@ async fn scan_once( /// Whether `path` is a request file to process: a `.json` that is not a /// `.response.json` sibling we wrote ourselves. fn is_request_file(path: &Path) -> bool { - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default(); + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); name.ends_with(".json") && !name.ends_with(".response.json") } @@ -242,14 +245,21 @@ async fn dispatch_file( Ok(r) => r, Err(e) => return OrchestratorResponse::failure(None, format!("invalid json: {e}")), }; - let action = request.action.clone(); + let action = request + .request_type + .as_ref() + .or(request.action.as_ref()) + .cloned(); let command = match request.validate() { Ok(c) => c, - Err(e) => return OrchestratorResponse::failure(Some(action), e.to_string()), + Err(e) => return OrchestratorResponse::failure(action, e.to_string()), }; match service.dispatch(project, command).await { - Ok(out) => OrchestratorResponse::success(action, out.detail), - Err(e) => OrchestratorResponse::failure(Some(action), e.to_string()), + Ok(out) => OrchestratorResponse::success( + action.unwrap_or_else(|| "unknown".to_owned()), + out.detail, + ), + Err(e) => OrchestratorResponse::failure(action, e.to_string()), } } diff --git a/crates/infrastructure/src/pty/mod.rs b/crates/infrastructure/src/pty/mod.rs index a58d6e4..9322cbe 100644 --- a/crates/infrastructure/src/pty/mod.rs +++ b/crates/infrastructure/src/pty/mod.rs @@ -256,9 +256,7 @@ impl PtyPort for PortablePtyAdapter { live.writer .write_all(data) .map_err(|e| PtyError::Io(e.to_string()))?; - live.writer - .flush() - .map_err(|e| PtyError::Io(e.to_string())) + live.writer.flush().map_err(|e| PtyError::Io(e.to_string())) } fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError> { @@ -313,10 +311,7 @@ impl PtyPort for PortablePtyAdapter { // Ask the child to terminate, then wait for its real status. let _ = live.child.kill(); - let status = live - .child - .wait() - .map_err(|e| PtyError::Io(e.to_string()))?; + let status = live.child.wait().map_err(|e| PtyError::Io(e.to_string()))?; // Dropping master/writer closes the PTY; the reader thread then sees EOF. // Dropping the broadcast hub drops every subscriber's sender, so any diff --git a/crates/infrastructure/src/runtime/mod.rs b/crates/infrastructure/src/runtime/mod.rs index 408c7e8..b17d1a2 100644 --- a/crates/infrastructure/src/runtime/mod.rs +++ b/crates/infrastructure/src/runtime/mod.rs @@ -70,8 +70,7 @@ impl CliAgentRuntime { let args = tokens.map(str::to_owned).collect(); // Detection runs in a neutral cwd; "." is a safe relative placeholder the // spawner resolves against the process cwd. - let cwd = ProjectPath::new("/") - .map_err(|e| RuntimeError::Detection(e.to_string()))?; + let cwd = ProjectPath::new("/").map_err(|e| RuntimeError::Detection(e.to_string()))?; Ok(SpawnSpec { command, args, @@ -90,7 +89,10 @@ impl CliAgentRuntime { /// `{projectRoot}` is still substituted with the same base for backwards /// compatibility (the caller always passes the run dir now). An empty template /// defaults to the base itself. - fn resolve_cwd(profile: &AgentProfile, base: &ProjectPath) -> Result { + fn resolve_cwd( + profile: &AgentProfile, + base: &ProjectPath, + ) -> Result { let template = profile.cwd_template.trim(); if template.is_empty() { return Ok(base.clone()); @@ -117,10 +119,7 @@ impl CliAgentRuntime { /// (e.g. `-f` → `["-f", ""]`); a flag *with* `{path}` is split on /// whitespace after substitution (e.g. `--context-file {path}` → /// `["--context-file", ""]`). - fn injection_plan( - injection: &ContextInjection, - ctx: &PreparedContext, - ) -> ContextInjectionPlan { + fn injection_plan(injection: &ContextInjection, ctx: &PreparedContext) -> ContextInjectionPlan { match injection { ContextInjection::ConventionFile { target } => ContextInjectionPlan::File { target: target.clone(), diff --git a/crates/infrastructure/src/store/context.rs b/crates/infrastructure/src/store/context.rs index 5309905..10210b5 100644 --- a/crates/infrastructure/src/store/context.rs +++ b/crates/infrastructure/src/store/context.rs @@ -67,7 +67,10 @@ impl IdeaiContextStore { /// Absolute path of the manifest file for a project. fn manifest_path(project: &Project) -> RemotePath { - RemotePath::new(Self::join(&project.root, &format!("{IDEAI_DIR}/{AGENTS_FILE}"))) + RemotePath::new(Self::join( + &project.root, + &format!("{IDEAI_DIR}/{AGENTS_FILE}"), + )) } /// Absolute path of an agent context `.md` from its (`.ideai/`-relative) diff --git a/crates/infrastructure/src/store/embedder.rs b/crates/infrastructure/src/store/embedder.rs index 2c02da8..52c7102 100644 --- a/crates/infrastructure/src/store/embedder.rs +++ b/crates/infrastructure/src/store/embedder.rs @@ -23,10 +23,27 @@ //! repeatable vectors from a hashing bag-of-words, good enough for tests and for a //! trivial offline fallback). -use async_trait::async_trait; +use std::path::PathBuf; +use std::sync::Arc; -use domain::ports::{Embedder, EmbedderError}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use domain::ports::{ + Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderPromptDismissal, + EmbedderPromptStore, FileSystem, FsError, RemotePath, StoreError, +}; use domain::profile::{EmbedderProfile, EmbedderStrategy}; +use domain::project::ProjectPath; + +/// Whether this binary was compiled with the HTTP embedding capability +/// (`localServer`/`api` real engines + Ollama detection). Reported honestly to the +/// C2/C3 UI so it only offers strategies actually wired into *this* build. +pub const VECTOR_HTTP_ENABLED: bool = cfg!(feature = "vector-http"); + +/// Whether this binary was compiled with the in-process ONNX embedding capability +/// (`localOnnx` real engine via `fastembed`). Reported honestly to the C2/C3 UI. +pub const VECTOR_ONNX_ENABLED: bool = cfg!(feature = "vector-onnx"); /// Builds the concrete [`Embedder`] for a profile, or `None` when the strategy is /// [`EmbedderStrategy::None`] (recall stays naïve). @@ -50,7 +67,10 @@ pub fn embedder_from_profile( EmbedderStrategy::LocalOnnx => { #[cfg(feature = "vector-onnx")] { - Some(Box::new(OnnxEmbedder::from_profile(profile, onnx_cache_dir))) + Some(Box::new(OnnxEmbedder::from_profile( + profile, + onnx_cache_dir, + ))) } #[cfg(not(feature = "vector-onnx"))] { @@ -325,10 +345,9 @@ impl Embedder for HttpEmbedder { } } - let response = request - .send() - .await - .map_err(|e| EmbedderError::Unavailable(format!("request to `{}` failed: {e}", self.endpoint)))?; + let response = request.send().await.map_err(|e| { + EmbedderError::Unavailable(format!("request to `{}` failed: {e}", self.endpoint)) + })?; if !response.status().is_success() { return Err(EmbedderError::Unavailable(format!( @@ -464,6 +483,189 @@ pub fn onnx_model_is_cached(cache_dir: &std::path::Path, model: &str) -> bool { false } +// --------------------------------------------------------------------------- +// EmbedderEnvProbe — best-effort local-environment inspector (LOT C2/C3). +// --------------------------------------------------------------------------- + +/// Default base URL of a local Ollama-style embedding server, probed by +/// [`EmbedderEnvProbe`] to detect an already-installed local engine (the +/// Linux-spirit "detect the existing first" rule). The base (no path): the probe +/// appends `/api/tags` via [`detect_ollama`]. +pub const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434"; + +/// Concrete [`EmbedderEnvInspector`]: a **best-effort, never-failing** probe of the +/// local embedding environment. +/// +/// - `onnx_cached_models`: for each model in [`RECOMMENDED_ONNX_MODELS`], reports +/// the ids already present in `onnx_cache_dir` (pure FS, no `fastembed` +/// dependency). The blocking `read_dir` runs on a `spawn_blocking` thread so it +/// never stalls the async runtime. +/// - `ollama_detected`: `true` only when built with the `vector-http` capability +/// **and** an Ollama-style server answers at `ollama_base_url`; `false` otherwise. +/// +/// Construction is cheap and infallible; nothing touches disk or the network until +/// [`inspect`](EmbedderEnvInspector::inspect) is called. +#[derive(Debug, Clone)] +pub struct EmbedderEnvProbe { + onnx_cache_dir: PathBuf, + /// Only read under `vector-http` (by [`detect_ollama`]); retained unconditionally + /// so the constructor signature is stable across build configs. + #[cfg_attr(not(feature = "vector-http"), allow(dead_code))] + ollama_base_url: String, +} + +impl EmbedderEnvProbe { + /// Builds the probe from the ONNX cache directory (`/embedders/onnx`, + /// see [`ONNX_CACHE_SUBDIR`]) and the base URL of the local Ollama-style server. + #[must_use] + pub fn new(onnx_cache_dir: PathBuf, ollama_base_url: impl Into) -> Self { + Self { + onnx_cache_dir, + ollama_base_url: ollama_base_url.into(), + } + } +} + +#[async_trait] +impl EmbedderEnvInspector for EmbedderEnvProbe { + async fn inspect(&self) -> EmbedderEnvReport { + // Pure-FS cache scan off the runtime: `onnx_model_is_cached` calls blocking + // `read_dir`, so run the whole scan on a blocking thread. + let cache_dir = self.onnx_cache_dir.clone(); + let onnx_cached_models = tokio::task::spawn_blocking(move || { + RECOMMENDED_ONNX_MODELS + .iter() + .filter(|m| onnx_model_is_cached(&cache_dir, m.id)) + .map(|m| m.id.to_owned()) + .collect::>() + }) + .await + .unwrap_or_default(); + + #[cfg(feature = "vector-http")] + let ollama_detected = detect_ollama(&self.ollama_base_url).await; + #[cfg(not(feature = "vector-http"))] + let ollama_detected = false; + + EmbedderEnvReport { + ollama_detected, + onnx_cached_models, + } + } +} + +// --------------------------------------------------------------------------- +// FsEmbedderPromptStore — per-project embedder-suggestion state (LOT C3). +// --------------------------------------------------------------------------- + +/// `.ideai/` directory name inside a project root. +const PROMPT_IDEAI_DIR: &str = ".ideai"; +/// Memory sub-dir (the suggestion state lives beside the memory it concerns). +const PROMPT_MEMORY_DIR: &str = "memory"; +/// State file name (dot-prefixed: derived/local state, like the vector index). +const PROMPT_FILE: &str = ".embedder-prompt.json"; + +/// Wire form of the suggestion-dismissal choice (camelCase). +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum DismissalWire { + Later, + Never, +} + +impl From for DismissalWire { + fn from(d: EmbedderPromptDismissal) -> Self { + match d { + EmbedderPromptDismissal::Later => Self::Later, + EmbedderPromptDismissal::Never => Self::Never, + } + } +} + +impl From for EmbedderPromptDismissal { + fn from(w: DismissalWire) -> Self { + match w { + DismissalWire::Later => Self::Later, + DismissalWire::Never => Self::Never, + } + } +} + +/// On-disk shape of `.ideai/memory/.embedder-prompt.json`: +/// `{ "dismissed": "later" | "never" }`. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +struct PromptDoc { + dismissed: DismissalWire, +} + +/// File-backed [`EmbedderPromptStore`] (LOT C3): persists the per-project response +/// to the embedder suggestion under `.ideai/memory/.embedder-prompt.json`, through +/// the [`FileSystem`] port (so it is location-neutral, SSH/WSL unchanged). +/// +/// Reads are **best-effort**: a missing file ⇒ `Ok(None)` (never answered); a +/// malformed file also degrades to `Ok(None)` rather than surfacing an error, so a +/// hand-corrupted state never blocks the suggestion logic. +#[derive(Clone)] +pub struct FsEmbedderPromptStore { + fs: Arc, +} + +impl FsEmbedderPromptStore { + /// Builds the store from the [`FileSystem`] port. + #[must_use] + pub fn new(fs: Arc) -> Self { + Self { fs } + } + + /// `/.ideai/memory`. + fn memory_dir(root: &ProjectPath) -> String { + let base = root.as_str().trim_end_matches(['/', '\\']); + format!("{base}/{PROMPT_IDEAI_DIR}/{PROMPT_MEMORY_DIR}") + } + + /// `/.embedder-prompt.json`. + fn prompt_path(root: &ProjectPath) -> RemotePath { + RemotePath::new(format!("{}/{PROMPT_FILE}", Self::memory_dir(root))) + } +} + +#[async_trait] +impl EmbedderPromptStore for FsEmbedderPromptStore { + async fn read( + &self, + root: &ProjectPath, + ) -> Result, StoreError> { + match self.fs.read(&Self::prompt_path(root)).await { + Ok(bytes) => Ok(serde_json::from_slice::(&bytes) + .ok() + .map(|doc| doc.dismissed.into())), + // Never answered (or unreadable) ⇒ no recorded dismissal. + Err(FsError::NotFound(_)) | Err(_) => Ok(None), + } + } + + async fn write( + &self, + root: &ProjectPath, + dismissal: EmbedderPromptDismissal, + ) -> Result<(), StoreError> { + let dir = RemotePath::new(Self::memory_dir(root)); + self.fs + .create_dir_all(&dir) + .await + .map_err(|e| StoreError::Io(e.to_string()))?; + let doc = PromptDoc { + dismissed: dismissal.into(), + }; + let bytes = serde_json::to_vec_pretty(&doc) + .map_err(|e| StoreError::Serialization(e.to_string()))?; + self.fs + .write(&Self::prompt_path(root), &bytes) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } +} + #[cfg(feature = "vector-onnx")] mod onnx { use std::path::{Path, PathBuf}; @@ -546,9 +748,7 @@ mod onnx { .get_or_try_init(|| async { let cache_dir = self.cache_dir.clone(); let built = tokio::task::spawn_blocking(move || { - TextEmbedding::try_new( - InitOptions::new(model).with_cache_dir(cache_dir), - ) + TextEmbedding::try_new(InitOptions::new(model).with_cache_dir(cache_dir)) }) .await .map_err(|e| EmbedderError::Io(format!("onnx init task failed: {e}")))? diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs index 84f3062..a4ac1f7 100644 --- a/crates/infrastructure/src/store/mod.rs +++ b/crates/infrastructure/src/store/mod.rs @@ -14,14 +14,15 @@ mod template; mod vector; pub use context::IdeaiContextStore; -pub use embedder::{ - embedder_from_profile, onnx_model_is_cached, HashEmbedder, OnnxModelInfo, StubEmbedder, - ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, -}; -#[cfg(feature = "vector-http")] -pub use embedder::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT}; #[cfg(feature = "vector-onnx")] pub use embedder::OnnxEmbedder; +#[cfg(feature = "vector-http")] +pub use embedder::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT}; +pub use embedder::{ + embedder_from_profile, onnx_model_is_cached, EmbedderEnvProbe, FsEmbedderPromptStore, + HashEmbedder, OnnxModelInfo, StubEmbedder, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, + RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, +}; pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall}; pub use profile::{FsEmbedderProfileStore, FsProfileStore}; pub use project::FsProjectStore; diff --git a/crates/infrastructure/src/store/profile.rs b/crates/infrastructure/src/store/profile.rs index 685e6e4..59f454a 100644 --- a/crates/infrastructure/src/store/profile.rs +++ b/crates/infrastructure/src/store/profile.rs @@ -23,7 +23,7 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use domain::ids::ProfileId; -use domain::ports::{FileSystem, ProfileStore, RemotePath, StoreError}; +use domain::ports::{EmbedderProfileStore, FileSystem, ProfileStore, RemotePath, StoreError}; use domain::profile::{AgentProfile, EmbedderProfile}; /// File name of the profiles store inside the app-data dir. @@ -181,10 +181,11 @@ impl Default for EmbedderDoc { /// calqué on [`FsProfileStore`]: `embedder.json` in the global IDE app-data dir, /// mirroring `profiles.json`. /// -/// No domain `EmbedderProfileStore` port exists yet — embedder profiles are pure -/// configuration loaded at the composition root, so this is a concrete loader. A -/// dedicated port (parallel to [`ProfileStore`]) is an easy follow-up the day the -/// UI needs CRUD over embedder profiles. +/// Implements the domain [`EmbedderProfileStore`] port (parallel to [`ProfileStore`]). +/// The inherent `list`/`save`/`delete` methods are kept so the composition root can +/// load the configured profile *before* type-erasing to `Arc` +/// (e.g. inside a one-shot blocking runtime in `state.rs`); the trait impl simply +/// delegates to them. /// /// Cheap to clone (everything behind `Arc`). #[derive(Clone)] @@ -270,3 +271,18 @@ impl FsEmbedderProfileStore { self.write_doc(&doc).await } } + +#[async_trait] +impl EmbedderProfileStore for FsEmbedderProfileStore { + async fn list(&self) -> Result, StoreError> { + FsEmbedderProfileStore::list(self).await + } + + async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError> { + FsEmbedderProfileStore::save(self, profile).await + } + + async fn delete(&self, id: &str) -> Result<(), StoreError> { + FsEmbedderProfileStore::delete(self, id).await + } +} diff --git a/crates/infrastructure/src/store/project.rs b/crates/infrastructure/src/store/project.rs index 9f10565..49b2e8c 100644 --- a/crates/infrastructure/src/store/project.rs +++ b/crates/infrastructure/src/store/project.rs @@ -76,8 +76,9 @@ impl FsProjectStore { async fn read_registry(&self) -> Result { let path = self.path(REGISTRY_FILE); match self.fs.read(&path).await { - Ok(bytes) => serde_json::from_slice(&bytes) - .map_err(|e| StoreError::Serialization(e.to_string())), + Ok(bytes) => { + serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string())) + } Err(domain::ports::FsError::NotFound(_)) => Ok(Registry { version: REGISTRY_VERSION, projects: Vec::new(), diff --git a/crates/infrastructure/src/store/skill.rs b/crates/infrastructure/src/store/skill.rs index 36bd130..1c034a6 100644 --- a/crates/infrastructure/src/store/skill.rs +++ b/crates/infrastructure/src/store/skill.rs @@ -183,8 +183,13 @@ impl FsSkillStore { })?; let content = String::from_utf8(bytes).map_err(|e| StoreError::Serialization(e.to_string()))?; - Skill::new(entry.id, entry.name.clone(), MarkdownDoc::new(content), scope) - .map_err(|e| StoreError::Serialization(e.to_string())) + Skill::new( + entry.id, + entry.name.clone(), + MarkdownDoc::new(content), + scope, + ) + .map_err(|e| StoreError::Serialization(e.to_string())) } } diff --git a/crates/infrastructure/src/store/vector.rs b/crates/infrastructure/src/store/vector.rs index 30efe1e..26388d9 100644 --- a/crates/infrastructure/src/store/vector.rs +++ b/crates/infrastructure/src/store/vector.rs @@ -235,10 +235,7 @@ impl VectorMemoryRecall { /// the `?` plumbing compiles; in practice [`AdaptiveMemoryRecall`] guards this /// path and falls back to naïve before any such error reaches a use case. async fn recall_embed(&self, texts: &[String]) -> Result>, MemoryError> { - self.embedder - .embed(texts) - .await - .map_err(map_embedder_error) + self.embedder.embed(texts).await.map_err(map_embedder_error) } } diff --git a/crates/infrastructure/tests/agent_runtime.rs b/crates/infrastructure/tests/agent_runtime.rs index 66f7557..1397e16 100644 --- a/crates/infrastructure/tests/agent_runtime.rs +++ b/crates/infrastructure/tests/agent_runtime.rs @@ -13,13 +13,13 @@ use std::sync::Arc; use async_trait::async_trait; +use domain::ids::ProfileId; use domain::ports::{ AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError, ProcessSpawner, RuntimeError, SessionPlan, SpawnSpec, }; use domain::profile::{AgentProfile, ContextInjection, SessionStrategy}; use domain::project::ProjectPath; -use domain::ids::ProfileId; use domain::MarkdownDoc; use infrastructure::CliAgentRuntime; @@ -98,7 +98,9 @@ fn prepare_convention_file_keeps_args_and_plans_file() { ); let root = ProjectPath::new("/home/me/proj").unwrap(); - let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap(); + let spec = rt + .prepare_invocation(&p, &ctx(), &root, &SessionPlan::None) + .unwrap(); assert_eq!(spec.command, "mycli"); assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged"); @@ -124,7 +126,9 @@ fn prepare_flag_with_path_substitutes_and_splits() { ); let root = ProjectPath::new("/p").unwrap(); - let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap(); + let spec = rt + .prepare_invocation(&p, &ctx(), &root, &SessionPlan::None) + .unwrap(); // static args first, then the substituted+split flag args. assert_eq!( @@ -149,7 +153,9 @@ fn prepare_flag_without_path_is_switch_then_path() { let p = profile(ContextInjection::flag("-f").unwrap(), "{projectRoot}"); let root = ProjectPath::new("/p").unwrap(); - let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap(); + let spec = rt + .prepare_invocation(&p, &ctx(), &root, &SessionPlan::None) + .unwrap(); assert_eq!(spec.args, vec!["--static", "arg", "-f", ".ideai/agent.md"]); assert_eq!( @@ -170,9 +176,15 @@ fn prepare_stdin_keeps_args_and_plans_stdin() { let p = profile(ContextInjection::stdin(), "{projectRoot}"); let root = ProjectPath::new("/p").unwrap(); - let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap(); + let spec = rt + .prepare_invocation(&p, &ctx(), &root, &SessionPlan::None) + .unwrap(); - assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged for stdin"); + assert_eq!( + spec.args, + vec!["--static", "arg"], + "args unchanged for stdin" + ); assert_eq!(spec.context_plan, Some(ContextInjectionPlan::Stdin)); } @@ -189,7 +201,9 @@ fn prepare_env_keeps_args_and_plans_env() { ); let root = ProjectPath::new("/p").unwrap(); - let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap(); + let spec = rt + .prepare_invocation(&p, &ctx(), &root, &SessionPlan::None) + .unwrap(); assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged for env"); assert_eq!( @@ -207,13 +221,12 @@ fn prepare_env_keeps_args_and_plans_env() { #[test] fn prepare_substitutes_project_root_in_cwd_template() { let rt = pure_runtime(); - let p = profile( - ContextInjection::stdin(), - "{projectRoot}/subdir", - ); + let p = profile(ContextInjection::stdin(), "{projectRoot}/subdir"); let root = ProjectPath::new("/home/me/proj").unwrap(); - let spec = rt.prepare_invocation(&p, &ctx(), &root, &SessionPlan::None).unwrap(); + let spec = rt + .prepare_invocation(&p, &ctx(), &root, &SessionPlan::None) + .unwrap(); assert_eq!(spec.cwd.as_str(), "/home/me/proj/subdir"); } @@ -223,7 +236,9 @@ fn prepare_empty_cwd_template_defaults_to_base() { let p = profile(ContextInjection::stdin(), ""); let base = ProjectPath::new("/home/me/proj").unwrap(); - let spec = rt.prepare_invocation(&p, &ctx(), &base, &SessionPlan::None).unwrap(); + let spec = rt + .prepare_invocation(&p, &ctx(), &base, &SessionPlan::None) + .unwrap(); assert_eq!(spec.cwd.as_str(), "/home/me/proj"); } @@ -232,10 +247,15 @@ fn prepare_substitutes_agent_run_dir_in_cwd_template() { // The canonical template (ARCHITECTURE §14.1): `{agentRunDir}` resolves to the // base cwd the launcher passes — the agent's isolated run directory. let rt = pure_runtime(); - let p = profile(ContextInjection::convention_file("CLAUDE.md").unwrap(), "{agentRunDir}"); + let p = profile( + ContextInjection::convention_file("CLAUDE.md").unwrap(), + "{agentRunDir}", + ); let run_dir = ProjectPath::new("/home/me/proj/.ideai/run/agent-1").unwrap(); - let spec = rt.prepare_invocation(&p, &ctx(), &run_dir, &SessionPlan::None).unwrap(); + let spec = rt + .prepare_invocation(&p, &ctx(), &run_dir, &SessionPlan::None) + .unwrap(); assert_eq!(spec.cwd.as_str(), "/home/me/proj/.ideai/run/agent-1"); } @@ -362,8 +382,12 @@ fn session_none_profile_adds_nothing_for_any_plan() { for plan in [ SessionPlan::None, - SessionPlan::Assign { conversation_id: "id-1".to_owned() }, - SessionPlan::Resume { conversation_id: "id-1".to_owned() }, + SessionPlan::Assign { + conversation_id: "id-1".to_owned(), + }, + SessionPlan::Resume { + conversation_id: "id-1".to_owned(), + }, ] { let spec = rt.prepare_invocation(&p, &ctx(), &root(), &plan).unwrap(); assert_eq!(spec.args, vec!["--static", "arg"], "plan = {plan:?}"); @@ -383,7 +407,9 @@ fn session_assign_with_flag_emits_flag_and_id() { &p, &ctx(), &root(), - &SessionPlan::Assign { conversation_id: "abc".to_owned() }, + &SessionPlan::Assign { + conversation_id: "abc".to_owned(), + }, ) .unwrap(); @@ -403,7 +429,9 @@ fn session_resume_with_flag_emits_resume_and_id() { &p, &ctx(), &root(), - &SessionPlan::Resume { conversation_id: "abc".to_owned() }, + &SessionPlan::Resume { + conversation_id: "abc".to_owned(), + }, ) .unwrap(); @@ -421,7 +449,9 @@ fn session_resume_without_assign_flag_emits_resume_only() { &p, &ctx(), &root(), - &SessionPlan::Resume { conversation_id: "abc".to_owned() }, + &SessionPlan::Resume { + conversation_id: "abc".to_owned(), + }, ) .unwrap(); @@ -454,7 +484,9 @@ fn session_assign_without_flag_adds_nothing() { &p, &ctx(), &root(), - &SessionPlan::Assign { conversation_id: "abc".to_owned() }, + &SessionPlan::Assign { + conversation_id: "abc".to_owned(), + }, ) .unwrap(); @@ -471,8 +503,12 @@ fn no_session_profile_is_unaffected_by_any_plan() { for plan in [ SessionPlan::None, - SessionPlan::Assign { conversation_id: "x".to_owned() }, - SessionPlan::Resume { conversation_id: "x".to_owned() }, + SessionPlan::Assign { + conversation_id: "x".to_owned(), + }, + SessionPlan::Resume { + conversation_id: "x".to_owned(), + }, ] { let spec = rt.prepare_invocation(&p, &ctx(), &root(), &plan).unwrap(); assert_eq!(spec.args, vec!["--static", "arg"], "plan = {plan:?}"); @@ -500,7 +536,9 @@ fn session_args_come_after_context_injection_args() { &p, &ctx(), &root(), - &SessionPlan::Assign { conversation_id: "abc".to_owned() }, + &SessionPlan::Assign { + conversation_id: "abc".to_owned(), + }, ) .unwrap(); diff --git a/crates/infrastructure/tests/context_store.rs b/crates/infrastructure/tests/context_store.rs index 686b210..9c2061d 100644 --- a/crates/infrastructure/tests/context_store.rs +++ b/crates/infrastructure/tests/context_store.rs @@ -140,8 +140,14 @@ async fn manifest_file_is_camelcase_json_under_ideai() { .expect("top-level `agents` array"); assert_eq!(agents.len(), 1); let entry = &agents[0]; - assert_eq!(entry.get("mdPath").and_then(|v| v.as_str()), Some("agents/backend.md")); + assert_eq!( + entry.get("mdPath").and_then(|v| v.as_str()), + Some("agents/backend.md") + ); assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("Backend")); - assert!(entry.get("profileId").is_some(), "camelCase profileId present"); + assert!( + entry.get("profileId").is_some(), + "camelCase profileId present" + ); assert!(entry.get("md_path").is_none(), "no snake_case leak"); } diff --git a/crates/infrastructure/tests/embedder_config.rs b/crates/infrastructure/tests/embedder_config.rs new file mode 100644 index 0000000..4a8e542 --- /dev/null +++ b/crates/infrastructure/tests/embedder_config.rs @@ -0,0 +1,236 @@ +//! L5 integration tests for the LOT C2 embedder-config adapters: +//! - [`FsEmbedderProfileStore`] driven through the [`EmbedderProfileStore`] port +//! against a real [`LocalFileSystem`] + temp dir (round-trip, `embedder.json` +//! persistence, delete-absent ⇒ `NotFound`), +//! - [`EmbedderEnvProbe::inspect`] without the HTTP feature (`ollama_detected` +//! always `false`) and reflecting a prepared ONNX cache. +//! +//! No network is required. The temp-dir convention mirrors `tests/project_store.rs` +//! (`std::env::temp_dir()` + a UUID, self-cleaning on drop) — no new dependency. + +use std::path::PathBuf; +use std::sync::Arc; + +use domain::ports::{ + EmbedderEnvInspector, EmbedderProfileStore, FileSystem, RemotePath, StoreError, +}; +use domain::profile::{EmbedderProfile, EmbedderStrategy}; +use infrastructure::{ + EmbedderEnvProbe, FsEmbedderProfileStore, LocalFileSystem, ONNX_CACHE_SUBDIR, + RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, +}; +use uuid::Uuid; + +/// A unique scratch directory under the OS temp dir, cleaned up on drop. +struct TempDir(PathBuf); +impl TempDir { + fn new() -> Self { + let p = std::env::temp_dir().join(format!("idea-l5-embedder-{}", Uuid::new_v4())); + std::fs::create_dir_all(&p).unwrap(); + Self(p) + } + fn app_data_dir(&self) -> String { + self.0.to_string_lossy().into_owned() + } + fn path(&self) -> &std::path::Path { + &self.0 + } + fn child(&self, name: &str) -> RemotePath { + RemotePath::new(self.0.join(name).to_string_lossy().into_owned()) + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn store(tmp: &TempDir) -> FsEmbedderProfileStore { + let fs: Arc = Arc::new(LocalFileSystem::new()); + FsEmbedderProfileStore::new(fs, tmp.app_data_dir()) +} + +fn sample(id: &str, name: &str, dimension: usize) -> EmbedderProfile { + EmbedderProfile::new( + id, + name, + EmbedderStrategy::LocalOnnx, + Some("multilingual-e5-small".to_owned()), + None, + None, + dimension, + ) + .unwrap() +} + +// --------------------------------------------------------------------------- +// FsEmbedderProfileStore via the EmbedderProfileStore port +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn save_then_list_roundtrips() { + let tmp = TempDir::new(); + let store: &dyn EmbedderProfileStore = &store(&tmp); + + assert!( + store.list().await.unwrap().is_empty(), + "no embedder.json yet ⇒ empty list" + ); + + let p = sample("local-onnx", "Local ONNX", 384); + store.save(&p).await.unwrap(); + + assert_eq!(store.list().await.unwrap(), vec![p]); +} + +#[tokio::test] +async fn save_upserts_by_id_without_duplicating() { + let tmp = TempDir::new(); + let store: &dyn EmbedderProfileStore = &store(&tmp); + + store.save(&sample("e", "before", 384)).await.unwrap(); + let updated = sample("e", "after", 768); + store.save(&updated).await.unwrap(); + + let listed = store.list().await.unwrap(); + assert_eq!(listed.len(), 1, "upsert must not duplicate by id"); + assert_eq!(listed[0], updated); +} + +#[tokio::test] +async fn delete_removes_profile() { + let tmp = TempDir::new(); + let store: &dyn EmbedderProfileStore = &store(&tmp); + + store.save(&sample("a", "A", 384)).await.unwrap(); + store.save(&sample("b", "B", 384)).await.unwrap(); + + store.delete("a").await.unwrap(); + + let listed = store.list().await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, "b"); +} + +#[tokio::test] +async fn delete_unknown_is_not_found() { + let tmp = TempDir::new(); + let store: &dyn EmbedderProfileStore = &store(&tmp); + store.save(&sample("a", "A", 384)).await.unwrap(); + + let err = store + .delete("ghost") + .await + .expect_err("deleting unknown id fails"); + assert!(matches!(err, StoreError::NotFound), "got {err:?}"); +} + +#[tokio::test] +async fn embedder_file_is_camelcase_versioned() { + let tmp = TempDir::new(); + let store: &dyn EmbedderProfileStore = &store(&tmp); + + store + .save( + &EmbedderProfile::new( + "api-openai", + "OpenAI", + EmbedderStrategy::Api, + Some("text-embedding-3-small".to_owned()), + Some("https://api.openai.com/v1/embeddings".to_owned()), + Some("OPENAI_API_KEY".to_owned()), + 1536, + ) + .unwrap(), + ) + .await + .unwrap(); + + let fs = LocalFileSystem::new(); + let bytes = fs.read(&tmp.child("embedder.json")).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + + assert_eq!(json["version"], 1); + let profiles = json + .get("profiles") + .and_then(|v| v.as_array()) + .expect("top-level `profiles` array"); + assert_eq!(profiles.len(), 1); + let entry = &profiles[0]; + assert_eq!(entry["id"], "api-openai"); + assert_eq!(entry["strategy"], "api"); + // camelCase field, never the secret itself. + assert_eq!(entry["apiKeyEnv"], "OPENAI_API_KEY"); + assert!(entry.get("api_key_env").is_none(), "no snake_case leak"); + assert_eq!(entry["dimension"], 1536); +} + +// --------------------------------------------------------------------------- +// EmbedderEnvProbe::inspect — pure-FS cache scan, no network +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn probe_empty_cache_reports_nothing() { + let tmp = TempDir::new(); + // An ONNX cache subdir that exists but is empty ⇒ no cached models. + let cache = tmp.path().join(ONNX_CACHE_SUBDIR); + std::fs::create_dir_all(&cache).unwrap(); + + let probe = EmbedderEnvProbe::new(cache, "http://127.0.0.1:1"); // unreachable host + let report = probe.inspect().await; + + assert!(report.onnx_cached_models.is_empty(), "empty cache ⇒ none"); + assert!( + !report.ollama_detected, + "without vector-http (or with an unreachable host) ⇒ never detected" + ); +} + +#[tokio::test] +async fn probe_missing_cache_dir_is_best_effort_empty() { + // A cache dir that does not exist must not panic — best-effort ⇒ empty. + let cache = std::env::temp_dir().join(format!("idea-no-such-cache-{}", Uuid::new_v4())); + assert!(!cache.exists()); + + let probe = EmbedderEnvProbe::new(cache, "http://127.0.0.1:1"); + let report = probe.inspect().await; + + assert!(report.onnx_cached_models.is_empty()); + assert!(!report.ollama_detected); +} + +#[tokio::test] +async fn probe_detects_prepared_onnx_cache() { + let tmp = TempDir::new(); + let cache = tmp.path().join(ONNX_CACHE_SUBDIR); + std::fs::create_dir_all(&cache).unwrap(); + + // Recreate the hf-hub-style cache layout for the recommended model: + // a non-empty subdirectory whose name contains the model token + // (`onnx_model_is_cached`: needle = id with `_`/`/`→`-`, lowercased). + let model = RECOMMENDED_ONNX_MODELS + .iter() + .find(|m| m.recommended) + .expect("a recommended model exists"); + let model_dir = cache.join(format!("models--intfloat--{}", model.id)); + std::fs::create_dir_all(&model_dir).unwrap(); + // Non-empty: the probe treats an empty dir as "not present". + std::fs::write(model_dir.join("model.onnx"), b"stub").unwrap(); + + let probe = EmbedderEnvProbe::new(cache, "http://127.0.0.1:1"); + let report = probe.inspect().await; + + assert_eq!( + report.onnx_cached_models, + vec![model.id.to_owned()], + "the prepared cached model must be detected" + ); + assert!(!report.ollama_detected, "no real Ollama required"); +} + +#[test] +fn compiled_capability_flags_match_features() { + // The consts must mirror the build's features exactly (honest reporting to the UI). + assert_eq!(VECTOR_HTTP_ENABLED, cfg!(feature = "vector-http")); + assert_eq!(VECTOR_ONNX_ENABLED, cfg!(feature = "vector-onnx")); +} diff --git a/crates/infrastructure/tests/git_repository.rs b/crates/infrastructure/tests/git_repository.rs index a4eb16b..ab5e49f 100644 --- a/crates/infrastructure/tests/git_repository.rs +++ b/crates/infrastructure/tests/git_repository.rs @@ -4,8 +4,8 @@ use std::path::PathBuf; -use domain::ports::GitPort; use domain::ports::GitError; +use domain::ports::GitPort; use domain::project::ProjectPath; use infrastructure::Git2Repository; use uuid::Uuid; @@ -99,7 +99,12 @@ async fn unstage_after_first_commit_resets_index() { // Modify + stage, then unstage → the change is no longer staged. tmp.write("a.txt", "v2"); git.stage(&root, "a.txt").await.unwrap(); - assert!(git.status(&root).await.unwrap().iter().any(|s| s.path == "a.txt" && s.staged)); + assert!(git + .status(&root) + .await + .unwrap() + .iter() + .any(|s| s.path == "a.txt" && s.staged)); git.unstage(&root, "a.txt").await.unwrap(); let st = git.status(&root).await.unwrap(); diff --git a/crates/infrastructure/tests/http_embedder.rs b/crates/infrastructure/tests/http_embedder.rs index 582f3e6..b1c2927 100644 --- a/crates/infrastructure/tests/http_embedder.rs +++ b/crates/infrastructure/tests/http_embedder.rs @@ -57,9 +57,7 @@ async fn drain_request(stream: &mut tokio::net::TcpStream) { } fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { - haystack - .windows(needle.len()) - .position(|w| w == needle) + haystack.windows(needle.len()).position(|w| w == needle) } /// Builds an HTTP `200 OK` response with a JSON body and the right `Content-Length`. @@ -98,17 +96,22 @@ async fn http_embedder_parses_vectors_and_restores_input_order() { .embed(&["first".to_string(), "second".to_string()]) .await .expect("embed must succeed"); - assert_eq!(out, vec![vec![1.0, 0.0], vec![0.0, 1.0]], "input order restored by index"); + assert_eq!( + out, + vec![vec![1.0, 0.0], vec![0.0, 1.0]], + "input order restored by index" + ); } #[tokio::test] async fn http_embedder_empty_input_short_circuits_without_network() { // A closed/never-bound endpoint: no request must be made for empty input. - let embedder = HttpEmbedder::from_profile(&local_server_profile( - "http://127.0.0.1:1/v1/embeddings", - 4, - )); - let out = embedder.embed(&[]).await.expect("empty input ⇒ empty output, no I/O"); + let embedder = + HttpEmbedder::from_profile(&local_server_profile("http://127.0.0.1:1/v1/embeddings", 4)); + let out = embedder + .embed(&[]) + .await + .expect("empty input ⇒ empty output, no I/O"); assert!(out.is_empty()); } @@ -124,10 +127,8 @@ async fn http_embedder_non_2xx_is_unavailable() { #[tokio::test] async fn http_embedder_unreachable_host_is_unavailable() { // Port 1 on loopback: nothing listens ⇒ connection refused ⇒ Unavailable. - let embedder = HttpEmbedder::from_profile(&local_server_profile( - "http://127.0.0.1:1/v1/embeddings", - 2, - )); + let embedder = + HttpEmbedder::from_profile(&local_server_profile("http://127.0.0.1:1/v1/embeddings", 2)); let err = embedder.embed(&["x".to_string()]).await.unwrap_err(); assert!(matches!(err, EmbedderError::Unavailable(_)), "got {err:?}"); } diff --git a/crates/infrastructure/tests/memory_recall.rs b/crates/infrastructure/tests/memory_recall.rs index 12e9580..8195aea 100644 --- a/crates/infrastructure/tests/memory_recall.rs +++ b/crates/infrastructure/tests/memory_recall.rs @@ -22,7 +22,9 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::markdown::MarkdownDoc; -use domain::memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType}; +use domain::memory::{ + Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType, +}; use domain::ports::{ DirEntry, FileSystem, FsError, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, RemotePath, }; @@ -114,7 +116,10 @@ fn query(budget: usize) -> MemoryQuery { } fn slugs(entries: &[MemoryIndexEntry]) -> Vec { - entries.iter().map(|e| e.slug.as_str().to_string()).collect() + entries + .iter() + .map(|e| e.slug.as_str().to_string()) + .collect() } // --------------------------------------------------------------------------- @@ -161,10 +166,7 @@ impl MemoryStore for CountingStore { async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> { panic!("recall must not call delete") } - async fn read_index( - &self, - _root: &ProjectPath, - ) -> Result, MemoryError> { + async fn read_index(&self, _root: &ProjectPath) -> Result, MemoryError> { self.read_index_calls.fetch_add(1, Ordering::SeqCst); Ok(Vec::new()) } @@ -224,17 +226,27 @@ async fn ample_budget_returns_all_entries_in_index_order() { #[tokio::test] async fn intermediate_budget_truncates_at_exact_boundary() { - let notes = [note("aaaa", "bbbb"), note("cccc", "dddd"), note("eeee", "ffff")]; + let notes = [ + note("aaaa", "bbbb"), + note("cccc", "dddd"), + note("eeee", "ffff"), + ]; let recall = recall_over(¬es).await; // Budget 1 < 2 ⇒ first entry already exceeds ⇒ none. assert!(recall.recall(&root(), &query(1)).await.unwrap().is_empty()); // Budget 2 == cost(1) ⇒ exactly the first entry; second (would reach 4) dropped. - assert_eq!(slugs(&recall.recall(&root(), &query(2)).await.unwrap()), vec!["aaaa"]); + assert_eq!( + slugs(&recall.recall(&root(), &query(2)).await.unwrap()), + vec!["aaaa"] + ); // Budget 3 < 4 ⇒ still only the first. - assert_eq!(slugs(&recall.recall(&root(), &query(3)).await.unwrap()), vec!["aaaa"]); + assert_eq!( + slugs(&recall.recall(&root(), &query(3)).await.unwrap()), + vec!["aaaa"] + ); // Budget 4 == cost(1)+cost(2) ⇒ first two; third (would reach 6) dropped. assert_eq!( @@ -266,9 +278,15 @@ async fn entry_cost_rounds_up_via_ceil() { let recall = recall_over(&[note("ab", "c"), note("de", "fgh")]).await; // Budget 1 covers only the 1-token first entry; the 2-token second is dropped. - assert_eq!(slugs(&recall.recall(&root(), &query(1)).await.unwrap()), vec!["ab"]); + assert_eq!( + slugs(&recall.recall(&root(), &query(1)).await.unwrap()), + vec!["ab"] + ); // Budget 2 still cannot fit the second on top of the first (1 + 2 = 3 > 2). - assert_eq!(slugs(&recall.recall(&root(), &query(2)).await.unwrap()), vec!["ab"]); + assert_eq!( + slugs(&recall.recall(&root(), &query(2)).await.unwrap()), + vec!["ab"] + ); // Budget 3 fits both (1 + 2 == 3). assert_eq!( slugs(&recall.recall(&root(), &query(3)).await.unwrap()), diff --git a/crates/infrastructure/tests/memory_store.rs b/crates/infrastructure/tests/memory_store.rs index bbbbfbe..d86dbb0 100644 --- a/crates/infrastructure/tests/memory_store.rs +++ b/crates/infrastructure/tests/memory_store.rs @@ -106,12 +106,17 @@ async fn save_writes_md_and_index_line() { let fs = MemFs::arc(); let store = FsMemoryStore::new(fs.clone()); store - .save(&root(), ¬e("alpha", "the hook", MemoryType::Project, "# Body")) + .save( + &root(), + ¬e("alpha", "the hook", MemoryType::Project, "# Body"), + ) .await .unwrap(); // The note `.md` exists with frontmatter + body. - let md = fs.raw("/proj/.ideai/memory/alpha.md").expect("note written"); + let md = fs + .raw("/proj/.ideai/memory/alpha.md") + .expect("note written"); assert!(md.starts_with("---\n")); assert!(md.contains("name: alpha")); assert!(md.contains("description: the hook")); @@ -119,7 +124,9 @@ async fn save_writes_md_and_index_line() { assert!(md.contains("# Body")); // The index has the header + the line. - let index = fs.raw("/proj/.ideai/memory/MEMORY.md").expect("index written"); + let index = fs + .raw("/proj/.ideai/memory/MEMORY.md") + .expect("index written"); assert!(index.starts_with("# Memory Index")); assert!(index.contains("- [alpha](alpha.md) — the hook")); } @@ -272,7 +279,10 @@ async fn resolve_links_ignores_broken_links() { async fn resolve_links_missing_source_is_not_found() { let store = FsMemoryStore::new(MemFs::arc()); assert!(matches!( - store.resolve_links(&root(), &slug("nope")).await.unwrap_err(), + store + .resolve_links(&root(), &slug("nope")) + .await + .unwrap_err(), MemoryError::NotFound )); } diff --git a/crates/infrastructure/tests/onnx_embedder.rs b/crates/infrastructure/tests/onnx_embedder.rs index 80903da..5312ba7 100644 --- a/crates/infrastructure/tests/onnx_embedder.rs +++ b/crates/infrastructure/tests/onnx_embedder.rs @@ -71,7 +71,8 @@ async fn onnx_unknown_model_is_unsupported() { // *before* any `try_new`/download — so this is safe without a network. We point // the cache at a path that is never created to prove no download is attempted. let cache = std::path::Path::new("/idea-onnx-cache-that-never-exists"); - let embedder = OnnxEmbedder::from_profile(&onnx_profile(Some("definitely-unknown"), 384), cache); + let embedder = + OnnxEmbedder::from_profile(&onnx_profile(Some("definitely-unknown"), 384), cache); let err = embedder .embed(&["x".to_string()]) @@ -113,7 +114,8 @@ async fn onnx_construction_is_cheap() { // and an unknown one (construction never fails for either). let cache = std::path::Path::new("/idea-onnx-cache-that-never-exists-3"); - let known = OnnxEmbedder::from_profile(&onnx_profile(Some("multilingual-e5-small"), 384), cache); + let known = + OnnxEmbedder::from_profile(&onnx_profile(Some("multilingual-e5-small"), 384), cache); assert_eq!(known.id(), "test-onnx"); assert_eq!(known.dimension(), 384); @@ -139,14 +141,16 @@ async fn embedder_from_profile_localonnx_is_real_engine_not_unsupported_stub() { // whereas the StubEmbedder would return Unsupported even for an empty batch. let cache = std::path::Path::new("/idea-onnx-cache-that-never-exists-4"); let known = onnx_profile(Some("multilingual-e5-small"), 384); - let embedder = - embedder_from_profile(&known, cache).expect("localOnnx must yield an embedder"); + let embedder = embedder_from_profile(&known, cache).expect("localOnnx must yield an embedder"); assert_eq!(embedder.dimension(), 384); let out = embedder .embed(&[]) .await .expect("real engine short-circuits empty input to Ok(vec![])"); - assert!(out.is_empty(), "empty batch ⇒ empty result on the real engine"); + assert!( + out.is_empty(), + "empty batch ⇒ empty result on the real engine" + ); // And an *unknown* model still surfaces Unsupported (deferred resolution), so the // mapping is the real engine in both cases (the stub would also say Unsupported, @@ -187,11 +191,17 @@ async fn onnx_embeds_e5_small_real_model() { for v in &out { assert_eq!(v.len(), 384, "e5-small produces 384-dim vectors"); let norm = v.iter().map(|x| x * x).sum::().sqrt(); - assert!((norm - 1.0).abs() < 1e-2, "fastembed L2-normalises; norm ≈ 1, got {norm}"); + assert!( + (norm - 1.0).abs() < 1e-2, + "fastembed L2-normalises; norm ≈ 1, got {norm}" + ); } // Deterministic across calls (model already cached after the first call). - let again = embedder.embed(&texts).await.expect("second embedding must succeed"); + let again = embedder + .embed(&texts) + .await + .expect("second embedding must succeed"); assert_eq!(out, again, "embedding must be deterministic across calls"); } diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index f012605..d3b2814 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -16,6 +16,7 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::agent::{AgentManifest, ManifestEntry}; use domain::events::DomainEvent; +use domain::ids::SkillId; use domain::ids::{AgentId, ProfileId, ProjectId}; use domain::markdown::MarkdownDoc; use domain::ports::{ @@ -24,7 +25,6 @@ use domain::ports::{ PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, }; -use domain::ids::SkillId; use domain::profile::{AgentProfile, ContextInjection}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; @@ -94,7 +94,13 @@ impl AgentContextStore for FakeContexts { ) -> Result { let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?; Ok(MarkdownDoc::new( - self.0.lock().unwrap().contents.get(&md).cloned().unwrap_or_default(), + self.0 + .lock() + .unwrap() + .contents + .get(&md) + .cloned() + .unwrap_or_default(), )) } async fn write_context( @@ -150,7 +156,11 @@ impl ProfileStore for FakeProfiles { struct FakeSkills; #[async_trait] impl SkillStore for FakeSkills { - async fn list(&self, _scope: SkillScope, _root: &ProjectPath) -> Result, StoreError> { + async fn list( + &self, + _scope: SkillScope, + _root: &ProjectPath, + ) -> Result, StoreError> { Ok(Vec::new()) } async fn get( @@ -321,6 +331,7 @@ fn build_service(contexts: FakeContexts) -> Arc { bus.clone(), Arc::new(SeqIds(Mutex::new(1))), Arc::new(FakeRecall), + None, )); let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions))); @@ -342,7 +353,11 @@ fn build_service(contexts: FakeContexts) -> Arc { } fn read_response(request_path: &std::path::Path) -> OrchestratorResponse { - let mut name = request_path.file_name().unwrap().to_string_lossy().into_owned(); + let mut name = request_path + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(); name.push_str(".response.json"); let response_path = request_path.with_file_name(name); let bytes = std::fs::read(&response_path).expect("response file written"); @@ -375,6 +390,30 @@ async fn valid_spawn_request_succeeds_and_is_consumed() { assert_eq!(contexts.entries()[0].name, "dev-backend"); } +#[tokio::test] +async fn valid_agent_run_request_succeeds_and_reports_type() { + let tmp = TempDir::new(); + let contexts = FakeContexts::new(); + let service = build_service(contexts.clone()); + + let req = tmp.0.join("req-agent-run.json"); + std::fs::write( + &req, + br#"{ "type": "agent.run", "requestedBy": "Main", "targetAgent": "architect", "profile": "claude-code", "task": "Analyse", "visibility": "background" }"#, + ) + .unwrap(); + + let response = process_request_file(&req, &project(), &service).await; + + assert!(response.ok, "expected ok, got {response:?}"); + assert_eq!(response.action.as_deref(), Some("agent.run")); + assert!(!req.exists(), "request file must be removed"); + let on_disk = read_response(&req); + assert!(on_disk.ok); + assert_eq!(contexts.entries().len(), 1); + assert_eq!(contexts.entries()[0].name, "architect"); +} + #[tokio::test] async fn valid_create_skill_request_succeeds_and_is_consumed() { let tmp = TempDir::new(); @@ -408,7 +447,11 @@ async fn invalid_json_request_yields_error_response() { assert!(!response.ok); assert!( - response.error.as_deref().unwrap_or_default().contains("invalid json"), + response + .error + .as_deref() + .unwrap_or_default() + .contains("invalid json"), "got {response:?}" ); assert!(!req.exists(), "poisoned request must still be removed"); @@ -427,5 +470,9 @@ async fn unknown_action_yields_error_response() { assert!(!response.ok); assert_eq!(response.action.as_deref(), Some("explode")); - assert!(response.error.as_deref().unwrap_or_default().contains("unknown orchestrator action")); + assert!(response + .error + .as_deref() + .unwrap_or_default() + .contains("unknown orchestrator action")); } diff --git a/crates/infrastructure/tests/project_store.rs b/crates/infrastructure/tests/project_store.rs index c331b58..c21899d 100644 --- a/crates/infrastructure/tests/project_store.rs +++ b/crates/infrastructure/tests/project_store.rs @@ -134,6 +134,9 @@ async fn registry_file_is_camelcase_json() { "project uses camelCase `createdAt`, got {entry}" ); assert!(entry.get("created_at").is_none(), "no snake_case leak"); - assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("jsoncheck")); + assert_eq!( + entry.get("name").and_then(|v| v.as_str()), + Some("jsoncheck") + ); assert_eq!(entry.get("root").and_then(|v| v.as_str()), Some("/srv/app")); } diff --git a/crates/infrastructure/tests/pty_adapter.rs b/crates/infrastructure/tests/pty_adapter.rs index 7fb374c..a07f60a 100644 --- a/crates/infrastructure/tests/pty_adapter.rs +++ b/crates/infrastructure/tests/pty_adapter.rs @@ -35,10 +35,7 @@ fn size() -> PtySize { /// Drains an output stream to a single `Vec` on a worker thread, returning /// the collected bytes or panicking if it does not finish within `TIMEOUT`. -fn drain_with_timeout( - stream: domain::ports::OutputStream, - timeout: Duration, -) -> Vec { +fn drain_with_timeout(stream: domain::ports::OutputStream, timeout: Duration) -> Vec { let (tx, rx) = mpsc::channel(); let worker = thread::spawn(move || { let mut all = Vec::new(); @@ -80,10 +77,7 @@ async fn write_is_echoed_back_through_output_stream() { // `cat` echoes its stdin back to stdout; we feed it a line then close stdin // by killing it, and assert we saw the echoed bytes. let pty = PortablePtyAdapter::new(); - let handle = pty - .spawn(sh_spec("cat"), size()) - .await - .expect("spawn cat"); + let handle = pty.spawn(sh_spec("cat"), size()).await.expect("spawn cat"); let stream = pty.subscribe_output(&handle).expect("subscribe once"); @@ -118,10 +112,7 @@ async fn subscribe_output_is_re_subscribable_for_reattach() { // first view detaches (drops its stream), a second view re-attaches and // still receives subsequent output — the core of the no-kill navigation fix. let pty = PortablePtyAdapter::new(); - let handle = pty - .spawn(sh_spec("cat"), size()) - .await - .expect("spawn cat"); + let handle = pty.spawn(sh_spec("cat"), size()).await.expect("spawn cat"); // First attachment: subscribe, observe an echo, then drop the stream // (simulating a view tearing down on navigation — NOT a kill). diff --git a/crates/infrastructure/tests/remote_host.rs b/crates/infrastructure/tests/remote_host.rs index 3da4c26..196e943 100644 --- a/crates/infrastructure/tests/remote_host.rs +++ b/crates/infrastructure/tests/remote_host.rs @@ -49,15 +49,9 @@ async fn selector_builds_local_host() { #[test] fn selector_rejects_ssh_and_wsl_for_now() { let ssh = RemoteRef::ssh("h", 22, "u", SshAuth::Agent, "/srv").unwrap(); - assert!(matches!( - remote_host(&ssh), - Err(RemoteError::Connection(_)) - )); + assert!(matches!(remote_host(&ssh), Err(RemoteError::Connection(_)))); let wsl = RemoteRef::wsl("Ubuntu").unwrap(); - assert!(matches!( - remote_host(&wsl), - Err(RemoteError::Connection(_)) - )); + assert!(matches!(remote_host(&wsl), Err(RemoteError::Connection(_)))); } /// Local host PTY/spawner handles are cloneable port objects (Arc-backed). diff --git a/crates/infrastructure/tests/skill_store.rs b/crates/infrastructure/tests/skill_store.rs index 493b062..1cdc37a 100644 --- a/crates/infrastructure/tests/skill_store.rs +++ b/crates/infrastructure/tests/skill_store.rs @@ -68,7 +68,12 @@ async fn global_roundtrip_save_get_list() { let s = store(&tmp); let root = tmp.project_root(); - let sk = skill(sid(1), "refactor", "# Refactor workflow", SkillScope::Global); + let sk = skill( + sid(1), + "refactor", + "# Refactor workflow", + SkillScope::Global, + ); s.save(&sk, &root).await.unwrap(); assert_eq!(s.get(SkillScope::Global, &root, sid(1)).await.unwrap(), sk); @@ -111,9 +116,12 @@ async fn scopes_are_isolated() { let s = store(&tmp); let root = tmp.project_root(); - s.save(&skill(sid(1), "g", "global body", SkillScope::Global), &root) - .await - .unwrap(); + s.save( + &skill(sid(1), "g", "global body", SkillScope::Global), + &root, + ) + .await + .unwrap(); s.save( &skill(sid(2), "p", "project body", SkillScope::Project), &root, @@ -191,9 +199,12 @@ async fn index_is_camelcase_with_content_hash() { let tmp = TempDir::new(); let s = store(&tmp); let root = tmp.project_root(); - s.save(&skill(sid(1), "refactor", "hello", SkillScope::Global), &root) - .await - .unwrap(); + s.save( + &skill(sid(1), "refactor", "hello", SkillScope::Global), + &root, + ) + .await + .unwrap(); let fs = LocalFileSystem::new(); let bytes = fs.read(&tmp.child("skills/index.json")).await.unwrap(); @@ -204,8 +215,5 @@ async fn index_is_camelcase_with_content_hash() { entry.get("contentHash").is_some(), "camelCase contentHash present" ); - assert!( - entry.get("content_hash").is_none(), - "no snake_case leak" - ); + assert!(entry.get("content_hash").is_none(), "no snake_case leak"); } diff --git a/crates/infrastructure/tests/template_store.rs b/crates/infrastructure/tests/template_store.rs index 8b8e21c..f8862ce 100644 --- a/crates/infrastructure/tests/template_store.rs +++ b/crates/infrastructure/tests/template_store.rs @@ -116,14 +116,20 @@ async fn delete_removes_from_index() { async fn index_is_camelcase_with_content_hash() { let tmp = TempDir::new(); let store = store(&tmp); - store.save(&template(tid(1), "Backend", "hello")).await.unwrap(); + store + .save(&template(tid(1), "Backend", "hello")) + .await + .unwrap(); let fs = LocalFileSystem::new(); let bytes = fs.read(&tmp.child("templates/index.json")).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); let entry = &json.get("templates").unwrap().as_array().unwrap()[0]; assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("Backend")); - assert!(entry.get("contentHash").is_some(), "camelCase contentHash present"); + assert!( + entry.get("contentHash").is_some(), + "camelCase contentHash present" + ); assert!(entry.get("defaultProfileId").is_some()); assert!(entry.get("content_hash").is_none(), "no snake_case leak"); } diff --git a/crates/infrastructure/tests/vector_recall.rs b/crates/infrastructure/tests/vector_recall.rs index a7b48b8..2f09e8a 100644 --- a/crates/infrastructure/tests/vector_recall.rs +++ b/crates/infrastructure/tests/vector_recall.rs @@ -152,7 +152,10 @@ fn query(text: &str, budget: usize) -> MemoryQuery { } fn slugs(entries: &[MemoryIndexEntry]) -> Vec { - entries.iter().map(|e| e.slug.as_str().to_string()).collect() + entries + .iter() + .map(|e| e.slug.as_str().to_string()) + .collect() } async fn store_with(notes: &[Memory], fs: Arc) -> Arc { @@ -215,16 +218,16 @@ async fn hash_embedder_dimension_is_consistent() { .await .unwrap(); assert_eq!(out.len(), 2, "one vector per input, order preserved"); - assert!(out.iter().all(|v| v.len() == 48), "every vector is dimension-long"); + assert!( + out.iter().all(|v| v.len() == 48), + "every vector is dimension-long" + ); } #[tokio::test] async fn hash_embedder_vectors_are_l2_normalised() { let e = HashEmbedder::new("h", 64); - let out = e - .embed(&["one two three four".to_string()]) - .await - .unwrap(); + let out = e.embed(&["one two three four".to_string()]).await.unwrap(); let norm = out[0].iter().map(|x| x * x).sum::().sqrt(); assert!((norm - 1.0).abs() < 1e-5, "norm ≈ 1, got {norm}"); } @@ -332,7 +335,12 @@ async fn vector_recall_ranks_closest_note_first() { .recall(&root(), &query("beta kiwi mango papaya", 100_000)) .await .unwrap(); - assert_eq!(out.first().map(|e| e.slug.as_str()), Some("beta"), "ranked: {:?}", slugs(&out)); + assert_eq!( + out.first().map(|e| e.slug.as_str()), + Some("beta"), + "ranked: {:?}", + slugs(&out) + ); // All three notes fit the ample budget. assert_eq!(out.len(), 3); } @@ -352,8 +360,15 @@ async fn vector_recall_truncates_to_budget() { let recall = vector_recall(embedder, store, fs); // Budget 2 fits only the top-ranked `beta` (cost 2); the next would exceed. - let out = recall.recall(&root(), &query("beta kiwi", 2)).await.unwrap(); - assert_eq!(slugs(&out), vec!["beta"], "budget must keep only the closest"); + let out = recall + .recall(&root(), &query("beta kiwi", 2)) + .await + .unwrap(); + assert_eq!( + slugs(&out), + vec!["beta"], + "budget must keep only the closest" + ); } #[tokio::test] @@ -389,20 +404,32 @@ async fn vector_recall_writes_and_reuses_the_derived_cache() { let recall = VectorMemoryRecall::new(spy.clone(), store, fs.clone()); // First recall: embeds 2 note texts + 1 query text = 3. - let _ = recall.recall(&root(), &query("alpha apple", 100_000)).await.unwrap(); + let _ = recall + .recall(&root(), &query("alpha apple", 100_000)) + .await + .unwrap(); let after_first = spy.embedded_texts.load(Ordering::SeqCst); assert_eq!(after_first, 3, "2 notes + 1 query embedded on a cold cache"); // The derived cache file now exists with both slugs and the embedder id. let doc = fs.raw(VECTORS_PATH).expect("vectors.json written"); - assert!(doc.contains("\"embedderId\": \"spy\""), "tagged with embedder id: {doc}"); + assert!( + doc.contains("\"embedderId\": \"spy\""), + "tagged with embedder id: {doc}" + ); assert!(doc.contains("\"alpha\""), "alpha vector cached: {doc}"); assert!(doc.contains("\"beta\""), "beta vector cached: {doc}"); // Second recall: note vectors come from the cache; only the query is embedded. - let _ = recall.recall(&root(), &query("beta banana", 100_000)).await.unwrap(); + let _ = recall + .recall(&root(), &query("beta banana", 100_000)) + .await + .unwrap(); let delta = spy.embedded_texts.load(Ordering::SeqCst) - after_first; - assert_eq!(delta, 1, "cache reuse ⇒ only the query is re-embedded, got {delta}"); + assert_eq!( + delta, 1, + "cache reuse ⇒ only the query is re-embedded, got {delta}" + ); } #[tokio::test] @@ -417,7 +444,10 @@ async fn vector_recall_invalidates_cache_on_embedder_id_change() { .recall(&root(), &query("alpha apple", 100_000)) .await .unwrap(); - assert!(fs.raw(VECTORS_PATH).unwrap().contains("\"embedderId\": \"first\"")); + assert!(fs + .raw(VECTORS_PATH) + .unwrap() + .contains("\"embedderId\": \"first\"")); // A different embedder id must invalidate ⇒ recompute all note vectors. let second = Arc::new(SpyEmbedder::new("second", 128)); @@ -432,7 +462,10 @@ async fn vector_recall_invalidates_cache_on_embedder_id_change() { "id change must invalidate and recompute note vectors" ); // The file is now tagged with the new embedder id. - assert!(fs.raw(VECTORS_PATH).unwrap().contains("\"embedderId\": \"second\"")); + assert!(fs + .raw(VECTORS_PATH) + .unwrap() + .contains("\"embedderId\": \"second\"")); } #[tokio::test] @@ -472,7 +505,10 @@ async fn vector_recall_with_stub_embedder_errors_without_panic() { let stub = Arc::new(StubEmbedder::new("stub", 64, "localOnnx")); let recall = VectorMemoryRecall::new(stub, store, fs); let res = recall.recall(&root(), &query("alpha apple", 1000)).await; - assert!(res.is_err(), "stub embedder ⇒ Err on the vector path, got {res:?}"); + assert!( + res.is_err(), + "stub embedder ⇒ Err on the vector path, got {res:?}" + ); } // =========================================================================== @@ -520,7 +556,10 @@ async fn adaptive_none_strategy_matches_naive_exactly() { let q = query("kiwi mango papaya", budget); let expected = naive.recall(&root(), &q).await.unwrap(); let got = adaptive.recall(&root(), &q).await.unwrap(); - assert_eq!(got, expected, "strategy none must equal naive at budget {budget}"); + assert_eq!( + got, expected, + "strategy none must equal naive at budget {budget}" + ); } } @@ -546,7 +585,11 @@ async fn adaptive_small_memory_routes_to_naive_order() { .recall(&root(), &query("gamma grape", 100_000)) .await .unwrap(); - assert_eq!(slugs(&out), vec!["alpha", "beta", "gamma"], "naïve keeps index order"); + assert_eq!( + slugs(&out), + vec!["alpha", "beta", "gamma"], + "naïve keeps index order" + ); } #[tokio::test] @@ -614,9 +657,15 @@ async fn adaptive_falls_back_to_naive_when_embedder_fails() { let total = infrastructure::index_token_size(&store.read_index(&root()).await.unwrap()); let q = query("carrot potato onion", total - 1); - let got = adaptive.recall(&root(), &q).await.expect("fallback must not error"); + let got = adaptive + .recall(&root(), &q) + .await + .expect("fallback must not error"); let expected = naive_ref.recall(&root(), &q).await.unwrap(); - assert_eq!(got, expected, "embedder failure must degrade to the naïve result"); + assert_eq!( + got, expected, + "embedder failure must degrade to the naïve result" + ); assert!(!got.is_empty(), "the degraded result still returns entries"); } @@ -646,11 +695,18 @@ impl Drop for TempDir { #[test] fn recommended_onnx_models_advertise_e5_small_384() { - assert_eq!(RECOMMENDED_ONNX_MODELS.len(), 1, "exactly one curated model"); + assert_eq!( + RECOMMENDED_ONNX_MODELS.len(), + 1, + "exactly one curated model" + ); let m = RECOMMENDED_ONNX_MODELS[0]; assert_eq!(m.id, "multilingual-e5-small"); assert_eq!(m.dimension, 384); - assert!(m.recommended, "the single curated model is the recommended default"); + assert!( + m.recommended, + "the single curated model is the recommended default" + ); } #[test] @@ -681,7 +737,9 @@ fn onnx_model_is_cached_true_when_model_subdir_present_and_nonempty() { // *contains* the model token (slashes/underscores normalised to `-`), mirroring // hf-hub's `models----` layout. let tmp = TempDir::new(); - let model_dir = tmp.path().join("models--Qdrant--multilingual-e5-small-onnx"); + let model_dir = tmp + .path() + .join("models--Qdrant--multilingual-e5-small-onnx"); std::fs::create_dir_all(&model_dir).unwrap(); // The subdir must be non-empty to count as "cached". std::fs::write(model_dir.join("model.onnx"), b"not really a model").unwrap(); @@ -696,7 +754,11 @@ fn onnx_model_is_cached_true_when_model_subdir_present_and_nonempty() { fn onnx_model_is_cached_false_when_matching_subdir_is_empty() { // A matching but *empty* subdir does not count as a present cached model. let tmp = TempDir::new(); - std::fs::create_dir_all(tmp.path().join("models--Qdrant--multilingual-e5-small-onnx")).unwrap(); + std::fs::create_dir_all( + tmp.path() + .join("models--Qdrant--multilingual-e5-small-onnx"), + ) + .unwrap(); assert!( !onnx_model_is_cached(tmp.path(), "multilingual-e5-small"), "an empty matching subdir is not a cached model" diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts index 9079134..031b6a0 100644 --- a/frontend/src/adapters/agent.test.ts +++ b/frontend/src/adapters/agent.test.ts @@ -59,4 +59,14 @@ describe("TauriAgentGateway invoke payloads", () => { agentId: "a", }); }); + + it("attach_live_agent wraps project, agent and target node in the request DTO", async () => { + invoke.mockResolvedValueOnce([ + { agentId: "agent-2", nodeId: "node-3", sessionId: "session-4" }, + ]); + await new TauriAgentGateway().attachLiveAgent("proj-1", "agent-2", "node-3"); + expect(invoke).toHaveBeenCalledWith("attach_live_agent", { + request: { projectId: "proj-1", agentId: "agent-2", nodeId: "node-3" }, + }); + }); }); diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index c1516ab..062d24b 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -45,6 +45,22 @@ export class TauriAgentGateway implements AgentGateway { return invoke("list_live_agents", { projectId }); } + attachLiveAgent( + projectId: string, + agentId: string, + nodeId: string, + ): Promise { + return invoke("attach_live_agent", { + request: { projectId, agentId, nodeId }, + }).then((list) => { + const attached = list[0]; + if (!attached) { + throw new Error(`running session for agent ${agentId} was not returned`); + } + return attached; + }); + } + createAgent(projectId: string, input: CreateAgentInput): Promise { // The `create_agent` command takes a single `request` DTO; `projectId` must // live *inside* it (camelCase), not at the top level. diff --git a/frontend/src/adapters/embedder.ts b/frontend/src/adapters/embedder.ts new file mode 100644 index 0000000..244fbb7 --- /dev/null +++ b/frontend/src/adapters/embedder.ts @@ -0,0 +1,35 @@ +/** + * Tauri adapter for {@link EmbedderGateway} (L14 / lot C2). Like the sibling + * adapters this is one of the *only* places that calls `invoke()`; components + * reach it exclusively through the port. + * + * Commands use snake_case (Tauri convention); payload keys are camelCase + * (matching the backend DTO `#[serde(rename_all = "camelCase")]`). The mutating + * command `save_embedder_profile` wraps its field under a `request` key (as + * `save_profile` does); `delete_embedder_profile` passes its arg flat. + */ + +import { invoke } from "@tauri-apps/api/core"; + +import type { EmbedderEngines, EmbedderProfile } from "@/domain"; +import type { EmbedderGateway } from "@/ports"; + +export class TauriEmbedderGateway implements EmbedderGateway { + listEmbedderProfiles(): Promise { + return invoke("list_embedder_profiles"); + } + + saveEmbedderProfile(profile: EmbedderProfile): Promise { + return invoke("save_embedder_profile", { + request: { profile }, + }); + } + + async deleteEmbedderProfile(embedderId: string): Promise { + await invoke("delete_embedder_profile", { embedderId }); + } + + describeEmbedderEngines(): Promise { + return invoke("describe_embedder_engines"); + } +} diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 25d792f..1c27d3d 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -21,6 +21,7 @@ import { TauriProfileGateway } from "./profile"; import { TauriTemplateGateway } from "./template"; import { TauriSkillGateway } from "./skill"; import { TauriMemoryGateway } from "./memory"; +import { TauriEmbedderGateway } from "./embedder"; import { TauriGitGateway } from "./git"; function notImplemented(what: string): never { @@ -51,6 +52,7 @@ export function createTauriGateways(): Gateways { template: new TauriTemplateGateway(), skill: new TauriSkillGateway(), memory: new TauriMemoryGateway(), + embedder: new TauriEmbedderGateway(), }; } @@ -64,5 +66,6 @@ export { TauriTemplateGateway, TauriSkillGateway, TauriMemoryGateway, + TauriEmbedderGateway, TauriGitGateway, }; diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 293b2e1..af035a7 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -9,6 +9,8 @@ import type { AgentDrift, AgentProfile, DomainEvent, + EmbedderEngines, + EmbedderProfile, FirstRunState, GatewayError, GitBranches, @@ -38,6 +40,7 @@ import type { CreateAgentInput, CreateMemoryInput, CreateSkillInput, + EmbedderGateway, CreateTemplateInput, Gateways, LiveAgent, @@ -178,6 +181,8 @@ export class MockAgentGateway implements AgentGateway { * Only populated when the caller supplies a `nodeId`. */ private liveByAgent = new Map(); + /** Live PTY session id per agent (`agentId → sessionId`). */ + private liveSessionByAgent = new Map(); private getAgents(projectId: string): Agent[] { if (!this.agents.has(projectId)) this.agents.set(projectId, []); @@ -198,7 +203,36 @@ export class MockAgentGateway implements AgentGateway { const known = new Set(this.getAgents(projectId).map((a) => a.id)); return [...this.liveByAgent.entries()] .filter(([agentId]) => known.has(agentId)) - .map(([agentId, nodeId]) => ({ agentId, nodeId })); + .map(([agentId, nodeId]) => ({ + agentId, + nodeId, + sessionId: this.liveSessionByAgent.get(agentId), + })); + } + + async attachLiveAgent( + projectId: string, + agentId: string, + nodeId: string, + ): Promise { + const list = this.getAgents(projectId); + if (!list.some((a) => a.id === agentId)) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `agent ${agentId} not found in project ${projectId}`, + }; + throw err; + } + const sessionId = this.liveSessionByAgent.get(agentId); + if (!sessionId || !this.sessions.has(sessionId)) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `agent ${agentId} has no live session`, + }; + throw err; + } + this.liveByAgent.set(agentId, nodeId); + return { agentId, nodeId, sessionId }; } async createAgent(projectId: string, input: CreateAgentInput): Promise { @@ -356,7 +390,10 @@ export class MockAgentGateway implements AgentGateway { this.sessions.set(sessionId, session); // Record liveness for `listLiveAgents` + the guard above (only when the // caller pins a node). - if (options.nodeId) this.liveByAgent.set(agentId, options.nodeId); + if (options.nodeId) { + this.liveByAgent.set(agentId, options.nodeId); + this.liveSessionByAgent.set(agentId, sessionId); + } // Greet so something is visible immediately (mirrors MockTerminalGateway). queueMicrotask(() => session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)), @@ -365,6 +402,7 @@ export class MockAgentGateway implements AgentGateway { this.sessions.delete(sessionId); if (this.liveByAgent.get(agentId) === options.nodeId) { this.liveByAgent.delete(agentId); + this.liveSessionByAgent.delete(agentId); } }); // Simulate session assignment (T4b): a fresh cell (no conversation id) gets a @@ -393,7 +431,15 @@ export class MockAgentGateway implements AgentGateway { } const scrollback = session.reattach(onData); return { - handle: makeMockHandle(session, () => this.sessions.delete(sessionId)), + handle: makeMockHandle(session, () => { + this.sessions.delete(sessionId); + for (const [agentId, liveSessionId] of this.liveSessionByAgent) { + if (liveSessionId === sessionId) { + this.liveSessionByAgent.delete(agentId); + this.liveByAgent.delete(agentId); + } + } + }), scrollback, }; } @@ -513,6 +559,7 @@ export class MockTerminalGateway implements TerminalGateway { export class MockProjectGateway implements ProjectGateway { private projects: Project[] = []; + private contexts = new Map(); async listProjects(): Promise { return [...this.projects]; @@ -550,6 +597,28 @@ export class MockProjectGateway implements ProjectGateway { } async closeProject(): Promise {} + + async readProjectContext(projectId: string): Promise { + if (!this.projects.some((p) => p.id === projectId)) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `project ${projectId} not found`, + }; + throw err; + } + return this.contexts.get(projectId) ?? ""; + } + + async updateProjectContext(projectId: string, content: string): Promise { + if (!this.projects.some((p) => p.id === projectId)) { + const err: GatewayError = { + code: "NOT_FOUND", + message: `project ${projectId} not found`, + }; + throw err; + } + this.contexts.set(projectId, content); + } } /** Internal per-project layout store entry. */ @@ -1321,6 +1390,80 @@ export class MockMemoryGateway implements MemoryGateway { } } +/** + * In-memory {@link EmbedderGateway} (L14 / lot C2). Seeds a curated set of + * recommended ONNX engines (e5-small recommended) with both feature flags on by + * default; tests can pass overrides to simulate a build where a strategy is not + * compiled in. + */ +export class MockEmbedderGateway implements EmbedderGateway { + private profiles = new Map(); + private engines: EmbedderEngines; + + constructor( + engines?: Partial, + seedProfiles: EmbedderProfile[] = [], + ) { + this.engines = { + recommendedOnnx: [ + { + id: "e5-small", + displayName: "E5 Small (multilingual)", + dimension: 384, + approxSizeMb: 120, + recommended: true, + }, + { + id: "bge-small", + displayName: "BGE Small", + dimension: 384, + approxSizeMb: 130, + recommended: false, + }, + ], + ollamaDetected: false, + onnxCachedModels: [], + vectorHttpEnabled: true, + vectorOnnxEnabled: true, + ...engines, + }; + for (const p of seedProfiles) this.profiles.set(p.id, p); + } + + async listEmbedderProfiles(): Promise { + return structuredClone([...this.profiles.values()]); + } + + async saveEmbedderProfile( + profile: EmbedderProfile, + ): Promise { + if (!profile.id.trim() || !profile.name.trim()) { + const err: GatewayError = { + code: "INVALID", + message: "embedder profile id/name must not be empty", + }; + throw err; + } + if (profile.dimension <= 0) { + const err: GatewayError = { + code: "INVALID", + message: "embedder profile dimension must be > 0", + }; + throw err; + } + this.profiles.set(profile.id, structuredClone(profile)); + return structuredClone(profile); + } + + async deleteEmbedderProfile(embedderId: string): Promise { + this.profiles.delete(embedderId); + } + + async describeEmbedderEngines(): Promise { + return structuredClone(this.engines); + } +} + /** Builds the full set of mock gateways. */ export function createMockGateways(): Gateways { const agentGateway = new MockAgentGateway(); @@ -1336,6 +1479,7 @@ export function createMockGateways(): Gateways { template: new MockTemplateGateway(agentGateway), skill: new MockSkillGateway(agentGateway), memory: new MockMemoryGateway(), + embedder: new MockEmbedderGateway(), }; } diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index 9186162..9ceb13a 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -12,9 +12,10 @@ import { createMockGateways, MockSystemGateway } from "./index"; const gateways: Gateways = createMockGateways(); describe("createMockGateways", () => { - it("exposes all eleven gateways", () => { + it("exposes all twelve gateways", () => { expect(Object.keys(gateways).sort()).toEqual([ "agent", + "embedder", "git", "layout", "memory", diff --git a/frontend/src/adapters/project.test.ts b/frontend/src/adapters/project.test.ts new file mode 100644 index 0000000..3850062 --- /dev/null +++ b/frontend/src/adapters/project.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const invoke = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invoke(...args), +})); + +import { TauriProjectGateway } from "./project"; + +describe("TauriProjectGateway invoke payloads", () => { + beforeEach(() => invoke.mockReset().mockResolvedValue({})); + + it("reads and updates the project context under .ideai", async () => { + invoke.mockResolvedValueOnce("# shared"); + await expect(new TauriProjectGateway().readProjectContext("proj-1")).resolves.toBe( + "# shared", + ); + expect(invoke).toHaveBeenCalledWith("read_project_context", { + projectId: "proj-1", + }); + + await new TauriProjectGateway().updateProjectContext("proj-1", "# next"); + expect(invoke).toHaveBeenCalledWith("update_project_context", { + request: { projectId: "proj-1", content: "# next" }, + }); + }); +}); diff --git a/frontend/src/adapters/project.ts b/frontend/src/adapters/project.ts index ec1da9c..1f744a1 100644 --- a/frontend/src/adapters/project.ts +++ b/frontend/src/adapters/project.ts @@ -27,4 +27,14 @@ export class TauriProjectGateway implements ProjectGateway { async closeProject(projectId: string): Promise { await invoke("close_project", { projectId }); } + + readProjectContext(projectId: string): Promise { + return invoke("read_project_context", { projectId }); + } + + async updateProjectContext(projectId: string, content: string): Promise { + await invoke("update_project_context", { + request: { projectId, content }, + }); + } } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 31fe1e8..8a66ef8 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -313,6 +313,65 @@ export interface MemoryIndexEntry { /** A resolved `[[wikilink]]` target — the slug of another note. */ export type MemoryLink = string; +// --------------------------------------------------------------------------- +// Embedder (L14 / lot C2) — mirror of the backend `EmbedderProfileDto` and +// `EmbedderEnginesDto`. Drives the memory/embedder settings panel. Identity of +// a profile is its `id`; changing the active embedder takes effect at the next +// app start (the UI says so explicitly). +// --------------------------------------------------------------------------- + +/** + * Embedding strategy of an embedder profile (mirror of the backend `strategy`). + * `none` ⇒ no vector tier (naïve recall); the other strategies select where the + * embeddings come from. + */ +export type EmbedderStrategy = "localOnnx" | "localServer" | "api" | "none"; + +/** + * A declarative embedder profile (mirror of the backend `EmbedderProfile` DTO, + * camelCase wire format). Transparent by design: secrets are never stored here — + * `apiKeyEnv` is the *name* of an environment variable, never the key itself. + */ +export interface EmbedderProfile { + id: string; + name: string; + strategy: EmbedderStrategy; + /** Model id/name (ONNX model, server/api model). Omitted for `none`. */ + model?: string; + /** Server/API endpoint URL. Omitted for `localOnnx` / `none`. */ + endpoint?: string; + /** Name of the env var holding the API key (api strategy). Never the key. */ + apiKeyEnv?: string; + /** Embedding vector dimension. */ + dimension: number; +} + +/** + * A recommended local ONNX engine (mirror of the backend `recommendedOnnx` + * entry). `e5-small` is the curated default (`recommended: true`). + */ +export interface RecommendedOnnxEngine { + id: string; + displayName: string; + dimension: number; + approxSizeMb: number; + recommended: boolean; +} + +/** + * Capabilities/availability snapshot of the embedder engines on this build + * (mirror of the backend `describe_embedder_engines`). The `vector*Enabled` + * flags reflect Cargo feature flags compiled into the build: a strategy whose + * flag is `false` is shown disabled ("not available in this build"). + */ +export interface EmbedderEngines { + recommendedOnnx: RecommendedOnnxEngine[]; + ollamaDetected: boolean; + onnxCachedModels: string[]; + vectorHttpEnabled: boolean; + vectorOnnxEnabled: boolean; +} + // --------------------------------------------------------------------------- // Templates (L7) — mirror of the domain `Template` / `AgentDrift`. // --------------------------------------------------------------------------- diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index cf53165..1069a20 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -116,6 +116,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { * set by the hook once launchAgent resolves. */ const [activeAgentId, setActiveAgentId] = useState(null); + const [panelNodeIds, setPanelNodeIds] = useState>({}); /** * Live PTY session id of the running agent terminal, keyed by agent id. Lets @@ -149,11 +150,15 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { } function handleLaunch(agentId: string) { + setPanelNodeIds((prev) => ({ + ...prev, + [agentId]: prev[agentId] ?? crypto.randomUUID(), + })); setActiveAgentId(agentId); } - function handleStop() { - vm.stopAgent(); + function handleStop(agentId?: string) { + void vm.stopAgent(agentId); setActiveAgentId(null); } @@ -288,6 +293,7 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { {vm.agents.map((a) => { const isSelected = a.id === vm.selectedAgentId; 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 ?? a.profileId; @@ -318,6 +324,11 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { )} {profileName} + {live && ( + + running in IdeA · {live.sessionId ?? live.nodeId} + + )}
@@ -334,12 +345,12 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { Sync )} - {isRunning ? ( + {isRunning || live ? ( + )} + +
+ + + ); +} diff --git a/frontend/src/features/embedder/embedder.test.tsx b/frontend/src/features/embedder/embedder.test.tsx new file mode 100644 index 0000000..bc34c88 --- /dev/null +++ b/frontend/src/features/embedder/embedder.test.tsx @@ -0,0 +1,237 @@ +/** + * L14 / lot C2 — embedder settings feature wired to the stateful + * `MockEmbedderGateway` via the real `DIProvider` (same harness as + * `memory.test.tsx`). + * + * Covers: + * - the strategy list renders on an equal footing (None, ONNX, Ollama, API, + * Custom) with the "Recommended" badge on ONNX + * - conditional fields per strategy (ONNX model picker + auto dimension; + * localServer endpoint; api endpoint + apiKeyEnv label; none → no fields) + * - a strategy whose build flag is false is shown disabled with + * "not available in this build" (None stays available) + * - Save calls the gateway with the right profile + * - "Back to None" deletes the configured profiles + * + * Plus the active-tier transparency line in MemoryPanel (Naïve vs Vector). + */ + +import { describe, it, expect, vi } from "vitest"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; + +import { MockEmbedderGateway, MockMemoryGateway } from "@/adapters/mock"; +import type { EmbedderEngines, EmbedderProfile } from "@/domain"; +import type { Gateways } from "@/ports"; +import { DIProvider } from "@/app/di"; +import { MemoryPanel } from "@/features/memory"; +import { EmbedderSettings } from "./EmbedderSettings"; + +function renderEmbedder( + engines?: Partial, + seed: EmbedderProfile[] = [], +) { + const embedder = new MockEmbedderGateway(engines, seed); + const gateways = { embedder } as unknown as Gateways; + return { + embedder, + ...render( + + + , + ), + }; +} + +async function waitForEmbedderIdle() { + await waitFor(() => { + expect( + (screen.getByRole("button", { name: "back to none" }) as HTMLButtonElement) + .disabled, + ).toBe(false); + }); +} + +describe("EmbedderSettings (with MockEmbedderGateway)", () => { + it("renders the strategy list on an equal footing with a Recommended badge", async () => { + renderEmbedder(); + await waitForEmbedderIdle(); + + // Every strategy is offered. + expect(screen.getByTestId("embedder-strategy-none")).toBeTruthy(); + expect(screen.getByTestId("embedder-strategy-localOnnx")).toBeTruthy(); + expect(screen.getByTestId("embedder-strategy-localServer")).toBeTruthy(); + expect(screen.getByTestId("embedder-strategy-api")).toBeTruthy(); + expect(screen.getByTestId("embedder-strategy-custom")).toBeTruthy(); + + // ONNX carries the "Recommended" badge. + expect(screen.getByText("Recommended")).toBeTruthy(); + + // The change-takes-effect notice is visible. + expect(screen.getByTestId("embedder-restart-note").textContent).toMatch( + /next app start/i, + ); + }); + + it("pre-selects ONNX with the recommended model and auto-fills its dimension (384)", async () => { + renderEmbedder(); + await waitForEmbedderIdle(); + + // ONNX is the default cursor → its model picker is shown. + const modelSelect = (await screen.findByLabelText( + "onnx model", + )) as HTMLSelectElement; + expect(modelSelect.value).toBe("e5-small"); + + // Dimension auto-filled from the model. + expect((screen.getByLabelText("dimension") as HTMLInputElement).value).toBe( + "384", + ); + }); + + it("shows endpoint for localServer and endpoint + apiKeyEnv (named, not the key) for api", async () => { + renderEmbedder(); + await waitForEmbedderIdle(); + + // localServer → endpoint, no apiKeyEnv. + fireEvent.click( + screen.getByRole("radio", { name: "Ollama (local server)" }), + ); + await waitFor(() => expect(screen.getByLabelText("endpoint")).toBeTruthy()); + expect(screen.queryByLabelText("api key env var")).toBeNull(); + + // api → endpoint + apiKeyEnv with an explicit "env var" label. + fireEvent.click(screen.getByRole("radio", { name: "API" })); + await waitFor(() => + expect(screen.getByLabelText("api key env var")).toBeTruthy(), + ); + expect(screen.getByText(/environment variable/i)).toBeTruthy(); + expect(screen.getByText(/never the key itself/i)).toBeTruthy(); + }); + + it("renders no conditional fields for None", async () => { + renderEmbedder(); + await waitForEmbedderIdle(); + + fireEvent.click(screen.getByRole("radio", { name: "None (naïve recall)" })); + await waitFor(() => + expect(screen.queryByLabelText("dimension")).toBeNull(), + ); + expect(screen.queryByLabelText("onnx model")).toBeNull(); + // No Save button in None mode (only "Back to None"). + expect(screen.queryByRole("button", { name: "save embedder" })).toBeNull(); + }); + + it("disables strategies whose build flag is false with a 'not available' note", async () => { + renderEmbedder({ vectorOnnxEnabled: false, vectorHttpEnabled: false }); + await waitForEmbedderIdle(); + + const onnxRadio = screen.getByRole("radio", { + name: "Local ONNX", + }) as HTMLInputElement; + const ollamaRadio = screen.getByRole("radio", { + name: "Ollama (local server)", + }) as HTMLInputElement; + const apiRadio = screen.getByRole("radio", { name: "API" }) as HTMLInputElement; + const noneRadio = screen.getByRole("radio", { + name: "None (naïve recall)", + }) as HTMLInputElement; + + expect(onnxRadio.disabled).toBe(true); + expect(ollamaRadio.disabled).toBe(true); + expect(apiRadio.disabled).toBe(true); + // None is always available. + expect(noneRadio.disabled).toBe(false); + + // The honesty note appears. + expect( + screen.getAllByText("not available in this build").length, + ).toBeGreaterThan(0); + }); + + it("Save sends the recommended ONNX profile (e5-small, dim 384)", async () => { + const { embedder } = renderEmbedder(); + const spy = vi.spyOn(embedder, "saveEmbedderProfile"); + await waitForEmbedderIdle(); + + fireEvent.click(screen.getByRole("button", { name: "save embedder" })); + + await waitFor(() => expect(spy).toHaveBeenCalledTimes(1)); + const saved = spy.mock.calls[0][0]; + expect(saved.strategy).toBe("localOnnx"); + expect(saved.model).toBe("e5-small"); + expect(saved.dimension).toBe(384); + expect(saved.id.length).toBeGreaterThan(0); + expect(saved.name.length).toBeGreaterThan(0); + // ONNX never carries an endpoint/apiKeyEnv. + expect(saved.endpoint).toBeUndefined(); + expect(saved.apiKeyEnv).toBeUndefined(); + }); + + it("Save for api sends endpoint + apiKeyEnv (the env var NAME)", async () => { + const { embedder } = renderEmbedder(); + const spy = vi.spyOn(embedder, "saveEmbedderProfile"); + await waitForEmbedderIdle(); + + fireEvent.click(screen.getByRole("radio", { name: "API" })); + await waitFor(() => + expect(screen.getByLabelText("api key env var")).toBeTruthy(), + ); + fireEvent.change(screen.getByLabelText("api key env var"), { + target: { value: "MY_KEY_VAR" }, + }); + fireEvent.click(screen.getByRole("button", { name: "save embedder" })); + + await waitFor(() => expect(spy).toHaveBeenCalled()); + const saved = spy.mock.calls.at(-1)![0]; + expect(saved.strategy).toBe("api"); + expect(saved.apiKeyEnv).toBe("MY_KEY_VAR"); + expect(saved.endpoint).toBeTruthy(); + }); + + it("'Back to None' deletes the configured profiles", async () => { + const seed: EmbedderProfile[] = [ + { id: "onnx-local", name: "Local ONNX", strategy: "localOnnx", model: "e5-small", dimension: 384 }, + ]; + const { embedder } = renderEmbedder(undefined, seed); + const spy = vi.spyOn(embedder, "deleteEmbedderProfile"); + await waitForEmbedderIdle(); + + fireEvent.click(screen.getByRole("button", { name: "back to none" })); + + await waitFor(() => expect(spy).toHaveBeenCalledWith("onnx-local")); + expect(await embedder.listEmbedderProfiles()).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// MemoryPanel — active recall tier transparency +// --------------------------------------------------------------------------- + +describe("MemoryPanel active tier (transparency)", () => { + function renderPanel(embedder: MockEmbedderGateway) { + const gateways = { + memory: new MockMemoryGateway(), + embedder, + } as unknown as Gateways; + return render( + + + , + ); + } + + it("shows 'Naïve (None)' when no embedder is configured", async () => { + renderPanel(new MockEmbedderGateway()); + const tier = await screen.findByTestId("memory-tier"); + await waitFor(() => expect(tier.textContent).toMatch(/Naïve \(None\)/)); + }); + + it("shows 'Vector — ' when an embedder is configured", async () => { + const embedder = new MockEmbedderGateway(undefined, [ + { id: "onnx-local", name: "Local ONNX", strategy: "localOnnx", model: "e5-small", dimension: 384 }, + ]); + renderPanel(embedder); + const tier = await screen.findByTestId("memory-tier"); + await waitFor(() => expect(tier.textContent).toMatch(/Vector — localOnnx e5-small/)); + }); +}); diff --git a/frontend/src/features/embedder/index.ts b/frontend/src/features/embedder/index.ts new file mode 100644 index 0000000..fd18991 --- /dev/null +++ b/frontend/src/features/embedder/index.ts @@ -0,0 +1,4 @@ +/** Public surface of the embedder feature (L14 / lot C2). */ +export { EmbedderSettings } from "./EmbedderSettings"; +export { useEmbedder } from "./useEmbedder"; +export type { EmbedderViewModel } from "./useEmbedder"; diff --git a/frontend/src/features/embedder/useEmbedder.ts b/frontend/src/features/embedder/useEmbedder.ts new file mode 100644 index 0000000..46e6d66 --- /dev/null +++ b/frontend/src/features/embedder/useEmbedder.ts @@ -0,0 +1,118 @@ +/** + * `useEmbedder` — view-model hook for the memory/embedder settings (L14 / C2). + * + * Owns the configured embedder profiles and the available engines (with the + * build-time feature flags). Consumes {@link EmbedderGateway} exclusively; never + * touches `invoke()` or `@tauri-apps/api`, keeping the component layer testable + * with mock gateways (ARCHITECTURE §1.3). + * + * Note: the embedder change takes effect at the *next app start* — this hook + * just persists the choice; it does not hot-swap the live recall tier. + */ + +import { useCallback, useEffect, useState } from "react"; + +import type { EmbedderEngines, EmbedderProfile, GatewayError } from "@/domain"; +import { useGateways } from "@/app/di"; + +/** What the embedder settings UI needs from this hook. */ +export interface EmbedderViewModel { + /** The configured embedder profiles. */ + profiles: EmbedderProfile[]; + /** The available engines + build feature flags, or `null` until loaded. */ + engines: EmbedderEngines | null; + /** The single active profile (first configured), or `null` (naïve/None). */ + active: EmbedderProfile | null; + /** Last error message, or `null`. */ + error: string | null; + /** Whether a request is in flight. */ + busy: boolean; + /** Reloads profiles + engines. */ + refresh: () => Promise; + /** Persists a profile (create/replace by id) and refreshes. */ + saveProfile: (profile: EmbedderProfile) => Promise; + /** Deletes a profile by id and refreshes. */ + deleteProfile: (embedderId: string) => Promise; +} + +function describe(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as GatewayError).message); + } + return String(e); +} + +export function useEmbedder(): EmbedderViewModel { + const { embedder } = useGateways(); + + const [profiles, setProfiles] = useState([]); + const [engines, setEngines] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + setBusy(true); + setError(null); + try { + const [list, eng] = await Promise.all([ + embedder.listEmbedderProfiles(), + embedder.describeEmbedderEngines(), + ]); + setProfiles(list); + setEngines(eng); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + }, [embedder]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const saveProfile = useCallback( + async (profile: EmbedderProfile) => { + setBusy(true); + setError(null); + try { + await embedder.saveEmbedderProfile(profile); + await refresh(); + } catch (e) { + setError(describe(e)); + setBusy(false); + } + }, + [embedder, refresh], + ); + + const deleteProfile = useCallback( + async (embedderId: string) => { + setBusy(true); + setError(null); + try { + await embedder.deleteEmbedderProfile(embedderId); + await refresh(); + } catch (e) { + setError(describe(e)); + setBusy(false); + } + }, + [embedder, refresh], + ); + + // The active embedder is the first configured profile (the backend keeps a + // single active embedder); `null` ⇒ naïve recall (None). + const active = profiles.find((p) => p.strategy !== "none") ?? null; + + return { + profiles, + engines, + active, + error, + busy, + refresh, + saveProfile, + deleteProfile, + }; +} diff --git a/frontend/src/features/layout/LayoutGrid.test.tsx b/frontend/src/features/layout/LayoutGrid.test.tsx index 368673b..9586ffb 100644 --- a/frontend/src/features/layout/LayoutGrid.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.test.tsx @@ -8,7 +8,7 @@ * rather than xterm's visual output. They stay robust whether or not xterm * bailed. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import type { Gateways } from "@/ports"; @@ -90,8 +90,10 @@ describe("LayoutGrid (with MockLayoutGateway)", () => { }); }); - it("closing a cell removes THAT cell and keeps its sibling (closes the right terminal)", async () => { + it("closing a cell removes THAT cell and keeps its sibling without killing its session", async () => { const layout = new MockLayoutGateway(); + const terminal = new MockTerminalGateway(); + const closeSpy = vi.spyOn(terminal, "closeTerminal"); const initial = await layout.loadLayout("p1"); const a = leaves(initial)[0].id; // Split A → container c with children [A (index 0), b (index 1)]. @@ -102,8 +104,18 @@ describe("LayoutGrid (with MockLayoutGateway)", () => { newLeaf: "b", container: "c", }); + await layout.mutateLayout("p1", { + type: "setSession", + target: "b", + session: "running-session-b", + }); - renderGrid(layout); + const gateways = { layout, terminal } as unknown as Gateways; + render( + + + , + ); await waitFor(() => expect(screen.getAllByTestId("layout-leaf")).toHaveLength(2), ); @@ -117,6 +129,7 @@ describe("LayoutGrid (with MockLayoutGateway)", () => { expect( screen.getByTestId("layout-leaf").getAttribute("data-node-id"), ).toBe(a); + expect(closeSpy).not.toHaveBeenCalled(); }); it("closing the other cell keeps the opposite sibling", async () => { diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 4b45661..d20e911 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -28,7 +28,7 @@ import type { } from "@/ports"; import { ResumeConversationPopup, TerminalView } from "@/features/terminals"; import { useGateways } from "@/app/di"; -import { normalizeWeights, resizeAdjacent } from "./layout"; +import { leaves, normalizeWeights, resizeAdjacent } from "./layout"; import { useLayout, type LayoutViewModel } from "./useLayout"; interface LayoutGridProps { @@ -56,6 +56,7 @@ export function LayoutGrid({ projectId, cwd, layoutId }: LayoutGridProps) { ); } + const visibleNodeIds = new Set(leaves(vm.layout).map((l) => l.id)); return (
); @@ -85,9 +87,10 @@ interface NodeViewProps { /** The enclosing split + this node's index in it, for the merge action. */ parentSplit: { container: string; index: number; siblings: number } | null; projectId: string; + visibleNodeIds: Set; } -function NodeView({ node, cwd, vm, parentSplit, projectId }: NodeViewProps) { +function NodeView({ node, cwd, vm, parentSplit, projectId, visibleNodeIds }: NodeViewProps) { switch (node.type) { case "leaf": return ( @@ -101,12 +104,29 @@ function NodeView({ node, cwd, vm, parentSplit, projectId }: NodeViewProps) { vm={vm} parentSplit={parentSplit} projectId={projectId} + visibleNodeIds={visibleNodeIds} /> ); case "split": - return ; + return ( + + ); case "grid": - return ; + return ( + + ); } } @@ -120,6 +140,7 @@ interface LeafViewProps { vm: LayoutViewModel; parentSplit: { container: string; index: number; siblings: number } | null; projectId: string; + visibleNodeIds: Set; } /** @@ -135,7 +156,7 @@ interface PendingResume { reject: (e: unknown) => void; } -function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm, parentSplit, projectId }: LeafViewProps) { +function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm, parentSplit, projectId, visibleNodeIds }: LeafViewProps) { // A cell can be closed only when it lives inside a (binary) split: closing it // collapses the parent split, keeping the *sibling*. Splits are always binary // in this model (a split wraps a leaf into a 2-child container), so the kept @@ -204,13 +225,25 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm // Build the terminal opener based on whether an agent is pinned. const agentId = agent ?? null; - /** - * Whether `candidate` is currently running in a cell *other than this one*. - * Such an agent cannot be launched here (one live session per agent), so the - * dropdown disables it and `onChange` rejects selecting it. - */ - const isLiveElsewhere = (candidate: string): boolean => - liveAgents.some((la) => la.agentId === candidate && la.nodeId !== id); + /** The live session for `candidate`, if any. */ + const liveFor = (candidate: string): LiveAgent | undefined => + liveAgents.find((la) => la.agentId === candidate); + + /** True when the live session is already displayed by another visible cell. */ + const visibleElsewhere = (candidate: string): LiveAgent | undefined => { + const live = liveFor(candidate); + return live && live.nodeId !== id && visibleNodeIds.has(live.nodeId) + ? live + : undefined; + }; + + /** A live session whose previous host cell no longer exists in the layout. */ + const backgroundLive = (candidate: string): LiveAgent | undefined => { + const live = liveFor(candidate); + return live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId) + ? live + : undefined; + }; // A transient notice shown when an action is blocked by the singleton // invariant (selecting / launching an agent already live in another cell). @@ -231,6 +264,16 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm onData: (bytes: Uint8Array) => void, convId: string | undefined, ): Promise => { + const background = backgroundLive(agentId!); + if (background?.sessionId && agentGateway!.attachLiveAgent) { + const attached = await agentGateway!.attachLiveAgent(projectId, agentId!, id); + const sessionId = attached.sessionId ?? background.sessionId; + void vm.setSession(id, sessionId); + const result = await agentGateway!.reattach(sessionId, onData); + refreshLive(); + return result.handle; + } + const handle = await agentGateway!.launchAgent( projectId, agentId!, @@ -328,15 +371,39 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm value={agentId ?? ""} onChange={(e) => { const val = e.target.value; - // Reject pinning an agent that is already running in another cell. - // (The dropdown also disables such options, but guard the change in - // case it is set programmatically.) - if (val !== "" && isLiveElsewhere(val)) { - setBusyNotice("Cet agent tourne déjà dans une autre cellule."); + if (val === "") { + setBusyNotice(null); + void vm.setCellAgent(id, null); return; } - setBusyNotice(null); - void vm.setCellAgent(id, val === "" ? null : val); + void (async () => { + const current = agentGateway?.listLiveAgents + ? await agentGateway.listLiveAgents(projectId).catch(() => liveAgents) + : liveAgents; + setLiveAgents(current); + const live = current.find((la) => la.agentId === val); + const isVisible = + live && live.nodeId !== id && visibleNodeIds.has(live.nodeId); + if (isVisible) { + setBusyNotice("Cet agent est déjà visible dans une autre cellule."); + return; + } + const isBackground = + live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId); + if (isBackground) { + if (!agentGateway?.attachLiveAgent || !live.sessionId) { + setBusyNotice("Session active introuvable pour cet agent."); + return; + } + setBusyNotice(null); + const attached = await agentGateway.attachLiveAgent(projectId, val, id); + await vm.attachLiveAgentToCell(id, val, attached.sessionId ?? live.sessionId); + refreshLive(); + return; + } + setBusyNotice(null); + await vm.setCellAgent(id, val); + })().catch((err: unknown) => setBusyNotice(describeNotice(err))); }} style={{ fontSize: 11, @@ -350,13 +417,12 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm > {agents.map((a) => { - // An agent already running in another cell cannot be pinned here. - // The agent pinned on THIS cell stays selectable (same node). - const elsewhere = isLiveElsewhere(a.id); + const elsewhere = visibleElsewhere(a.id); + const background = backgroundLive(a.id); return ( - ); })} @@ -437,9 +503,10 @@ interface SplitViewProps { cwd: string; vm: LayoutViewModel; projectId: string; + visibleNodeIds: Set; } -function SplitView({ split, cwd, vm, projectId }: SplitViewProps) { +function SplitView({ split, cwd, vm, projectId, visibleNodeIds }: SplitViewProps) { const isRow = split.direction === "row"; const baseWeights = split.children.map((c) => c.weight); const containerRef = useRef(null); @@ -480,6 +547,7 @@ function SplitView({ split, cwd, vm, projectId }: SplitViewProps) { cwd={cwd} vm={vm} projectId={projectId} + visibleNodeIds={visibleNodeIds} parentSplit={{ container: split.id, index: i, @@ -571,9 +639,10 @@ interface GridViewProps { cwd: string; vm: LayoutViewModel; projectId: string; + visibleNodeIds: Set; } -function GridView({ grid, cwd, vm, projectId }: GridViewProps) { +function GridView({ grid, cwd, vm, projectId, visibleNodeIds }: GridViewProps) { const cols = normalizeWeights(grid.colWeights) .map((p) => `${p}fr`) .join(" "); @@ -604,7 +673,14 @@ function GridView({ grid, cwd, vm, projectId }: GridViewProps) { overflow: "hidden", }} > - + ))} @@ -615,3 +691,10 @@ function GridView({ grid, cwd, vm, projectId }: GridViewProps) { function keyOf(node: LayoutNode, fallback: number): string { return node.node.id ?? String(fallback); } + +function describeNotice(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as { message: unknown }).message); + } + return String(e); +} diff --git a/frontend/src/features/layout/setCellAgent.test.tsx b/frontend/src/features/layout/setCellAgent.test.tsx index 75da0c4..c159557 100644 --- a/frontend/src/features/layout/setCellAgent.test.tsx +++ b/frontend/src/features/layout/setCellAgent.test.tsx @@ -232,15 +232,17 @@ describe("setCellAgent (agent dropdown per cell)", () => { renderGrid(layout, agent, "p1", terminal); - // Close the SECOND cell: keep the first, drop the second → its PTY dies. + // Close the SECOND cell: keep the first, drop the second. Closing a cell is + // now a detach-only view operation; it must not kill the PTY. await waitFor(() => { expect(screen.getByLabelText(`close ${droppedId}`)).toBeTruthy(); }); fireEvent.click(screen.getByLabelText(`close ${droppedId}`)); await waitFor(() => { - expect(closeSpy).toHaveBeenCalledWith("dropped-session"); + expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1); }); + expect(closeSpy).not.toHaveBeenCalled(); }); it("agent selector shows project agents as options", async () => { diff --git a/frontend/src/features/layout/singletonAgent.test.tsx b/frontend/src/features/layout/singletonAgent.test.tsx index ee390bd..94eb064 100644 --- a/frontend/src/features/layout/singletonAgent.test.tsx +++ b/frontend/src/features/layout/singletonAgent.test.tsx @@ -4,11 +4,11 @@ * - The mock agent gateway mirrors the backend guard: launching an agent already * live in another cell is refused with `AGENT_ALREADY_RUNNING`, and the same * node is idempotent; `listLiveAgents` reports who runs where (T5). - * - The per-cell dropdown disables (and `onChange` rejects) an agent already live - * in another cell, while the agent pinned on its own cell stays selectable (T6). + * - The per-cell dropdown blocks an agent already visible in another cell, but + * can rebind a background live session when the backend exposes its session id. */ import { describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import type { Gateways, LiveAgent } from "@/ports"; import { @@ -65,7 +65,13 @@ describe("singleton agent — mock gateway guard (T5)", () => { ); const live: LiveAgent[] = await agent.listLiveAgents("p1"); - expect(live).toEqual([{ agentId: a.id, nodeId: "node-A" }]); + expect(live).toEqual([ + { + agentId: a.id, + nodeId: "node-A", + sessionId: "mock-agent-session-1", + }, + ]); }); it("relaunching in the SAME node is allowed (idempotent), not refused", async () => { @@ -104,34 +110,68 @@ describe("singleton agent — dropdown guard (T6)", () => { ); } - it("disables an agent already running in another cell and rejects selecting it", async () => { + it("opens an agent already running elsewhere by rebinding its live session", async () => { + const layout = new MockLayoutGateway(); + const agent = new MockAgentGateway(); + const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" }); + await agent.launchAgent( + "p1", + a.id, + { cwd: "/x", rows: 24, cols: 80, nodeId: "old-node" }, + noop, + ); + + const tree = await layout.loadLayout("p1"); + const leafId = leaves(tree)[0].id; + + renderGrid(layout, agent); + + // The option is selectable: choosing it rebinds the running session here. + const option = await screen.findByRole("option", { + name: /Busy/, + }); + expect((option as HTMLOptionElement).disabled).toBe(false); + + const select = screen.getByRole("combobox") as HTMLSelectElement; + fireEvent.change(select, { target: { value: a.id } }); + + await waitFor(async () => { + const updated = await layout.loadLayout("p1"); + const leaf = leaves(updated).find((l) => l.id === leafId)!; + expect(leaf.agent).toBe(a.id); + expect(leaf.session).toBe("mock-agent-session-1"); + }); + expect(await agent.listLiveAgents("p1")).toEqual([ + { + agentId: a.id, + nodeId: leafId, + sessionId: "mock-agent-session-1", + }, + ]); + }); + + it("explains the missing backend contract when a live agent has no attachable session", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" }); - // Mark the agent live in a DIFFERENT node than the (single) leaf we render. vi.spyOn(agent, "listLiveAgents").mockResolvedValue([ { agentId: a.id, nodeId: "some-other-node" }, ]); renderGrid(layout, agent); - // The option for the live-elsewhere agent is rendered disabled with a label. const option = await screen.findByRole("option", { - name: /Busy \(en cours ailleurs\)/, + name: /Busy/, }); - expect((option as HTMLOptionElement).disabled).toBe(true); + expect((option as HTMLOptionElement).disabled).toBe(false); - // Programmatically selecting it must be rejected (agent not pinned). const select = screen.getByRole("combobox") as HTMLSelectElement; - const setCellAgentSpy = vi.spyOn(layout, "mutateLayout"); fireEvent.change(select, { target: { value: a.id } }); - // A notice is shown and no setCellAgent mutation was issued. - expect(await screen.findByRole("status")).toBeTruthy(); - expect( - setCellAgentSpy.mock.calls.filter((c) => c[1].type === "setCellAgent"), - ).toHaveLength(0); + expect((await screen.findByRole("status")).textContent).toMatch( + /Session active introuvable/, + ); }); it("the agent pinned on THIS cell stays selectable even while live (same node)", async () => { diff --git a/frontend/src/features/layout/useLayout.ts b/frontend/src/features/layout/useLayout.ts index 04f991b..9f810b8 100644 --- a/frontend/src/features/layout/useLayout.ts +++ b/frontend/src/features/layout/useLayout.ts @@ -17,7 +17,7 @@ import type { LayoutTree, } from "@/domain"; import { useGateways } from "@/app/di"; -import { droppedSessions, leaves, splitOp } from "./layout"; +import { leaves, splitOp } from "./layout"; /** What the layout grid UI needs from this hook. */ export interface LayoutViewModel { @@ -39,6 +39,12 @@ export interface LayoutViewModel { setSession: (target: string, session: string | null) => Promise; /** Pins or clears an agent on a cell (persisted in layout). */ setCellAgent: (target: string, agent: string | null) => Promise; + /** Shows an already-running agent session in a cell without respawning it. */ + attachLiveAgentToCell: ( + target: string, + agent: string, + session: string, + ) => Promise; /** * Records (or clears) the persistent CLI conversation id on a cell (T4b). Used * to persist the id assigned at first launch so the next open resumes it. @@ -134,23 +140,12 @@ export function useLayout( ); const merge = useCallback( async (container: string, keepIndex: number) => { - // Closing a cell collapses its split onto the kept child; the dropped - // child's PTYs must be killed (not just detached) or they'd linger as - // orphan agent processes. Snapshot the sessions to drop BEFORE mutating - // (the tree no longer holds them afterwards), then tear them down. - const dropped = layout ? droppedSessions(layout, container, keepIndex) : []; + // Closing a cell is a VIEW operation: it collapses the layout, but the + // dropped cell's PTY keeps running. TerminalView cleanup detaches the local + // stream; the agent/session can later be re-opened in a cell via IdeA. await mutate({ type: "merge", container, keepIndex }); - if (terminal) { - for (const session of dropped) { - try { - await terminal.closeTerminal(session); - } catch { - /* already gone — ignore */ - } - } - } }, - [layout, mutate, terminal], + [mutate], ); const resize = useCallback( (container: string, weights: number[]) => @@ -169,8 +164,10 @@ export function useLayout( const setCellAgent = useCallback( async (target: string, agent: string | null) => { // Find the cell's current agent + session. Changing the agent must reset - // the session (so the remounted TerminalView opens fresh for the new agent - // instead of reattaching to the old PTY) and kill the old PTY. + // the session so the remounted TerminalView opens/attaches for the new + // agent instead of reattaching to the old PTY. If the previous session was + // an agent session, it is only detached into the background; a cell is a + // view, not the process owner. const cell = layout ? leaves(layout).find((l) => l.id === target) : undefined; const oldAgent = cell?.agent ?? null; const oldSession = cell?.session ?? null; @@ -184,8 +181,37 @@ export function useLayout( { type: "setCellConversation", target, conversationId: null }, ]); - // Best-effort: tear down the previous PTY so no orphan process lingers. - if (oldSession && terminal) { + // Best-effort: tear down a previous plain terminal so no orphan shell + // lingers. Agent sessions keep running in the background. + if (oldSession && !oldAgent && terminal) { + try { + await terminal.closeTerminal(oldSession); + } catch { + /* already gone — ignore */ + } + } + }, + [layout, mutateChain, terminal], + ); + const attachLiveAgentToCell = useCallback( + async (target: string, agent: string, session: string) => { + const cell = layout ? leaves(layout).find((l) => l.id === target) : undefined; + const oldAgent = cell?.agent ?? null; + const oldSession = cell?.session ?? null; + + const operations: LayoutOperation[] = []; + if (agent !== oldAgent) { + operations.push( + { type: "setCellAgent", target, agent }, + { type: "setCellConversation", target, conversationId: null }, + ); + } + if (session !== oldSession) { + operations.push({ type: "setSession", target, session }); + } + await mutateChain(operations); + + if (oldSession && oldSession !== session && !oldAgent && terminal) { try { await terminal.closeTerminal(oldSession); } catch { @@ -212,6 +238,7 @@ export function useLayout( move, setSession, setCellAgent, + attachLiveAgentToCell, setCellConversation, }; } diff --git a/frontend/src/features/memory/MemoryPanel.tsx b/frontend/src/features/memory/MemoryPanel.tsx index 1806f0b..8df6b36 100644 --- a/frontend/src/features/memory/MemoryPanel.tsx +++ b/frontend/src/features/memory/MemoryPanel.tsx @@ -18,6 +18,7 @@ import { useCallback, useState } from "react"; import type { MemoryIndexEntry } from "@/domain"; import { Button, Panel, Spinner } from "@/shared"; import { useGateways } from "@/app/di"; +import { useEmbedder } from "@/features/embedder"; import { MemoryEditor } from "./MemoryEditor"; import { useMemory } from "./useMemory"; @@ -31,6 +32,7 @@ type EditorState = { mode: "create" } | { mode: "edit"; entry: MemoryIndexEntry export function MemoryPanel({ projectId }: MemoryPanelProps) { const vm = useMemory(projectId); + const embedderVm = useEmbedder(); const { memory } = useGateways(); const [editorState, setEditorState] = useState(null); @@ -87,6 +89,22 @@ export function MemoryPanel({ projectId }: MemoryPanelProps) { )} + {/* ── Active recall tier (read-only transparency) ── */} +

+ Tier:{" "} + {embedderVm.active ? ( + + Vector — {embedderVm.active.strategy} + {embedderVm.active.model ? ` ${embedderVm.active.model}` : ""} + + ) : ( + Naïve (None) + )} +

+ {vm.error && (

(null); + + useEffect(() => { + let cancelled = false; + setBusy(true); + setError(null); + project + .readProjectContext(projectId) + .then((text) => { + if (!cancelled) { + setContent(text); + setSavedContent(text); + } + }) + .catch((e) => { + if (!cancelled) setError(describe(e)); + }) + .finally(() => { + if (!cancelled) setBusy(false); + }); + return () => { + cancelled = true; + }; + }, [project, projectId]); + + async function save() { + setBusy(true); + setError(null); + try { + await project.updateProjectContext(projectId, content); + setSavedContent(content); + } catch (e) { + setError(describe(e)); + } finally { + setBusy(false); + } + } + + const dirty = content !== savedContent; + + return ( + + {error && ( +

+ {error} +

+ )} + +
+ .ideai/CONTEXT.md +
+ +
+