feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 09:24:51 +02:00
parent 32398827fb
commit 785e9935fd
118 changed files with 5793 additions and 866 deletions

View File

@ -114,7 +114,9 @@ Workspace 1───* Window 1───* Tab 1───1 Project
(LayoutNode récursif) ├──1 RemoteHost (Local | Ssh | Wsl) (LayoutNode récursif) ├──1 RemoteHost (Local | Ssh | Wsl)
│ feuilles └──1 AgentManifest (.ideai/agents.json) │ 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) ### 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`. - 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. - 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<String>`, `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) **`AgentTemplate`** (entité, store global)
- Champs : `id`, `name`, `content_md: MarkdownDoc`, `version: TemplateVersion`, `default_profile_id`. - Champs : `id`, `name`, `content_md: MarkdownDoc`, `version: TemplateVersion`, `default_profile_id`.
- Invariants : `version` **monotone croissante** ; toute modification du `content_md``version + 1` (voir §8). - 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. - 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é) **`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}`). - Champs : `id`, `node_id: Option<NodeId>` (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. `pty_size.rows>0 && cols>0`. - 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) **`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`. - 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. - 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. - 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` | | `UpdateTemplate` | Modifie un template, **bump version**, signale drift aux agents liés. | `TemplateStore`, `EventBus` |
| `DetectAgentDrift` | Compare `synced_template_version` vs `template.version`. | `TemplateStore`, `AgentContextStore` | | `DetectAgentDrift` | Compare `synced_template_version` vs `template.version`. | `TemplateStore`, `AgentContextStore` |
| `SyncAgentWithTemplate` | Applique la MAJ template→agent si `synchronized`. | `TemplateStore`, `AgentContextStore`, `EventBus` | | `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` | | `OpenTerminal` | Ouvre un PTY simple dans une cellule. | `RemoteHost``PtyPort`, `EventBus` |
| `WriteToTerminal` / `ResizeTerminal` / `CloseTerminal` | I/O PTY. | `PtyPort` | | `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) | | `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). - 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`. - 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`. - 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<LayoutTree, LayoutError>` (immutabilité ⇒ testabilité, undo/redo facile). - Les opérations `split`, `merge`, `resize`, `move` sont des **fonctions pures** `LayoutTree -> Result<LayoutTree, LayoutError>` (immutabilité ⇒ testabilité, undo/redo facile).
### 7.3 Sérialisation & persistance ### 7.3 Sérialisation & persistance
@ -458,6 +477,11 @@ drift(agent) =
│ ├── agents.json # AgentManifest (mapping md ↔ template ↔ sync ↔ version) │ ├── agents.json # AgentManifest (mapping md ↔ template ↔ sync ↔ version)
│ ├── layout.json # LayoutTree de l'onglet (sérialisé) │ ├── layout.json # LayoutTree de l'onglet (sérialisé)
│ ├── project.json # méta projet local (nom, profil par défaut, remote ref) │ ├── 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
│ │ ├── <slug>.md # notes mémoire, source de vérité
│ │ └── .index/ # index vectoriel dérivé/reconstructible
│ ├── agents/ │ ├── agents/
│ │ ├── reviewer.md # contexte d'un agent de projet │ │ ├── reviewer.md # contexte d'un agent de projet
│ │ ├── backend-dev.md │ │ ├── backend-dev.md
@ -470,9 +494,16 @@ drift(agent) =
│ ├── <agent-id>/ # cwd isolé par agent actif (créé à l'activation) │ ├── <agent-id>/ # cwd isolé par agent actif (créé à l'activation)
│ │ └── CLAUDE.md # fichier de convention généré par IdeA (profil-dépendant) │ │ └── 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`** : **Schéma `agents.json`** :
```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` | | 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 | | 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` | | 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` | | 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/<agent-id>/`, un dossier créé par IdeA à l'activation et nettoyé à la fermeture. **Décision** : le cwd du PTY d'un agent n'est **jamais** le project root. C'est `.ideai/run/<agent-id>/`, 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 : **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/`). 1. Le **chemin absolu du project root** (pour que l'agent sache où opérer).
2. 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. Les **skills actifs** assignés à cet agent (voir §14.2). 3. Le **contexte projet partagé** (`.ideai/CONTEXT.md`), si présent.
4. Le **rappel mémoire** du projet (index/hooks), si présent (voir §14.5.4). 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** : **Avantages** :
- Zéro collision entre agents, même N instances du même profil. - 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/<requester-id>/`. L'orchestrateur écrit un fichier JSON de requête : **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.
```json
{ "action": "spawn_agent", "name": "dev-backend", "profile": "claude-code", "context": "agents/dev-backend.md" } 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. **Mécanisme** : file-watching sur `.ideai/requests/<requester-id>/`. L'agent écrit un fichier JSON de requête stable, consommé par IdeA :
**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 :
```json ```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/<agent>.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.
--- ---

View File

@ -7,45 +7,47 @@
use tauri::ipc::Channel; use tauri::ipc::Channel;
use tauri::State; use tauri::State;
use crate::dto::DismissEmbedderSuggestionRequestDto;
use application::{ use application::{
AppError, CloseProjectInput, CreateAgentInput, CreateLayoutInput, DeleteAgentInput, AppError, AssignSkillToAgentInput, CloseProjectInput, CreateAgentInput, CreateLayoutInput,
DeleteLayoutInput, DeleteTemplateInput, DetectAgentDriftInput, GitBranchesInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput, DeleteEmbedderProfileInput,
GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput, DeleteTemplateInput,
GitStatusInput, InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, LoadLayoutInput, DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput, GitCommitInput,
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, RenameLayoutInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
SetActiveLayoutInput, SnapshotRunningAgentsInput, SyncAgentWithTemplateInput, InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput,
UpdateAgentContextInput, ListMemoriesInput, ListSkillsInput, LoadLayoutInput, MutateLayoutInput, OpenProjectInput,
AssignSkillToAgentInput, CreateSkillInput, DeleteSkillInput, ListSkillsInput, ReadAgentContextInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
UnassignSkillFromAgentInput, UpdateSkillInput, RenameLayoutInput, ResolveMemoryLinksInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
CreateMemoryInput, DeleteMemoryInput, GetMemoryInput, ListMemoriesInput, ReadMemoryIndexInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput,
RecallMemoryInput, ResolveMemoryLinksInput, UpdateMemoryInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateSkillInput,
}; };
use domain::ports::PtyHandle; use domain::ports::PtyHandle;
use crate::dto::{ use crate::dto::{
parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_node_id, parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_memory_slug,
parse_profile_id, LiveAgentListDto, parse_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id,
parse_project_id, parse_session_id, parse_template_id, AgentDriftListDto, AgentDto, parse_template_id, AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto,
AgentListDto, ConfigureProfilesRequestDto, CreateAgentFromTemplateRequestDto, AttachLiveAgentRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateProjectRequestDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
DetectProfilesRequestDto, DetectProfilesResponseDto, ErrorDto, FirstRunStateDto, DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
ConversationDetailsDto, InspectConversationRequestDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, OpenTerminalRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto,
ReattachResultDto, RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto, ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, ReattachResultDto,
SetActiveLayoutRequestDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ResizeTerminalRequestDto,
TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto, SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto,
UpdateTemplateRequestDto, WriteTerminalRequestDto, parse_skill_id, AssignSkillRequestDto, SkillListDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
CreateSkillRequestDto, SkillDto, SkillListDto, UnassignSkillRequestDto, UpdateSkillRequestDto, TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
parse_memory_slug, CreateMemoryRequestDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateSkillRequestDto,
MemoryListDto, RecallMemoryRequestDto, UpdateMemoryRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto,
}; };
use domain::{SkillRef, SkillScope};
use crate::pty::{PtyBridge, PtyChunk}; use crate::pty::{PtyBridge, PtyChunk};
use crate::state::AppState; use crate::state::AppState;
use domain::{SkillRef, SkillScope};
/// `health` — trivial command validating the full IPC pipeline /// `health` — trivial command validating the full IPC pipeline
/// (frontend gateway → invoke → command → use case → ports → event relay). /// (frontend gateway → invoke → command → use case → ports → event relay).
@ -112,10 +114,7 @@ pub async fn open_project(
/// # Errors /// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `STORE` on failure). /// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `STORE` on failure).
#[tauri::command] #[tauri::command]
pub async fn close_project( pub async fn close_project(project_id: String, state: State<'_, AppState>) -> Result<(), ErrorDto> {
project_id: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let id = parse_project_id(&project_id)?; let id = parse_project_id(&project_id)?;
// T5: freeze `agent_was_running` on every agent leaf BEFORE any PTY release, // 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 // 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<ProjectListDto,
.map_err(ErrorDto::from) .map_err(ErrorDto::from)
} }
/// `read_project_context` — read `.ideai/CONTEXT.md` for a project.
///
/// Missing context is returned as an empty string so a project whose `.ideai/`
/// was deleted can still open cleanly.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `FILESYSTEM`/`STORE` on read or UTF-8 failure).
#[tauri::command]
pub async fn read_project_context(
project_id: String,
state: State<'_, AppState>,
) -> Result<String, ErrorDto> {
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) // Terminals (L3)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -224,10 +266,7 @@ pub fn write_terminal(
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), ErrorDto> { ) -> Result<(), ErrorDto> {
let input = request.into_input()?; let input = request.into_input()?;
state state.write_terminal.execute(input).map_err(ErrorDto::from)
.write_terminal
.execute(input)
.map_err(ErrorDto::from)
} }
/// `resize_terminal` — resize a live PTY. /// `resize_terminal` — resize a live PTY.
@ -241,10 +280,7 @@ pub fn resize_terminal(
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), ErrorDto> { ) -> Result<(), ErrorDto> {
let input = request.into_input()?; let input = request.into_input()?;
state state.resize_terminal.execute(input).map_err(ErrorDto::from)
.resize_terminal
.execute(input)
.map_err(ErrorDto::from)
} }
/// `close_terminal` — kill a live PTY and tear down its channel. /// `close_terminal` — kill a live PTY and tear down its channel.
@ -629,6 +665,99 @@ pub async fn configure_profiles(
.map_err(ErrorDto::from) .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<EmbedderProfileListDto, ErrorDto> {
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<EmbedderProfileDto, ErrorDto> {
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<EmbedderEnginesDto, ErrorDto> {
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) // 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<LiveAgentListDto, ErrorDto> {
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. /// `read_agent_context` — read an agent's Markdown context.
/// ///
/// # Errors /// # Errors
@ -1190,10 +1349,7 @@ pub async fn git_log(
/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on /// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on
/// failure). /// failure).
#[tauri::command] #[tauri::command]
pub async fn git_init( pub async fn git_init(project_id: String, state: State<'_, AppState>) -> Result<(), ErrorDto> {
project_id: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&project_id, &state).await?; let project = resolve_project(&project_id, &state).await?;
let root = project.root.as_str().to_owned(); let root = project.root.as_str().to_owned();
state state
@ -1231,8 +1387,8 @@ pub async fn git_graph(
// Windows (L10) // Windows (L10)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
use application::MoveTabToNewWindowInput;
use crate::dto::{parse_tab_id, MoveTabResultDto}; use crate::dto::{parse_tab_id, MoveTabResultDto};
use application::MoveTabToNewWindowInput;
/// `move_tab_to_new_window` — detach a tab into a brand-new OS window. /// `move_tab_to_new_window` — detach a tab into a brand-new OS window.
/// ///

View File

@ -827,6 +827,183 @@ impl From<FirstRunStateOutput> 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<EmbedderProfileDto>);
impl From<Vec<EmbedderProfile>> for EmbedderProfileListDto {
fn from(v: Vec<EmbedderProfile>) -> Self {
Self(v.into_iter().map(EmbedderProfileDto).collect())
}
}
impl From<ListEmbedderProfilesOutput> for EmbedderProfileListDto {
fn from(out: ListEmbedderProfilesOutput) -> Self {
out.profiles.into()
}
}
impl From<SaveEmbedderProfileOutput> 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<SaveEmbedderProfileRequestDto> 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<OnnxModelView> 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<OnnxModelInfoDto>,
/// 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<String>,
/// 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<EmbedderEnginesView> 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<DismissChoiceDto> 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. /// Builds a [`DeleteProfileInput`] from a raw profile-id string.
/// ///
/// # Errors /// # Errors
@ -927,6 +1104,16 @@ pub struct UpdateAgentContextRequestDto {
pub content: String, 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`. /// Request DTO for `launch_agent`.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -1015,6 +1202,9 @@ pub struct LiveAgentDto {
pub agent_id: String, pub agent_id: String,
/// The hosting layout leaf's node id (UUID string). /// The hosting layout leaf's node id (UUID string).
pub node_id: 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). /// Response DTO for `list_live_agents` (transparent array on the wire).
@ -1023,21 +1213,35 @@ pub struct LiveAgentDto {
pub struct LiveAgentListDto(pub Vec<LiveAgentDto>); pub struct LiveAgentListDto(pub Vec<LiveAgentDto>);
impl LiveAgentListDto { 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] #[must_use]
pub fn from_pairs(pairs: Vec<(AgentId, NodeId)>) -> Self { pub fn from_pairs(pairs: Vec<(AgentId, NodeId, domain::SessionId)>) -> Self {
Self( Self(
pairs pairs
.into_iter() .into_iter()
.map(|(agent_id, node_id)| LiveAgentDto { .map(|(agent_id, node_id, session_id)| LiveAgentDto {
agent_id: agent_id.to_string(), agent_id: agent_id.to_string(),
node_id: node_id.to_string(), node_id: node_id.to_string(),
session_id: session_id.to_string(),
}) })
.collect(), .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. /// Parses an agent-id string (UUID) coming from the frontend.
/// ///
/// # Errors /// # Errors
@ -1056,9 +1260,9 @@ pub fn parse_agent_id(raw: &str) -> Result<AgentId, ErrorDto> {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
use application::{ use application::{
AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput, AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput, CreateTemplateOutput,
CreateTemplateOutput, DetectAgentDriftOutput, ListTemplatesOutput, SyncAgentWithTemplateOutput, DetectAgentDriftOutput, ListTemplatesOutput, SyncAgentWithTemplateOutput, UpdateTemplateInput,
UpdateTemplateInput, UpdateTemplateOutput, UpdateTemplateOutput,
}; };
use domain::{AgentTemplate, TemplateId}; use domain::{AgentTemplate, TemplateId};
@ -1253,9 +1457,7 @@ pub struct SyncAgentWithTemplateRequestDto {
// Git (L8) // Git (L8)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
use application::{ use application::{GitBranchesOutput, GitCommitOutput, GitLogOutput, GitStatusOutput};
GitBranchesOutput, GitCommitOutput, GitLogOutput, GitStatusOutput,
};
use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit}; use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit};
/// One changed path returned by `git_status`. /// One changed path returned by `git_status`.
@ -1284,7 +1486,12 @@ pub struct GitStatusListDto(pub Vec<GitFileStatusDto>);
impl From<GitStatusOutput> for GitStatusListDto { impl From<GitStatusOutput> for GitStatusListDto {
fn from(out: GitStatusOutput) -> Self { 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<MemoryIndexEntryDto>);
impl From<ReadMemoryIndexOutput> for MemoryIndexDto { impl From<ReadMemoryIndexOutput> for MemoryIndexDto {
fn from(out: ReadMemoryIndexOutput) -> Self { 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<RecallMemoryOutput> for MemoryIndexDto { impl From<RecallMemoryOutput> for MemoryIndexDto {
fn from(out: RecallMemoryOutput) -> Self { fn from(out: RecallMemoryOutput) -> Self {
Self(out.entries.into_iter().map(MemoryIndexEntryDto::from).collect()) Self(
out.entries
.into_iter()
.map(MemoryIndexEntryDto::from)
.collect(),
)
} }
} }

View File

@ -129,6 +129,21 @@ pub enum DomainEventDto {
/// Project id. /// Project id.
project_id: String, 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<String>,
/// 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). /// Raw PTY output (normally routed to a per-session channel, not here).
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
PtyOutput { PtyOutput {
@ -163,11 +178,7 @@ impl From<&DomainEvent> for DomainEventDto {
template_id: template_id.to_string(), template_id: template_id.to_string(),
version: version.get(), version: version.get(),
}, },
DomainEvent::AgentDriftDetected { DomainEvent::AgentDriftDetected { agent_id, from, to } => Self::AgentDriftDetected {
agent_id,
from,
to,
} => Self::AgentDriftDetected {
agent_id: agent_id.to_string(), agent_id: agent_id.to_string(),
from: from.get(), from: from.get(),
to: to.get(), to: to.get(),
@ -212,6 +223,19 @@ impl From<&DomainEvent> for DomainEventDto {
DomainEvent::MemoryIndexRebuilt { project_id } => Self::MemoryIndexRebuilt { DomainEvent::MemoryIndexRebuilt { project_id } => Self::MemoryIndexRebuilt {
project_id: project_id.to_string(), 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 { DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput {
session_id: session_id.to_string(), session_id: session_id.to_string(),
bytes: bytes.clone(), bytes: bytes.clone(),

View File

@ -92,6 +92,8 @@ pub fn run() {
commands::open_project, commands::open_project,
commands::close_project, commands::close_project,
commands::list_projects, commands::list_projects,
commands::read_project_context,
commands::update_project_context,
commands::open_terminal, commands::open_terminal,
commands::write_terminal, commands::write_terminal,
commands::resize_terminal, commands::resize_terminal,
@ -111,9 +113,15 @@ pub fn run() {
commands::save_profile, commands::save_profile,
commands::delete_profile, commands::delete_profile,
commands::configure_profiles, 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::create_agent,
commands::list_agents, commands::list_agents,
commands::list_live_agents, commands::list_live_agents,
commands::attach_live_agent,
commands::read_agent_context, commands::read_agent_context,
commands::update_agent_context, commands::update_agent_context,
commands::delete_agent, commands::delete_agent,

View File

@ -10,32 +10,37 @@ use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use application::{ use application::{
CloseProject, CloseTab, CloseTerminal, ConfigureProfiles, CreateAgentFromScratch, AssignSkillToAgent, CheckEmbedderSuggestion, CloseProject, CloseTab, CloseTerminal,
CreateAgentFromTemplate, CreateLayout, CreateProject, CreateTemplate, DeleteAgent, ConfigureProfiles, CreateAgentFromScratch, CreateAgentFromTemplate, CreateLayout, CreateMemory,
DeleteLayout, DeleteProfile, DeleteTemplate, DetectAgentDrift, DetectProfiles, FirstRunState, CreateProject, CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout,
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate, DescribeEmbedderEngines,
HealthUseCase, InspectConversation, LaunchAgent, ListAgents, ListLayouts, ListProfiles, ListProjects, ListSkills, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetMemory,
ListTemplates, LiveAgentRegistry, LoadLayout, MoveTabToNewWindow, MutateLayout, OpenProject, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
OpenTerminal, OrchestratorService, ReadAgentContext, ReferenceProfiles, RenameLayout, GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, ListAgents, ListEmbedderProfiles,
ResizeTerminal, SaveProfile, SetActiveLayout, SnapshotRunningAgents, ListLayouts, ListMemories, ListProfiles, ListProjects, ListSkills, ListTemplates,
AssignSkillToAgent, CreateSkill, DeleteSkill, UnassignSkillFromAgent, UpdateSkill, LiveAgentRegistry, LoadLayout, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
CreateMemory, DeleteMemory, GetMemory, ListMemories, ReadMemoryIndex, RecallMemory, OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext,
ResolveMemoryLinks, UpdateMemory, RecallMemory, ReferenceProfiles, RenameLayout, ResizeTerminal, ResolveMemoryLinks,
SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal, SaveEmbedderProfile, SaveProfile, SetActiveLayout, SnapshotRunningAgents, SuggestedThisSession,
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext,
UpdateMemory, UpdateProjectContext, UpdateSkill, UpdateTemplate, WriteToTerminal,
AGENT_MEMORY_RECALL_BUDGET,
}; };
use domain::ports::{ use domain::ports::{
AgentContextStore, AgentRuntime, Clock, Embedder, EventBus, FileSystem, GitPort, IdGenerator, AgentContextStore, AgentRuntime, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore,
MemoryRecall, MemoryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, MemoryRecall, MemoryStore,
TemplateStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, TemplateStore,
}; };
use domain::{DomainEvent, EmbedderProfile, Project, ProjectId}; use domain::{DomainEvent, EmbedderProfile, Project, ProjectId};
use infrastructure::{ use infrastructure::{
embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime, embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime,
FsEmbedderProfileStore, FsMemoryStore, FsOrchestratorWatcher, FsProfileStore, FsProjectStore, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, FsMemoryStore,
FsSkillStore, FsTemplateStore, Git2Repository, IdeaiContextStore, LocalFileSystem, FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore,
LocalProcessSpawner, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, NaiveMemoryRecall,
SystemClock, TokioBroadcastEventBus, UuidGenerator, VectorMemoryRecall, ONNX_CACHE_SUBDIR, 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; use crate::pty::PtyBridge;
@ -59,6 +64,10 @@ pub struct AppState {
pub close_tab: Arc<CloseTab>, pub close_tab: Arc<CloseTab>,
/// List known projects. /// List known projects.
pub list_projects: Arc<ListProjects>, pub list_projects: Arc<ListProjects>,
/// Read `.ideai/CONTEXT.md`.
pub read_project_context: Arc<ReadProjectContext>,
/// Overwrite `.ideai/CONTEXT.md`.
pub update_project_context: Arc<UpdateProjectContext>,
/// Open a terminal (spawn PTY, register session). /// Open a terminal (spawn PTY, register session).
pub open_terminal: Arc<OpenTerminal>, pub open_terminal: Arc<OpenTerminal>,
/// Write keystrokes to a terminal. /// Write keystrokes to a terminal.
@ -195,6 +204,19 @@ pub struct AppState {
/// Recall the most relevant memory entries for a query within a budget /// Recall the most relevant memory entries for a query within a budget
/// (LOT B — §14.5.2). /// (LOT B — §14.5.2).
pub recall_memory: Arc<RecallMemory>, pub recall_memory: Arc<RecallMemory>,
// --- Embedder config (LOT C2 — §14.5.3) ---
/// List the configured embedder profiles (`embedder.json`).
pub list_embedder_profiles: Arc<ListEmbedderProfiles>,
/// Save (upsert, validating) an embedder profile.
pub save_embedder_profile: Arc<SaveEmbedderProfile>,
/// Delete an embedder profile by id.
pub delete_embedder_profile: Arc<DeleteEmbedderProfile>,
/// Describe the embedding engines available to the configuration UI (catalogue
/// + detected local environment + compiled-in capabilities).
pub describe_embedder_engines: Arc<DescribeEmbedderEngines>,
// --- Embedder suggestion (LOT C3 — §14.5.5) ---
/// Persist the user's response to the embedder suggestion (`later`/`never`).
pub dismiss_embedder_suggestion: Arc<DismissEmbedderSuggestion>,
// --- Orchestrator (§14.3) --- // --- Orchestrator (§14.3) ---
/// Dispatches validated orchestrator requests to the agent/skill use cases. /// Dispatches validated orchestrator requests to the agent/skill use cases.
/// Shared by every per-project filesystem watcher. /// 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_project = Arc::new(CloseProject::new(Arc::clone(&store_port)));
let close_tab = Arc::new(CloseTab::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 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) --- // --- PTY adapter + terminal use cases (L3) ---
let pty = Arc::new(PortablePtyAdapter::new()); let pty = Arc::new(PortablePtyAdapter::new());
@ -372,10 +396,11 @@ impl AppState {
// Load the configured embedder profile (mono-profile for now: take the last // 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 // listed, fall back to `none`). A multi-profile selector is a follow-up; the
// `none` default keeps recall strictly naïve and dependency-free. // `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), Arc::clone(&fs_port),
app_data_dir.to_string_lossy().into_owned(), app_data_dir.to_string_lossy().into_owned(),
); ));
let embedder_store_port = Arc::clone(&embedder_store) as Arc<dyn EmbedderProfileStore>;
// `build` may run inside an ambient async runtime (Tauri's `setup`, or // `build` may run inside an ambient async runtime (Tauri's `setup`, or
// `#[tokio::test]`), so blocking the current thread on a future panics. // `#[tokio::test]`), so blocking the current thread on a future panics.
// Drive the one-shot load on a dedicated thread with its own runtime. // Drive the one-shot load on a dedicated thread with its own runtime.
@ -401,6 +426,66 @@ impl AppState {
&onnx_cache_dir, &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<dyn EmbedderEnvInspector>;
// 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<OnnxModelView> = 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<dyn EmbedderPromptStore>;
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( let create_agent = Arc::new(CreateAgentFromScratch::new(
Arc::clone(&contexts_port), Arc::clone(&contexts_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>, Arc::clone(&ids) as Arc<dyn IdGenerator>,
@ -426,6 +511,7 @@ impl AppState {
Arc::clone(&events_port), Arc::clone(&events_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>, Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&memory_recall_port), Arc::clone(&memory_recall_port),
Some(Arc::clone(&check_embedder_suggestion)),
)); ));
// --- Conversation inspection (T7) --- // --- Conversation inspection (T7) ---
@ -437,11 +523,9 @@ impl AppState {
let home_dir = std::env::var("HOME") let home_dir = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE")) .or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default(); .unwrap_or_default();
let inspectors: Vec<Arc<dyn domain::ports::SessionInspector>> = let inspectors: Vec<Arc<dyn domain::ports::SessionInspector>> = vec![Arc::new(
vec![Arc::new(ClaudeTranscriptInspector::new( ClaudeTranscriptInspector::new(Arc::clone(&fs_port), home_dir),
Arc::clone(&fs_port), )];
home_dir,
))];
let inspect_conversation = Arc::new(InspectConversation::new( let inspect_conversation = Arc::new(InspectConversation::new(
Arc::clone(&contexts_port), Arc::clone(&contexts_port),
Arc::clone(&profile_store_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_status = Arc::new(GitStatus::new(Arc::clone(&git_port)));
let git_stage = Arc::new(GitStage::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_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_branches = Arc::new(GitBranches::new(Arc::clone(&git_port)));
let git_checkout = Arc::new(GitCheckout::new( let git_checkout = Arc::new(GitCheckout::new(
Arc::clone(&git_port), Arc::clone(&git_port),
Arc::clone(&events_port), Arc::clone(&events_port),
)); ));
let git_log = Arc::new(GitLog::new(Arc::clone(&git_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))); let git_graph = Arc::new(GitGraph::new(Arc::clone(&git_port)));
// --- Skill use cases (L12) --- // --- Skill use cases (L12) ---
@ -578,6 +668,8 @@ impl AppState {
close_project, close_project,
close_tab, close_tab,
list_projects, list_projects,
read_project_context,
update_project_context,
open_terminal, open_terminal,
write_terminal, write_terminal,
resize_terminal, resize_terminal,
@ -639,6 +731,11 @@ impl AppState {
read_memory_index, read_memory_index,
resolve_memory_links, resolve_memory_links,
recall_memory, recall_memory,
list_embedder_profiles,
save_embedder_profile,
delete_embedder_profile,
describe_embedder_engines,
dismiss_embedder_suggestion,
orchestrator_service, orchestrator_service,
orchestrator_watchers: Mutex::new(HashMap::new()), orchestrator_watchers: Mutex::new(HashMap::new()),
move_tab, move_tab,

View File

@ -7,16 +7,16 @@ use app_tauri_lib::dto::{
LayoutOperationDto, OpenTerminalRequestDto, ReattachResultDto, ResizeTerminalRequestDto, LayoutOperationDto, OpenTerminalRequestDto, ReattachResultDto, ResizeTerminalRequestDto,
TerminalClosedDto, WriteTerminalRequestDto, TerminalClosedDto, WriteTerminalRequestDto,
}; };
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput}; use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId}; use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId};
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
use application::{AppError, HealthInput}; use application::{AppError, HealthInput};
use domain::events::DomainEvent; use domain::events::DomainEvent;
use domain::ids::{AgentId, SessionId}; use domain::ids::{AgentId, SessionId};
use domain::ProjectId; use domain::ProjectId;
use domain::TemplateVersion;
use domain::TemplateId; use domain::TemplateId;
use domain::TemplateVersion;
use serde_json::json; use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
@ -188,7 +188,10 @@ fn terminal_closed_dto_serialises_code_camel_case() {
// Signalled (None) round-trips as null. // Signalled (None) round-trips as null.
let none = TerminalClosedDto::from(CloseTerminalOutput { code: None }); 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] #[test]
@ -198,7 +201,10 @@ fn reattach_result_dto_serialises_camel_case() {
scrollback: vec![104, 105], scrollback: vec![104, 105],
}; };
let v = serde_json::to_value(&dto).unwrap(); 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] #[test]
@ -327,7 +333,18 @@ fn layout_dto_round_trips_a_split_tree_shape() {
conversation_id: None, conversation_id: None,
agent_was_running: false, 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(); .unwrap();
let dto = LayoutDto::from(LoadLayoutOutput { let dto = LayoutDto::from(LoadLayoutOutput {
layout_id: domain::LayoutId::new_random(), layout_id: domain::LayoutId::new_random(),

View File

@ -11,8 +11,8 @@ use application::AppError;
use application::{ use application::{
CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, ListAgentsOutput, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, ListAgentsOutput,
}; };
use domain::ports::ConversationDetails;
use domain::ids::{AgentId, NodeId, ProfileId, SessionId}; use domain::ids::{AgentId, NodeId, ProfileId, SessionId};
use domain::ports::ConversationDetails;
use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession}; use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
use domain::{Agent, AgentOrigin, ProjectPath}; use domain::{Agent, AgentOrigin, ProjectPath};
use serde_json::json; use serde_json::json;
@ -44,7 +44,10 @@ fn agent_dto_serialises_camelcase() {
assert_eq!(v["id"], agent.id.to_string()); assert_eq!(v["id"], agent.id.to_string());
assert_eq!(v["name"], "My Agent"); assert_eq!(v["name"], "My Agent");
assert_eq!(v["contextPath"], "agents/my-agent.md", "camelCase key"); 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); assert_eq!(v["synchronized"], false);
// origin: tagged `{ "type": "scratch" }` // origin: tagged `{ "type": "scratch" }`
assert_eq!(v["origin"]["type"], "scratch"); assert_eq!(v["origin"]["type"], "scratch");
@ -68,7 +71,9 @@ fn agent_list_dto_is_transparent_array() {
#[test] #[test]
fn create_agent_output_maps_to_agent_dto() { fn create_agent_output_maps_to_agent_dto() {
let agent = make_agent(5, 6); let agent = make_agent(5, 6);
let out = CreateAgentOutput { agent: agent.clone() }; let out = CreateAgentOutput {
agent: agent.clone(),
};
let dto = AgentDto::from(out); let dto = AgentDto::from(out);
assert_eq!(dto.0.id, agent.id); 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 // Option A: a stable code + a text message (the node is NOT enriched into the
// ErrorDto — the frontend branches on the code alone). // ErrorDto — the frontend branches on the code alone).
assert_eq!(dto.code, "AGENT_ALREADY_RUNNING"); 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] #[test]
fn live_agent_list_dto_serialises_camelcase_array() { fn live_agent_list_dto_serialises_camelcase_array() {
let agent_a = AgentId::from_uuid(Uuid::from_u128(11)); let agent_a = AgentId::from_uuid(Uuid::from_u128(11));
let node_a = NodeId::from_uuid(Uuid::from_u128(21)); 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 v = serde_json::to_value(&dto).unwrap();
let arr = v.as_array().expect("transparent array"); let arr = v.as_array().expect("transparent array");
assert_eq!(arr.len(), 1); assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["agentId"], agent_a.to_string()); assert_eq!(arr[0]["agentId"], agent_a.to_string());
assert_eq!(arr[0]["nodeId"], node_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. // No snake_case leak.
assert!(arr[0].get("agent_id").is_none()); assert!(arr[0].get("agent_id").is_none());
assert!(arr[0].get("node_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(); let v = serde_json::to_value(&dto).unwrap();
// Both optional fields are omitted from the wire (absent, not null). // Both optional fields are omitted from the wire (absent, not null).
assert!(v.get("lastTopic").is_none(), "absent topic ⇒ field omitted"); 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"); assert_eq!(v, json!({}), "fully degraded ⇒ empty object");
} }

View File

@ -6,7 +6,9 @@ use app_tauri_lib::dto::{
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
GitFileStatusDto, GitStageRequestDto, GitStatusListDto, GraphCommitDto, GraphCommitListDto, 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 domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit};
use serde_json::json; use serde_json::json;
use uuid::Uuid; use uuid::Uuid;

View File

@ -5,8 +5,8 @@
use app_tauri_lib::dto::{ use app_tauri_lib::dto::{
parse_layout_id, CreateLayoutRequestDto, CreateLayoutResultDto, DeleteLayoutRequestDto, parse_layout_id, CreateLayoutRequestDto, CreateLayoutResultDto, DeleteLayoutRequestDto,
DeleteLayoutResultDto, LayoutInfoDto, LayoutOperationDto, ListLayoutsDto, RenameLayoutRequestDto, DeleteLayoutResultDto, LayoutInfoDto, LayoutOperationDto, ListLayoutsDto,
SetActiveLayoutRequestDto, RenameLayoutRequestDto, SetActiveLayoutRequestDto,
}; };
use application::{ use application::{
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutKind, ListLayoutsOutput, CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutKind, ListLayoutsOutput,
@ -63,8 +63,16 @@ fn layout_info_dto_git_graph_kind() {
fn list_layouts_dto_from_output() { fn list_layouts_dto_from_output() {
let out = ListLayoutsOutput { let out = ListLayoutsOutput {
layouts: vec![ layouts: vec![
LayoutInfo { id: lid(1), name: "Default".to_owned(), kind: LayoutKind::Terminal }, LayoutInfo {
LayoutInfo { id: lid(2), name: "Backend".to_owned(), kind: LayoutKind::GitGraph }, 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), 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 dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
let op = dto.into_operation().unwrap(); let op = dto.into_operation().unwrap();
match op { match op {
application::LayoutOperation::SetCellAgent { target: t, agent: a } => { application::LayoutOperation::SetCellAgent {
target: t,
agent: a,
} => {
assert_eq!(t, target); assert_eq!(t, target);
assert_eq!(a, Some(agent)); 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 dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
let op = dto.into_operation().unwrap(); let op = dto.into_operation().unwrap();
match op { match op {
application::LayoutOperation::SetCellAgent { target: t, agent: None } => { application::LayoutOperation::SetCellAgent {
target: t,
agent: None,
} => {
assert_eq!(t, target); assert_eq!(t, target);
} }
_ => panic!("expected SetCellAgent with None agent"), _ => panic!("expected SetCellAgent with None agent"),

View File

@ -53,8 +53,14 @@ fn template_dto_serialises_camelcase() {
ProfileId::from_uuid(Uuid::from_u128(2)).to_string() ProfileId::from_uuid(Uuid::from_u128(2)).to_string()
); );
// no snake_case leak // no snake_case leak
assert!(v.get("content_md").is_none(), "no snake_case leak for contentMd"); assert!(
assert!(v.get("default_profile_id").is_none(), "no snake_case leak for defaultProfileId"); 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] #[test]
@ -98,7 +104,9 @@ fn template_list_dto_empty() {
#[test] #[test]
fn create_template_output_maps_to_template_dto() { fn create_template_output_maps_to_template_dto() {
let tmpl = make_template(5, 6); let tmpl = make_template(5, 6);
let out = CreateTemplateOutput { template: tmpl.clone() }; let out = CreateTemplateOutput {
template: tmpl.clone(),
};
let dto = TemplateDto::from(out); let dto = TemplateDto::from(out);
assert_eq!(dto.0.id, tmpl.id); 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() { fn update_template_output_maps_to_template_dto() {
let tmpl = make_template(7, 8); let tmpl = make_template(7, 8);
let bumped = tmpl.with_updated_content(MarkdownDoc::new("# Updated".to_owned())); 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); let dto = TemplateDto::from(out);
assert_eq!(dto.0.version, TemplateVersion(2)); assert_eq!(dto.0.version, TemplateVersion(2));
assert_eq!(dto.0.id, bumped.id); 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["from"], 1u64);
assert_eq!(v["to"], 3u64); assert_eq!(v["to"], 3u64);
// no snake_case leak // 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] #[test]

View File

@ -15,7 +15,10 @@ fn move_tab_result_serializes_new_window_id_camel_case() {
let dto = MoveTabResultDto::from(out); let dto = MoveTabResultDto::from(out);
let json = serde_json::to_string(&dto).unwrap(); let json = serde_json::to_string(&dto).unwrap();
assert!(json.contains("\"newWindowId\""), "json was {json}"); 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] #[test]

View File

@ -72,7 +72,10 @@ async fn stop_watch_unregisters_the_watcher() {
assert!(has_watcher(&state, &project.id)); assert!(has_watcher(&state, &project.id));
state.stop_orchestrator_watch(&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); assert_eq!(watcher_count(&state), 0);
// Stopping an unknown project is a no-op (does not panic). // Stopping an unknown project is a no-op (does not panic).

View File

@ -47,7 +47,10 @@ fn send_output_delivers_bytes_to_registered_channel() {
bridge.register(session, capturing_channel(Arc::clone(&sink))); bridge.register(session, capturing_channel(Arc::clone(&sink)));
let delivered = bridge.send_output(&session, vec![104, 105]); 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(); let captured = sink.lock().unwrap();
assert_eq!(captured.as_slice(), &[vec![104, 105]]); 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(&first)));
bridge.register(session, capturing_channel(Arc::clone(&second))); 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]); 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]]); 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); bridge.unregister_if(&session, old_gen);
// The current channel survives and still delivers — no duplication, no drop. // 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!(bridge.send_output(&session, vec![7]));
assert_eq!(new.lock().unwrap().as_slice(), &[vec![7]]); assert_eq!(new.lock().unwrap().as_slice(), &[vec![7]]);
} }

View File

@ -14,17 +14,18 @@
use std::sync::Arc; use std::sync::Arc;
use domain::ports::{ use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, IdGenerator, AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, FsError,
MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, PtyPort, RemotePath, SessionPlan, IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, PtyPort, RemotePath,
SkillStore, SpawnSpec, StoreError, SessionPlan, SkillStore, SpawnSpec, StoreError,
}; };
use domain::{ use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent, 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, ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession,
}; };
use crate::error::AppError; use crate::error::AppError;
use crate::project::project_context_path;
use crate::terminal::TerminalSessions; use crate::terminal::TerminalSessions;
/// Directory (relative to `.ideai/`) under which agent contexts are written. /// 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 /// (é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 /// config**: it may later become a per-project setting without changing the
/// contract. /// contract.
const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048; pub const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// CreateAgentFromScratch // CreateAgentFromScratch
@ -115,7 +116,9 @@ impl CreateAgentFromScratch {
entries.push(ManifestEntry::from_agent(&agent)); entries.push(ManifestEntry::from_agent(&agent));
let manifest = AgentManifest::new(manifest.version, entries) let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?; .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. // Now the path resolves: write the initial context.
let md = MarkdownDoc::new(input.initial_content.unwrap_or_default()); let md = MarkdownDoc::new(input.initial_content.unwrap_or_default());
@ -171,7 +174,10 @@ impl ListAgents {
let agents = manifest let agents = manifest
.entries .entries
.iter() .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::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
Ok(ListAgentsOutput { agents }) Ok(ListAgentsOutput { agents })
} }
@ -311,7 +317,9 @@ impl DeleteAgent {
} }
let manifest = AgentManifest::new(manifest.version, entries) let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?; .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 { self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project.id, 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 /// file at activation (ARCHITECTURE §14.5.4). Best-effort by contract: an absent
/// or empty memory yields an empty list, never blocking a launch. /// or empty memory yields an empty list, never blocking a launch.
recall: Arc<dyn MemoryRecall>, recall: Arc<dyn MemoryRecall>,
/// 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<Arc<crate::embedder::CheckEmbedderSuggestion>>,
} }
impl LaunchAgent { impl LaunchAgent {
@ -397,6 +410,7 @@ impl LaunchAgent {
events: Arc<dyn EventBus>, events: Arc<dyn EventBus>,
ids: Arc<dyn IdGenerator>, ids: Arc<dyn IdGenerator>,
recall: Arc<dyn MemoryRecall>, recall: Arc<dyn MemoryRecall>,
embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
) -> Self { ) -> Self {
Self { Self {
contexts, contexts,
@ -409,6 +423,7 @@ impl LaunchAgent {
events, events,
ids, ids,
recall, recall,
embedder_suggestion,
} }
} }
@ -426,7 +441,11 @@ impl LaunchAgent {
) -> Result<Vec<Skill>, AppError> { ) -> Result<Vec<Skill>, AppError> {
let mut out = Vec::with_capacity(agent.skills.len()); let mut out = Vec::with_capacity(agent.skills.len());
for skill_ref in &agent.skills { 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), Ok(skill) => out.push(skill),
Err(StoreError::NotFound) => {} Err(StoreError::NotFound) => {}
Err(e) => return Err(e.into()), Err(e) => return Err(e.into()),
@ -453,6 +472,19 @@ impl LaunchAgent {
self.recall.recall(root, &query).await.unwrap_or_default() 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<String, AppError> {
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. /// Executes the launch.
/// ///
/// Step order is contractually significant (and unit-tested): resolve the /// 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 // runs AFTER the NotFound resolution above (so an unknown agent still
// errors NotFound) but BEFORE any I/O (run dir, seed, spawn). If the // errors NotFound) but BEFORE any I/O (run dir, seed, spawn). If the
// agent already owns a live session: // agent already owns a live session:
// - on a *different* (or unspecified) node → refuse the launch with // - with a requested node → rebind the live session to that cell and
// `AgentAlreadyRunning`, pointing at the existing host cell; // return it without respawning;
// - on the *same* node → idempotent (a concurrent double-launch of the // - without a requested node → idempotent background/no-op launch:
// very same cell): return the existing session without respawning, // return the existing session without respawning.
// assigning no new conversation id.
// The resume path (agent dead ⇒ no live session) is unaffected. // The resume path (agent dead ⇒ no live session) is unaffected.
if let Some(existing_id) = self.sessions.session_for_agent(&input.agent_id) { if let Some(existing_id) = self.sessions.session_for_agent(&input.agent_id) {
let host_node = self if let Some(node_id) = input.node_id {
.sessions if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id) {
.node_for_agent(&input.agent_id) return Ok(LaunchAgentOutput {
.expect("a live session always has a host node"); session,
let same_node = input.node_id.as_ref() == Some(&host_node); assigned_conversation_id: None,
if !same_node {
return Err(AppError::AgentAlreadyRunning {
agent_id: input.agent_id,
node_id: host_node,
}); });
} }
// 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) { if let Some(session) = self.sessions.session(&existing_id) {
return Ok(LaunchAgentOutput { return Ok(LaunchAgentOutput {
session, session,
@ -525,9 +553,7 @@ impl LaunchAgent {
.await? .await?
.into_iter() .into_iter()
.find(|p| p.id == agent.profile_id) .find(|p| p.id == agent.profile_id)
.ok_or_else(|| { .ok_or_else(|| AppError::NotFound(format!("profile {} for agent", agent.profile_id)))?;
AppError::NotFound(format!("profile {} for agent", agent.profile_id))
})?;
// 3. Compute and create the agent's isolated run directory // 3. Compute and create the agent's isolated run directory
// `<root>/.ideai/run/<agent-id>/` (ARCHITECTURE §14.1). The PTY cwd is // `<root>/.ideai/run/<agent-id>/` (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 // 5. Resolve the agent's assigned skills (their `.md` bodies), then apply
// the injection plan side effects *before* spawning. // the injection plan side effects *before* spawning.
let skills = self.resolve_skills(&agent, &input.project.root).await?; let skills = self.resolve_skills(&agent, &input.project.root).await?;
let project_context = self.resolve_project_context(&input.project).await?;
let memory = self let memory = self
.resolve_memory(&input.project.root, content.as_str()) .resolve_memory(&input.project.root, content.as_str())
.await; .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( self.apply_injection(
&input.project, &input.project,
&agent.context_path, &agent.context_path,
&content, &content,
&project_context,
&skills, &skills,
&memory, &memory,
&mut spec, &mut spec,
@ -598,9 +638,7 @@ impl LaunchAgent {
session_id, session_id,
node_id, node_id,
spec.cwd.clone(), spec.cwd.clone(),
SessionKind::Agent { SessionKind::Agent { agent_id: agent.id },
agent_id: agent.id,
},
size, size,
); );
session.status = SessionStatus::Running; session.status = SessionStatus::Running;
@ -718,6 +756,7 @@ impl LaunchAgent {
project: &Project, project: &Project,
context_rel_path: &str, context_rel_path: &str,
content: &MarkdownDoc, content: &MarkdownDoc,
project_context: &str,
skills: &[Skill], skills: &[Skill],
memory: &[MemoryIndexEntry], memory: &[MemoryIndexEntry],
spec: &mut SpawnSpec, spec: &mut SpawnSpec,
@ -733,6 +772,7 @@ impl LaunchAgent {
// persona `.md`, then the bodies of its assigned skills (§14.2). // persona `.md`, then the bodies of its assigned skills (§14.2).
let document = compose_convention_file( let document = compose_convention_file(
project.root.as_str(), project.root.as_str(),
project_context,
content.as_str(), content.as_str(),
skills, skills,
memory, memory,
@ -769,7 +809,10 @@ fn join(base: &ProjectPath, rel: &str) -> String {
/// # Errors /// # Errors
/// Propagates [`DomainError`](domain::error::DomainError) if the joined path is /// Propagates [`DomainError`](domain::error::DomainError) if the joined path is
/// not a valid [`ProjectPath`] (should not happen for an absolute project root). /// not a valid [`ProjectPath`] (should not happen for an absolute project root).
pub(crate) fn agent_run_dir(root: &ProjectPath, agent_id: &AgentId) -> Result<ProjectPath, domain::error::DomainError> { pub(crate) fn agent_run_dir(
root: &ProjectPath,
agent_id: &AgentId,
) -> Result<ProjectPath, domain::error::DomainError> {
ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}"))) 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 /// 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, /// 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 /// so it must be told where to work), the IdeA orchestration contract, the
/// of its assigned `skills` under a `# Skills` section (ARCHITECTURE §14.2). /// 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 /// Skills are emitted in the order given (the caller passes them in manifest
/// order, making the output deterministic); each is introduced by a `##` header /// order, making the output deterministic); each is introduced by a `##` header
@ -858,6 +902,7 @@ fn json_escape(s: &str) -> String {
#[must_use] #[must_use]
pub(crate) fn compose_convention_file( pub(crate) fn compose_convention_file(
project_root: &str, project_root: &str,
project_context: &str,
agent_md: &str, agent_md: &str,
skills: &[Skill], skills: &[Skill],
memory: &[MemoryIndexEntry], memory: &[MemoryIndexEntry],
@ -871,6 +916,21 @@ pub(crate) fn compose_convention_file(
opère sur le project root, pas sur ce dossier.\n\n", opère sur le project root, pas sur ce dossier.\n\n",
); );
out.push_str("---\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/<ton-agent>/` ; 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); out.push_str(agent_md);
if !skills.is_empty() { 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). /// suffix when needed. Shared with the template-driven agent creation (L7).
pub(crate) fn unique_md_path(name: &str, manifest: &AgentManifest) -> String { pub(crate) fn unique_md_path(name: &str, manifest: &AgentManifest) -> String {
let slug = slugify(name); 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 candidate = format!("{AGENTS_SUBDIR}/{base}.md");
let mut n = 2; let mut n = 2;
while manifest.entries.iter().any(|e| e.md_path == candidate) { while manifest.entries.iter().any(|e| e.md_path == candidate) {
@ -967,7 +1031,7 @@ mod tests {
#[test] #[test]
fn compose_convention_file_carries_root_then_persona() { fn compose_convention_file_carries_root_then_persona() {
let doc = 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. // Absolute project root present.
assert!(doc.contains("/abs/project/root")); assert!(doc.contains("/abs/project/root"));
@ -982,6 +1046,26 @@ mod tests {
assert!(!doc.contains("# Skills")); 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] #[test]
fn compose_convention_file_appends_assigned_skills_in_order() { fn compose_convention_file_appends_assigned_skills_in_order() {
let s = |n: u128, name: &str, body: &str| { let s = |n: u128, name: &str, body: &str| {
@ -995,8 +1079,12 @@ mod tests {
}; };
let doc = compose_convention_file( let doc = compose_convention_file(
"/root", "/root",
"",
"# Persona", "# 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() { fn compose_convention_file_empty_memory_is_identical_to_no_memory() {
// An empty `memory` must yield exactly the previous document: no section, // An empty `memory` must yield exactly the previous document: no section,
// byte-for-byte identical to the no-skills/no-memory composition. // 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!( assert!(
!with_empty.contains("# Mémoire projet"), !with_empty.contains("# Mémoire projet"),
"no memory ⇒ no memory section" "no memory ⇒ no memory section"
@ -1037,7 +1125,7 @@ mod tests {
// 4-arg call; this pins the omission contract explicitly). // 4-arg call; this pins the omission contract explicitly).
assert_eq!( assert_eq!(
with_empty, 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() { fn compose_convention_file_appends_memory_entries_in_order() {
let doc = compose_convention_file( let doc = compose_convention_file(
"/root", "/root",
"",
"# Persona", "# Persona",
&[], &[],
&[ &[
mem("alpha-note", "Alpha", "the first hook", MemoryType::User), 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. // Deterministic order: first entry precedes the second.
let alpha_at = doc.find("[Alpha]").unwrap(); let alpha_at = doc.find("[Alpha]").unwrap();
let beta_at = doc.find("[Beta]").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] #[test]
@ -1080,6 +1177,7 @@ mod tests {
.unwrap(); .unwrap();
let doc = compose_convention_file( let doc = compose_convention_file(
"/root", "/root",
"",
"# Persona", "# Persona",
std::slice::from_ref(&skill), std::slice::from_ref(&skill),
&[mem("note", "Note", "a hook", MemoryType::Project)], &[mem("note", "Note", "a hook", MemoryType::Project)],

View File

@ -14,14 +14,12 @@ mod usecases;
pub(crate) use lifecycle::unique_md_path; pub(crate) use lifecycle::unique_md_path;
pub use catalogue::{reference_profile_id, reference_profiles}; pub use catalogue::{reference_profile_id, reference_profiles};
pub use inspect::{ pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
InspectConversation, InspectConversationInput, InspectConversationOutput,
};
pub use lifecycle::{ pub use lifecycle::{
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, UpdateAgentContext, ListAgentsOutput, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
UpdateAgentContextInput, UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
}; };
pub use usecases::{ pub use usecases::{
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,

View File

@ -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,
};

View File

@ -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<Mutex<HashSet<ProjectId>>>;
/// 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<dyn EmbedderProfileStore>,
memories: Arc<dyn MemoryStore>,
prompts: Arc<dyn EmbedderPromptStore>,
inspector: Arc<dyn EmbedderEnvInspector>,
events: Arc<dyn EventBus>,
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<dyn EmbedderProfileStore>,
memories: Arc<dyn MemoryStore>,
prompts: Arc<dyn EmbedderPromptStore>,
inspector: Arc<dyn EmbedderEnvInspector>,
events: Arc<dyn EventBus>,
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<CheckEmbedderSuggestionOutput, AppError> {
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<DismissChoice> 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<dyn EmbedderPromptStore>,
}
impl DismissEmbedderSuggestion {
/// Builds the use case from the [`EmbedderPromptStore`] port.
#[must_use]
pub fn new(prompts: Arc<dyn EmbedderPromptStore>) -> 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(())
}
}

View File

@ -0,0 +1,245 @@
//! Embedder configuration use cases (LOT C2). Each is a single-responsibility
//! struct carrying its ports as `Arc<dyn Port>` 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<EmbedderProfile>,
}
/// Lists the configured embedder profiles from the store.
pub struct ListEmbedderProfiles {
store: Arc<dyn EmbedderProfileStore>,
}
impl ListEmbedderProfiles {
/// Builds the use case from the [`EmbedderProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn EmbedderProfileStore>) -> Self {
Self { store }
}
/// Lists configured embedder profiles.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(&self) -> Result<ListEmbedderProfilesOutput, AppError> {
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<String>,
/// Endpoint URL for a server/API strategy.
pub endpoint: Option<String>,
/// Name of the env var carrying the API key (never the key itself).
pub api_key_env: Option<String>,
/// 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<dyn EmbedderProfileStore>,
}
impl SaveEmbedderProfile {
/// Builds the use case from the [`EmbedderProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn EmbedderProfileStore>) -> 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<SaveEmbedderProfileOutput, AppError> {
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<dyn EmbedderProfileStore>,
}
impl DeleteEmbedderProfile {
/// Builds the use case from the [`EmbedderProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn EmbedderProfileStore>) -> 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<OnnxModelView>,
/// 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<String>,
/// 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<dyn EmbedderEnvInspector>,
recommended_onnx: Vec<OnnxModelView>,
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<dyn EmbedderEnvInspector>,
recommended_onnx: Vec<OnnxModelView>,
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<EmbedderEnginesView, AppError> {
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,
})
}
}

View File

@ -6,7 +6,7 @@ mod usecases;
pub use usecases::{ pub use usecases::{
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit, GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput, GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit,
GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput, GitInitInput, GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus,
GitStatusOutput, GitUnstage, GitStatusInput, GitStatusOutput, GitUnstage,
}; };

View File

@ -47,12 +47,12 @@ pub struct HealthUseCase {
impl HealthUseCase { impl HealthUseCase {
/// Builds the use case from its injected ports. /// Builds the use case from its injected ports.
#[must_use] #[must_use]
pub fn new(clock: Arc<dyn Clock>, ids: Arc<dyn IdGenerator>, events: Arc<dyn EventBus>) -> Self { pub fn new(
Self { clock: Arc<dyn Clock>,
clock, ids: Arc<dyn IdGenerator>,
ids, events: Arc<dyn EventBus>,
events, ) -> Self {
} Self { clock, ids, events }
} }
/// Executes the health check. /// Executes the health check.

View File

@ -25,14 +25,14 @@ mod snapshot;
mod store; mod store;
mod usecases; mod usecases;
pub use snapshot::{
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
};
pub use management::{ pub use management::{
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput, CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout, DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
}; };
pub use snapshot::{
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
};
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE}; pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
pub use usecases::{ pub use usecases::{
LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout, LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout,

View File

@ -151,7 +151,8 @@ pub async fn persist_doc(
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let ideai_dir = RemotePath::new(join_root(&project.root, IDEAI_DIR)); let ideai_dir = RemotePath::new(join_root(&project.root, IDEAI_DIR));
fs.create_dir_all(&ideai_dir).await?; 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(()) Ok(())
} }

View File

@ -8,7 +8,10 @@
use std::sync::Arc; use std::sync::Arc;
use domain::ports::{EventBus, FileSystem, ProjectStore}; 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; use crate::error::AppError;
@ -69,6 +72,14 @@ pub enum LayoutOperation {
/// Session to host, or `None` to clear. /// Session to host, or `None` to clear.
session: Option<SessionId>, session: Option<SessionId>,
}, },
/// 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). /// Attach or detach an agent to/from a leaf (per-cell agent, feature #3).
SetCellAgent { SetCellAgent {
/// The hosting leaf. /// The hosting leaf.
@ -117,6 +128,7 @@ impl LayoutOperation {
Self::Resize { container, weights } => tree.resize(*container, weights), Self::Resize { container, weights } => tree.resize(*container, weights),
Self::Move { from, to } => tree.move_session(*from, *to), Self::Move { from, to } => tree.move_session(*from, *to),
Self::SetSession { target, session } => tree.set_session(*target, *session), 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::SetCellAgent { target, agent } => tree.set_cell_agent(*target, *agent),
Self::SetCellConversation { Self::SetCellConversation {
target, target,

View File

@ -12,6 +12,7 @@
#![warn(missing_docs)] #![warn(missing_docs)]
pub mod agent; pub mod agent;
pub mod embedder;
pub mod error; pub mod error;
pub mod git; pub mod git;
pub mod health; pub mod health;
@ -35,17 +36,23 @@ pub use agent::{
ListProfiles, ListProfilesOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput, ListProfiles, ListProfilesOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput,
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, SaveProfile, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, SaveProfile,
SaveProfileInput, SaveProfileOutput, UpdateAgentContext, UpdateAgentContextInput, 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 error::AppError;
pub use orchestrator::{OrchestratorOutcome, OrchestratorService};
pub use git::{ pub use git::{
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit, GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput, GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit,
GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput, GitInitInput, GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus,
GitStatusOutput, GitUnstage, GitStatusInput, GitStatusOutput, GitUnstage,
}; };
pub use health::{HealthInput, HealthReport, HealthUseCase}; pub use health::{HealthInput, HealthReport, HealthUseCase};
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
pub use layout::{ pub use layout::{
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput, CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts, DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
@ -56,15 +63,25 @@ pub use layout::{
}; };
pub use memory::{ pub use memory::{
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput, CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput, ListMemoriesOutput, GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput,
ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory, RecallMemoryInput, ListMemoriesOutput, ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory,
RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput, RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput,
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
}; };
pub use orchestrator::{OrchestratorOutcome, OrchestratorService};
pub use project::{ pub use project::{
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject, CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject, 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::{ pub use template::{
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput, AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
@ -74,16 +91,9 @@ pub use template::{
SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput, SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput,
UpdateTemplateOutput, UpdateTemplateOutput,
}; };
pub use skill::{
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
UpdateSkillOutput,
};
pub use terminal::{ pub use terminal::{
LiveAgentRegistry, CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, OpenTerminal,
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput, OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions,
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, WriteToTerminal, WriteToTerminal, WriteToTerminalInput,
WriteToTerminalInput,
}; };
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput}; pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};

View File

@ -16,8 +16,8 @@ mod usecases;
pub use usecases::{ pub use usecases::{
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput, CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput, ListMemoriesOutput, GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput,
ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory, RecallMemoryInput, ListMemoriesOutput, ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory,
RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput, RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput,
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput, ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
}; };

View File

@ -136,7 +136,8 @@ impl UpdateMemory {
let memory = Memory::new(frontmatter, MarkdownDoc::new(input.content)) let memory = Memory::new(frontmatter, MarkdownDoc::new(input.content))
.map_err(|e| AppError::Invalid(e.to_string()))?; .map_err(|e| AppError::Invalid(e.to_string()))?;
self.memories.save(&input.project_root, &memory).await?; 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 }) Ok(UpdateMemoryOutput { memory })
} }
} }
@ -262,8 +263,11 @@ impl DeleteMemory {
/// - [`AppError::NotFound`] if no note carries that slug, /// - [`AppError::NotFound`] if no note carries that slug,
/// - [`AppError::Store`] on persistence failure. /// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: DeleteMemoryInput) -> Result<(), AppError> { pub async fn execute(&self, input: DeleteMemoryInput) -> Result<(), AppError> {
self.memories.delete(&input.project_root, &input.slug).await?; self.memories
self.events.publish(DomainEvent::MemoryDeleted { slug: input.slug }); .delete(&input.project_root, &input.slug)
.await?;
self.events
.publish(DomainEvent::MemoryDeleted { slug: input.slug });
Ok(()) Ok(())
} }
} }

View File

@ -16,7 +16,7 @@
use std::sync::Arc; use std::sync::Arc;
use domain::ports::ProfileStore; use domain::ports::ProfileStore;
use domain::{OrchestratorCommand, Project, ProfileId}; use domain::{OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
use crate::agent::{ use crate::agent::{
CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents,
@ -95,7 +95,11 @@ impl OrchestratorService {
name, name,
profile, profile,
context, 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::StopAgent { name } => self.stop_agent(project, name).await,
OrchestratorCommand::UpdateAgentContext { name, context } => { OrchestratorCommand::UpdateAgentContext { name, context } => {
self.update_agent_context(project, name, context).await self.update_agent_context(project, name, context).await
@ -115,15 +119,19 @@ impl OrchestratorService {
&self, &self,
project: &Project, project: &Project,
name: String, name: String,
profile: String, profile: Option<String>,
context: Option<String>, context: Option<String>,
visibility: OrchestratorVisibility,
) -> Result<OrchestratorOutcome, AppError> { ) -> Result<OrchestratorOutcome, AppError> {
let existing = self.find_agent_id_by_name(project, &name).await?; let existing = self.find_agent_id_by_name(project, &name).await?;
let agent_id = match existing { let agent_id = match existing {
Some(id) => id, Some(id) => id,
None => { 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 let created = self
.create_agent .create_agent
.execute(CreateAgentInput { .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 self.launch_agent
.execute(LaunchAgentInput { .execute(LaunchAgentInput {
project: project.clone(), project: project.clone(),
agent_id, agent_id,
rows: DEFAULT_ROWS, rows: DEFAULT_ROWS,
cols: DEFAULT_COLS, cols: DEFAULT_COLS,
node_id: None, node_id,
conversation_id: None, conversation_id: None,
}) })
.await?; .await?;
Ok(OrchestratorOutcome { 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}")
}
},
}) })
} }

View File

@ -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<dyn FileSystem>,
}
impl ReadProjectContext {
/// Builds the use case.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>) -> 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<ReadProjectContextOutput, AppError> {
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<dyn FileSystem>,
}
impl UpdateProjectContext {
/// Builds the use case.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>) -> 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 `<root>/.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}"),
))
}

View File

@ -14,11 +14,18 @@
//! - [`ListProjects`] — list known projects from the registry. //! - [`ListProjects`] — list known projects from the registry.
mod close; mod close;
mod context;
mod create; mod create;
pub(crate) mod meta; pub(crate) mod meta;
mod open; mod open;
pub use close::{CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput}; 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 create::{CreateProject, CreateProjectInput, CreateProjectOutput};
pub use meta::ProjectMeta; pub use meta::ProjectMeta;
pub use open::{ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput}; pub use open::{
ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput,
};

View File

@ -7,9 +7,7 @@ use domain::{AgentManifest, Project, ProjectId};
use crate::error::AppError; use crate::error::AppError;
use super::meta::{ use super::meta::{from_json_bytes, join_root, ProjectMeta, AGENTS_FILE, IDEAI_DIR, PROJECT_FILE};
from_json_bytes, join_root, ProjectMeta, AGENTS_FILE, IDEAI_DIR, PROJECT_FILE,
};
/// Input for [`OpenProject::execute`]. /// Input for [`OpenProject::execute`].
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]

View File

@ -249,12 +249,20 @@ impl AssignSkillToAgent {
// Mutate through the domain entity so the dedup invariant is enforced in // Mutate through the domain entity so the dedup invariant is enforced in
// one place, then fold the result back into the manifest entry. // 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); let changed = agent.assign_skill(input.skill);
*entry = domain::ManifestEntry::from_agent(&agent); *entry = domain::ManifestEntry::from_agent(&agent);
if changed { if changed {
self.persist_and_announce(&input.project, manifest, input.agent_id, input.skill.skill_id, true) self.persist_and_announce(
&input.project,
manifest,
input.agent_id,
input.skill.skill_id,
true,
)
.await?; .await?;
} }
Ok(()) Ok(())
@ -320,14 +328,18 @@ impl UnassignSkillFromAgent {
.find(|e| e.agent_id == input.agent_id) .find(|e| e.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", 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); let changed = agent.unassign_skill(input.skill_id);
*entry = domain::ManifestEntry::from_agent(&agent); *entry = domain::ManifestEntry::from_agent(&agent);
if changed { if changed {
let manifest = AgentManifest::new(manifest.version, manifest.entries) let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|e| AppError::Invalid(e.to_string()))?; .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 { self.events.publish(DomainEvent::SkillAssigned {
agent_id: input.agent_id, agent_id: input.agent_id,
skill_id: input.skill_id, skill_id: input.skill_id,

View File

@ -286,7 +286,9 @@ impl CreateAgentFromTemplate {
entries.push(ManifestEntry::from_agent(&agent)); entries.push(ManifestEntry::from_agent(&agent));
let manifest = AgentManifest::new(manifest.version, entries) let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?; .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. // Seed the agent context with the template content.
self.contexts self.contexts
@ -477,7 +479,9 @@ impl SyncAgentWithTemplate {
// Persist the manifest (revalidated) and overwrite the agent context. // Persist the manifest (revalidated) and overwrite the agent context.
let manifest = AgentManifest::new(manifest.version, manifest.entries) let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|e| AppError::Invalid(e.to_string()))?; .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 self.contexts
.write_context(&input.project, &input.agent_id, &template.content_md) .write_context(&input.project, &input.agent_id, &template.content_md)
.await?; .await?;

View File

@ -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 /// 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). /// is already running elsewhere (it cannot be launched in a second cell).
#[must_use] #[must_use]
pub fn live_agents(&self) -> Vec<(AgentId, NodeId)> { pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
self.entries self.entries
.lock() .lock()
.map(|m| { .map(|m| {
m.values() m.values()
.filter_map(|e| match e.session.kind { .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, SessionKind::Plain => None,
}) })
.collect() .collect()
@ -148,6 +150,28 @@ impl TerminalSessions {
.unwrap_or_default() .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<TerminalSession> {
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. /// Returns the [`PtyHandle`]s of every currently-registered session.
/// ///
/// Used at application shutdown to kill all live PTYs cleanly (the /// Used at application shutdown to kill all live PTYs cleanly (the

View File

@ -67,10 +67,7 @@ impl OpenTerminal {
/// # Errors /// # Errors
/// - [`AppError::Invalid`] for a non-absolute cwd or a zero-sized terminal, /// - [`AppError::Invalid`] for a non-absolute cwd or a zero-sized terminal,
/// - [`AppError::Process`] if the PTY fails to spawn. /// - [`AppError::Process`] if the PTY fails to spawn.
pub async fn execute( pub async fn execute(&self, input: OpenTerminalInput) -> Result<OpenTerminalOutput, AppError> {
&self,
input: OpenTerminalInput,
) -> Result<OpenTerminalOutput, AppError> {
let cwd = ProjectPath::new(input.cwd).map_err(|e| AppError::Invalid(e.to_string()))?; let cwd = ProjectPath::new(input.cwd).map_err(|e| AppError::Invalid(e.to_string()))?;
let size = let size =
PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?; PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?;

View File

@ -192,7 +192,15 @@ fn profile(id: ProfileId) -> AgentProfile {
} }
fn agent(id: AgentId, profile_id: ProfileId) -> Agent { 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 { fn input(agent_id: AgentId) -> InspectConversationInput {
@ -228,7 +236,10 @@ async fn supporting_inspector_propagates_details() {
); );
let out = uc.execute(input(aid)).await.unwrap(); 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)); assert_eq!(out.details.token_count, Some(4242));
// Routed the conversation id and the agent's isolated run dir (not the root). // 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 aid = AgentId::from_uuid(Uuid::from_u128(42));
let a = agent(aid, pid); let a = agent(aid, pid);
let (inspector, _seen) = let (inspector, _seen) = FakeInspector::new(
FakeInspector::new(true, InspectResult::Err(InspectError::Read("boom".to_owned()))); true,
InspectResult::Err(InspectError::Read("boom".to_owned())),
);
let uc = InspectConversation::new( let uc = InspectConversation::new(
Arc::new(FakeContexts::with_agent(&a)), 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(); 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 aid = AgentId::from_uuid(Uuid::from_u128(99));
let (inspector, _seen) = FakeInspector::new( let (inspector, _seen) = FakeInspector::new(
true, true,
InspectResult::Ok(ConversationDetails { last_topic: None, token_count: None }), InspectResult::Ok(ConversationDetails {
last_topic: None,
token_count: None,
}),
); );
let uc = InspectConversation::new( let uc = InspectConversation::new(

View File

@ -29,16 +29,16 @@ use domain::ports::{
OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath,
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
}; };
use domain::{MemoryIndexEntry, MemorySlug, MemoryType};
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy}; use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
use domain::project::{Project, ProjectPath}; use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef; use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope}; use domain::skill::{Skill, SkillScope};
use domain::{MemoryIndexEntry, MemorySlug, MemoryType};
use domain::{PtySize, SessionId, SkillId, SkillRef}; use domain::{PtySize, SessionId, SkillId, SkillRef};
use uuid::Uuid; use uuid::Uuid;
use application::{ use application::{
AppError, CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent, CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
LaunchAgentInput, ListAgents, ListAgentsInput, ReadAgentContext, ReadAgentContextInput, LaunchAgentInput, ListAgents, ListAgentsInput, ReadAgentContext, ReadAgentContextInput,
TerminalSessions, UpdateAgentContext, UpdateAgentContextInput, TerminalSessions, UpdateAgentContext, UpdateAgentContextInput,
}; };
@ -84,7 +84,10 @@ impl FakeContexts {
let me = Self::new(); let me = Self::new();
{ {
let mut inner = me.0.lock().unwrap(); let mut inner = me.0.lock().unwrap();
inner.manifest.entries.push(ManifestEntry::from_agent(agent)); inner
.manifest
.entries
.push(ManifestEntry::from_agent(agent));
inner inner
.contents .contents
.insert(agent.context_path.clone(), content.to_owned()); .insert(agent.context_path.clone(), content.to_owned());
@ -196,7 +199,12 @@ impl FakeSkills {
#[async_trait] #[async_trait]
impl SkillStore for FakeSkills { impl SkillStore for FakeSkills {
async fn list(&self, scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> { async fn list(&self, scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, 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( async fn get(
&self, &self,
@ -333,6 +341,7 @@ struct FakeFs {
trace: Trace, trace: Trace,
writes: WriteLog<String>, writes: WriteLog<String>,
created_dirs: Arc<Mutex<Vec<String>>>, created_dirs: Arc<Mutex<Vec<String>>>,
project_context: Arc<Mutex<Option<String>>>,
} }
impl FakeFs { impl FakeFs {
@ -341,8 +350,12 @@ impl FakeFs {
trace, trace,
writes: Arc::new(Mutex::new(Vec::new())), writes: Arc::new(Mutex::new(Vec::new())),
created_dirs: 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<u8>)> { fn writes(&self) -> Vec<(String, Vec<u8>)> {
self.writes.lock().unwrap().clone() self.writes.lock().unwrap().clone()
} }
@ -376,6 +389,15 @@ impl FakeFs {
#[async_trait] #[async_trait]
impl FileSystem for FakeFs { impl FileSystem for FakeFs {
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> { async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, 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())) Err(FsError::NotFound(path.as_str().to_owned()))
} }
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
@ -648,7 +670,10 @@ async fn read_then_update_context_roundtrips() {
.await .await
.unwrap(); .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] #[tokio::test]
@ -696,7 +721,10 @@ type LaunchFixture = (
); );
/// Wires a LaunchAgent over fakes for a given injection strategy/plan. /// Wires a LaunchAgent over fakes for a given injection strategy/plan.
fn launch_fixture(injection: ContextInjection, plan: Option<ContextInjectionPlan>) -> LaunchFixture { fn launch_fixture(
injection: ContextInjection,
plan: Option<ContextInjectionPlan>,
) -> LaunchFixture {
launch_fixture_with_profile(profile(pid(9), injection), plan) 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(bus.clone()),
Arc::new(SeqIds::new()), Arc::new(SeqIds::new()),
Arc::new(recall), Arc::new(recall),
None,
); );
(launch, agent, fs, pty, bus, sessions, tr, session_probe) (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 // Ordering contract. The Claude permission seed is written first (right after
// the run dir is created), then prepare → injection (convention file) → spawn. // 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.len(), 1);
assert_eq!(seeds[0].0, format!("{run_dir}/.claude/settings.local.json")); assert_eq!(seeds[0].0, format!("{run_dir}/.claude/settings.local.json"));
let seed = String::from_utf8(seeds[0].1.clone()).unwrap(); let seed = String::from_utf8(seeds[0].1.clone()).unwrap();
assert!(seed.contains("bypassPermissions"), "seed grants autonomy: {seed}"); assert!(
assert!(seed.contains("/home/me/proj"), "seed grants the project root"); 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. // The run directory (and the seed's `.claude` subdir) were created before spawn.
assert_eq!(fs.created_run_dirs(), vec![run_dir.clone()]); assert_eq!(fs.created_run_dirs(), vec![run_dir.clone()]);
assert!(fs assert!(fs.created_dirs().contains(&format!("{run_dir}/.claude")));
.created_dirs()
.contains(&format!("{run_dir}/.claude")));
// Spawn happened at the isolated run dir with the profile command. // Spawn happened at the isolated run dir with the profile command.
let spawns = pty.spawns(); 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 /// Inserts a live agent session into the registry, simulating an already-running
/// agent pinned on `node`. Returns the session id. /// agent pinned on `node`. Returns the session id.
fn seed_live_agent_session( 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 /// **Singleton invariant (T1)**: launching an agent that already owns a live
/// session in *another* cell is refused with `AgentAlreadyRunning`, pointing at /// session in another cell re-attaches the existing session to the requested
/// the existing host node. The registry is left untouched and the PTY is never /// node. The PTY is never spawned a second time.
/// spawned.
#[tokio::test] #[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( let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture(
ContextInjection::convention_file("CLAUDE.md").unwrap(), ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some(ContextInjectionPlan::File { 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)); seed_live_agent_session(&sessions, agent.id, host, sid(42));
let before = sessions.len(); 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); let mut input = launch_input(agent.id);
input.node_id = Some(nid(2)); let target = nid(2);
let err = launch.execute(input).await.expect_err("must be refused"); input.node_id = Some(target);
let out = launch.execute(input).await.expect("reattach succeeds");
match err { assert_eq!(out.session.id, sid(42), "returns the existing session");
AppError::AgentAlreadyRunning { agent_id, node_id } => { assert_eq!(out.session.node_id, target, "session is rebound to target");
assert_eq!(agent_id, agent.id); assert_eq!(sessions.node_for_agent(&agent.id), Some(target));
assert_eq!(node_id, host, "reports the existing host cell"); assert_eq!(sessions.len(), before, "registry size is unchanged");
} assert!(pty.spawns().is_empty(), "no PTY spawn on reattach");
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");
} }
/// A launch with an unspecified node (`node_id: None`) for an already-live agent /// 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] #[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( let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture(
ContextInjection::convention_file("CLAUDE.md").unwrap(), ContextInjection::convention_file("CLAUDE.md").unwrap(),
Some(ContextInjectionPlan::File { 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)); seed_live_agent_session(&sessions, agent.id, nid(1), sid(42));
// node_id defaults to None in launch_input. // node_id defaults to None in launch_input.
let err = launch let out = launch
.execute(launch_input(agent.id)) .execute(launch_input(agent.id))
.await .await
.expect_err("must be refused"); .expect("background relaunch is idempotent");
assert!(matches!(err, AppError::AgentAlreadyRunning { .. })); assert_eq!(out.session.id, sid(42));
assert_eq!(out.session.node_id, nid(1));
assert!(pty.spawns().is_empty()); 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"); let out = launch.execute(input).await.expect("idempotent launch");
assert_eq!(out.session.id, sid(42), "returns the existing session"); 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_eq!(sessions.len(), before, "no new session registered");
assert!(pty.spawns().is_empty(), "no respawn on same-node relaunch"); 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"); let out = launch.execute(input).await.expect("relaunch must succeed");
assert_eq!(out.session.id, sid(777), "spawned a fresh PTY session"); 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* /// **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(SpyBus::default()),
Arc::new(SeqIds::new()), Arc::new(SeqIds::new()),
Arc::new(FakeRecall::default()), Arc::new(FakeRecall::default()),
None,
); );
launch.execute(launch_input(agent_a.id)).await.unwrap(); 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_a = format!("/home/me/proj/.ideai/run/{}", agent_a.id);
let dir_b = format!("/home/me/proj/.ideai/run/{}", agent_b.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). // 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()]); 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")); assert!(writes.iter().all(|(p, _)| p != "/home/me/proj/CLAUDE.md"));
// Each convention file carries its own persona. // Each convention file carries its own persona.
assert!(String::from_utf8(writes[0].1.clone()).unwrap().contains("# alpha")); assert!(String::from_utf8(writes[0].1.clone())
assert!(String::from_utf8(writes[1].1.clone()).unwrap().contains("# bravo")); .unwrap()
.contains("# alpha"));
assert!(String::from_utf8(writes[1].1.clone())
.unwrap()
.contains("# bravo"));
} }
#[tokio::test] #[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). // 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_id = |n: u128| SkillId::from_uuid(Uuid::from_u128(n));
let skill = |n: u128, name: &str, body: &str| { 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)); 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(SpyBus::default()),
Arc::new(SeqIds::new()), Arc::new(SeqIds::new()),
Arc::new(FakeRecall::default()), Arc::new(FakeRecall::default()),
None,
); );
launch.execute(launch_input(agent.id)).await.unwrap(); 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 persona_at = doc.find("# persona").unwrap();
let refac_at = doc.find("REFAC_BODY").unwrap(); let refac_at = doc.find("REFAC_BODY").unwrap();
let review_at = doc.find("REVIEW_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] #[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 // `# 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). // and the exact `- [Title](slug.md) — hook (type)` format (§14.5.4).
let recall = FakeRecall::returning(vec![ let recall = FakeRecall::returning(vec![
mem_entry("git-optional", "Git optionnel", "git reste un simple tool", MemoryType::Project), mem_entry(
mem_entry("perm-archi", "Permissions", "sandbox OS + résumé injecté", MemoryType::Reference), "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) = let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
launch_fixture_with_profile_and_recall( 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 { Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(), target: "CLAUDE.md".to_owned(),
}), }),
@ -1124,7 +1222,10 @@ async fn launch_conventionfile_injects_project_memory_in_order() {
assert_eq!(writes.len(), 1); assert_eq!(writes.len(), 1);
let doc = String::from_utf8(writes[0].1.clone()).unwrap(); 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!( assert!(
doc.contains("- [Git optionnel](git-optional.md) — git reste un simple tool (project)"), doc.contains("- [Git optionnel](git-optional.md) — git reste un simple tool (project)"),
"first memory line exact format: {doc}" "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 first_at = doc.find("[Git optionnel]").unwrap();
let second_at = doc.find("[Permissions]").unwrap(); let second_at = doc.find("[Permissions]").unwrap();
assert!(persona_at < section_at, "memory after persona: {doc}"); 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] #[tokio::test]
@ -1155,7 +1259,10 @@ async fn launch_conventionfile_without_memory_omits_section() {
launch.execute(launch_input(agent.id)).await.unwrap(); launch.execute(launch_input(agent.id)).await.unwrap();
let doc = String::from_utf8(fs.context_writes()[0].1.clone()).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] #[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. // succeeds and the convention file simply carries no memory section.
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
launch_fixture_with_profile_and_recall( 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 { Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(), 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 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(); let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap();
assert!( 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() { async fn launch_env_strategy_injects_no_memory_section() {
// For the `env` strategy IdeA writes no convention file, so no memory section is // For the `env` strategy IdeA writes no convention file, so no memory section is
// injected — aligned with how skills are only injected for `conventionFile`. // injected — aligned with how skills are only injected for `conventionFile`.
let recall = FakeRecall::returning(vec![mem_entry( let recall = FakeRecall::returning(vec![mem_entry("note", "Note", "a hook", MemoryType::User)]);
"note",
"Note",
"a hook",
MemoryType::User,
)]);
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
launch_fixture_with_profile_and_recall( launch_fixture_with_profile_and_recall(
profile(pid(9), ContextInjection::env("IDEA_CONTEXT").unwrap()), 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 // 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). // 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)); 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 contexts = FakeContexts::with_agent(&agent, "# persona");
let profiles = FakeProfiles::new(vec![profile( 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(SpyBus::default()),
Arc::new(SeqIds::new()), Arc::new(SeqIds::new()),
Arc::new(FakeRecall::default()), 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(); 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] #[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. // 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!(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(); let writes = pty.writes();
assert_eq!(writes.len(), 1); assert_eq!(writes.len(), 1);
assert_eq!(writes[0].0, sid(777)); assert_eq!(writes[0].0, sid(777));
@ -1290,10 +1411,8 @@ async fn launch_stdin_strategy_pipes_context_after_spawn() {
#[tokio::test] #[tokio::test]
async fn launch_unknown_agent_is_not_found() { async fn launch_unknown_agent_is_not_found() {
let (launch, _agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture( let (launch, _agent, _fs, pty, _bus, _sessions, _tr, _session) =
ContextInjection::stdin(), launch_fixture(ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin));
Some(ContextInjectionPlan::Stdin),
);
let err = launch.execute(launch_input(aid(404))).await.unwrap_err(); let err = launch.execute(launch_input(aid(404))).await.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
assert!(pty.spawns().is_empty(), "no spawn for unknown agent"); 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( let launch = LaunchAgent::new(
Arc::new(contexts), Arc::new(contexts),
Arc::new(profiles), 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(FakeFs::new(Arc::clone(&tr))),
Arc::new(pty.clone()), Arc::new(pty.clone()),
Arc::new(FakeSkills::default()), Arc::new(FakeSkills::default()),
@ -1318,6 +1440,7 @@ async fn launch_unknown_profile_is_not_found() {
Arc::new(SpyBus::default()), Arc::new(SpyBus::default()),
Arc::new(SeqIds::new()), Arc::new(SeqIds::new()),
Arc::new(FakeRecall::default()), Arc::new(FakeRecall::default()),
None,
); );
let err = launch.execute(launch_input(agent.id)).await.unwrap_err(); 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), 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. // SeqIds yields Uuid::from_u128(1) on its first call.
let expected = Uuid::from_u128(1).to_string(); let expected = Uuid::from_u128(1).to_string();
// The use case exposes the assigned id (caller persists it on the leaf). // 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. // The runtime received an Assign plan carrying exactly that id.
assert_eq!( assert_eq!(
*session.lock().unwrap(), *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) = let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) =
launch_fixture(ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin)); 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!(out.assigned_conversation_id, None);
assert_eq!(*session.lock().unwrap(), Some(SessionPlan::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), 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)); assert_eq!(*session.lock().unwrap(), Some(SessionPlan::None));
} }

View File

@ -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<Mutex<Vec<EmbedderProfile>>>);
#[async_trait]
impl EmbedderProfileStore for InMemoryEmbedderProfileStore {
async fn list(&self) -> Result<Vec<EmbedderProfile>, 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);
}

View File

@ -28,7 +28,10 @@ fn fs_not_found_maps_to_not_found_other_to_filesystem() {
AppError::from(FsError::PermissionDenied("/tmp/x".into())).code(), AppError::from(FsError::PermissionDenied("/tmp/x".into())).code(),
"FILESYSTEM" "FILESYSTEM"
); );
assert_eq!(AppError::from(FsError::Io("boom".into())).code(), "FILESYSTEM"); assert_eq!(
AppError::from(FsError::Io("boom".into())).code(),
"FILESYSTEM"
);
} }
#[test] #[test]
@ -67,7 +70,10 @@ fn memory_errors_map_per_variant() {
assert_eq!(invalid.code(), "INVALID"); assert_eq!(invalid.code(), "INVALID");
assert_eq!(invalid, AppError::Invalid("bad yaml".to_owned())); assert_eq!(invalid, AppError::Invalid("bad yaml".to_owned()));
// Io → Store. // 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). // Serialization → Store (the catch-all arm).
assert_eq!( assert_eq!(
AppError::from(MemoryError::Serialization("bad index".into())).code(), AppError::from(MemoryError::Serialization("bad index".into())).code(),

View File

@ -82,11 +82,7 @@ impl GitPort for FakeGit {
self.record(&format!("checkout:{branch}")); self.record(&format!("checkout:{branch}"));
Ok(()) Ok(())
} }
async fn log( async fn log(&self, _r: &ProjectPath, _limit: usize) -> Result<Vec<GitCommitInfo>, GitError> {
&self,
_r: &ProjectPath,
_limit: usize,
) -> Result<Vec<GitCommitInfo>, GitError> {
self.record("log"); self.record("log");
Ok(Vec::new()) Ok(Vec::new())
} }
@ -135,7 +131,9 @@ async fn status_passes_through_to_port() {
staged: true, staged: true,
}]); }]);
let out = GitStatus::new(Arc::new(git.clone())) let out = GitStatus::new(Arc::new(git.clone()))
.execute(GitStatusInput { root: ROOT.to_owned() }) .execute(GitStatusInput {
root: ROOT.to_owned(),
})
.await .await
.unwrap(); .unwrap();
assert_eq!(out.entries.len(), 1); assert_eq!(out.entries.len(), 1);
@ -229,9 +227,14 @@ async fn checkout_publishes_event() {
#[tokio::test] #[tokio::test]
async fn branches_returns_list_and_current() { async fn branches_returns_list_and_current() {
let git = FakeGit::default(); 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())) let out = GitBranches::new(Arc::new(git.clone()))
.execute(GitBranchesInput { root: ROOT.to_owned() }) .execute(GitBranchesInput {
root: ROOT.to_owned(),
})
.await .await
.unwrap(); .unwrap();
assert_eq!(out.branches, vec!["main", "dev"]); assert_eq!(out.branches, vec!["main", "dev"]);

View File

@ -180,8 +180,14 @@ fn lid(n: u128) -> LayoutId {
} }
async fn register_project(store: &FakeStore, id: ProjectId) -> ProjectId { async fn register_project(store: &FakeStore, id: ProjectId) -> ProjectId {
let project = let project = Project::new(
Project::new(id, "Demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::Local, 0).unwrap(); id,
"Demo",
ProjectPath::new(ROOT).unwrap(),
RemoteRef::Local,
0,
)
.unwrap();
store.save_project(&project).await.unwrap(); store.save_project(&project).await.unwrap();
id id
} }
@ -264,7 +270,10 @@ async fn load_migrates_a_legacy_layout_json() {
let fs = FakeFs::default(); let fs = FakeFs::default();
let id = register_project(&store, pid(2)).await; let id = register_project(&store, pid(2)).await;
// Only the legacy single-layout file exists. // 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 load = LoadLayout::new(Arc::new(store), Arc::new(fs.clone()));
let out = load let out = load
@ -300,7 +309,10 @@ async fn load_defaults_to_single_empty_leaf_when_absent() {
_ => panic!("expected a single default leaf"), _ => panic!("expected a single default leaf"),
} }
// Default was written through; two loads are deterministic. // 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")); 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); let tree = active_tree_json(&env.fs);
assert_eq!(tree["root"]["type"], "split", "tagged on `type`"); assert_eq!(tree["root"]["type"], "split", "tagged on `type`");
assert_eq!(tree["root"]["node"]["direction"], "row"); 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!( assert_eq!(
env.bus.events(), env.bus.events(),
@ -499,6 +514,62 @@ async fn mutate_set_session_attaches_and_clears() {
.is_none()); .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] #[tokio::test]
async fn mutate_invalid_op_errors_and_does_not_persist() { async fn mutate_invalid_op_errors_and_does_not_persist() {
let env = mut_env(pid(14)).await; let env = mut_env(pid(14)).await;
@ -744,7 +815,9 @@ async fn create_layout_appends_and_activates_it() {
.unwrap(); .unwrap();
let list = ListLayouts::new(Arc::new(store), Arc::new(fs)) let list = ListLayouts::new(Arc::new(store), Arc::new(fs))
.execute(ListLayoutsInput { project_id: pid(30) }) .execute(ListLayoutsInput {
project_id: pid(30),
})
.await .await
.unwrap(); .unwrap();
assert_eq!(list.layouts.len(), 2, "Default + Backend"); assert_eq!(list.layouts.len(), 2, "Default + Backend");
@ -774,11 +847,7 @@ async fn create_layout_rejects_empty_name() {
#[tokio::test] #[tokio::test]
async fn rename_layout_changes_the_name() { async fn rename_layout_changes_the_name() {
let (store, fs, bus) = mgmt_env(pid(32)).await; let (store, fs, bus) = mgmt_env(pid(32)).await;
RenameLayout::new( RenameLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
Arc::new(store.clone()),
Arc::new(fs.clone()),
Arc::new(bus),
)
.execute(RenameLayoutInput { .execute(RenameLayoutInput {
project_id: pid(32), project_id: pid(32),
layout_id: lid(1), layout_id: lid(1),
@ -788,7 +857,9 @@ async fn rename_layout_changes_the_name() {
.unwrap(); .unwrap();
let list = ListLayouts::new(Arc::new(store), Arc::new(fs)) let list = ListLayouts::new(Arc::new(store), Arc::new(fs))
.execute(ListLayoutsInput { project_id: pid(32) }) .execute(ListLayoutsInput {
project_id: pid(32),
})
.await .await
.unwrap(); .unwrap();
assert_eq!(list.layouts[0].name, "Main"); assert_eq!(list.layouts[0].name, "Main");
@ -825,21 +896,23 @@ async fn delete_active_layout_reassigns_active() {
.await .await
.unwrap(); .unwrap();
let out = DeleteLayout::new( let out = DeleteLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
Arc::new(store.clone()),
Arc::new(fs.clone()),
Arc::new(bus),
)
.execute(DeleteLayoutInput { .execute(DeleteLayoutInput {
project_id: pid(34), project_id: pid(34),
layout_id: created.layout_id, layout_id: created.layout_id,
}) })
.await .await
.unwrap(); .unwrap();
assert_eq!(out.active_id, lid(1), "active fell back to the Default layout"); assert_eq!(
out.active_id,
lid(1),
"active fell back to the Default layout"
);
let list = ListLayouts::new(Arc::new(store), Arc::new(fs)) let list = ListLayouts::new(Arc::new(store), Arc::new(fs))
.execute(ListLayoutsInput { project_id: pid(34) }) .execute(ListLayoutsInput {
project_id: pid(34),
})
.await .await
.unwrap(); .unwrap();
assert_eq!(list.layouts.len(), 1); assert_eq!(list.layouts.len(), 1);
@ -863,11 +936,7 @@ async fn set_active_layout_switches_and_load_follows() {
.unwrap(); .unwrap();
// Switch back to the Default layout. // Switch back to the Default layout.
SetActiveLayout::new( SetActiveLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
Arc::new(store.clone()),
Arc::new(fs.clone()),
Arc::new(bus),
)
.execute(SetActiveLayoutInput { .execute(SetActiveLayoutInput {
project_id: pid(35), project_id: pid(35),
layout_id: lid(1), layout_id: lid(1),

View File

@ -17,7 +17,8 @@ use domain::{
use application::{ use application::{
CreateMemory, CreateMemoryInput, DeleteMemory, DeleteMemoryInput, GetMemory, GetMemoryInput, CreateMemory, CreateMemoryInput, DeleteMemory, DeleteMemoryInput, GetMemory, GetMemoryInput,
ListMemories, ListMemoriesInput, ReadMemoryIndex, ReadMemoryIndexInput, RecallMemory, ListMemories, ListMemoriesInput, ReadMemoryIndex, ReadMemoryIndexInput, RecallMemory,
RecallMemoryInput, ResolveMemoryLinks, ResolveMemoryLinksInput, UpdateMemory, UpdateMemoryInput, RecallMemoryInput, ResolveMemoryLinks, ResolveMemoryLinksInput, UpdateMemory,
UpdateMemoryInput,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -65,10 +66,7 @@ impl MemoryStore for FakeMemories {
Ok(()) Ok(())
} }
async fn read_index( async fn read_index(&self, _root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
&self,
_root: &ProjectPath,
) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
Ok(self Ok(self
.0 .0
.lock() .lock()
@ -292,7 +290,15 @@ async fn update_revalidates_invariants_and_rejects_empty_content() {
assert_eq!(err.code(), "INVALID"); assert_eq!(err.code(), "INVALID");
// Original note untouched, no event. // 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()); assert!(bus.events().is_empty());
} }

View File

@ -17,7 +17,8 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait; use async_trait::async_trait;
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry}; use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
use domain::events::DomainEvent; 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::markdown::MarkdownDoc;
use domain::ports::{ use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
@ -25,7 +26,6 @@ use domain::ports::{
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError, StoreError,
}; };
use domain::ids::SkillId;
use domain::profile::{AgentProfile, ContextInjection}; use domain::profile::{AgentProfile, ContextInjection};
use domain::project::{Project, ProjectPath}; use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef; use domain::remote::RemoteRef;
@ -65,7 +65,10 @@ impl FakeContexts {
let me = Self::new(); let me = Self::new();
{ {
let mut inner = me.0.lock().unwrap(); let mut inner = me.0.lock().unwrap();
inner.manifest.entries.push(ManifestEntry::from_agent(agent)); inner
.manifest
.entries
.push(ManifestEntry::from_agent(agent));
inner inner
.contents .contents
.insert(agent.context_path.clone(), content.to_owned()); .insert(agent.context_path.clone(), content.to_owned());
@ -160,7 +163,11 @@ impl ProfileStore for FakeProfiles {
struct FakeSkills; struct FakeSkills;
#[async_trait] #[async_trait]
impl SkillStore for FakeSkills { impl SkillStore for FakeSkills {
async fn list(&self, _scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> { async fn list(
&self,
_scope: SkillScope,
_root: &ProjectPath,
) -> Result<Vec<Skill>, StoreError> {
Ok(Vec::new()) Ok(Vec::new())
} }
async fn get( async fn get(
@ -206,7 +213,11 @@ struct RecordingSkills(Arc<Mutex<Vec<Skill>>>);
#[async_trait] #[async_trait]
impl SkillStore for RecordingSkills { impl SkillStore for RecordingSkills {
async fn list(&self, _scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> { async fn list(
&self,
_scope: SkillScope,
_root: &ProjectPath,
) -> Result<Vec<Skill>, StoreError> {
Ok(self.0.lock().unwrap().clone()) Ok(self.0.lock().unwrap().clone())
} }
async fn get( async fn get(
@ -287,14 +298,19 @@ impl FileSystem for FakeFs {
struct FakePty { struct FakePty {
next_id: SessionId, next_id: SessionId,
kills: Arc<Mutex<Vec<SessionId>>>, kills: Arc<Mutex<Vec<SessionId>>>,
spawns: Arc<Mutex<Vec<SessionId>>>,
} }
impl FakePty { impl FakePty {
fn new(next_id: SessionId) -> Self { fn new(next_id: SessionId) -> Self {
Self { Self {
next_id, next_id,
kills: Arc::new(Mutex::new(Vec::new())), kills: Arc::new(Mutex::new(Vec::new())),
spawns: Arc::new(Mutex::new(Vec::new())),
} }
} }
fn spawns(&self) -> Vec<SessionId> {
self.spawns.lock().unwrap().clone()
}
fn kills(&self) -> Vec<SessionId> { fn kills(&self) -> Vec<SessionId> {
self.kills.lock().unwrap().clone() self.kills.lock().unwrap().clone()
} }
@ -302,6 +318,7 @@ impl FakePty {
#[async_trait] #[async_trait]
impl PtyPort for FakePty { impl PtyPort for FakePty {
async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> { async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
self.spawns.lock().unwrap().push(self.next_id);
Ok(PtyHandle { Ok(PtyHandle {
session_id: self.next_id, session_id: self.next_id,
}) })
@ -368,6 +385,9 @@ fn aid(n: u128) -> AgentId {
fn sid(n: u128) -> SessionId { fn sid(n: u128) -> SessionId {
SessionId::from_uuid(Uuid::from_u128(n)) SessionId::from_uuid(Uuid::from_u128(n))
} }
fn nid(n: u128) -> NodeId {
NodeId::from_uuid(Uuid::from_u128(n))
}
fn project() -> Project { fn project() -> Project {
Project::new( Project::new(
@ -430,6 +450,7 @@ fn fixture(contexts: FakeContexts) -> Fixture {
Arc::new(bus.clone()), Arc::new(bus.clone()),
Arc::new(SeqIds::new()), Arc::new(SeqIds::new()),
Arc::new(FakeRecall), Arc::new(FakeRecall),
None,
)); ));
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new( 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_eq!(manifest.entries[0].name, "dev-backend");
assert!(fx.sessions.session(&sid(777)).is_some()); assert!(fx.sessions.session(&sid(777)).is_some());
let launched = fx let launched = fx.bus.events().into_iter().any(
.bus |e| matches!(e, DomainEvent::AgentLaunched { session_id, .. } if session_id == sid(777)),
.events() );
.into_iter()
.any(|e| matches!(e, DomainEvent::AgentLaunched { session_id, .. } if session_id == sid(777)));
assert!(launched, "AgentLaunched must be published"); 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()); 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] #[tokio::test]
async fn stop_agent_kills_the_right_session() { async fn stop_agent_kills_the_right_session() {
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md"); let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");

View File

@ -92,9 +92,7 @@ impl AgentRuntime for StubRuntime {
match self.by_command.get(&profile.command) { match self.by_command.get(&profile.command) {
Some(DetectResult::Available) => Ok(true), Some(DetectResult::Available) => Ok(true),
Some(DetectResult::Missing) | None => Ok(false), Some(DetectResult::Missing) | None => Ok(false),
Some(DetectResult::Error) => { Some(DetectResult::Error) => Err(RuntimeError::Detection("boom".to_owned())),
Err(RuntimeError::Detection("boom".to_owned()))
}
} }
} }
@ -163,7 +161,10 @@ async fn detect_error_degrades_to_unavailable_not_hard_failure() {
.await .await
.expect("detection error must not fail the use case"); .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()]); 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()); assert!(list.execute().await.unwrap().profiles.is_empty());
} }

View File

@ -22,7 +22,8 @@ use domain::{Project, ProjectId, ProjectPath, RemoteRef};
use application::{ use application::{
CloseProject, CloseProjectInput, CloseTab, CloseTabInput, CreateProject, CreateProjectInput, 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> { async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
self.0 self.0.lock().unwrap().dirs.insert(path.as_str().to_owned());
.lock()
.unwrap()
.dirs
.insert(path.as_str().to_owned());
Ok(()) Ok(())
} }
@ -221,6 +218,8 @@ struct Env {
bus: SpyBus, bus: SpyBus,
create: CreateProject, create: CreateProject,
open: OpenProject, open: OpenProject,
read_context: ReadProjectContext,
update_context: UpdateProjectContext,
} }
fn env() -> Env { fn env() -> Env {
@ -238,6 +237,8 @@ fn env() -> Env {
Arc::new(bus.clone()), Arc::new(bus.clone()),
); );
let open = OpenProject::new(Arc::new(store.clone()), Arc::new(fs.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 { Env {
store, store,
@ -245,6 +246,8 @@ fn env() -> Env {
bus, bus,
create, create,
open, open,
read_context,
update_context,
} }
} }
@ -301,6 +304,44 @@ async fn create_registers_project_in_store() {
assert_eq!(stored[0].name, "Demo"); 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] #[tokio::test]
async fn create_publishes_project_created_event() { async fn create_publishes_project_created_event() {
let env = env(); let env = env();
@ -543,7 +584,10 @@ async fn close_without_workspace_skips_persistence() {
.await .await
.unwrap(); .unwrap();
assert!(store.saved_workspace().is_none(), "no persistence when None"); assert!(
store.saved_workspace().is_none(),
"no persistence when None"
);
} }
#[tokio::test] #[tokio::test]

View File

@ -50,7 +50,11 @@ struct FakeHost {
fs: Arc<dyn FileSystem>, fs: Arc<dyn FileSystem>,
} }
impl FakeHost { impl FakeHost {
fn make(kind: RemoteKind, connect_ok: bool, existing_root: Option<&str>) -> Arc<dyn RemoteHost> { fn make(
kind: RemoteKind,
connect_ok: bool,
existing_root: Option<&str>,
) -> Arc<dyn RemoteHost> {
Arc::new(Self { Arc::new(Self {
kind, kind,
connect_ok, connect_ok,

View File

@ -58,7 +58,10 @@ impl SkillStore for FakeSkills {
} }
async fn save(&self, skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> { async fn save(&self, skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
let mut v = self.0.lock().unwrap(); 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(); *slot = skill.clone();
} else { } else {
v.push(skill.clone()); v.push(skill.clone());
@ -85,7 +88,10 @@ impl SkillStore for FakeSkills {
struct FakeContexts(Arc<Mutex<AgentManifest>>); struct FakeContexts(Arc<Mutex<AgentManifest>>);
impl FakeContexts { impl FakeContexts {
fn new(entries: Vec<ManifestEntry>) -> Self { fn new(entries: Vec<ManifestEntry>) -> Self {
Self(Arc::new(Mutex::new(AgentManifest { version: 1, entries }))) Self(Arc::new(Mutex::new(AgentManifest {
version: 1,
entries,
})))
} }
fn manifest(&self) -> AgentManifest { fn manifest(&self) -> AgentManifest {
self.0.lock().unwrap().clone() 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!(out.skill.scope, SkillScope::Project);
assert_eq!( assert_eq!(
store.list(SkillScope::Project, &root()).await.unwrap().len(), store
.list(SkillScope::Project, &root())
.await
.unwrap()
.len(),
1 1
); );
assert!(store assert!(store

View File

@ -186,8 +186,14 @@ fn agent_leaf(node: NodeId, agent: Option<AgentId>) -> LeafCell {
} }
async fn register_project(store: &FakeStore, id: ProjectId) { async fn register_project(store: &FakeStore, id: ProjectId) {
let project = let project = Project::new(
Project::new(id, "Demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::Local, 0).unwrap(); id,
"Demo",
ProjectPath::new(ROOT).unwrap(),
RemoteRef::Local,
0,
)
.unwrap();
store.save_project(&project).await.unwrap(); store.save_project(&project).await.unwrap();
} }
@ -222,11 +228,7 @@ fn was_running(fs: &FakeFs, node: NodeId) -> Option<bool> {
find(&tree.root, node) find(&tree.root, node)
} }
fn make_use_case( fn make_use_case(store: &FakeStore, fs: &FakeFs, live: &FakeLive) -> SnapshotRunningAgents {
store: &FakeStore,
fs: &FakeFs,
live: &FakeLive,
) -> SnapshotRunningAgents {
SnapshotRunningAgents::new( SnapshotRunningAgents::new(
Arc::new(store.clone()) as Arc<dyn ProjectStore>, Arc::new(store.clone()) as Arc<dyn ProjectStore>,
Arc::new(fs.clone()) as Arc<dyn FileSystem>, Arc::new(fs.clone()) as Arc<dyn FileSystem>,
@ -247,7 +249,11 @@ async fn live_agent_is_marked_running() {
let leaf = nid(10); let leaf = nid(10);
let agent = aid(100); 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 live = FakeLive::with_nodes(&[leaf]);
let uc = make_use_case(&store, &fs, &live); 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 leaf = nid(10);
let agent = aid(100); 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. // Registry is empty: the agent has no live session.
let live = FakeLive::default(); let live = FakeLive::default();
@ -347,7 +357,11 @@ async fn snapshot_reads_liveness_before_kill() {
let leaf = nid(10); let leaf = nid(10);
let agent = aid(100); 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 live = FakeLive::with_nodes(&[leaf]);
let uc = make_use_case(&store, &fs, &live); 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 // Re-seed and demonstrate the opposite order yields `false` — proving the
// flag is sensitive to registry state at call time (hence order matters). // 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) }) uc.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
.await .await
.unwrap(); .unwrap();
@ -443,7 +461,10 @@ async fn duplicate_leaves_same_agent_only_live_node_is_running() {
.unwrap(); .unwrap();
assert_eq!(out.running, 1, "only the live cell counts as running"); 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_a), Some(true));
assert_eq!( assert_eq!(
was_running(&fs, leaf_b), was_running(&fs, leaf_b),

View File

@ -69,7 +69,10 @@ struct FakeContexts(Arc<Mutex<(AgentManifest, HashMap<String, String>)>>);
impl FakeContexts { impl FakeContexts {
fn new(entries: Vec<ManifestEntry>) -> Self { fn new(entries: Vec<ManifestEntry>) -> Self {
Self(Arc::new(Mutex::new(( Self(Arc::new(Mutex::new((
AgentManifest { version: 1, entries }, AgentManifest {
version: 1,
entries,
},
HashMap::new(), HashMap::new(),
)))) ))))
} }
@ -92,13 +95,11 @@ impl FakeContexts {
} }
#[async_trait] #[async_trait]
impl AgentContextStore for FakeContexts { impl AgentContextStore for FakeContexts {
async fn read_context( async fn read_context(&self, _p: &Project, agent: &AgentId) -> Result<MarkdownDoc, StoreError> {
&self,
_p: &Project,
agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?; 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( async fn write_context(
&self, &self,
@ -107,7 +108,11 @@ impl AgentContextStore for FakeContexts {
md: &MarkdownDoc, md: &MarkdownDoc,
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?; 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(()) Ok(())
} }
async fn load_manifest(&self, _p: &Project) -> Result<AgentManifest, StoreError> { async fn load_manifest(&self, _p: &Project) -> Result<AgentManifest, StoreError> {
@ -186,7 +191,16 @@ fn template(id: TemplateId, name: &str, content: &str, version: u64) -> AgentTem
} }
/// A synchronized, template-backed manifest entry synced at `synced`. /// A synchronized, template-backed manifest entry synced at `synced`.
fn synced_entry(agent: AgentId, md: &str, template: TemplateId, synced: u64) -> ManifestEntry { 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,7 +248,10 @@ async fn update_template_bumps_version_and_publishes_event() {
#[tokio::test] #[tokio::test]
async fn update_unknown_template_is_not_found() { async fn update_unknown_template_is_not_found() {
let err = UpdateTemplate::new(Arc::new(FakeTemplates::default()), Arc::new(SpyBus::default())) let err = UpdateTemplate::new(
Arc::new(FakeTemplates::default()),
Arc::new(SpyBus::default()),
)
.execute(UpdateTemplateInput { .execute(UpdateTemplateInput {
template_id: tid(404), template_id: tid(404),
content: "x".to_owned(), content: "x".to_owned(),
@ -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. // 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); assert_eq!(contexts.manifest().entries.len(), 1);
} }
@ -295,7 +315,15 @@ async fn detect_drift_flags_only_synchronized_agents_behind() {
// a2: synchronized, synced at v3 → up to date, no drift. // a2: synchronized, synced at v3 → up to date, no drift.
// a3: from template but NOT synchronized → ignored. // a3: from template but NOT synchronized → ignored.
// a4: scratch (no template) → 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))) let a3 = ManifestEntry::new(
aid(3),
"A3",
"agents/a3.md",
pid(1),
Some(tid(1)),
false,
Some(v(1)),
)
.unwrap(); .unwrap();
let a4 = ManifestEntry::new(aid(4), "A4", "agents/a4.md", pid(1), None, false, None).unwrap(); let a4 = ManifestEntry::new(aid(4), "A4", "agents/a4.md", pid(1), None, false, None).unwrap();
let contexts = FakeContexts::new(vec![ let contexts = FakeContexts::new(vec![
@ -306,11 +334,7 @@ async fn detect_drift_flags_only_synchronized_agents_behind() {
]); ]);
let bus = SpyBus::default(); let bus = SpyBus::default();
let out = DetectAgentDrift::new( let out = DetectAgentDrift::new(Arc::new(store), Arc::new(contexts), Arc::new(bus.clone()))
Arc::new(store),
Arc::new(contexts),
Arc::new(bus.clone()),
)
.execute(DetectAgentDriftInput { project: project() }) .execute(DetectAgentDriftInput { project: project() })
.await .await
.unwrap(); .unwrap();
@ -375,7 +399,10 @@ async fn sync_applies_to_synchronized_and_updates_version_and_context() {
assert!(out.synced); assert!(out.synced);
assert_eq!(out.version, Some(v(3))); assert_eq!(out.version, Some(v(3)));
// Context replaced by the template content. // 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. // Manifest synced version advanced to 3.
let entry = &contexts.manifest().entries[0]; let entry = &contexts.manifest().entries[0];
assert_eq!(entry.synced_template_version, Some(v(3))); assert_eq!(entry.synced_template_version, Some(v(3)));
@ -392,8 +419,15 @@ async fn sync_applies_to_synchronized_and_updates_version_and_context() {
async fn sync_ignores_non_synchronized_agent() { async fn sync_ignores_non_synchronized_agent() {
let store = FakeTemplates::with(vec![template(tid(1), "T", "body", 3)]); let store = FakeTemplates::with(vec![template(tid(1), "T", "body", 3)]);
// Non-synchronized agent from a template. // Non-synchronized agent from a template.
let entry = let entry = ManifestEntry::new(
ManifestEntry::new(aid(1), "A", "agents/a1.md", pid(1), Some(tid(1)), false, Some(v(1))) aid(1),
"A",
"agents/a1.md",
pid(1),
Some(tid(1)),
false,
Some(v(1)),
)
.unwrap(); .unwrap();
let contexts = FakeContexts::new(vec![entry]); let contexts = FakeContexts::new(vec![entry]);
contexts contexts
@ -417,5 +451,8 @@ async fn sync_ignores_non_synchronized_agent() {
assert!(!out.synced, "non-synchronized agent is left untouched"); assert!(!out.synced, "non-synchronized agent is left untouched");
assert_eq!(out.version, None); assert_eq!(out.version, None);
assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("keep me")); 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"
);
} }

View File

@ -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 close = CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions));
let out = close let out = close
.execute(CloseTerminalInput { .execute(CloseTerminalInput { session_id: sid(5) })
session_id: sid(5),
})
.await .await
.expect("close succeeds"); .expect("close succeeds");
assert_eq!(out.code, Some(0)); assert_eq!(out.code, Some(0));
assert!(sessions.is_empty(), "session removed from registry"); assert!(sessions.is_empty(), "session removed from registry");
assert!( 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" "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 close = CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions));
let out = close let out = close
.execute(CloseTerminalInput { .execute(CloseTerminalInput { session_id: sid(6) })
session_id: sid(6),
})
.await .await
.unwrap(); .unwrap();
assert_eq!(out.code, None); assert_eq!(out.code, None);

View File

@ -106,6 +106,25 @@ pub enum DomainEvent {
/// The project whose index was rebuilt. /// The project whose index was rebuilt.
project_id: ProjectId, 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<String>,
/// 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). /// Raw PTY output (usually routed to a dedicated channel, not this bus).
PtyOutput { PtyOutput {
/// The session. /// The session.

View File

@ -429,6 +429,57 @@ impl LayoutTree {
Ok(tree) 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<Self, LayoutError> {
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`. /// Attaches (or, with `None`, detaches) an [`AgentId`] to the leaf `target`.
/// ///
/// Records which agent should be auto-launched in the cell (feature #3). /// Records which agent should be auto-launched in the cell (feature #3).
@ -513,11 +564,7 @@ impl LayoutTree {
/// ///
/// # Errors /// # Errors
/// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree. /// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree.
pub fn set_agent_running( pub fn set_agent_running(&self, target: NodeId, running: bool) -> Result<Self, LayoutError> {
&self,
target: NodeId,
running: bool,
) -> Result<Self, LayoutError> {
let mut found = false; let mut found = false;
let root = map_node(&self.root, &mut |node| { let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Leaf(leaf) = node { if let LayoutNode::Leaf(leaf) = node {
@ -580,9 +627,7 @@ impl LayoutTree {
match node { match node {
LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf.session), LayoutNode::Leaf(leaf) if leaf.id == id => Some(leaf.session),
LayoutNode::Leaf(_) => None, LayoutNode::Leaf(_) => None,
LayoutNode::Split(split) => { LayoutNode::Split(split) => split.children.iter().find_map(|c| find(&c.node, id)),
split.children.iter().find_map(|c| find(&c.node, id))
}
LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)), LayoutNode::Grid(grid) => grid.cells.iter().find_map(|c| find(&c.node, id)),
} }
} }

View File

@ -74,9 +74,7 @@ pub use profile::{
pub use markdown::MarkdownDoc; pub use markdown::MarkdownDoc;
pub use memory::{ pub use memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType};
Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType,
};
pub use remote::{RemoteKind, RemoteRef, SshAuth}; pub use remote::{RemoteKind, RemoteRef, SshAuth};
@ -91,14 +89,16 @@ pub use layout::{
pub use events::DomainEvent; pub use events::DomainEvent;
pub use orchestrator::{OrchestratorCommand, OrchestratorError, OrchestratorRequest}; pub use orchestrator::{
OrchestratorCommand, OrchestratorError, OrchestratorRequest, OrchestratorVisibility,
};
pub use ports::{ pub use ports::{
AgentContextStore, AgentRuntime, Clock, ContextInjectionPlan, DirEntry, Embedder, AgentContextStore, AgentRuntime, Clock, ContextInjectionPlan, DirEntry, Embedder,
EmbedderError, EventBus, EventStream, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderProfileStore,
ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem,
IdGenerator, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator,
PreparedContext, ProcessError, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PreparedContext,
ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort,
RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError, TemplateStore, RemoteError, RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError, TemplateStore,
}; };

View File

@ -36,9 +36,7 @@ impl MemorySlug {
/// outside `[a-z0-9-]`. /// outside `[a-z0-9-]`.
pub fn new(raw: impl Into<String>) -> Result<Self, DomainError> { pub fn new(raw: impl Into<String>) -> Result<Self, DomainError> {
let raw = raw.into(); let raw = raw.into();
let invalid = || DomainError::InvalidSlug { let invalid = || DomainError::InvalidSlug { value: raw.clone() };
value: raw.clone(),
};
if raw.is_empty() { if raw.is_empty() {
return Err(invalid()); return Err(invalid());
} }

View File

@ -13,12 +13,13 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::ids::NodeId;
use crate::skill::SkillScope; use crate::skill::SkillScope;
/// Errors raised while validating a raw [`OrchestratorRequest`]. /// Errors raised while validating a raw [`OrchestratorRequest`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum OrchestratorError { 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}")] #[error("unknown orchestrator action: {0}")]
UnknownAction(String), UnknownAction(String),
/// A field required by the chosen action is missing or empty. /// A field required by the chosen action is missing or empty.
@ -37,6 +38,14 @@ pub enum OrchestratorError {
/// The offending scope value. /// The offending scope value.
scope: String, 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. /// 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct OrchestratorRequest { pub struct OrchestratorRequest {
/// The requested action (`spawn_agent`, `stop_agent`, `update_agent_context`). /// Legacy v1 action (`spawn_agent`, `stop_agent`, `update_agent_context`).
pub action: String, #[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
/// 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<String>,
/// Optional requester id/name, informational at this layer.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_by: Option<String>,
/// Target agent display name (required by every v1 action). /// Target agent display name (required by every v1 action).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>, pub name: Option<String>,
/// V2 target agent display name (`agent.run`/`agent.stop`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_agent: Option<String>,
/// Runtime profile slug/name (required by `spawn_agent`). /// Runtime profile slug/name (required by `spawn_agent`).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>, pub profile: Option<String>,
@ -61,6 +81,21 @@ pub struct OrchestratorRequest {
/// `create_skill` this carries the **new Markdown body** to write. /// `create_skill` this carries the **new Markdown body** to write.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>, pub context: Option<String>,
/// Optional task/message carried by `agent.run` / future `agent.message`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task: Option<String>,
/// 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<String>,
/// Target layout leaf for `visibility: "visible"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_id: Option<NodeId>,
/// V2 visible-cell target (`attachToCell`), equivalent to `nodeId`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attach_to_cell: Option<NodeId>,
/// Skill scope for `create_skill` (`"global"` or `"project"`, case-insensitive). /// Skill scope for `create_skill` (`"global"` or `"project"`, case-insensitive).
/// Optional — absent/empty defaults to [`SkillScope::Project`]. Ignored by the /// Optional — absent/empty defaults to [`SkillScope::Project`]. Ignored by the
/// other actions. /// other actions.
@ -79,10 +114,14 @@ pub enum OrchestratorCommand {
SpawnAgent { SpawnAgent {
/// Target agent display name. /// Target agent display name.
name: String, name: String,
/// Profile slug/name to resolve against the configured profiles. /// Profile slug/name to resolve against the configured profiles. Required
profile: String, /// for legacy `spawn_agent`; optional for `agent.run` when the agent
/// already exists and its manifest owns the profile id.
profile: Option<String>,
/// Optional initial `.md` body for a freshly-created agent. /// Optional initial `.md` body for a freshly-created agent.
context: Option<String>, context: Option<String>,
/// Desired visibility for the launched session.
visibility: OrchestratorVisibility,
}, },
/// Stop a running agent by killing its terminal session. /// Stop a running agent by killing its terminal session.
StopAgent { 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 { impl OrchestratorRequest {
/// Validates the raw request into a well-formed [`OrchestratorCommand`]. /// Validates the raw request into a well-formed [`OrchestratorCommand`].
/// ///
@ -120,25 +171,44 @@ impl OrchestratorRequest {
/// [`OrchestratorError::UnknownAction`] for an unsupported action; /// [`OrchestratorError::UnknownAction`] for an unsupported action;
/// [`OrchestratorError::MissingField`] when a required field is absent/empty. /// [`OrchestratorError::MissingField`] when a required field is absent/empty.
pub fn validate(&self) -> Result<OrchestratorCommand, OrchestratorError> { pub fn validate(&self) -> Result<OrchestratorCommand, OrchestratorError> {
let action = self.action.trim(); let action = self.action_name()?;
match action { match action {
"spawn_agent" => Ok(OrchestratorCommand::SpawnAgent { "spawn_agent" => Ok(OrchestratorCommand::SpawnAgent {
name: self.require_name(action)?, 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: self
.context .context
.as_ref() .as_ref()
.or(self.task.as_ref())
.filter(|c| !c.is_empty()) .filter(|c| !c.is_empty())
.cloned(), .cloned(),
visibility: self.parse_visibility(action)?,
}), }),
"stop_agent" => Ok(OrchestratorCommand::StopAgent { "stop_agent" => Ok(OrchestratorCommand::StopAgent {
name: self.require_name(action)?, name: self.require_name(action)?,
}), }),
"agent.stop" => Ok(OrchestratorCommand::StopAgent {
name: self.require_target_agent(action)?,
}),
"update_agent_context" => Ok(OrchestratorCommand::UpdateAgentContext { "update_agent_context" => Ok(OrchestratorCommand::UpdateAgentContext {
name: self.require_name(action)?, name: self.require_name(action)?,
context: self.require("context", action, self.context.as_deref())?, 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)?, name: self.require_name(action)?,
content: self.require("context", action, self.context.as_deref())?, content: self.require("context", action, self.context.as_deref())?,
scope: self.parse_scope(action)?, 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<OrchestratorVisibility, OrchestratorError> {
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. /// Requires a non-empty `name`, shared by all actions.
fn require_name(&self, action: &str) -> Result<String, OrchestratorError> { fn require_name(&self, action: &str) -> Result<String, OrchestratorError> {
self.require("name", action, self.name.as_deref()) self.require("name", action, self.name.as_deref())
} }
fn require_target_agent(&self, action: &str) -> Result<String, OrchestratorError> {
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: "<request>".to_owned(),
field: "type".to_owned(),
})
}
/// Requires `value` to be present and non-empty (after trimming), else a /// Requires `value` to be present and non-empty (after trimming), else a
/// [`OrchestratorError::MissingField`] naming `field`/`action`. /// [`OrchestratorError::MissingField`] naming `field`/`action`.
fn require( fn require(
@ -205,8 +315,9 @@ mod tests {
r.validate().unwrap(), r.validate().unwrap(),
OrchestratorCommand::SpawnAgent { OrchestratorCommand::SpawnAgent {
name: "dev-backend".to_owned(), name: "dev-backend".to_owned(),
profile: "claude-code".to_owned(), profile: Some("claude-code".to_owned()),
context: Some("agents/dev-backend.md".to_owned()), context: Some("agents/dev-backend.md".to_owned()),
visibility: OrchestratorVisibility::Background,
} }
); );
} }
@ -218,12 +329,43 @@ mod tests {
r.validate().unwrap(), r.validate().unwrap(),
OrchestratorCommand::SpawnAgent { OrchestratorCommand::SpawnAgent {
name: "a".to_owned(), name: "a".to_owned(),
profile: "claude-code".to_owned(), profile: Some("claude-code".to_owned()),
context: None, 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] #[test]
fn spawn_agent_missing_profile_is_rejected() { fn spawn_agent_missing_profile_is_rejected() {
let r = req(r#"{ "action": "spawn_agent", "name": "a" }"#); 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] #[test]
fn stop_agent_validates() { fn stop_agent_validates() {
let r = req(r#"{ "action": "stop_agent", "name": "dev-backend" }"#); let r = req(r#"{ "action": "stop_agent", "name": "dev-backend" }"#);
@ -261,9 +438,8 @@ mod tests {
#[test] #[test]
fn update_context_requires_a_body() { fn update_context_requires_a_body() {
let ok = req( let ok =
r##"{ "action": "update_agent_context", "name": "a", "context": "# new body" }"##, req(r##"{ "action": "update_agent_context", "name": "a", "context": "# new body" }"##);
);
assert_eq!( assert_eq!(
ok.validate().unwrap(), ok.validate().unwrap(),
OrchestratorCommand::UpdateAgentContext { OrchestratorCommand::UpdateAgentContext {
@ -284,9 +460,8 @@ mod tests {
#[test] #[test]
fn create_skill_defaults_to_project_scope() { fn create_skill_defaults_to_project_scope() {
let r = req( let r =
r##"{ "action": "create_skill", "name": "deploy", "context": "# Deploy steps" }"##, req(r##"{ "action": "create_skill", "name": "deploy", "context": "# Deploy steps" }"##);
);
assert_eq!( assert_eq!(
r.validate().unwrap(), r.validate().unwrap(),
OrchestratorCommand::CreateSkill { OrchestratorCommand::CreateSkill {
@ -343,7 +518,9 @@ mod tests {
let r = req(r#"{ "action": "delete_everything", "name": "a" }"#); let r = req(r#"{ "action": "delete_everything", "name": "a" }"#);
assert_eq!( assert_eq!(
r.validate(), r.validate(),
Err(OrchestratorError::UnknownAction("delete_everything".to_owned())) Err(OrchestratorError::UnknownAction(
"delete_everything".to_owned()
))
); );
} }

View File

@ -32,7 +32,7 @@ use crate::events::DomainEvent;
use crate::ids::{AgentId, SessionId}; use crate::ids::{AgentId, SessionId};
use crate::markdown::MarkdownDoc; use crate::markdown::MarkdownDoc;
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug}; use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
use crate::profile::AgentProfile; use crate::profile::{AgentProfile, EmbedderProfile};
use crate::project::{Project, ProjectPath}; use crate::project::{Project, ProjectPath};
use crate::remote::RemoteKind; use crate::remote::RemoteKind;
use crate::skill::{Skill, SkillScope}; use crate::skill::{Skill, SkillScope};
@ -798,6 +798,94 @@ pub trait ProfileStore: Send + Sync {
async fn mark_configured(&self) -> Result<(), StoreError>; 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<Vec<EmbedderProfile>, 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<String>,
}
/// 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<Option<EmbedderPromptDismissal>, 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. /// Reads/writes agent `.md` contexts and the project manifest, within a project.
#[async_trait] #[async_trait]
pub trait AgentContextStore: Send + Sync { pub trait AgentContextStore: Send + Sync {
@ -895,8 +983,7 @@ pub trait GitPort: Send + Sync {
/// ///
/// # Errors /// # Errors
/// [`GitError`] on failure. /// [`GitError`] on failure.
async fn log(&self, root: &ProjectPath, limit: usize) async fn log(&self, root: &ProjectPath, limit: usize) -> Result<Vec<GitCommitInfo>, GitError>;
-> Result<Vec<GitCommitInfo>, GitError>;
/// Returns the commit graph (all local branches, topological + time sort). /// Returns the commit graph (all local branches, topological + time sort).
/// ///

View File

@ -105,10 +105,19 @@ fn profile_uses_camel_case_keys_and_skips_none_options() {
) )
.unwrap(); .unwrap();
let json = serde_json::to_string(&p).unwrap(); let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("\"apiKeyEnv\":\"MY_KEY\""), "camelCase apiKeyEnv: {json}"); assert!(
assert!(json.contains("\"strategy\":\"api\""), "camelCase strategy: {json}"); json.contains("\"apiKeyEnv\":\"MY_KEY\""),
"camelCase apiKeyEnv: {json}"
);
assert!(
json.contains("\"strategy\":\"api\""),
"camelCase strategy: {json}"
);
// `model` is None ⇒ skipped from the wire form. // `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] #[test]
@ -138,7 +147,10 @@ fn none_profile_has_none_strategy() {
let p = EmbedderProfile::none(); let p = EmbedderProfile::none();
assert_eq!(p.strategy, EmbedderStrategy::None); assert_eq!(p.strategy, EmbedderStrategy::None);
assert_eq!(p.id, "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. // And it round-trips like any other profile.
roundtrip(&p); roundtrip(&p);
} }
@ -151,7 +163,9 @@ fn none_profile_has_none_strategy() {
fn new_rejects_empty_id() { fn new_rejects_empty_id() {
assert!(matches!( assert!(matches!(
EmbedderProfile::new("", "Name", EmbedderStrategy::None, None, None, None, 8), 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] #[test]
fn new_accepts_valid_minimal_profile() { 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.dimension, 1);
assert_eq!(p.strategy, EmbedderStrategy::None); assert_eq!(p.strategy, EmbedderStrategy::None);
} }

View File

@ -5,8 +5,8 @@ mod helpers;
use domain::{ use domain::{
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError, Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError,
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, SessionStrategy, ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef,
Skill, SkillId, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion, SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion,
}; };
use helpers::{AtomicSeqIdGenerator, FixedClock}; use helpers::{AtomicSeqIdGenerator, FixedClock};
use uuid::Uuid; use uuid::Uuid;
@ -203,7 +203,16 @@ fn profile_rejects_empty_command() {
#[test] #[test]
fn profile_rejects_empty_name() { fn profile_rejects_empty_name() {
let err = AgentProfile::new(profile_id(), "", "claude", vec![], ci_stdin(), None, "{r}", None) let err = AgentProfile::new(
profile_id(),
"",
"claude",
vec![],
ci_stdin(),
None,
"{r}",
None,
)
.unwrap_err(); .unwrap_err();
assert!(matches!(err, DomainError::EmptyField { field } if field == "profile.name")); assert!(matches!(err, DomainError::EmptyField { field } if field == "profile.name"));
} }
@ -331,7 +340,8 @@ fn template_version_next_increments() {
#[test] #[test]
fn template_rejects_empty_name() { 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 { .. })); assert!(matches!(err, DomainError::EmptyField { .. }));
} }
@ -345,8 +355,15 @@ fn agent_id(n: u128) -> domain::AgentId {
#[test] #[test]
fn manifest_entry_synchronized_requires_template_metadata() { fn manifest_entry_synchronized_requires_template_metadata() {
let err = let err = ManifestEntry::new(
ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, true, None) agent_id(1),
"A",
"agents/a.md",
profile_id(),
None,
true,
None,
)
.unwrap_err(); .unwrap_err();
assert!(matches!(err, DomainError::InconsistentManifest { .. })); assert!(matches!(err, DomainError::InconsistentManifest { .. }));
@ -366,8 +383,15 @@ fn manifest_entry_synchronized_requires_template_metadata() {
#[test] #[test]
fn manifest_entry_rejects_empty_name() { fn manifest_entry_rejects_empty_name() {
let err = let err = ManifestEntry::new(
ManifestEntry::new(agent_id(1), " ", "agents/a.md", profile_id(), None, false, None) agent_id(1),
" ",
"agents/a.md",
profile_id(),
None,
false,
None,
)
.unwrap_err(); .unwrap_err();
assert!(matches!(err, DomainError::EmptyField { .. })); assert!(matches!(err, DomainError::EmptyField { .. }));
} }
@ -416,11 +440,25 @@ fn manifest_entry_agent_roundtrip() {
#[test] #[test]
fn manifest_rejects_duplicate_md_path() { fn manifest_rejects_duplicate_md_path() {
let e1 = let e1 = ManifestEntry::new(
ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, false, None) agent_id(1),
"A",
"agents/a.md",
profile_id(),
None,
false,
None,
)
.unwrap(); .unwrap();
let e2 = let e2 = ManifestEntry::new(
ManifestEntry::new(agent_id(2), "B", "agents/a.md", profile_id(), None, false, None) agent_id(2),
"B",
"agents/a.md",
profile_id(),
None,
false,
None,
)
.unwrap(); .unwrap();
let err = AgentManifest::new(1, vec![e1, e2]).unwrap_err(); let err = AgentManifest::new(1, vec![e1, e2]).unwrap_err();
assert!(matches!(err, DomainError::InconsistentManifest { .. })); assert!(matches!(err, DomainError::InconsistentManifest { .. }));
@ -428,11 +466,25 @@ fn manifest_rejects_duplicate_md_path() {
#[test] #[test]
fn manifest_unique_md_paths_ok() { fn manifest_unique_md_paths_ok() {
let e1 = let e1 = ManifestEntry::new(
ManifestEntry::new(agent_id(1), "A", "agents/a.md", profile_id(), None, false, None) agent_id(1),
"A",
"agents/a.md",
profile_id(),
None,
false,
None,
)
.unwrap(); .unwrap();
let e2 = let e2 = ManifestEntry::new(
ManifestEntry::new(agent_id(2), "B", "agents/b.md", profile_id(), None, false, None) agent_id(2),
"B",
"agents/b.md",
profile_id(),
None,
false,
None,
)
.unwrap(); .unwrap();
assert!(AgentManifest::new(1, vec![e1, e2]).is_ok()); assert!(AgentManifest::new(1, vec![e1, e2]).is_ok());
} }
@ -465,7 +517,12 @@ fn skill_rejects_empty_name() {
SkillScope::Project, SkillScope::Project,
) )
.unwrap_err(); .unwrap_err();
assert_eq!(err, DomainError::EmptyField { field: "skill.name" }); assert_eq!(
err,
DomainError::EmptyField {
field: "skill.name"
}
);
} }
#[test] #[test]

View File

@ -72,9 +72,7 @@ impl Default for AtomicSeqIdGenerator {
impl IdGenerator for AtomicSeqIdGenerator { impl IdGenerator for AtomicSeqIdGenerator {
fn new_uuid(&self) -> Uuid { fn new_uuid(&self) -> Uuid {
let n = self let n = self.next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
.next
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Uuid::from_u128(u128::from(n)) Uuid::from_u128(u128::from(n))
} }
} }

View File

@ -3,11 +3,11 @@
mod helpers; mod helpers;
use domain::ids::AgentId;
use domain::{ use domain::{
Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell, Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell,
SplitContainer, WeightedChild, SplitContainer, WeightedChild,
}; };
use domain::ids::AgentId;
use helpers::{node, session}; use helpers::{node, session};
fn agent_id(n: u128) -> AgentId { fn agent_id(n: u128) -> AgentId {
@ -463,6 +463,36 @@ fn set_session_duplicate_across_leaves_rejected() {
assert_eq!(err, LayoutError::DuplicateSession(session(100))); 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) // set_cell_agent (#3: per-cell agent)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -503,7 +533,9 @@ fn set_cell_agent_is_immutable_source_unchanged() {
#[test] #[test]
fn set_cell_agent_missing_leaf_is_node_not_found() { fn set_cell_agent_missing_leaf_is_node_not_found() {
let tree = single(1, None); 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))); assert_eq!(err, LayoutError::NodeNotFound(node(404)));
} }

View File

@ -2,9 +2,7 @@
//! [`MemorySlug`] validation, `[[slug]]` link scanning, frontmatter serde //! [`MemorySlug`] validation, `[[slug]]` link scanning, frontmatter serde
//! round-trip, and the derived index entry. //! round-trip, and the derived index entry.
use domain::{ use domain::{DomainError, MarkdownDoc, Memory, MemoryFrontmatter, MemorySlug, MemoryType};
DomainError, MarkdownDoc, Memory, MemoryFrontmatter, MemorySlug, MemoryType,
};
fn fm(slug: &str, description: &str, ty: MemoryType) -> MemoryFrontmatter { fn fm(slug: &str, description: &str, ty: MemoryType) -> MemoryFrontmatter {
MemoryFrontmatter { MemoryFrontmatter {
@ -93,7 +91,10 @@ fn memory_rejects_empty_description() {
#[test] #[test]
fn memory_rejects_empty_body() { 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 { .. })); assert!(matches!(r.unwrap_err(), DomainError::EmptyField { .. }));
} }
@ -103,7 +104,9 @@ fn memory_rejects_empty_body() {
#[test] #[test]
fn outgoing_links_none() { 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] #[test]

View File

@ -6,8 +6,8 @@ mod helpers;
use domain::{ use domain::{
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction, Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction,
LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef, LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef,
SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth, TemplateVersion, SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth,
WeightedChild, TemplateVersion, WeightedChild,
}; };
use helpers::{node, session}; use helpers::{node, session};
use uuid::Uuid; use uuid::Uuid;
@ -72,7 +72,14 @@ fn project_uses_camel_case_and_tagged_remote() {
#[test] #[test]
fn remote_ssh_roundtrip_and_tags() { 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); assert_eq!(roundtrip(&r), r);
let json = serde_json::to_string(&r).unwrap(); let json = serde_json::to_string(&r).unwrap();
assert!(json.contains("\"kind\":\"ssh\""), "json was {json}"); assert!(json.contains("\"kind\":\"ssh\""), "json was {json}");
@ -118,9 +125,12 @@ fn profile_roundtrip_all_injection_variants() {
#[test] #[test]
fn context_injection_strategy_tag_is_camel_case() { fn context_injection_strategy_tag_is_camel_case() {
let json = serde_json::to_string(&ContextInjection::convention_file("CLAUDE.md").unwrap()) let json =
.unwrap(); serde_json::to_string(&ContextInjection::convention_file("CLAUDE.md").unwrap()).unwrap();
assert!(json.contains("\"strategy\":\"conventionFile\""), "json was {json}"); assert!(
json.contains("\"strategy\":\"conventionFile\""),
"json was {json}"
);
let json = serde_json::to_string(&ContextInjection::stdin()).unwrap(); let json = serde_json::to_string(&ContextInjection::stdin()).unwrap();
assert!(json.contains("\"strategy\":\"stdin\""), "json was {json}"); 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 session = SessionStrategy::new(None, "--continue").unwrap();
let json = serde_json::to_string(&session).unwrap(); let json = serde_json::to_string(&session).unwrap();
assert!(!json.contains("assignFlag"), "json was {json}"); 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); assert_eq!(roundtrip(&session), session);
} }
@ -244,13 +257,22 @@ fn agent_roundtrip_from_template() {
let json = serde_json::to_string(&a).unwrap(); let json = serde_json::to_string(&a).unwrap();
assert!(json.contains("\"contextPath\""), "json was {json}"); assert!(json.contains("\"contextPath\""), "json was {json}");
// AgentOrigin tagged with `type`, camelCased. // 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: // Inner fields must be camelCased per ARCHITECTURE §9.1:
// { "type":"fromTemplate", "templateId":"...", "syncedTemplateVersion":N }. // { "type":"fromTemplate", "templateId":"...", "syncedTemplateVersion":N }.
assert!(json.contains("\"templateId\""), "json was {json}"); 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("\"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}"); assert!(!json.contains("\"synced_version\""), "json was {json}");
} }
@ -285,14 +307,18 @@ fn manifest_roundtrip_and_camel_case() {
Some(TemplateVersion(5)), Some(TemplateVersion(5)),
) )
.unwrap(); .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(); let m = AgentManifest::new(1, vec![e1, e2]).unwrap();
assert_eq!(roundtrip(&m), m); assert_eq!(roundtrip(&m), m);
let json = serde_json::to_string(&m).unwrap(); let json = serde_json::to_string(&m).unwrap();
// entries are serialized under "agents". // entries are serialized under "agents".
assert!(json.contains("\"agents\":["), "json was {json}"); assert!(json.contains("\"agents\":["), "json was {json}");
assert!(json.contains("\"mdPath\""), "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). // Non-synchronized entry omits optional template fields (skip_serializing_if).
assert!(!json.contains("\"templateId\":null"), "json was {json}"); assert!(!json.contains("\"templateId\":null"), "json was {json}");
} }
@ -307,13 +333,25 @@ fn sid(n: u128) -> SkillId {
#[test] #[test]
fn skill_roundtrip_and_camel_case_scope() { 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); assert_eq!(roundtrip(&s), s);
let json = serde_json::to_string(&s).unwrap(); let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"scope\":\"global\""), "json was {json}"); assert!(json.contains("\"scope\":\"global\""), "json was {json}");
assert!(json.contains("\"contentMd\""), "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(); let pj = serde_json::to_string(&p).unwrap();
assert!(pj.contains("\"scope\":\"project\""), "json was {pj}"); 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(); let json = serde_json::to_string(&tree).unwrap();
// agent present when set // 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 // null variant omitted
let tree_no_agent = LayoutTree::new(LayoutNode::Leaf(LeafCell { let tree_no_agent = LayoutTree::new(LayoutNode::Leaf(LeafCell {
id: node(2), id: node(2),
@ -422,5 +463,8 @@ fn leaf_with_agent_roundtrip_and_omits_null() {
agent_was_running: false, agent_was_running: false,
})); }));
let json2 = serde_json::to_string(&tree_no_agent).unwrap(); 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}"
);
} }

View File

@ -3,8 +3,8 @@
//! window is dropped; an active moved tab hands activity back to a sibling. //! window is dropped; an active moved tab hands activity back to a sibling.
use domain::{ use domain::{
LayoutNode, LayoutTree, LayoutError, LeafCell, NodeId, ProjectId, Tab, TabId, Window, LayoutError, LayoutNode, LayoutTree, LeafCell, NodeId, ProjectId, Tab, TabId, Window, WindowId,
WindowId, Workspace, Workspace,
}; };
use uuid::Uuid; use uuid::Uuid;

View File

@ -37,9 +37,7 @@ fn map_io(path: &RemotePath, err: &io::Error) -> FsError {
#[async_trait] #[async_trait]
impl FileSystem for LocalFileSystem { impl FileSystem for LocalFileSystem {
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> { async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
fs::read(path.as_str()) fs::read(path.as_str()).await.map_err(|e| map_io(path, &e))
.await
.map_err(|e| map_io(path, &e))
} }
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { 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))?; .map_err(|e| map_io(path, &e))?;
while let Some(entry) = read_dir.next_entry().await.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 let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
.file_type()
.await
.map(|t| t.is_dir())
.unwrap_or(false);
entries.push(DirEntry { entries.push(DirEntry {
name: entry.file_name().to_string_lossy().into_owned(), name: entry.file_name().to_string_lossy().into_owned(),
is_dir, is_dir,

View File

@ -177,11 +177,7 @@ impl GitPort for Git2Repository {
Ok(()) Ok(())
} }
async fn log( async fn log(&self, root: &ProjectPath, limit: usize) -> Result<Vec<GitCommitInfo>, GitError> {
&self,
root: &ProjectPath,
limit: usize,
) -> Result<Vec<GitCommitInfo>, GitError> {
let repo = open(root)?; let repo = open(root)?;
let mut revwalk = repo.revwalk().map_err(op)?; let mut revwalk = repo.revwalk().map_err(op)?;
// No commits yet ⇒ nothing to walk. // No commits yet ⇒ nothing to walk.

View File

@ -224,9 +224,7 @@ fn parse_transcript(body: &[u8]) -> ConversationDetails {
let output = usage.output_tokens.unwrap_or(0); let output = usage.output_tokens.unwrap_or(0);
if input != 0 || output != 0 { if input != 0 || output != 0 {
saw_usage = true; saw_usage = true;
token_total = token_total token_total = token_total.saturating_add(input).saturating_add(output);
.saturating_add(input)
.saturating_add(output);
} }
} }
} }
@ -345,10 +343,7 @@ mod tests {
#[test] #[test]
fn parse_returns_none_when_no_topic_or_usage() { fn parse_returns_none_when_no_topic_or_usage() {
let body = concat!( let body = concat!(r#"{"role":"assistant","content":"no usage here"}"#, "\n",);
r#"{"role":"assistant","content":"no usage here"}"#,
"\n",
);
let d = parse_transcript(body.as_bytes()); let d = parse_transcript(body.as_bytes());
assert_eq!(d.last_topic, None); assert_eq!(d.last_topic, None);
assert_eq!(d.token_count, None); assert_eq!(d.token_count, None);

View File

@ -39,13 +39,15 @@ pub use process::LocalProcessSpawner;
pub use pty::PortablePtyAdapter; pub use pty::PortablePtyAdapter;
pub use remote::{remote_host, LocalHost}; pub use remote::{remote_host, LocalHost};
pub use runtime::CliAgentRuntime; 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")] #[cfg(feature = "vector-onnx")]
pub use store::OnnxEmbedder; 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,
};

View File

@ -205,7 +205,10 @@ async fn scan_once(
/// Whether `path` is a request file to process: a `.json` that is not a /// Whether `path` is a request file to process: a `.json` that is not a
/// `.response.json` sibling we wrote ourselves. /// `.response.json` sibling we wrote ourselves.
fn is_request_file(path: &Path) -> bool { 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") name.ends_with(".json") && !name.ends_with(".response.json")
} }
@ -242,14 +245,21 @@ async fn dispatch_file(
Ok(r) => r, Ok(r) => r,
Err(e) => return OrchestratorResponse::failure(None, format!("invalid json: {e}")), 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() { let command = match request.validate() {
Ok(c) => c, 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 { match service.dispatch(project, command).await {
Ok(out) => OrchestratorResponse::success(action, out.detail), Ok(out) => OrchestratorResponse::success(
Err(e) => OrchestratorResponse::failure(Some(action), e.to_string()), action.unwrap_or_else(|| "unknown".to_owned()),
out.detail,
),
Err(e) => OrchestratorResponse::failure(action, e.to_string()),
} }
} }

View File

@ -256,9 +256,7 @@ impl PtyPort for PortablePtyAdapter {
live.writer live.writer
.write_all(data) .write_all(data)
.map_err(|e| PtyError::Io(e.to_string()))?; .map_err(|e| PtyError::Io(e.to_string()))?;
live.writer live.writer.flush().map_err(|e| PtyError::Io(e.to_string()))
.flush()
.map_err(|e| PtyError::Io(e.to_string()))
} }
fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError> { 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. // Ask the child to terminate, then wait for its real status.
let _ = live.child.kill(); let _ = live.child.kill();
let status = live let status = live.child.wait().map_err(|e| PtyError::Io(e.to_string()))?;
.child
.wait()
.map_err(|e| PtyError::Io(e.to_string()))?;
// Dropping master/writer closes the PTY; the reader thread then sees EOF. // Dropping master/writer closes the PTY; the reader thread then sees EOF.
// Dropping the broadcast hub drops every subscriber's sender, so any // Dropping the broadcast hub drops every subscriber's sender, so any

View File

@ -70,8 +70,7 @@ impl CliAgentRuntime {
let args = tokens.map(str::to_owned).collect(); let args = tokens.map(str::to_owned).collect();
// Detection runs in a neutral cwd; "." is a safe relative placeholder the // Detection runs in a neutral cwd; "." is a safe relative placeholder the
// spawner resolves against the process cwd. // spawner resolves against the process cwd.
let cwd = ProjectPath::new("/") let cwd = ProjectPath::new("/").map_err(|e| RuntimeError::Detection(e.to_string()))?;
.map_err(|e| RuntimeError::Detection(e.to_string()))?;
Ok(SpawnSpec { Ok(SpawnSpec {
command, command,
args, args,
@ -90,7 +89,10 @@ impl CliAgentRuntime {
/// `{projectRoot}` is still substituted with the same base for backwards /// `{projectRoot}` is still substituted with the same base for backwards
/// compatibility (the caller always passes the run dir now). An empty template /// compatibility (the caller always passes the run dir now). An empty template
/// defaults to the base itself. /// defaults to the base itself.
fn resolve_cwd(profile: &AgentProfile, base: &ProjectPath) -> Result<ProjectPath, RuntimeError> { fn resolve_cwd(
profile: &AgentProfile,
base: &ProjectPath,
) -> Result<ProjectPath, RuntimeError> {
let template = profile.cwd_template.trim(); let template = profile.cwd_template.trim();
if template.is_empty() { if template.is_empty() {
return Ok(base.clone()); return Ok(base.clone());
@ -117,10 +119,7 @@ impl CliAgentRuntime {
/// (e.g. `-f` → `["-f", "<path>"]`); a flag *with* `{path}` is split on /// (e.g. `-f` → `["-f", "<path>"]`); a flag *with* `{path}` is split on
/// whitespace after substitution (e.g. `--context-file {path}` → /// whitespace after substitution (e.g. `--context-file {path}` →
/// `["--context-file", "<path>"]`). /// `["--context-file", "<path>"]`).
fn injection_plan( fn injection_plan(injection: &ContextInjection, ctx: &PreparedContext) -> ContextInjectionPlan {
injection: &ContextInjection,
ctx: &PreparedContext,
) -> ContextInjectionPlan {
match injection { match injection {
ContextInjection::ConventionFile { target } => ContextInjectionPlan::File { ContextInjection::ConventionFile { target } => ContextInjectionPlan::File {
target: target.clone(), target: target.clone(),

View File

@ -67,7 +67,10 @@ impl IdeaiContextStore {
/// Absolute path of the manifest file for a project. /// Absolute path of the manifest file for a project.
fn manifest_path(project: &Project) -> RemotePath { 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) /// Absolute path of an agent context `.md` from its (`.ideai/`-relative)

View File

@ -23,10 +23,27 @@
//! repeatable vectors from a hashing bag-of-words, good enough for tests and for a //! repeatable vectors from a hashing bag-of-words, good enough for tests and for a
//! trivial offline fallback). //! 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::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 /// Builds the concrete [`Embedder`] for a profile, or `None` when the strategy is
/// [`EmbedderStrategy::None`] (recall stays naïve). /// [`EmbedderStrategy::None`] (recall stays naïve).
@ -50,7 +67,10 @@ pub fn embedder_from_profile(
EmbedderStrategy::LocalOnnx => { EmbedderStrategy::LocalOnnx => {
#[cfg(feature = "vector-onnx")] #[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"))] #[cfg(not(feature = "vector-onnx"))]
{ {
@ -325,10 +345,9 @@ impl Embedder for HttpEmbedder {
} }
} }
let response = request let response = request.send().await.map_err(|e| {
.send() EmbedderError::Unavailable(format!("request to `{}` failed: {e}", self.endpoint))
.await })?;
.map_err(|e| EmbedderError::Unavailable(format!("request to `{}` failed: {e}", self.endpoint)))?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(EmbedderError::Unavailable(format!( return Err(EmbedderError::Unavailable(format!(
@ -464,6 +483,189 @@ pub fn onnx_model_is_cached(cache_dir: &std::path::Path, model: &str) -> bool {
false 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 (`<app_data_dir>/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<String>) -> 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::<Vec<String>>()
})
.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<EmbedderPromptDismissal> for DismissalWire {
fn from(d: EmbedderPromptDismissal) -> Self {
match d {
EmbedderPromptDismissal::Later => Self::Later,
EmbedderPromptDismissal::Never => Self::Never,
}
}
}
impl From<DismissalWire> 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<dyn FileSystem>,
}
impl FsEmbedderPromptStore {
/// Builds the store from the [`FileSystem`] port.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
Self { fs }
}
/// `<root>/.ideai/memory`.
fn memory_dir(root: &ProjectPath) -> String {
let base = root.as_str().trim_end_matches(['/', '\\']);
format!("{base}/{PROMPT_IDEAI_DIR}/{PROMPT_MEMORY_DIR}")
}
/// `<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<Option<EmbedderPromptDismissal>, StoreError> {
match self.fs.read(&Self::prompt_path(root)).await {
Ok(bytes) => Ok(serde_json::from_slice::<PromptDoc>(&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")] #[cfg(feature = "vector-onnx")]
mod onnx { mod onnx {
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -546,9 +748,7 @@ mod onnx {
.get_or_try_init(|| async { .get_or_try_init(|| async {
let cache_dir = self.cache_dir.clone(); let cache_dir = self.cache_dir.clone();
let built = tokio::task::spawn_blocking(move || { let built = tokio::task::spawn_blocking(move || {
TextEmbedding::try_new( TextEmbedding::try_new(InitOptions::new(model).with_cache_dir(cache_dir))
InitOptions::new(model).with_cache_dir(cache_dir),
)
}) })
.await .await
.map_err(|e| EmbedderError::Io(format!("onnx init task failed: {e}")))? .map_err(|e| EmbedderError::Io(format!("onnx init task failed: {e}")))?

View File

@ -14,14 +14,15 @@ mod template;
mod vector; mod vector;
pub use context::IdeaiContextStore; 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")] #[cfg(feature = "vector-onnx")]
pub use embedder::OnnxEmbedder; 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 memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
pub use profile::{FsEmbedderProfileStore, FsProfileStore}; pub use profile::{FsEmbedderProfileStore, FsProfileStore};
pub use project::FsProjectStore; pub use project::FsProjectStore;

View File

@ -23,7 +23,7 @@ use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use domain::ids::ProfileId; use domain::ids::ProfileId;
use domain::ports::{FileSystem, ProfileStore, RemotePath, StoreError}; use domain::ports::{EmbedderProfileStore, FileSystem, ProfileStore, RemotePath, StoreError};
use domain::profile::{AgentProfile, EmbedderProfile}; use domain::profile::{AgentProfile, EmbedderProfile};
/// File name of the profiles store inside the app-data dir. /// 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, /// calqué on [`FsProfileStore`]: `embedder.json` in the global IDE app-data dir,
/// mirroring `profiles.json`. /// mirroring `profiles.json`.
/// ///
/// No domain `EmbedderProfileStore` port exists yet — embedder profiles are pure /// Implements the domain [`EmbedderProfileStore`] port (parallel to [`ProfileStore`]).
/// configuration loaded at the composition root, so this is a concrete loader. A /// The inherent `list`/`save`/`delete` methods are kept so the composition root can
/// dedicated port (parallel to [`ProfileStore`]) is an easy follow-up the day the /// load the configured profile *before* type-erasing to `Arc<dyn EmbedderProfileStore>`
/// UI needs CRUD over embedder profiles. /// (e.g. inside a one-shot blocking runtime in `state.rs`); the trait impl simply
/// delegates to them.
/// ///
/// Cheap to clone (everything behind `Arc`). /// Cheap to clone (everything behind `Arc`).
#[derive(Clone)] #[derive(Clone)]
@ -270,3 +271,18 @@ impl FsEmbedderProfileStore {
self.write_doc(&doc).await self.write_doc(&doc).await
} }
} }
#[async_trait]
impl EmbedderProfileStore for FsEmbedderProfileStore {
async fn list(&self) -> Result<Vec<EmbedderProfile>, 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
}
}

View File

@ -76,8 +76,9 @@ impl FsProjectStore {
async fn read_registry(&self) -> Result<Registry, StoreError> { async fn read_registry(&self) -> Result<Registry, StoreError> {
let path = self.path(REGISTRY_FILE); let path = self.path(REGISTRY_FILE);
match self.fs.read(&path).await { match self.fs.read(&path).await {
Ok(bytes) => serde_json::from_slice(&bytes) Ok(bytes) => {
.map_err(|e| StoreError::Serialization(e.to_string())), serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
}
Err(domain::ports::FsError::NotFound(_)) => Ok(Registry { Err(domain::ports::FsError::NotFound(_)) => Ok(Registry {
version: REGISTRY_VERSION, version: REGISTRY_VERSION,
projects: Vec::new(), projects: Vec::new(),

View File

@ -183,7 +183,12 @@ impl FsSkillStore {
})?; })?;
let content = let content =
String::from_utf8(bytes).map_err(|e| StoreError::Serialization(e.to_string()))?; String::from_utf8(bytes).map_err(|e| StoreError::Serialization(e.to_string()))?;
Skill::new(entry.id, entry.name.clone(), MarkdownDoc::new(content), scope) Skill::new(
entry.id,
entry.name.clone(),
MarkdownDoc::new(content),
scope,
)
.map_err(|e| StoreError::Serialization(e.to_string())) .map_err(|e| StoreError::Serialization(e.to_string()))
} }
} }

View File

@ -235,10 +235,7 @@ impl VectorMemoryRecall {
/// the `?` plumbing compiles; in practice [`AdaptiveMemoryRecall`] guards this /// the `?` plumbing compiles; in practice [`AdaptiveMemoryRecall`] guards this
/// path and falls back to naïve before any such error reaches a use case. /// path and falls back to naïve before any such error reaches a use case.
async fn recall_embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, MemoryError> { async fn recall_embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, MemoryError> {
self.embedder self.embedder.embed(texts).await.map_err(map_embedder_error)
.embed(texts)
.await
.map_err(map_embedder_error)
} }
} }

View File

@ -13,13 +13,13 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use domain::ids::ProfileId;
use domain::ports::{ use domain::ports::{
AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError, AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError,
ProcessSpawner, RuntimeError, SessionPlan, SpawnSpec, ProcessSpawner, RuntimeError, SessionPlan, SpawnSpec,
}; };
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy}; use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
use domain::project::ProjectPath; use domain::project::ProjectPath;
use domain::ids::ProfileId;
use domain::MarkdownDoc; use domain::MarkdownDoc;
use infrastructure::CliAgentRuntime; 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 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.command, "mycli");
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged"); 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 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. // static args first, then the substituted+split flag args.
assert_eq!( assert_eq!(
@ -149,7 +153,9 @@ fn prepare_flag_without_path_is_switch_then_path() {
let p = profile(ContextInjection::flag("-f").unwrap(), "{projectRoot}"); let p = profile(ContextInjection::flag("-f").unwrap(), "{projectRoot}");
let root = ProjectPath::new("/p").unwrap(); 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!(spec.args, vec!["--static", "arg", "-f", ".ideai/agent.md"]);
assert_eq!( assert_eq!(
@ -170,9 +176,15 @@ fn prepare_stdin_keeps_args_and_plans_stdin() {
let p = profile(ContextInjection::stdin(), "{projectRoot}"); let p = profile(ContextInjection::stdin(), "{projectRoot}");
let root = ProjectPath::new("/p").unwrap(); 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)); 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 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!(spec.args, vec!["--static", "arg"], "args unchanged for env");
assert_eq!( assert_eq!(
@ -207,13 +221,12 @@ fn prepare_env_keeps_args_and_plans_env() {
#[test] #[test]
fn prepare_substitutes_project_root_in_cwd_template() { fn prepare_substitutes_project_root_in_cwd_template() {
let rt = pure_runtime(); let rt = pure_runtime();
let p = profile( let p = profile(ContextInjection::stdin(), "{projectRoot}/subdir");
ContextInjection::stdin(),
"{projectRoot}/subdir",
);
let root = ProjectPath::new("/home/me/proj").unwrap(); 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"); 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 p = profile(ContextInjection::stdin(), "");
let base = ProjectPath::new("/home/me/proj").unwrap(); 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"); 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 // The canonical template (ARCHITECTURE §14.1): `{agentRunDir}` resolves to the
// base cwd the launcher passes — the agent's isolated run directory. // base cwd the launcher passes — the agent's isolated run directory.
let rt = pure_runtime(); 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 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"); 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 [ for plan in [
SessionPlan::None, SessionPlan::None,
SessionPlan::Assign { conversation_id: "id-1".to_owned() }, SessionPlan::Assign {
SessionPlan::Resume { conversation_id: "id-1".to_owned() }, conversation_id: "id-1".to_owned(),
},
SessionPlan::Resume {
conversation_id: "id-1".to_owned(),
},
] { ] {
let spec = rt.prepare_invocation(&p, &ctx(), &root(), &plan).unwrap(); let spec = rt.prepare_invocation(&p, &ctx(), &root(), &plan).unwrap();
assert_eq!(spec.args, vec!["--static", "arg"], "plan = {plan:?}"); assert_eq!(spec.args, vec!["--static", "arg"], "plan = {plan:?}");
@ -383,7 +407,9 @@ fn session_assign_with_flag_emits_flag_and_id() {
&p, &p,
&ctx(), &ctx(),
&root(), &root(),
&SessionPlan::Assign { conversation_id: "abc".to_owned() }, &SessionPlan::Assign {
conversation_id: "abc".to_owned(),
},
) )
.unwrap(); .unwrap();
@ -403,7 +429,9 @@ fn session_resume_with_flag_emits_resume_and_id() {
&p, &p,
&ctx(), &ctx(),
&root(), &root(),
&SessionPlan::Resume { conversation_id: "abc".to_owned() }, &SessionPlan::Resume {
conversation_id: "abc".to_owned(),
},
) )
.unwrap(); .unwrap();
@ -421,7 +449,9 @@ fn session_resume_without_assign_flag_emits_resume_only() {
&p, &p,
&ctx(), &ctx(),
&root(), &root(),
&SessionPlan::Resume { conversation_id: "abc".to_owned() }, &SessionPlan::Resume {
conversation_id: "abc".to_owned(),
},
) )
.unwrap(); .unwrap();
@ -454,7 +484,9 @@ fn session_assign_without_flag_adds_nothing() {
&p, &p,
&ctx(), &ctx(),
&root(), &root(),
&SessionPlan::Assign { conversation_id: "abc".to_owned() }, &SessionPlan::Assign {
conversation_id: "abc".to_owned(),
},
) )
.unwrap(); .unwrap();
@ -471,8 +503,12 @@ fn no_session_profile_is_unaffected_by_any_plan() {
for plan in [ for plan in [
SessionPlan::None, SessionPlan::None,
SessionPlan::Assign { conversation_id: "x".to_owned() }, SessionPlan::Assign {
SessionPlan::Resume { conversation_id: "x".to_owned() }, conversation_id: "x".to_owned(),
},
SessionPlan::Resume {
conversation_id: "x".to_owned(),
},
] { ] {
let spec = rt.prepare_invocation(&p, &ctx(), &root(), &plan).unwrap(); let spec = rt.prepare_invocation(&p, &ctx(), &root(), &plan).unwrap();
assert_eq!(spec.args, vec!["--static", "arg"], "plan = {plan:?}"); assert_eq!(spec.args, vec!["--static", "arg"], "plan = {plan:?}");
@ -500,7 +536,9 @@ fn session_args_come_after_context_injection_args() {
&p, &p,
&ctx(), &ctx(),
&root(), &root(),
&SessionPlan::Assign { conversation_id: "abc".to_owned() }, &SessionPlan::Assign {
conversation_id: "abc".to_owned(),
},
) )
.unwrap(); .unwrap();

View File

@ -140,8 +140,14 @@ async fn manifest_file_is_camelcase_json_under_ideai() {
.expect("top-level `agents` array"); .expect("top-level `agents` array");
assert_eq!(agents.len(), 1); assert_eq!(agents.len(), 1);
let entry = &agents[0]; 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_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"); assert!(entry.get("md_path").is_none(), "no snake_case leak");
} }

View File

@ -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<dyn FileSystem> = 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"));
}

View File

@ -4,8 +4,8 @@
use std::path::PathBuf; use std::path::PathBuf;
use domain::ports::GitPort;
use domain::ports::GitError; use domain::ports::GitError;
use domain::ports::GitPort;
use domain::project::ProjectPath; use domain::project::ProjectPath;
use infrastructure::Git2Repository; use infrastructure::Git2Repository;
use uuid::Uuid; 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. // Modify + stage, then unstage → the change is no longer staged.
tmp.write("a.txt", "v2"); tmp.write("a.txt", "v2");
git.stage(&root, "a.txt").await.unwrap(); 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(); git.unstage(&root, "a.txt").await.unwrap();
let st = git.status(&root).await.unwrap(); let st = git.status(&root).await.unwrap();

View File

@ -57,9 +57,7 @@ async fn drain_request(stream: &mut tokio::net::TcpStream) {
} }
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> { fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack haystack.windows(needle.len()).position(|w| w == needle)
.windows(needle.len())
.position(|w| w == needle)
} }
/// Builds an HTTP `200 OK` response with a JSON body and the right `Content-Length`. /// 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()]) .embed(&["first".to_string(), "second".to_string()])
.await .await
.expect("embed must succeed"); .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] #[tokio::test]
async fn http_embedder_empty_input_short_circuits_without_network() { async fn http_embedder_empty_input_short_circuits_without_network() {
// A closed/never-bound endpoint: no request must be made for empty input. // A closed/never-bound endpoint: no request must be made for empty input.
let embedder = HttpEmbedder::from_profile(&local_server_profile( let embedder =
"http://127.0.0.1:1/v1/embeddings", HttpEmbedder::from_profile(&local_server_profile("http://127.0.0.1:1/v1/embeddings", 4));
4, let out = embedder
)); .embed(&[])
let out = embedder.embed(&[]).await.expect("empty input ⇒ empty output, no I/O"); .await
.expect("empty input ⇒ empty output, no I/O");
assert!(out.is_empty()); assert!(out.is_empty());
} }
@ -124,10 +127,8 @@ async fn http_embedder_non_2xx_is_unavailable() {
#[tokio::test] #[tokio::test]
async fn http_embedder_unreachable_host_is_unavailable() { async fn http_embedder_unreachable_host_is_unavailable() {
// Port 1 on loopback: nothing listens ⇒ connection refused ⇒ Unavailable. // Port 1 on loopback: nothing listens ⇒ connection refused ⇒ Unavailable.
let embedder = HttpEmbedder::from_profile(&local_server_profile( let embedder =
"http://127.0.0.1:1/v1/embeddings", HttpEmbedder::from_profile(&local_server_profile("http://127.0.0.1:1/v1/embeddings", 2));
2,
));
let err = embedder.embed(&["x".to_string()]).await.unwrap_err(); let err = embedder.embed(&["x".to_string()]).await.unwrap_err();
assert!(matches!(err, EmbedderError::Unavailable(_)), "got {err:?}"); assert!(matches!(err, EmbedderError::Unavailable(_)), "got {err:?}");
} }

View File

@ -22,7 +22,9 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait; use async_trait::async_trait;
use domain::markdown::MarkdownDoc; 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::{ use domain::ports::{
DirEntry, FileSystem, FsError, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, RemotePath, DirEntry, FileSystem, FsError, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, RemotePath,
}; };
@ -114,7 +116,10 @@ fn query(budget: usize) -> MemoryQuery {
} }
fn slugs(entries: &[MemoryIndexEntry]) -> Vec<String> { fn slugs(entries: &[MemoryIndexEntry]) -> Vec<String> {
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> { async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
panic!("recall must not call delete") panic!("recall must not call delete")
} }
async fn read_index( async fn read_index(&self, _root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
&self,
_root: &ProjectPath,
) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
self.read_index_calls.fetch_add(1, Ordering::SeqCst); self.read_index_calls.fetch_add(1, Ordering::SeqCst);
Ok(Vec::new()) Ok(Vec::new())
} }
@ -224,17 +226,27 @@ async fn ample_budget_returns_all_entries_in_index_order() {
#[tokio::test] #[tokio::test]
async fn intermediate_budget_truncates_at_exact_boundary() { 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(&notes).await; let recall = recall_over(&notes).await;
// Budget 1 < 2 ⇒ first entry already exceeds ⇒ none. // Budget 1 < 2 ⇒ first entry already exceeds ⇒ none.
assert!(recall.recall(&root(), &query(1)).await.unwrap().is_empty()); assert!(recall.recall(&root(), &query(1)).await.unwrap().is_empty());
// Budget 2 == cost(1) ⇒ exactly the first entry; second (would reach 4) dropped. // 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. // 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. // Budget 4 == cost(1)+cost(2) ⇒ first two; third (would reach 6) dropped.
assert_eq!( 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; 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. // 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). // 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). // Budget 3 fits both (1 + 2 == 3).
assert_eq!( assert_eq!(
slugs(&recall.recall(&root(), &query(3)).await.unwrap()), slugs(&recall.recall(&root(), &query(3)).await.unwrap()),

View File

@ -106,12 +106,17 @@ async fn save_writes_md_and_index_line() {
let fs = MemFs::arc(); let fs = MemFs::arc();
let store = FsMemoryStore::new(fs.clone()); let store = FsMemoryStore::new(fs.clone());
store store
.save(&root(), &note("alpha", "the hook", MemoryType::Project, "# Body")) .save(
&root(),
&note("alpha", "the hook", MemoryType::Project, "# Body"),
)
.await .await
.unwrap(); .unwrap();
// The note `.md` exists with frontmatter + body. // 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.starts_with("---\n"));
assert!(md.contains("name: alpha")); assert!(md.contains("name: alpha"));
assert!(md.contains("description: the hook")); assert!(md.contains("description: the hook"));
@ -119,7 +124,9 @@ async fn save_writes_md_and_index_line() {
assert!(md.contains("# Body")); assert!(md.contains("# Body"));
// The index has the header + the line. // 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.starts_with("# Memory Index"));
assert!(index.contains("- [alpha](alpha.md) — the hook")); 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() { async fn resolve_links_missing_source_is_not_found() {
let store = FsMemoryStore::new(MemFs::arc()); let store = FsMemoryStore::new(MemFs::arc());
assert!(matches!( assert!(matches!(
store.resolve_links(&root(), &slug("nope")).await.unwrap_err(), store
.resolve_links(&root(), &slug("nope"))
.await
.unwrap_err(),
MemoryError::NotFound MemoryError::NotFound
)); ));
} }

View File

@ -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 // *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. // 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 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 let err = embedder
.embed(&["x".to_string()]) .embed(&["x".to_string()])
@ -113,7 +114,8 @@ async fn onnx_construction_is_cheap() {
// and an unknown one (construction never fails for either). // and an unknown one (construction never fails for either).
let cache = std::path::Path::new("/idea-onnx-cache-that-never-exists-3"); 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.id(), "test-onnx");
assert_eq!(known.dimension(), 384); 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. // 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 cache = std::path::Path::new("/idea-onnx-cache-that-never-exists-4");
let known = onnx_profile(Some("multilingual-e5-small"), 384); let known = onnx_profile(Some("multilingual-e5-small"), 384);
let embedder = let embedder = embedder_from_profile(&known, cache).expect("localOnnx must yield an embedder");
embedder_from_profile(&known, cache).expect("localOnnx must yield an embedder");
assert_eq!(embedder.dimension(), 384); assert_eq!(embedder.dimension(), 384);
let out = embedder let out = embedder
.embed(&[]) .embed(&[])
.await .await
.expect("real engine short-circuits empty input to Ok(vec![])"); .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 // 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, // 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 { for v in &out {
assert_eq!(v.len(), 384, "e5-small produces 384-dim vectors"); assert_eq!(v.len(), 384, "e5-small produces 384-dim vectors");
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt(); let norm = v.iter().map(|x| x * x).sum::<f32>().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). // 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"); assert_eq!(out, again, "embedding must be deterministic across calls");
} }

View File

@ -16,6 +16,7 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait; use async_trait::async_trait;
use domain::agent::{AgentManifest, ManifestEntry}; use domain::agent::{AgentManifest, ManifestEntry};
use domain::events::DomainEvent; use domain::events::DomainEvent;
use domain::ids::SkillId;
use domain::ids::{AgentId, ProfileId, ProjectId}; use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc; use domain::markdown::MarkdownDoc;
use domain::ports::{ use domain::ports::{
@ -24,7 +25,6 @@ use domain::ports::{
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError, StoreError,
}; };
use domain::ids::SkillId;
use domain::profile::{AgentProfile, ContextInjection}; use domain::profile::{AgentProfile, ContextInjection};
use domain::project::{Project, ProjectPath}; use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef; use domain::remote::RemoteRef;
@ -94,7 +94,13 @@ impl AgentContextStore for FakeContexts {
) -> Result<MarkdownDoc, StoreError> { ) -> Result<MarkdownDoc, StoreError> {
let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?; let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
Ok(MarkdownDoc::new( 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( async fn write_context(
@ -150,7 +156,11 @@ impl ProfileStore for FakeProfiles {
struct FakeSkills; struct FakeSkills;
#[async_trait] #[async_trait]
impl SkillStore for FakeSkills { impl SkillStore for FakeSkills {
async fn list(&self, _scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> { async fn list(
&self,
_scope: SkillScope,
_root: &ProjectPath,
) -> Result<Vec<Skill>, StoreError> {
Ok(Vec::new()) Ok(Vec::new())
} }
async fn get( async fn get(
@ -321,6 +331,7 @@ fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
bus.clone(), bus.clone(),
Arc::new(SeqIds(Mutex::new(1))), Arc::new(SeqIds(Mutex::new(1))),
Arc::new(FakeRecall), Arc::new(FakeRecall),
None,
)); ));
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone()))); let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions))); let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));
@ -342,7 +353,11 @@ fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
} }
fn read_response(request_path: &std::path::Path) -> OrchestratorResponse { 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"); name.push_str(".response.json");
let response_path = request_path.with_file_name(name); let response_path = request_path.with_file_name(name);
let bytes = std::fs::read(&response_path).expect("response file written"); 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"); 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] #[tokio::test]
async fn valid_create_skill_request_succeeds_and_is_consumed() { async fn valid_create_skill_request_succeeds_and_is_consumed() {
let tmp = TempDir::new(); let tmp = TempDir::new();
@ -408,7 +447,11 @@ async fn invalid_json_request_yields_error_response() {
assert!(!response.ok); assert!(!response.ok);
assert!( assert!(
response.error.as_deref().unwrap_or_default().contains("invalid json"), response
.error
.as_deref()
.unwrap_or_default()
.contains("invalid json"),
"got {response:?}" "got {response:?}"
); );
assert!(!req.exists(), "poisoned request must still be removed"); assert!(!req.exists(), "poisoned request must still be removed");
@ -427,5 +470,9 @@ async fn unknown_action_yields_error_response() {
assert!(!response.ok); assert!(!response.ok);
assert_eq!(response.action.as_deref(), Some("explode")); 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"));
} }

View File

@ -134,6 +134,9 @@ async fn registry_file_is_camelcase_json() {
"project uses camelCase `createdAt`, got {entry}" "project uses camelCase `createdAt`, got {entry}"
); );
assert!(entry.get("created_at").is_none(), "no snake_case leak"); 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")); assert_eq!(entry.get("root").and_then(|v| v.as_str()), Some("/srv/app"));
} }

View File

@ -35,10 +35,7 @@ fn size() -> PtySize {
/// Drains an output stream to a single `Vec<u8>` on a worker thread, returning /// Drains an output stream to a single `Vec<u8>` on a worker thread, returning
/// the collected bytes or panicking if it does not finish within `TIMEOUT`. /// the collected bytes or panicking if it does not finish within `TIMEOUT`.
fn drain_with_timeout( fn drain_with_timeout(stream: domain::ports::OutputStream, timeout: Duration) -> Vec<u8> {
stream: domain::ports::OutputStream,
timeout: Duration,
) -> Vec<u8> {
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
let worker = thread::spawn(move || { let worker = thread::spawn(move || {
let mut all = Vec::new(); 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 // `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. // by killing it, and assert we saw the echoed bytes.
let pty = PortablePtyAdapter::new(); let pty = PortablePtyAdapter::new();
let handle = pty let handle = pty.spawn(sh_spec("cat"), size()).await.expect("spawn cat");
.spawn(sh_spec("cat"), size())
.await
.expect("spawn cat");
let stream = pty.subscribe_output(&handle).expect("subscribe once"); 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 // first view detaches (drops its stream), a second view re-attaches and
// still receives subsequent output — the core of the no-kill navigation fix. // still receives subsequent output — the core of the no-kill navigation fix.
let pty = PortablePtyAdapter::new(); let pty = PortablePtyAdapter::new();
let handle = pty let handle = pty.spawn(sh_spec("cat"), size()).await.expect("spawn cat");
.spawn(sh_spec("cat"), size())
.await
.expect("spawn cat");
// First attachment: subscribe, observe an echo, then drop the stream // First attachment: subscribe, observe an echo, then drop the stream
// (simulating a view tearing down on navigation — NOT a kill). // (simulating a view tearing down on navigation — NOT a kill).

View File

@ -49,15 +49,9 @@ async fn selector_builds_local_host() {
#[test] #[test]
fn selector_rejects_ssh_and_wsl_for_now() { fn selector_rejects_ssh_and_wsl_for_now() {
let ssh = RemoteRef::ssh("h", 22, "u", SshAuth::Agent, "/srv").unwrap(); let ssh = RemoteRef::ssh("h", 22, "u", SshAuth::Agent, "/srv").unwrap();
assert!(matches!( assert!(matches!(remote_host(&ssh), Err(RemoteError::Connection(_))));
remote_host(&ssh),
Err(RemoteError::Connection(_))
));
let wsl = RemoteRef::wsl("Ubuntu").unwrap(); let wsl = RemoteRef::wsl("Ubuntu").unwrap();
assert!(matches!( assert!(matches!(remote_host(&wsl), Err(RemoteError::Connection(_))));
remote_host(&wsl),
Err(RemoteError::Connection(_))
));
} }
/// Local host PTY/spawner handles are cloneable port objects (Arc-backed). /// Local host PTY/spawner handles are cloneable port objects (Arc-backed).

View File

@ -68,7 +68,12 @@ async fn global_roundtrip_save_get_list() {
let s = store(&tmp); let s = store(&tmp);
let root = tmp.project_root(); 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(); s.save(&sk, &root).await.unwrap();
assert_eq!(s.get(SkillScope::Global, &root, sid(1)).await.unwrap(), sk); assert_eq!(s.get(SkillScope::Global, &root, sid(1)).await.unwrap(), sk);
@ -111,7 +116,10 @@ async fn scopes_are_isolated() {
let s = store(&tmp); let s = store(&tmp);
let root = tmp.project_root(); let root = tmp.project_root();
s.save(&skill(sid(1), "g", "global body", SkillScope::Global), &root) s.save(
&skill(sid(1), "g", "global body", SkillScope::Global),
&root,
)
.await .await
.unwrap(); .unwrap();
s.save( s.save(
@ -191,7 +199,10 @@ async fn index_is_camelcase_with_content_hash() {
let tmp = TempDir::new(); let tmp = TempDir::new();
let s = store(&tmp); let s = store(&tmp);
let root = tmp.project_root(); let root = tmp.project_root();
s.save(&skill(sid(1), "refactor", "hello", SkillScope::Global), &root) s.save(
&skill(sid(1), "refactor", "hello", SkillScope::Global),
&root,
)
.await .await
.unwrap(); .unwrap();
@ -204,8 +215,5 @@ async fn index_is_camelcase_with_content_hash() {
entry.get("contentHash").is_some(), entry.get("contentHash").is_some(),
"camelCase contentHash present" "camelCase contentHash present"
); );
assert!( assert!(entry.get("content_hash").is_none(), "no snake_case leak");
entry.get("content_hash").is_none(),
"no snake_case leak"
);
} }

View File

@ -116,14 +116,20 @@ async fn delete_removes_from_index() {
async fn index_is_camelcase_with_content_hash() { async fn index_is_camelcase_with_content_hash() {
let tmp = TempDir::new(); let tmp = TempDir::new();
let store = store(&tmp); 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 fs = LocalFileSystem::new();
let bytes = fs.read(&tmp.child("templates/index.json")).await.unwrap(); let bytes = fs.read(&tmp.child("templates/index.json")).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let entry = &json.get("templates").unwrap().as_array().unwrap()[0]; let entry = &json.get("templates").unwrap().as_array().unwrap()[0];
assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("Backend")); 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("defaultProfileId").is_some());
assert!(entry.get("content_hash").is_none(), "no snake_case leak"); assert!(entry.get("content_hash").is_none(), "no snake_case leak");
} }

View File

@ -152,7 +152,10 @@ fn query(text: &str, budget: usize) -> MemoryQuery {
} }
fn slugs(entries: &[MemoryIndexEntry]) -> Vec<String> { fn slugs(entries: &[MemoryIndexEntry]) -> Vec<String> {
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<MemFs>) -> Arc<FsMemoryStore> { async fn store_with(notes: &[Memory], fs: Arc<MemFs>) -> Arc<FsMemoryStore> {
@ -215,16 +218,16 @@ async fn hash_embedder_dimension_is_consistent() {
.await .await
.unwrap(); .unwrap();
assert_eq!(out.len(), 2, "one vector per input, order preserved"); 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] #[tokio::test]
async fn hash_embedder_vectors_are_l2_normalised() { async fn hash_embedder_vectors_are_l2_normalised() {
let e = HashEmbedder::new("h", 64); let e = HashEmbedder::new("h", 64);
let out = e let out = e.embed(&["one two three four".to_string()]).await.unwrap();
.embed(&["one two three four".to_string()])
.await
.unwrap();
let norm = out[0].iter().map(|x| x * x).sum::<f32>().sqrt(); let norm = out[0].iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-5, "norm ≈ 1, got {norm}"); 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)) .recall(&root(), &query("beta kiwi mango papaya", 100_000))
.await .await
.unwrap(); .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. // All three notes fit the ample budget.
assert_eq!(out.len(), 3); assert_eq!(out.len(), 3);
} }
@ -352,8 +360,15 @@ async fn vector_recall_truncates_to_budget() {
let recall = vector_recall(embedder, store, fs); let recall = vector_recall(embedder, store, fs);
// Budget 2 fits only the top-ranked `beta` (cost 2); the next would exceed. // 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(); let out = recall
assert_eq!(slugs(&out), vec!["beta"], "budget must keep only the closest"); .recall(&root(), &query("beta kiwi", 2))
.await
.unwrap();
assert_eq!(
slugs(&out),
vec!["beta"],
"budget must keep only the closest"
);
} }
#[tokio::test] #[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()); let recall = VectorMemoryRecall::new(spy.clone(), store, fs.clone());
// First recall: embeds 2 note texts + 1 query text = 3. // 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); let after_first = spy.embedded_texts.load(Ordering::SeqCst);
assert_eq!(after_first, 3, "2 notes + 1 query embedded on a cold cache"); 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. // The derived cache file now exists with both slugs and the embedder id.
let doc = fs.raw(VECTORS_PATH).expect("vectors.json written"); 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("\"alpha\""), "alpha vector cached: {doc}");
assert!(doc.contains("\"beta\""), "beta vector cached: {doc}"); assert!(doc.contains("\"beta\""), "beta vector cached: {doc}");
// Second recall: note vectors come from the cache; only the query is embedded. // 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; 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] #[tokio::test]
@ -417,7 +444,10 @@ async fn vector_recall_invalidates_cache_on_embedder_id_change() {
.recall(&root(), &query("alpha apple", 100_000)) .recall(&root(), &query("alpha apple", 100_000))
.await .await
.unwrap(); .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. // A different embedder id must invalidate ⇒ recompute all note vectors.
let second = Arc::new(SpyEmbedder::new("second", 128)); 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" "id change must invalidate and recompute note vectors"
); );
// The file is now tagged with the new embedder id. // 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] #[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 stub = Arc::new(StubEmbedder::new("stub", 64, "localOnnx"));
let recall = VectorMemoryRecall::new(stub, store, fs); let recall = VectorMemoryRecall::new(stub, store, fs);
let res = recall.recall(&root(), &query("alpha apple", 1000)).await; 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 q = query("kiwi mango papaya", budget);
let expected = naive.recall(&root(), &q).await.unwrap(); let expected = naive.recall(&root(), &q).await.unwrap();
let got = adaptive.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)) .recall(&root(), &query("gamma grape", 100_000))
.await .await
.unwrap(); .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] #[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 total = infrastructure::index_token_size(&store.read_index(&root()).await.unwrap());
let q = query("carrot potato onion", total - 1); 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(); 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"); assert!(!got.is_empty(), "the degraded result still returns entries");
} }
@ -646,11 +695,18 @@ impl Drop for TempDir {
#[test] #[test]
fn recommended_onnx_models_advertise_e5_small_384() { 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]; let m = RECOMMENDED_ONNX_MODELS[0];
assert_eq!(m.id, "multilingual-e5-small"); assert_eq!(m.id, "multilingual-e5-small");
assert_eq!(m.dimension, 384); 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] #[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 // *contains* the model token (slashes/underscores normalised to `-`), mirroring
// hf-hub's `models--<org>--<name>` layout. // hf-hub's `models--<org>--<name>` layout.
let tmp = TempDir::new(); 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(); std::fs::create_dir_all(&model_dir).unwrap();
// The subdir must be non-empty to count as "cached". // The subdir must be non-empty to count as "cached".
std::fs::write(model_dir.join("model.onnx"), b"not really a model").unwrap(); 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() { fn onnx_model_is_cached_false_when_matching_subdir_is_empty() {
// A matching but *empty* subdir does not count as a present cached model. // A matching but *empty* subdir does not count as a present cached model.
let tmp = TempDir::new(); 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!( assert!(
!onnx_model_is_cached(tmp.path(), "multilingual-e5-small"), !onnx_model_is_cached(tmp.path(), "multilingual-e5-small"),
"an empty matching subdir is not a cached model" "an empty matching subdir is not a cached model"

View File

@ -59,4 +59,14 @@ describe("TauriAgentGateway invoke payloads", () => {
agentId: "a", 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" },
});
});
}); });

View File

@ -45,6 +45,22 @@ export class TauriAgentGateway implements AgentGateway {
return invoke<LiveAgent[]>("list_live_agents", { projectId }); return invoke<LiveAgent[]>("list_live_agents", { projectId });
} }
attachLiveAgent(
projectId: string,
agentId: string,
nodeId: string,
): Promise<LiveAgent> {
return invoke<LiveAgent[]>("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<Agent> { createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
// The `create_agent` command takes a single `request` DTO; `projectId` must // The `create_agent` command takes a single `request` DTO; `projectId` must
// live *inside* it (camelCase), not at the top level. // live *inside* it (camelCase), not at the top level.

View File

@ -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<EmbedderProfile[]> {
return invoke<EmbedderProfile[]>("list_embedder_profiles");
}
saveEmbedderProfile(profile: EmbedderProfile): Promise<EmbedderProfile> {
return invoke<EmbedderProfile>("save_embedder_profile", {
request: { profile },
});
}
async deleteEmbedderProfile(embedderId: string): Promise<void> {
await invoke("delete_embedder_profile", { embedderId });
}
describeEmbedderEngines(): Promise<EmbedderEngines> {
return invoke<EmbedderEngines>("describe_embedder_engines");
}
}

View File

@ -21,6 +21,7 @@ import { TauriProfileGateway } from "./profile";
import { TauriTemplateGateway } from "./template"; import { TauriTemplateGateway } from "./template";
import { TauriSkillGateway } from "./skill"; import { TauriSkillGateway } from "./skill";
import { TauriMemoryGateway } from "./memory"; import { TauriMemoryGateway } from "./memory";
import { TauriEmbedderGateway } from "./embedder";
import { TauriGitGateway } from "./git"; import { TauriGitGateway } from "./git";
function notImplemented(what: string): never { function notImplemented(what: string): never {
@ -51,6 +52,7 @@ export function createTauriGateways(): Gateways {
template: new TauriTemplateGateway(), template: new TauriTemplateGateway(),
skill: new TauriSkillGateway(), skill: new TauriSkillGateway(),
memory: new TauriMemoryGateway(), memory: new TauriMemoryGateway(),
embedder: new TauriEmbedderGateway(),
}; };
} }
@ -64,5 +66,6 @@ export {
TauriTemplateGateway, TauriTemplateGateway,
TauriSkillGateway, TauriSkillGateway,
TauriMemoryGateway, TauriMemoryGateway,
TauriEmbedderGateway,
TauriGitGateway, TauriGitGateway,
}; };

View File

@ -9,6 +9,8 @@ import type {
AgentDrift, AgentDrift,
AgentProfile, AgentProfile,
DomainEvent, DomainEvent,
EmbedderEngines,
EmbedderProfile,
FirstRunState, FirstRunState,
GatewayError, GatewayError,
GitBranches, GitBranches,
@ -38,6 +40,7 @@ import type {
CreateAgentInput, CreateAgentInput,
CreateMemoryInput, CreateMemoryInput,
CreateSkillInput, CreateSkillInput,
EmbedderGateway,
CreateTemplateInput, CreateTemplateInput,
Gateways, Gateways,
LiveAgent, LiveAgent,
@ -178,6 +181,8 @@ export class MockAgentGateway implements AgentGateway {
* Only populated when the caller supplies a `nodeId`. * Only populated when the caller supplies a `nodeId`.
*/ */
private liveByAgent = new Map<string, string>(); private liveByAgent = new Map<string, string>();
/** Live PTY session id per agent (`agentId → sessionId`). */
private liveSessionByAgent = new Map<string, string>();
private getAgents(projectId: string): Agent[] { private getAgents(projectId: string): Agent[] {
if (!this.agents.has(projectId)) this.agents.set(projectId, []); 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)); const known = new Set(this.getAgents(projectId).map((a) => a.id));
return [...this.liveByAgent.entries()] return [...this.liveByAgent.entries()]
.filter(([agentId]) => known.has(agentId)) .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<LiveAgent> {
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<Agent> { async createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
@ -356,7 +390,10 @@ export class MockAgentGateway implements AgentGateway {
this.sessions.set(sessionId, session); this.sessions.set(sessionId, session);
// Record liveness for `listLiveAgents` + the guard above (only when the // Record liveness for `listLiveAgents` + the guard above (only when the
// caller pins a node). // 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). // Greet so something is visible immediately (mirrors MockTerminalGateway).
queueMicrotask(() => queueMicrotask(() =>
session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)), session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
@ -365,6 +402,7 @@ export class MockAgentGateway implements AgentGateway {
this.sessions.delete(sessionId); this.sessions.delete(sessionId);
if (this.liveByAgent.get(agentId) === options.nodeId) { if (this.liveByAgent.get(agentId) === options.nodeId) {
this.liveByAgent.delete(agentId); this.liveByAgent.delete(agentId);
this.liveSessionByAgent.delete(agentId);
} }
}); });
// Simulate session assignment (T4b): a fresh cell (no conversation id) gets a // 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); const scrollback = session.reattach(onData);
return { 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, scrollback,
}; };
} }
@ -513,6 +559,7 @@ export class MockTerminalGateway implements TerminalGateway {
export class MockProjectGateway implements ProjectGateway { export class MockProjectGateway implements ProjectGateway {
private projects: Project[] = []; private projects: Project[] = [];
private contexts = new Map<string, string>();
async listProjects(): Promise<Project[]> { async listProjects(): Promise<Project[]> {
return [...this.projects]; return [...this.projects];
@ -550,6 +597,28 @@ export class MockProjectGateway implements ProjectGateway {
} }
async closeProject(): Promise<void> {} async closeProject(): Promise<void> {}
async readProjectContext(projectId: string): Promise<string> {
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<void> {
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. */ /** 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<string, EmbedderProfile>();
private engines: EmbedderEngines;
constructor(
engines?: Partial<EmbedderEngines>,
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<EmbedderProfile[]> {
return structuredClone([...this.profiles.values()]);
}
async saveEmbedderProfile(
profile: EmbedderProfile,
): Promise<EmbedderProfile> {
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<void> {
this.profiles.delete(embedderId);
}
async describeEmbedderEngines(): Promise<EmbedderEngines> {
return structuredClone(this.engines);
}
}
/** Builds the full set of mock gateways. */ /** Builds the full set of mock gateways. */
export function createMockGateways(): Gateways { export function createMockGateways(): Gateways {
const agentGateway = new MockAgentGateway(); const agentGateway = new MockAgentGateway();
@ -1336,6 +1479,7 @@ export function createMockGateways(): Gateways {
template: new MockTemplateGateway(agentGateway), template: new MockTemplateGateway(agentGateway),
skill: new MockSkillGateway(agentGateway), skill: new MockSkillGateway(agentGateway),
memory: new MockMemoryGateway(), memory: new MockMemoryGateway(),
embedder: new MockEmbedderGateway(),
}; };
} }

View File

@ -12,9 +12,10 @@ import { createMockGateways, MockSystemGateway } from "./index";
const gateways: Gateways = createMockGateways(); const gateways: Gateways = createMockGateways();
describe("createMockGateways", () => { describe("createMockGateways", () => {
it("exposes all eleven gateways", () => { it("exposes all twelve gateways", () => {
expect(Object.keys(gateways).sort()).toEqual([ expect(Object.keys(gateways).sort()).toEqual([
"agent", "agent",
"embedder",
"git", "git",
"layout", "layout",
"memory", "memory",

View File

@ -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" },
});
});
});

View File

@ -27,4 +27,14 @@ export class TauriProjectGateway implements ProjectGateway {
async closeProject(projectId: string): Promise<void> { async closeProject(projectId: string): Promise<void> {
await invoke("close_project", { projectId }); await invoke("close_project", { projectId });
} }
readProjectContext(projectId: string): Promise<string> {
return invoke<string>("read_project_context", { projectId });
}
async updateProjectContext(projectId: string, content: string): Promise<void> {
await invoke("update_project_context", {
request: { projectId, content },
});
}
} }

Some files were not shown because too many files have changed in this diff Show More