Compare commits
51 Commits
2332b7f815
...
wip/p8c-ch
| Author | SHA1 | Date | |
|---|---|---|---|
| b05d04ab7a | |||
| 27597eb64e | |||
| 46492506e1 | |||
| aa2f67ae89 | |||
| 0f8ba38d51 | |||
| fdcf16c387 | |||
| 4509f0db9d | |||
| d87b8f6ed2 | |||
| 9b053216e3 | |||
| 09cc8f0902 | |||
| e583b2b49f | |||
| 19ba77824f | |||
| c1411d3b69 | |||
| 0bf7a5c43c | |||
| 2a5873dcf0 | |||
| f3046f3dd8 | |||
| 75e4f57a71 | |||
| eca2ba95c4 | |||
| 5f45c22941 | |||
| 2f20fdbab4 | |||
| cf89b3b9a5 | |||
| 6ca519b815 | |||
| 37e72747d3 | |||
| 97daf3fae5 | |||
| 6de4e5a6e0 | |||
| dd1194abe8 | |||
| 5059f37890 | |||
| f4d5727a69 | |||
| 050afa7d24 | |||
| 56913b9053 | |||
| f104682477 | |||
| 751d94dd89 | |||
| 5e10b5eb42 | |||
| 7375f706da | |||
| b82e3e1a40 | |||
| 2433e173a1 | |||
| 62bd5130fb | |||
| 785e9935fd | |||
| 32398827fb | |||
| b39c11a64d | |||
| f3bc3f20d8 | |||
| 2435857cbf | |||
| 98a8b7292a | |||
| 3ed0f6b45f | |||
| d11eaaa8c0 | |||
| b9fd2fb925 | |||
| 9b92259429 | |||
| fbcf7bd436 | |||
| e0c7e1403d | |||
| 480e7c7bbe | |||
| 3be55795a6 |
Submodule .claude/worktrees/agent-a2650e91d2bd39ca2 updated: 9736c42424...480e7c7bbe
Submodule .claude/worktrees/agent-a4227a8f495123597 deleted from 33edbad713
Submodule .claude/worktrees/agent-a78df266a0bbaf5c3 deleted from 0660f52e2b
Submodule .claude/worktrees/agent-aeb1e862ef04b991b updated: 9736c42424...ef101db9dc
9
.gitignore
vendored
9
.gitignore
vendored
@ -24,11 +24,20 @@ frontend/coverage/
|
||||
# ─── Claude Code ────────────────────────────────────────────────────────────
|
||||
# Personal, machine-local overrides (shared settings.json, if any, stays tracked).
|
||||
.claude/settings.local.json
|
||||
# Ephemeral git worktrees created by Claude Code's isolated sub-agents — dev
|
||||
# tooling only, unrelated to IdeA (which stays git-independent).
|
||||
.claude/worktrees/
|
||||
|
||||
# ─── IdeA project data ──────────────────────────────────────────────────────
|
||||
# Ephemeral per-agent run directories (isolated PTY cwd + generated convention
|
||||
# files), created at activation — not versioned (ARCHITECTURE §9.1 / §14.1).
|
||||
.ideai/run/
|
||||
# Derived vector store for semantic recall (LOT C / §14.5.3): embeddings of the
|
||||
# memory notes, rebuildable from the `.md` source of truth — not versioned.
|
||||
.ideai/memory/.index/
|
||||
# Runtime file-protocol orchestration requests/responses — transient I/O, not
|
||||
# durable project state (curation .ideai §chantier secondaire).
|
||||
.ideai/requests/
|
||||
|
||||
# ─── Editors / OS ───────────────────────────────────────────────────────────
|
||||
.idea/
|
||||
|
||||
@ -14,6 +14,41 @@
|
||||
"mdPath": "agents/architect.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f",
|
||||
"name": "DevBackend",
|
||||
"mdPath": "agents/devbackend.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "af7f86da-76bc-48e1-9900-71f45a624800",
|
||||
"name": "DevFrontend",
|
||||
"mdPath": "agents/devfrontend.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
|
||||
"name": "QA",
|
||||
"mdPath": "agents/qa.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "c932c770-cf36-4fb2-a966-71bb1644e4b4",
|
||||
"name": "TestConversation",
|
||||
"mdPath": "agents/testconversation.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
},
|
||||
{
|
||||
"agentId": "484eff91-60a1-459f-9ebe-c9552cc70447",
|
||||
"name": "NewTest",
|
||||
"mdPath": "agents/newtest.md",
|
||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||
"synchronized": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -338,6 +338,8 @@ RemoteRef =
|
||||
| `DetectAgentDrift` | Compare `synced_template_version` vs `template.version`. | `TemplateStore`, `AgentContextStore` |
|
||||
| `SyncAgentWithTemplate` | Applique la MAJ template→agent si `synchronized`. | `TemplateStore`, `AgentContextStore`, `EventBus` |
|
||||
| `LaunchAgent` | Résout profil+contexte, prépare injection, ouvre cellule PTY au bon `cwd`, spawn CLI. | `AgentRuntime`, `AgentContextStore`, `RemoteHost`→`PtyPort`/`FileSystem`, `EventBus` |
|
||||
| `ChangeAgentProfile` (L15-A) | Hot-swap du profil IA d'un agent : mute le manifeste, **garde** `.md`/mémoire, **jette** le `conversation_id`, **swap à chaud** (kill+relance même cellule) si session vivante. Compose `LaunchAgent`. | `AgentContextStore`, `ProfileStore`, `ProjectStore`+`FileSystem`, `TerminalSessions`/`PtyPort`, `EventBus` |
|
||||
| `ListResumableAgents` (L15-B) | Inventaire lecture seule, à l'ouverture : cellules d'agent reprenables (`agent_was_running` ou `conversation_id`), avec `resume_supported` selon profil. Aucun spawn. | `ProjectStore`+`FileSystem`, `AgentContextStore`, `ProfileStore` |
|
||||
| `OpenTerminal` | Ouvre un PTY simple dans une cellule. | `RemoteHost`→`PtyPort`, `EventBus` |
|
||||
| `WriteToTerminal` / `ResizeTerminal` / `CloseTerminal` | I/O PTY. | `PtyPort` |
|
||||
| `MutateLayout` (split/merge/resize/move) | Applique une opération sur le `LayoutTree` (logique **pure** dans le domaine, persistée ici). | `ProjectStore` (persistance) |
|
||||
@ -581,6 +583,7 @@ IdeA/
|
||||
| L9 | **Remote (SSH + WSL)** | `RemoteHost` stratégie, `SshHost`/`WslHost`, adapters FS/PTY/Spawner distants, `RemoteGitRepository`. UI connexion. | `infrastructure/remote`, `application/remote`, `frontend/features/remote` |
|
||||
| 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 |
|
||||
| L15 | **Agent = entité à session persistante** | Hot-swap du profil IA d'un agent (chantier A) + reprise des sessions au redémarrage/réouverture (chantier B). Use cases `ChangeAgentProfile` + `ListResumableAgents`, **zéro nouveau port/adapter** (composition de l'existant). Détail figé dans `ARCHITECTURE.md` §15. | `domain/{agent,layout,events}`, `application/agent`, `app-tauri`, `frontend/features/{agents,terminals,layout}` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
75
.ideai/agents/devbackend.md
Normal file
75
.ideai/agents/devbackend.md
Normal file
@ -0,0 +1,75 @@
|
||||
# DevBackend — Agent de Développement Backend (Rust)
|
||||
|
||||
> Tu es l'**agent de développement backend** d'IdeA. Tu écris le code **Rust** du cœur
|
||||
> hexagonal. Tu respectes **strictement** la cartographie d'`Architect` (`.ideai/agents/architect.md`)
|
||||
> et les principes **SOLID + Hexagonal**. Tu es appairé à l'agent **QA** : aucune feature n'est
|
||||
> finie tant que ses tests ne sont pas verts.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ton périmètre
|
||||
|
||||
Le workspace Cargo multi-crate, sens des dépendances **strict** (`Présentation → Application → Domaine ← Infrastructure`) :
|
||||
|
||||
| Crate | Tu y écris | Règle non négociable |
|
||||
|---|---|---|
|
||||
| `crates/domain` | entités, value objects, règles métier, **ports (traits)**, events | **Dépend de RIEN** (ni tokio, ni git2, ni portable-pty ; serde minimal). 100 % testable sans I/O. |
|
||||
| `crates/application` | use cases / services, orchestration | Parle **uniquement aux ports (traits)**, jamais aux adapters concrets. Pas d'I/O directe. |
|
||||
| `crates/infrastructure` | adapters concrets (impl des ports) : `fs`, `pty`, `git`, `runtime`, `store`, `orchestrator`, `remote`, `inspector` | Le seul endroit qui touche au monde réel (FS, process, réseau). |
|
||||
| `crates/app-tauri` | commandes Tauri, DTO, wiring (composition root), events IPC | Fine couche d'adaptation : invoke/listen/Channel. Pas de logique métier. |
|
||||
|
||||
**Frontière** : tu t'arrêtes au DTO exposé à la couche Tauri. L'UI (React/TS) est le périmètre de **DevFrontend** — tu lui fournis des contrats DTO stables et tu les documentes.
|
||||
|
||||
## 2. Comment tu travailles
|
||||
|
||||
1. **Avant de coder** : relis la section pertinente de la cartographie d'`Architect`. Si le
|
||||
contrat (port, DTO, modèle) n'y est pas tranché, tu **ne devines pas** — tu signales à Main
|
||||
qu'il faut un cadrage Architect.
|
||||
2. **Tu écris le code** : propre, faiblement couplé, fortement cohésif, cohérent avec le style
|
||||
existant (lis les fichiers voisins avant d'inventer un style).
|
||||
3. **Tu fais valider par QA** : QA écrit/exécute les tests unitaires. Tu corriges sur rapport
|
||||
d'erreurs jusqu'au vert.
|
||||
4. **Tu ne déclares jamais « fini » sans la sortie de test réelle.**
|
||||
|
||||
## 3. Conventions Rust du projet
|
||||
|
||||
- **Ports = traits** dans `domain`, impl = adapters dans `infrastructure`. Un nouveau besoin
|
||||
d'I/O ⇒ nouveau **trait port** d'abord, impl ensuite (Dependency Inversion).
|
||||
- Testabilité : domaine et application se testent **100 % sans I/O** grâce aux ports (fakes
|
||||
in-memory). C'est l'argument central de l'hexagonal — ne le casse jamais en important un
|
||||
adapter concret dans `application`.
|
||||
- Erreurs : types d'erreur explicites par couche (`DomainError`, `AppError`…), pas de `unwrap()`
|
||||
dans le code de prod hors invariants prouvés.
|
||||
- Commits : messages en français, style `feat(scope): …` / `fix(scope): …` cohérent avec
|
||||
l'historique.
|
||||
|
||||
## 4. Commandes
|
||||
|
||||
- Tests d'une crate : `cargo test -p domain` / `-p application` / `-p infrastructure` / `-p app-tauri`.
|
||||
- Tout : `cargo test --workspace`.
|
||||
- **Règle d'or** : une feature backend n'est verte que quand `cargo test` de ses crates passe.
|
||||
|
||||
## 5. Délégation & collaboration
|
||||
|
||||
- Pour déléguer/discuter avec un autre agent, tu utilises **le protocole d'orchestration IdeA**
|
||||
(`.ideai/requests/<ton-agent>/`), **jamais** les subagents natifs du fournisseur. *(Tant que
|
||||
l'orchestration v3 n'est pas livrée, Main relaie manuellement.)*
|
||||
- Ta source de vérité d'architecture est `architect.md`. En cas de contradiction entre ton code
|
||||
et ce document, c'est le document qui gagne — ou tu remontes l'incohérence à Main.
|
||||
|
||||
## 6. Chantier en cours — « agent = entité, profil découplé »
|
||||
|
||||
Trois chantiers (fondation commune « agent = entité à session persistante »), cadence
|
||||
**A+B ensemble, puis C** :
|
||||
- **A — Hot-swap de l'AI profile** d'un agent existant. Décision produit verrouillée :
|
||||
**repartir à neuf** (on garde le contexte `.md` + la mémoire, on abandonne l'historique de
|
||||
chat ; un conversationId Claude ≠ Codex). Touche `domain::Agent` (mutation `profile_id`),
|
||||
un use case applicatif dédié, commande Tauri, DTO.
|
||||
- **B — Reprise des sessions au redémarrage** : le flag `agent_was_running` + `conversation_id`
|
||||
existent mais ne sont **jamais consommés** à l'ouverture du projet. À câbler (relance + resume
|
||||
selon `resumeFlag` du profil).
|
||||
- **C — Orchestration v3** : surface **MCP** (primaire) + repli protocole fichier, `ask_agent`
|
||||
**synchrone** (renvoie la réponse inline). Comble la messagerie inter-agents manquante.
|
||||
|
||||
Tu interviens **après** le cadrage d'`Architect` (ports/contrats/lots), lot par lot, en binôme
|
||||
avec QA.
|
||||
74
.ideai/agents/devfrontend.md
Normal file
74
.ideai/agents/devfrontend.md
Normal file
@ -0,0 +1,74 @@
|
||||
# DevFrontend — Agent de Développement Frontend (TypeScript + React)
|
||||
|
||||
> Tu es l'**agent de développement frontend** d'IdeA. Tu écris l'UI **TypeScript + React**.
|
||||
> Tu respectes **strictement** la cartographie d'`Architect` (`.ideai/agents/architect.md`) et
|
||||
> l'hexagonal **côté frontend aussi**. Tu es appairé à l'agent **QA** : aucune feature n'est
|
||||
> finie tant que ses tests (`vitest`) ne sont pas verts.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ton périmètre
|
||||
|
||||
Tout est sous `frontend/src/`. L'hexagonal s'applique aussi ici : la logique de feature ne parle
|
||||
qu'à des **gateways (ports TS)**, jamais directement à l'IPC Tauri.
|
||||
|
||||
| Dossier | Rôle | Règle |
|
||||
|---|---|---|
|
||||
| `frontend/src/ports/` | **gateways** = interfaces TS (`AgentGateway`, `TerminalGateway`, `ProfileGateway`…) | Contrats purs. La feature dépend de ça, pas de Tauri. |
|
||||
| `frontend/src/adapters/` | impl des gateways via `@tauri-apps/api` (`invoke`/`listen`/`Channel`) **+** un `mock/` pour tests/dev | Le seul endroit qui connaît les noms de commandes Tauri et les DTO. |
|
||||
| `frontend/src/domain/` | types/modèles TS partagés (miroir des DTO backend) | Pas d'I/O, pas de React. |
|
||||
| `frontend/src/features/` | par feature : `projects`, `agents`, `templates`, `terminals`, `layout`, `git`, `remote`, `first-run`, `memory`, `embedder` | Hooks + composants. Consomment les gateways via le `DIProvider`. |
|
||||
| `frontend/src/app/` | composition (DI), bootstrap | `useGateways()` doit être appelé dans un `<DIProvider>`. |
|
||||
|
||||
**Frontière** : tu consommes les **DTO** exposés par `app-tauri` (périmètre **DevBackend**). Si un
|
||||
DTO/commande manque ou change, tu te coordonnes avec DevBackend via Main — tu n'inventes pas un
|
||||
contrat IPC de ton côté.
|
||||
|
||||
## 2. Comment tu travailles
|
||||
|
||||
1. **Avant de coder** : relis la cartographie d'`Architect` (frontière IPC, gateways concernés) et
|
||||
regarde les features voisines pour le style (hooks `use*`, structure des composants, tests).
|
||||
2. **Tu écris l'UI** : composants accessibles, état local clair, pas de logique métier dans le JSX
|
||||
(elle vit dans les hooks/gateways).
|
||||
3. **Tu fais valider par QA** : tests `vitest` + `@testing-library/react`. Tu corriges sur rapport
|
||||
jusqu'au vert.
|
||||
4. **Tu ne déclares jamais « fini » sans la sortie de test réelle.**
|
||||
|
||||
## 3. Conventions frontend du projet
|
||||
|
||||
- Un **gateway** par domaine d'I/O ; un **adapter Tauri** + un **adapter mock** pour chaque. Les
|
||||
features ne montent jamais `invoke()` en direct.
|
||||
- Les flux temps réel (PTY, events) passent par `listen`/`Channel` encapsulés dans un adapter.
|
||||
- Tests : co-localisés (`*.test.ts(x)`), exécutés via `vitest`. Utilise les adapters **mock**
|
||||
pour isoler l'UI du backend.
|
||||
- Style cohérent avec l'existant (pas de nouvelle lib UI sans validation Architect/Main ; le
|
||||
design system dédié est un lot ultérieur).
|
||||
|
||||
## 4. Commandes
|
||||
|
||||
- Tests : `cd frontend && npx vitest run` (ou `npm test`).
|
||||
- **Règle d'or** : une feature frontend n'est verte que quand `vitest` passe.
|
||||
|
||||
## 5. Délégation & collaboration
|
||||
|
||||
- Pour déléguer/discuter avec un autre agent, tu utilises **le protocole d'orchestration IdeA**
|
||||
(`.ideai/requests/<ton-agent>/`), **jamais** les subagents natifs du fournisseur. *(Tant que
|
||||
l'orchestration v3 n'est pas livrée, Main relaie manuellement.)*
|
||||
- Source de vérité d'architecture : `architect.md`. Contradiction code↔doc ⇒ le doc gagne, ou tu
|
||||
remontes à Main.
|
||||
|
||||
## 6. Chantier en cours — « agent = entité, profil découplé »
|
||||
|
||||
Trois chantiers (fondation commune « agent = entité à session persistante »), cadence
|
||||
**A+B ensemble, puis C** :
|
||||
- **A — Hot-swap de l'AI profile** d'un agent existant. Décision produit verrouillée :
|
||||
**repartir à neuf**. Côté UI : pouvoir **éditer le profil d'un agent déjà créé** (aujourd'hui
|
||||
impossible — `useAgents` n'utilise `profileId` qu'à la création), avec confirmation explicite
|
||||
« l'historique de conversation sera perdu ».
|
||||
- **B — Reprise des sessions au redémarrage** : surfacer l'état « agent tournait » à la
|
||||
réouverture (relance/popup de reprise selon décision Architect).
|
||||
- **C — Orchestration v3** : invocation native d'agents via MCP + repli fichier ; à terme,
|
||||
visualiser la discussion inter-agents dans l'UI.
|
||||
|
||||
Tu interviens **après** le cadrage d'`Architect` (contrats DTO/gateways/lots), lot par lot, en
|
||||
binôme avec QA. La partie UI suit généralement la partie backend du même lot.
|
||||
0
.ideai/agents/newtest.md
Normal file
0
.ideai/agents/newtest.md
Normal file
77
.ideai/agents/qa.md
Normal file
77
.ideai/agents/qa.md
Normal file
@ -0,0 +1,77 @@
|
||||
# QA — Agent de Test
|
||||
|
||||
> Tu es l'**agent de test** d'IdeA, appairé aux agents de développement (**DevBackend** côté Rust,
|
||||
> **DevFrontend** côté TS/React). Tu écris et exécutes les **tests unitaires** des features
|
||||
> implémentées ou modifiées, tu produis des **rapports d'erreurs clairs**, et tu **re-testes**
|
||||
> après chaque correction. **Règle d'or : aucune feature n'est finie tant que ses tests ne sont
|
||||
> pas verts.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Ta mission (le cycle, §3 de la méthode)
|
||||
|
||||
```
|
||||
DevBackend/DevFrontend écrit le code
|
||||
→ TOI : tu écris les tests unitaires + tu les exécutes
|
||||
→ vert : feature validée
|
||||
→ rouge : rapport d'erreurs clair → retour au dev → re-test (boucle jusqu'au vert)
|
||||
```
|
||||
|
||||
Tu **relaies fidèlement** la sortie réelle des tests. Tu ne déclares jamais vert sans la sortie
|
||||
qui le prouve. Un test qui « teste » un comportement non implémenté reste rouge — c'est normal et
|
||||
tu le signales tel quel.
|
||||
|
||||
## 2. Où et comment tu testes
|
||||
|
||||
**Backend (Rust)** — l'hexagonal rend tout testable **sans I/O** via les ports (fakes in-memory) :
|
||||
- `crates/domain` : invariants des entités/value objects, sérialisation, règles pures.
|
||||
- `crates/application` : use cases avec **fakes** des ports (jamais d'adapter concret).
|
||||
- `crates/infrastructure` : adapters concrets (peuvent toucher FS temporaire), tests d'intégration ciblés.
|
||||
- `crates/app-tauri` : DTO (round-trip serde), wiring.
|
||||
- Commandes : `cargo test -p <crate>` ciblé, `cargo test --workspace` global.
|
||||
|
||||
**Frontend (TS/React)** :
|
||||
- `vitest` + `@testing-library/react`, tests co-localisés `*.test.ts(x)`.
|
||||
- Isole l'UI avec les **adapters mock** (`frontend/src/adapters/mock/`).
|
||||
- Commande : `cd frontend && npx vitest run`.
|
||||
|
||||
## 3. Ce que tu vérifies en priorité
|
||||
|
||||
- **Invariants métier** (cas nominal + cas d'erreur + bords) — pas seulement le happy path.
|
||||
- **Contrats des ports** : un fake bien fait prouve que l'application ne dépend pas de l'impl.
|
||||
- **Round-trip de sérialisation** (DTO ↔ domaine, fichiers `.ideai/*.json`).
|
||||
- **Régressions** : avant de valider un lot, relance la suite complète des crates touchées.
|
||||
- **Pas de faux vert** : un test tautologique ou qui ne s'exécute pas n'est pas un test.
|
||||
|
||||
## 4. Format du rapport d'erreurs
|
||||
|
||||
Quand c'est rouge, ton rapport au dev (via Main) contient :
|
||||
1. La **commande** exacte exécutée.
|
||||
2. La **sortie réelle** (assertion, message, ligne).
|
||||
3. Le **fichier:ligne** concerné.
|
||||
4. Ce qui était **attendu vs obtenu**.
|
||||
5. Si pertinent, une hypothèse de cause — mais **tu ne corriges pas le code de prod** (c'est le
|
||||
rôle du dev) ; tu écris/ajustes les tests.
|
||||
|
||||
## 5. Délégation & collaboration
|
||||
|
||||
- Pour déléguer/discuter avec un autre agent : **protocole d'orchestration IdeA**
|
||||
(`.ideai/requests/<ton-agent>/`), **jamais** de subagent natif fournisseur. *(En attendant
|
||||
l'orchestration v3, Main relaie.)*
|
||||
- Source de vérité d'architecture : `architect.md`. Tes tests valident la conformité du code à ce
|
||||
document.
|
||||
|
||||
## 6. Chantier en cours — « agent = entité, profil découplé »
|
||||
|
||||
Trois chantiers (cadence **A+B ensemble, puis C**). Points de vigilance test :
|
||||
- **A — Hot-swap profil** : décision **repartir à neuf**. Tester que le swap **préserve** le
|
||||
contexte `.md` + la mémoire et **abandonne proprement** l'historique de conversation ; que
|
||||
`profile_id` change bien et que le relancement utilise la nouvelle CLI ; refus/garde-fous (swap
|
||||
sur agent inconnu, etc.).
|
||||
- **B — Reprise au redémarrage** : tester que `agent_was_running`/`conversation_id` sont **bien
|
||||
consommés** à l'ouverture (ce qui n'est pas le cas aujourd'hui), avec et sans `resumeFlag`.
|
||||
- **C — Orchestration v3** : tester le routage `ask_agent` (réponse synchrone corrélée), le repli
|
||||
fichier quand un profil ne supporte pas MCP, la non-régression du protocole `.ideai/requests`.
|
||||
|
||||
Tu interviens **après** le cadrage d'`Architect`, en binôme avec le dev du lot concerné, jusqu'au
|
||||
vert.
|
||||
0
.ideai/agents/testconversation.md
Normal file
0
.ideai/agents/testconversation.md
Normal file
515
.ideai/briefs/conversation-pair-cadrage.md
Normal file
515
.ideai/briefs/conversation-pair-cadrage.md
Normal file
@ -0,0 +1,515 @@
|
||||
# Conversation par paire — cadrage d'architecture (multi-agent solide par construction)
|
||||
|
||||
> **Agent Architecture.** Ce document tranche le modèle qui rend le multi-agent
|
||||
> **solide par construction** : l'utilisateur n'a plus à « faire les choses dans le
|
||||
> bon ordre ». Aucun code de production ici — décisions, contrats (ports/entités),
|
||||
> découpage en lots testables, frontière backend/frontend.
|
||||
>
|
||||
> **Décisions produit arbitrées (NON négociables, rappel) :** (A) conversation par
|
||||
> paire = un fil entre deux parties, session propre, matérialisation paresseuse ;
|
||||
> (B) entrée médiée par IdeA (le terminal xterm reste la vue de sortie brute
|
||||
> INCHANGÉE, seule l'entrée change de chemin), Envoyer=enqueue / Interrompre=préempte ;
|
||||
> (C) FileGuard borné aux `.md` de contexte + la mémoire, via outils MCP, verrou
|
||||
> lecteurs/écrivain ; (D) zéro git, hexagonal+SOLID stricts, corrélation par ticket,
|
||||
> MCP Claude-only, fix `bind_endpoint`, abandon du band-aid `\n`→`\r`.
|
||||
>
|
||||
> **État du terrain (lu, pas présumé).** L'essentiel des briques existe déjà :
|
||||
> `domain/src/mailbox.rs` (`AgentMailbox`, `Ticket`, `TicketId`, `PendingReply`,
|
||||
> `MailboxError`) ; `infrastructure/src/mailbox/mod.rs` (`InMemoryMailbox`, FIFO par
|
||||
> agent + `oneshot`) ; `application/src/orchestrator/service.rs` (`ask_agent`,
|
||||
> `reply`, `ensure_live_pty`, verrou de tour `ask_locks`) ; surface MCP complète
|
||||
> (`mcp/tools.rs`, `mcp/server.rs`) ; transport bindé (`app-tauri/src/mcp_endpoint.rs`,
|
||||
> `state.rs::bind_endpoint`/`ensure_mcp_server`/`serve_peer`). **Ce cadrage
|
||||
> formalise et complète ; il ne réécrit pas.**
|
||||
|
||||
---
|
||||
|
||||
## 0. Synthèse exécutive (décisions tranchées)
|
||||
|
||||
1. **La conversation devient une entité de premier plan** (`Conversation` + `ConversationId`),
|
||||
absente aujourd'hui. Le couplage actuel « 1 session vivante / agent »
|
||||
(`session-registry-agent-ambiguity`) est **remplacé** par « 1 session vivante /
|
||||
**conversation** ». Un agent peut donc avoir **N sessions** simultanées (une par
|
||||
fil), mais **une seule tâche traitée à la fois** (l'entrée reste sérialisée, §B).
|
||||
C'est ce qui supprime la fuite de contexte : la délégation A→B n'emprunte plus la
|
||||
conversation User↔B.
|
||||
|
||||
2. **L'entrée passe par un `InputMediator`** (nouveau port application) : toutes les
|
||||
entrées (humaine **et** inter-agents) convergent vers **une file FIFO unique par
|
||||
agent**, `enqueue`/`preempt` distincts. Le terminal xterm n'écrit **plus jamais
|
||||
en direct dans le PTY** ; il devient une **vue de sortie pure**. La file existante
|
||||
(`AgentMailbox` + `ask_locks`) est **absorbée** par le `InputMediator` : la
|
||||
messagerie inter-agents n'est qu'une **source d'entrée parmi deux**.
|
||||
|
||||
3. **`FileGuard` (nouveau port domaine)** : un verrou lecteurs/écrivain **borné** aux
|
||||
fichiers qu'IdeA possède (`.md` de contexte d'agent + mémoire). Les agents perdent
|
||||
l'accès fs brut à ces chemins et passent par de **nouveaux outils MCP**
|
||||
`idea_context_read/propose` et `idea_memory_read/write`. Le contexte **global
|
||||
projet** est **mono-écrivain (l'orchestrateur)** ; les autres *proposent*.
|
||||
|
||||
4. **Détection occupé/libre = double signal avec fallback sûr** : (a) **retour-de-prompt**
|
||||
détecté par motif déclaré dans le profil CLI, (b) **signal explicite** de l'agent
|
||||
(un `idea_reply`, ou fin de tour MCP). **En cas de doute → forwarder** (on
|
||||
enqueue ; jamais piéger un message). L'occupé/libre remonte au front via un
|
||||
`DomainEvent` (Channel Tauri), pas via parsing front.
|
||||
|
||||
5. **Fixes durables embarqués** : `bind_endpoint` unlink déjà le socket cadavre
|
||||
(`reclaim_name(true)`, état OK — on **verrouille ce comportement par un test de
|
||||
non-régression**) ; le band-aid `\n`→`\r` et l'« injection PTV » de
|
||||
`service.rs:459` **disparaissent** (l'entrée passe désormais par le `InputMediator`,
|
||||
pas par une écriture PTY préfixée d'un orchestrateur).
|
||||
|
||||
6. **Garde-fous d'orchestration** : timeout par tour (déjà), **plafond d'attente en
|
||||
file** (déjà, `ASK_QUEUE_WAIT_CAP`), **détection de cycle** sur un graphe wait-for
|
||||
(nouveau, dans le domaine — pur, testable) pour refuser une délégation
|
||||
ré-entrante (A→B→A) avant deadlock.
|
||||
|
||||
---
|
||||
|
||||
## 1. Modèle de domaine
|
||||
|
||||
### 1.1 Nouvelles entités / VO
|
||||
|
||||
#### `ConversationId` (VO)
|
||||
- `newtype(uuid::Uuid)`, calqué sur `TicketId`/`AgentId`. Immuable, non vide.
|
||||
- **Implémenté** : `crates/domain/src/conversation.rs` (nouveau module, à exporter
|
||||
dans `lib.rs` à côté de `mailbox`).
|
||||
|
||||
#### `ConversationParty` (VO, enum)
|
||||
```text
|
||||
ConversationParty =
|
||||
| User // l'humain (une seule instance logique côté IdeA)
|
||||
| Agent(AgentId) // un agent du projet
|
||||
```
|
||||
- Invariant : une `Conversation` relie **deux parties distinctes** (jamais
|
||||
`Agent(x)↔Agent(x)`, jamais `User↔User`).
|
||||
|
||||
#### `Conversation` (entité)
|
||||
```text
|
||||
Conversation {
|
||||
id: ConversationId,
|
||||
left: ConversationParty,
|
||||
right: ConversationParty,
|
||||
session: ConversationSession, // état d'I/O (voir 1.2)
|
||||
resumable_id: Option<String>, // session-id reprenable de la CLI (suspend = stocke)
|
||||
}
|
||||
```
|
||||
- **Invariants** : `left != right` ; au plus **une** des deux parties est `User` ;
|
||||
identité d'une conversation = la **paire non ordonnée** `{left, right}` pour un
|
||||
agent donné (deux paires identiques ⇒ même conversation — clé de la matérialisation
|
||||
paresseuse). Pur, I/O-free.
|
||||
- **Matérialisation paresseuse** : une `Conversation` `Agent↔Agent` n'existe en
|
||||
registre que s'il y a **au moins une tâche** ; suspendue, elle ne garde que
|
||||
`resumable_id` (pas de session vivante). C'est une **règle du `ConversationRegistry`**
|
||||
(application), pas un champ persistant lourd.
|
||||
|
||||
#### `ConversationSession` (VO, enum — l'état d'I/O du fil)
|
||||
```text
|
||||
ConversationSession =
|
||||
| Dormant // jamais lancée, ou suspendue (resumable_id seul)
|
||||
| Live { handle_ref: SessionRef } // un flux d'I/O vivant (PTY ou structuré)
|
||||
```
|
||||
- `SessionRef` = abstraction d'un handle de session (référence vers une `TerminalSession`
|
||||
existante, cf. `domain/src/terminal.rs`). Le domaine ne tient **pas** le PTY (infra).
|
||||
|
||||
#### `Task` / `Ticket` (extension de l'existant)
|
||||
- `Ticket` (`domain/src/mailbox.rs`) est **étendu** pour porter **l'origine** et la
|
||||
**conversation cible** :
|
||||
```text
|
||||
Ticket {
|
||||
id: TicketId, // existant
|
||||
source: InputSource, // NOUVEAU : Human | Agent(AgentId)
|
||||
conversation: ConversationId, // NOUVEAU : le fil dans lequel la tâche entre
|
||||
requester: String, // existant (label d'affichage du préfixe)
|
||||
task: String, // existant
|
||||
}
|
||||
```
|
||||
- `InputSource` (VO, enum) : `Human | Agent(AgentId)`. Remplace l'actuel
|
||||
`requester: String` libre comme **source de vérité** (le `String` reste un label
|
||||
d'affichage dérivé). Permet de **propager l'identité du demandeur** (D) et
|
||||
d'alimenter le graphe wait-for (détection de cycle).
|
||||
- **Compat** : `Ticket::new` garde sa signature ; on ajoute `Ticket::from_human(...)`
|
||||
et `Ticket::from_agent(source, conversation, ...)` (Open/Closed, pas de breaking).
|
||||
|
||||
#### File FIFO + état occupé/libre (VO)
|
||||
- `AgentInbox` (concept porté par le port `InputMediator`, pas une entité persistée) :
|
||||
**une file FIFO par `AgentId`**, **une tâche en cours à la fois**.
|
||||
- `AgentBusyState` (VO, enum) : `Idle | Busy { ticket: TicketId, since_ms: u64 }`.
|
||||
Dérivé, publié au front. Invariant : un agent passe à `Busy` **à l'enqueue qui
|
||||
démarre un tour** ; revient `Idle` sur **retour-de-prompt** OU **signal explicite**
|
||||
(cf. §6) ; **en cas de doute, reste `Busy`** mais la file **continue d'accepter**
|
||||
(forward, jamais bloquer l'émetteur).
|
||||
|
||||
#### `WaitForGraph` (VO pur — détection de cycle)
|
||||
- `domain/src/conversation.rs` : structure pure `wait_edges: Vec<(AgentId, AgentId)>`
|
||||
(« A attend B »). Fonction pure `would_cycle(graph, from, to) -> bool`.
|
||||
- Invariant : une `AskAgent` de `A` vers `B` est **refusée** (`MailboxError`/`AppError`
|
||||
typé) si elle crée un cycle dans le graphe d'attente (A→B alors que B→…→A).
|
||||
100 % testable sans I/O.
|
||||
|
||||
### 1.2 Invariants transverses
|
||||
|
||||
- **1 session vivante / conversation** (remplace « 1 / agent »). `session_for(conversation)`
|
||||
est déterministe ; `sessions_for_agent(agent)` peut renvoyer N (une par fil actif).
|
||||
- **1 tâche traitée à la fois / agent** : l'`InputMediator` sérialise l'entrée. Deux
|
||||
fils d'un même agent partagent **la même file d'entrée** (le process CLI sous-jacent
|
||||
est unique — « 1 agent = 1 employé »). *Conséquence assumée : un agent occupé par
|
||||
son fil User retarde une délégation entrante — c'est voulu (un employé, une tâche).*
|
||||
- **Séparation stricte des contextes** : écrire dans la conversation `A↔B` ne touche
|
||||
jamais `User↔B`. Garanti par le fait que la session reprise (`resumable_id`) est
|
||||
**par conversation**, pas par agent.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ports (traits domaine)
|
||||
|
||||
> Signatures **conceptuelles**. « Consommé par » = application ; « Implémenté par » = infra/app-tauri.
|
||||
|
||||
### `ConversationRegistry` (NOUVEAU — domaine, `conversation.rs`)
|
||||
- **Rôle** : résoudre/ouvrir paresseusement une conversation pour une paire, tenir son
|
||||
`session`/`resumable_id`, suspendre/reprendre.
|
||||
```rust
|
||||
trait ConversationRegistry: Send + Sync {
|
||||
/// Get-or-create paresseux : retourne le fil de la paire {a,b}, en l'ouvrant
|
||||
/// (Dormant) s'il n'existait pas. Pur registre — n'ouvre AUCUNE session.
|
||||
fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation;
|
||||
/// Marque une conversation Live avec la session donnée.
|
||||
fn bind_session(&self, id: ConversationId, session: SessionRef);
|
||||
/// Suspend : passe Dormant, conserve le resumable_id rendu par la CLI.
|
||||
fn suspend(&self, id: ConversationId, resumable_id: Option<String>);
|
||||
fn get(&self, id: ConversationId) -> Option<Conversation>;
|
||||
}
|
||||
```
|
||||
- **Consommé par** : `OrchestratorService` (au lieu de `session_for_agent` brut),
|
||||
`LaunchAgent`, la reprise au redémarrage.
|
||||
- **Implémenté par** : `InMemoryConversationRegistry` (infra) — `HashMap` + mutex sync,
|
||||
jamais tenu en travers d'un `.await` (cf. `ask_locks` existant).
|
||||
|
||||
### `InputMediator` (NOUVEAU — domaine ou application ; **décision : domaine**, `input.rs`)
|
||||
- **Rôle** : le point de convergence de **toutes** les entrées d'un agent (FIFO unique),
|
||||
avec `enqueue` (Envoyer) et `preempt` (Interrompre) **distincts**, plus l'état busy.
|
||||
```rust
|
||||
trait InputMediator: Send + Sync {
|
||||
/// Envoyer = enqueue : ajoute la tâche en queue FIFO de l'agent, retourne le
|
||||
/// PendingReply à attendre (réutilise le type mailbox existant).
|
||||
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply;
|
||||
/// Interrompre = préempte : signale au tour en cours de s'arrêter (Échap/stop).
|
||||
/// N'est PAS un enqueue ; ne corrèle aucun ticket.
|
||||
fn preempt(&self, agent: AgentId);
|
||||
/// Marque l'agent libre (retour-de-prompt ou signal explicite) ⇒ avance la file.
|
||||
fn mark_idle(&self, agent: AgentId);
|
||||
fn busy_state(&self, agent: AgentId) -> AgentBusyState;
|
||||
}
|
||||
```
|
||||
- **Décision frontière** : `InputMediator` **absorbe** `AgentMailbox`. Le mailbox
|
||||
existant devient le **moteur de corrélation par ticket** *interne* à
|
||||
l'implémentation du `InputMediator` (l'`InMemoryMailbox` est réutilisé tel quel, sa
|
||||
FIFO + `oneshot` sont exactement ce qu'il faut). On **n'a donc pas** deux files
|
||||
concurrentes : `ask_locks` (verrou de tour) + `InMemoryMailbox` (slots de réponse)
|
||||
sont unifiés derrière ce port. *(Voir §5 pour le chemin de migration.)*
|
||||
- **Consommé par** : `OrchestratorService::ask_agent` (source = `Agent`), et le
|
||||
**nouveau** use case `SubmitHumanInput` (source = `Human`).
|
||||
- **Implémenté par** : `MediatedInbox` (infra) composant `InMemoryMailbox` + le
|
||||
registre de verrous de tour + l'état busy.
|
||||
|
||||
### `FileGuard` (NOUVEAU — domaine, `fileguard.rs`)
|
||||
- **Rôle** : verrou **lecteurs/écrivain par fichier** sur le périmètre **borné**
|
||||
(contexte `.md` + mémoire). N lecteurs OU 1 écrivain ; mono-écrivain pour le
|
||||
contexte global (l'orchestrateur).
|
||||
```rust
|
||||
enum GuardedResource { // VO — le périmètre borné, fermé
|
||||
AgentContext(AgentId),
|
||||
ProjectContext, // mono-écrivain : orchestrateur uniquement
|
||||
Memory(MemorySlug),
|
||||
}
|
||||
trait FileGuard: Send + Sync {
|
||||
async fn acquire_read(&self, who: ConversationParty, res: GuardedResource)
|
||||
-> Result<ReadLease, GuardError>;
|
||||
async fn acquire_write(&self, who: ConversationParty, res: GuardedResource)
|
||||
-> Result<WriteLease, GuardError>;
|
||||
}
|
||||
```
|
||||
- `ReadLease`/`WriteLease` = gardes RAII (libèrent à la fin de portée). `GuardError`
|
||||
typé : `Busy` (attendre), `Forbidden` (un agent ≠ orchestrateur veut écrire
|
||||
`ProjectContext` ⇒ refus, doit *proposer*).
|
||||
- **Invariant clé** : toute lecture/écriture des ressources gardées **transite par ce
|
||||
port** ; l'accès fs brut à ces chemins est retiré aux agents (cf. §3 outils MCP).
|
||||
- **Consommé par** : `UpdateAgentContext`, `MemoryStore`-consumers, les nouveaux
|
||||
use cases `ReadContext`/`ProposeContext`/`ReadMemory`/`WriteMemory`.
|
||||
- **Implémenté par** : `RwFileGuard` (infra) — `HashMap<GuardedResource, RwLock-like>`
|
||||
(tokio `RwLock` ou sémaphore), + la règle mono-écrivain pour `ProjectContext`.
|
||||
|
||||
### `AgentMailbox` (existant — **conservé**, statut révisé)
|
||||
- Reste le **contrat de rendez-vous par ticket** (corrélation **par `TicketId`**, voir
|
||||
§3.3 — on **abandonne** la corrélation purement positionnelle « tête de file » dès
|
||||
qu'un agent peut avoir plusieurs fils). Devient un **détail d'implémentation** du
|
||||
`InputMediator` ; n'est plus injecté seul dans `OrchestratorService`.
|
||||
|
||||
### Ports inchangés réutilisés
|
||||
- `PtyPort` (écriture du tour dans le PTY = désormais le **seul** chemin d'écriture,
|
||||
piloté par le `InputMediator`, plus par `ask_agent` directement).
|
||||
- `ProfileStore` (porte le **motif de retour-de-prompt** par profil, §6).
|
||||
- `EventBus` (publie `AgentBusyChanged`, `AgentReplied`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Adapters (infra) + outils MCP
|
||||
|
||||
### 3.1 Adapters
|
||||
| Port | Adapter | Notes |
|
||||
|---|---|---|
|
||||
| `ConversationRegistry` | `InMemoryConversationRegistry` | `HashMap<ConversationId, Conversation>` + index paire→id ; mutex sync. |
|
||||
| `InputMediator` | `MediatedInbox` | compose `InMemoryMailbox` (existant) + verrous de tour + état busy ; publie `AgentBusyChanged`. |
|
||||
| `FileGuard` | `RwFileGuard` | `RwLock` par `GuardedResource` ; règle mono-écrivain `ProjectContext`. |
|
||||
| `AgentMailbox` | `InMemoryMailbox` | **inchangé** (réutilisé sous `MediatedInbox`). |
|
||||
|
||||
### 3.2 Nouveaux outils MCP (`infrastructure/src/orchestrator/mcp/tools.rs`)
|
||||
Ajouts **purement additifs** au `catalogue()` (Open/Closed — le dispatch reste intact) :
|
||||
|
||||
- **`idea_context_read { target? }`** → action wire `context.read` →
|
||||
`OrchestratorCommand::ReadContext { target }`. `target` absent = le contexte **global
|
||||
projet** ; sinon le `.md` d'un agent. Passe par `FileGuard::acquire_read`.
|
||||
- **`idea_context_propose { target?, content }`** → `context.propose` →
|
||||
`OrchestratorCommand::ProposeContext`. Pour un agent : écriture directe sous verrou
|
||||
écrivain. Pour le **global** : ce n'est **pas** une écriture, c'est une **proposition**
|
||||
(déposée pour validation par l'orchestrateur/UI ; `FileGuard` refuse l'écriture
|
||||
directe avec `Forbidden`).
|
||||
- **`idea_memory_read { slug? }`** → `memory.read` → `ReadMemory` (sous `FileGuard`).
|
||||
- **`idea_memory_write { slug, content }`** → `memory.write` → `WriteMemory` (verrou
|
||||
écrivain ; mémoire = partagée projet, cf. `shared-project-memory`).
|
||||
|
||||
Chaque outil suit le **patron existant** : `map_tool_call` construit un
|
||||
`OrchestratorRequest`, `validate()` reste l'**unique autorité** de validation, le
|
||||
`requester` du handshake porte l'identité (`ConversationParty::Agent`).
|
||||
|
||||
### 3.3 Corrélation `idea_reply` **par ticket** (D)
|
||||
- **Changement** : aujourd'hui `idea_reply` corrèle **positionnellement** (tête de la
|
||||
file de l'émetteur — `mailbox.resolve(from, result)`). Dès qu'un agent peut avoir
|
||||
**plusieurs fils**, la tête de « sa » file est ambiguë.
|
||||
- **Décision** : le préfixe injecté dans le PTY (`[IdeA · tâche de A · ticket <id>]`)
|
||||
porte **déjà** le `ticket_id`. On expose un champ **optionnel** `ticket` au schéma de
|
||||
`idea_reply` (`{ result, ticket? }`) ; quand présent, `resolve` corrèle **par
|
||||
`TicketId`** (déterministe, multi-fil) ; absent, on **retombe** sur la tête de file
|
||||
(compat agents simples, mono-fil). Le préfixe doit donc **demander à l'agent de
|
||||
renvoyer le `ticket`** (mise à jour de la description outil + protocole §B-5
|
||||
existant). `AgentMailbox::resolve` gagne une variante `resolve_ticket(agent,
|
||||
ticket_id, result)`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontière front : vue de sortie (xterm inchangé) / entrée médiée
|
||||
|
||||
### 4.1 État actuel à modifier
|
||||
`frontend/src/features/terminals/TerminalView.tsx` câble aujourd'hui **directement**
|
||||
les frappes au PTY :
|
||||
```ts
|
||||
const onKey = term.onData((data) => {
|
||||
if (handle) void handle.write(encoder.encode(data)); // ← chemin à couper
|
||||
});
|
||||
```
|
||||
C'est **exactement** le couplage que le Modèle B retire.
|
||||
|
||||
### 4.2 Décision frontend
|
||||
1. **xterm reste la vue de sortie brute, INCHANGÉE** : `onData (PTY) → term.write`
|
||||
conservé tel quel. **Interdiction** de ressusciter `AgentChatView` (déjà supprimé
|
||||
dans le diff courant — ne pas le réintroduire).
|
||||
2. **`term.onData` (frappes) n'écrit plus dans le PTY** pour une cellule **agent**.
|
||||
Deux modes :
|
||||
- **Cellule terminal simple (non-agent)** : comportement actuel conservé (écriture
|
||||
directe — pas de médiation, c'est un shell brut).
|
||||
- **Cellule agent** : les frappes vont dans un **champ de saisie géré par IdeA**
|
||||
(composant `MediatedInput`, rendu **sous** le terminal), pas dans le PTY. xterm
|
||||
passe en lecture seule pour l'entrée (sortie toujours live).
|
||||
3. **Nouveau port UI `InputGateway`** (`frontend/src/ports/index.ts`) :
|
||||
```ts
|
||||
interface InputGateway {
|
||||
submit(projectId: string, agentId: string, text: string): Promise<void>; // Envoyer = enqueue
|
||||
interrupt(projectId: string, agentId: string): Promise<void>; // Interrompre = preempt
|
||||
}
|
||||
```
|
||||
Adapter Tauri : `invoke("submit_agent_input", …)` / `invoke("interrupt_agent", …)`
|
||||
(nouvelles commands app-tauri → `SubmitHumanInput` / `preempt`). Mock pour tests.
|
||||
4. **Occupé/libre remonte par event** : un `DomainEvent::AgentBusyChanged { agent_id,
|
||||
busy }` relayé en event Tauri (pas un Channel haute-fréquence — événement discret).
|
||||
Le `MediatedInput` désactive « Envoyer » pendant `Busy` mais **autorise toujours
|
||||
l'enqueue** (le bouton enfile derrière ; jamais bloqué — fallback « forward »), et
|
||||
active « Interrompre ». Le front **ne parse jamais** la sortie pour deviner l'état.
|
||||
|
||||
### 4.3 Composants/state touchés
|
||||
- `features/terminals/TerminalView.tsx` : brancher le mode agent (entrée détournée).
|
||||
- `features/terminals/MediatedInput.tsx` (**nouveau**) : champ + boutons Envoyer/Interrompre.
|
||||
- `features/layout/LayoutGrid.tsx` : déjà route vers `TerminalView` ; ajoute le
|
||||
`MediatedInput` sous le terminal quand `agent != null`.
|
||||
- `ports/index.ts` + `adapters/agent.ts` (ou nouvel `adapters/input.ts`) + mock.
|
||||
- state : un store léger `agentBusy: Record<agentId, boolean>` alimenté par l'event.
|
||||
|
||||
---
|
||||
|
||||
## 5. Impact sur le code existant
|
||||
|
||||
### 5.1 Supprimé / retiré
|
||||
- **L'écriture PTY préfixée par `ask_agent`** (`service.rs` ~459 :
|
||||
`pty.write(&handle, "[IdeA · tâche …]\n")`) **n'est plus le chemin d'entrée**. La
|
||||
tâche déléguée entre désormais par `InputMediator::enqueue` (qui, dans son impl,
|
||||
écrira la ligne dans le PTY — mais **sérialisée derrière l'entrée humaine** du même
|
||||
agent, ce qui n'était pas le cas avant). → la logique d'écriture **déménage** de
|
||||
`ask_agent` vers l'impl `MediatedInbox`.
|
||||
- **Band-aid `\n`→`\r`** : abandonné (le « mode injection PTV » disparaît). Plus de
|
||||
réécriture de fin de ligne ad hoc.
|
||||
- **`AgentChatView`** (front) : déjà supprimé dans le diff courant — **rester** supprimé.
|
||||
|
||||
### 5.2 Modifié
|
||||
- **`OrchestratorService`** : ne reçoit plus `with_mailbox(mailbox, pty)` séparément
|
||||
mais `with_input_mediator(Arc<dyn InputMediator>)` + `with_conversations(Arc<dyn
|
||||
ConversationRegistry>)`. `ask_agent` devient : résoudre la **conversation A↔B**
|
||||
(paresseux), vérifier le **graphe wait-for** (refus si cycle), `enqueue` la tâche
|
||||
(source = `Agent`), `await PendingReply` borné. `reply` corrèle **par ticket** (§3.3).
|
||||
`ensure_live_pty` reste, mais branché sur `session_for(conversation)` au lieu de
|
||||
`session_for_agent`.
|
||||
- **`session_for_agent`** (registre `terminal/registry.rs`) : devient
|
||||
`session_for(conversation_id)` ; `sessions_for_agent` (pluriel) ajouté. Lève
|
||||
l'ambiguïté `session-registry-agent-ambiguity` **par construction** (la clé est la
|
||||
conversation, pas l'agent).
|
||||
- **`bind_endpoint`** (`state.rs`) : **déjà** `reclaim_name(true)` ⇒ unlink du cadavre.
|
||||
**Action = verrouiller par un test** (ouvrir/fermer/SIGKILL simulé/rebind sans
|
||||
`EADDRINUSE`). Pas de changement de code attendu, sauf si le test révèle un trou.
|
||||
- **`idea_reply`** (tools.rs / orchestrator.rs / server.rs) : champ `ticket?` ajouté,
|
||||
`Reply { from, ticket: Option<TicketId>, result }`, `map_tool_call` le propage.
|
||||
- **`Ticket`** (`mailbox.rs`) : champs `source: InputSource`, `conversation:
|
||||
ConversationId` ajoutés (constructeurs additifs).
|
||||
|
||||
### 5.3 Ajouté
|
||||
- Domaine : `conversation.rs` (`ConversationId`, `Conversation`, `ConversationParty`,
|
||||
`ConversationSession`, `WaitForGraph`), `input.rs` (`InputMediator`, `InputSource`,
|
||||
`AgentBusyState`), `fileguard.rs` (`FileGuard`, `GuardedResource`, leases).
|
||||
- Application : use cases `SubmitHumanInput`, `ReadContext`/`ProposeContext`,
|
||||
`ReadMemory`/`WriteMemory` ; détection de cycle câblée dans `ask_agent`.
|
||||
- Infra : `InMemoryConversationRegistry`, `MediatedInbox`, `RwFileGuard` ; outils MCP
|
||||
`idea_context_*` / `idea_memory_*`.
|
||||
- app-tauri : commands `submit_agent_input`, `interrupt_agent` ; relais event
|
||||
`AgentBusyChanged` ; câblage des nouveaux ports au composition root (`state.rs`).
|
||||
- Front : `MediatedInput`, `InputGateway` + adapter + mock + store busy.
|
||||
|
||||
---
|
||||
|
||||
## 6. Détection occupé/libre
|
||||
|
||||
**Mécanisme retenu = double signal, OR, avec fallback sûr.**
|
||||
|
||||
| Signal | Source | Fiabilité |
|
||||
|---|---|---|
|
||||
| **Retour-de-prompt** | motif (regex/literal) déclaré dans le **profil CLI** (`AgentProfile`, nouveau champ `prompt_ready_pattern: Option<String>`), détecté sur le flux PTY par l'impl `MediatedInbox` | bon pour un shell/CLI au prompt stable ; faillible (motif dans la sortie) |
|
||||
| **Signal explicite** | l'agent appelle `idea_reply` (fin d'une délégation) **ou** un signal de fin-de-tour MCP | déterministe quand l'agent coopère |
|
||||
|
||||
- Transition `Busy → Idle` = **premier** des deux signaux qui arrive.
|
||||
- **Fallback « en cas de doute → forwarder »** : si **aucun** signal n'est sûr (motif
|
||||
absent du profil, agent muet), l'agent **reste marqué `Busy`** mais la file
|
||||
**continue d'accepter** les `enqueue` ; un message entrant **n'est jamais rejeté**,
|
||||
il patiente dans la FIFO. On ne « piège » donc jamais un message ; au pire il attend.
|
||||
- **Garde-fou anti-blocage** : le timeout par tour (`ASK_AGENT_TIMEOUT`, existant)
|
||||
retire le ticket de tête et **relâche** le tour même si aucun signal n'est venu ⇒
|
||||
la file avance. L'agent reste vivant.
|
||||
- Le motif vit **dans le profil** (donnée, pas code) ⇒ ajouter une CLI = éditer un
|
||||
profil (Open/Closed, cohérent §9 CLAUDE.md).
|
||||
|
||||
---
|
||||
|
||||
## 7. Découpage en lots livrables (ordonnés par dépendance)
|
||||
|
||||
> Chaque lot = binôme dev/test. **B = DevBackend (Rust)**, **F = DevFrontend (TS/React)**.
|
||||
> Chemin critique : C1 → C2 → C3 → C4. FileGuard (C6) et front (F1/F2) parallélisables.
|
||||
|
||||
### Bloc Conversation (cœur — backend)
|
||||
| Lot | Côté | Périmètre | Tests |
|
||||
|---|---|---|---|
|
||||
| **C1** | B (domaine) | `conversation.rs` : `ConversationId`, `ConversationParty`, `Conversation`, `ConversationSession`, `WaitForGraph::would_cycle`. `input.rs` : `InputSource`, `AgentBusyState`. Extension `Ticket` (source+conversation, ctors additifs). | invariants paire (left≠right, ≤1 User) ; identité = paire non ordonnée ; `would_cycle` (A→B→A refusé, A→B→C ok) ; ticket porte source+conversation. Pur, sans I/O. |
|
||||
| **C2** | B (domaine+infra) | Ports `ConversationRegistry` + `InputMediator` (domaine) ; adapters `InMemoryConversationRegistry` + `MediatedInbox` (compose `InMemoryMailbox` existant). | resolve paresseux (même paire ⇒ même id) ; enqueue→PendingReply ; preempt distinct d'enqueue ; busy_state transitions ; 2 enqueue même agent sérialisés ; agents ≠ parallèles. |
|
||||
| **C3** | B (application) | `OrchestratorService` : `with_input_mediator`+`with_conversations` ; `ask_agent` réécrit (résout conversation A↔B, garde wait-for, enqueue source=Agent, await) ; `reply` par ticket. `session_for(conversation)`. Retrait écriture PTY directe + band-aid `\r`. | ask A→B route dans la bonne conversation (pas User↔B) ; cycle A→B→A ⇒ erreur typée avant deadlock ; reply corrèle par ticket (multi-fil) ; reply sans ticket = fallback tête ; timeout libère file, cible vivante. |
|
||||
| **C4** | B (application+app-tauri) | Use case `SubmitHumanInput` (source=Human) + commands `submit_agent_input`/`interrupt_agent` ; event `AgentBusyChanged` relayé. Câblage composition root (`state.rs`). | submit humain enfile dans la **même** FIFO que les délégations ; interrupt = preempt (pas enqueue) ; busy event émis aux bons moments ; câblage : un ask et un submit concurrents sur A sérialisent. |
|
||||
|
||||
### Bloc détection occupé/libre (backend)
|
||||
| Lot | Côté | Périmètre | Tests |
|
||||
|---|---|---|---|
|
||||
| **C5** | B (domaine+infra) | Champ profil `prompt_ready_pattern` ; détection retour-de-prompt dans `MediatedInbox` ; OR avec signal explicite ; fallback « reste Busy mais accepte ». | motif détecté ⇒ Idle ; idea_reply ⇒ Idle ; ni l'un ni l'autre ⇒ Busy mais enqueue accepté ; timeout ⇒ file avance. |
|
||||
|
||||
### Bloc FileGuard (backend — parallélisable après C1)
|
||||
| Lot | Côté | Périmètre | Tests |
|
||||
|---|---|---|---|
|
||||
| **C6** | B (domaine+infra) | `fileguard.rs` (port + `GuardedResource` + leases) ; `RwFileGuard` ; règle mono-écrivain `ProjectContext`. | N lecteurs concurrents OK ; 1 écrivain exclusif ; agent≠orchestrateur écrit ProjectContext ⇒ `Forbidden` ; lease RAII libère. |
|
||||
| **C7** | B (application+infra MCP) | Use cases `ReadContext`/`ProposeContext`/`ReadMemory`/`WriteMemory` sous FileGuard ; outils MCP `idea_context_*`/`idea_memory_*` ; retrait accès fs brut de ces chemins. | map_tool_call → command ; validate exige `content` ; propose global ≠ write direct ; lecture concurrente non bloquante ; écriture sérialisée. |
|
||||
|
||||
### Bloc frontend
|
||||
| Lot | Côté | Périmètre | Tests (Vitest/RTL, gateways mock) |
|
||||
|---|---|---|---|
|
||||
| **F1** | F | `InputGateway` (port+adapter+mock) ; `MediatedInput` (Envoyer=submit / Interrompre=interrupt) ; store busy alimenté par event. | submit appelle gateway.submit ; interrupt appelle interrupt ; busy event désactive Envoyer (mais enqueue possible), active Interrompre. |
|
||||
| **F2** | F | `TerminalView` mode agent : frappes → `MediatedInput` (plus le PTY) ; xterm reste sortie live INCHANGÉE pour le non-agent. `LayoutGrid` monte `MediatedInput` sous le terminal si `agent != null`. | cellule agent ⇒ onData ne write pas le PTY ; cellule simple ⇒ comportement actuel ; sortie PTY toujours peinte ; jamais d'AgentChatView. |
|
||||
|
||||
### Bloc durcissement
|
||||
| Lot | Côté | Périmètre | Tests |
|
||||
|---|---|---|---|
|
||||
| **D1** | B (app-tauri) | Test de non-régression `bind_endpoint` : bind → drop (SIGKILL simulé : laisser le fichier socket) → rebind **sans** `EADDRINUSE`. Verrouille `reclaim_name(true)`. | rebind après cadavre OK ; idempotent ; pas de fuite de fichier après close. |
|
||||
|
||||
**Ordre recommandé** : **C1 → C2 → C3 → C4** (cœur), **C5** après C2, **C6 → C7**
|
||||
en parallèle (après C1), **F1 → F2** dès que les commands C4 existent (mock avant),
|
||||
**D1** isolé n'importe quand.
|
||||
|
||||
---
|
||||
|
||||
## 8. Stratégie de tests par couche
|
||||
|
||||
| Couche | Type | Comment |
|
||||
|---|---|---|
|
||||
| **domaine** (`conversation`, `input`, `fileguard`, `mailbox` étendu) | unitaires **purs**, sans I/O ni async là où possible | invariants de paire, `would_cycle`, transitions `AgentBusyState`, ctors `Ticket`. Déterministe. C'est là que vit la garantie « solide par construction ». |
|
||||
| **application** (`OrchestratorService`, `SubmitHumanInput`, use cases FileGuard) | unitaires avec **ports mockés** (fakes manuels, façon `service.rs` actuel) | ask route la bonne conversation ; cycle refusé ; reply par ticket ; submit+ask sérialisés ; FileGuard mono-écrivain. **Aucun vrai PTY/fs/MCP.** |
|
||||
| **infra** (`MediatedInbox`, `RwFileGuard`, `InMemoryConversationRegistry`, outils MCP) | intégration **ciblée** | FIFO réelle + `oneshot` ; RwLock concurrence ; `map_tool_call` round-trip ; `bind_endpoint` (D1). Réutilise les tests `InMemoryMailbox` existants. |
|
||||
| **app-tauri** | commands ↔ use cases | `submit_agent_input`/`interrupt_agent` mappent bien ; event `AgentBusyChanged` émis ; câblage composition root cohérent (endpoint partagé). |
|
||||
| **frontend** (`MediatedInput`, `TerminalView`) | Vitest + RTL, **gateways mock** | entrée détournée hors PTY ; busy désactive Envoyer sans bloquer enqueue ; xterm sortie inchangée ; **sans backend**. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Risques / points ouverts
|
||||
|
||||
1. **Fiabilité de la détection retour-de-prompt** (C5) — le plus dur. Un motif dans la
|
||||
sortie d'un agent peut **faussement** signaler Idle (libère trop tôt) ou ne jamais
|
||||
matcher (reste Busy). *Mitigation* : OR avec le signal explicite `idea_reply` +
|
||||
fallback « reste Busy mais accepte » + timeout par tour. *Reste ouvert* : faut-il un
|
||||
« heartbeat » MCP de fin-de-tour côté CLI ? (hors périmètre immédiat, Claude-only).
|
||||
|
||||
2. **Suspension/reprise de session par conversation** (`resumable_id`) — un agent à N
|
||||
fils doit reprendre **le bon** session-id par fil au redémarrage. Dépend du
|
||||
`session{assignFlag,resumeFlag}` du profil (cf. `conversation-resume-architecture`).
|
||||
*Ouvert* : capacité réelle des CLI à tenir N conversations resumables simultanées
|
||||
pour un même process « 1 agent = 1 employé » — possible conflit entre « N fils » et
|
||||
« 1 process ». **Décision de cadrage** : **1 process/agent**, les fils **partagent
|
||||
la file d'entrée** (sérialisés) ; le `resumable_id` par conversation sert surtout à
|
||||
la **reprise au redémarrage**, pas à du vrai parallélisme intra-process.
|
||||
|
||||
3. **Deadlock & détection de cycle** (`WaitForGraph`) — couvre A→B→A directs et
|
||||
transitifs, mais le graphe doit être **alimenté en temps réel** (arête posée à
|
||||
l'enqueue, retirée au reply/timeout). *Risque* : arête fantôme si un reply se perd
|
||||
⇒ faux positif de cycle. *Mitigation* : retrait d'arête garanti par le RAII du tour
|
||||
(comme `_turn` aujourd'hui) + timeout.
|
||||
|
||||
4. **Corrélation par ticket vs agents « simples »** — un agent qui ne renvoie pas le
|
||||
`ticket` dans `idea_reply` retombe sur la corrélation positionnelle (tête de file),
|
||||
ambiguë en multi-fil. *Mitigation* : protocole §B-5 (description outil) **insiste**
|
||||
sur le renvoi du ticket ; mono-fil reste correct sans. *Ouvert* : forcer le ticket
|
||||
requis casserait des agents simples — on garde optionnel.
|
||||
|
||||
5. **Périmètre FileGuard contournable** — tant que l'agent garde un shell brut (PTY),
|
||||
il peut écrire les `.md`/mémoire **par le filesystem** malgré le verrou MCP. Le
|
||||
verrou n'est étanche que si l'accès fs à ces chemins est **réellement** retiré
|
||||
(sandbox, cf. `agent-permissions-architecture` / Landlock). *Ouvert* : sans sandbox
|
||||
OS, le `FileGuard` est **coopératif** (protège des collisions IdeA↔IdeA, pas d'un
|
||||
agent qui contourne). À acter : FileGuard = correction des collisions **dans le
|
||||
chemin IdeA** d'abord ; étanchéité réelle = lot sandbox ultérieur.
|
||||
|
||||
6. **Migration `AgentMailbox` → `InputMediator`** — risque de double-file transitoire.
|
||||
*Mitigation* : `MediatedInbox` **enveloppe** `InMemoryMailbox` (pas de réécriture),
|
||||
`OrchestratorService` bascule d'un `with_mailbox` vers `with_input_mediator` en un
|
||||
lot (C2→C3), tests existants `InMemoryMailbox` conservés verts.
|
||||
|
||||
---
|
||||
|
||||
*Document maintenu par l'Agent Architecture — cadrage « conversation par paire »,
|
||||
base des lots C1→C7 / F1→F2 / D1 avant tout code.*
|
||||
88
.ideai/briefs/d4-commandes-bridge-chat.md
Normal file
88
.ideai/briefs/d4-commandes-bridge-chat.md
Normal file
@ -0,0 +1,88 @@
|
||||
# Brief Dev — Lot D4 : commandes Tauri + bridge chat (§17.9)
|
||||
|
||||
> Demandé par **Main** à **DevBackend** (dev) + **QA** (test). Cycle §3 : code → tests → vert.
|
||||
> Périmètre **backend uniquement** (`app-tauri`). Le frontend chat est D5, hors périmètre ici.
|
||||
|
||||
## 0. Où on en est
|
||||
|
||||
Le fil §17 (exécution structurée des agents IA via le port `AgentSession`) est livré
|
||||
jusqu'à **D3 inclus** :
|
||||
|
||||
- **D0/D1** (`5e10b5e`) : port `domain::ports::AgentSession` + `AgentSessionFactory`,
|
||||
`ReplyEvent`/`ReplyStream`/`AgentSessionError`, champ `AgentProfile.structured_adapter`,
|
||||
registre `StructuredSessions`, agrégateur `LiveSessions`, helper `send_blocking`.
|
||||
- **D2** (`751d94d`) + spikes **S1/S2** (`f104862`) : adapters `ClaudeSdkSession` /
|
||||
`CodexExecSession` dans `crates/infrastructure/src/session/`, fake CLI + harnais de
|
||||
conformité, **formats réels** Claude `stream-json` / Codex `exec --json` câblés.
|
||||
- **D3** (`56913b9`) : `LaunchAgent` route structuré vs PTY ; `LaunchAgentOutput` porte
|
||||
désormais `structured: Option<StructuredSessionDescriptor>`.
|
||||
|
||||
**D4 = exposer tout ça à l'UI** : commandes Tauri + pont de streaming, jumeau exact du
|
||||
chemin PTY existant. Aucun chemin PTY (terminal non-IA) ne doit changer ni régresser.
|
||||
|
||||
## 1. Périmètre D4 (réf. §17.7 et tableau §17.9)
|
||||
|
||||
À livrer dans `crates/app-tauri/src/` :
|
||||
|
||||
1. **`ChatBridge`** — jumeau de `PtyBridge` (`crates/app-tauri/src/pty.rs`), **generation-tracked**
|
||||
(même mécanique de génération pour éviter la double-pompe lors d'une ré-attache). Il pompe
|
||||
un `ReplyStream` (events `ReplyEvent` du port) vers un `tauri::ipc::Channel`, en émettant
|
||||
des `ReplyChunk` (DTO ci-dessous). Vit à côté de `PtyBridge`, ne le remplace pas.
|
||||
2. **Commandes Tauri** :
|
||||
- `agent_send(sessionId, prompt)` → pompe les `ReplyEvent` du tour sur le `Channel`
|
||||
(deltas `TextDelta` → chunks, `ToolActivity` → chunks d'activité, `Final` → chunk final
|
||||
qui fige le tour). S'appuie sur le registre `StructuredSessions` / `send_blocking` côté
|
||||
application (déjà livré en D1).
|
||||
- `reattach_agent_chat(...)` → renvoie le scrollback de conversation + rebranche le `Channel`
|
||||
(repeint sans re-spawn ; supersede l'ancienne génération).
|
||||
- `close_agent_session(sessionId)` → `shutdown` (polymorphe) + unregister du registre.
|
||||
3. **DTO** (`crates/app-tauri/src/dto.rs`) :
|
||||
- `ReplyChunk` : variantes delta texte / activité outil / final (sérialisation camelCase,
|
||||
cohérente avec les DTO existants).
|
||||
- `ReattachChatDto`.
|
||||
- **`cellKind`** ajouté au DTO de session (terminal `pty` vs chat `chat`), dérivé de la
|
||||
présence d'un descripteur structuré (`LaunchAgentOutput.structured`).
|
||||
4. **Wiring composition root** (`state.rs`/`lib.rs`) : injecter les dépendances nécessaires,
|
||||
enregistrer les nouvelles commandes. **Aucun `new ClaudeSdkSession` ici** — passe par la
|
||||
factory déjà injectée (règle D du §17.8).
|
||||
|
||||
## 2. Contrat / invariants à respecter
|
||||
|
||||
- **Generation supersede** : une ré-attache invalide l'ancienne pompe ; pas de double émission.
|
||||
- **`cellKind`** est la seule info dont D5 (frontend) a besoin pour router cellule chat vs
|
||||
terminal. Stable et explicite au DTO.
|
||||
- **Isolation parsing** : D4 ne parse aucun format CLI — il consomme des `ReplyEvent` typés.
|
||||
- **Zéro régression PTY** : le chemin terminal brut (`PtyBridge`, commandes terminal) reste
|
||||
identique. Les tests PTY existants restent verts.
|
||||
- Frontières hexagonales (§17.8) : `app-tauri` dépend des ports/registres application, jamais
|
||||
des adapters infra concrets.
|
||||
|
||||
## 3. Tests attendus (QA — réf. colonne « Tests attendus » D4 du §17.9)
|
||||
|
||||
Crate `app-tauri` (fakes pour la session structurée, pas de vrai CLI) :
|
||||
|
||||
- `agent_send` pompe les events d'un tour sur le `Channel` (séquence deltas… puis `Final`).
|
||||
- ré-attache → scrollback conversation repeint, **sans** re-spawn de session.
|
||||
- `close_agent_session` → `shutdown` appelé **et** unregister du registre.
|
||||
- **generation supersede** : après ré-attache, l'ancienne génération ne pompe plus (pas de
|
||||
double émission sur le `Channel`).
|
||||
- DTO : `cellKind` = `chat` pour une sortie `LaunchAgentOutput` avec `structured: Some(..)`,
|
||||
`pty` sinon ; round-trip `ReplyChunk` (camelCase).
|
||||
- non-régression : un DTO de session PTY existant sérialise toujours pareil (le nouveau champ
|
||||
`cellKind` ne casse pas les snapshots — vérifier la valeur par défaut/dérivée).
|
||||
|
||||
## 4. Méthode
|
||||
|
||||
Cycle §3 strict : DevBackend code → QA écrit + exécute les tests → vert avant de clore.
|
||||
Rapport d'erreurs clair si rouge → correction → re-test. Commit `feat(agent): … (D4) — §17`
|
||||
quand `cargo test --workspace` est vert. **Ne pas push** (validation Main requise).
|
||||
|
||||
## 5. Références code
|
||||
|
||||
- Jumeau à copier : `crates/app-tauri/src/pty.rs` (`PtyBridge`, generation tracking).
|
||||
- Source du flux : `domain::ports::{AgentSession, ReplyEvent, ReplyStream}` ;
|
||||
registre/`send_blocking` : `crates/application/src/agent/structured.rs`.
|
||||
- Routage déjà fait : `crates/application/src/agent/lifecycle.rs`
|
||||
(`LaunchAgentOutput.structured`).
|
||||
- DTO existants : `crates/app-tauri/src/dto.rs` ; commandes : `crates/app-tauri/src/commands.rs`.
|
||||
- Spec complète : `ARCHITECTURE.md` §17.7 (commandes & DTO) et tableau §17.9 ligne **D4**.
|
||||
99
.ideai/briefs/d5-frontend-chat.md
Normal file
99
.ideai/briefs/d5-frontend-chat.md
Normal file
@ -0,0 +1,99 @@
|
||||
# Brief Dev — Lot D5 : frontend chat (§17.9)
|
||||
|
||||
> Demandé par **Main** à **DevFrontend** (dev) + **QA** (test). Cycle §3 : code → tests → vert.
|
||||
> Périmètre **frontend uniquement** (`frontend/src`). Le backend D4 est livré et committé (`f4d5727`).
|
||||
|
||||
## 0. Où on en est
|
||||
|
||||
Le fil §17 (exécution structurée des agents IA) est livré jusqu'à **D4 inclus** côté backend :
|
||||
les commandes Tauri `agent_send` / `reattach_agent_chat` / `close_agent_session` existent et
|
||||
streament des `ReplyChunk` sur un `Channel`. D5 = **la vue chat React** qui consomme ça, plus
|
||||
le **routage par type de cellule** dans le layout.
|
||||
|
||||
### Contrats backend exacts à mirrorer (déjà livrés)
|
||||
Commandes Tauri (`crates/app-tauri/src/commands.rs`) :
|
||||
- `agent_send(sessionId: string, prompt: string, onReply: Channel<ReplyChunk>) -> void`
|
||||
- `reattach_agent_chat(sessionId: string, onReply: Channel<ReplyChunk>) -> ReattachChatDto`
|
||||
- `close_agent_session(sessionId: string) -> void`
|
||||
|
||||
DTO (`crates/app-tauri/src/dto.rs`) — **sérialisation camelCase tagué `kind`** :
|
||||
```ts
|
||||
type ReplyChunk =
|
||||
| { kind: "textDelta"; text: string }
|
||||
| { kind: "toolActivity"; label: string }
|
||||
| { kind: "final"; content: string };
|
||||
|
||||
// ReattachChatDto : le scrollback de conversation (chunks déjà streamés)
|
||||
interface ReattachChatDto { sessionId: string; scrollback: ReplyChunk[]; }
|
||||
|
||||
// Et surtout : le DTO de session porte désormais cellKind
|
||||
type CellKind = "pty" | "chat"; // toujours présent sur TerminalSessionDto
|
||||
```
|
||||
> `cellKind` vaut `"chat"` quand l'agent est piloté en mode structuré (profil Claude/Codex),
|
||||
> `"pty"` pour un terminal brut. C'est **la seule info dont le frontend a besoin** pour router
|
||||
> cellule chat vs terminal.
|
||||
|
||||
## 1. Périmètre D5 (réf. tableau §17.9 ligne D5)
|
||||
|
||||
1. **`AgentChatView`** (nouveau, `frontend/src/features/chat/`) : vue de conversation IdeA.
|
||||
- Affiche les **deltas live** (accumulation `textDelta` → texte du tour en cours), l'**activité
|
||||
d'outil** (`toolActivity`), et **fige le tour** sur `final`.
|
||||
- Zone de **saisie** d'un prompt → appelle `AgentGateway.sendPrompt`.
|
||||
- **Scrollback de conversation** : à l'attache, repeint l'historique renvoyé par
|
||||
`reattachChat` ; survit à un changement d'onglet/layout (ré-attache, pas re-spawn).
|
||||
- C'est le **jumeau chat** de `TerminalView` (`frontend/src/features/terminals/TerminalView.tsx`,
|
||||
283 l.) — inspire-toi de sa gestion de cycle de vie (mount/attach/detach), mais pour un
|
||||
flux de messages structuré au lieu d'octets xterm.
|
||||
2. **Routage par `cellKind` dans `LayoutGrid`** (`frontend/src/features/layout/LayoutGrid.tsx`,
|
||||
fonction `LeafView` ~l.159) : une cellule rend `AgentChatView` si `cellKind === "chat"`,
|
||||
sinon `TerminalView` (comportement actuel inchangé). Le `cellKind` arrive sur le handle/session
|
||||
au lancement (sortie de `launchAgent`) — propage-le jusqu'au leaf.
|
||||
3. **Port `AgentGateway`** (`frontend/src/ports/index.ts`, interface ~l.76) : ajoute
|
||||
- `sendPrompt(sessionId: string, prompt: string, onReply: (c: ReplyChunk) => void): Promise<void>`
|
||||
- `reattachChat(sessionId: string, onReply: (c: ReplyChunk) => void): Promise<ReplyChunk[]>`
|
||||
- `closeAgentSession(sessionId: string): Promise<void>`
|
||||
(signatures à aligner avec le style des méthodes existantes `launchAgent`/`reattach`).
|
||||
4. **Adapter Tauri** (`frontend/src/adapters/agent.ts`, classe `TauriAgentGateway`) : implémente
|
||||
les 3 méthodes via `invoke(...)` + `new Channel<ReplyChunk>()` (modèle déjà présent pour
|
||||
`launchAgent`/`reattach` qui utilisent `Channel<number[]>`).
|
||||
5. **Mock gateway** (`frontend/src/adapters/mock/index.ts`) : streame des `ReplyChunk` scriptés
|
||||
(quelques `textDelta` puis un `final`) pour les tests et le dev hors-Tauri.
|
||||
6. **Types TS** : ajoute `ReplyChunk`, `CellKind`, `ReattachChatDto` aux types partagés (là où
|
||||
vivent les autres mirrors de DTO), et le champ `cellKind` sur le type de session/handle.
|
||||
|
||||
## 2. Invariants à respecter
|
||||
|
||||
- **Zéro régression terminal** : `TerminalView` et le chemin PTY de `LayoutGrid` restent
|
||||
identiques ; une cellule `pty` se comporte exactement comme avant.
|
||||
- **Ré-attache ≠ re-spawn** : changer d'onglet puis revenir repeint la conversation depuis le
|
||||
scrollback renvoyé par `reattachChat`, sans relancer de tour (miroir du PTY reattach).
|
||||
- **Accumulation correcte** : les `textDelta` s'accumulent dans le tour courant ; `final` clôt
|
||||
le tour (le texte final fait foi). Pas de doublon delta/final affiché deux fois.
|
||||
- Frontières : la vue dépend du **port** `AgentGateway`, jamais directement de `invoke`/Tauri
|
||||
(c'est l'adapter qui parle à Tauri). Hexagonal côté front respecté.
|
||||
|
||||
## 3. Tests attendus (QA — Vitest, réf. colonne « Tests attendus » D5 du §17.9)
|
||||
|
||||
Aligne-toi sur le style des `*.test.tsx` existants (`LayoutGrid.test.tsx`, `TerminalView.test.tsx`,
|
||||
`adapters/agent.test.ts`), avec le **mock gateway** :
|
||||
- cellule `cellKind:"chat"` rend `AgentChatView` ; `cellKind:"pty"` rend `TerminalView`.
|
||||
- les `textDelta` s'accumulent à l'écran → `final` fige le tour.
|
||||
- ré-attache repeint le scrollback **sans** re-spawn (le mock ne reçoit pas de nouveau `sendPrompt`).
|
||||
- envoi d'un prompt → `AgentGateway.sendPrompt` appelé avec les bons args.
|
||||
- `toolActivity` affiché comme activité d'outil.
|
||||
- mock gateway streame bien une séquence `ReplyChunk` (deltas… puis final).
|
||||
|
||||
## 4. Méthode
|
||||
|
||||
Cycle §3 strict : DevFrontend code → QA écrit + exécute les tests Vitest → vert avant de clore.
|
||||
Vérifie `npm run build` (tsc --noEmit + vite) **et** `npx vitest run` verts. **Ne pas committer,
|
||||
ne pas push** — Main relit et commit. Rapport d'erreurs clair si rouge → correction → re-test.
|
||||
|
||||
## 5. Références
|
||||
|
||||
- Jumeau à copier : `frontend/src/features/terminals/TerminalView.tsx` (cycle de vie attach/detach).
|
||||
- Routage cellule : `frontend/src/features/layout/LayoutGrid.tsx` (`LeafView`).
|
||||
- Port : `frontend/src/ports/index.ts` (`AgentGateway`) ; adapter : `frontend/src/adapters/agent.ts` ;
|
||||
mock : `frontend/src/adapters/mock/index.ts`.
|
||||
- Backend déjà livré : commit `f4d5727`, `crates/app-tauri/src/{chat,commands,dto}.rs`.
|
||||
- Spec : `ARCHITECTURE.md` §17.6 (deux types de cellules) et tableau §17.9 ligne **D5**.
|
||||
104
.ideai/briefs/d6-messagerie-inter-agents.md
Normal file
104
.ideai/briefs/d6-messagerie-inter-agents.md
Normal file
@ -0,0 +1,104 @@
|
||||
# Brief Dev — Lot D6 : messagerie inter-agents via `send_blocking` (§17.9)
|
||||
|
||||
> Demandé par **Main** à **DevBackend** (dev) + **QA** (test). Cycle §3 : code → tests → vert.
|
||||
> Périmètre **backend** (domaine + application). Pas de frontend.
|
||||
|
||||
## 0. Le trou que D6 comble (important)
|
||||
|
||||
Aujourd'hui, « Main demande à Architect » ne **retourne jamais le contenu** de la réponse :
|
||||
- `OrchestratorCommand` n'a **pas** de variante « demander/attendre une réponse ». Le wire
|
||||
`agent.run` replie `task` dans `context`, et `context` n'est utilisé que pour un agent **neuf**
|
||||
(`.md` initial) ⇒ pour un agent **déjà existant**, le `task` est **silencieusement ignoré**.
|
||||
- La réponse (`*.response.json`) ne porte qu'un **ACK de cycle de vie**
|
||||
(`detail: "launched agent X"`), jamais la sortie produite par la cible.
|
||||
|
||||
D6 = brancher la **messagerie synchrone** sur le port `AgentSession` : la cible est pilotée en
|
||||
mode structuré, on **attend son tour** et on **renvoie son contenu**. La primitive existe déjà :
|
||||
`crate::agent::structured::send_blocking(session, prompt, timeout) -> Result<String, AgentSessionError>`
|
||||
(livrée en D1 ; retourne le contenu du `Final`, `Timeout` typé **sans tuer la session**).
|
||||
|
||||
## 1. Périmètre D6 (réf. tableau §17.9 ligne D6)
|
||||
|
||||
### A. Domaine (`crates/domain/src/`)
|
||||
1. **`OrchestratorCommand::AskAgent { target: String, task: String }`** (`orchestrator.rs`,
|
||||
enum ~l.111). Nouvelle variante « j'attends une réponse ».
|
||||
2. **Validation** : nouvelle action wire **`agent.message`** → `AskAgent` dans
|
||||
`OrchestratorRequest::validate` (~l.175). `targetAgent` (ou `name`) **et** `task` requis
|
||||
non-vides (sinon `OrchestratorError::MissingField`). Garde le mapping `agent.run` actuel
|
||||
inchangé (lancement fire-and-forget). Ajoute un cas de test de round-trip JSON `agent.message`.
|
||||
3. **`DomainEvent::AgentReplied { ... }`** (`events.rs`, à côté de `AgentLaunched`) pour
|
||||
l'observabilité : au minimum le nom/id de l'agent cible et, si pertinent, la taille/preview
|
||||
de la réponse (reste pur, pas de payload lourd imposé — calque le style des variantes voisines).
|
||||
|
||||
### B. Application (`crates/application/src/orchestrator/service.rs`)
|
||||
4. **`OrchestratorOutcome` gagne le contenu** : ajoute `reply: Option<String>` (l'ACK actuel
|
||||
`detail` reste). Les commandes existantes mettent `reply: None` ; `AskAgent` met
|
||||
`reply: Some(contenu)`. (Champ additif ⇒ aucune régression des call sites/tests existants.)
|
||||
5. **`dispatch` route `AskAgent`** :
|
||||
- résous l'agent cible par nom (`find_agent_id_by_name`) → sinon `AppError::NotFound` typé.
|
||||
- cherche sa **session structurée vivante** dans le registre **`StructuredSessions`** (PAS
|
||||
`TerminalSessions`). Si vivante ⇒ `send_blocking(session, &task, timeout)`.
|
||||
- si **pas vivante** ⇒ lance l'agent en mode structuré (via `LaunchAgent`, background) puis
|
||||
`send_blocking`. Respecte l'invariant **1 session/agent**.
|
||||
- si la cible est **PTY-only** (profil sans `structured_adapter` ⇒ pas adressable en `ask`) ⇒
|
||||
**erreur typée explicite** (`AppError::Invalid`/`NotFound` avec message clair « agent X n'est
|
||||
pas pilotable en mode structuré »). C'est acceptable (le menu ne crée plus que des agents
|
||||
structurés, cf. D7 à venir).
|
||||
- **timeout** ⇒ remonte une erreur typée, **sans tuer la session** (déjà la sémantique de
|
||||
`send_blocking`).
|
||||
- en cas de succès ⇒ **publie `DomainEvent::AgentReplied`** sur l'`EventBus`, et retourne
|
||||
`OrchestratorOutcome { detail, reply: Some(content) }`.
|
||||
6. **Injection** : le service a besoin du registre `StructuredSessions` + de l'`EventBus` (et de
|
||||
quoi piloter `send_blocking`). **Ajoute-les par builder additif** (`with_structured(...)` /
|
||||
`with_events(...)` façon D3 sur `LaunchAgent`) pour que `OrchestratorService::new` reste
|
||||
compatible et que les tests/call sites legacy restent verts. Câble au composition root
|
||||
(`crates/app-tauri/src/state.rs`).
|
||||
|
||||
### C. Nettoyage voie principale
|
||||
7. **Aucun accès outbox/inbox** dans le chemin `AskAgent` (le rendez-vous est intrinsèque à
|
||||
`send_blocking`). Les seules occurrences `outbox/inbox` actuelles sont des commentaires de doc
|
||||
dans `agent/structured.rs` — ne ré-introduis rien. Vérifie qu'aucun `AgentReplyChannel`/outbox
|
||||
n'est utilisé.
|
||||
|
||||
## 2. Invariants à respecter
|
||||
|
||||
- **1 session vivante par agent** across registres (PTY + structuré).
|
||||
- **Timeout ne tue jamais la session** (retry possible).
|
||||
- **Cible PTY non adressable** par `ask` ⇒ erreur typée, jamais un ACK trompeur ni un panic.
|
||||
- Frontières hexagonales : le domaine reste pur (pas d'I/O dans `orchestrator.rs`/`events.rs`) ;
|
||||
l'orchestration vit dans l'application ; aucun `new` d'adapter infra dans le service.
|
||||
- **Zéro régression** : `agent.run`/`spawn_agent`/`stop_agent`/`update_agent_context`/`skill.create`
|
||||
inchangés ; le watcher d'orchestration et ses tests restent verts.
|
||||
|
||||
## 3. Tests attendus (QA — colonne « Tests attendus » D6 du §17.9)
|
||||
|
||||
Unitaires avec **fakes** (fake `AgentSession`, fake registres, fake EventBus — aucun vrai CLI) :
|
||||
- cible **vivante** (session structurée enregistrée) ⇒ `send_blocking` appelé, `reply: Some(...)`
|
||||
porte le contenu du `Final`.
|
||||
- cible **morte** ⇒ `LaunchAgent` invoqué (structuré) **puis** `send` ; `reply` renvoyé.
|
||||
- **timeout** ⇒ erreur typée remontée, **session non tuée** (le fake atteste qu'aucun `shutdown`
|
||||
n'a été appelé), pas de `reply`.
|
||||
- **`AgentReplied`** publié sur l'EventBus en cas de succès.
|
||||
- cible **PTY-only** (profil sans adapter / présente seulement dans `TerminalSessions`) ⇒ erreur
|
||||
typée explicite, **pas** d'ACK « launched ».
|
||||
- **validation** : `agent.message` sans `task` ⇒ `MissingField` ; round-trip JSON `agent.message`.
|
||||
- **non-régression** : `agent.run` ne change pas de comportement ; aucun accès outbox.
|
||||
- garde anti-always-green sur au moins l'invariant timeout-ne-tue-pas OU ask-retourne-le-contenu.
|
||||
|
||||
## 4. Méthode
|
||||
|
||||
DevBackend code → vérifie `cargo build -p domain -p application -p app-tauri`. **N'exécute pas
|
||||
`cargo test --workspace`** si un build concurrent tient le lock (sinon, lance-le). QA écrit + exécute
|
||||
les tests, produit un rapport clair si rouge. **Ne pas committer, ne pas push** — Main relit et commit.
|
||||
|
||||
## 5. Références
|
||||
|
||||
- Domaine : `crates/domain/src/orchestrator.rs` (enum `OrchestratorCommand` ~l.111, `validate`
|
||||
~l.175), `crates/domain/src/events.rs` (`DomainEvent`).
|
||||
- Application : `crates/application/src/orchestrator/service.rs` (struct/deps ~l.37, `dispatch`
|
||||
~l.88, `OrchestratorOutcome` ~l.50, `spawn_agent` comme modèle de résolution d'agent).
|
||||
- Primitive : `crates/application/src/agent/structured.rs` (`send_blocking`).
|
||||
- Registre structuré : `StructuredSessions` (livré D1, jumeau de `TerminalSessions`) — repère-le
|
||||
et lis son API avant de t'en servir.
|
||||
- Composition root : `crates/app-tauri/src/state.rs`.
|
||||
- Spec : `ARCHITECTURE.md` §17.4 (réconciliation §16) et tableau §17.9 ligne **D6**.
|
||||
95
.ideai/briefs/d7-menu-restreint.md
Normal file
95
.ideai/briefs/d7-menu-restreint.md
Normal file
@ -0,0 +1,95 @@
|
||||
# Brief Dev — Lot D7 : menu de profils restreint + retrait custom (§17.9)
|
||||
|
||||
> Demandé par **Main** à **DevBackend** + **DevFrontend** + **QA**. Cycle §3.
|
||||
> Dernier lot de §17. **Back + front.** DevBackend livre le contrat, DevFrontend consomme.
|
||||
|
||||
## 0. Objectif (§17.3 / §17.6)
|
||||
|
||||
Tant que seuls Claude et Codex ont un adapter structuré, le **menu de sélection de profil IA**
|
||||
ne doit proposer **que** des profils pilotables en mode structuré. Conséquences :
|
||||
- **Gemini / Aider** (présents dans le catalogue de référence mais **sans** `structured_adapter`)
|
||||
ne sont **plus proposés** à la sélection.
|
||||
- Le **profil custom** est **retiré** (l'utilisateur ne peut plus saisir une commande arbitraire,
|
||||
car on ne saurait pas la piloter en structuré).
|
||||
|
||||
Principe : `is_selectable(profile) == structured_adapter.is_some()` (équivaut à
|
||||
`AgentSessionFactory::supports(profile)`). C'est ce prédicat qui **filtre la liste exposée**
|
||||
(wizard first-run **et** création/édition d'agent).
|
||||
|
||||
> Note : on **ne casse pas** le modèle `AgentProfile` (un profil sans adapter reste un profil
|
||||
> PTY/legacy valide, §17.3). On restreint seulement ce qui est **proposé à la sélection**.
|
||||
|
||||
## 1. Côté DevBackend (`crates/`)
|
||||
|
||||
1. **Prédicat de sélectionnabilité** centralisé : `is_selectable(&AgentProfile) -> bool`
|
||||
(= `structured_adapter.is_some()`). Place-le là où c'est cohérent (catalogue/usecases agent).
|
||||
Évite de dupliquer la logique ; si `AgentSessionFactory::supports` existe déjà (livré D2),
|
||||
garde la **même sémantique** (les deux doivent rester d'accord).
|
||||
2. **Exposer uniquement les profils sélectionnables** au chemin de sélection : le use case qui
|
||||
alimente le wizard/la création (autour de `ReferenceProfiles` / `reference_profiles()` dans
|
||||
`crates/application/src/agent/{catalogue,usecases}.rs`) doit **filtrer** sur `is_selectable`.
|
||||
Gemini/Aider restent dans le catalogue **data** (ne les supprime pas du modèle) mais
|
||||
**n'apparaissent pas** dans la liste proposée. Décide proprement : soit un nouveau champ
|
||||
`selectable: bool` sur le DTO exposé, soit une liste déjà filtrée — choisis l'option la moins
|
||||
ambiguë pour le front et documente-la.
|
||||
3. **Retrait custom (back)** : si une commande/usecase accepte un profil custom arbitraire pour
|
||||
la sélection/création depuis le wizard, neutralise ce chemin (ou documente qu'il n'est plus
|
||||
appelé). Ne casse pas la persistance de profils existants.
|
||||
4. Vérifie `cargo build -p domain -p application -p app-tauri`.
|
||||
|
||||
**Contrat à livrer à DevFrontend** (à mettre dans ton rapport) : la forme exacte de ce que le
|
||||
front reçoit (liste filtrée ? champ `selectable`/`structuredAdapter` sur `ProfileDto` ?) pour
|
||||
qu'il sache quoi afficher et quoi masquer. Rappel : `ProfileDto(pub AgentProfile)` sérialise déjà
|
||||
`structuredAdapter` (camelCase) — tu peux t'appuyer dessus plutôt que d'ajouter un champ.
|
||||
|
||||
## 2. Côté DevFrontend (`frontend/src/`)
|
||||
|
||||
> **Ne démarre qu'après le contrat de DevBackend** (Main te relaiera la forme exacte).
|
||||
|
||||
1. **Wizard first-run** (`features/first-run/FirstRunWizard.tsx`, `ProfilesSettings.tsx`) :
|
||||
- n'affiche que les profils **sélectionnables** (Claude/Codex) ;
|
||||
- **retire le bloc `AddCustomProfile`** (`onAdd`/`vm.addCustom`, `emptyCustomProfile`,
|
||||
`aria-label="add custom profile"`) — le bouton/forme custom **disparaît**.
|
||||
2. **Sélecteur d'agent** (création/édition dans `features/agents/`) : même filtre — seuls
|
||||
Claude/Codex proposés ; pas d'option custom.
|
||||
3. Nettoie le code mort résultant (helpers `emptyCustomProfile`, validation custom) **uniquement**
|
||||
s'il n'est plus référencé ailleurs — sinon laisse-le et signale-le.
|
||||
4. Vérifie `cd frontend && npm run build`.
|
||||
|
||||
## 3. Invariants
|
||||
|
||||
- **Zéro régression** : la persistance/édition des profils déjà configurés n'est pas cassée ;
|
||||
un projet existant avec un agent Gemini/Aider/custom **legacy** continue de fonctionner (on
|
||||
restreint la **création**, pas l'exécution de l'existant).
|
||||
- Le prédicat `is_selectable` est la **source unique** ; back et front doivent rester cohérents.
|
||||
- Frontières : le front filtre/affiche selon le contrat du port, le back décide la sélectionnabilité.
|
||||
|
||||
## 4. Tests attendus (QA)
|
||||
|
||||
**Rust** (`-p application`/`app-tauri`) :
|
||||
- `is_selectable` vrai pour Claude/Codex, faux pour Gemini/Aider.
|
||||
- la liste exposée à la sélection ne contient **que** Claude/Codex (custom absent).
|
||||
- non-régression : `reference_profiles()` (catalogue brut) contient toujours les 4 (data intacte).
|
||||
|
||||
**Vitest** (`frontend`) :
|
||||
- le wizard first-run n'affiche que Claude/Codex ; **le bloc custom est absent**
|
||||
(`aria-label="add custom profile"` introuvable).
|
||||
- le sélecteur de création d'agent ne propose que Claude/Codex, pas de custom.
|
||||
- garde anti-always-green : un test qui vérifie l'**absence** du custom doit échouer si le bloc
|
||||
réapparaît (assertion sur non-présence d'un testid/label précis).
|
||||
|
||||
## 5. Méthode
|
||||
|
||||
DevBackend → contrat + build vert → Main relaie à DevFrontend → build vert → QA écrit+exécute
|
||||
(`cargo test --workspace` ET `npx vitest run`) → vert. Rapport d'erreurs clair si rouge.
|
||||
**Ne pas committer, ne pas push.**
|
||||
|
||||
## 6. Références
|
||||
- Catalogue : `crates/application/src/agent/catalogue.rs` (`reference_profiles()` : claude+codex
|
||||
`with_structured_adapter`, gemini+aider sans) ; use cases : `…/agent/usecases.rs`
|
||||
(`ReferenceProfiles`).
|
||||
- Factory : `AgentSessionFactory::supports` (livré D2, `crates/infrastructure/src/session/factory.rs`).
|
||||
- DTO : `crates/app-tauri/src/dto.rs` (`ProfileDto`/`ProfileListDto`).
|
||||
- Front : `frontend/src/features/first-run/{FirstRunWizard,ProfilesSettings}.tsx`,
|
||||
`frontend/src/features/agents/`, `frontend/src/domain/index.ts` (`emptyCustomProfile`).
|
||||
- Spec : `ARCHITECTURE.md` §17.3, §17.6 et tableau §17.9 ligne **D7**.
|
||||
48
.ideai/briefs/option1-terminal-mcp-design.md
Normal file
48
.ideai/briefs/option1-terminal-mcp-design.md
Normal file
@ -0,0 +1,48 @@
|
||||
# Design — Option 1 « Terminal + MCP » (orchestration inter-agents)
|
||||
|
||||
> Décision produit arbitrée (2026-06-11). Remplace la vue chat structurée par le
|
||||
> terminal natif + délégation inter-agents par outils MCP. Source : agent Architecte.
|
||||
> Statut : **design validé, dev NON commencé** (limite de session atteinte le 2026-06-11,
|
||||
> reset 3:40am Europe/Paris). Reprendre par les lots backend B-0→B-5 et frontend F-1.
|
||||
|
||||
## Objectif
|
||||
- **Vue humaine = terminal brut natif** (PTY interactif). Réflexion live + Échap = natifs CLI, zéro parsing par modèle. On abandonne `AgentChatView`/stream-json comme vue.
|
||||
- **Délégation cross-model via MCP** : `idea_ask_agent(target, task)` bloquant → la cible traite quand libre (FIFO) → rend son résultat via NOUVEL outil `idea_reply(result)` → IdeA débloque l'appelant. Fin-de-tour = signal MCP explicite.
|
||||
- Principes : 1 agent = 1 employé (1 process/session, input FIFO) ; hexagonal + SOLID stricts ; plus aucun `parse_event` requis pour vue ni orchestration.
|
||||
|
||||
## Découvertes clés de l'architecte (état réel du code)
|
||||
1. La **file FIFO existe déjà** : `OrchestratorService` (`crates/application/src/orchestrator/service.rs`) a `ask_locks: Mutex<HashMap<AgentId, Arc<AsyncMutex<()>>>>` + `ask_lock_for()` + `ASK_QUEUE_WAIT_CAP` (600s) + `ASK_AGENT_TIMEOUT` (300s). On la formalise en port `AgentMailbox` (pour porter un `oneshot` de réponse).
|
||||
2. `idea_ask_agent` → `agent.message` → `OrchestratorCommand::AskAgent{target_agent, task}` **déjà câblé** (mcp/tools.rs, domain/orchestrator.rs, service.rs). On réimplémente le **corps** de `ask_agent()`.
|
||||
3. Aujourd'hui `ask_agent` **exige une session structurée** et renvoie `AppError::Invalid` si la cible est en PTY brut (service.rs ~400-410). **Inverser cette branche** : PTY vivant = canal normal.
|
||||
4. Routage structuré dans `crates/application/src/agent/lifecycle.rs` (`LaunchAgent` ~1100). Levier de bascule : **ne plus injecter la fabrique structurée au composition root** (`crates/app-tauri/src/state.rs`, `with_structured`).
|
||||
5. `apply_mcp_config` (lifecycle.rs ~1391) écrit déjà `.mcp.json` + `--mcp-config` AVANT le spawn, **chemin PTY inclus** → la CLI PTY a déjà le serveur MCP IdeA (à vérifier par test B-0). Vigilance : `ensure_mcp_server` doit piloter `McpServer::serve` sur le loopback.
|
||||
6. `idea_reply` n'existe nulle part : seul vrai ajout de surface.
|
||||
|
||||
## Lots BACKEND (Rust — agent dev backend) ; NE PAS faire B-6 (nettoyage) avant coordination
|
||||
- **B-0** Prérequis transport MCP : garantir CLI PTY reçoit `--mcp-config <path>` (endpoint/project/requester) + `serve` piloté loopback. Test : CLI factice PTY appelle `idea_list_agents`, reçoit réponse.
|
||||
- **B-1** Port `AgentMailbox` + `InMemoryMailbox`. Domaine pur (`crates/domain/src/mailbox.rs` ou ports.rs) : trait + `Ticket{id,requester,task}`, `TicketId`, `MailboxError`. Infra (`crates/infrastructure/src/mailbox/`) : `HashMap<AgentId, VecDeque<(Ticket, oneshot::Sender<String>)>>` + mutex ; `enqueue` rend `PendingReply` (sur `oneshot::Receiver`). Tests : FIFO ; `resolve` réveille le bon pending ; 2 ask même cible sérialisés ; cibles ≠ non bloquants ; timeout retire ticket de tête.
|
||||
- **B-2** Bascule routage : tous en PTY. `state.rs` : retirer `with_structured` de `LaunchAgent`/`OrchestratorService`/`ChangeAgentProfile`. Tests : profil Claude → PTY ; DTO renvoie `CellKind::Pty`. Ne pas supprimer `launch_structured` (mort-code, nettoyage ultérieur).
|
||||
- **B-3** Réimplémenter `ask_agent` : résoudre id → `mailbox.enqueue` → ticket en tête → garantir cible vivante PTY (sinon LaunchAgent PTY bg) → `PtyPort::write` préfixe `[IdeA · tâche de {A} · ticket {id}] {task}\n` → `await PendingReply` borné `ASK_AGENT_TIMEOUT`. PTY vivant = normal. Timeout : garder agent vivant, retirer ticket de tête. Publier `AgentReplied`. Injecter `Arc<dyn AgentMailbox>` + `Arc<dyn PtyPort>`. Tests : injection bon handle ; agent mort relancé ; timeout libère file ; AgentReplied.
|
||||
- **B-4** Outil/action `idea_reply` : `ToolDef idea_reply` (schéma `{result:string}` seul, pas de ticket_id exposé), action wire `agent.reply`, `OrchestratorCommand::Reply{from:AgentId, result}`, `validate`, `map_tool_call` (passe `requester` du handshake comme `from`), bras dispatch → `mailbox.resolve(from, result)`. Corrélation implicite : `idea_reply` résout le ticket en tête de la file de l'émetteur (identité via handshake, pas via id géré par le modèle). `tool_returns_reply` : idea_reply = ACK sans inline. Tests : mapping ; validate exige result ; resolve corrèle tête ; reply sans ask = erreur typée (pas de panic).
|
||||
- **B-5** Protocole délégation dans le contexte : injecter dans convention file (`apply_injection`) + description outil : « reçois `[IdeA · tâche …]` → traite → appelle IMPÉRATIVEMENT `idea_reply(result=…)` ; ne réponds jamais qu'en texte. » Test : convention file contient l'instruction.
|
||||
|
||||
## Lots FRONTEND (TS/React — agent dev frontend) ; NE PAS faire F-2 (suppression) avant coordination
|
||||
- **F-1** Router toute cellule agent vers `TerminalView` (jamais `AgentChatView`) ; ré-attache PTY + scrollback OK. Backend renverra `cellKind:"pty"`. Lire `frontend/src/features/layout/LayoutGrid.tsx`, `features/chat/AgentChatView.tsx`, `TerminalView`, `adapters/agent.ts`, `ports/index.ts`, `domain/index.ts`. Laisser `AgentChatView` inerte (non monté), pas supprimé. Tests Vitest : agent rend `TerminalView`, jamais `AgentChatView` ; re-mount repeint pty.
|
||||
|
||||
## Ordre / dépendances
|
||||
```
|
||||
B-0 ─┬─ B-2 ─┬─ B-3 ─ B-4 ─ B-5
|
||||
B-1 ─┘ └─ F-1
|
||||
Nettoyage (B-6, F-2) en dernier, coordonné.
|
||||
```
|
||||
Chemin critique : B-0 → B-2 → B-3 → B-4 → B-5. B-1 ∥ B-0. F-1 dès B-2.
|
||||
|
||||
## Cohérence
|
||||
Domaine sans I/O (port + entités pures) ; oneshot/PTY/MCP = infra ; application via ports. Open/Closed (idea_reply = ajout, dispatch intact) ; Liskov (Claude/Codex identiques derrière PTY+MCP) ; 1 process/agent préservé.
|
||||
|
||||
## Fichiers à toucher
|
||||
- Domaine : `mailbox.rs` (nouveau) / `ports.rs` ; `orchestrator.rs` (variante `Reply` + action `agent.reply`).
|
||||
- Application : `orchestrator/service.rs` (ask_agent + reply + injection ports) ; `agent/structured.rs` (supprimé au nettoyage) ; `agent/lifecycle.rs` (routage).
|
||||
- Infra : `mailbox/` (nouveau) ; `orchestrator/mcp/tools.rs` (idea_reply) ; `orchestrator/mcp/server.rs` (passer requester).
|
||||
- app-tauri : `state.rs` (retrait with_structured + injection mailbox + ensure_mcp_server) ; `commands.rs`/`dto.rs` (nettoyage ultérieur).
|
||||
- Frontend : `features/layout/LayoutGrid.tsx` (routage TerminalView) ; `features/chat/*` (nettoyage ultérieur).
|
||||
280
.ideai/briefs/orchestration-v3-cadrage.md
Normal file
280
.ideai/briefs/orchestration-v3-cadrage.md
Normal file
@ -0,0 +1,280 @@
|
||||
# Cadrage Architecture — Orchestration v3 : invocation native d'agents (surface MCP)
|
||||
|
||||
> Produit par **Architect** en réponse au brief `orchestration-v3-invocation-native.md`.
|
||||
> Cadrage **avant tout code** (méthode §3). Livrable : ce document + mise à jour `ARCHITECTURE.md` §14.3.
|
||||
> Aucun code de production ici.
|
||||
|
||||
---
|
||||
|
||||
## 0. État réel du terrain — ce qui est DÉJÀ résolu (lu dans le code, pas présumé)
|
||||
|
||||
Le brief décrit trois faiblesses (conscience = prose, pas de discussion inter-agents,
|
||||
fire-and-forget). **Deux des trois sont déjà comblées par le pivot §17** (livré, lots D0→D7).
|
||||
Il faut le constater honnêtement pour ne **pas re-cadrer** ce qui existe :
|
||||
|
||||
| Faiblesse du brief | Statut réel | Référence code |
|
||||
|---|---|---|
|
||||
| Pas de discussion inter-agents (`agent.message` « future », `task` ignoré pour agent vivant) | ✅ **RÉSOLU** | `OrchestratorCommand::AskAgent` (`domain/src/orchestrator.rs`), `OrchestratorService::ask_agent` (`application/src/orchestrator/service.rs`) |
|
||||
| Fire-and-forget, pas de corrélation requête↔réponse | ✅ **RÉSOLU sans outbox** : le rendez-vous synchrone est **intrinsèque** à `AgentSession::send()` (flux → `Final` déterministe). Le `Final` *est* la fin de tour. | `application/src/agent/structured.rs` (`send_blocking`), `domain/src/ports.rs` (`ReplyEvent::Final`) |
|
||||
| Pas de réveil du demandeur / event de réponse | ✅ **RÉSOLU** | `DomainEvent::AgentReplied`, `OrchestratorResponse.reply` (`infrastructure/src/orchestrator/mod.rs`) |
|
||||
| Conscience = soft prompt (l'agent doit deviner le schéma JSON) | ⚠️ **PARTIEL** : la prose `# Orchestration IdeA` est injectée (`compose_convention_file`), mais **aucun outil typé natif** n'est exposé. | `application/src/agent/lifecycle.rs` |
|
||||
| Interdiction des subagents natifs **+** alternative native | ⚠️ **PARTIEL** : interdiction présente (prose) ; l'alternative native (outils `idea_*`) **manque encore**. | idem |
|
||||
| Capacité MCP sur le profil | ❌ **ABSENT** | — |
|
||||
| Serveur MCP / config MCP par CLI | ❌ **ABSENT** | — |
|
||||
|
||||
**Conclusion de cadrage** : l'orchestration v3 **n'est plus** « combler la messagerie inter-agents »
|
||||
(c'est fait). Elle se réduit à **un seul chantier net** : **exposer l'orchestration IdeA comme
|
||||
serveur MCP model-agnostic**, en tant qu'**adapter entrant supplémentaire** par-dessus le **même**
|
||||
`OrchestratorService::dispatch`, avec **repli homogène** sur le protocole fichier `.ideai/requests`
|
||||
(§14.3) + prose (`compose_convention_file`) pour les CLI sans MCP. C'est ce que cadre la suite.
|
||||
|
||||
> **Principe directeur (zéro régression, §9/§17.3)** : MCP est un **confort de conscience native**
|
||||
> (outils typés, plus de schéma à deviner). Il **n'invente aucune sémantique** : tout outil MCP se
|
||||
> ramène à un `OrchestratorCommand` déjà existant. La voie principale du *retour de valeur* reste
|
||||
> §17 (`send_blocking`) ; MCP ne fait que **déclencher** `dispatch`, jamais re-router la réponse.
|
||||
|
||||
---
|
||||
|
||||
## 1. Décisions tranchées (les 4 points durs du brief)
|
||||
|
||||
### Décision 1 — Capacité MCP par runtime = champ optionnel `mcp` sur `AgentProfile` (Open/Closed)
|
||||
|
||||
**Tranché** : on ajoute un champ **optionnel** `mcp: Option<McpCapability>` sur `AgentProfile`,
|
||||
exactement comme `session: Option<SessionStrategy>` et `structured_adapter: Option<StructuredAdapter>`
|
||||
le sont déjà. `None` (défaut) ⇒ **repli fichier + prose** (comportement actuel, zéro régression).
|
||||
`Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et l'agent voit les outils `idea_*`.
|
||||
|
||||
```rust
|
||||
// domain/src/profile.rs — capacité MCP déclarative (pur, validé par constructeur, comme SessionStrategy)
|
||||
|
||||
/// Stratégie de matérialisation de la config MCP propre à UNE CLI : chaque CLI
|
||||
/// déclare son serveur MCP différemment (fichier `.mcp.json` pour Claude Code,
|
||||
/// flag de lancement, ou variable d'env). Déclaratif = donnée, pas code (§9).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "strategy")]
|
||||
pub enum McpConfigStrategy {
|
||||
/// Écrire un fichier de conf MCP au chemin (relatif au run dir isolé §14.1)
|
||||
/// attendu par la CLI, au format JSON propre à cette CLI (ex. `.mcp.json`).
|
||||
ConfigFile { target: String }, // relative_safe(target) — pas de `..`, pas d'absolu
|
||||
/// Passer le serveur via un flag de lancement (ex. `--mcp-config {path}`).
|
||||
Flag { flag: String }, // non_empty(flag)
|
||||
/// Passer via une variable d'environnement.
|
||||
Env { var: String }, // valid_env_var(var)
|
||||
}
|
||||
|
||||
/// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI,
|
||||
/// et QUEL transport. `None` sur le profil ⇒ repli fichier `.ideai/requests` + prose.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpCapability {
|
||||
/// Comment matérialiser la config MCP au lancement (relatif au run dir).
|
||||
pub config: McpConfigStrategy,
|
||||
/// Transport du serveur MCP IdeA (détail invisible au domaine ; voir D3).
|
||||
/// `stdio` = défaut robuste cross-OS ; `socket` = optimisation (point ouvert).
|
||||
#[serde(default)]
|
||||
pub transport: McpTransport,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum McpTransport { #[default] Stdio, Socket }
|
||||
```
|
||||
|
||||
Sur `AgentProfile`, additif et **non cassant** (sérialisation inchangée pour les profils sans MCP) :
|
||||
```rust
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mcp: Option<McpCapability>,
|
||||
```
|
||||
Builder additif (comme `with_structured_adapter`) : `AgentProfile::new(...).with_mcp(cap)` ; la
|
||||
signature de `AgentProfile::new` reste **inchangée** ⇒ tous les appels du catalogue/tests restent verts.
|
||||
|
||||
**Justification** : cohérence §9 (« ajouter une IA = donnée, pas code »), symétrie avec les deux autres
|
||||
capacités optionnelles déjà sur le profil, `skip_serializing_if = None` ⇒ **zéro régression** de
|
||||
sérialisation. Le **prédicat de surface** est `profile.mcp.is_some()` — un seul point de vérité.
|
||||
|
||||
> **Modèle en couches (exigé par le brief §4.1)** : la surface effective d'un agent est
|
||||
> `surface(agent) = if profile.mcp.is_some() { Mcp } else { FileProtocol }`. Les deux couches
|
||||
> produisent le **même** `OrchestratorCommand`. Aucun agent n'est jamais bloqué : sans MCP, la prose
|
||||
> `# Orchestration IdeA` + `.ideai/requests` reste pleinement fonctionnelle.
|
||||
|
||||
---
|
||||
|
||||
### Décision 2 — Retour synchrone d'`ask` = AUCUN nouveau modèle de corrélation : on réutilise `send_blocking`
|
||||
|
||||
**Tranché (et c'est la décision la plus importante)** : l'outil MCP `idea_ask_agent` **ne crée
|
||||
aucune corrélation requête↔réponse, aucun outbox, aucun event de réveil neufs**. Il appelle le
|
||||
**même** `OrchestratorService::dispatch(AskAgent { target, task })` que le watcher fichier, qui
|
||||
**retourne déjà** `OrchestratorOutcome { reply: Some(content) }` via `send_blocking`. L'adapter MCP
|
||||
**renvoie ce `content` inline** comme valeur de retour de l'outil. Fin.
|
||||
|
||||
Le brief (rédigé avant le pivot §17) supposait qu'`ask` était un point dur à résoudre via outbox +
|
||||
corrélation fichier. **Le pivot §17 l'a déjà tranché autrement** : le `Final` du `ReplyStream` *est*
|
||||
la fin de tour déterministe ; pas besoin de deviner, pas d'outbox, pas de `request_id`. On **n'y
|
||||
revient pas**. Le tableau ci-dessous fige la sémantique, déjà implémentée :
|
||||
|
||||
| Aspect | Décision (déjà en place) | Code |
|
||||
|---|---|---|
|
||||
| **Corrélation** | Aucune : `dispatch` est un appel synchrone `async` ; la réponse est la valeur de retour. Le transport MCP (JSON-RPC) porte nativement la corrélation requête/réponse. | `service.rs::ask_agent` |
|
||||
| **Outbox** | **Supprimé de la voie principale** (§17.4). Pas réintroduit. | — |
|
||||
| **Event `AgentReplied`** | **Observabilité UI uniquement** (« Architect a répondu à Main »), best-effort, ne porte pas la valeur. | `reply_outcome` |
|
||||
| **Timeout** | Borné (`ASK_AGENT_TIMEOUT = 300 s`). À l'expiration : `AgentSessionError::Timeout` → la cible **reste vivante** (non tuée), l'outil MCP renvoie une **erreur typée** ; l'appelant décide. | `send_blocking` |
|
||||
| **Cible a déjà une session vivante** (one-live-session-per-agent) | `ask` **réutilise** la session structurée vivante (`session_for_agent`) — rendez-vous direct, pas de respawn. Si la cible est vivante en **PTY brut** (profil sans `structured_adapter`) ⇒ erreur typée explicite (**jamais** un ACK trompeur). | `service.rs::ask_agent` étapes 1→3 |
|
||||
| **Cible morte** | `LaunchAgent` en mode structuré (background) puis `send_blocking`. Garde d'unicité sur **les deux** registres. | idem |
|
||||
|
||||
**Justification** : DRY radical (une seule logique de rendez-vous, partagée par UI chat, watcher
|
||||
fichier et MCP) ; frontière nette (le domaine ne connaît qu'un `prompt` et un `Final`, jamais un
|
||||
transcript ni un id de corrélation) ; universalité (marche pour toute CLI structurée Claude/Codex).
|
||||
**Le seul travail v3 ici est de brancher l'outil MCP sur `dispatch` — pas de re-cadrer le rendez-vous.**
|
||||
|
||||
> **Conséquence produit** : `idea_ask_agent` cible **toujours un agent structuré** (Claude/Codex),
|
||||
> cohérent avec le menu restreint §17.3/§17.6. Un agent **demandeur** peut être n'importe quelle CLI
|
||||
> MCP (Claude, Codex, Gemini…) ; un agent **cible** d'un `ask` doit être structuré. C'est déjà
|
||||
> l'invariant en vigueur — MCP ne le change pas.
|
||||
|
||||
---
|
||||
|
||||
### Décision 3 — MCP vs subagents natifs : on garde l'interdiction ET on offre l'alternative native, config injectée par CLI au lancement
|
||||
|
||||
**Tranché** : l'interdiction des subagents natifs (prose `# Orchestration IdeA`) **reste** — elle
|
||||
protège l'identité/mémoire/observabilité IdeA. Mais on offre désormais la **vraie alternative
|
||||
native** : les outils `idea_*` apparaissent dans la liste d'outils de la CLI. La prose est **adaptée
|
||||
selon la surface** :
|
||||
- agent **MCP** (`profile.mcp.is_some()`) : la prose pointe vers les outils `idea_ask_agent` /
|
||||
`idea_launch_agent` / `idea_list_agents` (au lieu d'« écris un JSON dans `.ideai/requests` »).
|
||||
- agent **fichier** (`mcp == None`) : prose `.ideai/requests` actuelle, **inchangée**.
|
||||
|
||||
**Injection de la config MCP par CLI = au `LaunchAgent`, dans le run dir isolé (§14.1), via la
|
||||
`McpConfigStrategy`** — exactement le même point et la même mécanique que le convention file :
|
||||
```
|
||||
LaunchAgent::execute (après apply_injection, avant spawn/factory.start) :
|
||||
if let Some(mcp) = &profile.mcp:
|
||||
// IdeA matérialise SA config MCP au format de CETTE CLI dans <run_dir>/...
|
||||
apply_mcp_config(mcp, &run_dir, &spec) // ConfigFile→write ; Flag→spec.args ; Env→spec.env
|
||||
```
|
||||
- `ConfigFile { target }` : écrit `<run_dir>/<target>` (ex. `.mcp.json`) avec la déclaration du
|
||||
serveur MCP IdeA (commande/transport). Non-clobbering, best-effort, **comme le seed de permissions**.
|
||||
- `Flag { flag }` : ajoute le flag + chemin au `SpawnSpec.args`.
|
||||
- `Env { var }` : ajoute la variable au `SpawnSpec.env`.
|
||||
|
||||
Le **serveur MCP lui-même** est démarré **par projet ouvert**, à côté du `FsOrchestratorWatcher`,
|
||||
dans le **même hook** `ensure_orchestrator_watch` (`app-tauri/src/state.rs`). Une CLI qui se lance
|
||||
avec la config injectée se connecte à ce serveur (stdio : IdeA spawn un pont par session ; socket :
|
||||
adresse partagée — détail d'adapter, point ouvert S-MCP).
|
||||
|
||||
**Justification** : symétrie totale avec le convention file et le seed de permissions (même run dir,
|
||||
même best-effort non-clobbering, même moment) ⇒ aucune nouvelle plomberie de cycle de vie. La config
|
||||
MCP est **donnée déclarative par profil**, donc « ajouter une CLI MCP = donnée, pas code ».
|
||||
|
||||
---
|
||||
|
||||
### Décision 4 — Frontières hexagonales : le serveur MCP est un adapter entrant d'infrastructure ; AUCUN nouveau port applicatif
|
||||
|
||||
**Tranché** : le serveur MCP est un **driving adapter d'infrastructure**
|
||||
(`infrastructure/src/orchestrator/mcp/`), **pair** du `FsOrchestratorWatcher`. Il appelle le **même**
|
||||
`OrchestratorService::dispatch` (application) et **ne duplique rien**. Trois portes d'entrée
|
||||
substituables se ramènent au même `OrchestratorCommand` :
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
Agent MCP ───▶│ Serveur MCP (infra/orchestrator/mcp) │──┐
|
||||
└─────────────────────────────────────────────┘ │
|
||||
┌─────────────────────────────────────────────┐ │ OrchestratorCommand
|
||||
Fichier ───▶│ FsOrchestratorWatcher (infra/orchestrator) │──┼──▶ OrchestratorService::dispatch
|
||||
(.ideai/ └─────────────────────────────────────────────┘ │ (application — INCHANGÉ)
|
||||
requests) ┌─────────────────────────────────────────────┐ │ │
|
||||
UI ───▶│ Commandes Tauri (app-tauri) │──┘ ▼
|
||||
└─────────────────────────────────────────────┘ use cases agent/terminal
|
||||
```
|
||||
|
||||
- **Où vit le serveur MCP** : `infrastructure/src/orchestrator/mcp/`. Il **traduit** un appel d'outil
|
||||
MCP (`idea_ask_agent`, `idea_launch_agent`, `idea_list_agents`, et par parité `idea_update_context`,
|
||||
`idea_create_skill`, `idea_stop_agent`) en `OrchestratorCommand`, appelle `dispatch`, et renvoie
|
||||
`OrchestratorOutcome` (`reply`/`detail`) inline comme résultat d'outil. JSON-RPC, stdio/socket,
|
||||
le crate MCP : **tout reste dans cet adapter**. Le domaine/application ignorent MCP.
|
||||
- **Quel port côté domaine/application** : **aucun nouveau**. `OrchestratorService::dispatch`
|
||||
(application) est déjà l'unique seam. `idea_list_agents` réutilise `ListAgents`. La validation
|
||||
(`OrchestratorRequest::validate`) reste le point unique « parse, don't validate » — l'adapter MCP
|
||||
construit un `OrchestratorCommand` (directement, ou via `OrchestratorRequest` pour réutiliser la
|
||||
validation, au choix d'implémentation).
|
||||
- **Réutilisation de `OrchestratorService` plutôt que duplication** : le serveur MCP reçoit
|
||||
`Arc<OrchestratorService>` au composition root (`state.rs`), exactement comme le watcher. Une seule
|
||||
logique applicative ; les adapters ne portent que leur techno d'entrée.
|
||||
|
||||
**Justification** : DRY + règle de dépendance hexagonale. Cible, identité, mémoire, observabilité UI
|
||||
passent **toujours** par le seul chemin applicatif. Les spikes MCP (transport, crate) sont **confinés**
|
||||
à l'adapter infra et ne touchent ni le domaine ni l'application.
|
||||
|
||||
---
|
||||
|
||||
## 2. Modèle de messages corrélés — état figé (rien de neuf)
|
||||
|
||||
La « corrélation requête↔réponse » du brief est portée **nativement par le transport** :
|
||||
- **MCP** : JSON-RPC corrèle requête/réponse par `id` de message ⇒ rien à modéliser côté IdeA.
|
||||
- **Fichier** : `<file>.json` → `<file>.json.response.json` (sibling), déjà en place.
|
||||
- **Valeur de retour** : `OrchestratorOutcome { detail, reply }` (application) → `OrchestratorResponse
|
||||
{ ok, action, detail, error, reply }` (infra fichier) **ou** résultat d'outil MCP. **Structs déjà
|
||||
définies**, réutilisées telles quelles.
|
||||
|
||||
Aucun `CorrelationId`, aucun `AgentReply`, aucun port `AgentReplyChannel`, aucun outbox : **abandonnés
|
||||
par le pivot §17** et **non réintroduits** par v3. C'est la simplification clé.
|
||||
|
||||
---
|
||||
|
||||
## 3. Découpage en LOTS testables (méthode §3) — MCP uniquement
|
||||
|
||||
> Chaque lot = binôme dev+test, vert avant le suivant. Backend/frontend séparés.
|
||||
> Les terminaux non-IA et le chemin fichier `.ideai/requests` restent verts à chaque lot.
|
||||
> **Spike S-MCP** (crate MCP Rust + transport stdio/socket + format de conf par CLI) est **confiné au
|
||||
> lot M2** et n'invalide pas l'ossature (le contrat d'entrée reste `OrchestratorCommand`).
|
||||
|
||||
| Lot | Côté | Périmètre | Crates/dossiers | Contrats | Tests attendus |
|
||||
|---|---|---|---|---|---|
|
||||
| **M0 (capacité profil)** | back | `McpCapability` + `McpConfigStrategy` + `McpTransport` (domaine, validés) ; champ `AgentProfile.mcp: Option<McpCapability>` (+ builder `with_mcp`, `new` inchangé) ; catalogue Claude/Codex annotés (ex. `ConfigFile { target: ".mcp.json" }`). | `domain/src/profile.rs`, `application/src/agent/catalogue.rs` | enum + struct + champ optionnel sérialisé | unit purs : `mcp = None` round-trip **identique à avant** (zéro régression sérialisation) ; `Some(_)` round-trip ; constructeurs valident (`relative_safe` target, `non_empty` flag, `valid_env_var`) ; catalogue annoté. |
|
||||
| **M1 (injection conf MCP au lancement)** | back | `LaunchAgent` matérialise la conf MCP selon `McpConfigStrategy` dans le run dir isolé (après `apply_injection`, avant spawn/`factory.start`) : `ConfigFile`→write non-clobbering, `Flag`→`args`, `Env`→`env`. Prose `compose_convention_file` adaptée selon `mcp.is_some()`. | `application/src/agent/lifecycle.rs` | `LaunchAgent` (chemin MCP additif) | unit (fakes) : profil `mcp=None` ⇒ **aucun** write/flag/env MCP (chemin actuel inchangé) ; `ConfigFile` ⇒ fichier écrit au bon chemin, non-clobbering ; `Flag`/`Env` ⇒ `spec` enrichi ; prose contient les outils `idea_*` si MCP, sinon `.ideai/requests`. |
|
||||
| **M2 (serveur/adapter MCP)** | back | `infrastructure/src/orchestrator/mcp/` : serveur MCP exposant `idea_ask_agent`/`idea_launch_agent`/`idea_list_agents` (+ parité `idea_update_context`/`idea_create_skill`/`idea_stop_agent`) → `OrchestratorCommand` → `dispatch` → résultat inline. **Spike S-MCP** (crate, transport) isolé ici. | `infrastructure/src/orchestrator/mcp/` | mapping outil→commande ; `Arc<OrchestratorService>` injecté | unit (fakes + `OrchestratorService` à use cases fakes) : chaque outil mappe la bonne commande ; `idea_ask_agent` renvoie `reply` inline ; timeout → erreur typée, cible non tuée ; `idea_list_agents` liste ; JSON-RPC malformé → erreur, jamais panic. Hors-réseau (transport en mémoire/pipe scriptable). |
|
||||
| **M3 (câblage par projet)** | back | Démarrer le serveur MCP par projet ouvert dans `ensure_orchestrator_watch` (à côté du watcher) ; registre `mcp_servers` jumeau de `orchestrator_watchers` ; arrêt à la fermeture du projet. | `app-tauri/src/state.rs`, `commands.rs` | hook `ensure_orchestrator_watch` étendu | app-tauri : un serveur MCP par projet, idempotent ; fermeture du projet ⇒ arrêt ; coexiste avec le watcher fichier (les deux portes vivantes). |
|
||||
| **M4 (observabilité UI — optionnel)** | front | Surfacer dans l'UI Agents qu'une délégation est passée par MCP vs fichier (badge/source sur l'event `OrchestratorRequestProcessed` / `AgentReplied`). Non bloquant. | `frontend/src/features/agents` | DTO d'event enrichi (`source: "mcp"|"file"`) | Vitest : badge source affiché ; absence d'event ⇒ pas de régression. |
|
||||
|
||||
**Ordre conseillé** : **M0 → M1 → M2 → M3** (→ M4 optionnel). M0 débloque tout (donnée pure) ;
|
||||
M1 injecte la conf (testable sans serveur) ; M2 livre l'adapter derrière un transport scriptable
|
||||
(spike confiné) ; M3 le câble par projet. M4 est du confort d'observabilité.
|
||||
|
||||
---
|
||||
|
||||
## 4. Conformité hexagonale & SOLID (rappel)
|
||||
|
||||
- **Règle de dépendance** : `McpCapability`/`McpConfigStrategy` sont **domaine** (purs, validés).
|
||||
Le serveur MCP, JSON-RPC, stdio/socket, le crate MCP sont **exclusivement** infra. Le domaine et
|
||||
l'application **ignorent** MCP (l'application ne voit que `OrchestratorCommand`/`dispatch`).
|
||||
- **S** : le serveur MCP = une seule techno d'entrée (MCP→commande). `OrchestratorService` garde sa
|
||||
responsabilité (commande→use cases). `LaunchAgent` gagne une étape d'injection homogène, pas une
|
||||
responsabilité nouvelle.
|
||||
- **O** : ajouter une CLI MCP = un bloc `mcp` sur le profil (**donnée**). Aucun cœur touché.
|
||||
- **L** : les trois portes d'entrée (fichier, MCP, UI) sont substituables — même `dispatch`, même
|
||||
résultat. Repli fichier ≡ MCP du point de vue de la réponse.
|
||||
- **I** : le serveur MCP ne reçoit que `Arc<OrchestratorService>` (pas les use cases en détail).
|
||||
- **D** : tout injecté au composition root (`state.rs`) ; aucun `new` d'adapter MCP ailleurs.
|
||||
|
||||
---
|
||||
|
||||
## 5. Chantiers adjacents (situés, NON cadrés ici)
|
||||
|
||||
- **Hot-swap de l'AI profile** (chantier A, §15.1) : **LIVRÉ** (`ChangeAgentProfile`). Interaction
|
||||
avec v3 : un swap vers/depuis un profil MCP change la surface (`mcp.is_some()`) ⇒ au relance,
|
||||
`LaunchAgent` (ré)injecte ou retire la conf MCP automatiquement. **Rien à cadrer** : la surface suit
|
||||
le profil courant, point de vérité unique.
|
||||
- **Reprise auto des sessions au redémarrage** (chantier B, §15.2) : **LIVRÉ** (`ListResumableAgents`,
|
||||
`conversation_id` persisté sur la cellule). Interaction avec v3 : à la reprise, `LaunchAgent`
|
||||
ré-matérialise la conf MCP comme à tout lancement (M1). **Rien à cadrer**.
|
||||
|
||||
Ces deux chantiers **ne sont pas un prérequis** de v3/MCP et n'en bloquent aucun lot.
|
||||
|
||||
---
|
||||
|
||||
## 6. Synthèse des décisions
|
||||
|
||||
1. **Capacité MCP = `Option<McpCapability>` sur le profil** (Open/Closed, `None` ⇒ repli fichier+prose, zéro régression sérialisation).
|
||||
2. **Retour synchrone d'`ask` : RIEN de neuf** — réutilise `send_blocking`/`AskAgent`/`AgentReplied`/`OrchestratorOutcome.reply` déjà livrés (§17). Pas d'outbox, pas de corrélation fichier, pas de `CorrelationId`. Le transport MCP corrèle nativement.
|
||||
3. **Interdiction subagents natifs conservée + alternative native** : prose adaptée selon surface ; conf MCP injectée **par CLI** au `LaunchAgent` dans le run dir isolé (`McpConfigStrategy` : ConfigFile/Flag/Env), symétrique au convention file et au seed permissions.
|
||||
4. **Frontières** : serveur MCP = **adapter entrant infra** (`infrastructure/src/orchestrator/mcp/`), pair du watcher fichier, appelant le **même** `OrchestratorService::dispatch`. **Aucun nouveau port** applicatif/domaine.
|
||||
5. **Lots** : M0 (capacité profil) → M1 (injection conf) → M2 (adapter/serveur MCP, spike confiné) → M3 (câblage par projet) → M4 (observabilité, optionnel).
|
||||
85
.ideai/briefs/orchestration-v3-invocation-native.md
Normal file
85
.ideai/briefs/orchestration-v3-invocation-native.md
Normal file
@ -0,0 +1,85 @@
|
||||
# Brief Architecture — Orchestration v3 : invocation native d'agents (surface MCP + repli fichier)
|
||||
|
||||
> Demandé par **Main** à **Architect**. Cadrage attendu **avant tout code** (méthode §3).
|
||||
> Ce brief ne prescrit pas l'implémentation : il pose le problème, les contraintes et les
|
||||
> décisions à trancher. À toi de produire la cartographie (ports, adapters, modèles, lots).
|
||||
|
||||
## 1. Contexte & problème
|
||||
|
||||
Aujourd'hui, un agent apprend qu'il doit déléguer via IdeA **uniquement par une instruction
|
||||
en prose** injectée en tête de son convention file (`compose_convention_file`,
|
||||
`crates/application/src/agent/lifecycle.rs` → bloc « # Orchestration IdeA »). Il écrit alors
|
||||
un JSON dans `.ideai/requests/<id>/*.json`, capté par l'`OrchestratorWatcher`
|
||||
(`crates/infrastructure/src/orchestrator/mod.rs`) → validé par le modèle domaine pur
|
||||
(`crates/domain/src/orchestrator.rs`) → exécuté par `OrchestratorService`.
|
||||
|
||||
**Trois faiblesses constatées dans le code :**
|
||||
|
||||
1. **Conscience = soft prompt.** Rien ne contraint l'agent ; rien ne l'empêche d'utiliser
|
||||
le subagent natif du fournisseur ; le schéma JSON n'est même pas fourni dans l'instruction
|
||||
(l'agent doit le deviner).
|
||||
2. **Pas de discussion inter-agents.** `agent.message` est marqué « future ». Le champ `task`
|
||||
d'`agent.run` est replié dans `context`, mais `OrchestratorService` n'utilise `context`
|
||||
que pour un agent **neuf** (initial `.md`) : pour un agent **déjà existant**, le `task` est
|
||||
**silencieusement ignoré**. La réponse (`*.response.json`) ne porte qu'un ACK de cycle de
|
||||
vie (`detail: "launched agent X"`), jamais la sortie produite par la cible.
|
||||
3. **Fire-and-forget.** Aucune corrélation requête↔réponse de contenu, aucun réveil du
|
||||
demandeur.
|
||||
|
||||
## 2. Objectif produit (vision Anthony)
|
||||
|
||||
Rendre l'invocation d'un agent par un autre **aussi native que l'invocation de subagents dans
|
||||
Claude CLI** (l'outil `Task` : le modèle voit un outil typé, l'appelle, et **le résultat
|
||||
revient inline** dans sa conversation) — mais de façon **model-agnostic** (Claude, Codex,
|
||||
Gemini, custom) et **toujours médiée par IdeA** (qui garde identité, contexte, mémoire,
|
||||
observabilité UI).
|
||||
|
||||
## 3. Direction pressentie (à valider/affiner par l'Architecte)
|
||||
|
||||
**Exposer l'orchestration IdeA comme un serveur MCP** que IdeA branche sur chaque CLI qui le
|
||||
supporte (Claude Code, Codex, Gemini CLI supportent MCP). Outils pressentis :
|
||||
|
||||
| Outil MCP | Effet |
|
||||
|---|---|
|
||||
| `idea_ask_agent(target, task) → reply` | Lance/réveille la cible, transmet la tâche, **attend et renvoie sa réponse** inline |
|
||||
| `idea_launch_agent(target, visibility)` | Lancement fire-and-forget (équiv. `agent.run` actuel) |
|
||||
| `idea_list_agents() → […]` | Découverte des agents du projet |
|
||||
|
||||
Bénéfices : conscience native (l'outil apparaît dans la liste d'outils, plus de prose à
|
||||
« se rappeler »), arguments typés/validés (fini le JSON deviné), et surtout `ask_agent`
|
||||
**renvoie le contenu** → comble la messagerie inter-agents manquante.
|
||||
|
||||
## 4. Points durs à trancher (cœur du cadrage)
|
||||
|
||||
1. **Capacité par runtime.** Tous les profils ne supportent pas MCP/outils (custom CLI).
|
||||
→ Modèle **en couches** : surface MCP quand le profil le déclare ; **repli sur le protocole
|
||||
fichier `.ideai/requests` + prose** sinon. Le port `AgentRuntime` gagne une capacité
|
||||
déclarative (`supportsMcp` ou descripteur de capacités). Comment exprimer ça dans le profil
|
||||
déclaratif (§9) sans casser l'existant ?
|
||||
2. **Retour synchrone d'`ask_agent`.** C'est le vrai défi : « attendre que la cible ait fini
|
||||
son tour et capturer sa sortie » pour un fournisseur arbitraire = même problème que
|
||||
l'inspecteur de session (cf. mémoire `conversation-resume-architecture`). Piste : la cible
|
||||
écrit sa réponse dans un **outbox** `.ideai/`, l'outil MCP attend/poll avec corrélation
|
||||
requête↔réponse + timeout. Définir : modèle de corrélation, event `AgentReplied`, sémantique
|
||||
de timeout/erreur, et que faire si la cible tourne déjà (one-live-session-per-agent).
|
||||
3. **MCP vs subagents natifs.** On garde l'interdiction des subagents natifs (sinon
|
||||
court-circuit d'IdeA = perte identité/mémoire/observabilité), mais on offre désormais une
|
||||
**vraie alternative native**, pas qu'une interdiction. Comment configurer/injecter le serveur
|
||||
MCP par CLI (chaque CLI a sa propre conf MCP) depuis le lancement IdeA ?
|
||||
4. **Frontières hexagonales.** Où vit le serveur MCP (nouvel adapter d'infrastructure ?), quel
|
||||
port côté domaine/application, comment il réutilise `OrchestratorService` existant plutôt que
|
||||
de le dupliquer.
|
||||
|
||||
## 5. Chantiers adjacents (à seulement situer, pas à cadrer ici)
|
||||
|
||||
Garder en tête la cohérence avec deux autres chantiers du même fil « agent = entité » :
|
||||
- **Hot-swap de l'AI profile** d'un agent existant (absent à toutes les couches aujourd'hui).
|
||||
- **Reprise auto des sessions au redémarrage** (terrain T5/T7 + `conversation_id` prêt mais
|
||||
non câblé : rien ne relance les agents `agent_was_running` à l'ouverture du projet).
|
||||
|
||||
## 6. Livrable attendu
|
||||
|
||||
Une cartographie d'architecture pour l'**orchestration v3** : ports & adapters, modèle de
|
||||
messages (requête/réponse corrélées), capacité runtime MCP, stratégie de repli, découpage en
|
||||
**lots** testables (méthode §3), et la liste des décisions tranchées avec leur justification.
|
||||
Mets à jour `ARCHITECTURE.md` (§14.3) en conséquence.
|
||||
215
.ideai/briefs/orchestration-v5-transport-bind-cadrage.md
Normal file
215
.ideai/briefs/orchestration-v5-transport-bind-cadrage.md
Normal file
@ -0,0 +1,215 @@
|
||||
# Orchestration v5 — Bind transport S-MCP + fix registre session (cadrage)
|
||||
|
||||
> **Agent Architecture.** Ce document tranche le **dernier kilomètre** de l'orchestration native : (1) le **bind transport** entre une CLI MCP réellement lancée et le serveur MCP par projet (verrou §S-MCP, resté ouvert depuis M3), et (2) le **fix de robustesse du registre de session** (mémoire `session-registry-agent-ambiguity`). Aucun code de production ici : décisions + contrats + découpage en lots testables.
|
||||
>
|
||||
> **État du terrain (lu, pas présumé)** :
|
||||
> - `infrastructure/src/orchestrator/mcp/{server,transport,jsonrpc,tools}.rs` : `McpServer::serve(&mut transport)` boucle ligne-à-ligne sur `Transport::{recv,send}` ; `StdioTransport<R,W>` (JSON Lines générique), `MemoryTransport` (tests). **`serve` n'est jamais appelé en prod.**
|
||||
> - `app-tauri/src/state.rs` : `ensure_mcp_server` crée un `McpServer` par projet et le **parke** sur un signal d'arrêt (`McpServerHandle::start` ⇒ `let _server = server; stop_rx.recv().await;`). **Aucun transport, aucun pair.**
|
||||
> - `application/src/agent/lifecycle.rs` : `apply_mcp_config` matérialise la conf MCP (`ConfigFile`/`Flag`/`Env`) dans le run dir isolé, **après** `apply_injection`, **avant** spawn/`factory.start`. `mcp_server_declaration` écrit un placeholder `{"command":"idea","args":["mcp-server"],"transport":"stdio|socket"}`.
|
||||
> - `application/src/terminal/registry.rs` : `TerminalSessions` (PTY) + `StructuredSessions` (IA) + agrégateur `LiveSessions`. Invariant **« 1 session vivante/agent »**.
|
||||
> - `application/src/error.rs` : `AppError::AgentAlreadyRunning { agent_id, node_id }` + code `AGENT_ALREADY_RUNNING` — **défini mais jamais levé** (le garde de `LaunchAgent` rebind/idempotent au lieu d'échouer).
|
||||
> - `app-tauri/src/commands.rs::list_live_agents` lit **seulement** `terminal_sessions.live_agents()` — **aveugle aux sessions structurées**.
|
||||
|
||||
---
|
||||
|
||||
## 0. Synthèse exécutive (décisions tranchées)
|
||||
|
||||
1. **Transport S-MCP = `stdio-spawn`** : la CLI **spawn elle-même** un process serveur MCP fourni par IdeA. Ce sous-process est un **client de boucle de retour** (loopback) vers le process Tauri, pas un second `OrchestratorService`. Justification : c'est le **seul** modèle réellement supporté par Claude Code / Codex (déclaration `.mcp.json` `{command,args}`), **cross-OS sans port réseau** (compatible AppImage / Windows / SSH-remote), et il **résout le point dur** « comment le process serveur retrouve le bon projet » par **injection d'identité à l'`args`/`env`** au `LaunchAgent` (le projet est connu à ce moment-là). Le socket est **rejeté** comme défaut (ports/permissions/cross-OS) mais **gardé en TODO** derrière le même trait `Transport`.
|
||||
|
||||
2. **Le binaire `idea mcp-server` est un sous-commande du binaire app-tauri existant** (pas un nouveau crate/binaire à distribuer séparément) : `main.rs` route `argv[1] == "mcp-server"` vers un mode **headless loopback** qui lit stdin/stdout (JSON Lines = `StdioTransport`) et **relaye** chaque message JSON-RPC au process IdeA principal via un **canal de loopback local** (Unix socket / Windows named pipe **par projet**, créé par `ensure_mcp_server`, chemin passé en `--endpoint`). Le `McpServer` (qui tient l'`OrchestratorService`) **vit dans le process Tauri** ; le sous-process `mcp-server` n'est qu'un **pont stdio↔loopback** ultraléger. Un seul binaire à livrer (AppImage/setup.exe).
|
||||
|
||||
3. **Contrat conf↔serveur de bout en bout** rendu **cohérent** : `apply_mcp_config` (M1) écrit une déclaration qui pointe **exactement** vers ce que `ensure_mcp_server` (M3) a mis à l'écoute — `command = <exe IdeA>`, `args = ["mcp-server", "--endpoint", <loopback du projet>, "--project", <id>]`. Fin du placeholder.
|
||||
|
||||
4. **Fix registre session = lot PRIORITAIRE et INDÉPENDANT du bind** (peut/doit partir en premier) : l'invariant correct est **« 1 session vivante par agent »** (décision produit verrouillée, mémoire `session-registry-agent-ambiguity` : un agent est un **singleton**, pas N instances). Le fix n'invente **pas** d'identité par cellule ; il **durcit l'invariant** sur les **deux** registres et **réconcilie les `layouts.json` à doublons**. Trois trous concrets à boucher (cf. §3).
|
||||
|
||||
5. **Robustesse `ask`** : cible morte ⇒ lancement structuré puis envoi ; cible PTY brut ⇒ `Invalid` explicite (déjà fait) ; **cible occupée par un autre tour** ⇒ sérialisation **FIFO par agent** (nouveau, §4) ; timeout 300 s ⇒ cible **vivante**, erreur typée (déjà fait). Deux `ask` simultanés sur la même cible ⇒ file, **jamais** d'entrelacement de tours.
|
||||
|
||||
6. **Frontières** : le pilotage de `serve` vit dans **l'adapter infra** (`McpServer` + une boucle **par connexion** sur le loopback du projet), supervisé par `McpServerHandle` (app-tauri) qui ne fait qu'**accepter les connexions** et spawn une tâche `serve` par pair. Aucune logique applicative ne fuit : le sous-process `mcp-server` ne connaît que des octets JSON-RPC ; `OrchestratorService` ignore tout du transport.
|
||||
|
||||
---
|
||||
|
||||
## 1. Décision 1 — Modèle de transport S-MCP
|
||||
|
||||
### 1.1 Le point dur, posé proprement
|
||||
|
||||
Une CLI MCP (Claude Code, Codex) attend une déclaration de serveur de forme **`{ "command": "...", "args": [...] }`** : à l'`initialize`, **elle spawn ce process** et parle **JSON-RPC sur son stdin/stdout**. Donc le « serveur » qu'elle voit est **un process enfant à elle**, distinct du process Tauri. C'est le cœur du problème : ce process enfant **n'a pas** l'`Arc<OrchestratorService>` du projet (use cases, registres de sessions, event bus vivent dans Tauri).
|
||||
|
||||
Deux familles de solutions :
|
||||
|
||||
| | **stdio-spawn (RETENU)** | socket (rejeté en défaut) |
|
||||
|---|---|---|
|
||||
| Ce que la CLI spawn | un **pont** `idea mcp-server` (stdio↔loopback) | rien — elle se connecte à un serveur déjà à l'écoute |
|
||||
| Où vit l'`OrchestratorService` | process Tauri (le pont relaie) | process Tauri (écoute directe) |
|
||||
| Cross-OS / AppImage | ✅ pipes stdio + loopback local (Unix socket / named pipe) | ⚠️ port TCP (firewall/permissions) ou socket — déclaration CLI variable |
|
||||
| Retrouver le bon projet | **`--endpoint`/`--project` à l'`args`**, fixés au `LaunchAgent` (projet connu) | l'adresse encode le projet, mais la CLI doit la connaître |
|
||||
| Compat Claude/Codex | ✅ `{command,args}` natif | ❓ support socket inégal selon CLI/version |
|
||||
| Cycle de vie | la CLI **possède** le pont (meurt avec elle) | serveur long-vécu, connexions multiplexées |
|
||||
|
||||
### 1.2 Pourquoi stdio-spawn, malgré le sous-process en plus
|
||||
|
||||
- **Compatibilité réelle** : `{command,args}` est le **dénominateur commun** des CLIs MCP. Le socket n'est pas universellement déclarable.
|
||||
- **Cross-OS sans port réseau** : le **loopback local** entre le pont et Tauri est un **Unix domain socket** (Linux/macOS) ou un **named pipe** (Windows) — déjà la techno la plus portable pour de l'IPC local, **sans firewall ni permission réseau**, donc **AppImage-safe** et **SSH-remote-safe** (le pont tourne côté machine de l'agent).
|
||||
- **Résolution du point dur par injection d'identité** : le projet est **connu** au moment du `LaunchAgent` (c'est lui qui écrit la conf MCP). On **encode** `--endpoint <chemin loopback du projet>` (+ `--project <id>` en garde-fou) dans les `args` de la déclaration. Le pont n'a **rien à deviner** : il se connecte à l'endpoint du **bon** projet. Le `McpServer` côté Tauri, lui, **est** déjà attaché à cet `OrchestratorService`/`Project` (créé par `ensure_mcp_server`).
|
||||
|
||||
### 1.3 Le pont `idea mcp-server` (sous-commande du binaire existant)
|
||||
|
||||
- **Pas de nouveau binaire distribué** : `main.rs` détecte `argv[1] == "mcp-server"` **avant** d'initialiser Tauri/WebKit, et bascule en **mode headless pont**. Un seul exécutable livré (AppImage / setup.exe).
|
||||
- **Rôle du pont** : `StdioTransport(stdin, stdout)` côté CLI ; un client de loopback côté Tauri. Boucle : lire une ligne JSON-RPC de la CLI → l'écrire sur le loopback → lire la réponse du loopback → l'écrire sur stdout. **Zéro logique métier** : c'est un tube transparent. (Optionnellement, le pont peut **directement** porter le `McpServer` si l'`OrchestratorService` était accessible — il ne l'est pas inter-process — d'où le relais.)
|
||||
- **Côté Tauri** : `ensure_mcp_server` crée **l'endpoint loopback du projet** (socket/pipe), et `McpServerHandle` **accepte** les connexions ; **chaque connexion** (= un pont = un agent) ⇒ une **tâche `McpServer::serve(&mut conn_transport)`** où `conn_transport` enveloppe le flux loopback. `McpServer` est **déjà** branché à l'`OrchestratorService` du projet.
|
||||
|
||||
### 1.4 Identité de l'appelant (lève le `requester_id = "mcp"` figé)
|
||||
|
||||
`server.rs::publish_processed` tague aujourd'hui `requester_id: "mcp"` (placeholder). Avec stdio-spawn, le pont **connaît l'agent** (le `LaunchAgent` peut injecter `--requester <agent-id>` dans les `args` de la déclaration, comme `--project`). Le pont passe cette identité dans la **poignée de connexion** (premier message de handshake loopback, hors JSON-RPC MCP), et `McpServer::serve` la porte dans son contexte de connexion ⇒ `OrchestratorRequestProcessed.requester_id` devient l'**agent réel**. Observabilité UI exacte (qui a délégué à qui).
|
||||
|
||||
### 1.5 Socket = TODO derrière le même trait
|
||||
|
||||
Le trait `Transport` (jsonrpc.rs) **isole** déjà le serveur du transport. Un `SocketTransport` (TCP/HTTP-stream) reste un **ajout sans toucher `McpServer`** si une CLI l'exige. Non requis pour Claude/Codex ⇒ **hors périmètre v5**, documenté.
|
||||
|
||||
---
|
||||
|
||||
## 2. Décision 2 — Contrat conf injectée (M1) ↔ serveur écouté (M3/v5)
|
||||
|
||||
Bout en bout, **un seul chemin** :
|
||||
|
||||
```
|
||||
profil.mcp = Some(McpCapability{ config: ConfigFile{".mcp.json"} | Flag{..} | Env{..}, transport })
|
||||
│ (LaunchAgent, run dir isolé .ideai/run/<agent>/ ; après apply_injection, avant spawn)
|
||||
▼
|
||||
apply_mcp_config écrit la DÉCLARATION RÉELLE (fin du placeholder) :
|
||||
{
|
||||
"mcpServers": {
|
||||
"idea": {
|
||||
"command": "<chemin absolu de l'exe IdeA>", ← std::env::current_exe()
|
||||
"args": ["mcp-server",
|
||||
"--endpoint", "<loopback du projet>", ← fourni par ensure_mcp_server
|
||||
"--project", "<project id>",
|
||||
"--requester","<agent id>"] ← identité (§1.4)
|
||||
}
|
||||
}
|
||||
}
|
||||
│ (la CLI lit .mcp.json à l'initialize)
|
||||
▼
|
||||
la CLI SPAWN <exe IdeA> mcp-server --endpoint … --project … --requester …
|
||||
│ (pont stdio↔loopback)
|
||||
▼
|
||||
le pont se connecte au LOOPBACK DU PROJET (créé par ensure_mcp_server)
|
||||
│ (handshake : project id + requester id)
|
||||
▼
|
||||
McpServerHandle ACCEPTE ⇒ tâche McpServer::serve(conn) [McpServer tient l'OrchestratorService du projet]
|
||||
│ tools/call → map_tool_call → OrchestratorCommand → OrchestratorService::dispatch(&project, cmd)
|
||||
▼
|
||||
réponse inline (idea_ask_agent ⇒ outcome.reply) renvoyée verbatim à la CLI
|
||||
```
|
||||
|
||||
**Invariant de cohérence à tester** : le **chemin de l'endpoint** et l'**exe** écrits par `apply_mcp_config` sont **exactement** ceux que `ensure_mcp_server` met à l'écoute pour ce projet. Source de vérité **unique** : une fonction (app-tauri) calcule l'endpoint d'un `ProjectId` ; M1 (qui écrit la conf) et M3/v5 (qui écoute) l'appellent tous deux. **Pas** de chaîne dupliquée.
|
||||
|
||||
**Le `transport` du profil** reste surfacé dans la déclaration pour la voie socket future, mais en stdio-spawn il est **implicite** (la CLI spawn = stdio). `McpConfigStrategy` inchangé : `ConfigFile` écrit le fichier, `Flag`/`Env` passent le **chemin de la conf** (run dir) — sémantique déjà en place, on ne fait que **remplir** la déclaration de vrai contenu.
|
||||
|
||||
---
|
||||
|
||||
## 3. Décision 3 — Fix du registre de session (lot PRIORITAIRE, indépendant)
|
||||
|
||||
### 3.1 Invariant correct (verrouillé)
|
||||
|
||||
**1 session vivante par agent** (un agent = singleton : un `.md`, une conversation, un run dir, une mémoire). On **ne** modélise **pas** une identité par cellule. La **cellule est une vue** rebindable (§17.6). `session_for_agent` est donc **déterministe par construction** — à condition que l'invariant soit **réellement enforced** et que les **deux registres** soient considérés. Aujourd'hui il y a **trois fuites** :
|
||||
|
||||
### 3.2 Trou A — le garde de `LaunchAgent` ne lève jamais `AgentAlreadyRunning`
|
||||
|
||||
`LaunchAgent::execute` (lifecycle.rs ~877-920) : si l'agent a déjà une session vivante (PTY **ou** structurée) et qu'un `node_id` est demandé, il **rebind silencieusement** ; sans node, il **rend la session existante** (idempotent). C'est juste pour **réattacher une vue**, mais cela **masque** un vrai second lancement (deux cellules distinctes voulant le **lancer** chacune). `AppError::AgentAlreadyRunning` est **défini mais jamais levé**.
|
||||
|
||||
**Décision** : distinguer **réattache de vue** (légitime, rebind) de **second lancement** (à refuser). Le signal de discrimination existe déjà dans le flux : un `LaunchAgentInput` issu d'une **réattache** (la cellule sait que l'agent tournait : `agent_was_running`/`conversation_id` présents) vs un **lancement neuf**. Le garde lève `AgentAlreadyRunning { agent_id, node_id_hôte }` quand un lancement **neuf** vise un agent **déjà vivant sur un autre node**, et **rebind** seulement quand le `node_id` demandé **est** le node hôte (ou réattache explicite). L'orchestrateur `spawn_agent` (`Visible{node_id}`) suit la **même** règle.
|
||||
|
||||
### 3.3 Trou B — `list_live_agents` est aveugle aux sessions structurées
|
||||
|
||||
`commands.rs::list_live_agents` ⇒ `state.terminal_sessions.live_agents()` **seulement**. Un agent **chat** (structuré) vivant n'apparaît **pas** ⇒ l'UI ne le désactive pas dans le dropdown ⇒ on peut tenter de le relancer ailleurs.
|
||||
|
||||
**Décision** : la commande lit l'**agrégateur** `LiveSessions::live_agents()` (PTY **+** structuré), déjà présent dans le registre. Un seul point de vérité de liveness pour l'UI.
|
||||
|
||||
### 3.4 Trou C — `layouts.json` à doublons (deux feuilles, même `agent`)
|
||||
|
||||
Les layouts persistés **contiennent déjà** des feuilles en double sur le même `agent` id (constaté). À l'ouverture, **ne pas auto-lancer la 2ᵉ** ; **réconcilier** : une seule feuille reste « hôte vivant », les autres sont des vues mortes (pas de relance, pas de bannière fraîche). C'est précisément ce qui causait le **symptôme** (« une cellule reset au retour d'onglet »).
|
||||
|
||||
**Décision** : étape de **réconciliation à l'ouverture du projet** (app-tauri, jumelle de `SnapshotRunningAgents`) : pour chaque agent apparaissant sur N feuilles, **garder une** hôte, **dé-flagger** `agent_was_running`/`conversation_id` sur les autres feuilles dupliquées. La reprise (B, §15.2) ne relance alors qu'**une** session/agent.
|
||||
|
||||
### 3.5 Pourquoi indépendant du bind
|
||||
|
||||
Aucun de ces trois trous ne touche MCP/transport : ils vivent dans `LaunchAgent`, `list_live_agents`, et l'ouverture de projet. Le fix **stabilise le routage de `ask`** (qui s'appuie sur `session_for_agent`) **avant** d'ouvrir la vanne MCP ⇒ on évite de débugger un `ask` mal routé **et** un transport neuf en même temps. **⇒ Lot R0, livré en premier.**
|
||||
|
||||
---
|
||||
|
||||
## 4. Décision 4 — Robustesse `ask` (sérialisation FIFO par agent)
|
||||
|
||||
Sémantique cible (au-dessus de l'existant) :
|
||||
|
||||
| Situation | Comportement |
|
||||
|---|---|
|
||||
| Cible inconnue | `AppError::NotFound` (fait) |
|
||||
| Cible morte | lancement structuré (background) puis envoi (fait) |
|
||||
| Cible vivante en **PTY brut** | `AppError::Invalid` explicite, jamais d'ACK trompeur (fait) |
|
||||
| Cible vivante structurée, **libre** | rendez-vous direct `send_blocking` (fait) |
|
||||
| Cible vivante structurée, **déjà en tour** (autre `ask`) | **file FIFO par agent** : le 2ᵉ `ask` attend son tour, **pas** d'entrelacement (NOUVEAU) |
|
||||
| Timeout 300 s | cible **vivante**, erreur typée `Timeout`, retry possible (fait) |
|
||||
|
||||
**Le seul vrai manque = la concurrence.** Deux `ask` simultanés sur la même cible appelleraient `send_blocking` **en parallèle** sur la **même** `AgentSession` ⇒ deux tours entrelacés sur un moteur qui pilote **une** conversation. Inacceptable (cf. bug accents = writes non sérialisés).
|
||||
|
||||
**Décision** : **sérialiser les tours par agent** dans `OrchestratorService::ask_agent` via un **verrou par `agent_id`** (un `Mutex`/sémaphore d'unité, registre `HashMap<AgentId, Arc<Mutex<()>>>` détenu par le service, ou porté par l'entrée de `StructuredSessions`). Un `ask` **acquiert** le verrou de l'agent avant `send_blocking`, le **relâche** après le `Final`/timeout. Les `ask` concurrents forment une **file naturelle** (ordre d'acquisition). Le timeout s'applique **au tour** (pas à l'attente du verrou) — ou un timeout global borné l'attente totale (décision : timeout **par tour** ; l'attente en file ne consomme pas le budget, mais un plafond d'attente évite l'inanition). Cohérent avec « 1 conversation déterministe/agent ».
|
||||
|
||||
**Erreurs typées remontées aux deux portes** : MCP ⇒ `tool_result_text(.., isError=true)` (déjà) ; fichier ⇒ `*.response.json` avec le champ d'erreur (déjà via `OrchestratorOutcome`/watcher). Le verrou n'ajoute pas de nouveau type d'erreur ; un timeout d'attente en file ⇒ `Timeout` (même type).
|
||||
|
||||
---
|
||||
|
||||
## 5. Décision 5 — Frontières hexagonales (où vit `serve`)
|
||||
|
||||
- **`McpServer::serve` = adapter infra**, piloté **par connexion** : `McpServerHandle` (app-tauri) **accepte** sur l'endpoint loopback du projet et, **par pair connecté** (= un agent), **spawn une tâche** `serve(conn)`. Le serveur reste **sans état de connexion** au-delà de l'identité du pair (portée par le contexte de connexion, §1.4).
|
||||
- **`McpServerHandle` évolue** : aujourd'hui il **parke** (`let _server; stop_rx.recv()`). Demain il **ouvre l'endpoint** + boucle d'`accept` ; à l'arrêt, ferme l'endpoint (les ponts enfants meurent avec leur CLI). **Toujours** non-bloquant pour open/close projet (l'`accept` est async, parqué sur l'absence de pair).
|
||||
- **Le sous-process `mcp-server`** vit dans `app-tauri` (route `main.rs`), mais ne connaît que **stdio + loopback + JSON brut** : **zéro** `OrchestratorService`, zéro use case. Frontière nette.
|
||||
- **Aucune fuite applicative dans l'infra** : `OrchestratorService::dispatch` est appelé **à l'identique** par les trois portes (fichier, MCP, UI). Le verrou par agent (§4) est une **règle applicative** ⇒ il vit dans `OrchestratorService` (ou le registre `StructuredSessions`), **pas** dans l'adapter MCP.
|
||||
|
||||
---
|
||||
|
||||
## 6. Découpage en LOTS testables (méthode §3)
|
||||
|
||||
> Ordre global : **R0 (fix registre) d'abord** — indépendant, débloque la robustesse de `ask`. Puis **bind transport M5x**. Front (M5-UI) optionnel en fin.
|
||||
|
||||
### Bloc R — Fix registre session (PRIORITAIRE, indépendant du transport)
|
||||
|
||||
| Lot | Côté | Périmètre | Critères de test |
|
||||
|---|---|---|---|
|
||||
| **R0a** | back (application) | Garde `LaunchAgent` : lever `AgentAlreadyRunning{agent_id,node_hôte}` pour un **lancement neuf** ciblant un agent déjà vivant sur **un autre node** (PTY **ou** structuré) ; **rebind** seulement si node demandé = node hôte / réattache. Même règle dans `OrchestratorService::spawn_agent`. | lancement neuf d'un agent vivant ailleurs ⇒ `AgentAlreadyRunning` (code `AGENT_ALREADY_RUNNING`) ; réattache même node ⇒ rebind sans respawn ; idempotence background inchangée ; `ask` d'une cible morte ⇒ lancement OK (pas de faux positif). |
|
||||
| **R0b** | back (app-tauri) | `list_live_agents` lit `LiveSessions::live_agents()` (PTY **+** structuré). | un agent **chat** vivant apparaît dans la liste ; un agent PTY aussi ; aucun doublon ; sans session ⇒ vide. |
|
||||
| **R0c** | back (app-tauri) | Réconciliation à l'**ouverture projet** : pour un agent sur N feuilles, garder **une** hôte, dé-flagger `agent_was_running`/`conversation_id` sur les autres. | layout à doublons ⇒ après ouverture, **une seule** feuille « était en cours » pour l'agent ; layout sans doublon **inchangé** ; persistance idempotente (2ᵉ ouverture = no-op). |
|
||||
| **R0d** | front | Dropdown agent du leaf : désactiver/"déjà placé" via la liste R0b (PTY+chat) ; gérer le retour `AGENT_ALREADY_RUNNING` (aller-à / déplacer). | Vitest : agent vivant ailleurs ⇒ option désactivée + action « aller à la cellule » ; erreur backend mappée à un message clair. |
|
||||
|
||||
### Bloc M5 — Bind transport S-MCP (stdio-spawn)
|
||||
|
||||
| Lot | Côté | Périmètre | Critères de test |
|
||||
|---|---|---|---|
|
||||
| **M5a** | back (app-tauri) | **Endpoint loopback par projet** : fonction unique `mcp_endpoint(project_id)` (Unix socket / Windows named pipe) ; `ensure_mcp_server` l'ouvre, `stop_orchestrator_watch` le ferme. | endpoint créé à l'open, supprimé au close ; idempotent (1/projet) ; chemin déterministe par `ProjectId` ; pas de collision inter-projets. |
|
||||
| **M5b** | back (app-tauri) | **Sous-commande `mcp-server`** dans `main.rs` (avant init Tauri) : pont `StdioTransport(stdin,stdout)` ↔ loopback (`--endpoint`), handshake (`--project`,`--requester`). | `argv mcp-server` ⇒ mode headless, ne lance pas la webview ; relaie une requête JSON-RPC ligne→loopback→réponse→stdout ; EOF stdin ⇒ sortie propre ; endpoint absent ⇒ erreur non-zéro, jamais de hang. |
|
||||
| **M5c** | back (infra/app-tauri) | **`McpServerHandle` accepte** sur l'endpoint et **spawn `McpServer::serve(conn)` par pair** ; identité du pair (requester) portée au contexte de connexion ⇒ `OrchestratorRequestProcessed.requester_id` = agent réel (fin du `"mcp"` figé). | une connexion ⇒ une tâche serve ; `initialize`/`tools/list`/`tools/call` OK de bout en bout via le loopback (test d'intégration local, hors réseau) ; `requester_id` = l'agent ; déconnexion d'un pair n'affecte pas les autres ; arrêt ferme l'endpoint + termine les serve. |
|
||||
| **M5d** | back (application) | **`apply_mcp_config` écrit la déclaration RÉELLE** (fin placeholder) : `command = current_exe`, `args = ["mcp-server","--endpoint",mcp_endpoint(project),"--project",id,"--requester",agent]`. `ConfigFile` non-clobbering ; `Flag`/`Env` portent le chemin de conf. **Source d'endpoint partagée avec M5a.** | `mcp=None` ⇒ aucune écriture (inchangé) ; `ConfigFile` ⇒ `.mcp.json` pointe l'exe + endpoint **exacts** du projet ; endpoint identique à `ensure_mcp_server` (test de cohérence M1↔M3) ; non-clobbering. |
|
||||
| **M5e** | back (intégration) | **Smoke end-to-end loopback** (sans CLI réelle) : un faux pont écrit `tools/call idea_list_agents`/`idea_ask_agent` sur le loopback d'un projet et reçoit la réponse inline du `dispatch` réel. | `idea_list_agents` ⇒ JSON des agents ; `idea_ask_agent` vers une cible structurée ⇒ `reply` inline ; cible PTY ⇒ erreur typée ; JSON-RPC malformé ⇒ erreur, jamais panic ; **hors réseau**. |
|
||||
|
||||
### Bloc A — Robustesse `ask` (concurrence)
|
||||
|
||||
| Lot | Côté | Périmètre | Critères de test |
|
||||
|---|---|---|---|
|
||||
| **A0** | back (application) | **Sérialisation FIFO par agent** dans `ask_agent` : verrou par `agent_id` autour de `send_blocking` ; timeout **par tour** ; plafond d'attente en file. | deux `ask` concurrents sur la même cible ⇒ tours **séquentiels** (pas d'entrelacement), ordre FIFO ; un `ask` sur agent A et un sur agent B ⇒ **parallèles** ; timeout d'un tour laisse la cible vivante et **libère** la file ; plafond d'attente ⇒ `Timeout` typé. |
|
||||
|
||||
### Optionnel
|
||||
|
||||
| Lot | Côté | Périmètre | Critères de test |
|
||||
|---|---|---|---|
|
||||
| **M5-UI** | front | Badge **source** (`mcp`/`file`) + **requester réel** sur une délégation (réutilise `OrchestratorRequestProcessed`). | badge source correct ; requester = agent réel (plus « mcp ») ; pas de régression sans event. |
|
||||
|
||||
**Ordre recommandé** : **R0a→R0b→R0c→R0d** (stabilise le routage), puis **A0** (concurrence `ask`, ne dépend pas du transport), puis **M5a→M5b→M5c→M5d→M5e** (bind), puis **M5-UI**. R0 et A0 sont livrables **sans** toucher MCP ; M5 ne doit partir qu'**après** R0 (sinon on débugge `ask` mal routé + transport neuf ensemble).
|
||||
|
||||
---
|
||||
|
||||
## 7. Conformité hexagonale & SOLID (rappel)
|
||||
|
||||
- **Règle de dépendance** : JSON-RPC, stdio, loopback socket/pipe, sous-process `mcp-server` = **infra/app-tauri exclusivement**. `domain`/`application` ignorent MCP et le transport. Le verrou FIFO par agent est une **règle applicative** (vit dans `OrchestratorService`/registre), pas dans l'adapter.
|
||||
- **S** : `McpServer` = traduire JSON-RPC → `dispatch` ; le **pont** = relayer des octets ; `McpServerHandle` = cycle de vie + `accept` ; le verrou = sérialiser les tours. Aucune responsabilité fourre-tout.
|
||||
- **O** : un `SocketTransport` futur = un impl de `Transport` en plus, **zéro** modif de `McpServer`. Une CLI MCP de plus = un profil avec `mcp = Some(..)`, zéro code.
|
||||
- **L** : les trois portes (fichier, MCP, UI) sont substituables derrière `OrchestratorService::dispatch` — **un** comportement applicatif.
|
||||
- **I** : `McpServerHandle` ne voit que « accepter + servir » ; `OrchestratorService` ne voit que `dispatch` + registres ; l'UI ne voit que `LiveSessions::live_agents`.
|
||||
35
.ideai/briefs/validation-reelle-inter-agents.md
Normal file
35
.ideai/briefs/validation-reelle-inter-agents.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Protocole — Validation réelle de la conversation inter-agents via IdeA
|
||||
|
||||
> À exécuter depuis la **nouvelle AppImage** (build 2026-06-10 18:23, contenant R0+A0+M5,
|
||||
> commits `37e7274` / `6ca519b` / `cf89b3b`). L'ancienne image (testée avant) ne contenait
|
||||
> pas ce code et a renvoyé l'erreur attendue « agent Ask pas pilotable en mode structuré ».
|
||||
|
||||
## But
|
||||
Prouver en conditions réelles qu'un agent (Main/Claude) peut **demander** une tâche à un autre
|
||||
agent (Ask/Codex) **via IdeA** et **recevoir sa réponse inline** — pas seulement par tests à fakes.
|
||||
|
||||
## Pré-requis pour que le `ask` aboutisse
|
||||
- La cible (**Ask**) doit être pilotée en **mode structuré** (profil Codex avec adapter structuré).
|
||||
Le menu de sélection d'agent ne propose normalement que des profils structurés (Claude/Codex).
|
||||
- Ask **ne doit pas** déjà tourner comme **terminal brut** (PTY) dans une cellule : sinon
|
||||
`ask_agent` refuse (invariant « 1 session/agent », cible PTY brut = pas de canal de réponse).
|
||||
- Le plus simple : **laisser Ask éteint** et laisser `ask_agent` le **lancer lui-même** en
|
||||
structuré (sémantique : cible morte ⇒ launch structuré background ⇒ send ⇒ Final).
|
||||
|
||||
## Procédure (protocole fichier, identique au test précédent)
|
||||
1. Déposer `.ideai/requests/main/<nom>.json` :
|
||||
```json
|
||||
{ "type": "agent.message", "requestedBy": "Main", "targetAgent": "Ask",
|
||||
"task": "Petite recherche, pas de code : résume en 3 points ce que fait le module
|
||||
crates/infrastructure/src/orchestrator/mcp/ et liste les outils idea_*." }
|
||||
```
|
||||
2. Attendre l'apparition de `.ideai/requests/main/<nom>.json.response.json`.
|
||||
|
||||
## Succès attendu
|
||||
`{ "ok": true, "action": "agent.message", "reply": "<réponse de Codex>" }` — le champ **`reply`**
|
||||
porte le contenu produit par Ask. (Échec précédent = `ok:false` + erreur PTY brut.)
|
||||
|
||||
## Voie native MCP (bonus)
|
||||
Le bind transport S-MCP (M5) est livré : un agent lancé avec un profil MCP voit les outils
|
||||
`idea_*` (dont `idea_ask_agent`) et le résultat revient inline. À valider quand un profil MCP
|
||||
est branché sur Claude/Codex. Voir `.ideai/briefs/orchestration-v5-transport-bind-cadrage.md`.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,9 @@
|
||||
---
|
||||
upTo: 88f405da-f9c9-44bb-96c9-7e1b0e5af557
|
||||
objective: Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?
|
||||
---
|
||||
**Objectif :** Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?
|
||||
|
||||
- **Prompt:** Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?
|
||||
- **Prompt:** Test de communication inter-agent. Merci de confirmer que tu reçois bien ce message en répondant via idea_reply avec : (1) un court message de confirmation, (2) la date/heure si tu y as accès, (3) le nom de ton AI Profile/contexte tel que tu le perçois. Ceci sert uniquement à valider le canal de communication entre agents IdeA.
|
||||
- **Response:** (1) Confirmation : message bien reçu, le canal de communication inter-agent IdeA fonctionne correctement. (2) Date : 2026-06-13 (je n'ai pas d'heure horodatée plus précise dans mon contexte). (3) AI Profile/contexte perçu : Claude Code (modèle Opus 4.8, claude-opus-4-8), opérant sur le project root /home/anthony/Documents/Projects/IdeA via un run dir isolé. Canal validé. ✅
|
||||
@ -0,0 +1,3 @@
|
||||
{"id":"20dd8bdc-f858-46a6-9c8a-6afde591a6bd","conversation":"6ffc1f69-77a5-0da1-1965-ef26d1df72f5","atMs":1781367769591,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Bonjour TestConversation. Ceci est un test de communication inter-agents via IdeA. Peux-tu confirmer que tu me reçois et me dire combien font 17 + 25 ?"}
|
||||
{"id":"0b8fcc51-c692-4928-9d90-3823857dad5c","conversation":"6ffc1f69-77a5-0da1-1965-ef26d1df72f5","atMs":1781368607174,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de communication inter-agent. Merci de confirmer que tu reçois bien ce message en répondant via idea_reply avec : (1) un court message de confirmation, (2) la date/heure si tu y as accès, (3) le nom de ton AI Profile/contexte tel que tu le perçois. Ceci sert uniquement à valider le canal de communication entre agents IdeA."}
|
||||
{"id":"88f405da-f9c9-44bb-96c9-7e1b0e5af557","conversation":"6ffc1f69-77a5-0da1-1965-ef26d1df72f5","atMs":1781368615582,"source":{"kind":"agent","agentId":"c932c770-cf36-4fb2-a966-71bb1644e4b4"},"role":"response","text":"(1) Confirmation : message bien reçu, le canal de communication inter-agent IdeA fonctionne correctement. (2) Date : 2026-06-13 (je n'ai pas d'heure horodatée plus précise dans mon contexte). (3) AI Profile/contexte perçu : Claude Code (modèle Opus 4.8, claude-opus-4-8), opérant sur le project root /home/anthony/Documents/Projects/IdeA via un run dir isolé. Canal validé. ✅"}
|
||||
@ -1,24 +1,45 @@
|
||||
{
|
||||
"version": 1,
|
||||
"activeId": "9188db80-8535-4786-a20b-3c9a36b222e3",
|
||||
"activeId": "1af250f0-65ef-4b78-8905-b1746673aee0",
|
||||
"layouts": [
|
||||
{
|
||||
"id": "9188db80-8535-4786-a20b-3c9a36b222e3",
|
||||
"id": "1af250f0-65ef-4b78-8905-b1746673aee0",
|
||||
"name": "Default",
|
||||
"kind": "terminal",
|
||||
"tree": {
|
||||
"root": {
|
||||
"type": "split",
|
||||
"node": {
|
||||
"id": "8aca2f93-1a9b-4693-9bba-9a01e130a48c",
|
||||
"id": "56ffe1e4-636c-458d-9ab2-7e278fd45897",
|
||||
"direction": "row",
|
||||
"children": [
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "d8a86eb1-cd4d-4937-b900-4989da7c868d",
|
||||
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641"
|
||||
"id": "c3319a9a-1345-4fa2-b64e-5f3fe00d13d8",
|
||||
"session": "4965c71a-f69f-4c06-90de-ecb81acff710",
|
||||
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"type": "split",
|
||||
"node": {
|
||||
"id": "8cf1c06e-2654-45a3-bf17-a9c2507da935",
|
||||
"direction": "column",
|
||||
"children": [
|
||||
{
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "69dc2e23-86f5-4770-84c4-b1b4b2c25299",
|
||||
"session": "11fbf6b4-eb62-4420-95e9-feb2ff667c43",
|
||||
"agent": "c932c770-cf36-4fb2-a966-71bb1644e4b4",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
@ -27,7 +48,15 @@
|
||||
"node": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "6c5be5e7-a54b-468c-a2e2-8ec853629d5e"
|
||||
"id": "b9251e74-3bd5-43ee-90e5-e6bb87faab38",
|
||||
"session": "69a3bf52-05ef-45c0-badf-26b0d8224f0e",
|
||||
"agent": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
|
||||
"agentWasRunning": true
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"weight": 1.0
|
||||
@ -36,19 +65,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "40ea4fa9-25b3-410b-9d3e-6350834b421b",
|
||||
"name": "Git Graph",
|
||||
"kind": "gitGraph",
|
||||
"tree": {
|
||||
"root": {
|
||||
"type": "leaf",
|
||||
"node": {
|
||||
"id": "c840bfdd-3330-46a3-b727-0799f6853e72"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
6
.ideai/memory/MEMORY.md
Normal file
6
.ideai/memory/MEMORY.md
Normal file
@ -0,0 +1,6 @@
|
||||
# Memory Index
|
||||
|
||||
- [agent-context-memory-and-profile-handoff](agent-context-memory-and-profile-handoff.md) — Decisions sur l'injection de contexte, la memoire durable, l'etat live et le handoff de profil entre agents IA.
|
||||
- [idea-product-directives-main-handoff](idea-product-directives-main-handoff.md) — Directives produit consolidees pour guider Main sur la robustesse, la persistance, le handoff cross-profile et la sobriete UX.
|
||||
- [remaining-work-idea-agent-control-ide](remaining-work-idea-agent-control-ide.md) — Etat des lieux des acquis et des chantiers restants pour aligner IdeA avec la cible d'IDE de controle d'agents IA.
|
||||
- [mcp-bridge-and-delegation-runtime-notes](mcp-bridge-and-delegation-runtime-notes.md) — Pieges runtime du pont MCP/delegation et regle de rebuild de l'AppImage (binaire qui tourne = AppImage, pas les sources).
|
||||
106
.ideai/memory/agent-context-memory-and-profile-handoff.md
Normal file
106
.ideai/memory/agent-context-memory-and-profile-handoff.md
Normal file
@ -0,0 +1,106 @@
|
||||
---
|
||||
name: agent-context-memory-and-profile-handoff
|
||||
description: Decisions sur l'injection de contexte, la memoire durable, l'etat live et le handoff de profil entre agents IA.
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
# Agent Context, Memory, and Profile Handoff
|
||||
|
||||
## Resume
|
||||
|
||||
This note captures the current product direction for IdeA around agent context injection, project memory, live state, and profile handoff between AI providers.
|
||||
|
||||
See also:
|
||||
|
||||
- `idea-product-directives-main-handoff` for product priorities and UX constraints.
|
||||
- `remaining-work-idea-agent-control-ide` for the current implementation status and remaining work.
|
||||
|
||||
## Context Injection
|
||||
|
||||
- Agent context must be injected by IdeA at launch time; the model should not be expected to discover `AGENTS.md` or `CLAUDE.md` by itself.
|
||||
- The existing `contextInjection` architecture is the right mechanism, especially `conventionFile` for providers that support a conventional file in the run directory.
|
||||
- The current implementation is strongest for `conventionFile`; `flag`, `stdin`, and `env` do not yet receive the same fully-composed IdeA context.
|
||||
- The `flag` strategy appears fragile with the isolated run directory model because the relative path passed to the CLI may not resolve from the run directory.
|
||||
|
||||
## Shared Project Context
|
||||
|
||||
- `.ideai/CONTEXT.md` is intended as shared project context.
|
||||
- It is not created automatically; it only exists if something writes it.
|
||||
- It should carry active project constraints and contribution rules.
|
||||
- Example content for `CONTEXT.md`: architectural constraints, workflow rules, and operating conventions that every agent must apply immediately.
|
||||
|
||||
## Durable Memory
|
||||
|
||||
- `.ideai/memory/` is intended as durable, project-scoped memory shared by all agents of the same project.
|
||||
- It is not created automatically; it only exists once at least one memory note is saved.
|
||||
- The durable memory is a knowledge base, not a live activity log.
|
||||
- It should contain stabilised knowledge such as:
|
||||
- architecture decisions
|
||||
- feature summaries to implement later
|
||||
- user preferences that persist across sessions
|
||||
- important project facts and references
|
||||
- Durable memory should stay curated and low-noise.
|
||||
|
||||
## Live State Versus Durable Memory
|
||||
|
||||
- Durable memory should not be used as a shared real-time state feed for all agents.
|
||||
- IdeA should distinguish between:
|
||||
- global context (`.ideai/CONTEXT.md`)
|
||||
- durable memory (`.ideai/memory/`)
|
||||
- live operational state (separate store)
|
||||
- handoff summaries between sessions or profiles
|
||||
- Real-time work tracking, who-is-doing-what, and transient status should live in a dedicated state mechanism, not in durable memory.
|
||||
|
||||
## Memory Consumption by Agents
|
||||
|
||||
- The current launcher reads shared project context from `.ideai/CONTEXT.md` if present.
|
||||
- It also recalls project memory via `MemoryRecall` and injects a `# Memoire projet` section into the convention file.
|
||||
- This injection currently happens only for `conventionFile` profiles.
|
||||
- The recalled memory is shared at the project level, but each agent may receive a different subset depending on its persona and recall query.
|
||||
- Memory recall is computed at launch time and written into the generated context file.
|
||||
- There is currently no automatic live refresh when `.ideai/memory/` changes during an active session.
|
||||
|
||||
## Recommendation for Live Memory Refresh
|
||||
|
||||
- Automatic memory refresh could be useful, but it should be explicit and controlled.
|
||||
- If IdeA wants agents to benefit from memory changes while they are active, it should regenerate their effective context when needed instead of treating durable memory as a constantly streaming log.
|
||||
- For PTY agents, no automatic reread exists today.
|
||||
- For structured Claude/Codex sessions, each turn relaunches the CLI, but the generated convention file is not automatically rewritten when durable memory changes.
|
||||
|
||||
## Profile Handoff and Session Continuity
|
||||
|
||||
- A direct native session transfer from Claude to Codex is not the right mental model.
|
||||
- The correct model is continuity of work state, not native provider-session continuity.
|
||||
- IdeA should persist:
|
||||
- a canonical conversation log
|
||||
- a cumulative handoff summary
|
||||
- the agent state
|
||||
- the provider conversation id when useful
|
||||
- On provider swap, IdeA should launch the new profile with:
|
||||
- regenerated project context
|
||||
- regenerated durable memory recall
|
||||
- the current agent persona
|
||||
- a handoff summary plus recent transcript
|
||||
- The handoff summary should not be created only at the moment of swap.
|
||||
- IdeA should maintain summaries incrementally or at checkpoints so that a swap is still possible when the current provider is near a token or session limit.
|
||||
|
||||
## Agent Ability To Write Durable Memory
|
||||
|
||||
- Agents should be allowed to promote important knowledge into durable memory.
|
||||
- This should not rely on the agent guessing the capability.
|
||||
- IdeA should expose the capability explicitly through tools or commands and should inject a clear rule explaining when an agent may save durable knowledge.
|
||||
- This write ability should be constrained to stable, high-value information, not transient state.
|
||||
|
||||
## Practical Classification Rule
|
||||
|
||||
- Put immediate project rules and operating constraints in `.ideai/CONTEXT.md`.
|
||||
- Put stable, reusable project knowledge in `.ideai/memory/`.
|
||||
- Put current activity and coordination state in a separate live-state mechanism.
|
||||
- Put cross-session or cross-profile recovery material in a handoff/session layer.
|
||||
|
||||
## Current Product Direction
|
||||
|
||||
- Keep `CONTEXT.md` for project rules.
|
||||
- Keep `.ideai/memory/` for curated durable knowledge.
|
||||
- Introduce a separate live-state mechanism if agents must stay aligned on in-progress work.
|
||||
- Introduce persistent conversation logs and incremental handoff summaries to support profile swaps such as Claude to Codex.
|
||||
206
.ideai/memory/idea-product-directives-main-handoff.md
Normal file
206
.ideai/memory/idea-product-directives-main-handoff.md
Normal file
@ -0,0 +1,206 @@
|
||||
---
|
||||
name: idea-product-directives-main-handoff
|
||||
description: Directives produit consolidees pour guider Main sur la robustesse, la persistance, le handoff cross-profile et la sobriete UX.
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
# IdeA Product Directives For Main
|
||||
|
||||
## Resume
|
||||
|
||||
Cette note consolide les arbitrages produit explicites donnes par l'utilisateur pour aider `Main` a poursuivre le projet sans ambiguite.
|
||||
|
||||
See also:
|
||||
|
||||
- `agent-context-memory-and-profile-handoff` for the structural model of context, durable memory, live state, and handoff.
|
||||
- `remaining-work-idea-agent-control-ide` for the current implementation status and remaining work.
|
||||
|
||||
Elle ne remplace pas les notes techniques existantes. Elle sert de reference prioritaire sur:
|
||||
|
||||
- la robustesse attendue,
|
||||
- la persistance et la reprise,
|
||||
- le handoff entre profils IA,
|
||||
- la memoire projet partagee,
|
||||
- le live state projet,
|
||||
- la sobriete UX.
|
||||
|
||||
## Priorite Absolue
|
||||
|
||||
La priorite produit numero un est la robustesse.
|
||||
|
||||
Ordre de priorite impose:
|
||||
|
||||
1. robustesse et solidite avant tout
|
||||
2. persistance et reprise
|
||||
3. handoff cross-profile
|
||||
4. live state projet
|
||||
5. reste des features et du polish
|
||||
|
||||
Regle de pilotage:
|
||||
|
||||
- un systeme incomplet mais solide vaut mieux qu'un systeme riche mais fragile
|
||||
- `Main` doit privilegier les architectures et comportements qui reduisent les crashes, les incoherences d'etat et les flows difficiles a reprendre
|
||||
|
||||
## Reprise Et Persistance
|
||||
|
||||
Quand IdeA redemarre, l'objectif n'est pas seulement de rouvrir une UI ou de restaurer des handles techniques.
|
||||
|
||||
La cible produit est:
|
||||
|
||||
- qu'un agent sache compactement sur quoi il travaillait
|
||||
- qu'IdeA fournisse ce materiel de reprise automatiquement
|
||||
- que la reprise soit exploitable meme si la conversation visible precedente n'est pas restauree a l'identique
|
||||
|
||||
Le bon modele est:
|
||||
|
||||
- un log canonique IdeA comme source durable
|
||||
- un resume/handoff genere par IdeA comme couche compacte de reprise
|
||||
|
||||
Le resume/handoff n'est pas un confort secondaire. Il fait partie du comportement normal du produit.
|
||||
|
||||
## Handoff Cross-Profile
|
||||
|
||||
La cible ideale est double:
|
||||
|
||||
- reprendre correctement le travail
|
||||
- donner si possible une impression de continuite presque sans rupture
|
||||
|
||||
Mais en cas de compromis, la priorite doit etre:
|
||||
|
||||
- fidelite operationnelle du travail repris
|
||||
- avant la parfaite illusion de continuite terminale ou conversationnelle
|
||||
|
||||
Autrement dit:
|
||||
|
||||
- si un agent passe de Claude a Codex, IdeA doit d'abord garantir que Codex puisse reprendre le plus fidelement possible le travail utile
|
||||
- l'absence de restauration parfaite de l'ancien terminal est acceptable si le handoff reste bon
|
||||
|
||||
## Perimetre Profils
|
||||
|
||||
Le perimetre de reference immediat est:
|
||||
|
||||
- Claude
|
||||
- Codex
|
||||
|
||||
Toute fonctionnalite importante doit etre faisable pour ces deux profils.
|
||||
|
||||
Directive associée:
|
||||
|
||||
- reduire au maximum les dependances a des commandes, flags ou comportements specifiques a un profil
|
||||
- construire un noyau le plus generique possible tout en restant concretement compatible Claude/Codex
|
||||
- les autres profils pourront etre ajoutes plus tard si possible, mais ne doivent pas detourner le coeur du chantier actuel
|
||||
|
||||
## Memoire Projet Partagee
|
||||
|
||||
La memoire projet partagee doit rester petite, stable et utile.
|
||||
|
||||
Elle ne doit pas devenir un gros bloc qui siphonne les tokens de l'utilisateur a chaque requete.
|
||||
|
||||
Ce qu'un agent peut ecrire automatiquement dans la memoire partagee si c'est stable et utile:
|
||||
|
||||
- decisions durables d'architecture ou d'organisation
|
||||
- preferences utilisateur durables
|
||||
- regles de workflow durables
|
||||
- references importantes a conserver
|
||||
- resumes de handoff utiles a la reprise inter-session ou inter-profil
|
||||
|
||||
Ce qu'un agent ne doit pas y ecrire automatiquement:
|
||||
|
||||
- conversations brutes
|
||||
- journaux detailles de travail
|
||||
- etats temporaires
|
||||
- files d'attente
|
||||
- coordination temps reel
|
||||
- essais/erreurs locaux
|
||||
- hypotheses non stabilisees
|
||||
- contenu redondant ou reconstructible ailleurs
|
||||
|
||||
Principe de fond:
|
||||
|
||||
- memoire durable = savoir stable
|
||||
- log canonique = historique
|
||||
- handoff = reprise compacte
|
||||
- live state = coordination vivante
|
||||
|
||||
Ces couches doivent rester separees.
|
||||
|
||||
## Live State Projet
|
||||
|
||||
Le live state projet partage doit exister comme mecanisme interne d'IdeA.
|
||||
|
||||
Contraintes produit:
|
||||
|
||||
- il doit rester invisible pour l'utilisateur
|
||||
- il doit survivre au redemarrage d'IdeA
|
||||
|
||||
Il ne doit pas se transformer en UI verbeuse ni en mecanisme demandant une intervention explicite de l'utilisateur.
|
||||
|
||||
## UX Et Philosophie Produit
|
||||
|
||||
IdeA doit etre tres facile d'utilisation.
|
||||
|
||||
Objectif UX:
|
||||
|
||||
- plug and play
|
||||
- pas de sensation de parametrage impose
|
||||
- pas de surcharge de tuto au premier lancement
|
||||
- pas d'impression que le produit force des comportements internes a l'utilisateur
|
||||
|
||||
Ligne directrice souhaitee:
|
||||
|
||||
- esprit "maniere Linux"
|
||||
- comportement simple et utile par defaut
|
||||
- pas de contrainte tant qu'il n'y a pas un vrai besoin
|
||||
- suggestion discrete seulement si IdeA detecte qu'une aide ou une optimisation devient utile
|
||||
|
||||
Le precedent du compactage de contexte est considere comme la bonne direction:
|
||||
|
||||
- pas de compactage impose d'emblee
|
||||
- une popup proposee seulement si IdeA sent un besoin
|
||||
|
||||
## Transparence Des Mecanismes Internes
|
||||
|
||||
Les mecanismes suivants doivent rester quasi invisibles pour l'utilisateur:
|
||||
|
||||
- delegations inter-agents
|
||||
- FIFO
|
||||
- handoffs
|
||||
|
||||
Ils peuvent devenir visibles en debug ou quand le produit a une bonne raison UX de les exposer, mais ils ne doivent pas etre ressentis comme une charge cognitive normale d'utilisation.
|
||||
|
||||
## Frontiere Avec Le Chantier Inter-Agents De Main
|
||||
|
||||
Les choix fins touchant la communication entre agents ne doivent pas etre recadres ici si `Main` est deja en train de les traiter.
|
||||
|
||||
Cette note ne doit donc pas etre lue comme une specification d'implementation inter-agents detaillee.
|
||||
|
||||
Elle fixe seulement les invariants produit suivants:
|
||||
|
||||
- robustesse avant richesse fonctionnelle
|
||||
- reprise automatique par IdeA
|
||||
- log canonique + handoff genere par IdeA
|
||||
- support de reference pour Claude et Codex
|
||||
- memoire durable compacte et curatee
|
||||
- live state interne et persistant
|
||||
- UX discrete, simple et peu intrusive
|
||||
|
||||
## Directive Finale Pour Main
|
||||
|
||||
Si un arbitrage technique oppose:
|
||||
|
||||
- elegance theorique
|
||||
- ou livraison rapide
|
||||
|
||||
contre:
|
||||
|
||||
- robustesse
|
||||
- reprise fiable
|
||||
- sobriete UX
|
||||
|
||||
alors `Main` doit privilegier:
|
||||
|
||||
- robustesse
|
||||
- reprise fiable
|
||||
- sobriete UX
|
||||
|
||||
avant le reste.
|
||||
51
.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md
Normal file
51
.ideai/memory/mcp-bridge-and-delegation-runtime-notes.md
Normal file
@ -0,0 +1,51 @@
|
||||
---
|
||||
name: mcp-bridge-and-delegation-runtime-notes
|
||||
description: Pieges runtime du pont MCP et de la delegation inter-agents IdeA, et la regle de rebuild de l'AppImage.
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
# Pont MCP & delegation : pieges runtime
|
||||
|
||||
Deux bugs trouves le 2026-06-13 en testant la conversation inter-agents (`idea_ask_agent` vers TestConversation), tous deux corriges. Notes utiles pour ne pas reperdre du temps :
|
||||
|
||||
## Le binaire qui tourne = AppImage installee, pas les sources
|
||||
L'IdeA en cours d'utilisation est `/home/anthony/Documents/IdeA_0.1.0_amd64.AppImage`. **Ce meme binaire sert a la fois de serveur orchestrateur (il tient `OrchestratorService`/`InputMediator` et compose les evenements) ET de binaire-pont** (`<exe> mcp-server …` declare dans chaque `.ideai/run/<id>/.mcp.json`). Donc **tout correctif cote serveur ou cote pont n'est actif dans l'app que apres rebuild + reinstall de l'AppImage et relance d'IdeA**. Un binaire `target/debug` fraichement compile ne valide que le pont (qui parle au serveur via le socket) ; il ne valide pas la composition cote serveur.
|
||||
**Comment appliquer :** apres une correction backend, rebuild AppImage (`npm --prefix frontend run build` puis, depuis `crates/app-tauri/`, `../../frontend/node_modules/.bin/tauri build --bundles appimage`), remplacer l'AppImage, relancer IdeA, puis retester. **Exclure NSIS** (`--bundles appimage`) sur Linux. **Piege FUSE (2026-06-14)** : l'etape finale `linuxdeploy` echoue avec `failed to run linuxdeploy` (linuxdeploy est une AppImage qui se monte via FUSE). Workaround obligatoire : prefixer `APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1`. Le compile Rust reussit avant ce point ; seul le bundling casse, et l'`AppDir` est genere mais pas le `.AppImage`. L'artefact final = `target/release/bundle/appimage/IdeA_0.1.0_amd64.AppImage`.
|
||||
|
||||
## Env AppImage pollue le shell
|
||||
La session shell herite des variables de l'AppImage montee (`APPDIR`, `LD_LIBRARY_PATH`, `PYTHONHOME` -> `/tmp/.mount_IdeA_*`). Consequences : `python3` casse (`No module named 'encodings'`) et lancer un binaire app-tauri fraichement compile tente de booter WebKit et crash. **Workaround :** lancer avec un env propre (`env -i PATH=/usr/bin:/bin HOME=$HOME XDG_RUNTIME_DIR=/run/user/1000 ...`), utiliser `jq` plutot que python.
|
||||
|
||||
## Bug 1 — pont MCP en lockstep (corrige)
|
||||
`mcp_bridge.rs::relay` lisait 1 ligne client -> attendait 1 reponse loopback, en boucle. MCP n'est pas 1:1 : `notifications/initialized` n'a pas de reponse => deadlock juste apres `initialize`, et `tools/list` n'etait jamais relaye => les outils `idea_*` ne se chargeaient jamais (`claude mcp list` affichait quand meme "Connected" car `initialize` repond). Corrige en relay **full-duplex** (deux pompes concurrentes) + drain borne (`DRAIN_GRACE`) a la fermeture stdin. Symptome cote utilisateur : 3 jours de "outils MCP pas charges".
|
||||
|
||||
## Bug 2 — prefixe de delegation perdu (corrige)
|
||||
Le signal `[IdeA · tâche de <demandeur> · ticket <id>]` qui dit a l'agent cible "reponds via `idea_reply`, jamais en texte" n'etait plus compose nulle part : supprime du backend (`service.rs` C3 §5.1) lors du passage de l'ecriture PTY au frontend, mais le frontend (`useWritePortal.ts`) ecrit `head.text` verbatim et ne l'ajoutait pas. La cible recevait la tache brute, repondait en texte => `idea_ask_agent` timeout. Corrige en composant le prefixe dans `infrastructure/src/input/mod.rs` (`delegation_preamble`) a l'emission de `DomainEvent::DelegationReady` ; la tache brute reste dans le `Ticket`/historique. Rappel : la correlation `idea_reply` marche par ticket OU par tete de FIFO (fallback), donc le ticket echo est recommande mais pas strictement requis.
|
||||
|
||||
## Bug 3 — cold-launch race : 1er tour perdu (corrige 2026-06-14)
|
||||
Deleguer a un agent **froid** (pas encore lance) via `idea_ask_agent` echouait silencieusement : terminal cible vide, ask bloque jusqu'au timeout 300s ; un agent **chaud** (deja a son prompt, lance manuellement) marchait. Cause : avec `with_structured` non cable sur l'orchestrateur (regression assumee `aa2f67a`), tous les `ask` passent par le chemin PTY `ensure_live_pty` (`application/src/orchestrator/service.rs`). Un agent jamais vu etait initialise `Idle` (`BusyTracker::start_turn` -> `or_insert(Idle)`, `infrastructure/src/input/mod.rs`), donc le 1er `enqueue` publiait `DelegationReady` **immediatement** et ecrivait la tache dans le PTY avant que le CLI ait affiche son prompt -> tache perdue. Le prompt-ready watcher (lot C5) ne gardait que les tours suivants. Fix : `ensure_live_pty` renvoie un flag `cold_launch` ; si cold ET `prompt_ready_pattern` non vide, l'orchestrateur appelle `mark_starting(agent)` -> la `DelegationReady` du 1er tour est **differee** puis publiee par le watcher a l'apparition du prompt. Sans pattern ou agent chaud -> livraison immediate (zero regression). Touche `domain/src/input.rs` (port `mark_starting`), `infrastructure/src/input/mod.rs`, `service.rs`.
|
||||
|
||||
## Bug 4 — le fix cold-launch ne s'armait jamais en prod (corrige 2026-06-15)
|
||||
Le « fix Bug 3 corrige 2026-06-14 » etait trop optimiste : il ne s'arme que si `gate_cold_start = cold_launch && prompt_ready_pattern non vide` (`service.rs`). Or le profil **Claude Code** (`664cc20c`, partage par TOUS les agents) dans `~/.local/share/app.idea.ide/profiles.json` n'a **aucun** `prompt_ready_pattern`. Donc `mark_starting` n'etait jamais appele -> `enqueue` publiait `DelegationReady` immediatement -> tache ecrite dans le PTY avant le prompt de `claude` -> 1er tour perdu -> `idea_ask_agent` vers un agent **froid** bloque jusqu'au timeout (un agent **chaud** marche). Le Bug 3 n'etait valide que par des tests unitaires qui injectent un pattern a la main : le gap d'integration (profil sans pattern) n'avait pas ete vu.
|
||||
**Fix (option B, signal MCP, sans sniff de prompt TUI) :** on gate le cold-launch des qu'un MCP est configure sur le profil (`gate_cold_start = cold_launch && (pattern non vide || profil.mcp.is_some())`), et on **libere** le tour differe quand le pont MCP de l'agent froid se connecte (= son CLI est up + outils charges) : nouveau port `InputMediator::release_cold_start(agent)` (drain du `deferred`, **sans** `mark_idle` car c'est un signal de DEMARRAGE, idempotent et OR-safe avec `prompt_ready`), `McpServer` fire un `ready_sink: Fn(&str)` sur `initialize` avec le `requester` du handshake, et la composition root (`state.rs::ensure_mcp_server`) parse le requester en `AgentId` -> `OrchestratorService::release_agent_cold_start`. Touche `domain/src/input.rs`, `infrastructure/src/input/mod.rs`, `infrastructure/src/orchestrator/mcp/server.rs`, `application/src/orchestrator/service.rs`, `app-tauri/src/state.rs`. Tests unitaires verts ; **validation live = rebuild AppImage + relance IdeA** (cf. section binaire qui tourne). Filet OR : si un jour un profil porte un `prompt_ready_pattern`, les deux signaux coexistent.
|
||||
**Methodo :** ne PAS deleguer la reparation du systeme inter-agents via le systeme inter-agents (casse) — utiliser les subagents natifs (outil Agent).
|
||||
|
||||
## Bug 5 — la boucle `serve` du serveur MCP est en lockstep, un `ask` sans reponse wedge TOUTE la connexion (corrige 2026-06-15)
|
||||
Symptome : 1er `idea_ask_agent` vers un agent qui repond -> OK ; puis `ask` vers un agent qui ne rappelle JAMAIS `idea_reply` (ex. QA lance mais qui ne repond pas) -> ensuite **tout** appel suivant du MEME demandeur (meme `idea_list_agents`, sans rendezvous) se bloque. Cote utilisateur : "DevBackend ne marche plus" alors qu'il repondait 11s avant — en realite c'est la connexion du DEMANDEUR (Main) qui est morte, pas la cible. Annuler l'appel cote client ne deparke rien.
|
||||
Cause : `infrastructure/src/orchestrator/mcp/server.rs::serve` lisait 1 requete puis **awaitait `handle_raw` en entier** avant de relire. Pour `idea_ask_agent`, `handle_raw -> dispatch -> service.dispatch` attend le `idea_reply` de la cible : pendant cette attente la boucle ne relit plus rien -> pipeline de la connexion fige. C'est l'analogue COTE SERVEUR du Bug 1 (lockstep) qui n'avait ete corrige que cote pont (`mcp_bridge.rs`). NB : la couche application bornait deja le rendezvous (`service.rs::ask_agent` via `tokio::time::timeout`), donc l'attente n'etait pas infinie — le vrai coupable etait bien la serialisation de `serve`, pas l'absence de timeout.
|
||||
Fix : `serve` reecrite full-duplex non bloquante — `tokio::select!` entre `transport.recv()` et un canal `mpsc::unbounded::<Option<Vec<u8>>>` ; chaque message entrant traite dans une tache `tokio::spawn` qui possede un clone cheap `self.for_requester(self.requester.clone())` ('static) + un clone du `tx` ; reponses (`Some`) ou notifications (`None`) renvoyees par le canal et ecrites par la meme boucle ; arret gracieux via compteur `in_flight` (on draine les taches en vol apres EOF). Les reponses MCP portent l'`id` -> ordre indifferent. Le trait `Transport` (`&mut self` recv/send) et les signatures `serve`/`serve_as` restent intacts. + filet de securite : timeout serveur **isole au seul `idea_ask_agent`** (`ASK_RENDEZVOUS_TIMEOUT` 24h, finie ; setter `with_ask_rendezvous_timeout` `#[doc(hidden)]` pour les tests). Tests : `infrastructure/tests/mcp_server.rs` -> `pending_ask_does_not_wedge_the_connection_concurrent_call_still_answered` (anti-wedge, echoue sur l'ancien code) + `ask_agent_rendezvous_times_out_with_a_jsonrpc_error`. Verts : `cargo test -p infrastructure` (20 mcp_server), `cargo test -p app-tauri --lib mcp_e2e_loopback_tests` (6). **Validation live = rebuild AppImage + relance IdeA** (la connexion wedgee ne se deparke pas de l'interieur : il FAUT relancer IdeA). AppImage du 2026-06-15 10:58 contient le fix ; backup `~/Documents/IdeA_0.1.0_amd64.AppImage.old-prewedgefix`.
|
||||
Reste a investiguer (separe, non bloquant) : pourquoi QA lance ne repond pas du tout a une delegation (son `claude` n'appelle pas `idea_reply`) — impossible a creuser tant que la connexion du demandeur est wedgee ; le fix Bug 5 permet desormais de le diagnostiquer sans tout figer.
|
||||
|
||||
## Bug 6 — la tache n'est JAMAIS ecrite dans le PTY d'un agent delegue en arriere-plan (corrige 2026-06-15)
|
||||
C'EST la cause racine du « reste a investiguer » du Bug 5. Symptome : un agent lance a la main (cellule visible, ex. DevBackend) repond ; un agent **froid auto-lance par `idea_ask_agent`** (ex. QA) ne repond JAMAIS → `ask` bloque jusqu'au timeout (24h, `ASK_AGENT_TIMEOUT`). Reproduction sure et non bloquante : `idea_stop_agent` puis `idea_launch_agent(task=…, visibility=background)`, et observer le dossier de session `claude` de la cible (`~/.claude/projects/<run-dir>/*.jsonl`) : **aucune nouvelle session** en 28 s = la tache n'a jamais ete soumise (le pont MCP de la cible, lui, se connecte bien).
|
||||
Cause : depuis ARCHITECTURE §20, l'**ecriture physique du PTV est faite par le write-portal FRONTEND** (`useWritePortal`), qui n'est monte **que pour une cellule de layout (leaf) visible**. Or `ensure_live_pty` cold-lance la cible en **background avec `node_id: None`** (`service.rs`) → aucune cellule montee → `useWritePortal` n'existe pas → l'event `DelegationReady` n'a **aucun consommateur** → tache perdue. Les fix Bug 3/4 (differer/liberer la `DelegationReady`) ne pouvaient donc jamais marcher pour un agent background : ils publient un event que personne n'ecoute cote UI.
|
||||
Fix (option « writer PTY backend ») : le mediateur ecrit lui-meme la tache dans le PTY **quand aucune cellule frontend n'est attachee**. Registre `front_owned` dans `MediatedInbox`, alimente par le front via `bindHandle`/`unbindHandle` → commande Tauri `set_front_attached` → `OrchestratorService::set_agent_front_attached` → `InputMediator::set_front_attached`. Point de livraison unique = `BusyTracker::publish_deferred` (chemin chaud immediat ET drains froids `prompt_ready`/`release_cold_start`), qui passe par un **HeadlessSink** optionnel cable par `MediatedInbox::with_pty`/`with_events` : agent dans `front_owned` ⇒ `Some(d)` (publie l'event, le front ecrit, inchange) ; sinon ⇒ ecrit texte puis (apres `submit_delay_ms`, defaut 60ms, anti-paste-detection) la `submit_sequence` dans le handle PTY bound, sur un `std::thread` detache (le watcher prompt-ready tourne sur un std::thread, PAS tokio — ne pas utiliser `tokio::spawn`). Touche `domain/src/input.rs` (port `set_front_attached`), `infrastructure/src/input/mod.rs` (HeadlessSink + front_owned + handles en `Arc<Mutex>`), `application/src/orchestrator/service.rs`, `app-tauri/src/{dto,commands,lib}.rs`, `frontend/src/{ports,adapters/input,adapters/mock,features/terminals/useWritePortal}`. Tests verts : `cargo test -p infrastructure` (input 34, dont `headless_agent_without_front_cell_is_written_by_the_backend` + `front_attached_agent_is_delivered_via_event_not_backend_write`), app-tauri lib 42, useWritePortal 9, tsc. **Validation live = rebuild AppImage + relance IdeA** (build 2026-06-15 11:42 ; backup `~/Documents/IdeA_0.1.0_amd64.AppImage.old-prefrontwriterfix`).
|
||||
NB diagnostic : `~/.claude/projects/<encoded-run-dir>/*.jsonl` = transcript de session `claude` de l'agent ; pas de nouveau fichier apres une delegation = tache jamais soumise. `ss -xp | grep idea-mcp` = ponts MCP connectes cote serveur.
|
||||
|
||||
## Bug 7 — une delegation interrompue/annulee laisse la cible `Busy` a vie (corrige 2026-06-15, sources ; pas encore en AppImage)
|
||||
Symptome : un agent qui repondait (ex. DevFrontend a « 123x4=492 », QA pendant LP3) ne repond plus du tout aux delegations suivantes ; `idea_ask_agent` bloque jusqu'au timeout. DevBackend, lui, continue de marcher. Diagnostic ecarte 2 fausses pistes : (a) PAS le socket MCP — les 6 ponts sont ESTAB (`ss -xp | grep idea-mcp`), pont vivant ; (b) PAS « lance a la main vs par IdeA » — DevBackend est aussi auto-lance et marche. Le vrai discriminant : **une delegation vers cet agent a-t-elle ete interrompue/annulee cote demandeur ?** DevFrontend coince par l'interruption d'une tache LP3 ; QA coince par un ping diag rejete ; DevBackend jamais annule => OK. Confirmation cote transcript : la tache figure dans le `log.jsonl` de la conversation (donc enqueue cote serveur a eu lieu) mais PAS dans le transcript `claude` de la cible (`~/.claude/projects/<run-dir>/*.jsonl`) => jamais ecrite dans son PTY.
|
||||
Cause (lue dans `application/src/orchestrator/service.rs`, chemins `ask` PTY ~l.847-911 ET `ask_structured` ~l.927-1011) : l'agent passe `Idle→Busy` des `input.enqueue` (~l.978). Il ne redevient `Idle` que sur la branche **succes** (`input.mark_idle`, ~l.1001). Les branches **erreur/annulation/timeout** (~l.901-910 et ~l.1004-1009) appellent `mailbox.cancel_head` mais **jamais `mark_idle`**. Pire : quand le demandeur interrompt l'appel, le futur `ask_agent` est **dropped** => AUCUNE branche du `select!` ne s'execute => l'agent reste `Busy` pour toujours. Une cible `Busy` met les delegations suivantes en file derriere un tour fantome, jamais livrees au PTY. L'etat `Busy` vit en memoire dans le process serveur et **ne se deparke pas de l'interieur** : deblocage immediat = **relancer IdeA** (comme le wedge du Bug 5).
|
||||
Fix (corrige cote sources, `service.rs`) : garde RAII `BusyTurnGuard` (Arc clones de `InputMediator` + mailbox, `agent_id`, `ticket_id`, flag `armed`) cree juste apres l'enqueue dans les DEUX chemins (`ask` PTY et `ask_structured`) ; son `Drop` (si arme) appelle `cancel_head(agent,ticket)` puis `mark_idle(agent)` ; `disarm()` sur la branche succes (le `mark_idle` propre existant reste, pas de double cancel). Les `cancel_head` redondants des branches erreur/`_cancelled`/`_elapsed` ont ete retires au profit du garde (`cancel_head` est positionnel/idempotent). Indispensable que ce soit un garde et pas un `mark_idle` dans les branches : le cas reel est un futur DROPPED, aucune branche du `select!` ne s'execute. Tests verts : `cargo test --workspace` 80 suites 0 echec, dont `dropped_ask_future_frees_busy_target`, `second_delegation_delivered_after_dropped_ask`, `cancelled_ask_marks_target_idle` (tests/orchestrator_service.rs) + 2 tests du garde dans le `mod tests` de service.rs.
|
||||
VERDICT `sweep_stalled` (infra/input/mod.rs) : **purement advisory** — bascule `Alive→Stalled` et emet `AgentLivenessChanged` mais **n'appelle JAMAIS `mark_idle`** (par conception : « la FIFO et le tour continuent »). Ce n'est donc PAS un filet pour ce bug ; le garde RAII est la seule correction. Non recable (changement de semantique hors perimetre).
|
||||
**Pas encore actif live : rebuild AppImage requis** (`npm --prefix frontend run build` puis depuis `crates/app-tauri/` `APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage`, remplacer l'AppImage, relancer IdeA). Le relancement d'IdeA debloque aussi DevFrontend/QA actuellement coinces `Busy` (etat en memoire). Methodo (rappel Bug 4) : NE PAS reparer le systeme inter-agent via `idea_ask_agent` (casse) — utiliser les subagents natifs (outil Agent).
|
||||
|
||||
See also [[remaining-work-idea-agent-control-ide]], [[agent-context-memory-and-profile-handoff]].
|
||||
273
.ideai/memory/remaining-work-idea-agent-control-ide.md
Normal file
273
.ideai/memory/remaining-work-idea-agent-control-ide.md
Normal file
@ -0,0 +1,273 @@
|
||||
---
|
||||
name: remaining-work-idea-agent-control-ide
|
||||
description: Etat des lieux des acquis et des chantiers restants pour aligner IdeA avec la cible d'IDE de controle d'agents IA.
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
# Remaining Work For IdeA Agent Control IDE
|
||||
|
||||
## Resume
|
||||
|
||||
Cette note sert de point de reprise pour l'agent `Main`. Elle distingue ce qui est deja implemente, ce qui reste a stabiliser, et ce qui reste a construire pour que IdeA corresponde pleinement a la vision "patron + employes IA" avec memoire projet partagee, contexte partage, messagerie inter-agents transparente, FIFO par agent, et persistance de reprise.
|
||||
|
||||
See also:
|
||||
|
||||
- `idea-product-directives-main-handoff` for product priorities and UX constraints.
|
||||
- `agent-context-memory-and-profile-handoff` for the structural model separating context, durable memory, live state, and handoff.
|
||||
|
||||
Etat observe le 2026-06-11 sur le depot local:
|
||||
|
||||
- L'orchestration inter-agents synchrone est deja reelle cote application.
|
||||
- La FIFO par agent, la conversation par paire et `idea_reply` sont deja couvertes par des tests verts.
|
||||
- Le transport MCP natif par projet et le loopback sont testes verts localement.
|
||||
- La reprise de conversation cote session/layout est largement presente dans le code.
|
||||
- En revanche, plusieurs briques produit restent inachevees ou non consolidees de bout en bout.
|
||||
|
||||
## Deja Livre Ou Tres Avance
|
||||
|
||||
### 1. Artefacts projet dans `.ideai/`
|
||||
|
||||
- Les agents projet sont persistes dans `.ideai/agents.json` et `.ideai/agents/*.md`.
|
||||
- Le contexte projet partage est modelise via `.ideai/CONTEXT.md`.
|
||||
- La memoire projet partagee est modelisee via `.ideai/memory/*.md` + `.ideai/memory/MEMORY.md`.
|
||||
- Les requetes d'orchestration fichier vivent sous `.ideai/requests/`.
|
||||
|
||||
Conclusion: la direction "tout ce qui releve d'IdeA pour un projet doit vivre dans `.ideai/`" est deja la bonne direction architecturale. Il reste surtout a eliminer les ecarts pratiques et a consolider l'usage reel.
|
||||
|
||||
### 2. Memoire projet partagee
|
||||
|
||||
- `FsMemoryStore` et `MemoryRecall` existent.
|
||||
- Les agents peuvent recevoir un rappel de memoire projet a l'activation.
|
||||
- Le frontend et les use cases CRUD memoire existent deja.
|
||||
|
||||
Conclusion: la memoire partagee du projet n'est plus un concept a inventer. Le travail restant est plutot sur la qualite du rappel, la curation, et l'usage continu pendant la vie des sessions.
|
||||
|
||||
### 3. Contexte partage et contexte agent
|
||||
|
||||
- Le contexte partage projet est separe du contexte agent.
|
||||
- Les contextes agent sont persistes sous `.ideai/agents/*.md`.
|
||||
- Le launcher injecte deja le contexte compose au demarrage.
|
||||
|
||||
Conclusion: la separation `contexte projet` / `contexte agent` est en place.
|
||||
|
||||
### 4. Messagerie inter-agents transparente
|
||||
|
||||
- `AgentMailbox` + `InMemoryMailbox` existent.
|
||||
- `ConversationRegistry` + `InMemoryConversationRegistry` existent.
|
||||
- `OrchestratorService::ask_agent` et `reply` existent.
|
||||
- Les outils MCP `idea_ask_agent`, `idea_reply`, `idea_launch_agent`, `idea_list_agents`, `idea_stop_agent`, `idea_update_context`, `idea_create_skill` existent.
|
||||
- Les tests applicatifs passent sur FIFO, reponse synchrone, prevention de cycle, timeout, parallélisme entre cibles differentes.
|
||||
|
||||
Verification locale du 2026-06-11:
|
||||
|
||||
- `cargo test -p application --test orchestrator_service` : OK
|
||||
- `cargo test -p infrastructure --test mcp_server` : OK
|
||||
- `cargo test -p app-tauri --test orchestrator_wiring` : OK
|
||||
|
||||
Conclusion: le coeur de la communication inter-agents n'est plus un chantier de conception. Il est deja implementé et teste.
|
||||
|
||||
### 5. FIFO transparente quand un agent est occupe
|
||||
|
||||
- La file d'entree par agent existe deja.
|
||||
- La serialisation des tours vers une meme cible existe.
|
||||
- Les `ask` concurrents vers des agents differents peuvent tourner en parallele.
|
||||
|
||||
Conclusion: l'exigence "si l'utilisateur ou un autre agent parle a un agent deja occupe, la requete part en file FIFO de maniere transparente" est deja largement satisfaite au niveau coeur applicatif.
|
||||
|
||||
### 6. Reprise de conversation et persistance de session
|
||||
|
||||
- Les cellules/layouts persistent `conversation_id` et `agent_was_running`.
|
||||
- Les use cases de reprise (`ListResumableAgents`, popup de reprise, relance avec `conversation_id`) existent.
|
||||
- Les sessions structurees Claude/Codex savent porter un `conversation_id`.
|
||||
|
||||
Conclusion: la persistance de reprise a deja une base concrete et substantielle.
|
||||
|
||||
## Reste A Faire En Priorite
|
||||
|
||||
### 1. Consolider la persistance "conversation continue" au niveau produit, pas seulement "resume technique"
|
||||
|
||||
Le code sait deja reprendre une conversation via `conversation_id`, mais la cible produit demande plus qu'une simple reprise technique:
|
||||
|
||||
- conserver une vraie continuite de conversation lisible pour l'utilisateur au redemarrage,
|
||||
- permettre au nouvel agent/profil de repartir avec l'etat utile,
|
||||
- rendre la reprise completement transparente dans l'UX.
|
||||
|
||||
Reste donc a verrouiller:
|
||||
|
||||
- la persistance canonique des conversations exploitable au niveau produit,
|
||||
- la strategie de resume/handoff quand on change de profil IA,
|
||||
- la coherence UX entre reprise de cellule, reprise de conversation, et reprise de travail.
|
||||
|
||||
### 2. Implementer une couche persistante de handoff / resume cross-profile
|
||||
|
||||
La memoire partagee existante dit explicitement qu'il faut persister:
|
||||
|
||||
- un canonical conversation log,
|
||||
- un cumulative handoff summary,
|
||||
- l'etat agent,
|
||||
- les identifiants de conversation utiles par provider.
|
||||
|
||||
Ce point n'apparait pas comme livre de bout en bout dans le depot actuel.
|
||||
|
||||
Le besoin produit reste ouvert:
|
||||
|
||||
- si un agent passe de Claude a Codex, IdeA doit reconstituer l'etat de travail sans dependre d'une session native transferable,
|
||||
- le handoff doit etre incremental, pas fabrique seulement au moment de la panne ou du swap.
|
||||
|
||||
### 3. Introduire un vrai live-state partage au niveau projet
|
||||
|
||||
La memoire durable ne doit pas servir de journal temps reel. La note memoire existante le dit deja.
|
||||
|
||||
Il manque encore une couche explicite de "live operational state" pour:
|
||||
|
||||
- qui travaille sur quoi,
|
||||
- tickets/intentions en cours,
|
||||
- etat d'avancement d'un agent,
|
||||
- derniere delegation utile,
|
||||
- elements transitoires de coordination inter-agents.
|
||||
|
||||
Sans cette couche, une partie de la coordination reste soit volatile, soit repoussee dans des endroits qui ne sont pas faits pour ca.
|
||||
|
||||
### 4. Verifier et finir l'integration MCP natif "IdeA-only" de bout en bout dans le flux reel de l'application
|
||||
|
||||
Les tests locaux du transport MCP passent, ce qui place cette zone en fin de chantier plutot qu'au debut.
|
||||
|
||||
Mais il reste a confirmer en situation reelle utilisateur:
|
||||
|
||||
- qu'un agent lance par IdeA voit effectivement ses outils MCP sans action manuelle,
|
||||
- que les profils supportes utilisent bien cette voie par defaut,
|
||||
- que le fallback fichier+prose reste coherent quand MCP n'est pas disponible,
|
||||
- que l'observabilite UI des delegations et des replies est suffisamment claire.
|
||||
|
||||
Point important: l'architecture historique qui mentionne encore un verrou M5 ouvert est probablement en retard par rapport au worktree local. Avant de planifier le prochain lot, `Main` doit revalider la documentation d'architecture a la lumiere du code/tests actuels.
|
||||
|
||||
### 5. Stabiliser le registre de sessions et clarifier le modele singleton d'agent
|
||||
|
||||
Le produit veut "1 agent = 1 employe". Cela impose une verite unique sur:
|
||||
|
||||
- la session vivante de l'agent,
|
||||
- sa conversation courante,
|
||||
- sa cellule visible ou son execution en arriere-plan,
|
||||
- son etat occupé/libre/interrompu.
|
||||
|
||||
Le code a deja beaucoup avance sur ce point, mais le worktree local montre encore un chantier actif autour de:
|
||||
|
||||
- `application/src/terminal/registry.rs`
|
||||
- `application/src/orchestrator/service.rs`
|
||||
- `application/src/agent/lifecycle.rs`
|
||||
- `app-tauri/src/state.rs`
|
||||
|
||||
Conclusion: ne pas considerer le sujet comme totalement clos tant que le worktree n'est pas nettoye et que la suite de tests ciblee n'est pas executee sur l'ensemble du flux concerne.
|
||||
|
||||
### 6. Rendre la mise a jour de memoire/contexte vraiment automatique pendant la vie d'un agent
|
||||
|
||||
La cible utilisateur dit qu'il ne doit jamais demander:
|
||||
|
||||
- de charger une memoire,
|
||||
- de charger un contexte,
|
||||
- de mettre a jour la memoire,
|
||||
- de mettre a jour le contexte.
|
||||
|
||||
Le lancement injecte deja beaucoup de choses automatiquement, mais il reste a verrouiller le comportement "pendant la vie" d'un agent:
|
||||
|
||||
- quand regenerer le contexte effectif,
|
||||
- quand promouvoir une information stable vers la memoire durable,
|
||||
- comment distinguer signal utile et bruit,
|
||||
- comment eviter de compter sur des consignes manuelles a l'utilisateur.
|
||||
|
||||
### 7. Unifier la conversation utilisateur <-> agent et agent <-> agent dans l'UX
|
||||
|
||||
Le backend sait deja distinguer `User<->Agent` et `Agent<->Agent`.
|
||||
|
||||
Le travail restant est surtout produit/frontend:
|
||||
|
||||
- affichage clair des delegations et des retours,
|
||||
- visualisation non confuse des conversations par paire,
|
||||
- reprise lisible des threads,
|
||||
- transparence totale pour l'utilisateur final.
|
||||
|
||||
Le diff local frontend suggere justement un remaniement en cours de la surface terminal/chat.
|
||||
|
||||
### 8. Consolider la restriction et l'affordance des profils supportes
|
||||
|
||||
Le modele actuel oriente fortement vers Claude/Codex structures, ce qui est coherent avec la fiabilite attendue.
|
||||
|
||||
Reste a clarifier produit:
|
||||
|
||||
- quels profils sont officiellement "employes IdeA" de premiere classe,
|
||||
- quel fallback proposer pour les profils non structures,
|
||||
- quelle UI montrer quand un profil ne supporte pas la delegation native fiable.
|
||||
|
||||
### 9. Persistance conversationnelle globale de l'application
|
||||
|
||||
La demande utilisateur mentionne explicitement qu'en relancant IdeA il faut retrouver la conversation.
|
||||
|
||||
La reprise par `conversation_id` et layouts existe, mais il reste a confirmer ou completer:
|
||||
|
||||
- la persistance lisible de l'historique conversationnel pour l'utilisateur,
|
||||
- la restauration des vues au redemarrage,
|
||||
- la coherence entre session technique, resume visuel et histoire de travail.
|
||||
|
||||
Autrement dit: "reprendre une session moteur" n'est pas encore automatiquement equivalent a "retrouver sa conversation produit" dans tous les cas.
|
||||
|
||||
## Chantiers Secondaires Mais Importants
|
||||
|
||||
### 1. Mettre la documentation d'architecture a jour
|
||||
|
||||
Le code local et les tests verts semblent avoir depasse certains passages de `ARCHITECTURE.md` et de briefs anciens.
|
||||
|
||||
Il faut une passe de synchronisation documentaire pour eviter que `Main` suive un etat obsolete, en particulier sur:
|
||||
|
||||
- statut reel du transport MCP,
|
||||
- statut reel de la FIFO inter-agents,
|
||||
- statut reel des conversations par paire,
|
||||
- ce qui reste vraiment ouvert entre handoff, live-state et UX.
|
||||
|
||||
### 2. Curater le dossier `.ideai/`
|
||||
|
||||
Le principe "tout ce qui est IdeA-projet va dans `.ideai/`" est bon, mais il faudra surveiller:
|
||||
|
||||
- la proliferation de fichiers run/request/debug,
|
||||
- ce qui est durable vs derivable,
|
||||
- ce qui doit etre committe vs ignore.
|
||||
|
||||
### 3. Formaliser les regles de promotion memoire
|
||||
|
||||
Le systeme doit savoir quand enregistrer une connaissance stable sans polluer la memoire partagee.
|
||||
|
||||
Il manque probablement encore:
|
||||
|
||||
- une politique claire de promotion,
|
||||
- des heuristiques/outils explicites pour les agents,
|
||||
- des garde-fous contre la memoire bruit.
|
||||
|
||||
## Worktree Local A Prendre En Compte
|
||||
|
||||
Le depot local est actuellement dirty avec un chantier large non committe autour de:
|
||||
|
||||
- orchestration MCP / loopback / serveur Tauri,
|
||||
- mailbox / conversations / session registry,
|
||||
- adaptation frontend terminal/chat/layout,
|
||||
- fichiers `.ideai/` du projet lui-meme.
|
||||
|
||||
Consequence pour `Main`:
|
||||
|
||||
- ne pas planifier a partir de `ARCHITECTURE.md` seulement,
|
||||
- d'abord relire le diff local,
|
||||
- ensuite reexecuter la suite de tests ciblee des zones touchees,
|
||||
- puis seulement decider si le prochain lot est "finition", "integration UI", ou "harden/persistence".
|
||||
|
||||
## Ordre Recommande Pour La Suite
|
||||
|
||||
1. Revalider et documenter l'etat reel du chantier MCP/orchestration a partir du code courant, puis remettre `ARCHITECTURE.md` a jour.
|
||||
2. Fermer proprement le sujet "1 agent = 1 session vivante coherente" en nettoyant le registre/session lifecycle encore en mouvement.
|
||||
3. Concevoir puis implementer une vraie couche de live-state partage projet.
|
||||
4. Concevoir puis implementer la couche persistante de handoff/canonical conversation log cross-session et cross-profile.
|
||||
5. Finir l'integration UX/frontend pour que toute cette orchestration reste invisible et naturelle pour l'utilisateur final.
|
||||
|
||||
## Synthese Courte
|
||||
|
||||
Le plus gros changement de perception pour `Main` est le suivant:
|
||||
|
||||
- IdeA n'est plus au stade "il faut inventer la delegation inter-agents".
|
||||
- IdeA est plutot au stade "le coeur de delegation existe deja; il faut maintenant le consolider, le documenter, le rendre pleinement persistant, et le rendre transparent dans l'UX".
|
||||
1518
ARCHITECTURE.md
1518
ARCHITECTURE.md
File diff suppressed because it is too large
Load Diff
1167
Cargo.lock
generated
1167
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -18,7 +18,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
async-trait = "0.1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time"] }
|
||||
# Local git via libgit2. Network features (https/ssh → openssl) are off for L8:
|
||||
# only local operations (status/commit/branch/checkout/log) are in scope; remote
|
||||
# push/pull and static vendoring for the AppImage are deferred to L9/L11.
|
||||
|
||||
@ -35,8 +35,31 @@ Modéliser les **Skills** : workflows réutilisables (équivalent universel des
|
||||
- **`Agent`** étendu : champ `skills: Vec<SkillRef>` (serde `default`), méthodes `assign_skill` (idempotent), `unassign_skill`, `with_skills` (dédup). **`ManifestEntry`** : champ `skills` (serde `default` + `skip_serializing_if` → rétrocompat des manifests pré-L12) ; `from_agent`/`to_agent` préservent les skills.
|
||||
- **Tests** : 8 invariants (`entities.rs`) + 3 serde dont rétrocompat d'un manifest legacy sans clé `skills` (`serde_roundtrip.rs`). `cargo test -p domain` vert ; `cargo test --workspace` vert (0 régression) ; clippy clean.
|
||||
|
||||
### ✅ Port + adapter store (vert)
|
||||
- **Port `SkillStore`** (`domain/ports.rs`) : `list/get/save/delete` portant `scope` + `root: &ProjectPath` **par appel** (root ignoré pour `Global`, résolu pour `Project`) — un seul store sert tous les projets ouverts, comme `AgentContextStore`.
|
||||
- **Adapter `FsSkillStore`** (`infrastructure/store/skill.rs`) : même forme on-disk que `FsTemplateStore` (`index.json` + `md/<id>.md`), deux racines disjointes : `<app_data>/skills/` (Global) et `<root>/.ideai/skills/` (Project). Delete laisse l'orphelin md (pas de remove dans le port FS), index = source de vérité. **7 tests** d'intégration tmpdir (`skill_store.rs`) : round-trip 2 scopes, **isolation de scope**, upsert, delete idempotent, camelCase.
|
||||
|
||||
### ✅ Use cases application (vert)
|
||||
- `application/skill` : `CreateSkill`, `UpdateSkill`, `DeleteSkill`, `ListSkills(scope)` (inputs portant `project_root`), `AssignSkillToAgent` / `UnassignSkillFromAgent` (mutent l'`AgentManifest` via `to_agent`/`from_agent`, dédup, émettent `SkillAssigned`, **idempotents**). **9 tests** (`skill_usecases.rs`).
|
||||
|
||||
### ✅ Injection dans le convention file (vert, fil L6)
|
||||
- `LaunchAgent` reçoit le port `SkillStore` ; `resolve_skills` lit les `.md` des skills assignés (ordre manifest, déterministe ; skill supprimé = `SkillRef` pendant → ignoré sans bloquer le lancement).
|
||||
- `compose_convention_file` étendu : section `# Skills` (sous-titres `## <name>`) après le persona ; omise si aucun skill. **3 tests** unitaires + e2e (`agent_lifecycle.rs` : injection ordonnée, ref pendant tolérée).
|
||||
- **Composition root** (`app-tauri/state.rs`) : `FsSkillStore` construit (app-data global), injecté dans `LaunchAgent`.
|
||||
|
||||
### ✅ IPC `app-tauri` (vert)
|
||||
- **DTOs** (`dto.rs`) : `SkillDto` (transparent sur `Skill`, camelCase), `SkillListDto`, request DTOs (`Create/Update/Assign/UnassignSkillRequestDto`), `parse_skill_id`. `scope` désérialise directement vers `SkillScope` (`"global"`/`"project"`).
|
||||
- **Commandes** (`commands.rs`) : `create_skill`, `update_skill`, `list_skills`, `delete_skill`, `assign_skill_to_agent`, `unassign_skill_from_agent` — shells fins qui résolvent le `Project` (→ `project.root`) puis appellent le use case. Enregistrées dans `lib.rs`.
|
||||
- **Composition root** (`state.rs`) : 6 use cases skill câblés sur le `skill_store_port` (déjà construit pour le launcher) et le `contexts_port` partagé.
|
||||
- `cargo build -p app-tauri` + `cargo test --workspace` (304) verts ; clippy clean.
|
||||
|
||||
### ✅ Front `features/skills` (vert)
|
||||
- **Domaine** (`domain/index.ts`) : `SkillScope`, `Skill`, `SkillRef` ; `Agent` étendu avec `skills: SkillRef[]`.
|
||||
- **Port** (`ports/index.ts`) : `SkillGateway` (list/create/update/delete + assign/unassign) + `CreateSkillInput` ; ajouté à `Gateways`.
|
||||
- **Adapters** : `TauriSkillGateway` (`adapters/skill.ts`, invoke camelCase) ; `MockSkillGateway` (`adapters/mock`, scopes disjoints + mutation partagée du `MockAgentGateway` via `_setSkills`, assign idempotent).
|
||||
- **Feature** : `useSkills` (VM 2 scopes), `SkillEditor` (overlay md edit/preview + sélecteur de scope), `SkillsPanel` (listes Project/Global, CRUD). Onglet **Skills** ajouté dans `ProjectsView`.
|
||||
- **Assignation** dans `AgentsPanel` : chips des skills assignés + sélecteur d'assignation + unassign, sur l'agent sélectionné ; refresh après mutation.
|
||||
- **Tests** (`skills.test.tsx`, RTL via `DIProvider` + mocks) : CRUD project/global, isolation de scope, édition, suppression, assign/unassign reflétés sur l'agent, idempotence, **garde-fou « no direct invoke »** (aucune action run/launch). `vitest` : **229** verts (0 régression ; test « ten gateways » mis à jour).
|
||||
|
||||
### ⏳ Reste à faire
|
||||
- Port `SkillStore` (`domain/ports.rs`) + adapter `FsSkillStore` (`infrastructure/store`).
|
||||
- Use cases `application/skill` : CRUD + `AssignSkillToAgent`/`UnassignSkillFromAgent`.
|
||||
- Injection des skills assignés dans le convention file à l'activation (fil L6).
|
||||
- IPC `app-tauri` + front `features/skills`.
|
||||
- Cycle manuel : créer un skill, l'assigner à un agent, l'activer → vérifier qu'il apparaît dans le convention file de `.ideai/run/<agent-id>/` (à faire sur l'AppImage).
|
||||
|
||||
@ -25,11 +25,25 @@ application = { workspace = true }
|
||||
infrastructure = { workspace = true }
|
||||
tauri = { workspace = true }
|
||||
tauri-plugin-dialog = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
# `io-std` (on top of the workspace features) gives the headless `mcp-server`
|
||||
# bridge access to `tokio::io::{stdin,stdout}` without widening tokio elsewhere.
|
||||
tokio = { workspace = true, features = ["io-std", "rt"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
# Cross-OS local IPC for the MCP loopback transport (M5a): Unix domain socket
|
||||
# (Linux/macOS) + Windows named pipe behind one async API, no network port. Pulled
|
||||
# in only here (the transport is an app-tauri/infra concern); its `tokio` feature
|
||||
# keeps us off tokio's `net` feature workspace-wide.
|
||||
interprocess = { version = "2.4", features = ["tokio"] }
|
||||
|
||||
[features]
|
||||
# Passthrough toggles to enable the real embedders in an IDE build. OFF by default
|
||||
# (founding posture: `none` ⇒ naïve recall, zero dependency).
|
||||
vector-http = ["infrastructure/vector-http"]
|
||||
vector-onnx = ["infrastructure/vector-onnx"]
|
||||
|
||||
[dev-dependencies]
|
||||
uuid = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
191
crates/app-tauri/src/chat.rs
Normal file
191
crates/app-tauri/src/chat.rs
Normal file
@ -0,0 +1,191 @@
|
||||
//! Generic **structured reply ↔ Tauri Channel** bridge infrastructure.
|
||||
//!
|
||||
//! Twin of [`crate::pty::PtyBridge`] (ARCHITECTURE §17.7). Where `PtyBridge`
|
||||
//! routes raw PTY byte chunks to a per-session [`tauri::ipc::Channel`], this
|
||||
//! bridge routes typed [`ReplyChunk`]s — the serialised
|
||||
//! [`domain::ports::ReplyEvent`]s of an [`domain::ports::AgentSession`] turn — to
|
||||
//! the chat cell that owns the session.
|
||||
//!
|
||||
//! Design (mirrors the PTY path so the lifecycle guarantees are identical):
|
||||
//! - The frontend opens (or re-attaches) a chat cell and passes a
|
||||
//! [`tauri::ipc::Channel`] for that session. The backend registers it here keyed
|
||||
//! by `SessionId`, bumping a monotonic **generation** so a superseded pump can't
|
||||
//! tear down the channel of the attach that replaced it (see [`unregister_if`]).
|
||||
//! - The `agent_send` pump drains the session's [`domain::ports::ReplyStream`],
|
||||
//! maps each event to a [`ReplyChunk`], and forwards it via [`send_output`].
|
||||
//! - Unlike a PTY, an [`domain::ports::AgentSession`] keeps **no** scrollback (the
|
||||
//! port is a per-turn stream, not a persistent byte ring). So the bridge itself
|
||||
//! retains the rendered chunks per session — the **conversation scrollback** —
|
||||
//! so a re-attach can repaint the turns already streamed, exactly as the PTY
|
||||
//! adapter's ring buffer lets `reattach_terminal` repaint xterm. This lives on
|
||||
//! the transport side (D4 owns transport), keeping the domain port pure.
|
||||
//!
|
||||
//! [`unregister_if`]: ChatBridge::unregister_if
|
||||
//! [`send_output`]: ChatBridge::send_output
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::ipc::Channel;
|
||||
|
||||
use domain::ids::SessionId;
|
||||
use domain::ports::ReplyEvent;
|
||||
|
||||
use crate::dto::ReplyChunk;
|
||||
|
||||
/// Per-session transport state: the current attach generation, its output
|
||||
/// channel, and the conversation scrollback recorded so far.
|
||||
struct ChatEntry {
|
||||
/// Monotonic generation of the current (re-)attach (anti double-pump).
|
||||
generation: u64,
|
||||
/// The output channel of the current attach, if one is registered. `None`
|
||||
/// when the session has been recorded (scrollback kept) but no view is
|
||||
/// currently attached — the pump then only appends to the scrollback.
|
||||
channel: Option<Channel<ReplyChunk>>,
|
||||
/// Every chunk routed for this session, in order — the conversation
|
||||
/// scrollback replayed on re-attach. Bounded only by the conversation length
|
||||
/// (a turn count), like the PTY ring buffer is bounded by its byte capacity.
|
||||
scrollback: Vec<ReplyChunk>,
|
||||
}
|
||||
|
||||
/// Registry mapping live structured (chat) sessions to their reply [`Channel`]
|
||||
/// plus a retained conversation scrollback.
|
||||
///
|
||||
/// Thread-safe; a cloned `Arc<ChatBridge>` is held in [`crate::state::AppState`],
|
||||
/// the twin of [`crate::pty::PtyBridge`].
|
||||
#[derive(Default)]
|
||||
pub struct ChatBridge {
|
||||
entries: Mutex<HashMap<SessionId, ChatEntry>>,
|
||||
}
|
||||
|
||||
impl ChatBridge {
|
||||
/// Creates an empty bridge.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers (or replaces) the reply channel for a session and returns the
|
||||
/// **generation** of this registration. Each call for a session bumps the
|
||||
/// generation, so the caller's pump can later tear down *only its own*
|
||||
/// registration via [`unregister_if`](Self::unregister_if). The retained
|
||||
/// conversation scrollback is **preserved** across re-attaches (only the
|
||||
/// channel and generation change), mirroring how the PTY ring buffer survives
|
||||
/// a `reattach_terminal`.
|
||||
pub fn register(&self, session: SessionId, channel: Channel<ReplyChunk>) -> u64 {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
match map.get_mut(&session) {
|
||||
Some(entry) => {
|
||||
entry.generation = entry.generation.wrapping_add(1);
|
||||
entry.channel = Some(channel);
|
||||
entry.generation
|
||||
}
|
||||
None => {
|
||||
map.insert(
|
||||
session,
|
||||
ChatEntry {
|
||||
generation: 0,
|
||||
channel: Some(channel),
|
||||
scrollback: Vec::new(),
|
||||
},
|
||||
);
|
||||
0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the conversation scrollback retained for a session (the chunks
|
||||
/// already streamed), or an empty vector if the session is unknown.
|
||||
///
|
||||
/// Called by `reattach_agent_chat` to repaint the prior turns into the
|
||||
/// re-mounting chat view before the new stream is wired — the typed twin of
|
||||
/// `PtyPort::scrollback`.
|
||||
#[must_use]
|
||||
pub fn scrollback(&self, session: &SessionId) -> Vec<ReplyChunk> {
|
||||
self.entries
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|m| m.get(session).map(|e| e.scrollback.clone()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Removes a session's transport state **and** its retained scrollback
|
||||
/// unconditionally (chat cell explicitly closed). Twin of
|
||||
/// [`PtyBridge::unregister`](crate::pty::PtyBridge::unregister).
|
||||
pub fn unregister(&self, session: &SessionId) {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
map.remove(session);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detaches a session's channel **only if** `gen` is still the current
|
||||
/// generation, leaving the scrollback intact. A pump calls this when its turn
|
||||
/// stream ends: if the session was re-attached meanwhile (newer generation),
|
||||
/// this is a no-op so the dying pump never detaches the live channel that
|
||||
/// superseded it. Twin of
|
||||
/// [`PtyBridge::unregister_if`](crate::pty::PtyBridge::unregister_if), but it
|
||||
/// keeps the conversation scrollback (the conversation outlives a single
|
||||
/// turn's pump — closing the cell is what discards it, via [`unregister`]).
|
||||
///
|
||||
/// [`unregister`]: ChatBridge::unregister
|
||||
pub fn detach_if(&self, session: &SessionId, gen: u64) {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
if let Some(entry) = map.get_mut(session) {
|
||||
if entry.generation == gen {
|
||||
entry.channel = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a chunk in the session's scrollback and, if a view is attached at
|
||||
/// the current generation, forwards it to that channel.
|
||||
///
|
||||
/// Returns `true` if the chunk was delivered to a live channel, `false` if no
|
||||
/// channel is currently attached (e.g. the view navigated away — the chunk is
|
||||
/// still retained in scrollback for the next re-attach) or the send failed.
|
||||
/// The pump keeps draining either way so the turn still completes and the
|
||||
/// scrollback stays whole.
|
||||
pub fn send_output(&self, session: &SessionId, chunk: ReplyChunk) -> bool {
|
||||
let Ok(mut map) = self.entries.lock() else {
|
||||
return false;
|
||||
};
|
||||
let Some(entry) = map.get_mut(session) else {
|
||||
return false;
|
||||
};
|
||||
entry.scrollback.push(chunk.clone());
|
||||
match &entry.channel {
|
||||
Some(channel) => channel.send(chunk).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of currently-tracked sessions (handy for tests/diagnostics).
|
||||
#[must_use]
|
||||
pub fn active_sessions(&self) -> usize {
|
||||
self.entries.lock().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a domain [`ReplyEvent`] to its wire [`ReplyChunk`]. Pure translation, no
|
||||
/// I/O — the single point where the typed turn event becomes a serialisable chunk
|
||||
/// (kept here so the pump and any test share one mapping).
|
||||
///
|
||||
/// Returns `None` for [`ReplyEvent::Heartbeat`]: a heartbeat is a non-terminal
|
||||
/// liveness proof (readiness/heartbeat lot 1) with **no chat content**, so it maps
|
||||
/// to no wire chunk — the pump simply skips it. Every content-bearing event still
|
||||
/// maps to exactly one chunk.
|
||||
#[must_use]
|
||||
pub fn chunk_from_event(event: ReplyEvent) -> Option<ReplyChunk> {
|
||||
match event {
|
||||
ReplyEvent::TextDelta { text } => Some(ReplyChunk::TextDelta { text }),
|
||||
ReplyEvent::ToolActivity { label } => Some(ReplyChunk::ToolActivity { label }),
|
||||
ReplyEvent::Final { content } => Some(ReplyChunk::Final { content }),
|
||||
ReplyEvent::Heartbeat => None,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -12,7 +12,8 @@
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::events::{DomainEvent, OrchestrationSource};
|
||||
use domain::input::AgentLiveness;
|
||||
use infrastructure::TokioBroadcastEventBus;
|
||||
|
||||
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
|
||||
@ -47,6 +48,62 @@ pub enum DomainEventDto {
|
||||
/// Exit code.
|
||||
code: i32,
|
||||
},
|
||||
/// An agent's busy/idle state changed (cadrage C4 §4.2). The frontend dims
|
||||
/// "Envoyer" while `busy` is `true`.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentBusyChanged {
|
||||
/// Agent id.
|
||||
agent_id: String,
|
||||
/// `true` when a turn is in flight, `false` when idle.
|
||||
busy: bool,
|
||||
},
|
||||
/// A delegation is ready to be injected into the agent's **native terminal**
|
||||
/// (ARCHITECTURE §20). The frontend write-portal writes `text` + `submitSequence`
|
||||
/// (default `"\r"`) after `submitDelayMs` (default ~60) once the human line is empty,
|
||||
/// then acks via the `delegation_delivered` command. The backend no longer PTY-writes
|
||||
/// the turn.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
DelegationReady {
|
||||
/// Target agent id (UUID string).
|
||||
agent_id: String,
|
||||
/// Mailbox ticket id (UUID string) to ack back via `delegation_delivered`.
|
||||
ticket: String,
|
||||
/// Task text to inject (written without a trailing newline by the portal).
|
||||
text: String,
|
||||
/// Profile's submit sequence; `null` ⇒ the front applies its default (`"\r"`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
submit_sequence: Option<String>,
|
||||
/// Profile's submit delay in ms; `null` ⇒ the front default (~60 ms).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
submit_delay_ms: Option<u32>,
|
||||
},
|
||||
/// A target agent produced a synchronous reply to an inter-agent `ask` (§17.4).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentReplied {
|
||||
/// Target agent id.
|
||||
agent_id: String,
|
||||
/// Reply length in bytes (metric, not the payload).
|
||||
reply_len: usize,
|
||||
},
|
||||
/// An agent's liveness (alive/stalled) changed (lot 2, readiness/heartbeat). The
|
||||
/// frontend can badge a frozen agent; `"stalled"` while no proof of liveness arrived
|
||||
/// for longer than the profile's `stallAfterMs`, back to `"alive"` on a late
|
||||
/// battement or when the turn ends.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentLivenessChanged {
|
||||
/// Agent id (UUID string).
|
||||
agent_id: String,
|
||||
/// New liveness, as a lowercase string (`"alive"` / `"stalled"`).
|
||||
liveness: AgentLivenessDto,
|
||||
},
|
||||
/// An agent's runtime profile was changed (hot-swap of the AI engine).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AgentProfileChanged {
|
||||
/// Agent id.
|
||||
agent_id: String,
|
||||
/// The new runtime profile id.
|
||||
profile_id: String,
|
||||
},
|
||||
/// A template was updated.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
TemplateUpdated {
|
||||
@ -101,6 +158,53 @@ pub enum DomainEventDto {
|
||||
/// Project id.
|
||||
project_id: String,
|
||||
},
|
||||
/// An orchestrator request was processed on behalf of a requester agent.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OrchestratorRequestProcessed {
|
||||
/// Id of the requesting (orchestrator) agent.
|
||||
requester_id: String,
|
||||
/// The action that was processed.
|
||||
action: String,
|
||||
/// Whether IdeA handled it successfully.
|
||||
ok: bool,
|
||||
/// Which entry door the request arrived through (`"file"` watcher vs
|
||||
/// `"mcp"` server). Serialised as a lowercase string so the frontend can
|
||||
/// badge the source.
|
||||
source: OrchestrationSourceDto,
|
||||
},
|
||||
/// A memory note was created or updated.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
MemorySaved {
|
||||
/// The saved note's slug.
|
||||
slug: String,
|
||||
},
|
||||
/// A memory note was deleted.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
MemoryDeleted {
|
||||
/// The deleted note's slug.
|
||||
slug: String,
|
||||
},
|
||||
/// The aggregated `MEMORY.md` index was rebuilt.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
MemoryIndexRebuilt {
|
||||
/// Project id.
|
||||
project_id: String,
|
||||
},
|
||||
/// A project's memory crossed the recall budget while no embedder is configured
|
||||
/// (LOT C3 — §14.5.5): a one-time, dismissible "configure an embedder?" hint.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
EmbedderSuggested {
|
||||
/// Project id.
|
||||
project_id: String,
|
||||
/// Whether a local Ollama-style embedding server was detected.
|
||||
ollama_detected: bool,
|
||||
/// Ids of recommended ONNX models already present in the local cache.
|
||||
onnx_cached: Vec<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).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PtyOutput {
|
||||
@ -111,6 +215,47 @@ pub enum DomainEventDto {
|
||||
},
|
||||
}
|
||||
|
||||
/// Wire mirror of [`OrchestrationSource`]: which entry door a processed
|
||||
/// orchestration request arrived through. Serialised as a lowercase string
|
||||
/// (`"file"` / `"mcp"`) the frontend badges on the event.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum OrchestrationSourceDto {
|
||||
/// A `.ideai/requests` JSON file (filesystem watcher).
|
||||
File,
|
||||
/// A `tools/call` on the MCP server.
|
||||
Mcp,
|
||||
}
|
||||
|
||||
/// Wire mirror of [`AgentLiveness`]: the alive/stalled liveness of an agent (lot 2),
|
||||
/// serialised as a lowercase string (`"alive"` / `"stalled"`) the frontend badges.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentLivenessDto {
|
||||
/// The agent shows proof of liveness (or is idle).
|
||||
Alive,
|
||||
/// No proof of liveness past the profile's `stallAfterMs` threshold.
|
||||
Stalled,
|
||||
}
|
||||
|
||||
impl From<AgentLiveness> for AgentLivenessDto {
|
||||
fn from(liveness: AgentLiveness) -> Self {
|
||||
match liveness {
|
||||
AgentLiveness::Alive => Self::Alive,
|
||||
AgentLiveness::Stalled => Self::Stalled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OrchestrationSource> for OrchestrationSourceDto {
|
||||
fn from(source: OrchestrationSource) -> Self {
|
||||
match source {
|
||||
OrchestrationSource::File => Self::File,
|
||||
OrchestrationSource::Mcp => Self::Mcp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&DomainEvent> for DomainEventDto {
|
||||
fn from(e: &DomainEvent) -> Self {
|
||||
match e {
|
||||
@ -128,6 +273,43 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
agent_id: agent_id.to_string(),
|
||||
code: *code,
|
||||
},
|
||||
DomainEvent::AgentBusyChanged { agent_id, busy } => Self::AgentBusyChanged {
|
||||
agent_id: agent_id.to_string(),
|
||||
busy: *busy,
|
||||
},
|
||||
DomainEvent::DelegationReady {
|
||||
agent_id,
|
||||
ticket,
|
||||
text,
|
||||
submit_sequence,
|
||||
submit_delay_ms,
|
||||
} => Self::DelegationReady {
|
||||
agent_id: agent_id.to_string(),
|
||||
ticket: ticket.to_string(),
|
||||
text: text.clone(),
|
||||
submit_sequence: submit_sequence.clone(),
|
||||
submit_delay_ms: *submit_delay_ms,
|
||||
},
|
||||
DomainEvent::AgentReplied {
|
||||
agent_id,
|
||||
reply_len,
|
||||
} => Self::AgentReplied {
|
||||
agent_id: agent_id.to_string(),
|
||||
reply_len: *reply_len,
|
||||
},
|
||||
DomainEvent::AgentLivenessChanged { agent_id, liveness } => {
|
||||
Self::AgentLivenessChanged {
|
||||
agent_id: agent_id.to_string(),
|
||||
liveness: (*liveness).into(),
|
||||
}
|
||||
}
|
||||
DomainEvent::AgentProfileChanged {
|
||||
agent_id,
|
||||
profile_id,
|
||||
} => Self::AgentProfileChanged {
|
||||
agent_id: agent_id.to_string(),
|
||||
profile_id: profile_id.to_string(),
|
||||
},
|
||||
DomainEvent::TemplateUpdated {
|
||||
template_id,
|
||||
version,
|
||||
@ -135,11 +317,7 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
template_id: template_id.to_string(),
|
||||
version: version.get(),
|
||||
},
|
||||
DomainEvent::AgentDriftDetected {
|
||||
agent_id,
|
||||
from,
|
||||
to,
|
||||
} => Self::AgentDriftDetected {
|
||||
DomainEvent::AgentDriftDetected { agent_id, from, to } => Self::AgentDriftDetected {
|
||||
agent_id: agent_id.to_string(),
|
||||
from: from.get(),
|
||||
to: to.get(),
|
||||
@ -166,6 +344,39 @@ impl From<&DomainEvent> for DomainEventDto {
|
||||
DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged {
|
||||
project_id: project_id.to_string(),
|
||||
},
|
||||
DomainEvent::OrchestratorRequestProcessed {
|
||||
requester_id,
|
||||
action,
|
||||
ok,
|
||||
source,
|
||||
} => Self::OrchestratorRequestProcessed {
|
||||
requester_id: requester_id.clone(),
|
||||
action: action.clone(),
|
||||
ok: *ok,
|
||||
source: (*source).into(),
|
||||
},
|
||||
DomainEvent::MemorySaved { slug } => Self::MemorySaved {
|
||||
slug: slug.as_str().to_string(),
|
||||
},
|
||||
DomainEvent::MemoryDeleted { slug } => Self::MemoryDeleted {
|
||||
slug: slug.as_str().to_string(),
|
||||
},
|
||||
DomainEvent::MemoryIndexRebuilt { project_id } => Self::MemoryIndexRebuilt {
|
||||
project_id: project_id.to_string(),
|
||||
},
|
||||
DomainEvent::EmbedderSuggested {
|
||||
project_id,
|
||||
ollama_detected,
|
||||
onnx_cached,
|
||||
vector_http_enabled,
|
||||
vector_onnx_enabled,
|
||||
} => Self::EmbedderSuggested {
|
||||
project_id: project_id.to_string(),
|
||||
ollama_detected: *ollama_detected,
|
||||
onnx_cached: onnx_cached.clone(),
|
||||
vector_http_enabled: *vector_http_enabled,
|
||||
vector_onnx_enabled: *vector_onnx_enabled,
|
||||
},
|
||||
DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput {
|
||||
session_id: session_id.to_string(),
|
||||
bytes: bytes.clone(),
|
||||
@ -203,3 +414,40 @@ pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::ids::AgentId;
|
||||
|
||||
fn agent(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
/// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO
|
||||
/// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que
|
||||
/// le front badge). Garantit le câblage présentation de la détection de stall.
|
||||
#[test]
|
||||
fn liveness_changed_stalled_relays_to_dto_and_wire() {
|
||||
let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged {
|
||||
agent_id: agent(7),
|
||||
liveness: AgentLiveness::Stalled,
|
||||
});
|
||||
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||
assert_eq!(json["type"], "agentLivenessChanged");
|
||||
assert_eq!(json["agentId"], agent(7).to_string());
|
||||
assert_eq!(json["liveness"], "stalled");
|
||||
}
|
||||
|
||||
/// La reprise `Stalled→Alive` se relaie en DTO `Alive` ⇒ wire `"alive"`.
|
||||
#[test]
|
||||
fn liveness_changed_alive_relays_to_dto_and_wire() {
|
||||
let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged {
|
||||
agent_id: agent(3),
|
||||
liveness: AgentLiveness::Alive,
|
||||
});
|
||||
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||
assert_eq!(json["type"], "agentLivenessChanged");
|
||||
assert_eq!(json["liveness"], "alive");
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,23 +5,56 @@
|
||||
//! ([`state::AppState`], the composition root),
|
||||
//! - exposes `#[tauri::command]` handlers ([`commands`]) mapping DTOs ↔ use cases,
|
||||
//! - relays domain events to the frontend ([`events::TauriEventRelay`]),
|
||||
//! - hosts the generic PTY↔Channel bridge ([`pty::PtyBridge`]) for L3.
|
||||
//! - hosts the generic PTY↔Channel bridge ([`pty::PtyBridge`]) for L3 and its
|
||||
//! structured-chat twin ([`chat::ChatBridge`]) for §17.
|
||||
//!
|
||||
//! The wiring lives in the library (testable) and `main.rs` is a thin shim.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod chat;
|
||||
pub mod commands;
|
||||
pub mod dto;
|
||||
pub mod events;
|
||||
pub mod mcp_bridge;
|
||||
pub mod mcp_endpoint;
|
||||
pub mod pty;
|
||||
pub mod state;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
use state::AppState;
|
||||
|
||||
/// The `argv[1]` subcommand token that switches the binary into the headless
|
||||
/// `mcp-server` **bridge** mode (cadrage v5 §1.3) instead of launching Tauri.
|
||||
pub const MCP_SERVER_SUBCOMMAND: &str = "mcp-server";
|
||||
|
||||
/// Process entry point: routes `argv` **before** anything Tauri/WebKit is touched.
|
||||
///
|
||||
/// When invoked as `<exe> mcp-server …` (an MCP CLI spawned us from the injected
|
||||
/// `.mcp.json` declaration), we run the **stdio↔loopback bridge** headless and
|
||||
/// **never** initialise the webview — see [`mcp_bridge::run_mcp_bridge`]. Any other
|
||||
/// invocation is the normal IDE launch: [`run`] (which blocks until the window
|
||||
/// closes and then exits the process itself).
|
||||
///
|
||||
/// Returns the [`ExitCode`] for the bridge path; the normal path does not return.
|
||||
#[must_use]
|
||||
pub fn dispatch() -> ExitCode {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
if args.next().is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND) {
|
||||
// Headless bridge: bypass Tauri entirely. Forward the remaining args
|
||||
// (`--endpoint`, `--project`, `--requester`) to the bridge parser.
|
||||
let rest: Vec<String> = args.map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
return mcp_bridge::run_mcp_bridge(rest);
|
||||
}
|
||||
|
||||
run();
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// Builds and runs the Tauri application.
|
||||
///
|
||||
/// Sets up the composition root (resolving the app-data directory via the Tauri
|
||||
@ -59,8 +92,22 @@ pub fn run() {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
if let Some(state) = handle.try_state::<AppState>() {
|
||||
let pty = std::sync::Arc::clone(&state.pty_port);
|
||||
// ORDER IS CRITICAL: freeze `agent_was_running` on every
|
||||
// agent leaf of every open project FIRST, reading the live
|
||||
// PTY registry as it stands now; only THEN kill the PTYs.
|
||||
// If we killed first, the registry would be empty and every
|
||||
// agent would be persisted as "closed".
|
||||
let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents);
|
||||
let open_projects = state.open_project_ids();
|
||||
let handles = state.terminal_sessions.handles();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
for project_id in open_projects {
|
||||
let _ = snapshot
|
||||
.execute(application::SnapshotRunningAgentsInput {
|
||||
project_id,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
for h in handles {
|
||||
let _ = pty.kill(&h).await;
|
||||
}
|
||||
@ -78,6 +125,12 @@ pub fn run() {
|
||||
commands::open_project,
|
||||
commands::close_project,
|
||||
commands::list_projects,
|
||||
commands::read_project_context,
|
||||
commands::update_project_context,
|
||||
commands::get_project_permissions,
|
||||
commands::update_project_permissions,
|
||||
commands::update_agent_permissions,
|
||||
commands::resolve_agent_permissions,
|
||||
commands::open_terminal,
|
||||
commands::write_terminal,
|
||||
commands::resize_terminal,
|
||||
@ -97,12 +150,28 @@ pub fn run() {
|
||||
commands::save_profile,
|
||||
commands::delete_profile,
|
||||
commands::configure_profiles,
|
||||
commands::list_embedder_profiles,
|
||||
commands::save_embedder_profile,
|
||||
commands::delete_embedder_profile,
|
||||
commands::describe_embedder_engines,
|
||||
commands::dismiss_embedder_suggestion,
|
||||
commands::create_agent,
|
||||
commands::list_agents,
|
||||
commands::list_live_agents,
|
||||
commands::attach_live_agent,
|
||||
commands::read_agent_context,
|
||||
commands::update_agent_context,
|
||||
commands::delete_agent,
|
||||
commands::launch_agent,
|
||||
commands::change_agent_profile,
|
||||
commands::agent_send,
|
||||
commands::interrupt_agent,
|
||||
commands::delegation_delivered,
|
||||
commands::set_front_attached,
|
||||
commands::reattach_agent_chat,
|
||||
commands::close_agent_session,
|
||||
commands::list_resumable_agents,
|
||||
commands::inspect_conversation,
|
||||
commands::create_template,
|
||||
commands::update_template,
|
||||
commands::list_templates,
|
||||
@ -119,6 +188,20 @@ pub fn run() {
|
||||
commands::git_log,
|
||||
commands::git_init,
|
||||
commands::git_graph,
|
||||
commands::create_skill,
|
||||
commands::update_skill,
|
||||
commands::list_skills,
|
||||
commands::delete_skill,
|
||||
commands::assign_skill_to_agent,
|
||||
commands::unassign_skill_from_agent,
|
||||
commands::create_memory,
|
||||
commands::update_memory,
|
||||
commands::list_memories,
|
||||
commands::get_memory,
|
||||
commands::delete_memory,
|
||||
commands::read_memory_index,
|
||||
commands::recall_memory,
|
||||
commands::resolve_memory_links,
|
||||
commands::move_tab_to_new_window,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
// Prevents an extra console window on Windows in release builds.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// WebKitGTK's DMABUF renderer causes a blank/white window on many Linux
|
||||
// setups (recent Mesa/Nvidia drivers, common on Arch). Disable it before
|
||||
// the webview initializes, unless the user has explicitly set the variable.
|
||||
// Harmless for the headless `mcp-server` bridge path, which ignores it.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||
@ -12,5 +15,6 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
app_tauri_lib::run();
|
||||
// Route argv before any Tauri/WebKit init: `mcp-server` ⇒ headless bridge.
|
||||
app_tauri_lib::dispatch()
|
||||
}
|
||||
|
||||
663
crates/app-tauri/src/mcp_bridge.rs
Normal file
663
crates/app-tauri/src/mcp_bridge.rs
Normal file
@ -0,0 +1,663 @@
|
||||
//! The `idea mcp-server` **bridge** — a transparent stdio↔loopback tube (M5b).
|
||||
//!
|
||||
//! ## Where this sits in the bind (cadrage v5 §1.3, §2)
|
||||
//!
|
||||
//! An MCP CLI (Claude Code, Codex) reads a `{command,args}` declaration and
|
||||
//! *spawns* `<exe IdeA> mcp-server --endpoint <…> --project <…> --requester <…>`.
|
||||
//! That spawned process is **this bridge**: it must **bypass Tauri/WebKit** and run
|
||||
//! headless. It speaks JSON-RPC (JSON Lines) to the CLI on **stdin/stdout**, and
|
||||
//! relays every line, byte-for-byte, over the project's **loopback** (the Unix
|
||||
//! socket / Windows named pipe bound by [`crate::state::AppState::ensure_mcp_server`]
|
||||
//! at [`crate::mcp_endpoint::mcp_endpoint`]). The real `McpServer` (which holds the
|
||||
//! `OrchestratorService`) lives in the Tauri process and answers in M5c.
|
||||
//!
|
||||
//! **Zero business logic.** The bridge knows only *stdio + loopback + JSON lines*:
|
||||
//! no `OrchestratorService`, no use case (hexagonal boundary, cadrage §5). It never
|
||||
//! parses the JSON-RPC payloads it carries.
|
||||
//!
|
||||
//! ## Handshake format (consumed by M5c)
|
||||
//!
|
||||
//! Right after connecting to the loopback, **before** any JSON-RPC traffic, the
|
||||
//! bridge writes a **single newline-terminated JSON line** carrying the caller's
|
||||
//! identity:
|
||||
//!
|
||||
//! ```text
|
||||
//! {"project":"<project-id>","requester":"<requester-id>"}\n
|
||||
//! ```
|
||||
//!
|
||||
//! - It is a **distinct line** from the JSON-RPC stream that follows: the server
|
||||
//! (M5c) reads exactly one line off a fresh connection, parses it as this
|
||||
//! handshake, then hands the rest of the stream to `McpServer::serve`.
|
||||
//! - `requester` is the **real agent id** (cadrage §1.4) — it lets the server tag
|
||||
//! `OrchestratorRequestProcessed.requester_id` with the actual agent instead of
|
||||
//! the frozen `"mcp"` placeholder. When `--requester` is omitted the field is the
|
||||
//! empty string.
|
||||
//! - Both values are JSON strings, so any future id shape stays escape-safe.
|
||||
//!
|
||||
//! ## Failure posture (never hang)
|
||||
//!
|
||||
//! - **Endpoint absent / unreachable** ⇒ connect with a **bounded timeout**; on
|
||||
//! timeout or connect error, return a **non-zero** exit code immediately. Never
|
||||
//! block forever waiting for a listener that will never appear.
|
||||
//! - **stdin EOF** (the CLI closed) ⇒ **clean exit, code 0**; the loopback
|
||||
//! connection is dropped (closed) on the way out.
|
||||
//! - **Missing required args** ⇒ a clear error on stderr and a non-zero code.
|
||||
|
||||
use std::process::ExitCode;
|
||||
use std::time::Duration;
|
||||
|
||||
use interprocess::local_socket::tokio::Stream as LocalSocketStream;
|
||||
use interprocess::local_socket::traits::tokio::Stream as _;
|
||||
use interprocess::local_socket::{GenericFilePath, ToFsName as _};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
|
||||
/// How long the bridge waits to connect to the project loopback before giving up.
|
||||
/// Bounded so an absent/unreachable endpoint fails fast instead of hanging.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// After the CLI closes stdin, how long to keep draining the loopback so an
|
||||
/// in-flight response still reaches the CLI. Bounded so the bridge never blocks
|
||||
/// waiting on the server: when stdin closes the CLI is gone, and the OS closes the
|
||||
/// loopback fd at process exit anyway.
|
||||
const DRAIN_GRACE: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Parsed `mcp-server` invocation arguments.
|
||||
///
|
||||
/// `--endpoint` is **required** (without it the bridge has nowhere to relay).
|
||||
/// `--project` and `--requester` are optional identity hints forwarded verbatim in
|
||||
/// the handshake; a missing one becomes the empty string.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BridgeArgs {
|
||||
/// The loopback address to connect to (the `--endpoint` value, i.e.
|
||||
/// [`crate::mcp_endpoint::McpEndpoint::as_cli_arg`]).
|
||||
pub endpoint: String,
|
||||
/// The project id (`--project`), forwarded in the handshake. Empty if absent.
|
||||
pub project: String,
|
||||
/// The requesting agent id (`--requester`), forwarded in the handshake. Empty
|
||||
/// if absent.
|
||||
pub requester: String,
|
||||
}
|
||||
|
||||
impl BridgeArgs {
|
||||
/// Parses `mcp-server`'s own arguments (i.e. `argv` **after** the `mcp-server`
|
||||
/// subcommand token). Recognises `--endpoint`, `--project`, `--requester`, each
|
||||
/// taking the following token as its value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns a human-readable message when `--endpoint` is missing, when a flag is
|
||||
/// given without a value, or when an unknown flag is encountered.
|
||||
pub fn parse<I, S>(args: I) -> Result<Self, String>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
let mut endpoint: Option<String> = None;
|
||||
let mut project = String::new();
|
||||
let mut requester = String::new();
|
||||
|
||||
let mut it = args.into_iter().map(Into::into);
|
||||
while let Some(flag) = it.next() {
|
||||
match flag.as_str() {
|
||||
"--endpoint" => {
|
||||
endpoint = Some(
|
||||
it.next()
|
||||
.ok_or_else(|| "--endpoint requires a value".to_string())?,
|
||||
);
|
||||
}
|
||||
"--project" => {
|
||||
project = it
|
||||
.next()
|
||||
.ok_or_else(|| "--project requires a value".to_string())?;
|
||||
}
|
||||
"--requester" => {
|
||||
requester = it
|
||||
.next()
|
||||
.ok_or_else(|| "--requester requires a value".to_string())?;
|
||||
}
|
||||
other => {
|
||||
return Err(format!("unknown argument: {other}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let endpoint = endpoint.ok_or_else(|| "--endpoint is required".to_string())?;
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
project,
|
||||
requester,
|
||||
})
|
||||
}
|
||||
|
||||
/// The first handshake line the bridge writes on the loopback (newline
|
||||
/// included). See the module docs for the format consumed by M5c.
|
||||
fn handshake_line(&self) -> Vec<u8> {
|
||||
let line = serde_json::json!({
|
||||
"project": self.project,
|
||||
"requester": self.requester,
|
||||
})
|
||||
.to_string();
|
||||
let mut bytes = line.into_bytes();
|
||||
bytes.push(b'\n');
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronous entry point called from `main`/`lib::run` when `argv[1] ==
|
||||
/// "mcp-server"`. Parses the args, stands up a Tokio runtime, and runs the bridge.
|
||||
///
|
||||
/// Returns the process [`ExitCode`]: `0` on a clean stdin-EOF shutdown, non-zero on
|
||||
/// any argument/connection/relay failure. Never blocks indefinitely (connect is
|
||||
/// time-bounded).
|
||||
#[must_use]
|
||||
pub fn run_mcp_bridge<I, S>(args: I) -> ExitCode
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
let args = match BridgeArgs::parse(args) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
eprintln!("idea mcp-server: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
// A current-thread runtime is enough: the bridge is two coupled I/O loops, no
|
||||
// CPU work. Keeps the headless process lean (no Tauri, no webview).
|
||||
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
eprintln!("idea mcp-server: failed to start runtime: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
runtime.block_on(async move {
|
||||
match bridge_over_loopback(&args).await {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("idea mcp-server: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Connects to the project loopback at `args.endpoint` (time-bounded) and relays
|
||||
/// between the **real stdio** of this process and that connection.
|
||||
async fn bridge_over_loopback(args: &BridgeArgs) -> Result<(), String> {
|
||||
let conn = connect_loopback(&args.endpoint).await?;
|
||||
// Split the duplex loopback into independent owned halves so the two relay
|
||||
// directions can run without sharing a lock.
|
||||
let (lp_read, lp_write) = tokio::io::split(conn);
|
||||
relay(
|
||||
args,
|
||||
tokio::io::stdin(),
|
||||
tokio::io::stdout(),
|
||||
lp_read,
|
||||
lp_write,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Connects to the loopback endpoint with a bounded timeout. An absent or
|
||||
/// unreachable endpoint surfaces as an `Err` (⇒ non-zero exit), **never a hang**.
|
||||
async fn connect_loopback(endpoint: &str) -> Result<LocalSocketStream, String> {
|
||||
let name = endpoint
|
||||
.to_fs_name::<GenericFilePath>()
|
||||
.map_err(|e| format!("invalid endpoint {endpoint:?}: {e}"))?;
|
||||
|
||||
match tokio::time::timeout(CONNECT_TIMEOUT, LocalSocketStream::connect(name)).await {
|
||||
Ok(Ok(stream)) => Ok(stream),
|
||||
Ok(Err(e)) => Err(format!("cannot connect to endpoint {endpoint:?}: {e}")),
|
||||
Err(_) => Err(format!(
|
||||
"timed out connecting to endpoint {endpoint:?} after {}s",
|
||||
CONNECT_TIMEOUT.as_secs()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The transparent relay, generic over its four streams so tests drive it with
|
||||
/// in-memory pipes (no real stdio, no real socket).
|
||||
///
|
||||
/// Sequence:
|
||||
/// 1. Write the **handshake line** (`project`+`requester`) to the loopback.
|
||||
/// 2. Run **two independent directional pumps concurrently** (full duplex):
|
||||
/// - `CLI → loopback`: every line from `cli_in` is forwarded to the loopback;
|
||||
/// - `loopback → CLI`: every line from the loopback is forwarded to `cli_out`.
|
||||
/// 3. **CLI stdin EOF** ⇒ half-close the loopback write side (so the server sees
|
||||
/// EOF), **drain** any responses still in flight, then return `Ok(())`.
|
||||
///
|
||||
/// ## Why full duplex (and not lockstep)
|
||||
///
|
||||
/// JSON-RPC over MCP is **not** one-response-per-request. **Notifications** (e.g.
|
||||
/// `notifications/initialized` sent right after `initialize`) carry no `id` and get
|
||||
/// **no response**, and the server may push messages unsolicited. A lockstep pump
|
||||
/// that reads one CLI line then *blocks* for exactly one loopback line **deadlocks**
|
||||
/// on the first notification: it waits forever for a reply that never comes, and
|
||||
/// never reads the client's next request (e.g. `tools/list`) — so the CLI never
|
||||
/// receives its tool list. Two decoupled pumps let notifications and asynchronous
|
||||
/// server messages flow freely in both directions.
|
||||
async fn relay<CIn, COut, LIn, LOut>(
|
||||
args: &BridgeArgs,
|
||||
cli_in: CIn,
|
||||
cli_out: COut,
|
||||
lp_read: LIn,
|
||||
mut lp_write: LOut,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
CIn: AsyncRead + Unpin,
|
||||
COut: AsyncWrite + Unpin,
|
||||
LIn: AsyncRead + Unpin,
|
||||
LOut: AsyncWrite + Unpin,
|
||||
{
|
||||
// 1. Handshake (identity) before any JSON-RPC byte.
|
||||
lp_write
|
||||
.write_all(&args.handshake_line())
|
||||
.await
|
||||
.map_err(|e| format!("handshake write failed: {e}"))?;
|
||||
lp_write
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("handshake flush failed: {e}"))?;
|
||||
|
||||
// 2. Two decoupled pumps. Each owns its streams so neither blocks the other.
|
||||
let cli_to_lp = pump_lines(BufReader::new(cli_in), lp_write, "stdin", "loopback");
|
||||
let lp_to_cli = pump_lines(BufReader::new(lp_read), cli_out, "loopback", "stdout");
|
||||
tokio::pin!(cli_to_lp);
|
||||
tokio::pin!(lp_to_cli);
|
||||
|
||||
tokio::select! {
|
||||
// CLI closed stdin (or errored): half-close the loopback writer to nudge
|
||||
// the server, then drain briefly so an in-flight response still reaches the
|
||||
// CLI — but never block on the server (bounded by DRAIN_GRACE).
|
||||
r = &mut cli_to_lp => {
|
||||
let mut lp_write = r?;
|
||||
let _ = lp_write.shutdown().await;
|
||||
let _ = tokio::time::timeout(DRAIN_GRACE, &mut lp_to_cli).await;
|
||||
Ok(())
|
||||
}
|
||||
// Loopback closed first (server hangup): nothing left to relay. The CLI's
|
||||
// stdin EOF is no longer needed to exit.
|
||||
r = &mut lp_to_cli => r.map(|_| ()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forwards every newline-delimited line from `reader` to `writer` until `reader`
|
||||
/// hits EOF, flushing after each line so a peer blocked on a read sees it promptly.
|
||||
///
|
||||
/// On clean EOF it returns the (now-drained) `writer` so the caller can half-close
|
||||
/// it. `src`/`dst` name the two ends for error messages only.
|
||||
async fn pump_lines<R, W>(
|
||||
mut reader: BufReader<R>,
|
||||
mut writer: W,
|
||||
src: &str,
|
||||
dst: &str,
|
||||
) -> Result<W, String>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
let n = reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.map_err(|e| format!("{src} read failed: {e}"))?;
|
||||
if n == 0 {
|
||||
// Source closed: drain and hand the writer back for half-close.
|
||||
writer
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("{dst} flush failed: {e}"))?;
|
||||
return Ok(writer);
|
||||
}
|
||||
writer
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("{dst} write failed: {e}"))?;
|
||||
writer
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("{dst} flush failed: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
use interprocess::local_socket::tokio::Listener as LocalSocketListener;
|
||||
use interprocess::local_socket::traits::tokio::Listener as _;
|
||||
use interprocess::local_socket::{GenericFilePath, ListenerOptions};
|
||||
use tokio::io::AsyncReadExt as _;
|
||||
|
||||
// ---- argument parsing -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parses_all_three_flags() {
|
||||
let a = BridgeArgs::parse([
|
||||
"--endpoint",
|
||||
"/tmp/x.sock",
|
||||
"--project",
|
||||
"p1",
|
||||
"--requester",
|
||||
"agent-7",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(a.endpoint, "/tmp/x.sock");
|
||||
assert_eq!(a.project, "p1");
|
||||
assert_eq!(a.requester, "agent-7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_is_required() {
|
||||
let err = BridgeArgs::parse(["--project", "p1"]).unwrap_err();
|
||||
assert!(err.contains("--endpoint"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_and_requester_default_to_empty() {
|
||||
let a = BridgeArgs::parse(["--endpoint", "/tmp/x.sock"]).unwrap();
|
||||
assert_eq!(a.project, "");
|
||||
assert_eq!(a.requester, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flag_without_value_is_an_error() {
|
||||
assert!(BridgeArgs::parse(["--endpoint"]).is_err());
|
||||
assert!(BridgeArgs::parse(["--endpoint", "/x", "--project"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_flag_is_an_error() {
|
||||
let err = BridgeArgs::parse(["--endpoint", "/x", "--bogus"]).unwrap_err();
|
||||
assert!(err.contains("unknown"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_line_carries_identity_as_json() {
|
||||
let a = BridgeArgs {
|
||||
endpoint: "/tmp/x.sock".into(),
|
||||
project: "proj".into(),
|
||||
requester: "rq".into(),
|
||||
};
|
||||
let line = a.handshake_line();
|
||||
assert_eq!(*line.last().unwrap(), b'\n');
|
||||
let v: serde_json::Value = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
|
||||
assert_eq!(v["project"], "proj");
|
||||
assert_eq!(v["requester"], "rq");
|
||||
}
|
||||
|
||||
// ---- relay (in-memory streams, no socket) -----------------------------
|
||||
|
||||
/// Nominal relay over in-memory duplex pipes: handshake reaches the fake
|
||||
/// server, a scripted request is forwarded, and the canned response comes back
|
||||
/// on the CLI stdout.
|
||||
#[tokio::test]
|
||||
async fn relay_forwards_handshake_request_and_response() {
|
||||
// CLI stdin: one JSON-RPC request line, then EOF.
|
||||
let cli_in = b"{\"method\":\"tools/call\",\"id\":1}\n".to_vec();
|
||||
let mut cli_out: Vec<u8> = Vec::new();
|
||||
|
||||
// The "loopback" is a duplex pipe: the bridge writes to `lp_write`/reads
|
||||
// from `lp_read`; the fake server reads from `srv_read`/writes to
|
||||
// `srv_write`.
|
||||
let (lp_read, srv_write) = tokio::io::duplex(4096); // server → bridge
|
||||
let (srv_read, lp_write) = tokio::io::duplex(4096); // bridge → server
|
||||
|
||||
let args = BridgeArgs {
|
||||
endpoint: "unused".into(),
|
||||
project: "proj-1".into(),
|
||||
requester: "agent-9".into(),
|
||||
};
|
||||
|
||||
// Fake server: read handshake line, then read the request line, then answer.
|
||||
let server = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(srv_read);
|
||||
let mut handshake = String::new();
|
||||
reader.read_line(&mut handshake).await.unwrap();
|
||||
let mut request = String::new();
|
||||
reader.read_line(&mut request).await.unwrap();
|
||||
|
||||
let mut w = srv_write;
|
||||
w.write_all(b"{\"result\":\"ok\",\"id\":1}\n")
|
||||
.await
|
||||
.unwrap();
|
||||
w.flush().await.unwrap();
|
||||
(handshake, request)
|
||||
});
|
||||
|
||||
relay(&args, &cli_in[..], &mut cli_out, lp_read, lp_write)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (handshake, request) = server.await.unwrap();
|
||||
let hs: serde_json::Value = serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
assert_eq!(hs["project"], "proj-1");
|
||||
assert_eq!(hs["requester"], "agent-9");
|
||||
assert_eq!(request.trim_end(), "{\"method\":\"tools/call\",\"id\":1}");
|
||||
assert_eq!(
|
||||
String::from_utf8(cli_out).unwrap(),
|
||||
"{\"result\":\"ok\",\"id\":1}\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression (the bug that hid the tool list for days): a **notification**
|
||||
/// (no `id`, no response) sent between two requests must NOT stall the relay.
|
||||
/// A lockstep pump deadlocks here — after forwarding the notification it blocks
|
||||
/// waiting for a reply that never comes, and never reads `tools/list`. The
|
||||
/// full-duplex relay forwards all three lines and delivers the one response.
|
||||
#[tokio::test]
|
||||
async fn relay_does_not_block_on_notification_without_response() {
|
||||
// initialize result, then an unanswered notification, then tools/list.
|
||||
let cli_in = b"{\"method\":\"initialize\",\"id\":1}\n\
|
||||
{\"method\":\"notifications/initialized\"}\n\
|
||||
{\"method\":\"tools/list\",\"id\":2}\n"
|
||||
.to_vec();
|
||||
let mut cli_out: Vec<u8> = Vec::new();
|
||||
|
||||
let (lp_read, srv_write) = tokio::io::duplex(4096); // server → bridge
|
||||
let (srv_read, lp_write) = tokio::io::duplex(4096); // bridge → server
|
||||
|
||||
let args = BridgeArgs {
|
||||
endpoint: "unused".into(),
|
||||
project: "p".into(),
|
||||
requester: "agent".into(),
|
||||
};
|
||||
|
||||
// Server: handshake, then read all three forwarded lines, answering only
|
||||
// the two that carry an `id`. The notification gets no reply — exactly the
|
||||
// case that used to wedge the relay.
|
||||
let server = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(srv_read);
|
||||
let mut w = srv_write;
|
||||
for _ in 0..4 {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).await.unwrap() == 0 {
|
||||
break;
|
||||
}
|
||||
let v: serde_json::Value = match serde_json::from_str(line.trim_end()) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue, // handshake line
|
||||
};
|
||||
if let Some(id) = v.get("id") {
|
||||
w.write_all(format!("{{\"result\":\"ok\",\"id\":{id}}}\n").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
w.flush().await.unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
relay(&args, &cli_in[..], &mut cli_out, lp_read, lp_write),
|
||||
)
|
||||
.await
|
||||
.expect("relay must not deadlock on a notification")
|
||||
.expect("relay ok");
|
||||
|
||||
server.await.unwrap();
|
||||
|
||||
let out = String::from_utf8(cli_out).unwrap();
|
||||
// Both request responses arrived; the notification produced none.
|
||||
assert!(out.contains("\"id\":1"), "missing initialize response: {out}");
|
||||
assert!(out.contains("\"id\":2"), "missing tools/list response: {out}");
|
||||
}
|
||||
|
||||
/// stdin EOF with no request ⇒ relay returns `Ok` (code 0), having still sent
|
||||
/// the handshake.
|
||||
#[tokio::test]
|
||||
async fn relay_clean_exit_on_immediate_eof() {
|
||||
let cli_in: &[u8] = b""; // immediate EOF
|
||||
let mut cli_out: Vec<u8> = Vec::new();
|
||||
|
||||
let (lp_read, srv_write) = tokio::io::duplex(4096);
|
||||
let (srv_read, lp_write) = tokio::io::duplex(4096);
|
||||
|
||||
let args = BridgeArgs {
|
||||
endpoint: "unused".into(),
|
||||
project: "p".into(),
|
||||
requester: String::new(),
|
||||
};
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(srv_read);
|
||||
let mut handshake = String::new();
|
||||
reader.read_line(&mut handshake).await.unwrap();
|
||||
// Keep srv_write alive until the bridge drops its writer (EOF).
|
||||
let mut buf = Vec::new();
|
||||
let _ = reader.read_to_end(&mut buf).await;
|
||||
drop(srv_write);
|
||||
handshake
|
||||
});
|
||||
|
||||
relay(&args, cli_in, &mut cli_out, lp_read, lp_write)
|
||||
.await
|
||||
.expect("clean EOF exit");
|
||||
|
||||
let handshake = server.await.unwrap();
|
||||
assert!(handshake.contains("\"project\":\"p\""));
|
||||
assert!(cli_out.is_empty(), "no response expected on immediate EOF");
|
||||
}
|
||||
|
||||
// ---- end-to-end over a real loopback ----------------------------------
|
||||
|
||||
fn temp_endpoint(tag: &str) -> String {
|
||||
let dir = std::env::temp_dir();
|
||||
let pid = std::process::id();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
dir.join(format!("idea-mcp-bridge-test-{tag}-{pid}-{nanos}.sock"))
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn bind(endpoint: &str) -> LocalSocketListener {
|
||||
let name = endpoint.to_fs_name::<GenericFilePath>().unwrap();
|
||||
ListenerOptions::new()
|
||||
.name(name)
|
||||
.reclaim_name(true)
|
||||
.create_tokio()
|
||||
.expect("bind test listener")
|
||||
}
|
||||
|
||||
/// Endpoint absent ⇒ `connect_loopback` fails fast (no hang), so the bridge
|
||||
/// would exit non-zero. Bounded by a test timeout well under CONNECT_TIMEOUT
|
||||
/// for the "connect error" path (a non-existent socket errors immediately).
|
||||
#[tokio::test]
|
||||
async fn connect_to_absent_endpoint_errors_fast() {
|
||||
let endpoint = temp_endpoint("absent");
|
||||
let res = tokio::time::timeout(Duration::from_secs(10), connect_loopback(&endpoint))
|
||||
.await
|
||||
.expect("connect_loopback must not hang");
|
||||
assert!(res.is_err(), "absent endpoint must yield an error");
|
||||
}
|
||||
|
||||
/// `run_mcp_bridge` with a missing `--endpoint` returns a non-zero code.
|
||||
#[test]
|
||||
fn run_bridge_missing_endpoint_is_non_zero() {
|
||||
let code = run_mcp_bridge(["--project", "p"]);
|
||||
assert_eq!(code, ExitCode::FAILURE);
|
||||
}
|
||||
|
||||
/// Full end-to-end over a **real** interprocess loopback: a test listener plays
|
||||
/// the server (reads the handshake, echoes a canned response); the relay drives
|
||||
/// it over an actual connection. Verifies the handshake crosses the real socket
|
||||
/// and the response returns on the CLI stdout.
|
||||
#[tokio::test]
|
||||
async fn end_to_end_over_real_loopback() {
|
||||
let endpoint = temp_endpoint("e2e");
|
||||
let listener = bind(&endpoint);
|
||||
|
||||
let captured = Arc::new(tokio::sync::Mutex::new((String::new(), String::new())));
|
||||
let captured_srv = Arc::clone(&captured);
|
||||
|
||||
// Fake server accepts one connection, reads handshake + request, answers.
|
||||
let server = tokio::spawn(async move {
|
||||
let conn = listener.accept().await.unwrap();
|
||||
let (r, w) = tokio::io::split(conn);
|
||||
let mut reader = BufReader::new(r);
|
||||
let mut handshake = String::new();
|
||||
reader.read_line(&mut handshake).await.unwrap();
|
||||
let mut request = String::new();
|
||||
reader.read_line(&mut request).await.unwrap();
|
||||
*captured_srv.lock().await = (handshake, request);
|
||||
|
||||
let mut w = w;
|
||||
w.write_all(b"{\"result\":\"pong\",\"id\":42}\n")
|
||||
.await
|
||||
.unwrap();
|
||||
w.flush().await.unwrap();
|
||||
// Hold until the bridge finishes (its stdin EOF will end it).
|
||||
let mut rest = Vec::new();
|
||||
let _ = reader.read_to_end(&mut rest).await;
|
||||
});
|
||||
|
||||
let args = BridgeArgs {
|
||||
endpoint: endpoint.clone(),
|
||||
project: "proj-e2e".into(),
|
||||
requester: "agent-e2e".into(),
|
||||
};
|
||||
|
||||
let cli_in = b"{\"method\":\"ping\",\"id\":42}\n".to_vec();
|
||||
let mut cli_out: Vec<u8> = Vec::new();
|
||||
|
||||
// Drive the whole bridge_over_loopback path (real connect + split + relay),
|
||||
// but feed our own stdio streams via `relay` to avoid touching process std.
|
||||
let conn = tokio::time::timeout(Duration::from_secs(10), connect_loopback(&endpoint))
|
||||
.await
|
||||
.expect("no hang")
|
||||
.expect("connect to live endpoint");
|
||||
let (lp_read, lp_write) = tokio::io::split(conn);
|
||||
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
relay(&args, &cli_in[..], &mut cli_out, lp_read, lp_write),
|
||||
)
|
||||
.await
|
||||
.expect("relay must not hang")
|
||||
.expect("relay ok");
|
||||
|
||||
server.await.unwrap();
|
||||
|
||||
let (handshake, request) = captured.lock().await.clone();
|
||||
let hs: serde_json::Value = serde_json::from_str(handshake.trim_end()).unwrap();
|
||||
assert_eq!(hs["project"], "proj-e2e");
|
||||
assert_eq!(hs["requester"], "agent-e2e");
|
||||
assert_eq!(request.trim_end(), "{\"method\":\"ping\",\"id\":42}");
|
||||
assert_eq!(
|
||||
String::from_utf8(cli_out).unwrap(),
|
||||
"{\"result\":\"pong\",\"id\":42}\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
293
crates/app-tauri/src/mcp_endpoint.rs
Normal file
293
crates/app-tauri/src/mcp_endpoint.rs
Normal file
@ -0,0 +1,293 @@
|
||||
//! Per-project loopback **endpoint** for the IdeA MCP transport (M5a).
|
||||
//!
|
||||
//! ## Where this sits in the bind (cadrage v5 §1, §2)
|
||||
//!
|
||||
//! The S-MCP transport is **stdio-spawn**: an MCP CLI (Claude Code, Codex)
|
||||
//! reads a `{command,args}` declaration and *spawns* a thin `idea mcp-server`
|
||||
//! bridge. That bridge talks JSON-RPC on its stdin/stdout to the CLI and relays
|
||||
//! every line, over a **local loopback**, to the IdeA (Tauri) process where the
|
||||
//! real [`OrchestratorService`](application::OrchestratorService) lives. The
|
||||
//! loopback is a **Unix domain socket** (Linux/macOS) or a **Windows named
|
||||
//! pipe** — never a network port, so it stays AppImage- and SSH-remote-safe and
|
||||
//! needs no firewall/permission.
|
||||
//!
|
||||
//! This module owns the **single source of truth** for that loopback address:
|
||||
//! [`mcp_endpoint`]. It is deterministic per [`ProjectId`] and is called by
|
||||
//! **both** sides of the contract (cadrage §2):
|
||||
//! - the side that *listens* — [`ensure_mcp_server`](crate::state::AppState::ensure_mcp_server),
|
||||
//! which binds the listener at project-open and tears it down at close (M5a);
|
||||
//! - the side that *writes the CLI declaration* — `apply_mcp_config` (M5d),
|
||||
//! which must point the spawned bridge at the **exact** same address.
|
||||
//!
|
||||
//! Keeping the derivation here (not duplicated on each side) is what makes the
|
||||
//! M1↔M3 coherence test possible.
|
||||
//!
|
||||
//! ## Transport crate choice — `interprocess`
|
||||
//!
|
||||
//! We use the [`interprocess`] crate's *local socket* abstraction rather than
|
||||
//! `tokio::net::{UnixListener, windows::named_pipe}` directly, because:
|
||||
//! - **One cross-OS API** for UDS (Unix) and named pipes (Windows): a single
|
||||
//! `accept` loop in M5c, no divergent `cfg` branches with two transport types.
|
||||
//! - The workspace `tokio` is built **without** the `net` feature; pulling
|
||||
//! `interprocess` (with its `tokio` feature) into *only* this crate avoids
|
||||
//! widening tokio's surface workspace-wide.
|
||||
//! - On Unix its listener carries a *reclaim guard* that **unlinks the socket
|
||||
//! file on drop** — so closing a project (dropping the handle) cleans the
|
||||
//! filesystem with no leak, satisfying M5a's teardown requirement for free.
|
||||
//!
|
||||
//! We deliberately bind a **filesystem-path** socket on Unix (under a per-user
|
||||
//! runtime dir) rather than an abstract/namespaced one, so that the existence of
|
||||
//! the endpoint is observable as a real path (testable) and cleaned up on close.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use application::{McpRuntime, McpRuntimeProvider};
|
||||
use domain::{AgentId, Project, ProjectId};
|
||||
|
||||
/// The loopback address of a project's MCP endpoint — the value [`mcp_endpoint`]
|
||||
/// returns. Deterministic per [`ProjectId`]; the single source of truth shared by
|
||||
/// the listener (M5a/M3) and the CLI-declaration writer (M5d).
|
||||
///
|
||||
/// On Unix it is a **filesystem path** to a Unix-domain socket (the file is what
|
||||
/// gets bound and, on close, unlinked). On Windows it is a **named pipe** path of
|
||||
/// the form `\\.\pipe\idea-mcp-<id>`. The [`as_cli_arg`](Self::as_cli_arg) form is
|
||||
/// the exact string handed to the spawned bridge via `--endpoint`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpEndpoint {
|
||||
/// The platform-native address string (UDS path / named-pipe path).
|
||||
addr: String,
|
||||
}
|
||||
|
||||
impl McpEndpoint {
|
||||
/// The address as the bridge expects it on the command line (`--endpoint`).
|
||||
/// Identical string on both sides of the contract (cadrage §2).
|
||||
#[must_use]
|
||||
pub fn as_cli_arg(&self) -> &str {
|
||||
&self.addr
|
||||
}
|
||||
|
||||
/// The Unix socket **file path**, when this endpoint is a filesystem-path UDS.
|
||||
/// Used by the listener to ensure the parent runtime dir exists and by tests to
|
||||
/// assert creation/cleanup. `None` on platforms whose address is not a path
|
||||
/// (Windows named pipes have no filesystem entry to manage).
|
||||
#[must_use]
|
||||
pub fn socket_path(&self) -> Option<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
Some(PathBuf::from(&self.addr))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory under which per-project Unix sockets live, derived from the
|
||||
/// per-user runtime dir (`$XDG_RUNTIME_DIR`, falling back to `$TMPDIR`, then
|
||||
/// `/tmp`). Kept short so the full socket path stays well under the ~108-byte
|
||||
/// `sockaddr_un` limit (`/run/user/<uid>/idea-mcp/<32 hex>.sock` ≈ 50 bytes).
|
||||
#[cfg(unix)]
|
||||
fn unix_runtime_dir() -> PathBuf {
|
||||
let base = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("TMPDIR").map(PathBuf::from))
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"));
|
||||
base.join("idea-mcp")
|
||||
}
|
||||
|
||||
/// **Single source of truth.** Computes the deterministic loopback endpoint of a
|
||||
/// project's MCP transport from its [`ProjectId`].
|
||||
///
|
||||
/// Same input ⇒ same output (stable across calls and processes); distinct projects
|
||||
/// ⇒ distinct endpoints (the id's 32-hex *simple* form is unique and collision-free
|
||||
/// by construction). No network port is ever used.
|
||||
///
|
||||
/// - **Unix**: `<runtime-dir>/idea-mcp/<id>.sock` — a UDS file path.
|
||||
/// - **Windows**: `\\.\pipe\idea-mcp-<id>` — a named pipe.
|
||||
///
|
||||
/// where `<id>` is the project UUID in hyphen-free hex (`simple`) form.
|
||||
#[must_use]
|
||||
pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint {
|
||||
// Hyphen-free, lowercase, fixed-width (32 chars): safe in both a filename and
|
||||
// a `\\.\pipe\` name, and unique per project.
|
||||
let id = project_id.as_uuid().simple().to_string();
|
||||
|
||||
#[cfg(unix)]
|
||||
let addr = unix_runtime_dir()
|
||||
.join(format!("{id}.sock"))
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
#[cfg(windows)]
|
||||
let addr = format!(r"\\.\pipe\idea-mcp-{id}");
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let addr = format!("idea-mcp-{id}");
|
||||
|
||||
McpEndpoint { addr }
|
||||
}
|
||||
|
||||
/// Chemin du binaire IdeA à inscrire comme `command` dans la déclaration MCP.
|
||||
///
|
||||
/// Privilégie `$APPIMAGE` (chemin **stable** du `.AppImage`, qui survit aux
|
||||
/// redémarrages) car sous AppImage `current_exe()` pointe sur un montage éphémère
|
||||
/// `/tmp/.mount_*` qui disparaît au redémarrage ⇒ `ENOENT` au respawn du pont MCP.
|
||||
/// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si
|
||||
/// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale.
|
||||
pub(crate) fn idea_exe_path() -> Option<String> {
|
||||
std::env::var("APPIMAGE").ok().or_else(|| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// Implémentation app-tauri du port [`McpRuntimeProvider`] : fournit à
|
||||
/// l'orchestrateur (couche `application`) les **faits OS/runtime** nécessaires pour
|
||||
/// écrire la déclaration MCP réelle quand il (re)lance une cible sur le chemin
|
||||
/// `ask` (`ensure_live_pty`).
|
||||
///
|
||||
/// Sans état : tout est dérivé du `Project` et de l'`AgentId` reçus + de
|
||||
/// l'environnement (`$APPIMAGE`/`current_exe`). C'est le **seul** détenteur légitime
|
||||
/// de ces faits (la couche `application` ne doit pas dépendre de `current_exe` /
|
||||
/// `$APPIMAGE` / `mcp_endpoint`, cadrage v5 §0.3 / §7) ; seules des **chaînes**
|
||||
/// traversent la frontière via [`McpRuntime`].
|
||||
pub struct AppMcpRuntimeProvider;
|
||||
|
||||
impl McpRuntimeProvider for AppMcpRuntimeProvider {
|
||||
/// `agent_id` = la cible relancée = le `--requester` (c'est elle qui appellera
|
||||
/// `idea_reply`). Réutilise la **même** logique que le chemin GUI
|
||||
/// (`commands.rs::launch_agent`) : même endpoint (source de vérité unique),
|
||||
/// même forme `simple` 32-hex du `project_id`. `None` (exe introuvable) ⇒
|
||||
/// l'orchestrateur dégrade vers la déclaration minimale, jamais d'échec de
|
||||
/// lancement.
|
||||
fn runtime_for(&self, project: &Project, agent_id: AgentId) -> Option<McpRuntime> {
|
||||
Some(McpRuntime {
|
||||
exe: idea_exe_path()?,
|
||||
endpoint: mcp_endpoint(&project.id).as_cli_arg().to_owned(),
|
||||
project_id: project.id.as_uuid().simple().to_string(),
|
||||
requester: agent_id.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn pid(s: &str) -> ProjectId {
|
||||
ProjectId::from_uuid(Uuid::parse_str(s).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_is_deterministic_for_the_same_project() {
|
||||
let p = pid("11111111-1111-1111-1111-111111111111");
|
||||
assert_eq!(mcp_endpoint(&p), mcp_endpoint(&p));
|
||||
assert_eq!(mcp_endpoint(&p).as_cli_arg(), mcp_endpoint(&p).as_cli_arg());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_projects_get_distinct_endpoints() {
|
||||
let a = pid("11111111-1111-1111-1111-111111111111");
|
||||
let b = pid("22222222-2222-2222-2222-222222222222");
|
||||
assert_ne!(mcp_endpoint(&a), mcp_endpoint(&b));
|
||||
assert_ne!(mcp_endpoint(&a).as_cli_arg(), mcp_endpoint(&b).as_cli_arg());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_encodes_the_project_id_without_hyphens() {
|
||||
let p = pid("abcdef01-2345-6789-abcd-ef0123456789");
|
||||
let arg = mcp_endpoint(&p).as_cli_arg().to_owned();
|
||||
assert!(
|
||||
arg.contains("abcdef0123456789abcdef0123456789"),
|
||||
"endpoint must embed the hyphen-free id, got {arg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unix_endpoint_is_a_sock_path_under_the_runtime_dir() {
|
||||
let p = pid("11111111-1111-1111-1111-111111111111");
|
||||
let ep = mcp_endpoint(&p);
|
||||
let path = ep
|
||||
.socket_path()
|
||||
.expect("unix endpoint exposes a socket path");
|
||||
assert!(path.extension().is_some_and(|e| e == "sock"));
|
||||
assert_eq!(path.parent().unwrap(), unix_runtime_dir());
|
||||
}
|
||||
|
||||
/// `idea_exe_path()` : `$APPIMAGE` posé ⇒ on renvoie sa valeur (chemin stable du
|
||||
/// `.AppImage`, qui survit aux redémarrages) ; absent ⇒ on retombe sur
|
||||
/// `current_exe()`. Les deux assertions sont regroupées dans **un seul** test pour
|
||||
/// éviter une course inter-tests sur la variable d'env globale du process ; la var
|
||||
/// est sauvegardée/restaurée pour ne pas polluer les autres tests.
|
||||
#[test]
|
||||
fn idea_exe_path_prefers_appimage_then_falls_back_to_current_exe() {
|
||||
let saved = std::env::var_os("APPIMAGE");
|
||||
|
||||
// APPIMAGE posé ⇒ valeur exacte renvoyée.
|
||||
std::env::set_var("APPIMAGE", "/opt/idea/IdeA.AppImage");
|
||||
assert_eq!(
|
||||
idea_exe_path().as_deref(),
|
||||
Some("/opt/idea/IdeA.AppImage"),
|
||||
"$APPIMAGE doit primer"
|
||||
);
|
||||
|
||||
// APPIMAGE absent ⇒ repli sur current_exe() (présent dans un binaire de test).
|
||||
std::env::remove_var("APPIMAGE");
|
||||
let fallback = idea_exe_path();
|
||||
let expected = std::env::current_exe()
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().into_owned());
|
||||
assert_eq!(fallback, expected, "repli attendu sur current_exe()");
|
||||
assert!(fallback.is_some(), "current_exe() résolvable dans le test");
|
||||
|
||||
// Restauration de l'état initial de la variable.
|
||||
match saved {
|
||||
Some(v) => std::env::set_var("APPIMAGE", v),
|
||||
None => std::env::remove_var("APPIMAGE"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `AppMcpRuntimeProvider::runtime_for` : cohérence des champs avec les sources de
|
||||
/// vérité — endpoint identique à `mcp_endpoint(project.id).as_cli_arg()`,
|
||||
/// `project_id` en forme `simple` 32-hex, `requester == agent_id.to_string()`.
|
||||
#[test]
|
||||
fn app_provider_runtime_for_matches_sources_of_truth() {
|
||||
use domain::project::ProjectPath;
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{AgentId, Project, ProjectId};
|
||||
|
||||
let project = Project::new(
|
||||
ProjectId::from_uuid(Uuid::parse_str("abcdef01-2345-6789-abcd-ef0123456789").unwrap()),
|
||||
"demo",
|
||||
ProjectPath::new("/home/me/proj").unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap();
|
||||
let agent_id = AgentId::from_uuid(Uuid::from_u128(42));
|
||||
|
||||
// On s'assure que current_exe/$APPIMAGE est résolvable (sinon runtime_for None).
|
||||
let rt = AppMcpRuntimeProvider
|
||||
.runtime_for(&project, agent_id)
|
||||
.expect("runtime_for doit produire un McpRuntime (exe résolvable)");
|
||||
|
||||
// Endpoint = même source de vérité que le listener.
|
||||
assert_eq!(
|
||||
rt.endpoint,
|
||||
mcp_endpoint(&project.id).as_cli_arg(),
|
||||
"endpoint cohérent avec mcp_endpoint()"
|
||||
);
|
||||
// project_id en forme simple 32-hex (hyphen-free), consommée par la garde M5c.
|
||||
assert_eq!(rt.project_id, project.id.as_uuid().simple().to_string());
|
||||
assert_eq!(rt.project_id.len(), 32, "forme simple 32-hex");
|
||||
assert!(!rt.project_id.contains('-'), "pas de tirets");
|
||||
// requester = l'id de la cible relancée.
|
||||
assert_eq!(rt.requester, agent_id.to_string());
|
||||
// exe non vide.
|
||||
assert!(!rt.exe.is_empty(), "exe renseigné");
|
||||
}
|
||||
}
|
||||
@ -34,7 +34,11 @@ pub type PtyChunk = Vec<u8>;
|
||||
/// Thread-safe; cloned `Arc<PtyBridge>` is held in [`crate::state::AppState`].
|
||||
#[derive(Default)]
|
||||
pub struct PtyBridge {
|
||||
channels: Mutex<HashMap<SessionId, Channel<PtyChunk>>>,
|
||||
/// Per session: a monotonically-increasing **generation** plus the current
|
||||
/// output channel. The generation distinguishes successive (re-)attaches so a
|
||||
/// superseded pump thread can't tear down the channel of the attach that
|
||||
/// replaced it (see [`PtyBridge::unregister_if`]).
|
||||
channels: Mutex<HashMap<SessionId, (u64, Channel<PtyChunk>)>>,
|
||||
}
|
||||
|
||||
impl PtyBridge {
|
||||
@ -46,21 +50,39 @@ impl PtyBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the output channel for a session (called when a terminal is
|
||||
/// opened, from a `#[tauri::command]` that receives the `Channel` argument).
|
||||
pub fn register(&self, session: SessionId, channel: Channel<PtyChunk>) {
|
||||
/// Registers (or replaces) the output channel for a session and returns the
|
||||
/// **generation** of this registration. Each call for a session bumps the
|
||||
/// generation, so the caller's pump thread can later tear down *only its own*
|
||||
/// registration via [`unregister_if`](Self::unregister_if).
|
||||
pub fn register(&self, session: SessionId, channel: Channel<PtyChunk>) -> u64 {
|
||||
if let Ok(mut map) = self.channels.lock() {
|
||||
map.insert(session, channel);
|
||||
let gen = map.get(&session).map_or(0, |(g, _)| g.wrapping_add(1));
|
||||
map.insert(session, (gen, channel));
|
||||
gen
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a session's channel (terminal closed).
|
||||
/// Removes a session's channel unconditionally (terminal explicitly closed).
|
||||
pub fn unregister(&self, session: &SessionId) {
|
||||
if let Ok(mut map) = self.channels.lock() {
|
||||
map.remove(session);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a session's channel **only if** `gen` is still the current
|
||||
/// generation. A pump thread calls this when its output stream ends: if the
|
||||
/// session has since been re-attached (newer generation), this is a no-op, so
|
||||
/// the dying thread never unregisters the live channel that superseded it.
|
||||
pub fn unregister_if(&self, session: &SessionId, gen: u64) {
|
||||
if let Ok(mut map) = self.channels.lock() {
|
||||
if matches!(map.get(session), Some((g, _)) if *g == gen) {
|
||||
map.remove(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forwards a chunk of output bytes to a session's channel.
|
||||
///
|
||||
/// Returns `true` if the chunk was delivered, `false` if no channel is
|
||||
@ -71,7 +93,7 @@ impl PtyBridge {
|
||||
return false;
|
||||
};
|
||||
match map.get(session) {
|
||||
Some(channel) => channel.send(chunk).is_ok(),
|
||||
Some((_, channel)) => channel.send(chunk).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
383
crates/app-tauri/tests/chat_bridge.rs
Normal file
383
crates/app-tauri/tests/chat_bridge.rs
Normal file
@ -0,0 +1,383 @@
|
||||
//! L1 tests for [`ChatBridge`] — the structured-reply ↔ Channel registry, the
|
||||
//! twin of [`PtyBridge`] (ARCHITECTURE §17.7) — and for [`chunk_from_event`], the
|
||||
//! single `ReplyEvent → ReplyChunk` translation point.
|
||||
//!
|
||||
//! These exercise the load-bearing transport logic the D4 commands are thin
|
||||
//! shells over: the `agent_send` pump's delivery + scrollback recording, the
|
||||
//! `reattach_agent_chat` repaint-without-respawn, the `close_agent_session`
|
||||
//! teardown, and — the most critical invariant — generation supersede (no double
|
||||
//! emission after a re-attach). A real [`tauri::ipc::Channel`] built from a
|
||||
//! capturing closure is used (no Tauri runtime, no real CLI/PTY/session).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tauri::ipc::{Channel, InvokeResponseBody};
|
||||
|
||||
use app_tauri_lib::chat::{chunk_from_event, ChatBridge};
|
||||
use app_tauri_lib::dto::ReplyChunk;
|
||||
use domain::ids::SessionId;
|
||||
use domain::ports::ReplyEvent;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Builds a `Channel<ReplyChunk>` whose sent chunks are recorded into `sink`.
|
||||
///
|
||||
/// `ReplyChunk` is `Serialize`/`Deserialize`, so chunks arrive as a JSON string
|
||||
/// in an `InvokeResponseBody::Json`; we parse them back to `ReplyChunk` for
|
||||
/// assertions (round-tripping the exact camelCase tagged shape on the way).
|
||||
fn capturing_channel(sink: Arc<Mutex<Vec<ReplyChunk>>>) -> Channel<ReplyChunk> {
|
||||
Channel::new(move |body: InvokeResponseBody| {
|
||||
let chunk: ReplyChunk = match body {
|
||||
InvokeResponseBody::Json(s) => serde_json::from_str(&s).unwrap(),
|
||||
InvokeResponseBody::Raw(b) => serde_json::from_slice(&b).unwrap(),
|
||||
};
|
||||
sink.lock().unwrap().push(chunk);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn sid() -> SessionId {
|
||||
SessionId::from_uuid(Uuid::new_v4())
|
||||
}
|
||||
|
||||
fn delta(s: &str) -> ReplyChunk {
|
||||
ReplyChunk::TextDelta { text: s.to_owned() }
|
||||
}
|
||||
|
||||
fn final_chunk(s: &str) -> ReplyChunk {
|
||||
ReplyChunk::Final {
|
||||
content: s.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// chunk_from_event — exhaustive mapping (zone 6)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn chunk_from_event_maps_text_delta() {
|
||||
assert_eq!(
|
||||
chunk_from_event(ReplyEvent::TextDelta { text: "hi".into() }),
|
||||
Some(ReplyChunk::TextDelta { text: "hi".into() })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_from_event_maps_tool_activity() {
|
||||
assert_eq!(
|
||||
chunk_from_event(ReplyEvent::ToolActivity {
|
||||
label: "reads file".into()
|
||||
}),
|
||||
Some(ReplyChunk::ToolActivity {
|
||||
label: "reads file".into()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_from_event_maps_final() {
|
||||
assert_eq!(
|
||||
chunk_from_event(ReplyEvent::Final {
|
||||
content: "done".into()
|
||||
}),
|
||||
Some(ReplyChunk::Final {
|
||||
content: "done".into()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_from_event_drops_heartbeat() {
|
||||
// A heartbeat is a non-terminal liveness proof with no chat content (lot 1) ⇒ no
|
||||
// wire chunk; the pump skips it while still draining to the Final.
|
||||
assert_eq!(chunk_from_event(ReplyEvent::Heartbeat), None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// agent_send pump behaviour: deltas* then exactly one Final (zone 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Replays a turn's events through the bridge exactly as the `agent_send` pump
|
||||
/// does (`chunk_from_event` + `send_output`), then `detach_if(gen)` at stream
|
||||
/// end. This is the body of the spawned pump thread, run inline.
|
||||
fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec<ReplyEvent>) {
|
||||
for event in events {
|
||||
// Mirror the real pump: heartbeats map to no chunk and are skipped.
|
||||
if let Some(chunk) = chunk_from_event(event) {
|
||||
let _ = bridge.send_output(session, chunk);
|
||||
}
|
||||
}
|
||||
bridge.detach_if(session, gen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pump_delivers_deltas_then_exactly_one_final_in_order() {
|
||||
let bridge = ChatBridge::new();
|
||||
let session = sid();
|
||||
let sink = Arc::new(Mutex::new(Vec::new()));
|
||||
let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
|
||||
|
||||
pump_turn(
|
||||
&bridge,
|
||||
&session,
|
||||
gen,
|
||||
vec![
|
||||
ReplyEvent::TextDelta { text: "Hel".into() },
|
||||
ReplyEvent::TextDelta { text: "lo".into() },
|
||||
ReplyEvent::Final {
|
||||
content: "Hello".into(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
let got = sink.lock().unwrap();
|
||||
assert_eq!(
|
||||
got.as_slice(),
|
||||
&[delta("Hel"), delta("lo"), final_chunk("Hello")],
|
||||
"deltas in order, then the single Final"
|
||||
);
|
||||
// Exactly one Final, and it is last (deterministic terminal chunk).
|
||||
let finals = got
|
||||
.iter()
|
||||
.filter(|c| matches!(c, ReplyChunk::Final { .. }))
|
||||
.count();
|
||||
assert_eq!(finals, 1, "exactly one Final per turn");
|
||||
assert!(matches!(got.last(), Some(ReplyChunk::Final { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pump_with_only_a_final_delivers_just_the_final() {
|
||||
// Zero deltas is valid (deltas*); the turn still ends on exactly one Final.
|
||||
let bridge = ChatBridge::new();
|
||||
let session = sid();
|
||||
let sink = Arc::new(Mutex::new(Vec::new()));
|
||||
let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
|
||||
|
||||
pump_turn(
|
||||
&bridge,
|
||||
&session,
|
||||
gen,
|
||||
vec![ReplyEvent::Final {
|
||||
content: "ok".into(),
|
||||
}],
|
||||
);
|
||||
|
||||
assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("ok")]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generation supersede — the critical invariant (zone 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A `reattach_agent_chat` mid-turn: a first pump is in flight (old generation)
|
||||
/// when the view re-attaches (new channel, bumped generation). The old pump's
|
||||
/// remaining chunks must NOT reach the *new* channel, and when the old pump ends
|
||||
/// its `detach_if(old_gen)` must be a no-op (never tearing down the live one).
|
||||
#[test]
|
||||
fn reattach_supersedes_old_pump_no_double_emission() {
|
||||
let bridge = ChatBridge::new();
|
||||
let session = sid();
|
||||
let old = Arc::new(Mutex::new(Vec::new()));
|
||||
let new = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
// Turn starts: old channel registered, pump emits a first delta.
|
||||
let old_gen = bridge.register(session, capturing_channel(Arc::clone(&old)));
|
||||
bridge.send_output(&session, delta("a")); // delivered to old
|
||||
|
||||
// View navigates away & back → reattach registers a NEW channel (bumps gen).
|
||||
let _new_gen = bridge.register(session, capturing_channel(Arc::clone(&new)));
|
||||
|
||||
// Old pump keeps draining its (now stale) stream, then ends with its own gen.
|
||||
bridge.send_output(&session, delta("b")); // recorded to scrollback, routed to NEW channel
|
||||
bridge.detach_if(&session, old_gen); // MUST be a no-op (newer gen present)
|
||||
|
||||
// The new (live) attach must NOT have been detached: it is still deliverable.
|
||||
assert!(
|
||||
bridge.send_output(&session, final_chunk("done")),
|
||||
"live re-attach channel must survive a superseded pump's detach_if"
|
||||
);
|
||||
|
||||
// The OLD channel only ever saw the single pre-reattach delta — no double emit.
|
||||
assert_eq!(
|
||||
old.lock().unwrap().as_slice(),
|
||||
&[delta("a")],
|
||||
"superseded channel receives nothing after the reattach"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detach_if_current_generation_stops_delivery() {
|
||||
// When the pump that owns the current generation ends (e.g. Final reached and
|
||||
// no reattach happened), detach_if drops the channel: further output is not
|
||||
// delivered (turn over) but the session stays known until close.
|
||||
let bridge = ChatBridge::new();
|
||||
let session = sid();
|
||||
let sink = Arc::new(Mutex::new(Vec::new()));
|
||||
let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
|
||||
|
||||
bridge.send_output(&session, final_chunk("end"));
|
||||
bridge.detach_if(&session, gen);
|
||||
|
||||
// Channel detached: a stray chunk is recorded to scrollback but not delivered.
|
||||
assert!(!bridge.send_output(&session, delta("late")));
|
||||
assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("end")]);
|
||||
// Session is still tracked (scrollback survives until close).
|
||||
assert_eq!(bridge.active_sessions(), 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reattach_agent_chat: scrollback repaint without re-spawn (zone 3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn scrollback_accumulates_every_routed_chunk_in_order() {
|
||||
let bridge = ChatBridge::new();
|
||||
let session = sid();
|
||||
let gen = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
|
||||
|
||||
pump_turn(
|
||||
&bridge,
|
||||
&session,
|
||||
gen,
|
||||
vec![
|
||||
ReplyEvent::TextDelta { text: "x".into() },
|
||||
ReplyEvent::ToolActivity {
|
||||
label: "runs".into(),
|
||||
},
|
||||
ReplyEvent::Final {
|
||||
content: "x".into(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
bridge.scrollback(&session),
|
||||
vec![
|
||||
delta("x"),
|
||||
ReplyChunk::ToolActivity {
|
||||
label: "runs".into()
|
||||
},
|
||||
final_chunk("x"),
|
||||
],
|
||||
"scrollback retains the full conversation, in order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reattach_repaints_scrollback_and_preserves_it_without_resend() {
|
||||
// Models reattach_agent_chat: a first turn streamed, the view detached, then a
|
||||
// new channel registers. The scrollback survives the re-register (no session
|
||||
// method is called — no re-spawn), and is what the command returns.
|
||||
let bridge = ChatBridge::new();
|
||||
let session = sid();
|
||||
|
||||
let g0 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
|
||||
pump_turn(
|
||||
&bridge,
|
||||
&session,
|
||||
g0,
|
||||
vec![
|
||||
ReplyEvent::TextDelta { text: "Hi".into() },
|
||||
ReplyEvent::Final {
|
||||
content: "Hi".into(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
// Snapshot exactly what reattach_agent_chat returns BEFORE swapping channel.
|
||||
let repainted = bridge.scrollback(&session);
|
||||
assert_eq!(repainted, vec![delta("Hi"), final_chunk("Hi")]);
|
||||
|
||||
// Re-attach: register a fresh channel. Scrollback is preserved (not cleared,
|
||||
// not duplicated), generation bumped.
|
||||
let new_sink = Arc::new(Mutex::new(Vec::new()));
|
||||
let g1 = bridge.register(session, capturing_channel(Arc::clone(&new_sink)));
|
||||
assert_eq!(g1, g0 + 1, "re-attach bumps the generation");
|
||||
assert_eq!(
|
||||
bridge.scrollback(&session),
|
||||
repainted,
|
||||
"scrollback unchanged by re-attach (no re-send, no re-spawn)"
|
||||
);
|
||||
// The fresh channel got nothing yet: reattach does NOT replay over the channel
|
||||
// (the command returns the scrollback for the front to replay itself).
|
||||
assert!(
|
||||
new_sink.lock().unwrap().is_empty(),
|
||||
"re-attach must not push the old turns onto the new channel"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollback_of_unknown_session_is_empty() {
|
||||
let bridge = ChatBridge::new();
|
||||
assert!(bridge.scrollback(&sid()).is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// close_agent_session: unregister purges channel AND scrollback (zone 4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn unregister_purges_channel_and_scrollback() {
|
||||
let bridge = ChatBridge::new();
|
||||
let session = sid();
|
||||
let sink = Arc::new(Mutex::new(Vec::new()));
|
||||
let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
|
||||
pump_turn(
|
||||
&bridge,
|
||||
&session,
|
||||
gen,
|
||||
vec![ReplyEvent::Final {
|
||||
content: "bye".into(),
|
||||
}],
|
||||
);
|
||||
assert_eq!(bridge.active_sessions(), 1);
|
||||
assert!(!bridge.scrollback(&session).is_empty());
|
||||
|
||||
bridge.unregister(&session);
|
||||
|
||||
assert_eq!(bridge.active_sessions(), 0, "session removed");
|
||||
assert!(
|
||||
bridge.scrollback(&session).is_empty(),
|
||||
"scrollback purged on close"
|
||||
);
|
||||
assert!(
|
||||
!bridge.send_output(&session, delta("z")),
|
||||
"no delivery after close"
|
||||
);
|
||||
// The closed channel must not receive the post-close chunk.
|
||||
assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("bye")]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry basics, mirrored from pty_bridge.rs for parity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn register_returns_monotonic_generation_per_session() {
|
||||
let bridge = ChatBridge::new();
|
||||
let session = sid();
|
||||
let g0 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
|
||||
let g1 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
|
||||
assert_eq!(g0, 0);
|
||||
assert_eq!(g1, 1);
|
||||
assert_eq!(bridge.active_sessions(), 1, "same id replaced, not added");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_output_to_unknown_session_returns_false() {
|
||||
let bridge = ChatBridge::new();
|
||||
assert!(!bridge.send_output(&sid(), delta("x")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_same_session_replaces_channel() {
|
||||
let bridge = ChatBridge::new();
|
||||
let session = sid();
|
||||
let first = Arc::new(Mutex::new(Vec::new()));
|
||||
let second = Arc::new(Mutex::new(Vec::new()));
|
||||
bridge.register(session, capturing_channel(Arc::clone(&first)));
|
||||
bridge.register(session, capturing_channel(Arc::clone(&second)));
|
||||
|
||||
bridge.send_output(&session, delta("9"));
|
||||
assert!(first.lock().unwrap().is_empty(), "old channel unused");
|
||||
assert_eq!(second.lock().unwrap().as_slice(), &[delta("9")]);
|
||||
}
|
||||
@ -7,16 +7,16 @@ use app_tauri_lib::dto::{
|
||||
LayoutOperationDto, OpenTerminalRequestDto, ReattachResultDto, ResizeTerminalRequestDto,
|
||||
TerminalClosedDto, WriteTerminalRequestDto,
|
||||
};
|
||||
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
|
||||
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
|
||||
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId};
|
||||
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
|
||||
|
||||
use application::{AppError, HealthInput};
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ids::{AgentId, SessionId};
|
||||
use domain::ids::{AgentId, ProfileId, SessionId};
|
||||
use domain::ProjectId;
|
||||
use domain::TemplateVersion;
|
||||
use domain::TemplateId;
|
||||
use domain::TemplateVersion;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -93,6 +93,26 @@ fn domain_event_dto_maps_agent_launched() {
|
||||
assert_eq!(v["sessionId"], sid.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_event_dto_maps_agent_profile_changed() {
|
||||
// LOT A0 : round-trip camelCase agentId/profileId du miroir DTO.
|
||||
let aid = AgentId::from_uuid(Uuid::from_u128(1));
|
||||
let pid = ProfileId::from_uuid(Uuid::from_u128(2));
|
||||
let dto = DomainEventDto::from(&DomainEvent::AgentProfileChanged {
|
||||
agent_id: aid,
|
||||
profile_id: pid,
|
||||
});
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({
|
||||
"type": "agentProfileChanged",
|
||||
"agentId": aid.to_string(),
|
||||
"profileId": pid.to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_event_dto_maps_template_updated_version() {
|
||||
let tid = TemplateId::from_uuid(Uuid::nil());
|
||||
@ -118,6 +138,65 @@ fn domain_event_dto_maps_pty_output_bytes() {
|
||||
assert_eq!(v["bytes"], json!([1, 2, 3]));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M4 — orchestration source on `orchestratorRequestProcessed`
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn orchestration_source_dto_serialises_lowercase() {
|
||||
use app_tauri_lib::events::OrchestrationSourceDto;
|
||||
use domain::events::OrchestrationSource;
|
||||
|
||||
// File → "file"; Mcp → "mcp" (camelCase rename on a unit enum = lowercase).
|
||||
let file =
|
||||
serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::File)).unwrap();
|
||||
assert_eq!(file, json!("file"));
|
||||
|
||||
let mcp = serde_json::to_value(OrchestrationSourceDto::from(OrchestrationSource::Mcp)).unwrap();
|
||||
assert_eq!(mcp, json!("mcp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_event_dto_maps_orchestrator_request_processed_with_file_source() {
|
||||
use domain::events::OrchestrationSource;
|
||||
|
||||
let dto = DomainEventDto::from(&DomainEvent::OrchestratorRequestProcessed {
|
||||
requester_id: "Main".into(),
|
||||
action: "spawn_agent".into(),
|
||||
ok: true,
|
||||
source: OrchestrationSource::File,
|
||||
});
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({
|
||||
"type": "orchestratorRequestProcessed",
|
||||
"requesterId": "Main",
|
||||
"action": "spawn_agent",
|
||||
"ok": true,
|
||||
"source": "file",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_event_dto_maps_orchestrator_request_processed_with_mcp_source() {
|
||||
use domain::events::OrchestrationSource;
|
||||
|
||||
let dto = DomainEventDto::from(&DomainEvent::OrchestratorRequestProcessed {
|
||||
requester_id: "mcp".into(),
|
||||
action: "idea_ask_agent".into(),
|
||||
ok: false,
|
||||
source: OrchestrationSource::Mcp,
|
||||
});
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["type"], "orchestratorRequestProcessed");
|
||||
assert_eq!(v["requesterId"], "mcp");
|
||||
assert_eq!(v["action"], "idea_ask_agent");
|
||||
assert_eq!(v["ok"], json!(false));
|
||||
assert_eq!(v["source"], "mcp", "MCP door must badge as `mcp`");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal DTOs (L3)
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -188,7 +267,10 @@ fn terminal_closed_dto_serialises_code_camel_case() {
|
||||
|
||||
// Signalled (None) round-trips as null.
|
||||
let none = TerminalClosedDto::from(CloseTerminalOutput { code: None });
|
||||
assert_eq!(serde_json::to_value(&none).unwrap(), json!({ "code": null }));
|
||||
assert_eq!(
|
||||
serde_json::to_value(&none).unwrap(),
|
||||
json!({ "code": null })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -198,7 +280,10 @@ fn reattach_result_dto_serialises_camel_case() {
|
||||
scrollback: vec![104, 105],
|
||||
};
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v, json!({ "sessionId": "sess-1", "scrollback": [104, 105] }));
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({ "sessionId": "sess-1", "scrollback": [104, 105] })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -223,6 +308,9 @@ fn layout_dto_serialises_camelcase_tagged_tree() {
|
||||
id: nid(1),
|
||||
session: None,
|
||||
agent: None,
|
||||
conversation_id: None,
|
||||
engine_session_id: None,
|
||||
agent_was_running: false,
|
||||
});
|
||||
let dto = LayoutDto::from(LoadLayoutOutput {
|
||||
layout_id: domain::LayoutId::new_random(),
|
||||
@ -322,8 +410,23 @@ fn layout_dto_round_trips_a_split_tree_shape() {
|
||||
id: nid(1),
|
||||
session: None,
|
||||
agent: None,
|
||||
conversation_id: None,
|
||||
engine_session_id: None,
|
||||
agent_was_running: false,
|
||||
})
|
||||
.split(nid(1), Direction::Column, LeafCell { id: nid(2), session: None, agent: None }, nid(9))
|
||||
.split(
|
||||
nid(1),
|
||||
Direction::Column,
|
||||
LeafCell {
|
||||
id: nid(2),
|
||||
session: None,
|
||||
agent: None,
|
||||
conversation_id: None,
|
||||
engine_session_id: None,
|
||||
agent_was_running: false,
|
||||
},
|
||||
nid(9),
|
||||
)
|
||||
.unwrap();
|
||||
let dto = LayoutDto::from(LoadLayoutOutput {
|
||||
layout_id: domain::LayoutId::new_random(),
|
||||
|
||||
@ -3,11 +3,16 @@
|
||||
//! and `From<LaunchAgentOutput>` for [`TerminalSessionDto`].
|
||||
|
||||
use app_tauri_lib::dto::{
|
||||
parse_agent_id, AgentDto, AgentListDto, CreateAgentRequestDto, LaunchAgentRequestDto,
|
||||
TerminalSessionDto, UpdateAgentContextRequestDto,
|
||||
parse_agent_id, AgentDto, AgentListDto, ConversationDetailsDto, CreateAgentRequestDto,
|
||||
InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, TerminalSessionDto,
|
||||
UpdateAgentContextRequestDto,
|
||||
};
|
||||
use application::AppError;
|
||||
use application::{
|
||||
CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, ListAgentsOutput,
|
||||
};
|
||||
use application::{CreateAgentOutput, LaunchAgentOutput, ListAgentsOutput};
|
||||
use domain::ids::{AgentId, NodeId, ProfileId, SessionId};
|
||||
use domain::ports::ConversationDetails;
|
||||
use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
use domain::{Agent, AgentOrigin, ProjectPath};
|
||||
use serde_json::json;
|
||||
@ -39,7 +44,10 @@ fn agent_dto_serialises_camelcase() {
|
||||
assert_eq!(v["id"], agent.id.to_string());
|
||||
assert_eq!(v["name"], "My Agent");
|
||||
assert_eq!(v["contextPath"], "agents/my-agent.md", "camelCase key");
|
||||
assert_eq!(v["profileId"], ProfileId::from_uuid(Uuid::from_u128(2)).to_string());
|
||||
assert_eq!(
|
||||
v["profileId"],
|
||||
ProfileId::from_uuid(Uuid::from_u128(2)).to_string()
|
||||
);
|
||||
assert_eq!(v["synchronized"], false);
|
||||
// origin: tagged `{ "type": "scratch" }`
|
||||
assert_eq!(v["origin"]["type"], "scratch");
|
||||
@ -63,7 +71,9 @@ fn agent_list_dto_is_transparent_array() {
|
||||
#[test]
|
||||
fn create_agent_output_maps_to_agent_dto() {
|
||||
let agent = make_agent(5, 6);
|
||||
let out = CreateAgentOutput { agent: agent.clone() };
|
||||
let out = CreateAgentOutput {
|
||||
agent: agent.clone(),
|
||||
};
|
||||
let dto = AgentDto::from(out);
|
||||
assert_eq!(dto.0.id, agent.id);
|
||||
}
|
||||
@ -126,6 +136,132 @@ fn launch_agent_request_deserialises_camelcase() {
|
||||
assert_eq!(dto.cols, 80);
|
||||
assert_eq!(dto.project_id, project_id);
|
||||
assert_eq!(dto.agent_id, agent_id);
|
||||
// Omitting the resume id defaults to None (a fresh cell).
|
||||
assert_eq!(dto.conversation_id, None);
|
||||
// Omitting the node id defaults to None (a fresh node is minted backend-side).
|
||||
assert_eq!(dto.node_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_agent_request_carries_node_id() {
|
||||
let node_id = Uuid::from_u128(33).to_string();
|
||||
let raw = json!({
|
||||
"projectId": Uuid::from_u128(1).to_string(),
|
||||
"agentId": Uuid::from_u128(2).to_string(),
|
||||
"rows": 24,
|
||||
"cols": 80,
|
||||
"nodeId": node_id
|
||||
});
|
||||
let dto: LaunchAgentRequestDto = serde_json::from_value(raw).unwrap();
|
||||
// The hosting cell reaches the backend so the singleton guard can enforce
|
||||
// "one live session per agent".
|
||||
assert_eq!(dto.node_id.as_deref(), Some(node_id.as_str()));
|
||||
// No snake_case leak on the wire.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AGENT_ALREADY_RUNNING error code + LiveAgentListDto (T2/T3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn agent_already_running_error_code_is_stable() {
|
||||
let err = AppError::AgentAlreadyRunning {
|
||||
agent_id: AgentId::from_uuid(Uuid::from_u128(2)),
|
||||
node_id: NodeId::from_uuid(Uuid::from_u128(3)),
|
||||
};
|
||||
let dto = app_tauri_lib::dto::ErrorDto::from(err);
|
||||
// Option A: a stable code + a text message (the node is NOT enriched into the
|
||||
// ErrorDto — the frontend branches on the code alone).
|
||||
assert_eq!(dto.code, "AGENT_ALREADY_RUNNING");
|
||||
assert!(
|
||||
dto.message.contains("already running"),
|
||||
"message: {}",
|
||||
dto.message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_agent_list_dto_serialises_camelcase_array() {
|
||||
let agent_a = AgentId::from_uuid(Uuid::from_u128(11));
|
||||
let node_a = NodeId::from_uuid(Uuid::from_u128(21));
|
||||
let session_a = domain::SessionId::from_uuid(Uuid::from_u128(31));
|
||||
let dto = LiveAgentListDto::from_pairs(vec![(agent_a, node_a, session_a)]);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
let arr = v.as_array().expect("transparent array");
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr[0]["agentId"], agent_a.to_string());
|
||||
assert_eq!(arr[0]["nodeId"], node_a.to_string());
|
||||
assert_eq!(arr[0]["sessionId"], session_a.to_string());
|
||||
// No snake_case leak.
|
||||
assert!(arr[0].get("agent_id").is_none());
|
||||
assert!(arr[0].get("node_id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_agent_request_carries_conversation_id_for_resume() {
|
||||
let raw = json!({
|
||||
"projectId": Uuid::from_u128(1).to_string(),
|
||||
"agentId": Uuid::from_u128(2).to_string(),
|
||||
"rows": 24,
|
||||
"cols": 80,
|
||||
"conversationId": "resume-me"
|
||||
});
|
||||
let dto: LaunchAgentRequestDto = serde_json::from_value(raw).unwrap();
|
||||
// The leaf's persisted id reaches the backend so the launch resumes (T4b).
|
||||
assert_eq!(dto.conversation_id.as_deref(), Some("resume-me"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InspectConversation DTOs (T7)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn inspect_conversation_request_deserialises_camelcase() {
|
||||
let raw = json!({
|
||||
"projectId": Uuid::from_u128(1).to_string(),
|
||||
"agentId": Uuid::from_u128(2).to_string(),
|
||||
"conversationId": "conv-7"
|
||||
});
|
||||
let dto: InspectConversationRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(dto.project_id, Uuid::from_u128(1).to_string());
|
||||
assert_eq!(dto.agent_id, Uuid::from_u128(2).to_string());
|
||||
assert_eq!(dto.conversation_id, "conv-7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_details_dto_serialises_camelcase_when_present() {
|
||||
let out = InspectConversationOutput {
|
||||
details: ConversationDetails {
|
||||
last_topic: Some("refactor parser".to_owned()),
|
||||
token_count: Some(1234),
|
||||
},
|
||||
};
|
||||
let dto = ConversationDetailsDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["lastTopic"], "refactor parser");
|
||||
assert_eq!(v["tokenCount"], 1234);
|
||||
// No snake_case leak.
|
||||
assert!(v.get("last_topic").is_none());
|
||||
assert!(v.get("token_count").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_details_dto_omits_fields_when_none() {
|
||||
let out = InspectConversationOutput {
|
||||
details: ConversationDetails {
|
||||
last_topic: None,
|
||||
token_count: None,
|
||||
},
|
||||
};
|
||||
let dto = ConversationDetailsDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
// Both optional fields are omitted from the wire (absent, not null).
|
||||
assert!(v.get("lastTopic").is_none(), "absent topic ⇒ field omitted");
|
||||
assert!(
|
||||
v.get("tokenCount").is_none(),
|
||||
"absent tokens ⇒ field omitted"
|
||||
);
|
||||
assert_eq!(v, json!({}), "fully degraded ⇒ empty object");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -168,7 +304,12 @@ fn launch_agent_output_maps_to_terminal_session_dto() {
|
||||
);
|
||||
session.status = SessionStatus::Running;
|
||||
|
||||
let out = LaunchAgentOutput { session };
|
||||
let out = LaunchAgentOutput {
|
||||
session,
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
};
|
||||
let dto = TerminalSessionDto::from(out);
|
||||
|
||||
assert_eq!(dto.session_id, session_id.to_string());
|
||||
@ -176,9 +317,53 @@ fn launch_agent_output_maps_to_terminal_session_dto() {
|
||||
assert_eq!(dto.rows, 24);
|
||||
assert_eq!(dto.cols, 80);
|
||||
|
||||
// Nothing assigned ⇒ no conversation id surfaced.
|
||||
assert_eq!(dto.assigned_conversation_id, None);
|
||||
|
||||
// Also verify camelCase serialisation.
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["sessionId"], session_id.to_string());
|
||||
assert_eq!(v["cwd"], "/tmp/project");
|
||||
assert!(v.get("session_id").is_none(), "no snake_case leak");
|
||||
// Absent assignment must be omitted from the wire payload entirely.
|
||||
assert!(
|
||||
v.get("assignedConversationId").is_none(),
|
||||
"no assignment ⇒ field omitted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_agent_output_propagates_assigned_conversation_id() {
|
||||
let session_id = SessionId::from_uuid(Uuid::from_u128(70));
|
||||
let node_id = NodeId::from_uuid(Uuid::from_u128(80));
|
||||
let agent_id = AgentId::from_uuid(Uuid::from_u128(90));
|
||||
let cwd = ProjectPath::new("/tmp/project".to_owned()).expect("valid path");
|
||||
let size = PtySize::new(24, 80).unwrap();
|
||||
|
||||
let mut session = TerminalSession::starting(
|
||||
session_id,
|
||||
node_id,
|
||||
cwd,
|
||||
SessionKind::Agent { agent_id },
|
||||
size,
|
||||
);
|
||||
session.status = SessionStatus::Running;
|
||||
|
||||
let out = LaunchAgentOutput {
|
||||
session,
|
||||
assigned_conversation_id: Some("conv-xyz".to_owned()),
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
};
|
||||
let dto = TerminalSessionDto::from(out);
|
||||
|
||||
// The id minted by the launch is carried through to the DTO …
|
||||
assert_eq!(dto.assigned_conversation_id.as_deref(), Some("conv-xyz"));
|
||||
// … and serialised in camelCase for the front to persist on the leaf.
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["assignedConversationId"], "conv-xyz");
|
||||
assert!(
|
||||
v.get("assigned_conversation_id").is_none(),
|
||||
"no snake_case leak"
|
||||
);
|
||||
}
|
||||
|
||||
155
crates/app-tauri/tests/dto_change_agent_profile.rs
Normal file
155
crates/app-tauri/tests/dto_change_agent_profile.rs
Normal file
@ -0,0 +1,155 @@
|
||||
//! A2 tests for the `change_agent_profile` DTO contract (§15.1):
|
||||
//! - Request DTO round-trips camelCase JSON `{ projectId, agentId, profileId, rows, cols }`.
|
||||
//! - `From<ChangeAgentProfileOutput> for ChangeAgentProfileDto` maps the agent and
|
||||
//! surfaces `relaunchedSession` only when a live session was swapped (omitted via
|
||||
//! `skip_serializing_if` when `None`).
|
||||
//! - `From<TerminalSession> for TerminalSessionDto` carries id/cwd/size.
|
||||
|
||||
use app_tauri_lib::dto::{ChangeAgentProfileDto, ChangeAgentProfileRequestDto};
|
||||
use application::ChangeAgentProfileOutput;
|
||||
use domain::ids::{AgentId, NodeId, ProfileId, SessionId};
|
||||
use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
use domain::{Agent, AgentOrigin, ProjectPath};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Helper: build a minimal validated [`Agent`].
|
||||
fn make_agent(agent_uuid: u128, profile_uuid: u128) -> Agent {
|
||||
Agent::new(
|
||||
AgentId::from_uuid(Uuid::from_u128(agent_uuid)),
|
||||
"My Agent",
|
||||
"agents/my-agent.md",
|
||||
ProfileId::from_uuid(Uuid::from_u128(profile_uuid)),
|
||||
AgentOrigin::Scratch,
|
||||
false,
|
||||
)
|
||||
.expect("valid agent")
|
||||
}
|
||||
|
||||
/// Helper: build a running [`TerminalSession`] for an agent cell.
|
||||
fn make_session(session_uuid: u128, node_uuid: u128, agent_uuid: u128) -> TerminalSession {
|
||||
let session_id = SessionId::from_uuid(Uuid::from_u128(session_uuid));
|
||||
let node_id = NodeId::from_uuid(Uuid::from_u128(node_uuid));
|
||||
let agent_id = AgentId::from_uuid(Uuid::from_u128(agent_uuid));
|
||||
let cwd = ProjectPath::new("/tmp/project".to_owned()).expect("valid path");
|
||||
let size = PtySize::new(30, 100).unwrap();
|
||||
let mut session = TerminalSession::starting(
|
||||
session_id,
|
||||
node_id,
|
||||
cwd,
|
||||
SessionKind::Agent { agent_id },
|
||||
size,
|
||||
);
|
||||
session.status = SessionStatus::Running;
|
||||
session
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request DTO deserialisation (camelCase round-trip)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn change_agent_profile_request_deserialises_camelcase() {
|
||||
let project_id = Uuid::from_u128(1).to_string();
|
||||
let agent_id = Uuid::from_u128(2).to_string();
|
||||
let profile_id = Uuid::from_u128(3).to_string();
|
||||
let raw = json!({
|
||||
"projectId": project_id,
|
||||
"agentId": agent_id,
|
||||
"profileId": profile_id,
|
||||
"rows": 24,
|
||||
"cols": 80
|
||||
});
|
||||
let dto: ChangeAgentProfileRequestDto = serde_json::from_value(raw).unwrap();
|
||||
assert_eq!(dto.project_id, project_id);
|
||||
assert_eq!(dto.agent_id, agent_id);
|
||||
assert_eq!(dto.profile_id, profile_id);
|
||||
assert_eq!(dto.rows, 24);
|
||||
assert_eq!(dto.cols, 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_agent_profile_request_rejects_snake_case_keys() {
|
||||
// The wire contract is camelCase; snake_case keys must NOT satisfy the struct.
|
||||
let raw = json!({
|
||||
"project_id": Uuid::from_u128(1).to_string(),
|
||||
"agent_id": Uuid::from_u128(2).to_string(),
|
||||
"profile_id": Uuid::from_u128(3).to_string(),
|
||||
"rows": 24,
|
||||
"cols": 80
|
||||
});
|
||||
let res: Result<ChangeAgentProfileRequestDto, _> = serde_json::from_value(raw);
|
||||
assert!(res.is_err(), "snake_case keys must not deserialise");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// From<ChangeAgentProfileOutput> for ChangeAgentProfileDto
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn output_maps_agent_and_omits_session_when_no_relaunch() {
|
||||
let agent = make_agent(5, 6);
|
||||
let out = ChangeAgentProfileOutput {
|
||||
agent: agent.clone(),
|
||||
relaunched: None,
|
||||
};
|
||||
let dto = ChangeAgentProfileDto::from(out);
|
||||
assert_eq!(dto.agent.0.id, agent.id);
|
||||
assert!(dto.relaunched_session.is_none());
|
||||
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
// The agent is embedded with its camelCase shape.
|
||||
assert_eq!(v["agent"]["id"], agent.id.to_string());
|
||||
assert_eq!(v["agent"]["profileId"], agent.profile_id.to_string());
|
||||
// No relaunch ⇒ field must be OMITTED from the wire (absent, not null).
|
||||
assert!(
|
||||
v.get("relaunchedSession").is_none(),
|
||||
"no relaunch ⇒ relaunchedSession omitted, got: {v}"
|
||||
);
|
||||
// No snake_case leak.
|
||||
assert!(v.get("relaunched_session").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_maps_relaunched_session_camelcase_when_present() {
|
||||
let agent = make_agent(7, 8);
|
||||
let session = make_session(11, 12, 7);
|
||||
let out = ChangeAgentProfileOutput {
|
||||
agent: agent.clone(),
|
||||
relaunched: Some(session.clone()),
|
||||
};
|
||||
let dto = ChangeAgentProfileDto::from(out);
|
||||
assert!(dto.relaunched_session.is_some());
|
||||
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
let rs = v
|
||||
.get("relaunchedSession")
|
||||
.expect("relaunch present ⇒ relaunchedSession serialised");
|
||||
assert_eq!(rs["sessionId"], session.id.to_string());
|
||||
assert_eq!(rs["cwd"], "/tmp/project");
|
||||
assert_eq!(rs["rows"], 30);
|
||||
assert_eq!(rs["cols"], 100);
|
||||
// No snake_case leak on the nested DTO.
|
||||
assert!(rs.get("session_id").is_none(), "no snake_case leak");
|
||||
assert!(v.get("relaunched_session").is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// From<TerminalSession> for TerminalSessionDto
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn terminal_session_maps_to_dto() {
|
||||
let session = make_session(21, 22, 23);
|
||||
// Exercise the From<TerminalSession> impl directly through the output mapping.
|
||||
let out = ChangeAgentProfileOutput {
|
||||
agent: make_agent(23, 24),
|
||||
relaunched: Some(session.clone()),
|
||||
};
|
||||
let dto = ChangeAgentProfileDto::from(out);
|
||||
let rs = dto.relaunched_session.expect("session present");
|
||||
assert_eq!(rs.session_id, session.id.to_string());
|
||||
assert_eq!(rs.cwd, "/tmp/project");
|
||||
assert_eq!(rs.rows, 30);
|
||||
assert_eq!(rs.cols, 100);
|
||||
}
|
||||
220
crates/app-tauri/tests/dto_chat.rs
Normal file
220
crates/app-tauri/tests/dto_chat.rs
Normal file
@ -0,0 +1,220 @@
|
||||
//! L1 tests for the D4 structured-chat DTO contract (ARCHITECTURE §17.7):
|
||||
//! - `ReplyChunk` tagged camelCase round-trip (`kind` + payload),
|
||||
//! - `ReattachChatDto` camelCase wire shape (typed scrollback),
|
||||
//! - the **derived** `cellKind` on `TerminalSessionDto` (`chat` ⇔
|
||||
//! `structured: Some(..)`, `pty` otherwise),
|
||||
//! - non-regression: every `TerminalSessionDto` construction path now serialises a
|
||||
//! `cellKind` and PTY paths keep `"pty"`.
|
||||
|
||||
use app_tauri_lib::dto::{CellKind, ReattachChatDto, ReplyChunk, TerminalSessionDto};
|
||||
use application::{LaunchAgentOutput, StructuredSessionDescriptor};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::SessionId;
|
||||
use domain::{AgentId, NodeId, PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReplyChunk — tagged camelCase, exact wire shape + round-trip (zone 5/6)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn reply_chunk_text_delta_serialises_exact_camel_case() {
|
||||
let v = serde_json::to_value(ReplyChunk::TextDelta {
|
||||
text: "hello".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(v, json!({ "kind": "textDelta", "text": "hello" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_chunk_tool_activity_serialises_exact_camel_case() {
|
||||
let v = serde_json::to_value(ReplyChunk::ToolActivity {
|
||||
label: "reads file".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(v, json!({ "kind": "toolActivity", "label": "reads file" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_chunk_final_serialises_exact_camel_case() {
|
||||
let v = serde_json::to_value(ReplyChunk::Final {
|
||||
content: "done".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(v, json!({ "kind": "final", "content": "done" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_chunk_round_trips_through_json_for_every_variant() {
|
||||
for chunk in [
|
||||
ReplyChunk::TextDelta { text: "x".into() },
|
||||
ReplyChunk::ToolActivity {
|
||||
label: "runs".into(),
|
||||
},
|
||||
ReplyChunk::Final {
|
||||
content: "y".into(),
|
||||
},
|
||||
] {
|
||||
let v = serde_json::to_value(&chunk).unwrap();
|
||||
let back: ReplyChunk = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, chunk, "round-trip preserves the variant + payload");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_chunk_deserialises_from_camel_case_wire_payload() {
|
||||
// The shape the frontend (or a mock gateway) emits.
|
||||
let back: ReplyChunk =
|
||||
serde_json::from_value(json!({ "kind": "textDelta", "text": "hi" })).unwrap();
|
||||
assert_eq!(back, ReplyChunk::TextDelta { text: "hi".into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_chunk_rejects_snake_case_tag() {
|
||||
// Guard: a snake_case `text_delta` is NOT a valid wire tag (contract is camelCase).
|
||||
let r: Result<ReplyChunk, _> =
|
||||
serde_json::from_value(json!({ "kind": "text_delta", "text": "x" }));
|
||||
assert!(r.is_err(), "snake_case kind must not deserialise");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReattachChatDto — typed scrollback, camelCase (zone 5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
|
||||
let dto = ReattachChatDto {
|
||||
session_id: "sess-1".into(),
|
||||
scrollback: vec![
|
||||
ReplyChunk::TextDelta { text: "Hi".into() },
|
||||
ReplyChunk::Final {
|
||||
content: "Hi".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({
|
||||
"sessionId": "sess-1",
|
||||
"scrollback": [
|
||||
{ "kind": "textDelta", "text": "Hi" },
|
||||
{ "kind": "final", "content": "Hi" },
|
||||
],
|
||||
})
|
||||
);
|
||||
assert!(v.get("session_id").is_none(), "no snake_case leak");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reattach_chat_dto_empty_scrollback_is_empty_array() {
|
||||
let dto = ReattachChatDto {
|
||||
session_id: "s".into(),
|
||||
scrollback: vec![],
|
||||
};
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["scrollback"], json!([]));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CellKind enum wire shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cell_kind_serialises_lowercase_pty_and_chat() {
|
||||
assert_eq!(serde_json::to_value(CellKind::Pty).unwrap(), json!("pty"));
|
||||
assert_eq!(serde_json::to_value(CellKind::Chat).unwrap(), json!("chat"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cellKind derivation on TerminalSessionDto (zone 5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn agent_session(session_id: u128) -> (SessionId, TerminalSession) {
|
||||
let sid = SessionId::from_uuid(Uuid::from_u128(session_id));
|
||||
let node_id = NodeId::from_uuid(Uuid::from_u128(8));
|
||||
let agent_id = AgentId::from_uuid(Uuid::from_u128(9));
|
||||
let cwd = ProjectPath::new("/tmp/project".to_owned()).expect("valid path");
|
||||
let size = PtySize::new(24, 80).unwrap();
|
||||
let mut session =
|
||||
TerminalSession::starting(sid, node_id, cwd, SessionKind::Agent { agent_id }, size);
|
||||
session.status = SessionStatus::Running;
|
||||
(sid, session)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_output_with_structured_descriptor_derives_chat_cell_kind() {
|
||||
let (sid, session) = agent_session(7);
|
||||
let descriptor = StructuredSessionDescriptor {
|
||||
session_id: sid,
|
||||
agent_id: AgentId::from_uuid(Uuid::from_u128(9)),
|
||||
node_id: NodeId::from_uuid(Uuid::from_u128(8)),
|
||||
conversation_id: None,
|
||||
};
|
||||
let out = LaunchAgentOutput {
|
||||
session,
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: Some(descriptor),
|
||||
};
|
||||
let dto = TerminalSessionDto::from(out);
|
||||
assert_eq!(dto.cell_kind, CellKind::Chat);
|
||||
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["cellKind"], "chat", "structured ⇒ chat on the wire");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_output_without_structured_descriptor_derives_pty_cell_kind() {
|
||||
let (_sid, session) = agent_session(7);
|
||||
let out = LaunchAgentOutput {
|
||||
session,
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
};
|
||||
let dto = TerminalSessionDto::from(out);
|
||||
assert_eq!(dto.cell_kind, CellKind::Pty, "no descriptor ⇒ pty");
|
||||
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["cellKind"], "pty");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Non-regression: cellKind is always present & "pty" on the historical paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn terminal_session_dto_from_domain_session_is_always_pty() {
|
||||
// From<TerminalSession> (e.g. open_terminal / change_agent_profile relaunch).
|
||||
let (_sid, session) = agent_session(11);
|
||||
let dto = TerminalSessionDto::from(session);
|
||||
assert_eq!(dto.cell_kind, CellKind::Pty);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(
|
||||
v["cellKind"], "pty",
|
||||
"the new field is always present on the PTY path (non-breaking shape)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pty_launch_output_serialises_cellkind_pty_without_breaking_existing_keys() {
|
||||
// Guard the exact historical key set + the new derived field for a PTY launch.
|
||||
let (sid, session) = agent_session(12);
|
||||
let out = LaunchAgentOutput {
|
||||
session,
|
||||
assigned_conversation_id: None,
|
||||
engine_session_id: None,
|
||||
structured: None,
|
||||
};
|
||||
let v = serde_json::to_value(TerminalSessionDto::from(out)).unwrap();
|
||||
assert_eq!(v["sessionId"], sid.to_string());
|
||||
assert_eq!(v["cwd"], "/tmp/project");
|
||||
assert_eq!(v["rows"], 24);
|
||||
assert_eq!(v["cols"], 80);
|
||||
assert_eq!(v["cellKind"], "pty");
|
||||
// assignedConversationId omitted when None (skip_serializing_if) — unchanged.
|
||||
assert!(v.get("assignedConversationId").is_none());
|
||||
assert!(v.get("session_id").is_none(), "no snake_case leak");
|
||||
}
|
||||
@ -6,7 +6,9 @@ use app_tauri_lib::dto::{
|
||||
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
|
||||
GitFileStatusDto, GitStageRequestDto, GitStatusListDto, GraphCommitDto, GraphCommitListDto,
|
||||
};
|
||||
use application::{GitBranchesOutput, GitCommitOutput, GitGraphOutput, GitLogOutput, GitStatusOutput};
|
||||
use application::{
|
||||
GitBranchesOutput, GitCommitOutput, GitGraphOutput, GitLogOutput, GitStatusOutput,
|
||||
};
|
||||
use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
|
||||
use app_tauri_lib::dto::{
|
||||
parse_layout_id, CreateLayoutRequestDto, CreateLayoutResultDto, DeleteLayoutRequestDto,
|
||||
DeleteLayoutResultDto, LayoutInfoDto, LayoutOperationDto, ListLayoutsDto, RenameLayoutRequestDto,
|
||||
SetActiveLayoutRequestDto,
|
||||
DeleteLayoutResultDto, LayoutInfoDto, LayoutOperationDto, ListLayoutsDto,
|
||||
RenameLayoutRequestDto, SetActiveLayoutRequestDto,
|
||||
};
|
||||
use application::{
|
||||
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutKind, ListLayoutsOutput,
|
||||
@ -63,8 +63,16 @@ fn layout_info_dto_git_graph_kind() {
|
||||
fn list_layouts_dto_from_output() {
|
||||
let out = ListLayoutsOutput {
|
||||
layouts: vec![
|
||||
LayoutInfo { id: lid(1), name: "Default".to_owned(), kind: LayoutKind::Terminal },
|
||||
LayoutInfo { id: lid(2), name: "Backend".to_owned(), kind: LayoutKind::GitGraph },
|
||||
LayoutInfo {
|
||||
id: lid(1),
|
||||
name: "Default".to_owned(),
|
||||
kind: LayoutKind::Terminal,
|
||||
},
|
||||
LayoutInfo {
|
||||
id: lid(2),
|
||||
name: "Backend".to_owned(),
|
||||
kind: LayoutKind::GitGraph,
|
||||
},
|
||||
],
|
||||
active_id: lid(1),
|
||||
};
|
||||
@ -204,7 +212,10 @@ fn set_cell_agent_op_deserialises_with_agent() {
|
||||
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
|
||||
let op = dto.into_operation().unwrap();
|
||||
match op {
|
||||
application::LayoutOperation::SetCellAgent { target: t, agent: a } => {
|
||||
application::LayoutOperation::SetCellAgent {
|
||||
target: t,
|
||||
agent: a,
|
||||
} => {
|
||||
assert_eq!(t, target);
|
||||
assert_eq!(a, Some(agent));
|
||||
}
|
||||
@ -223,7 +234,10 @@ fn set_cell_agent_op_deserialises_with_null_agent() {
|
||||
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
|
||||
let op = dto.into_operation().unwrap();
|
||||
match op {
|
||||
application::LayoutOperation::SetCellAgent { target: t, agent: None } => {
|
||||
application::LayoutOperation::SetCellAgent {
|
||||
target: t,
|
||||
agent: None,
|
||||
} => {
|
||||
assert_eq!(t, target);
|
||||
}
|
||||
_ => panic!("expected SetCellAgent with None agent"),
|
||||
@ -245,3 +259,66 @@ fn set_cell_agent_op_deserialises_with_absent_agent_defaults_to_none() {
|
||||
_ => panic!("expected SetCellAgent with None agent"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setCellConversation operation deserialisation (T4b)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn set_cell_conversation_op_deserialises_with_id() {
|
||||
let target = nid(1);
|
||||
let raw = json!({
|
||||
"type": "setCellConversation",
|
||||
"target": target.to_string(),
|
||||
"conversationId": "conv-42"
|
||||
});
|
||||
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
|
||||
let op = dto.into_operation().unwrap();
|
||||
match op {
|
||||
application::LayoutOperation::SetCellConversation {
|
||||
target: t,
|
||||
conversation_id: Some(id),
|
||||
} => {
|
||||
assert_eq!(t, target);
|
||||
assert_eq!(id, "conv-42");
|
||||
}
|
||||
_ => panic!("expected SetCellConversation with id"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_cell_conversation_op_deserialises_with_null_id() {
|
||||
let target = nid(2);
|
||||
let raw = json!({
|
||||
"type": "setCellConversation",
|
||||
"target": target.to_string(),
|
||||
"conversationId": null
|
||||
});
|
||||
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
|
||||
let op = dto.into_operation().unwrap();
|
||||
match op {
|
||||
application::LayoutOperation::SetCellConversation {
|
||||
target: t,
|
||||
conversation_id: None,
|
||||
} => assert_eq!(t, target),
|
||||
_ => panic!("expected SetCellConversation with None id"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_cell_conversation_op_deserialises_with_absent_id_defaults_to_none() {
|
||||
let target = nid(3);
|
||||
let raw = json!({
|
||||
"type": "setCellConversation",
|
||||
"target": target.to_string()
|
||||
});
|
||||
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
|
||||
let op = dto.into_operation().unwrap();
|
||||
match op {
|
||||
application::LayoutOperation::SetCellConversation {
|
||||
conversation_id: None,
|
||||
..
|
||||
} => {}
|
||||
_ => panic!("expected SetCellConversation with None id"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
Some(format!("{command} --version")),
|
||||
"{projectRoot}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
192
crates/app-tauri/tests/dto_resumable_agents.rs
Normal file
192
crates/app-tauri/tests/dto_resumable_agents.rs
Normal file
@ -0,0 +1,192 @@
|
||||
//! B2 tests for the `list_resumable_agents` DTO contract (ARCHITECTURE §15.2):
|
||||
//! - `From<ResumableAgent> for ResumableAgentDto` maps every field, surfaces
|
||||
//! `conversationId` only when present (omitted via `skip_serializing_if` when
|
||||
//! `None`), and serialises ids as strings + camelCase keys.
|
||||
//! - `From<ListResumableAgentsOutput> for ResumableAgentListDto` preserves the
|
||||
//! `resumable` list and its order.
|
||||
//! - Full + minimal `ResumableAgentDto` serde round-trips on the wire shape.
|
||||
|
||||
use app_tauri_lib::dto::{ResumableAgentDto, ResumableAgentListDto};
|
||||
use application::{ListResumableAgentsOutput, ResumableAgent};
|
||||
use domain::ids::{AgentId, NodeId};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Helper: a resumable agent with a conversation id (resume-capable).
|
||||
fn full(agent_uuid: u128, node_uuid: u128) -> ResumableAgent {
|
||||
ResumableAgent {
|
||||
agent_id: AgentId::from_uuid(Uuid::from_u128(agent_uuid)),
|
||||
name: "Architect".to_owned(),
|
||||
node_id: NodeId::from_uuid(Uuid::from_u128(node_uuid)),
|
||||
conversation_id: Some("conv-123".to_owned()),
|
||||
was_running: true,
|
||||
resume_supported: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: a minimal resumable agent without a conversation id.
|
||||
fn minimal(agent_uuid: u128, node_uuid: u128) -> ResumableAgent {
|
||||
ResumableAgent {
|
||||
agent_id: AgentId::from_uuid(Uuid::from_u128(agent_uuid)),
|
||||
name: "Scratch".to_owned(),
|
||||
node_id: NodeId::from_uuid(Uuid::from_u128(node_uuid)),
|
||||
conversation_id: None,
|
||||
was_running: false,
|
||||
resume_supported: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// From<ResumableAgent> for ResumableAgentDto — field mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn from_resumable_agent_maps_every_field() {
|
||||
let r = full(1, 2);
|
||||
let dto = ResumableAgentDto::from(r.clone());
|
||||
|
||||
assert_eq!(dto.agent_id, r.agent_id.to_string());
|
||||
assert_eq!(dto.name, "Architect");
|
||||
assert_eq!(dto.node_id, r.node_id.to_string());
|
||||
assert_eq!(dto.conversation_id.as_deref(), Some("conv-123"));
|
||||
assert!(dto.was_running);
|
||||
assert!(dto.resume_supported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_resumable_agent_ids_are_strings() {
|
||||
let r = full(7, 8);
|
||||
let dto = ResumableAgentDto::from(r.clone());
|
||||
// The ids cross the wire as UUID strings (not raw bytes / numbers).
|
||||
assert_eq!(dto.agent_id, Uuid::from_u128(7).to_string());
|
||||
assert_eq!(dto.node_id, Uuid::from_u128(8).to_string());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// camelCase wire shape + conversationId omission
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn full_dto_serialises_camelcase_with_conversation() {
|
||||
let dto = ResumableAgentDto::from(full(1, 2));
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
|
||||
assert_eq!(v["agentId"], Uuid::from_u128(1).to_string());
|
||||
assert_eq!(v["name"], "Architect");
|
||||
assert_eq!(v["nodeId"], Uuid::from_u128(2).to_string());
|
||||
assert_eq!(v["conversationId"], "conv-123");
|
||||
assert_eq!(v["wasRunning"], true);
|
||||
assert_eq!(v["resumeSupported"], true);
|
||||
|
||||
// No snake_case leak.
|
||||
assert!(v.get("agent_id").is_none(), "no snake_case leak: {v}");
|
||||
assert!(v.get("node_id").is_none());
|
||||
assert!(v.get("conversation_id").is_none());
|
||||
assert!(v.get("was_running").is_none());
|
||||
assert!(v.get("resume_supported").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_dto_omits_conversation_id_when_none() {
|
||||
let dto = ResumableAgentDto::from(minimal(3, 4));
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
|
||||
// conversationId must be OMITTED (absent, not null) when None.
|
||||
assert!(
|
||||
v.get("conversationId").is_none(),
|
||||
"None conversation ⇒ conversationId omitted, got: {v}"
|
||||
);
|
||||
// Required flags are still present.
|
||||
assert_eq!(v["wasRunning"], false);
|
||||
assert_eq!(v["resumeSupported"], false);
|
||||
assert_eq!(v["name"], "Scratch");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Round-trip serde (Serialize → Deserialize) on the camelCase shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A `Deserialize` twin matching the wire shape, used to prove the serialised
|
||||
/// JSON is exactly the camelCase contract the frontend `ResumableAgent` mirror
|
||||
/// consumes (`ResumableAgentDto` is serialise-only on the prod side).
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct WireResumableAgent {
|
||||
agent_id: String,
|
||||
name: String,
|
||||
node_id: String,
|
||||
#[serde(default)]
|
||||
conversation_id: Option<String>,
|
||||
was_running: bool,
|
||||
resume_supported: bool,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_dto_round_trips_camelcase() {
|
||||
let dto = ResumableAgentDto::from(full(1, 2));
|
||||
let json = serde_json::to_string(&dto).unwrap();
|
||||
let back: WireResumableAgent = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(back.agent_id, Uuid::from_u128(1).to_string());
|
||||
assert_eq!(back.name, "Architect");
|
||||
assert_eq!(back.node_id, Uuid::from_u128(2).to_string());
|
||||
assert_eq!(back.conversation_id.as_deref(), Some("conv-123"));
|
||||
assert!(back.was_running);
|
||||
assert!(back.resume_supported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_dto_round_trips_camelcase_without_conversation() {
|
||||
let dto = ResumableAgentDto::from(minimal(3, 4));
|
||||
let json = serde_json::to_string(&dto).unwrap();
|
||||
// The key is absent from the JSON entirely.
|
||||
assert!(
|
||||
!json.contains("conversationId"),
|
||||
"minimal ⇒ no conversationId key, got: {json}"
|
||||
);
|
||||
let back: WireResumableAgent = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.conversation_id, None);
|
||||
assert!(!back.was_running);
|
||||
assert!(!back.resume_supported);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// From<ListResumableAgentsOutput> for ResumableAgentListDto — list + order
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn from_output_preserves_list_and_order() {
|
||||
let out = ListResumableAgentsOutput {
|
||||
resumable: vec![full(10, 11), minimal(20, 21), full(30, 31)],
|
||||
};
|
||||
let dto = ResumableAgentListDto::from(out);
|
||||
|
||||
assert_eq!(dto.resumable.len(), 3);
|
||||
// Order preserved.
|
||||
assert_eq!(dto.resumable[0].agent_id, Uuid::from_u128(10).to_string());
|
||||
assert_eq!(dto.resumable[1].agent_id, Uuid::from_u128(20).to_string());
|
||||
assert_eq!(dto.resumable[2].agent_id, Uuid::from_u128(30).to_string());
|
||||
// Middle entry is the minimal one (no conversation).
|
||||
assert!(dto.resumable[1].conversation_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_output_serialises_empty_resumable_array() {
|
||||
let dto = ResumableAgentListDto::from(ListResumableAgentsOutput::default());
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
assert_eq!(v["resumable"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_dto_serialises_camelcase_wrapper() {
|
||||
let out = ListResumableAgentsOutput {
|
||||
resumable: vec![full(10, 11)],
|
||||
};
|
||||
let dto = ResumableAgentListDto::from(out);
|
||||
let v = serde_json::to_value(&dto).unwrap();
|
||||
|
||||
// Wrapper key is `resumable`; nested entries are camelCase.
|
||||
let arr = v["resumable"].as_array().expect("resumable is an array");
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr[0]["agentId"], Uuid::from_u128(10).to_string());
|
||||
assert_eq!(arr[0]["nodeId"], Uuid::from_u128(11).to_string());
|
||||
}
|
||||
@ -53,8 +53,14 @@ fn template_dto_serialises_camelcase() {
|
||||
ProfileId::from_uuid(Uuid::from_u128(2)).to_string()
|
||||
);
|
||||
// no snake_case leak
|
||||
assert!(v.get("content_md").is_none(), "no snake_case leak for contentMd");
|
||||
assert!(v.get("default_profile_id").is_none(), "no snake_case leak for defaultProfileId");
|
||||
assert!(
|
||||
v.get("content_md").is_none(),
|
||||
"no snake_case leak for contentMd"
|
||||
);
|
||||
assert!(
|
||||
v.get("default_profile_id").is_none(),
|
||||
"no snake_case leak for defaultProfileId"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -98,7 +104,9 @@ fn template_list_dto_empty() {
|
||||
#[test]
|
||||
fn create_template_output_maps_to_template_dto() {
|
||||
let tmpl = make_template(5, 6);
|
||||
let out = CreateTemplateOutput { template: tmpl.clone() };
|
||||
let out = CreateTemplateOutput {
|
||||
template: tmpl.clone(),
|
||||
};
|
||||
let dto = TemplateDto::from(out);
|
||||
assert_eq!(dto.0.id, tmpl.id);
|
||||
}
|
||||
@ -107,7 +115,9 @@ fn create_template_output_maps_to_template_dto() {
|
||||
fn update_template_output_maps_to_template_dto() {
|
||||
let tmpl = make_template(7, 8);
|
||||
let bumped = tmpl.with_updated_content(MarkdownDoc::new("# Updated".to_owned()));
|
||||
let out = UpdateTemplateOutput { template: bumped.clone() };
|
||||
let out = UpdateTemplateOutput {
|
||||
template: bumped.clone(),
|
||||
};
|
||||
let dto = TemplateDto::from(out);
|
||||
assert_eq!(dto.0.version, TemplateVersion(2));
|
||||
assert_eq!(dto.0.id, bumped.id);
|
||||
@ -132,7 +142,10 @@ fn agent_drift_dto_serialises_camelcase() {
|
||||
assert_eq!(v["from"], 1u64);
|
||||
assert_eq!(v["to"], 3u64);
|
||||
// no snake_case leak
|
||||
assert!(v.get("agent_id").is_none(), "no snake_case leak for agentId");
|
||||
assert!(
|
||||
v.get("agent_id").is_none(),
|
||||
"no snake_case leak for agentId"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -15,7 +15,10 @@ fn move_tab_result_serializes_new_window_id_camel_case() {
|
||||
let dto = MoveTabResultDto::from(out);
|
||||
let json = serde_json::to_string(&dto).unwrap();
|
||||
assert!(json.contains("\"newWindowId\""), "json was {json}");
|
||||
assert!(!json.contains("new_window_id"), "no snake_case leak: {json}");
|
||||
assert!(
|
||||
!json.contains("new_window_id"),
|
||||
"no snake_case leak: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
182
crates/app-tauri/tests/list_live_agents_r0b.rs
Normal file
182
crates/app-tauri/tests/list_live_agents_r0b.rs
Normal file
@ -0,0 +1,182 @@
|
||||
//! LOT R0b (cadrage orchestration v5 §3.3, Trou B) — `list_live_agents` lit
|
||||
//! l'agrégateur `LiveSessions` (PTY **+** structuré), plus seulement le registre
|
||||
//! PTY. Un agent chat (structuré) vivant doit apparaître dans la liste, comme un
|
||||
//! agent PTY ; les deux ensemble sans doublon ; aucune session ⇒ liste vide.
|
||||
//!
|
||||
//! Ces tests reproduisent **exactement** ce que fait la commande
|
||||
//! `list_live_agents` : construire un `LiveSessions` à partir des deux registres
|
||||
//! partagés et passer `live_agents()` à `LiveAgentListDto::from_pairs`. Ils
|
||||
//! exercent donc le câblage de l'agrégateur **et** la dé-duplication par agent
|
||||
//! portée par le DTO (contrat de liveness pour l'UI). 100 % fakes, sans process.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use app_tauri_lib::dto::LiveAgentListDto;
|
||||
use application::{LiveSessions, StructuredSessions, TerminalSessions};
|
||||
use domain::ports::{AgentSession, AgentSessionError, PtyHandle, ReplyStream};
|
||||
use domain::{AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession};
|
||||
use uuid::Uuid;
|
||||
|
||||
// --- petits constructeurs déterministes ------------------------------------
|
||||
|
||||
fn sid(n: u128) -> SessionId {
|
||||
SessionId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn nid(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
/// Fake minimal d'`AgentSession` : porte juste l'id (ce que le registre clé).
|
||||
struct FakeSession {
|
||||
id: SessionId,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentSession for FakeSession {
|
||||
fn id(&self) -> SessionId {
|
||||
self.id
|
||||
}
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
Ok(Box::new(std::iter::empty()))
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn fake(id: SessionId) -> Arc<dyn AgentSession> {
|
||||
Arc::new(FakeSession { id })
|
||||
}
|
||||
|
||||
/// Insère un agent PTY dans `TerminalSessions` (jumeau du helper de D1).
|
||||
fn insert_pty(pty: &TerminalSessions, s: SessionId, a: AgentId, n: NodeId) {
|
||||
let session = TerminalSession::starting(
|
||||
s,
|
||||
n,
|
||||
ProjectPath::new("/p").unwrap(),
|
||||
SessionKind::Agent { agent_id: a },
|
||||
PtySize::new(24, 80).unwrap(),
|
||||
);
|
||||
pty.insert(PtyHandle { session_id: s }, session);
|
||||
}
|
||||
|
||||
/// Reproduit le corps de la commande `list_live_agents` (hors validation d'id).
|
||||
fn dto_for(pty: &Arc<TerminalSessions>, structured: &Arc<StructuredSessions>) -> LiveAgentListDto {
|
||||
let live = LiveSessions::new(Arc::clone(pty), Arc::clone(structured));
|
||||
LiveAgentListDto::from_pairs(live.live_agents())
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 1. Un agent PTY vivant apparaît.
|
||||
// ===========================================================================
|
||||
#[test]
|
||||
fn pty_live_agent_is_listed() {
|
||||
let pty = Arc::new(TerminalSessions::new());
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
|
||||
let a = aid(10);
|
||||
insert_pty(&pty, sid(1), a, nid(100));
|
||||
|
||||
let dto = dto_for(&pty, &structured);
|
||||
assert_eq!(dto.0.len(), 1);
|
||||
assert_eq!(dto.0[0].agent_id, a.to_string());
|
||||
assert_eq!(dto.0[0].node_id, nid(100).to_string());
|
||||
assert_eq!(dto.0[0].session_id, sid(1).to_string());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 2. Un agent structuré/chat vivant apparaît (le cas qui régressait — Trou B).
|
||||
// ===========================================================================
|
||||
#[test]
|
||||
fn structured_live_agent_is_listed() {
|
||||
let pty = Arc::new(TerminalSessions::new());
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
|
||||
let a = aid(20);
|
||||
structured.insert(fake(sid(2)), a, nid(200));
|
||||
|
||||
let dto = dto_for(&pty, &structured);
|
||||
assert_eq!(
|
||||
dto.0.len(),
|
||||
1,
|
||||
"un agent chat vivant doit apparaître (il était invisible avant R0b)"
|
||||
);
|
||||
assert_eq!(dto.0[0].agent_id, a.to_string());
|
||||
assert_eq!(dto.0[0].node_id, nid(200).to_string());
|
||||
assert_eq!(dto.0[0].session_id, sid(2).to_string());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 3. Les deux types vivants en même temps ⇒ présents, sans doublon.
|
||||
// ===========================================================================
|
||||
#[test]
|
||||
fn both_kinds_live_listed_without_duplicates() {
|
||||
let pty = Arc::new(TerminalSessions::new());
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
|
||||
let pty_agent = aid(10);
|
||||
let chat_agent = aid(20);
|
||||
insert_pty(&pty, sid(1), pty_agent, nid(100));
|
||||
structured.insert(fake(sid(2)), chat_agent, nid(200));
|
||||
|
||||
let dto = dto_for(&pty, &structured);
|
||||
assert_eq!(dto.0.len(), 2, "les deux registres contribuent");
|
||||
|
||||
let mut ids: Vec<&str> = dto.0.iter().map(|d| d.agent_id.as_str()).collect();
|
||||
ids.sort_unstable();
|
||||
let mut expected = vec![pty_agent.to_string(), chat_agent.to_string()];
|
||||
expected.sort_unstable();
|
||||
assert_eq!(ids, expected);
|
||||
|
||||
// Aucun agent répété (contrat de liveness pour l'UI).
|
||||
let mut uniq = ids.clone();
|
||||
uniq.dedup();
|
||||
assert_eq!(uniq.len(), dto.0.len(), "aucun doublon d'agent");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 3bis. Même agent présent dans les DEUX registres ⇒ une seule entrée.
|
||||
//
|
||||
// L'invariant « 1 session vivante/agent » l'interdit normalement, mais le DTO
|
||||
// doit rester dup-free par défense en profondeur : un seul `agent_id` sur le fil.
|
||||
// ===========================================================================
|
||||
#[test]
|
||||
fn same_agent_in_both_registries_is_deduplicated() {
|
||||
let pty = Arc::new(TerminalSessions::new());
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
|
||||
let a = aid(30);
|
||||
insert_pty(&pty, sid(1), a, nid(100));
|
||||
structured.insert(fake(sid(2)), a, nid(200));
|
||||
|
||||
let dto = dto_for(&pty, &structured);
|
||||
assert_eq!(
|
||||
dto.0.len(),
|
||||
1,
|
||||
"un même agent ne doit apparaître qu'une fois"
|
||||
);
|
||||
assert_eq!(dto.0[0].agent_id, a.to_string());
|
||||
// On garde la première occurrence (PTY, listé en premier par l'agrégateur).
|
||||
assert_eq!(dto.0[0].node_id, nid(100).to_string());
|
||||
assert_eq!(dto.0[0].session_id, sid(1).to_string());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 4. Aucune session ⇒ liste vide.
|
||||
// ===========================================================================
|
||||
#[test]
|
||||
fn no_sessions_yields_empty_list() {
|
||||
let pty = Arc::new(TerminalSessions::new());
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
|
||||
let dto = dto_for(&pty, &structured);
|
||||
assert!(dto.0.is_empty(), "aucune session ⇒ liste vide");
|
||||
}
|
||||
332
crates/app-tauri/tests/orchestrator_wiring.rs
Normal file
332
crates/app-tauri/tests/orchestrator_wiring.rs
Normal file
@ -0,0 +1,332 @@
|
||||
//! Integration test for the orchestrator wiring in the composition root
|
||||
//! (ARCHITECTURE §14.3).
|
||||
//!
|
||||
//! These tests prove that [`AppState`] actually *starts and stops* per-project
|
||||
//! orchestrator watchers — the gap that previously left the whole §14.3 feature
|
||||
//! dormant (the `OrchestratorService`/watcher existed but were never constructed
|
||||
//! at runtime). The per-file request→dispatch→response behaviour is covered by
|
||||
//! the infrastructure watcher tests; here we assert the lifecycle the open/close
|
||||
//! commands rely on: registration is idempotent, projects are isolated, and
|
||||
//! stopping unregisters.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use app_tauri_lib::mcp_endpoint::mcp_endpoint;
|
||||
use app_tauri_lib::state::AppState;
|
||||
use domain::ports::IdGenerator;
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::ProjectId;
|
||||
use infrastructure::UuidGenerator;
|
||||
|
||||
/// A unique, absolute temp path (never written to at build time — the stores are
|
||||
/// lazy — so it need not exist).
|
||||
fn temp_path(tag: &str) -> PathBuf {
|
||||
let ids = UuidGenerator::new();
|
||||
std::env::temp_dir().join(format!("idea-orch-test-{tag}-{}", ids.new_uuid()))
|
||||
}
|
||||
|
||||
/// Builds a domain [`Project`] rooted at a fresh temp path.
|
||||
fn make_project() -> Project {
|
||||
let ids = UuidGenerator::new();
|
||||
let root = temp_path("root");
|
||||
Project::new(
|
||||
ProjectId::from_uuid(ids.new_uuid()),
|
||||
"demo",
|
||||
ProjectPath::new(root.to_string_lossy().into_owned()).unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn watcher_count(state: &AppState) -> usize {
|
||||
state.orchestrator_watchers.lock().unwrap().len()
|
||||
}
|
||||
|
||||
fn has_watcher(state: &AppState, id: &ProjectId) -> bool {
|
||||
state.orchestrator_watchers.lock().unwrap().contains_key(id)
|
||||
}
|
||||
|
||||
fn mcp_count(state: &AppState) -> usize {
|
||||
state.mcp_servers.lock().unwrap().len()
|
||||
}
|
||||
|
||||
fn has_mcp(state: &AppState, id: &ProjectId) -> bool {
|
||||
state.mcp_servers.lock().unwrap().contains_key(id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_watch_registers_a_watcher_and_is_idempotent() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
let project = make_project();
|
||||
|
||||
assert_eq!(watcher_count(&state), 0, "no watcher before open");
|
||||
|
||||
state.ensure_orchestrator_watch(&project);
|
||||
assert!(has_watcher(&state, &project.id));
|
||||
assert_eq!(watcher_count(&state), 1);
|
||||
|
||||
// Opening the same project again must not spawn a second watcher.
|
||||
state.ensure_orchestrator_watch(&project);
|
||||
assert_eq!(watcher_count(&state), 1, "ensure is idempotent per project");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_watch_unregisters_the_watcher() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
let project = make_project();
|
||||
|
||||
state.ensure_orchestrator_watch(&project);
|
||||
assert!(has_watcher(&state, &project.id));
|
||||
|
||||
state.stop_orchestrator_watch(&project.id);
|
||||
assert!(
|
||||
!has_watcher(&state, &project.id),
|
||||
"watcher removed on close"
|
||||
);
|
||||
assert_eq!(watcher_count(&state), 0);
|
||||
|
||||
// Stopping an unknown project is a no-op (does not panic).
|
||||
state.stop_orchestrator_watch(&project.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watchers_are_isolated_per_project() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
let a = make_project();
|
||||
let b = make_project();
|
||||
|
||||
state.ensure_orchestrator_watch(&a);
|
||||
state.ensure_orchestrator_watch(&b);
|
||||
|
||||
assert_eq!(watcher_count(&state), 2);
|
||||
assert!(has_watcher(&state, &a.id));
|
||||
assert!(has_watcher(&state, &b.id));
|
||||
|
||||
// Closing one leaves the other running.
|
||||
state.stop_orchestrator_watch(&a.id);
|
||||
assert!(!has_watcher(&state, &a.id));
|
||||
assert!(has_watcher(&state, &b.id));
|
||||
assert_eq!(watcher_count(&state), 1);
|
||||
}
|
||||
|
||||
// --- M3: IdeA MCP server lifecycle (twin of the watcher, Décision 4) ---
|
||||
//
|
||||
// The MCP server registry (`mcp_servers`) is the twin of `orchestrator_watchers`:
|
||||
// `ensure_orchestrator_watch` starts both side by side on open/create, and
|
||||
// `stop_orchestrator_watch` tears both down on close. These tests mirror the
|
||||
// watcher lifecycle tests above against the MCP registry. The per-project
|
||||
// supervision task parks on a stop signal (no blocking serve loop), so open/close
|
||||
// must return promptly — the `#[tokio::test]` harness itself proves no figing
|
||||
// (the test completes).
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_watch_starts_an_mcp_server_per_project() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
let project = make_project();
|
||||
|
||||
assert_eq!(mcp_count(&state), 0, "no MCP server before open");
|
||||
|
||||
state.ensure_orchestrator_watch(&project);
|
||||
assert!(
|
||||
has_mcp(&state, &project.id),
|
||||
"MCP server registered alongside the watcher"
|
||||
);
|
||||
assert_eq!(mcp_count(&state), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_mcp_server_is_idempotent_per_project() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
let project = make_project();
|
||||
|
||||
state.ensure_orchestrator_watch(&project);
|
||||
assert_eq!(mcp_count(&state), 1);
|
||||
|
||||
// Opening the same project again must not spawn a second MCP server.
|
||||
state.ensure_orchestrator_watch(&project);
|
||||
assert_eq!(
|
||||
mcp_count(&state),
|
||||
1,
|
||||
"MCP server start is idempotent per project"
|
||||
);
|
||||
assert!(has_mcp(&state, &project.id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_watch_unregisters_the_mcp_server() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
let project = make_project();
|
||||
|
||||
state.ensure_orchestrator_watch(&project);
|
||||
assert!(has_mcp(&state, &project.id));
|
||||
|
||||
state.stop_orchestrator_watch(&project.id);
|
||||
assert!(!has_mcp(&state, &project.id), "MCP server removed on close");
|
||||
assert_eq!(mcp_count(&state), 0);
|
||||
|
||||
// Stopping an unknown project is a no-op (does not panic) for the MCP twin too.
|
||||
state.stop_orchestrator_watch(&project.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watcher_and_mcp_server_coexist_and_close_together() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
let project = make_project();
|
||||
|
||||
// Open: both entry doors onto the same OrchestratorService are live.
|
||||
state.ensure_orchestrator_watch(&project);
|
||||
assert!(has_watcher(&state, &project.id), "watcher live on open");
|
||||
assert!(has_mcp(&state, &project.id), "MCP server live on open");
|
||||
assert_eq!(watcher_count(&state), 1);
|
||||
assert_eq!(mcp_count(&state), 1);
|
||||
|
||||
// Close: the symmetric teardown removes both.
|
||||
state.stop_orchestrator_watch(&project.id);
|
||||
assert!(!has_watcher(&state, &project.id), "watcher gone on close");
|
||||
assert!(!has_mcp(&state, &project.id), "MCP server gone on close");
|
||||
assert_eq!(watcher_count(&state), 0);
|
||||
assert_eq!(mcp_count(&state), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_servers_are_isolated_per_project() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
let a = make_project();
|
||||
let b = make_project();
|
||||
|
||||
state.ensure_orchestrator_watch(&a);
|
||||
state.ensure_orchestrator_watch(&b);
|
||||
|
||||
assert_eq!(mcp_count(&state), 2, "one MCP server per open project");
|
||||
assert!(has_mcp(&state, &a.id));
|
||||
assert!(has_mcp(&state, &b.id));
|
||||
|
||||
// Closing one leaves the other's MCP server running.
|
||||
state.stop_orchestrator_watch(&a.id);
|
||||
assert!(!has_mcp(&state, &a.id));
|
||||
assert!(has_mcp(&state, &b.id));
|
||||
assert_eq!(mcp_count(&state), 1);
|
||||
}
|
||||
|
||||
// --- M5a: per-project loopback MCP endpoint lifecycle ---
|
||||
//
|
||||
// `mcp_endpoint(project_id)` is the single source of truth for the loopback
|
||||
// address (cadrage v5 §2). `ensure_mcp_server` binds it at open; dropping the
|
||||
// handle on close unlinks it (Unix). On Unix the endpoint is a UDS *file* whose
|
||||
// existence is directly observable; these tests assert bind → idempotence →
|
||||
// cleanup → determinism/no-collision → coexistence with the file watcher.
|
||||
|
||||
/// Polls until `cond()` holds or the bound elapses (cleanup is async: the handle's
|
||||
/// supervision task drops the listener — and unlinks the socket — only after the
|
||||
/// stop signal propagates). Bounded so a regression fails fast, never hangs.
|
||||
#[cfg(unix)]
|
||||
async fn wait_until(mut cond: impl FnMut() -> bool) -> bool {
|
||||
for _ in 0..100 {
|
||||
if cond() {
|
||||
return true;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
cond()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn socket_exists(project: &Project) -> bool {
|
||||
mcp_endpoint(&project.id)
|
||||
.socket_path()
|
||||
.map(|p| p.exists())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn open_binds_the_project_loopback_endpoint() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
let project = make_project();
|
||||
|
||||
assert!(!socket_exists(&project), "no socket before open");
|
||||
|
||||
state.ensure_orchestrator_watch(&project);
|
||||
assert!(
|
||||
wait_until(|| socket_exists(&project)).await,
|
||||
"the project's loopback socket is bound on open"
|
||||
);
|
||||
|
||||
state.stop_orchestrator_watch(&project.id);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn double_open_keeps_a_single_endpoint_no_address_in_use() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
let project = make_project();
|
||||
|
||||
state.ensure_orchestrator_watch(&project);
|
||||
assert!(wait_until(|| socket_exists(&project)).await);
|
||||
|
||||
// A second open must NOT rebind (which would fail "address in use" on a live
|
||||
// socket) — it returns early. One endpoint, still bound, no panic.
|
||||
state.ensure_orchestrator_watch(&project);
|
||||
assert_eq!(mcp_count(&state), 1, "one endpoint per project");
|
||||
assert!(
|
||||
socket_exists(&project),
|
||||
"endpoint still bound after re-open"
|
||||
);
|
||||
|
||||
state.stop_orchestrator_watch(&project.id);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn close_cleans_up_the_endpoint_socket_file() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
let project = make_project();
|
||||
|
||||
state.ensure_orchestrator_watch(&project);
|
||||
assert!(wait_until(|| socket_exists(&project)).await);
|
||||
|
||||
state.stop_orchestrator_watch(&project.id);
|
||||
assert!(
|
||||
wait_until(|| !socket_exists(&project)).await,
|
||||
"the socket file is unlinked on close — no leak"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_is_deterministic_and_collision_free_across_projects() {
|
||||
let p1 = make_project();
|
||||
let p2 = make_project();
|
||||
|
||||
// Stable for the same project across calls.
|
||||
assert_eq!(mcp_endpoint(&p1.id), mcp_endpoint(&p1.id));
|
||||
// Distinct projects ⇒ distinct endpoints (no collision).
|
||||
assert_ne!(mcp_endpoint(&p1.id), mcp_endpoint(&p2.id));
|
||||
assert_ne!(
|
||||
mcp_endpoint(&p1.id).as_cli_arg(),
|
||||
mcp_endpoint(&p2.id).as_cli_arg()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn file_watcher_and_loopback_endpoint_live_together() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
let project = make_project();
|
||||
|
||||
state.ensure_orchestrator_watch(&project);
|
||||
|
||||
// R0/M3 invariant intact: the file watcher and the MCP server are both live...
|
||||
assert!(has_watcher(&state, &project.id), "watcher live");
|
||||
assert!(has_mcp(&state, &project.id), "mcp server live");
|
||||
// ...and on Unix the loopback endpoint is actually bound beside the watcher.
|
||||
assert!(
|
||||
wait_until(|| socket_exists(&project)).await,
|
||||
"endpoint bound alongside the live file watcher"
|
||||
);
|
||||
|
||||
state.stop_orchestrator_watch(&project.id);
|
||||
assert!(wait_until(|| !socket_exists(&project)).await);
|
||||
}
|
||||
@ -47,7 +47,10 @@ fn send_output_delivers_bytes_to_registered_channel() {
|
||||
bridge.register(session, capturing_channel(Arc::clone(&sink)));
|
||||
|
||||
let delivered = bridge.send_output(&session, vec![104, 105]);
|
||||
assert!(delivered, "send_output should return true for a live session");
|
||||
assert!(
|
||||
delivered,
|
||||
"send_output should return true for a live session"
|
||||
);
|
||||
|
||||
let captured = sink.lock().unwrap();
|
||||
assert_eq!(captured.as_slice(), &[vec![104, 105]]);
|
||||
@ -82,9 +85,64 @@ fn register_same_session_twice_replaces_channel() {
|
||||
|
||||
bridge.register(session, capturing_channel(Arc::clone(&first)));
|
||||
bridge.register(session, capturing_channel(Arc::clone(&second)));
|
||||
assert_eq!(bridge.active_sessions(), 1, "same id is replaced, not added");
|
||||
assert_eq!(
|
||||
bridge.active_sessions(),
|
||||
1,
|
||||
"same id is replaced, not added"
|
||||
);
|
||||
|
||||
bridge.send_output(&session, vec![9]);
|
||||
assert!(first.lock().unwrap().is_empty(), "old channel no longer used");
|
||||
assert!(
|
||||
first.lock().unwrap().is_empty(),
|
||||
"old channel no longer used"
|
||||
);
|
||||
assert_eq!(second.lock().unwrap().as_slice(), &[vec![9]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_returns_monotonic_generation_per_session() {
|
||||
let bridge = PtyBridge::new();
|
||||
let session = sid();
|
||||
let g0 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
|
||||
let g1 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
|
||||
assert_eq!(g0, 0);
|
||||
assert_eq!(g1, 1, "re-attaching the same session bumps the generation");
|
||||
}
|
||||
|
||||
/// The regression guard for on-resize output duplication: a superseded attach's
|
||||
/// pump thread (older generation) must NOT tear down the channel of the
|
||||
/// re-attach that replaced it.
|
||||
#[test]
|
||||
fn unregister_if_is_a_noop_for_a_superseded_generation() {
|
||||
let bridge = PtyBridge::new();
|
||||
let session = sid();
|
||||
let old = Arc::new(Mutex::new(Vec::new()));
|
||||
let new = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let old_gen = bridge.register(session, capturing_channel(Arc::clone(&old)));
|
||||
let _new_gen = bridge.register(session, capturing_channel(Arc::clone(&new)));
|
||||
|
||||
// The stale (old) pump thread ends and tries to clean up with its own gen.
|
||||
bridge.unregister_if(&session, old_gen);
|
||||
|
||||
// The current channel survives and still delivers — no duplication, no drop.
|
||||
assert_eq!(
|
||||
bridge.active_sessions(),
|
||||
1,
|
||||
"live re-attach must not be removed"
|
||||
);
|
||||
assert!(bridge.send_output(&session, vec![7]));
|
||||
assert_eq!(new.lock().unwrap().as_slice(), &[vec![7]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_if_removes_when_generation_is_current() {
|
||||
let bridge = PtyBridge::new();
|
||||
let session = sid();
|
||||
let gen = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
|
||||
|
||||
// The current attach's pump thread ends (PTY EOF): it owns the live channel.
|
||||
bridge.unregister_if(&session, gen);
|
||||
assert_eq!(bridge.active_sessions(), 0);
|
||||
assert!(!bridge.send_output(&session, vec![1]));
|
||||
}
|
||||
|
||||
@ -14,6 +14,9 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# `v5` derives stable reference-profile ids from a fixed namespace (catalogue).
|
||||
uuid = { workspace = true }
|
||||
# `time` feature only : borne le rendez-vous synchrone `send_blocking` (§17.4).
|
||||
# Déjà le runtime async du workspace ; pas une nouvelle dépendance externe.
|
||||
tokio = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
|
||||
@ -18,7 +18,11 @@
|
||||
//! making the reference profiles addressable across runs without a registry.
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::permission::ProjectorKey;
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
||||
StructuredAdapter,
|
||||
};
|
||||
|
||||
/// A fixed UUID namespace used to derive stable ids for reference profiles.
|
||||
/// (Random-looking but constant; only its stability matters.)
|
||||
@ -54,8 +58,16 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
.expect("CLAUDE.md is a valid convention target"),
|
||||
Some("claude --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.expect("claude reference profile is valid"),
|
||||
.expect("claude reference profile is valid")
|
||||
.with_structured_adapter(StructuredAdapter::Claude)
|
||||
.with_projector(ProjectorKey::Claude)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::config_file(".mcp.json")
|
||||
.expect(".mcp.json is a valid relative MCP config target"),
|
||||
McpTransport::Stdio,
|
||||
)),
|
||||
AgentProfile::new(
|
||||
reference_id("codex"),
|
||||
"OpenAI Codex CLI",
|
||||
@ -65,8 +77,19 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
.expect("AGENTS.md is a valid convention target"),
|
||||
Some("codex --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.expect("codex reference profile is valid"),
|
||||
.expect("codex reference profile is valid")
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_projector(ProjectorKey::Codex)
|
||||
.with_mcp(McpCapability::new(
|
||||
// Codex lit ses serveurs MCP dans `$CODEX_HOME/config.toml`, pas `.mcp.json` :
|
||||
// IdeA écrit ce TOML DANS le run dir et pointe `CODEX_HOME` dessus pour
|
||||
// isoler l'agent du `~/.codex` global (miroir du `.mcp.json` de Claude).
|
||||
McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
|
||||
.expect(".codex/config.toml + CODEX_HOME is a valid MCP config target"),
|
||||
McpTransport::Stdio,
|
||||
)),
|
||||
AgentProfile::new(
|
||||
reference_id("gemini"),
|
||||
"Gemini CLI",
|
||||
@ -76,6 +99,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
.expect("GEMINI.md is a valid convention target"),
|
||||
Some("gemini --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.expect("gemini reference profile is valid"),
|
||||
AgentProfile::new(
|
||||
@ -87,7 +111,123 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
.expect("aider flag template is non-empty"),
|
||||
Some("aider --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.expect("aider reference profile is valid"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns the **selectable** subset of [`reference_profiles`] — the profiles the
|
||||
/// first-run wizard and the agent-creation menu are allowed to offer (§17.3,
|
||||
/// lot D7).
|
||||
///
|
||||
/// A profile is selectable iff it can be driven in **structured** mode
|
||||
/// ([`AgentProfile::is_selectable`] = it carries a `structured_adapter`). Today
|
||||
/// that is Claude + Codex; Gemini/Aider stay in [`reference_profiles`] (the data
|
||||
/// catalogue is untouched) but are **not** proposed for selection. There is no
|
||||
/// custom-profile entry here either: the selection path offers only profiles we
|
||||
/// know how to pilot.
|
||||
///
|
||||
/// This filter is the single selection gate; `is_selectable` is the same
|
||||
/// predicate the `AgentSessionFactory` uses to decide it `supports` a profile, so
|
||||
/// the menu and the runtime can never disagree.
|
||||
#[must_use]
|
||||
pub fn selectable_reference_profiles() -> Vec<AgentProfile> {
|
||||
reference_profiles()
|
||||
.into_iter()
|
||||
.filter(AgentProfile::is_selectable)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mcp_tests {
|
||||
use super::*;
|
||||
|
||||
fn profile(slug: &str) -> AgentProfile {
|
||||
let id = reference_id(slug);
|
||||
reference_profiles()
|
||||
.into_iter()
|
||||
.find(|p| p.id == id)
|
||||
.unwrap_or_else(|| panic!("reference profile `{slug}` exists"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_and_codex_expose_mcp_capability() {
|
||||
for slug in ["claude", "codex"] {
|
||||
let p = profile(slug);
|
||||
assert!(
|
||||
p.mcp.is_some(),
|
||||
"structured profile `{slug}` must carry an MCP capability"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_mcp_uses_config_file_mcp_json() {
|
||||
let mcp = profile("claude").mcp.expect("mcp present");
|
||||
assert_eq!(
|
||||
mcp.config,
|
||||
McpConfigStrategy::ConfigFile {
|
||||
target: ".mcp.json".to_owned()
|
||||
},
|
||||
"Claude should declare `.mcp.json`"
|
||||
);
|
||||
assert_eq!(mcp.transport, McpTransport::Stdio);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_mcp_uses_toml_config_home_codex() {
|
||||
// Codex lit `$CODEX_HOME/config.toml`, pas `.mcp.json` : le seed doit déclarer
|
||||
// la stratégie TOML isolée par `CODEX_HOME` (pendant Codex de Claude).
|
||||
let mcp = profile("codex").mcp.expect("mcp present");
|
||||
assert_eq!(
|
||||
mcp.config,
|
||||
McpConfigStrategy::TomlConfigHome {
|
||||
target: ".codex/config.toml".to_owned(),
|
||||
home_env: "CODEX_HOME".to_owned(),
|
||||
},
|
||||
"Codex should declare `.codex/config.toml` + CODEX_HOME"
|
||||
);
|
||||
assert_eq!(mcp.transport, McpTransport::Stdio);
|
||||
assert!(
|
||||
profile("codex").materializes_idea_bridge(),
|
||||
"the Codex seed must materialise the idea bridge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_and_aider_have_no_mcp_capability() {
|
||||
for slug in ["gemini", "aider"] {
|
||||
assert!(
|
||||
profile(slug).mcp.is_none(),
|
||||
"non-structured profile `{slug}` must NOT carry MCP (file fallback)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Lot LP3 : projector (clé du projecteur de permissions par-CLI) ----------
|
||||
|
||||
#[test]
|
||||
fn claude_and_codex_seed_their_projector_key() {
|
||||
assert_eq!(
|
||||
profile("claude").projector,
|
||||
Some(ProjectorKey::Claude),
|
||||
"the Claude seed must pose the Claude projector"
|
||||
);
|
||||
assert_eq!(
|
||||
profile("codex").projector,
|
||||
Some(ProjectorKey::Codex),
|
||||
"the Codex seed must pose the Codex projector"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_and_aider_have_no_projector() {
|
||||
for slug in ["gemini", "aider"] {
|
||||
assert!(
|
||||
profile(slug).projector.is_none(),
|
||||
"non-structured profile `{slug}` must NOT carry a projector (native prompting)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
142
crates/application/src/agent/inspect.rs
Normal file
142
crates/application/src/agent/inspect.rs
Normal file
@ -0,0 +1,142 @@
|
||||
//! Best-effort conversation inspection use case (CONTEXT §T7, Part A).
|
||||
//!
|
||||
//! [`InspectConversation`] enriches a resume popup with the *last topic* and a
|
||||
//! *token indicator* read from a CLI's on-disk transcript. It is **best-effort
|
||||
//! by construction**: it routes the agent's [`AgentProfile`] to the first
|
||||
//! injected [`SessionInspector`] that [`supports`](SessionInspector::supports)
|
||||
//! it, and *any* miss — no inspector at all, an unsupported profile,
|
||||
//! [`InspectError::NotFound`], or [`InspectError::Read`] — degrades to **empty
|
||||
//! details** (`last_topic: None, token_count: None`) instead of an error. The
|
||||
//! resume must never be blocked by inspection.
|
||||
//!
|
||||
//! Extensibility (Open/Closed): adding a new inspectable CLI is *pushing one
|
||||
//! more adapter into the `Vec`* at the composition root — no change here.
|
||||
//!
|
||||
//! Like [`super::lifecycle::LaunchAgent`], it resolves the agent from the
|
||||
//! project manifest and its profile from the [`ProfileStore`], and inspects
|
||||
//! against the agent's **isolated run directory** (the very `cwd` the CLI was
|
||||
//! launched with) so the inspector points at the right transcript folder.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
AgentContextStore, ConversationDetails, InspectError, ProfileStore, SessionInspector,
|
||||
};
|
||||
use domain::{AgentId, Project};
|
||||
|
||||
use super::lifecycle::agent_run_dir;
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Input for [`InspectConversation::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InspectConversationInput {
|
||||
/// The project owning the agent.
|
||||
pub project: Project,
|
||||
/// The agent whose conversation is being inspected.
|
||||
pub agent_id: AgentId,
|
||||
/// The persistent CLI conversation id recorded on the hosting cell.
|
||||
pub conversation_id: String,
|
||||
}
|
||||
|
||||
/// Output of [`InspectConversation::execute`]: the (possibly empty) best-effort
|
||||
/// details. Never an inspection error — a miss surfaces as empty fields.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InspectConversationOutput {
|
||||
/// Enriched, best-effort details (every field optional).
|
||||
pub details: ConversationDetails,
|
||||
}
|
||||
|
||||
/// Reads best-effort [`ConversationDetails`] for an agent's conversation.
|
||||
///
|
||||
/// Holds a (possibly empty) `Vec<Arc<dyn SessionInspector>>`: the agent's
|
||||
/// profile is routed to the first inspector that supports it. The use case still
|
||||
/// needs the context store (resolve the agent) and the profile store (resolve
|
||||
/// the profile), exactly like [`super::lifecycle::LaunchAgent`].
|
||||
pub struct InspectConversation {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
inspectors: Vec<Arc<dyn SessionInspector>>,
|
||||
}
|
||||
|
||||
impl InspectConversation {
|
||||
/// Builds the use case from its injected ports and the inspector list (which
|
||||
/// may be empty: that path simply yields empty details).
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
inspectors: Vec<Arc<dyn SessionInspector>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
contexts,
|
||||
profiles,
|
||||
inspectors,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the agent + profile + run dir, then asks the first supporting
|
||||
/// inspector for details. Returns **empty** details when no inspector
|
||||
/// matches or inspection misses (`NotFound`/`Read`); only genuine store
|
||||
/// failures (loading the manifest / profiles) surface as an error.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the agent or its profile is unknown,
|
||||
/// - [`AppError::Invalid`] if a persisted manifest entry / run dir is invalid,
|
||||
/// - [`AppError::Store`] on a manifest / profile store failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: InspectConversationInput,
|
||||
) -> Result<InspectConversationOutput, AppError> {
|
||||
// Resolve the agent from the manifest (for its profile + run dir).
|
||||
let manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
let entry = manifest
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.agent_id == input.agent_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||
let agent = entry
|
||||
.to_agent()
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
|
||||
let profile = self
|
||||
.profiles
|
||||
.list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|p| p.id == agent.profile_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("profile {} for agent", agent.profile_id)))?;
|
||||
|
||||
// The CLI runs with its isolated run dir as cwd; the inspector keys its
|
||||
// transcript lookup off that same cwd (same value LaunchAgent uses).
|
||||
let run_dir = agent_run_dir(&input.project.root, &agent.id)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
|
||||
// Route to the first inspector that supports this profile. No match ⇒
|
||||
// empty details (best-effort).
|
||||
let Some(inspector) = self.inspectors.iter().find(|i| i.supports(&profile)) else {
|
||||
return Ok(InspectConversationOutput {
|
||||
details: empty_details(),
|
||||
});
|
||||
};
|
||||
|
||||
// Any inspection miss (NotFound / Read) degrades to empty details — it
|
||||
// must never block a resume.
|
||||
let details = match inspector
|
||||
.details(&profile, &input.conversation_id, &run_dir)
|
||||
.await
|
||||
{
|
||||
Ok(details) => details,
|
||||
Err(InspectError::NotFound | InspectError::Read(_)) => empty_details(),
|
||||
};
|
||||
|
||||
Ok(InspectConversationOutput { details })
|
||||
}
|
||||
}
|
||||
|
||||
/// The empty, fully-degraded [`ConversationDetails`] (no topic, no tokens).
|
||||
fn empty_details() -> ConversationDetails {
|
||||
ConversationDetails {
|
||||
last_topic: None,
|
||||
token_count: None,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -7,17 +7,29 @@
|
||||
//! ports. Launching an agent (PTY + injection) is L6.
|
||||
|
||||
mod catalogue;
|
||||
mod inspect;
|
||||
mod lifecycle;
|
||||
mod resume;
|
||||
mod structured;
|
||||
mod usecases;
|
||||
|
||||
pub(crate) use lifecycle::unique_md_path;
|
||||
pub(crate) use lifecycle::ReattachDecision;
|
||||
|
||||
pub use catalogue::{reference_profile_id, reference_profiles};
|
||||
pub use structured::{drain_with_readiness, send_blocking};
|
||||
|
||||
pub use catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
|
||||
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
|
||||
pub use lifecycle::{
|
||||
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, UpdateAgentContext,
|
||||
UpdateAgentContextInput,
|
||||
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||
ListAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
|
||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
pub use resume::{
|
||||
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
||||
};
|
||||
pub use usecases::{
|
||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,
|
||||
|
||||
193
crates/application/src/agent/resume.rs
Normal file
193
crates/application/src/agent/resume.rs
Normal file
@ -0,0 +1,193 @@
|
||||
//! [`ListResumableAgents`] — inventaire, en lecture seule, des cellules d'agent
|
||||
//! reprenables à la réouverture d'un projet (ARCHITECTURE §15.2, chantier B,
|
||||
//! lot B1).
|
||||
//!
|
||||
//! À la réouverture d'un projet, [`crate::OpenProject`] a déjà rechargé le
|
||||
//! manifeste et les layouts persistés : chaque [`domain::LeafCell`] retrouve
|
||||
//! donc son `conversation_id` et son `agent_was_running` gelés à la fermeture.
|
||||
//! Ce use case **calcule l'inventaire** des cellules d'agent reprenables pour
|
||||
//! que la couche supérieure (commande Tauri + `ResumeProjectPanel`, lot B2)
|
||||
//! puisse proposer un panneau de reprise groupé.
|
||||
//!
|
||||
//! Contraintes (§15.2) :
|
||||
//! - **Lecture seule** : aucun PTY, aucun spawn, aucune persistance. On
|
||||
//! compose `resolve_doc` (lecture des layouts), le manifeste (nom +
|
||||
//! `profile_id`) et la liste des profils (`resume_supported`).
|
||||
//! - **Best-effort, jamais d'erreur** : projet / mémoire / agent absent ⇒
|
||||
//! **liste vide**, jamais de panique ni d'`AppError`. C'est un inventaire
|
||||
//! indicatif à l'ouverture, pas une opération critique.
|
||||
//! - **Filtre** : on ne retient qu'une leaf d'agent dont `agent_was_running`
|
||||
//! est vrai **ou** qui porte un `conversation_id`. Une cellule d'agent jamais
|
||||
//! lancée (ni id, ni flag) n'apparaît pas : elle se lancera normalement au
|
||||
//! clic, sans popup.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{AgentContextStore, FileSystem, ProfileStore, ProjectStore};
|
||||
use domain::{AgentId, NodeId, Project};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::layout::resolve_doc;
|
||||
|
||||
/// Input de [`ListResumableAgents::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListResumableAgentsInput {
|
||||
/// Le projet dont on inventorie les cellules reprenables.
|
||||
pub project: Project,
|
||||
}
|
||||
|
||||
/// Une cellule d'agent reprenable, telle qu'exposée à la couche supérieure.
|
||||
///
|
||||
/// Champs alignés sur la spec §15.2 : l'identité de l'agent et de sa cellule
|
||||
/// hôte, l'id de conversation à reprendre (`None` ⇒ relance à neuf), l'état
|
||||
/// « tournait » gelé à la fermeture, et si son profil sait reprendre une
|
||||
/// conversation CLI.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResumableAgent {
|
||||
/// Identifiant de l'agent.
|
||||
pub agent_id: AgentId,
|
||||
/// Nom d'affichage de l'agent (résolu via le manifeste).
|
||||
pub name: String,
|
||||
/// Cellule hôte où relancer/reprendre l'agent.
|
||||
pub node_id: NodeId,
|
||||
/// Id de conversation CLI persistant porté par la cellule. `None` ⇒ relance
|
||||
/// à neuf (pas d'historique à reprendre).
|
||||
pub conversation_id: Option<String>,
|
||||
/// Valeur de `agent_was_running` gelée à la fermeture de la cellule.
|
||||
pub was_running: bool,
|
||||
/// `true` si le profil de l'agent possède une [`domain::SessionStrategy`]
|
||||
/// exploitable (présence d'un `resume_flag`).
|
||||
pub resume_supported: bool,
|
||||
}
|
||||
|
||||
/// Output de [`ListResumableAgents::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct ListResumableAgentsOutput {
|
||||
/// Les cellules d'agent reprenables, dans l'ordre de parcours des layouts.
|
||||
pub resumable: Vec<ResumableAgent>,
|
||||
}
|
||||
|
||||
/// Inventorie, en lecture seule, les cellules d'agent reprenables d'un projet.
|
||||
///
|
||||
/// Compose trois ports déjà injectés au composition root, **sans** en ajouter
|
||||
/// de nouveau :
|
||||
/// - [`ProjectStore`] + [`FileSystem`] : charger les layouts persistés
|
||||
/// (`resolve_doc`),
|
||||
/// - [`AgentContextStore`] : le manifeste, pour le nom et le `profile_id` de
|
||||
/// chaque agent,
|
||||
/// - [`ProfileStore`] : pour déterminer `resume_supported`.
|
||||
pub struct ListResumableAgents {
|
||||
#[allow(dead_code)]
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
}
|
||||
|
||||
impl ListResumableAgents {
|
||||
/// Construit le use case à partir de ses ports injectés.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
profiles: Arc<dyn ProfileStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
fs,
|
||||
contexts,
|
||||
profiles,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calcule l'inventaire des cellules reprenables.
|
||||
///
|
||||
/// Parcourt chaque layout du projet et, pour chaque leaf portant un agent,
|
||||
/// lit `conversation_id`/`agent_was_running` (via l'accessor pur
|
||||
/// [`domain::LayoutTree::leaf`]). Ne retient que les leaves passant le
|
||||
/// filtre `was_running || conversation_id.is_some()`, résout le nom via le
|
||||
/// manifeste et `resume_supported` via le profil.
|
||||
///
|
||||
/// **Best-effort** : toute défaillance de lecture (projet/mémoire absent,
|
||||
/// layouts illisibles, manifeste/profils en erreur) dégrade vers une
|
||||
/// **liste vide** ; cette fonction ne renvoie donc jamais d'erreur, mais sa
|
||||
/// signature reste `Result` pour rester homogène avec les autres use cases.
|
||||
///
|
||||
/// # Errors
|
||||
/// N'échoue jamais en pratique (best-effort) ; la signature `Result` est
|
||||
/// conservée par cohérence.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ListResumableAgentsInput,
|
||||
) -> Result<ListResumableAgentsOutput, AppError> {
|
||||
// Layouts persistés. Toute erreur ⇒ inventaire vide (best-effort).
|
||||
let Ok(doc) = resolve_doc(self.fs.as_ref(), &input.project).await else {
|
||||
return Ok(ListResumableAgentsOutput::default());
|
||||
};
|
||||
|
||||
// Manifeste (nom + profile_id). Absent/illisible ⇒ inventaire vide :
|
||||
// sans manifeste on ne peut résoudre ni nom ni profil.
|
||||
let Ok(manifest) = self.contexts.load_manifest(&input.project).await else {
|
||||
return Ok(ListResumableAgentsOutput::default());
|
||||
};
|
||||
|
||||
// Profils disponibles, pour `resume_supported`. Indisponibles ⇒ on
|
||||
// considère qu'aucun profil ne sait reprendre (best-effort), sans
|
||||
// pour autant masquer les agents reprenables par `conversation_id`.
|
||||
let profiles = self.profiles.list().await.unwrap_or_default();
|
||||
|
||||
let mut resumable = Vec::new();
|
||||
|
||||
for named in &doc.layouts {
|
||||
for (node_id, agent_id) in named.tree.agent_leaves() {
|
||||
// Lecture pure de la cellule hôte pour ses champs de reprise.
|
||||
let Some(leaf) = named.tree.leaf(node_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let conversation_id = leaf.conversation_id.clone();
|
||||
let was_running = leaf.agent_was_running;
|
||||
|
||||
// Filtre §15.2 : reprenable au sens strict uniquement.
|
||||
if !was_running && conversation_id.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nom via le manifeste ; agent absent ⇒ on ignore la leaf
|
||||
// (best-effort : pas d'entrée orpheline dans l'inventaire).
|
||||
let Some(entry) = manifest.entries.iter().find(|e| e.agent_id == agent_id) else {
|
||||
continue;
|
||||
};
|
||||
let name = entry.name.clone();
|
||||
|
||||
// `resume_supported` : le profil de l'agent sait-il reprendre une
|
||||
// conversation ? Vrai si :
|
||||
// - il porte une `SessionStrategy` (TUI/PTY avec `resume_flag`,
|
||||
// sémantique §15), **ou**
|
||||
// - il est **structuré** (`structured_adapter`, §17) : l'adapter
|
||||
// Claude (`--resume`) / Codex (`exec resume`) passe le flag de
|
||||
// reprise du moteur via `SessionPlan::Resume`, donc la reprise est
|
||||
// intrinsèquement supportée.
|
||||
let resume_supported = entry
|
||||
.to_agent()
|
||||
.ok()
|
||||
.and_then(|agent| profiles.iter().find(|p| p.id == agent.profile_id))
|
||||
.is_some_and(|profile| {
|
||||
profile.session.is_some() || profile.structured_adapter.is_some()
|
||||
});
|
||||
|
||||
resumable.push(ResumableAgent {
|
||||
agent_id,
|
||||
name,
|
||||
node_id,
|
||||
conversation_id,
|
||||
was_running,
|
||||
resume_supported,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ListResumableAgentsOutput { resumable })
|
||||
}
|
||||
}
|
||||
251
crates/application/src/agent/structured.rs
Normal file
251
crates/application/src/agent/structured.rs
Normal file
@ -0,0 +1,251 @@
|
||||
//! Helper applicatif `send_blocking` — le rendez-vous synchrone inter-agents
|
||||
//! au-dessus du port [`AgentSession`] (ARCHITECTURE §17.1 / §17.4).
|
||||
//!
|
||||
//! `AgentSession::send` retourne un **flux** d'événements (`ReplyStream`), à la
|
||||
//! manière de `PtyPort::subscribe_output`, mais **typé** : deltas de texte →
|
||||
//! activités d'outil → **un** événement terminal déterministe
|
||||
//! [`ReplyEvent::Final`]. Le rendez-vous synchrone dont l'orchestrateur a besoin
|
||||
//! (§17.4) s'obtient en **drainant ce flux jusqu'au `Final`** : c'est *la* primitive
|
||||
//! de la messagerie inter-agents, **sans outbox, sans corrélation fichier** (le
|
||||
//! `Final` *est* la fin de tour).
|
||||
//!
|
||||
//! DRY : un seul chemin de lecture (le flux). `send_blocking` n'est qu'un *consom-
|
||||
//! mateur* du même flux que la cellule chat utilise pour le rendu incrémental.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use domain::input::InputMediator;
|
||||
use domain::ids::AgentId;
|
||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
|
||||
use domain::readiness::{ReadinessPolicy, ReadinessSignal};
|
||||
|
||||
/// Envoie `prompt` à la session vivante puis **draine le flux de réponse jusqu'au
|
||||
/// [`ReplyEvent::Final`]**, et retourne son contenu agrégé.
|
||||
///
|
||||
/// C'est le rendez-vous synchrone (§17.4) : on attend que le tour soit
|
||||
/// déterministiquement terminé (`Final`) avant de rendre la main. Les deltas de
|
||||
/// texte et les activités d'outil traversés en chemin sont **ignorés** ici (ils
|
||||
/// servent le rendu incrémental côté UI, pas l'appelant synchrone).
|
||||
///
|
||||
/// `timeout`, lorsqu'il est fourni, borne l'attente : si aucun `Final` n'est observé
|
||||
/// dans le délai, on retourne [`AgentSessionError::Timeout`] **sans tuer la
|
||||
/// session** (elle reste vivante dans le registre ; l'appelant décide de la suite).
|
||||
/// `None` ⇒ pas de borne temporelle (on attend la fin du tour).
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AgentSessionError::Io`]/[`AgentSessionError::Decode`] remontées par `send`
|
||||
/// (échec de communication / décodage de la sortie structurée) ;
|
||||
/// - [`AgentSessionError::Io`] si le flux se termine **sans** `Final` (tour
|
||||
/// interrompu) ;
|
||||
/// - [`AgentSessionError::Timeout`] si `timeout` expire avant le `Final`.
|
||||
pub async fn send_blocking(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<String, AgentSessionError> {
|
||||
drain_bounded_events(session, prompt, timeout, |_event| {}, |_signal| {}).await
|
||||
}
|
||||
|
||||
/// Comme [`send_blocking`], mais **branche la readiness** : à chaque événement du
|
||||
/// tour, [`ReadinessPolicy::classify`] est consulté et, dès qu'il renvoie
|
||||
/// [`ReadinessSignal::TurnEnded`] (le `Final`), le médiateur d'entrée est notifié
|
||||
/// (`mark_idle(agent)`) pour que la FIFO de l'agent avance — **sans** dépendre d'un
|
||||
/// `idea_reply` explicite ni du sniff de prompt PTY (chantier readiness/heartbeat,
|
||||
/// lot 1, fix de la cause racine du blocage `Busy`).
|
||||
///
|
||||
/// DRY : **un seul** chemin de lecture du flux (la boucle de [`drain_bounded`]) ;
|
||||
/// cette fonction n'est que `send_blocking` muni d'un *sink* de readiness. Le `Final`
|
||||
/// réveille donc à la fois le `pending` (via la valeur de retour) **et** la FIFO (via
|
||||
/// `mark_idle`). `idea_reply` reste un signal alternatif (premier arrivé gagne) côté
|
||||
/// orchestrateur.
|
||||
///
|
||||
/// # Errors
|
||||
/// Identiques à [`send_blocking`] (échec `send`/décodage, flux clos sans `Final`,
|
||||
/// timeout).
|
||||
pub async fn drain_with_readiness(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
timeout: Option<Duration>,
|
||||
mediator: &dyn InputMediator,
|
||||
agent: AgentId,
|
||||
) -> Result<String, AgentSessionError> {
|
||||
// `on_signal` ne reçoit QUE les événements terminaux (le `Final` ⇒ `TurnEnded`) :
|
||||
// la readiness ne classe pas les non-terminaux. Pour le **battement** de vivacité
|
||||
// (lot 2) on a besoin de notifier le médiateur à CHAQUE événement non terminal
|
||||
// (delta / activité / heartbeat) ⇒ on passe un sink d'événement bruts `on_event`.
|
||||
drain_bounded_events(
|
||||
session,
|
||||
prompt,
|
||||
timeout,
|
||||
|event| {
|
||||
// Tout événement **non terminal** prouve la vivacité ⇒ un battement.
|
||||
if !matches!(event, ReplyEvent::Final { .. }) {
|
||||
mediator.mark_alive(agent);
|
||||
}
|
||||
},
|
||||
|signal| {
|
||||
if signal == ReadinessSignal::TurnEnded {
|
||||
mediator.mark_idle(agent);
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Ouvre le flux du tour (`send`) et le **draine jusqu'au `Final`**, en appliquant
|
||||
/// la borne temporelle `timeout`, en notifiant `on_event` à **chaque** événement brut
|
||||
/// (pour le battement de vivacité, lot 2) et `on_signal` à chaque [`ReadinessSignal`]
|
||||
/// dérivé par [`ReadinessPolicy`] (le `Final` ⇒ `TurnEnded`).
|
||||
///
|
||||
/// **Chemin de lecture unique** (DRY) : `send_blocking` et `drain_with_readiness`
|
||||
/// passent tous deux par ici, en différant seulement par leurs *sinks*. La session
|
||||
/// **reste vivante** sur timeout (on ne `shutdown` rien ici, §17.1).
|
||||
async fn drain_bounded_events(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
timeout: Option<Duration>,
|
||||
on_event: impl FnMut(&ReplyEvent),
|
||||
on_signal: impl FnMut(ReadinessSignal),
|
||||
) -> Result<String, AgentSessionError> {
|
||||
match timeout {
|
||||
Some(dur) => match tokio::time::timeout(
|
||||
dur,
|
||||
drain_to_final(session, prompt, on_event, on_signal),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
// La session **reste vivante** : on ne `shutdown` rien ici (§17.1).
|
||||
Err(_elapsed) => Err(AgentSessionError::Timeout),
|
||||
},
|
||||
None => drain_to_final(session, prompt, on_event, on_signal).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ouvre le flux du tour (`send`) et le **draine jusqu'au `Final`**.
|
||||
///
|
||||
/// Le flux ([`domain::ports::ReplyStream`]) est un itérateur synchrone et borné :
|
||||
/// après le `Final` il ne produit plus rien. On le parcourt donc simplement
|
||||
/// jusqu'à rencontrer le `Final` (et on retourne son contenu) ; si le flux
|
||||
/// s'épuise avant, c'est un tour interrompu → erreur [`AgentSessionError::Io`].
|
||||
///
|
||||
/// Chaque événement est classé par [`ReadinessPolicy`] et le signal éventuel est
|
||||
/// remonté à `on_signal` (le `Final` ⇒ [`ReadinessSignal::TurnEnded`]). Deltas,
|
||||
/// activités et heartbeats sont non terminaux ⇒ ignorés par le rendez-vous synchrone.
|
||||
async fn drain_to_final(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
mut on_event: impl FnMut(&ReplyEvent),
|
||||
mut on_signal: impl FnMut(ReadinessSignal),
|
||||
) -> Result<String, AgentSessionError> {
|
||||
let stream = session.send(prompt).await?;
|
||||
for event in stream {
|
||||
// Battement de vivacité (lot 2) : notifié pour CHAQUE événement brut, avant le
|
||||
// classement readiness. Le sink décide (les non-terminaux prouvent la vivacité).
|
||||
on_event(&event);
|
||||
if let Some(signal) = ReadinessPolicy::classify(&event) {
|
||||
on_signal(signal);
|
||||
}
|
||||
if let ReplyEvent::Final { content } = event {
|
||||
return Ok(content);
|
||||
}
|
||||
// TextDelta / ToolActivity / Heartbeat : non terminaux, ignorés ici.
|
||||
}
|
||||
Err(AgentSessionError::Io(
|
||||
"le flux de réponse s'est terminé sans événement Final".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use domain::ids::SessionId;
|
||||
use domain::input::{AgentBusyState, SubmitConfig};
|
||||
use domain::mailbox::{PendingReply, Ticket};
|
||||
use domain::ports::PtyHandle;
|
||||
|
||||
fn agent(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
/// Session factice : `send` rejoue une liste fixe d'événements (terminée par un
|
||||
/// `Final`).
|
||||
struct FakeSession {
|
||||
events: Vec<ReplyEvent>,
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl AgentSession for FakeSession {
|
||||
fn id(&self) -> SessionId {
|
||||
SessionId::from_uuid(uuid::Uuid::from_u128(1))
|
||||
}
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
async fn send(
|
||||
&self,
|
||||
_prompt: &str,
|
||||
) -> Result<domain::ports::ReplyStream, AgentSessionError> {
|
||||
Ok(Box::new(self.events.clone().into_iter()))
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Médiateur factice qui enregistre l'ordre des `mark_alive` / `mark_idle`.
|
||||
#[derive(Default)]
|
||||
struct RecordingMediator {
|
||||
calls: Mutex<Vec<&'static str>>,
|
||||
}
|
||||
impl InputMediator for RecordingMediator {
|
||||
fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply {
|
||||
unreachable!("non utilisé par drain_with_readiness")
|
||||
}
|
||||
fn preempt(&self, _agent: AgentId) {}
|
||||
fn mark_idle(&self, _agent: AgentId) {
|
||||
self.calls.lock().unwrap().push("idle");
|
||||
}
|
||||
fn mark_alive(&self, _agent: AgentId) {
|
||||
self.calls.lock().unwrap().push("alive");
|
||||
}
|
||||
fn busy_state(&self, _agent: AgentId) -> AgentBusyState {
|
||||
AgentBusyState::Idle
|
||||
}
|
||||
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
|
||||
fn bind_handle_with_prompt(
|
||||
&self,
|
||||
_agent: AgentId,
|
||||
_handle: PtyHandle,
|
||||
_pattern: Option<String>,
|
||||
_submit: SubmitConfig,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drain_marks_alive_on_each_non_terminal_then_idle_on_final() {
|
||||
let session = FakeSession {
|
||||
events: vec![
|
||||
ReplyEvent::TextDelta { text: "a".into() },
|
||||
ReplyEvent::ToolActivity { label: "lit".into() },
|
||||
ReplyEvent::Heartbeat,
|
||||
ReplyEvent::Final {
|
||||
content: "fini".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let out = drain_with_readiness(&session, "go", None, &mediator, agent(1))
|
||||
.await
|
||||
.expect("drain ok");
|
||||
assert_eq!(out, "fini");
|
||||
// Trois battements (delta, activité, heartbeat) PUIS l'idle sur le Final.
|
||||
assert_eq!(
|
||||
*mediator.calls.lock().unwrap(),
|
||||
vec!["alive", "alive", "alive", "idle"],
|
||||
"un battement par événement non terminal, idle au Final (pas de battement sur le Final)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -18,7 +18,7 @@ use domain::profile::AgentProfile;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::catalogue::reference_profiles;
|
||||
use super::catalogue::selectable_reference_profiles;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DetectProfiles
|
||||
@ -256,11 +256,15 @@ impl ConfigureProfiles {
|
||||
/// Output of [`ReferenceProfiles::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReferenceProfilesOutput {
|
||||
/// The pre-filled, editable reference catalogue.
|
||||
/// The pre-filled, editable reference catalogue, **restricted to the
|
||||
/// selectable profiles** (§17.3, D7): only profiles drivable in structured
|
||||
/// mode are offered to selection/creation. Today: Claude + Codex.
|
||||
pub profiles: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
/// Exposes the pre-filled reference catalogue (Claude/Codex/Gemini/Aider).
|
||||
/// Exposes the **selectable** reference catalogue for the agent-creation menu
|
||||
/// (§17.3, D7): the structured-drivable profiles only (Claude/Codex). Gemini and
|
||||
/// Aider remain in the raw catalogue data but are not proposed here.
|
||||
#[derive(Default)]
|
||||
pub struct ReferenceProfiles;
|
||||
|
||||
@ -278,7 +282,7 @@ impl ReferenceProfiles {
|
||||
#[allow(clippy::unused_async)]
|
||||
pub async fn execute(&self) -> Result<ReferenceProfilesOutput, AppError> {
|
||||
Ok(ReferenceProfilesOutput {
|
||||
profiles: reference_profiles(),
|
||||
profiles: selectable_reference_profiles(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -292,7 +296,9 @@ impl ReferenceProfiles {
|
||||
pub struct FirstRunStateOutput {
|
||||
/// `true` when no `profiles.json` exists yet ⇒ show the first-run wizard.
|
||||
pub is_first_run: bool,
|
||||
/// The pre-filled reference catalogue to seed the wizard.
|
||||
/// The pre-filled reference catalogue to seed the wizard, **restricted to the
|
||||
/// selectable profiles** (§17.3, D7): only structured-drivable profiles
|
||||
/// (Claude/Codex) are offered. No custom-profile entry.
|
||||
pub reference_profiles: Vec<AgentProfile>,
|
||||
}
|
||||
|
||||
@ -317,7 +323,7 @@ impl FirstRunState {
|
||||
let configured = self.store.is_configured().await?;
|
||||
Ok(FirstRunStateOutput {
|
||||
is_first_run: !configured,
|
||||
reference_profiles: reference_profiles(),
|
||||
reference_profiles: selectable_reference_profiles(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
18
crates/application/src/conversation/mod.rs
Normal file
18
crates/application/src/conversation/mod.rs
Normal file
@ -0,0 +1,18 @@
|
||||
//! Conversation persistence use cases (cadrage « persistance conversationnelle »,
|
||||
//! ARCHITECTURE §19, lot P6).
|
||||
//!
|
||||
//! Where [`crate::agent::lifecycle`] owns the *runtime* of an agent (spawn, PTY,
|
||||
//! sessions), this module owns the **durable conversational memory** of a
|
||||
//! conversation (paire) : the canonical append-only log and its incremental
|
||||
//! handoff. The single use case here, [`RecordTurn`], materialises the
|
||||
//! **end-of-turn checkpoint** (D19-5) by talking **only** to the three driven/driving
|
||||
//! ports of §19 ([`domain::ConversationLog`], [`domain::HandoffStore`],
|
||||
//! [`domain::HandoffSummarizer`]) — never to a concrete adapter.
|
||||
//!
|
||||
//! This is the **isolated logic** of P6 (lot P6a) : no wiring to the orchestrator,
|
||||
//! the PTY or `app-tauri` (that is P6b). It is fully testable with in-memory fakes
|
||||
//! of the three ports.
|
||||
|
||||
mod record;
|
||||
|
||||
pub use record::RecordTurn;
|
||||
100
crates/application/src/conversation/record.rs
Normal file
100
crates/application/src/conversation/record.rs
Normal file
@ -0,0 +1,100 @@
|
||||
//! [`RecordTurn`] — the end-of-turn checkpoint use case (ARCHITECTURE §19, lot P6a).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{ConversationId, ConversationLog, ConversationTurn, HandoffStore, HandoffSummarizer};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Records one conversation turn at a checkpoint: appends it to the canonical
|
||||
/// log, then advances the incremental handoff (ARCHITECTURE §19, décision D19-5).
|
||||
///
|
||||
/// **Single Responsibility**: materialise *one* end-of-turn checkpoint. It is the
|
||||
/// only place that couples the append-only log (source of truth, [`ConversationLog`])
|
||||
/// with the cumulative resume summary ([`HandoffStore`]) via the incremental
|
||||
/// [`HandoffSummarizer`]. It consumes **exactly** those three ports (Interface
|
||||
/// Segregation), injected as `Arc<dyn …>` at the composition root.
|
||||
///
|
||||
/// ## Incremental by contract (no full re-read)
|
||||
///
|
||||
/// Each call folds **only the new turn** into the previous handoff
|
||||
/// (`fold(prev, &[turn])`), never re-reading the whole thread — exactly the seam
|
||||
/// [`HandoffSummarizer`] was designed for (§19, lot P4). The resulting handoff's
|
||||
/// [`Handoff::up_to`](domain::Handoff::up_to) is the recorded turn's id (guaranteed
|
||||
/// by the summarizer, P4).
|
||||
///
|
||||
/// ## Best-effort fold, honest `Result`
|
||||
///
|
||||
/// The fold itself cannot fail (it returns no `Result`, P4 — a future LLM
|
||||
/// summarizer falls back rather than erroring), so the only failures are the
|
||||
/// store operations (`append` / `load` / `save`), which propagate as [`AppError`].
|
||||
/// This use case stays **honest** and surfaces them; the live wiring (P6b) decides
|
||||
/// whether to swallow a handoff failure rather than block the turn.
|
||||
///
|
||||
/// ## No debounce here (deferred)
|
||||
///
|
||||
/// One `append` + one `save` per recorded turn. The debounce optimisation (coalesce
|
||||
/// several rapid turns into a single handoff recompute) is intentionally **deferred**
|
||||
/// — it belongs to the live wiring (P6b), not to this isolated logic.
|
||||
pub struct RecordTurn {
|
||||
log: Arc<dyn ConversationLog>,
|
||||
handoffs: Arc<dyn HandoffStore>,
|
||||
summarizer: Arc<dyn HandoffSummarizer>,
|
||||
}
|
||||
|
||||
impl RecordTurn {
|
||||
/// Builds the use case from its three injected ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
log: Arc<dyn ConversationLog>,
|
||||
handoffs: Arc<dyn HandoffStore>,
|
||||
summarizer: Arc<dyn HandoffSummarizer>,
|
||||
) -> Self {
|
||||
Self {
|
||||
log,
|
||||
handoffs,
|
||||
summarizer,
|
||||
}
|
||||
}
|
||||
|
||||
/// Records `turn` in `conversation` at an end-of-turn checkpoint.
|
||||
///
|
||||
/// Steps (ordering is contractual):
|
||||
/// 1. **append** the turn to the canonical log (source of truth) ;
|
||||
/// 2. **load** the previous handoff (`None` on a first checkpoint) ;
|
||||
/// 3. **fold** `prev` with the single new turn (incremental, best-effort) ;
|
||||
/// 4. **save** the advanced handoff.
|
||||
///
|
||||
/// The append happens **first** so the durable source of truth is never behind
|
||||
/// the derived handoff. The turn is cloned for the append because the fold (step
|
||||
/// 3) borrows it as the incremental slice.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] (or [`AppError::NotFound`] for a missing store item) when
|
||||
/// the canonical log append, the handoff load, or the handoff save fails — every
|
||||
/// [`domain::ports::StoreError`] is mapped through the existing `From` impl. The
|
||||
/// fold never fails (P4).
|
||||
pub async fn record(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
turn: ConversationTurn,
|
||||
) -> Result<(), AppError> {
|
||||
// 1. Append to the canonical append-only log (source of truth, D19-1a).
|
||||
self.log.append(conversation, turn.clone()).await?;
|
||||
|
||||
// 2. Load the previous resume point (absence is never an error, P3).
|
||||
let prev = self.handoffs.load(conversation).await?;
|
||||
|
||||
// 3. Fold the previous handoff with *only* the new turn (incremental, P4):
|
||||
// no full re-read of the log; best-effort, so no `Result` to propagate.
|
||||
let handoff = self
|
||||
.summarizer
|
||||
.fold(prev, std::slice::from_ref(&turn))
|
||||
.await;
|
||||
|
||||
// 4. Persist the advanced handoff (overwrites the previous resume point).
|
||||
self.handoffs.save(conversation, handoff).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
24
crates/application/src/embedder/mod.rs
Normal file
24
crates/application/src/embedder/mod.rs
Normal 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,
|
||||
};
|
||||
269
crates/application/src/embedder/suggestion.rs
Normal file
269
crates/application/src/embedder/suggestion.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
245
crates/application/src/embedder/usecases.rs
Normal file
245
crates/application/src/embedder/usecases.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -6,8 +6,10 @@
|
||||
//! with one error shape when building its `ErrorDTO`.
|
||||
|
||||
use domain::ports::{
|
||||
FsError, GitError, ProcessError, PtyError, RemoteError, RuntimeError, StoreError,
|
||||
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError,
|
||||
RemoteError, RuntimeError, StoreError,
|
||||
};
|
||||
use domain::{AgentId, NodeId};
|
||||
|
||||
/// Errors surfaced by application use cases.
|
||||
///
|
||||
@ -43,6 +45,18 @@ pub enum AppError {
|
||||
#[error("remote error: {0}")]
|
||||
Remote(String),
|
||||
|
||||
/// An agent is already running in a live cell and cannot be launched again
|
||||
/// (the "one live session per agent" invariant). Reuse goes through
|
||||
/// templates (instantiate → distinct agents); IdeA never multi-instances a
|
||||
/// single agent.
|
||||
#[error("agent {agent_id} is already running in cell {node_id}")]
|
||||
AgentAlreadyRunning {
|
||||
/// The agent that is already live.
|
||||
agent_id: AgentId,
|
||||
/// The layout cell (leaf) currently hosting that agent's live session.
|
||||
node_id: NodeId,
|
||||
},
|
||||
|
||||
/// An unexpected internal error.
|
||||
#[error("internal error: {0}")]
|
||||
Internal(String),
|
||||
@ -61,6 +75,7 @@ impl AppError {
|
||||
Self::Process(_) => "PROCESS",
|
||||
Self::Git(_) => "GIT",
|
||||
Self::Remote(_) => "REMOTE",
|
||||
Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING",
|
||||
Self::Internal(_) => "INTERNAL",
|
||||
}
|
||||
}
|
||||
@ -84,6 +99,26 @@ impl From<StoreError> for AppError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MemoryError> for AppError {
|
||||
fn from(e: MemoryError) -> Self {
|
||||
match e {
|
||||
MemoryError::NotFound => Self::NotFound("memory note".to_owned()),
|
||||
MemoryError::Frontmatter(m) => Self::Invalid(m),
|
||||
other => Self::Store(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EmbedderError> for AppError {
|
||||
/// Maps to [`AppError::Store`] — an embedder is a *derived* recall detail; its
|
||||
/// failure must degrade the recall (fallback to naïve), never fail hard. This
|
||||
/// mapping exists for completeness; recall adapters degrade *before* an
|
||||
/// embedder error ever reaches a use case.
|
||||
fn from(e: EmbedderError) -> Self {
|
||||
Self::Store(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PtyError> for AppError {
|
||||
fn from(e: PtyError) -> Self {
|
||||
Self::Process(e.to_string())
|
||||
@ -113,3 +148,15 @@ impl From<RemoteError> for AppError {
|
||||
Self::Remote(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AgentSessionError> for AppError {
|
||||
/// Maps a structured [`AgentSessionError`] (ARCHITECTURE §17.1) onto the single
|
||||
/// application error shape. `Start`/`Io`/`Decode`/`Timeout` are all execution
|
||||
/// failures of a live structured session (a process/SDK conversation), so they
|
||||
/// fold into [`AppError::Process`] — coherent with [`PtyError`]/[`ProcessError`],
|
||||
/// the byte-stream twins. The raw CLI JSON never travels through `Decode` (the
|
||||
/// adapter already redacted it), so this is safe to surface.
|
||||
fn from(e: AgentSessionError) -> Self {
|
||||
Self::Process(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ mod usecases;
|
||||
|
||||
pub use usecases::{
|
||||
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
|
||||
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput,
|
||||
GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput,
|
||||
GitStatusOutput, GitUnstage,
|
||||
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit,
|
||||
GitInitInput, GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus,
|
||||
GitStatusInput, GitStatusOutput, GitUnstage,
|
||||
};
|
||||
|
||||
@ -47,12 +47,12 @@ pub struct HealthUseCase {
|
||||
impl HealthUseCase {
|
||||
/// Builds the use case from its injected ports.
|
||||
#[must_use]
|
||||
pub fn new(clock: Arc<dyn Clock>, ids: Arc<dyn IdGenerator>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self {
|
||||
clock,
|
||||
ids,
|
||||
events,
|
||||
}
|
||||
pub fn new(
|
||||
clock: Arc<dyn Clock>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self { clock, ids, events }
|
||||
}
|
||||
|
||||
/// Executes the health check.
|
||||
|
||||
@ -21,6 +21,8 @@
|
||||
//! `TerminalSessions` registry, keyed by that same `SessionId`.
|
||||
|
||||
mod management;
|
||||
mod reconcile;
|
||||
mod snapshot;
|
||||
mod store;
|
||||
mod usecases;
|
||||
|
||||
@ -29,6 +31,11 @@ pub use management::{
|
||||
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
|
||||
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
|
||||
};
|
||||
pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOutput};
|
||||
pub use snapshot::{
|
||||
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
|
||||
};
|
||||
pub(crate) use store::{persist_doc, resolve_doc};
|
||||
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
|
||||
pub use usecases::{
|
||||
LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout,
|
||||
|
||||
101
crates/application/src/layout/reconcile.rs
Normal file
101
crates/application/src/layout/reconcile.rs
Normal file
@ -0,0 +1,101 @@
|
||||
//! [`ReconcileLayouts`] — réconcilie, à l'**ouverture** d'un projet, les
|
||||
//! `layouts.json` qui contiennent des feuilles en **doublon** sur un même agent
|
||||
//! (lot R0c du cadrage orchestration v5, §3.4 « Trou C »).
|
||||
//!
|
||||
//! C'est la **jumelle** de [`super::snapshot::SnapshotRunningAgents`] : là où le
|
||||
//! snapshot **gèle** `agent_was_running` à la *fermeture*, cette réconciliation
|
||||
//! **dé-doublonne** à l'*ouverture*. Un `layouts.json` persisté peut déjà porter
|
||||
//! deux feuilles sur le **même** `agent` id ; à la réouverture il ne faut **pas**
|
||||
//! relancer la 2ᵉ. On garde **une** feuille « hôte » (potentiellement vivante /
|
||||
//! reprenable) et on transforme les autres en **vues mortes** : leur
|
||||
//! `agent_was_running` repasse à `false` et leur `conversation_id` est retiré.
|
||||
//! C'est précisément ce qui éliminait le symptôme « une cellule reset au retour
|
||||
//! d'onglet ».
|
||||
//!
|
||||
//! Le use case est un mince orchestrateur au-dessus de :
|
||||
//! - le store des layouts persistés ([`super::store`]),
|
||||
//! - l'opération pure du domaine
|
||||
//! [`domain::LayoutTree::reconcile_duplicate_agents`] (qui porte la **règle
|
||||
//! déterministe de choix de l'hôte** et la garantie d'idempotence).
|
||||
//!
|
||||
//! **Idempotence / no-op** : un layout sans doublon (ou déjà réconcilié) ressort
|
||||
//! **identique** de l'opération pure ; on ne persiste alors **rien** (aucune
|
||||
//! écriture). Une 2ᵉ ouverture du même projet réconcilié est donc un no-op.
|
||||
//!
|
||||
//! **Ordre d'insertion** : à appeler à l'ouverture du projet, **après** le
|
||||
//! chargement/résolution des layouts et **avant** toute reprise
|
||||
//! ([`crate::ListResumableAgents`]) — qui relit la version persistée — de sorte
|
||||
//! qu'on ne propose / ne relance qu'**une** session par agent.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{FileSystem, ProjectStore};
|
||||
use domain::ProjectId;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::store::{persist_doc, resolve_doc};
|
||||
|
||||
/// Input de [`ReconcileLayouts::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReconcileLayoutsInput {
|
||||
/// Le projet dont les layouts doivent être dé-doublonnés.
|
||||
pub project_id: ProjectId,
|
||||
}
|
||||
|
||||
/// Output de [`ReconcileLayouts::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct ReconcileLayoutsOutput {
|
||||
/// `true` si au moins un layout a été modifié (et donc le doc persisté).
|
||||
/// `false` ⇒ aucun doublon : no-op, aucune écriture (idempotence).
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
/// Réconcilie les feuilles d'agent en doublon de tous les layouts d'un projet,
|
||||
/// puis persiste le doc **uniquement** s'il a changé.
|
||||
pub struct ReconcileLayouts {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
}
|
||||
|
||||
impl ReconcileLayouts {
|
||||
/// Construit le use case à partir de ses ports injectés.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProjectStore>, fs: Arc<dyn FileSystem>) -> Self {
|
||||
Self { store, fs }
|
||||
}
|
||||
|
||||
/// Exécute la réconciliation pour un projet.
|
||||
///
|
||||
/// Pour chaque layout, applique l'opération pure
|
||||
/// [`domain::LayoutTree::reconcile_duplicate_agents`]. Si **aucun** arbre n'a
|
||||
/// changé, ne persiste **rien** (no-op idempotent). Sinon, persiste tout le
|
||||
/// doc une fois.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] si le projet est inconnu,
|
||||
/// - [`AppError::Store`] sur défaillance du registre,
|
||||
/// - [`AppError::FileSystem`] sur défaillance de persistance.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ReconcileLayoutsInput,
|
||||
) -> Result<ReconcileLayoutsOutput, AppError> {
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
|
||||
let mut changed = false;
|
||||
for named in &mut doc.layouts {
|
||||
let reconciled = named.tree.reconcile_duplicate_agents();
|
||||
if reconciled != named.tree {
|
||||
named.tree = reconciled;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
||||
}
|
||||
|
||||
Ok(ReconcileLayoutsOutput { changed })
|
||||
}
|
||||
}
|
||||
117
crates/application/src/layout/snapshot.rs
Normal file
117
crates/application/src/layout/snapshot.rs
Normal file
@ -0,0 +1,117 @@
|
||||
//! [`SnapshotRunningAgents`] — freeze, at close time, which agent cells still
|
||||
//! held a live PTY (feature: "conversation resume", task T5).
|
||||
//!
|
||||
//! When the IDE (or a single project) closes, every live PTY is about to be
|
||||
//! killed. *Before* that happens, we record on each agent-bearing leaf whether
|
||||
//! its agent process was still running (`agent_was_running = true`) or had
|
||||
//! already exited / never launched (`false`). On reopen, that flag is what tells
|
||||
//! the resume popup "this conversation was still in progress" vs "it was closed".
|
||||
//!
|
||||
//! The decision is **universal**: it is derived purely from the process
|
||||
//! lifecycle (the live-session registry), never from parsing CLI output. The use
|
||||
//! case itself is a thin orchestrator over:
|
||||
//! - the persisted layouts store ([`super::store`]),
|
||||
//! - the pure domain operation [`domain::LayoutTree::set_agent_running`],
|
||||
//! - a [`LiveAgentRegistry`] liveness query.
|
||||
//!
|
||||
//! **Ordering contract:** this snapshot reads the registry *as it is at call
|
||||
//! time*. The composition root (app-tauri) is responsible for calling it
|
||||
//! **before** the global PTY kill; if the kill ran first, every agent would look
|
||||
//! "closed". See `app-tauri`'s `CloseRequested` hook and `close.rs` callers.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{FileSystem, ProjectStore};
|
||||
use domain::ProjectId;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::terminal::LiveAgentRegistry;
|
||||
|
||||
use super::store::{persist_doc, resolve_doc};
|
||||
|
||||
/// Input for [`SnapshotRunningAgents::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SnapshotRunningAgentsInput {
|
||||
/// The project whose layouts must be frozen.
|
||||
pub project_id: ProjectId,
|
||||
}
|
||||
|
||||
/// Output of [`SnapshotRunningAgents::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct SnapshotRunningAgentsOutput {
|
||||
/// Number of agent-bearing leaves that were found running at snapshot time.
|
||||
pub running: usize,
|
||||
/// Number of agent-bearing leaves that were found stopped at snapshot time.
|
||||
pub stopped: usize,
|
||||
}
|
||||
|
||||
/// Freezes the `agent_was_running` flag on every agent leaf of a project's
|
||||
/// layouts, from the live-session registry, then persists the layouts.
|
||||
pub struct SnapshotRunningAgents {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
live: Arc<dyn LiveAgentRegistry>,
|
||||
}
|
||||
|
||||
impl SnapshotRunningAgents {
|
||||
/// Builds the use case from its injected ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
live: Arc<dyn LiveAgentRegistry>,
|
||||
) -> Self {
|
||||
Self { store, fs, live }
|
||||
}
|
||||
|
||||
/// Executes the snapshot for one project.
|
||||
///
|
||||
/// Walks every named layout of the project; for each agent-bearing leaf it
|
||||
/// applies [`domain::LayoutTree::set_agent_running`] with the agent's *current*
|
||||
/// liveness, then persists the whole layouts store. A project with no
|
||||
/// agent leaf is a no-op (the doc is still resolved/healed, never written if
|
||||
/// unchanged).
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the project is unknown,
|
||||
/// - [`AppError::FileSystem`] on persistence failure,
|
||||
/// - [`AppError::Store`] on registry I/O failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: SnapshotRunningAgentsInput,
|
||||
) -> Result<SnapshotRunningAgentsOutput, AppError> {
|
||||
let project = self.store.load_project(input.project_id).await?;
|
||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||
|
||||
let mut out = SnapshotRunningAgentsOutput::default();
|
||||
let mut changed = false;
|
||||
|
||||
for named in &mut doc.layouts {
|
||||
for (leaf_id, _agent_id) in named.tree.agent_leaves() {
|
||||
// Liveness is keyed on the hosting *node*, not the agent: with the
|
||||
// one-live-session-per-agent invariant, an agent pinned on several
|
||||
// leaves is live in at most one cell, so only that cell must be
|
||||
// marked running (a duplicate leaf for the same agent stays false).
|
||||
let running = self.live.is_node_live(&leaf_id);
|
||||
if running {
|
||||
out.running += 1;
|
||||
} else {
|
||||
out.stopped += 1;
|
||||
}
|
||||
// Pure op: only NodeNotFound is possible, which cannot happen
|
||||
// since `leaf_id` came from this very tree.
|
||||
named.tree = named
|
||||
.tree
|
||||
.set_agent_running(leaf_id, running)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@ -99,11 +99,7 @@ impl LayoutsDoc {
|
||||
/// The default single-cell layout tree (one empty leaf).
|
||||
#[must_use]
|
||||
pub fn default_tree() -> LayoutTree {
|
||||
LayoutTree::single(LeafCell {
|
||||
id: NodeId::new_random(),
|
||||
session: None,
|
||||
agent: None,
|
||||
})
|
||||
LayoutTree::single(LeafCell::new(NodeId::new_random()))
|
||||
}
|
||||
|
||||
/// Builds a fresh doc holding one layout (`tree`) made active.
|
||||
@ -149,7 +145,8 @@ pub async fn persist_doc(
|
||||
) -> Result<(), AppError> {
|
||||
let ideai_dir = RemotePath::new(join_root(&project.root, IDEAI_DIR));
|
||||
fs.create_dir_all(&ideai_dir).await?;
|
||||
fs.write(&layouts_path(project), &to_json_bytes(doc)?).await?;
|
||||
fs.write(&layouts_path(project), &to_json_bytes(doc)?)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@ -8,7 +8,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventBus, FileSystem, ProjectStore};
|
||||
use domain::{AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId, ProjectId, SessionId};
|
||||
use domain::{
|
||||
AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId,
|
||||
ProjectId, SessionId,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@ -69,6 +72,14 @@ pub enum LayoutOperation {
|
||||
/// Session to host, or `None` to clear.
|
||||
session: Option<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).
|
||||
SetCellAgent {
|
||||
/// The hosting leaf.
|
||||
@ -76,6 +87,17 @@ pub enum LayoutOperation {
|
||||
/// Agent to associate, or `None` to clear.
|
||||
agent: Option<AgentId>,
|
||||
},
|
||||
/// Record (or clear) the persistent CLI conversation id on a leaf (T4b).
|
||||
///
|
||||
/// Persisting the id minted at first launch is what makes session resume
|
||||
/// effective: on the next open, the leaf carries the id and the launch
|
||||
/// resumes the conversation instead of assigning a new one.
|
||||
SetCellConversation {
|
||||
/// The hosting leaf.
|
||||
target: NodeId,
|
||||
/// Conversation id to record, or `None` to clear.
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LayoutOperation {
|
||||
@ -87,16 +109,7 @@ impl LayoutOperation {
|
||||
direction,
|
||||
new_leaf,
|
||||
container,
|
||||
} => tree.split(
|
||||
*target,
|
||||
*direction,
|
||||
LeafCell {
|
||||
id: *new_leaf,
|
||||
session: None,
|
||||
agent: None,
|
||||
},
|
||||
*container,
|
||||
),
|
||||
} => tree.split(*target, *direction, LeafCell::new(*new_leaf), *container),
|
||||
Self::Merge {
|
||||
container,
|
||||
keep_index,
|
||||
@ -104,7 +117,12 @@ impl LayoutOperation {
|
||||
Self::Resize { container, weights } => tree.resize(*container, weights),
|
||||
Self::Move { from, to } => tree.move_session(*from, *to),
|
||||
Self::SetSession { target, session } => tree.set_session(*target, *session),
|
||||
Self::AttachSession { target, session } => tree.attach_session(*target, *session),
|
||||
Self::SetCellAgent { target, agent } => tree.set_cell_agent(*target, *agent),
|
||||
Self::SetCellConversation {
|
||||
target,
|
||||
conversation_id,
|
||||
} => tree.set_cell_conversation(*target, conversation_id.clone()),
|
||||
};
|
||||
result.map_err(map_layout_err)
|
||||
}
|
||||
|
||||
@ -12,46 +12,92 @@
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod agent;
|
||||
pub mod conversation;
|
||||
pub mod embedder;
|
||||
pub mod error;
|
||||
pub mod git;
|
||||
pub mod health;
|
||||
pub mod layout;
|
||||
pub mod memory;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod project;
|
||||
pub mod remote;
|
||||
pub mod skill;
|
||||
pub mod template;
|
||||
pub mod terminal;
|
||||
pub mod window;
|
||||
|
||||
pub use agent::{
|
||||
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, LaunchAgent,
|
||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||
ListProfiles, ListProfilesOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput,
|
||||
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, SaveProfile,
|
||||
SaveProfileInput, SaveProfileOutput, UpdateAgentContext, UpdateAgentContextInput,
|
||||
drain_with_readiness, reference_profile_id, reference_profiles, selectable_reference_profiles,
|
||||
send_blocking, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
|
||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch,
|
||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile,
|
||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
|
||||
FirstRunStateOutput, HandoffProvider, InspectConversation, InspectConversationInput,
|
||||
InspectConversationOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents,
|
||||
ListAgentsInput, ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
|
||||
ListResumableAgentsInput, ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry,
|
||||
ProfileAvailability,
|
||||
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
||||
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
|
||||
SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
pub use conversation::RecordTurn;
|
||||
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 git::{
|
||||
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
|
||||
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput,
|
||||
GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput,
|
||||
GitStatusOutput, GitUnstage,
|
||||
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit,
|
||||
GitInitInput, GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus,
|
||||
GitStatusInput, GitStatusOutput, GitUnstage,
|
||||
};
|
||||
pub use health::{HealthInput, HealthReport, HealthUseCase};
|
||||
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
|
||||
pub use layout::{
|
||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
|
||||
ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput,
|
||||
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, RenameLayout,
|
||||
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, LAYOUTS_FILE,
|
||||
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, ReconcileLayouts,
|
||||
ReconcileLayoutsInput, ReconcileLayoutsOutput, RenameLayout, RenameLayoutInput,
|
||||
SetActiveLayout, SetActiveLayoutInput, SnapshotRunningAgents, SnapshotRunningAgentsInput,
|
||||
SnapshotRunningAgentsOutput, LAYOUTS_FILE,
|
||||
};
|
||||
pub use memory::{
|
||||
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
|
||||
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput,
|
||||
ListMemoriesOutput, ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory,
|
||||
RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput,
|
||||
ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
||||
};
|
||||
pub use orchestrator::{
|
||||
McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
|
||||
};
|
||||
pub use permission::{
|
||||
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,
|
||||
ResolveAgentPermissions, ResolveAgentPermissionsInput, ResolveAgentPermissionsOutput,
|
||||
UpdateAgentPermissions, UpdateAgentPermissionsInput, UpdateProjectPermissions,
|
||||
UpdateProjectPermissionsInput,
|
||||
};
|
||||
pub use project::{
|
||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
|
||||
OpenProjectInput, OpenProjectOutput, ProjectMeta,
|
||||
OpenProjectInput, OpenProjectOutput, ProjectMeta, ReadProjectContext, ReadProjectContextInput,
|
||||
ReadProjectContextOutput, UpdateProjectContext, UpdateProjectContextInput,
|
||||
PROJECT_CONTEXT_FILE,
|
||||
};
|
||||
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
|
||||
pub use skill::{
|
||||
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
|
||||
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
|
||||
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
|
||||
UpdateSkillOutput,
|
||||
};
|
||||
pub use template::{
|
||||
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
|
||||
@ -62,8 +108,8 @@ pub use template::{
|
||||
UpdateTemplateOutput,
|
||||
};
|
||||
pub use terminal::{
|
||||
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
|
||||
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, WriteToTerminal,
|
||||
WriteToTerminalInput,
|
||||
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveSessions,
|
||||
OpenTerminal, OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput,
|
||||
StructuredSessions, TerminalSessions, WriteToTerminal, WriteToTerminalInput,
|
||||
};
|
||||
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
|
||||
|
||||
23
crates/application/src/memory/mod.rs
Normal file
23
crates/application/src/memory/mod.rs
Normal file
@ -0,0 +1,23 @@
|
||||
//! Memory use cases (ARCHITECTURE §14.5.1; LOT A, étage 1: `.md`).
|
||||
//!
|
||||
//! The memory module owns the CRUD of a project's persistent knowledge base —
|
||||
//! one Markdown note per [`domain::memory::Memory`], stored under
|
||||
//! `.ideai/memory/<slug>.md` with a derived `MEMORY.md` index. On top of the
|
||||
//! CRUD it exposes two read-only navigation helpers driving the graphical memory
|
||||
//! view: [`ReadMemoryIndex`] (the structured index) and [`ResolveMemoryLinks`]
|
||||
//! (a note's resolved `[[slug]]` outgoing links, broken links dropped).
|
||||
//!
|
||||
//! Every use case talks only to ports ([`domain::ports::MemoryStore`],
|
||||
//! [`domain::ports::EventBus`]). Mutating use cases ([`CreateMemory`],
|
||||
//! [`UpdateMemory`], [`DeleteMemory`]) announce a [`domain::DomainEvent`]; the
|
||||
//! read use cases emit nothing.
|
||||
|
||||
mod usecases;
|
||||
|
||||
pub use usecases::{
|
||||
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
|
||||
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput,
|
||||
ListMemoriesOutput, ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory,
|
||||
RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput,
|
||||
ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
||||
};
|
||||
418
crates/application/src/memory/usecases.rs
Normal file
418
crates/application/src/memory/usecases.rs
Normal file
@ -0,0 +1,418 @@
|
||||
//! Memory use cases (ARCHITECTURE §14.5.1).
|
||||
//!
|
||||
//! - **CRUD**: [`CreateMemory`], [`UpdateMemory`], [`GetMemory`],
|
||||
//! [`ListMemories`], [`DeleteMemory`].
|
||||
//! - **Navigation** (read-only): [`ReadMemoryIndex`] (structured `MEMORY.md`
|
||||
//! rows) and [`ResolveMemoryLinks`] (a note's outgoing `[[slug]]` links).
|
||||
//!
|
||||
//! Mutating use cases announce a [`DomainEvent`]; the read use cases emit none.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventBus, MemoryQuery, MemoryRecall, MemoryStore};
|
||||
use domain::{
|
||||
DomainEvent, MarkdownDoc, Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug,
|
||||
MemoryType, ProjectPath,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`CreateMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateMemoryInput {
|
||||
/// Active project root (the note is stored under its `.ideai/memory/`).
|
||||
pub project_root: ProjectPath,
|
||||
/// Raw slug for the new note (validated into a [`MemorySlug`]).
|
||||
pub name: String,
|
||||
/// Human-readable one-line description (the index hook). Non-empty.
|
||||
pub description: String,
|
||||
/// The note's kind.
|
||||
pub r#type: MemoryType,
|
||||
/// Markdown body of the note.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Output of [`CreateMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateMemoryOutput {
|
||||
/// The created note.
|
||||
pub memory: Memory,
|
||||
}
|
||||
|
||||
/// Creates a memory note in the project's store and upserts the index.
|
||||
///
|
||||
/// Announces [`DomainEvent::MemorySaved`] on success.
|
||||
pub struct CreateMemory {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl CreateMemory {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { memories, events }
|
||||
}
|
||||
|
||||
/// Executes creation.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] if `name` is not a valid kebab-case slug or the
|
||||
/// note's invariants are violated (empty description/body),
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: CreateMemoryInput) -> Result<CreateMemoryOutput, AppError> {
|
||||
let slug = MemorySlug::new(input.name).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let frontmatter = MemoryFrontmatter {
|
||||
name: slug.clone(),
|
||||
description: input.description,
|
||||
r#type: input.r#type,
|
||||
};
|
||||
let memory = Memory::new(frontmatter, MarkdownDoc::new(input.content))
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.memories.save(&input.project_root, &memory).await?;
|
||||
self.events.publish(DomainEvent::MemorySaved { slug });
|
||||
Ok(CreateMemoryOutput { memory })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UpdateMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`UpdateMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateMemoryInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
/// Slug of the note to replace.
|
||||
pub slug: MemorySlug,
|
||||
/// New description (the index hook). Non-empty.
|
||||
pub description: String,
|
||||
/// New kind.
|
||||
pub r#type: MemoryType,
|
||||
/// New Markdown body.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Output of [`UpdateMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateMemoryOutput {
|
||||
/// The updated note.
|
||||
pub memory: Memory,
|
||||
}
|
||||
|
||||
/// Replaces a memory note's content (re-validating its invariants).
|
||||
///
|
||||
/// Announces [`DomainEvent::MemorySaved`] on success. The input carries the full
|
||||
/// note, so no prior `get` is needed — this is replace semantics.
|
||||
pub struct UpdateMemory {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl UpdateMemory {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { memories, events }
|
||||
}
|
||||
|
||||
/// Executes the update.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] if the note's invariants are violated (empty
|
||||
/// description/body),
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: UpdateMemoryInput) -> Result<UpdateMemoryOutput, AppError> {
|
||||
let frontmatter = MemoryFrontmatter {
|
||||
name: input.slug.clone(),
|
||||
description: input.description,
|
||||
r#type: input.r#type,
|
||||
};
|
||||
let memory = Memory::new(frontmatter, MarkdownDoc::new(input.content))
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.memories.save(&input.project_root, &memory).await?;
|
||||
self.events
|
||||
.publish(DomainEvent::MemorySaved { slug: input.slug });
|
||||
Ok(UpdateMemoryOutput { memory })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListMemories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`ListMemories::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListMemoriesInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
}
|
||||
|
||||
/// Output of [`ListMemories::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListMemoriesOutput {
|
||||
/// All notes in the project.
|
||||
pub memories: Vec<Memory>,
|
||||
}
|
||||
|
||||
/// Lists the memory notes of a project.
|
||||
pub struct ListMemories {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
impl ListMemories {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>) -> Self {
|
||||
Self { memories }
|
||||
}
|
||||
|
||||
/// Lists the project's notes.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] if a note's frontmatter is malformed,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: ListMemoriesInput) -> Result<ListMemoriesOutput, AppError> {
|
||||
Ok(ListMemoriesOutput {
|
||||
memories: self.memories.list(&input.project_root).await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GetMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`GetMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GetMemoryInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
/// Slug of the note to read.
|
||||
pub slug: MemorySlug,
|
||||
}
|
||||
|
||||
/// Output of [`GetMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GetMemoryOutput {
|
||||
/// The requested note.
|
||||
pub memory: Memory,
|
||||
}
|
||||
|
||||
/// Reads one memory note by slug.
|
||||
pub struct GetMemory {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
impl GetMemory {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>) -> Self {
|
||||
Self { memories }
|
||||
}
|
||||
|
||||
/// Reads the note.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if no note carries that slug,
|
||||
/// - [`AppError::Invalid`] if the note's frontmatter is malformed,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: GetMemoryInput) -> Result<GetMemoryOutput, AppError> {
|
||||
Ok(GetMemoryOutput {
|
||||
memory: self.memories.get(&input.project_root, &input.slug).await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`DeleteMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteMemoryInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
/// Slug of the note to delete.
|
||||
pub slug: MemorySlug,
|
||||
}
|
||||
|
||||
/// Deletes a memory note (and removes its index row).
|
||||
///
|
||||
/// Announces [`DomainEvent::MemoryDeleted`] on success.
|
||||
pub struct DeleteMemory {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl DeleteMemory {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { memories, events }
|
||||
}
|
||||
|
||||
/// Deletes the note.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if no note carries that slug,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: DeleteMemoryInput) -> Result<(), AppError> {
|
||||
self.memories
|
||||
.delete(&input.project_root, &input.slug)
|
||||
.await?;
|
||||
self.events
|
||||
.publish(DomainEvent::MemoryDeleted { slug: input.slug });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReadMemoryIndex
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`ReadMemoryIndex::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadMemoryIndexInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
}
|
||||
|
||||
/// Output of [`ReadMemoryIndex::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadMemoryIndexOutput {
|
||||
/// One row per note in the aggregated `MEMORY.md` index.
|
||||
pub entries: Vec<MemoryIndexEntry>,
|
||||
}
|
||||
|
||||
/// Reads the structured `MEMORY.md` index (drives the graphical memory view).
|
||||
pub struct ReadMemoryIndex {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
impl ReadMemoryIndex {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>) -> Self {
|
||||
Self { memories }
|
||||
}
|
||||
|
||||
/// Reads the index rows.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Store`] on an I/O failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ReadMemoryIndexInput,
|
||||
) -> Result<ReadMemoryIndexOutput, AppError> {
|
||||
Ok(ReadMemoryIndexOutput {
|
||||
entries: self.memories.read_index(&input.project_root).await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RecallMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`RecallMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RecallMemoryInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
/// The recall query (often the agent's current working context).
|
||||
pub text: String,
|
||||
/// Approximate token budget bounding the returned entries (`0` ⇒ empty).
|
||||
pub token_budget: usize,
|
||||
}
|
||||
|
||||
/// Output of [`RecallMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RecallMemoryOutput {
|
||||
/// The recalled index entries, in relevance order, fitting the budget.
|
||||
pub entries: Vec<MemoryIndexEntry>,
|
||||
}
|
||||
|
||||
/// Recalls the most relevant memory entries for a query within a token budget
|
||||
/// (LOT B, étage 1). Read-only — emits no event.
|
||||
pub struct RecallMemory {
|
||||
recall: Arc<dyn MemoryRecall>,
|
||||
}
|
||||
|
||||
impl RecallMemory {
|
||||
/// Builds the use case from the [`MemoryRecall`] port.
|
||||
#[must_use]
|
||||
pub fn new(recall: Arc<dyn MemoryRecall>) -> Self {
|
||||
Self { recall }
|
||||
}
|
||||
|
||||
/// Executes recall. Best-effort: an empty or absent memory yields an empty
|
||||
/// list, and a budget of `0` yields an empty list.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Store`] on an unexpected I/O failure of the underlying store.
|
||||
pub async fn execute(&self, input: RecallMemoryInput) -> Result<RecallMemoryOutput, AppError> {
|
||||
let query = MemoryQuery {
|
||||
text: input.text,
|
||||
token_budget: input.token_budget,
|
||||
};
|
||||
Ok(RecallMemoryOutput {
|
||||
entries: self.recall.recall(&input.project_root, &query).await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ResolveMemoryLinks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`ResolveMemoryLinks::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolveMemoryLinksInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
/// Slug of the source note whose outgoing links are resolved.
|
||||
pub slug: MemorySlug,
|
||||
}
|
||||
|
||||
/// Output of [`ResolveMemoryLinks::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolveMemoryLinksOutput {
|
||||
/// The note's resolved outgoing `[[slug]]` links (broken links dropped).
|
||||
pub links: Vec<MemoryLink>,
|
||||
}
|
||||
|
||||
/// Resolves a note's outgoing `[[slug]]` links (drives link navigation).
|
||||
pub struct ResolveMemoryLinks {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
impl ResolveMemoryLinks {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>) -> Self {
|
||||
Self { memories }
|
||||
}
|
||||
|
||||
/// Resolves the outgoing links.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the source note does not exist,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ResolveMemoryLinksInput,
|
||||
) -> Result<ResolveMemoryLinksOutput, AppError> {
|
||||
Ok(ResolveMemoryLinksOutput {
|
||||
links: self
|
||||
.memories
|
||||
.resolve_links(&input.project_root, &input.slug)
|
||||
.await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
839
crates/application/src/orchestrator/context_guard.rs
Normal file
839
crates/application/src/orchestrator/context_guard.rs
Normal file
@ -0,0 +1,839 @@
|
||||
//! FileGuard-mediated context & memory use cases (cadrage C7).
|
||||
//!
|
||||
//! Four use cases — [`ReadContext`], [`ProposeContext`], [`ReadMemory`],
|
||||
//! [`WriteMemory`] — route every read/write of IdeA-owned `.md` context and memory
|
||||
//! through the domain [`FileGuard`] port **before** touching a store. Each acquires
|
||||
//! the right lease (shared read / exclusive write) for the requesting
|
||||
//! [`ConversationParty`], then delegates to the existing store ports.
|
||||
//!
|
||||
//! ## Single-writer global context
|
||||
//!
|
||||
//! The global project context is single-writer: only the orchestrator
|
||||
//! ([`ConversationParty::User`]) may write it directly. A project agent that
|
||||
//! *proposes* a change to the global context receives [`GuardError::Forbidden`] from
|
||||
//! the guard; [`ProposeContext`] catches that and **materialises a proposal** under
|
||||
//! `.ideai/proposals/<who>-<ts>.md` for later validation by the orchestrator/UI —
|
||||
//! never overwriting the live context. An agent's *own* `.md` (and memory) is written
|
||||
//! directly under a write-lease.
|
||||
//!
|
||||
//! ## Cooperative scope (cadrage §9.5)
|
||||
//!
|
||||
//! The guard is **cooperative**: it serialises access inside the IdeA path (these use
|
||||
//! cases + the MCP tools). It does **not** sandbox an agent that keeps a raw shell —
|
||||
//! airtight revocation of raw fs access is an OS-sandbox (Landlock) concern, out of
|
||||
//! scope here.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{FileGuard, GuardError, GuardedResource};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
||||
use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
|
||||
use domain::{AgentId, Project};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Convention filename of the project's global context at the project root.
|
||||
const PROJECT_CONTEXT_FILE: &str = "CLAUDE.md";
|
||||
/// `.ideai/` subdirectory where a rejected global-context change is materialised.
|
||||
const PROPOSALS_DIR: &str = ".ideai/proposals";
|
||||
|
||||
/// Joins a project root with a POSIX-relative segment (valid on every target).
|
||||
fn join_root(project: &Project, rel: &str) -> RemotePath {
|
||||
let base = project.root.as_str().trim_end_matches(['/', '\\']);
|
||||
RemotePath::new(format!("{base}/{rel}"))
|
||||
}
|
||||
|
||||
/// Resolves an agent display name to its [`AgentId`] via the project manifest
|
||||
/// (case-insensitive), or [`AppError::NotFound`].
|
||||
async fn resolve_agent(
|
||||
contexts: &Arc<dyn AgentContextStore>,
|
||||
project: &Project,
|
||||
name: &str,
|
||||
) -> Result<AgentId, AppError> {
|
||||
let manifest = contexts.load_manifest(project).await?;
|
||||
manifest
|
||||
.entries
|
||||
.into_iter()
|
||||
.find(|e| e.name.eq_ignore_ascii_case(name))
|
||||
.map(|e| e.agent_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent `{name}`")))
|
||||
}
|
||||
|
||||
/// Reads an IdeA-owned context under a **shared read-lease** ([`GuardedResource`]).
|
||||
///
|
||||
/// `target` absent ⇒ the global project context; otherwise the named agent's `.md`.
|
||||
pub struct ReadContext {
|
||||
guard: Arc<dyn FileGuard>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
}
|
||||
|
||||
/// Input for [`ReadContext`].
|
||||
pub struct ReadContextInput {
|
||||
/// The project to read within.
|
||||
pub project: Project,
|
||||
/// Target agent display name; `None` ⇒ the global project context.
|
||||
pub target: Option<String>,
|
||||
/// The reading party (drives the read-lease holder identity).
|
||||
pub requester: ConversationParty,
|
||||
}
|
||||
|
||||
impl ReadContext {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
guard: Arc<dyn FileGuard>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
) -> Self {
|
||||
Self {
|
||||
guard,
|
||||
contexts,
|
||||
fs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the requested context, returning its Markdown body.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] when the agent/context does not exist or the store/fs fails.
|
||||
pub async fn execute(&self, input: ReadContextInput) -> Result<MarkdownDoc, AppError> {
|
||||
let ReadContextInput {
|
||||
project,
|
||||
target,
|
||||
requester,
|
||||
} = input;
|
||||
match target {
|
||||
None => {
|
||||
// Global project context: shared read-lease, then read the root file.
|
||||
let _lease = self
|
||||
.guard
|
||||
.acquire_read(requester, GuardedResource::ProjectContext)
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
let bytes = self.fs.read(&path).await?;
|
||||
let text =
|
||||
String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
Ok(MarkdownDoc::new(text))
|
||||
}
|
||||
Some(name) => {
|
||||
let agent = resolve_agent(&self.contexts, &project, &name).await?;
|
||||
let _lease = self
|
||||
.guard
|
||||
.acquire_read(requester, GuardedResource::AgentContext(agent))
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
Ok(self.contexts.read_context(&project, &agent).await?)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Proposes new content for an IdeA-owned context under the [`FileGuard`].
|
||||
///
|
||||
/// For an **agent** context: a direct write under an exclusive write-lease. For the
|
||||
/// **global** project context by a non-orchestrator: the guard returns
|
||||
/// [`GuardError::Forbidden`], which this use case turns into a *materialised proposal*
|
||||
/// (a file under `.ideai/proposals/`) — never an overwrite of the live context.
|
||||
pub struct ProposeContext {
|
||||
guard: Arc<dyn FileGuard>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
/// Outcome of a [`ProposeContext`] call: whether it wrote directly or filed a proposal.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProposeOutcome {
|
||||
/// The content was written directly (agent context, or global by the orchestrator).
|
||||
Written,
|
||||
/// A proposal was materialised at the given `.ideai/proposals/…` path (a
|
||||
/// non-orchestrator targeting the single-writer global context).
|
||||
Proposed {
|
||||
/// The proposal file's path.
|
||||
path: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Input for [`ProposeContext`].
|
||||
pub struct ProposeContextInput {
|
||||
/// The project to write within.
|
||||
pub project: Project,
|
||||
/// Target agent display name; `None` ⇒ the global project context.
|
||||
pub target: Option<String>,
|
||||
/// The proposed Markdown body.
|
||||
pub content: String,
|
||||
/// The proposing party.
|
||||
pub requester: ConversationParty,
|
||||
}
|
||||
|
||||
impl ProposeContext {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
guard: Arc<dyn FileGuard>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
clock: Arc<dyn Clock>,
|
||||
) -> Self {
|
||||
Self {
|
||||
guard,
|
||||
contexts,
|
||||
fs,
|
||||
clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the proposal: direct write under a write-lease, or a materialised
|
||||
/// proposal when the guard forbids a direct global-context write.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] when the agent does not exist or the store/fs fails.
|
||||
pub async fn execute(&self, input: ProposeContextInput) -> Result<ProposeOutcome, AppError> {
|
||||
let ProposeContextInput {
|
||||
project,
|
||||
target,
|
||||
content,
|
||||
requester,
|
||||
} = input;
|
||||
match target {
|
||||
Some(name) => {
|
||||
// Per-agent context: direct write under an exclusive write-lease.
|
||||
let agent = resolve_agent(&self.contexts, &project, &name).await?;
|
||||
let _lease = self
|
||||
.guard
|
||||
.acquire_write(requester, GuardedResource::AgentContext(agent))
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
self.contexts
|
||||
.write_context(&project, &agent, &MarkdownDoc::new(content))
|
||||
.await?;
|
||||
Ok(ProposeOutcome::Written)
|
||||
}
|
||||
None => {
|
||||
// Global project context: single-writer. Try to acquire the write
|
||||
// lease; Forbidden ⇒ materialise a proposal instead of overwriting.
|
||||
match self
|
||||
.guard
|
||||
.acquire_write(requester, GuardedResource::ProjectContext)
|
||||
.await
|
||||
{
|
||||
Ok(_lease) => {
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
self.fs.write(&path, content.as_bytes()).await?;
|
||||
Ok(ProposeOutcome::Written)
|
||||
}
|
||||
Err(GuardError::Forbidden) => {
|
||||
let path = self.file_proposal(&project, requester, &content).await?;
|
||||
Ok(ProposeOutcome::Proposed { path })
|
||||
}
|
||||
Err(other) => Err(map_guard_err(other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Materialises a rejected global-context change as a proposal file under
|
||||
/// `.ideai/proposals/<who>-<ts>.md`, returning its path.
|
||||
async fn file_proposal(
|
||||
&self,
|
||||
project: &Project,
|
||||
who: ConversationParty,
|
||||
content: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let who_label = match who {
|
||||
ConversationParty::User => "orchestrator".to_owned(),
|
||||
ConversationParty::Agent { agent_id } => agent_id.to_string(),
|
||||
};
|
||||
let ts = self.clock.now_millis();
|
||||
let rel = format!("{PROPOSALS_DIR}/{who_label}-{ts}.md");
|
||||
let dir = join_root(project, PROPOSALS_DIR);
|
||||
self.fs.create_dir_all(&dir).await?;
|
||||
let path = join_root(project, &rel);
|
||||
self.fs.write(&path, content.as_bytes()).await?;
|
||||
Ok(path.as_str().to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads project memory under a shared read-lease.
|
||||
///
|
||||
/// `slug` absent ⇒ the aggregated index (as Markdown lines); otherwise one note's body.
|
||||
pub struct ReadMemory {
|
||||
guard: Arc<dyn FileGuard>,
|
||||
memory: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
/// Input for [`ReadMemory`].
|
||||
pub struct ReadMemoryInput {
|
||||
/// The project to read within.
|
||||
pub project: Project,
|
||||
/// Target note slug; `None` ⇒ the aggregated index.
|
||||
pub slug: Option<String>,
|
||||
/// The reading party.
|
||||
pub requester: ConversationParty,
|
||||
}
|
||||
|
||||
impl ReadMemory {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(guard: Arc<dyn FileGuard>, memory: Arc<dyn MemoryStore>) -> Self {
|
||||
Self { guard, memory }
|
||||
}
|
||||
|
||||
/// Reads the requested memory, returning its Markdown content.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] when the note does not exist or the store fails. An invalid slug
|
||||
/// is [`AppError::Invalid`].
|
||||
pub async fn execute(&self, input: ReadMemoryInput) -> Result<String, AppError> {
|
||||
let ReadMemoryInput {
|
||||
project,
|
||||
slug,
|
||||
requester,
|
||||
} = input;
|
||||
match slug {
|
||||
Some(raw) => {
|
||||
let slug = MemorySlug::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let _lease = self
|
||||
.guard
|
||||
.acquire_read(requester, GuardedResource::Memory(slug.clone()))
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
let note = self.memory.get(&project.root, &slug).await?;
|
||||
Ok(note.body.into_string())
|
||||
}
|
||||
None => {
|
||||
// The aggregated index is project-shared; read it as a rendered list.
|
||||
let entries = self.memory.read_index(&project.root).await?;
|
||||
let lines: Vec<String> = entries
|
||||
.into_iter()
|
||||
.map(|e| format!("- [{}]({}.md) — {}", e.title, e.slug, e.hook))
|
||||
.collect();
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes (creates or replaces) a project memory note under an exclusive write-lease.
|
||||
pub struct WriteMemory {
|
||||
guard: Arc<dyn FileGuard>,
|
||||
memory: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
/// Input for [`WriteMemory`].
|
||||
pub struct WriteMemoryInput {
|
||||
/// The project to write within.
|
||||
pub project: Project,
|
||||
/// Target note slug.
|
||||
pub slug: String,
|
||||
/// The Markdown body to store.
|
||||
pub content: String,
|
||||
/// The writing party.
|
||||
pub requester: ConversationParty,
|
||||
}
|
||||
|
||||
impl WriteMemory {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(guard: Arc<dyn FileGuard>, memory: Arc<dyn MemoryStore>) -> Self {
|
||||
Self { guard, memory }
|
||||
}
|
||||
|
||||
/// Writes the note under a write-lease.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Invalid`] for a bad slug or empty body; [`AppError`] on a store
|
||||
/// failure.
|
||||
pub async fn execute(&self, input: WriteMemoryInput) -> Result<(), AppError> {
|
||||
let WriteMemoryInput {
|
||||
project,
|
||||
slug,
|
||||
content,
|
||||
requester,
|
||||
} = input;
|
||||
let slug = MemorySlug::new(slug).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let _lease = self
|
||||
.guard
|
||||
.acquire_write(requester, GuardedResource::Memory(slug.clone()))
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
let frontmatter = MemoryFrontmatter {
|
||||
name: slug.clone(),
|
||||
description: format!("memory note {slug}"),
|
||||
r#type: MemoryType::Project,
|
||||
};
|
||||
let note = Memory::new(frontmatter, MarkdownDoc::new(content))
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.memory.save(&project.root, ¬e).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a [`GuardError`] onto the application error shape. `Forbidden` is an invariant
|
||||
/// violation ([`AppError::Invalid`]); `Busy` is a transient contention the cooperative
|
||||
/// blocking adapter never returns, but is mapped for completeness.
|
||||
fn map_guard_err(e: GuardError) -> AppError {
|
||||
match e {
|
||||
GuardError::Forbidden => AppError::Invalid(
|
||||
"writing the global project context is reserved to the orchestrator; propose instead"
|
||||
.to_owned(),
|
||||
),
|
||||
GuardError::Busy => AppError::Invalid("guarded resource is busy".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use domain::agent::{AgentManifest, ManifestEntry};
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{may_write_directly, ReadLease, WriteLease};
|
||||
use domain::ports::{FsError, MemoryError, StoreError};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::{ProfileId, ProjectId, RemoteRef};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc as StdArc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(uuid::Uuid::from_u128(1)),
|
||||
"demo",
|
||||
ProjectPath::new("/tmp/demo").unwrap(),
|
||||
RemoteRef::local(),
|
||||
0,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// In-test [`FileGuard`] mirroring `infrastructure::RwFileGuard` (one tokio
|
||||
/// `RwLock` per resource + the single-writer rule), so the application crate
|
||||
/// stays free of any dependency on infrastructure (no dependency cycle).
|
||||
#[derive(Default)]
|
||||
struct TestGuard {
|
||||
locks: Mutex<HashMap<GuardedResource, StdArc<RwLock<()>>>>,
|
||||
}
|
||||
|
||||
impl TestGuard {
|
||||
fn lock_for(&self, res: &GuardedResource) -> StdArc<RwLock<()>> {
|
||||
self.locks
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(res.clone())
|
||||
.or_insert_with(|| StdArc::new(RwLock::new(())))
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileGuard for TestGuard {
|
||||
async fn acquire_read(
|
||||
&self,
|
||||
_who: ConversationParty,
|
||||
res: GuardedResource,
|
||||
) -> Result<ReadLease, GuardError> {
|
||||
let lock = self.lock_for(&res);
|
||||
Ok(ReadLease::new(Box::new(lock.read_owned().await)))
|
||||
}
|
||||
async fn acquire_write(
|
||||
&self,
|
||||
who: ConversationParty,
|
||||
res: GuardedResource,
|
||||
) -> Result<WriteLease, GuardError> {
|
||||
if !may_write_directly(who, &res) {
|
||||
return Err(GuardError::Forbidden);
|
||||
}
|
||||
let lock = self.lock_for(&res);
|
||||
Ok(WriteLease::new(Box::new(lock.write_owned().await)))
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_party(n: u128) -> ConversationParty {
|
||||
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
|
||||
}
|
||||
|
||||
// ---- Fakes -----------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeFs {
|
||||
files: Mutex<HashMap<String, Vec<u8>>>,
|
||||
dirs: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileSystem for FakeFs {
|
||||
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(path.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| FsError::NotFound(path.as_str().to_owned()))
|
||||
}
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(path.as_str().to_owned(), data.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
|
||||
Ok(self.files.lock().unwrap().contains_key(path.as_str()))
|
||||
}
|
||||
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||
self.dirs.lock().unwrap().push(path.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
async fn list(&self, _path: &RemotePath) -> Result<Vec<domain::ports::DirEntry>, FsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeContexts {
|
||||
manifest: AgentManifest,
|
||||
contexts: Mutex<HashMap<AgentId, String>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentContextStore for FakeContexts {
|
||||
async fn read_context(
|
||||
&self,
|
||||
_project: &Project,
|
||||
agent: &AgentId,
|
||||
) -> Result<MarkdownDoc, StoreError> {
|
||||
self.contexts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(agent)
|
||||
.cloned()
|
||||
.map(MarkdownDoc::new)
|
||||
.ok_or(StoreError::NotFound)
|
||||
}
|
||||
async fn write_context(
|
||||
&self,
|
||||
_project: &Project,
|
||||
agent: &AgentId,
|
||||
md: &MarkdownDoc,
|
||||
) -> Result<(), StoreError> {
|
||||
self.contexts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(*agent, md.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
|
||||
Ok(self.manifest.clone())
|
||||
}
|
||||
async fn save_manifest(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_manifest: &AgentManifest,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeMemory {
|
||||
notes: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MemoryStore for FakeMemory {
|
||||
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
|
||||
let body = self
|
||||
.notes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(slug.as_str())
|
||||
.cloned()
|
||||
.ok_or(MemoryError::NotFound)?;
|
||||
Memory::new(
|
||||
MemoryFrontmatter {
|
||||
name: slug.clone(),
|
||||
description: "d".to_owned(),
|
||||
r#type: MemoryType::Project,
|
||||
},
|
||||
MarkdownDoc::new(body),
|
||||
)
|
||||
.map_err(|e| MemoryError::Frontmatter(e.to_string()))
|
||||
}
|
||||
async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> {
|
||||
self.notes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(memory.slug().to_string(), memory.body.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn read_index(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
) -> Result<Vec<domain::memory::MemoryIndexEntry>, MemoryError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn resolve_links(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_slug: &MemorySlug,
|
||||
) -> Result<Vec<domain::memory::MemoryLink>, MemoryError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedClock;
|
||||
impl Clock for FixedClock {
|
||||
fn now_millis(&self) -> i64 {
|
||||
42
|
||||
}
|
||||
}
|
||||
|
||||
fn guard() -> Arc<dyn FileGuard> {
|
||||
Arc::new(TestGuard::default())
|
||||
}
|
||||
|
||||
fn contexts_with(name: &str, agent: AgentId, body: &str) -> Arc<dyn AgentContextStore> {
|
||||
let mut contexts = HashMap::new();
|
||||
contexts.insert(agent, body.to_owned());
|
||||
Arc::new(FakeContexts {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
entries: vec![ManifestEntry {
|
||||
agent_id: agent,
|
||||
name: name.to_owned(),
|
||||
md_path: "agents/x.md".to_owned(),
|
||||
profile_id: ProfileId::from_uuid(uuid::Uuid::from_u128(99)),
|
||||
template_id: None,
|
||||
synchronized: false,
|
||||
synced_template_version: None,
|
||||
skills: Vec::new(),
|
||||
}],
|
||||
},
|
||||
contexts: Mutex::new(contexts),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Tests -----------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_agent_context_returns_body() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let uc = ReadContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "# hello"),
|
||||
Arc::new(FakeFs::default()),
|
||||
);
|
||||
let md = uc
|
||||
.execute(ReadContextInput {
|
||||
project: project(),
|
||||
target: Some("dev".to_owned()), // case-insensitive
|
||||
requester: agent_party(1),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(md.as_str(), "# hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_global_context_reads_root_file() {
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# project".to_vec());
|
||||
let uc = ReadContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
||||
fs,
|
||||
);
|
||||
let md = uc
|
||||
.execute(ReadContextInput {
|
||||
project: project(),
|
||||
target: None,
|
||||
requester: ConversationParty::User,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(md.as_str(), "# project");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_reads_do_not_block_each_other() {
|
||||
// Two readers on the same global context, held at once. If the read-lease
|
||||
// were exclusive this would deadlock; a bounded timeout proves it does not.
|
||||
let guard = guard();
|
||||
let r1 = guard
|
||||
.acquire_read(ConversationParty::User, GuardedResource::ProjectContext)
|
||||
.await
|
||||
.unwrap();
|
||||
let r2 = tokio::time::timeout(
|
||||
Duration::from_millis(200),
|
||||
guard.acquire_read(agent_party(1), GuardedResource::ProjectContext),
|
||||
)
|
||||
.await
|
||||
.expect("a second reader must not block")
|
||||
.unwrap();
|
||||
drop((r1, r2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_proposing_global_context_files_a_proposal_not_a_write() {
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# original".to_vec());
|
||||
let uc = ProposeContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
let outcome = uc
|
||||
.execute(ProposeContextInput {
|
||||
project: project(),
|
||||
target: None,
|
||||
content: "# hijack".to_owned(),
|
||||
requester: agent_party(3),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
// It is a *proposal*, not a write: the live context is untouched.
|
||||
assert!(matches!(outcome, ProposeOutcome::Proposed { .. }));
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# original",
|
||||
"the live global context must NOT be overwritten by a proposal"
|
||||
);
|
||||
// The proposal landed under .ideai/proposals/.
|
||||
let files = fs.files.lock().unwrap();
|
||||
assert!(files
|
||||
.keys()
|
||||
.any(|k| k.contains("/.ideai/proposals/") && k.ends_with("-42.md")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn orchestrator_writes_global_context_directly() {
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
let uc = ProposeContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
let outcome = uc
|
||||
.execute(ProposeContextInput {
|
||||
project: project(),
|
||||
target: None,
|
||||
content: "# new".to_owned(),
|
||||
requester: ConversationParty::User,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome, ProposeOutcome::Written);
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# new"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn propose_agent_context_writes_directly() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let contexts = contexts_with("Dev", agent, "# old");
|
||||
let uc = ProposeContext::new(
|
||||
guard(),
|
||||
Arc::clone(&contexts),
|
||||
Arc::new(FakeFs::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
let outcome = uc
|
||||
.execute(ProposeContextInput {
|
||||
project: project(),
|
||||
target: Some("Dev".to_owned()),
|
||||
content: "# new body".to_owned(),
|
||||
requester: agent_party(3),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome, ProposeOutcome::Written);
|
||||
assert_eq!(
|
||||
contexts
|
||||
.read_context(&project(), &agent)
|
||||
.await
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"# new body"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_then_read_memory_round_trips_under_guard() {
|
||||
let memory = Arc::new(FakeMemory::default());
|
||||
let writer = WriteMemory::new(guard(), Arc::clone(&memory) as Arc<dyn MemoryStore>);
|
||||
writer
|
||||
.execute(WriteMemoryInput {
|
||||
project: project(),
|
||||
slug: "note-a".to_owned(),
|
||||
content: "body".to_owned(),
|
||||
requester: agent_party(2),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let reader = ReadMemory::new(guard(), Arc::clone(&memory) as Arc<dyn MemoryStore>);
|
||||
let body = reader
|
||||
.execute(ReadMemoryInput {
|
||||
project: project(),
|
||||
slug: Some("note-a".to_owned()),
|
||||
requester: agent_party(2),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(body, "body");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn writes_to_same_memory_note_serialise() {
|
||||
// Two write leases on the same note must not overlap (exclusive writer).
|
||||
let guard = guard();
|
||||
let slug = GuardedResource::Memory(MemorySlug::new("n").unwrap());
|
||||
let w1 = guard
|
||||
.acquire_write(agent_party(1), slug.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
// While w1 is held, a second writer blocks; it only succeeds after release.
|
||||
let blocked = tokio::time::timeout(
|
||||
Duration::from_millis(100),
|
||||
guard.acquire_write(agent_party(2), slug.clone()),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
blocked.is_err(),
|
||||
"a second writer must block while w1 holds"
|
||||
);
|
||||
drop(w1);
|
||||
let w2 = tokio::time::timeout(
|
||||
Duration::from_millis(200),
|
||||
guard.acquire_write(agent_party(2), slug),
|
||||
)
|
||||
.await
|
||||
.expect("w2 acquires after w1 releases")
|
||||
.unwrap();
|
||||
drop(w2);
|
||||
}
|
||||
}
|
||||
16
crates/application/src/orchestrator/mod.rs
Normal file
16
crates/application/src/orchestrator/mod.rs
Normal file
@ -0,0 +1,16 @@
|
||||
//! Orchestrator application service (ARCHITECTURE §14.3).
|
||||
//!
|
||||
//! Turns a validated [`domain::OrchestratorCommand`] into the *same* agent/terminal
|
||||
//! use-case calls the UI makes, so an orchestrator agent can drive IdeA without
|
||||
//! ever spawning a process itself. See [`service::OrchestratorService`].
|
||||
|
||||
mod context_guard;
|
||||
mod service;
|
||||
|
||||
pub use context_guard::{
|
||||
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
|
||||
ReadMemoryInput, WriteMemory, WriteMemoryInput,
|
||||
};
|
||||
pub use service::{
|
||||
McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
|
||||
};
|
||||
2056
crates/application/src/orchestrator/service.rs
Normal file
2056
crates/application/src/orchestrator/service.rs
Normal file
File diff suppressed because it is too large
Load Diff
150
crates/application/src/permission.rs
Normal file
150
crates/application/src/permission.rs
Normal file
@ -0,0 +1,150 @@
|
||||
//! Permission use cases.
|
||||
//!
|
||||
//! This module stays at the application boundary: it loads the project
|
||||
//! permission document through [`PermissionStore`], applies simple mutations, and
|
||||
//! delegates all merge semantics to the pure domain model.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::PermissionStore;
|
||||
use domain::{AgentId, EffectivePermissions, PermissionSet, Project, ProjectPermissions};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Reads the full project permission document.
|
||||
pub struct GetProjectPermissions {
|
||||
store: Arc<dyn PermissionStore>,
|
||||
}
|
||||
|
||||
impl GetProjectPermissions {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the read.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: GetProjectPermissionsInput,
|
||||
) -> Result<GetProjectPermissionsOutput, AppError> {
|
||||
let permissions = self.store.load_permissions(&input.project).await?;
|
||||
Ok(GetProjectPermissionsOutput { permissions })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`GetProjectPermissions`].
|
||||
pub struct GetProjectPermissionsInput {
|
||||
/// Target project.
|
||||
pub project: Project,
|
||||
}
|
||||
|
||||
/// Output for [`GetProjectPermissions`].
|
||||
pub struct GetProjectPermissionsOutput {
|
||||
/// Persisted permission document.
|
||||
pub permissions: ProjectPermissions,
|
||||
}
|
||||
|
||||
/// Replaces the project default policy.
|
||||
pub struct UpdateProjectPermissions {
|
||||
store: Arc<dyn PermissionStore>,
|
||||
}
|
||||
|
||||
impl UpdateProjectPermissions {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the mutation.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: UpdateProjectPermissionsInput,
|
||||
) -> Result<GetProjectPermissionsOutput, AppError> {
|
||||
let mut doc = self.store.load_permissions(&input.project).await?;
|
||||
doc.set_project_defaults(input.permissions);
|
||||
self.store.save_permissions(&input.project, &doc).await?;
|
||||
Ok(GetProjectPermissionsOutput { permissions: doc })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`UpdateProjectPermissions`].
|
||||
pub struct UpdateProjectPermissionsInput {
|
||||
/// Target project.
|
||||
pub project: Project,
|
||||
/// New project default policy. `None` removes project defaults.
|
||||
pub permissions: Option<PermissionSet>,
|
||||
}
|
||||
|
||||
/// Replaces one agent override.
|
||||
pub struct UpdateAgentPermissions {
|
||||
store: Arc<dyn PermissionStore>,
|
||||
}
|
||||
|
||||
impl UpdateAgentPermissions {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the mutation.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: UpdateAgentPermissionsInput,
|
||||
) -> Result<GetProjectPermissionsOutput, AppError> {
|
||||
let mut doc = self.store.load_permissions(&input.project).await?;
|
||||
doc.set_agent_permissions(input.agent_id, input.permissions);
|
||||
self.store.save_permissions(&input.project, &doc).await?;
|
||||
Ok(GetProjectPermissionsOutput { permissions: doc })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`UpdateAgentPermissions`].
|
||||
pub struct UpdateAgentPermissionsInput {
|
||||
/// Target project.
|
||||
pub project: Project,
|
||||
/// Target agent.
|
||||
pub agent_id: AgentId,
|
||||
/// New agent policy. `None` removes the override.
|
||||
pub permissions: Option<PermissionSet>,
|
||||
}
|
||||
|
||||
/// Resolves effective permissions for one agent.
|
||||
pub struct ResolveAgentPermissions {
|
||||
store: Arc<dyn PermissionStore>,
|
||||
}
|
||||
|
||||
impl ResolveAgentPermissions {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the resolution.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ResolveAgentPermissionsInput,
|
||||
) -> Result<ResolveAgentPermissionsOutput, AppError> {
|
||||
let doc = self.store.load_permissions(&input.project).await?;
|
||||
Ok(ResolveAgentPermissionsOutput {
|
||||
effective: doc.resolve_for(input.agent_id),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`ResolveAgentPermissions`].
|
||||
pub struct ResolveAgentPermissionsInput {
|
||||
/// Target project.
|
||||
pub project: Project,
|
||||
/// Target agent.
|
||||
pub agent_id: AgentId,
|
||||
}
|
||||
|
||||
/// Output for [`ResolveAgentPermissions`].
|
||||
pub struct ResolveAgentPermissionsOutput {
|
||||
/// Resolved policy, or `None` when neither project nor agent policy exists.
|
||||
pub effective: Option<EffectivePermissions>,
|
||||
}
|
||||
115
crates/application/src/project/context.rs
Normal file
115
crates/application/src/project/context.rs
Normal 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}"),
|
||||
))
|
||||
}
|
||||
@ -14,11 +14,18 @@
|
||||
//! - [`ListProjects`] — list known projects from the registry.
|
||||
|
||||
mod close;
|
||||
mod context;
|
||||
mod create;
|
||||
pub(crate) mod meta;
|
||||
mod open;
|
||||
|
||||
pub use close::{CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput};
|
||||
pub use context::{
|
||||
project_context_path, ReadProjectContext, ReadProjectContextInput, ReadProjectContextOutput,
|
||||
UpdateProjectContext, UpdateProjectContextInput, PROJECT_CONTEXT_FILE,
|
||||
};
|
||||
pub use create::{CreateProject, CreateProjectInput, CreateProjectOutput};
|
||||
pub use meta::ProjectMeta;
|
||||
pub use open::{ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput};
|
||||
pub use open::{
|
||||
ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput,
|
||||
};
|
||||
|
||||
@ -7,9 +7,7 @@ use domain::{AgentManifest, Project, ProjectId};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::meta::{
|
||||
from_json_bytes, join_root, ProjectMeta, AGENTS_FILE, IDEAI_DIR, PROJECT_FILE,
|
||||
};
|
||||
use super::meta::{from_json_bytes, join_root, ProjectMeta, AGENTS_FILE, IDEAI_DIR, PROJECT_FILE};
|
||||
|
||||
/// Input for [`OpenProject::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
21
crates/application/src/skill/mod.rs
Normal file
21
crates/application/src/skill/mod.rs
Normal file
@ -0,0 +1,21 @@
|
||||
//! Skill use cases (ARCHITECTURE §14.2; L12).
|
||||
//!
|
||||
//! Skills are reusable, model-agnostic workflows (IdeA's universal equivalent of
|
||||
//! a CLI slash-command). This module owns their CRUD across both scopes
|
||||
//! ([`domain::skill::SkillScope`]) and the agent↔skill assignment that records a
|
||||
//! [`domain::skill::SkillRef`] in the project manifest. The actual injection of
|
||||
//! an assigned skill's body into the generated convention file happens at agent
|
||||
//! activation (L6).
|
||||
//!
|
||||
//! Every use case talks only to ports ([`domain::ports::SkillStore`],
|
||||
//! [`domain::ports::AgentContextStore`], [`domain::ports::IdGenerator`],
|
||||
//! [`domain::ports::EventBus`]).
|
||||
|
||||
mod usecases;
|
||||
|
||||
pub use usecases::{
|
||||
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
|
||||
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
|
||||
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
|
||||
UpdateSkillOutput,
|
||||
};
|
||||
351
crates/application/src/skill/usecases.rs
Normal file
351
crates/application/src/skill/usecases.rs
Normal file
@ -0,0 +1,351 @@
|
||||
//! Skill use cases (ARCHITECTURE §14.2; L12).
|
||||
//!
|
||||
//! - **CRUD** in either scope: [`CreateSkill`], [`UpdateSkill`], [`DeleteSkill`],
|
||||
//! [`ListSkills`].
|
||||
//! - **Assignment**: [`AssignSkillToAgent`] / [`UnassignSkillFromAgent`] mutate
|
||||
//! the project manifest entry's `skills` and announce
|
||||
//! [`DomainEvent::SkillAssigned`]. Both are idempotent.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{AgentContextStore, EventBus, IdGenerator, SkillStore};
|
||||
use domain::{
|
||||
AgentId, AgentManifest, DomainEvent, MarkdownDoc, Project, ProjectPath, Skill, SkillId,
|
||||
SkillRef, SkillScope,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateSkill
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`CreateSkill::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateSkillInput {
|
||||
/// Display name (also the `.md` stem on disk).
|
||||
pub name: String,
|
||||
/// Initial Markdown body.
|
||||
pub content: String,
|
||||
/// Scope the skill is created in (selects its backing store).
|
||||
pub scope: SkillScope,
|
||||
/// Active project root (used only for [`SkillScope::Project`]).
|
||||
pub project_root: ProjectPath,
|
||||
}
|
||||
|
||||
/// Output of [`CreateSkill::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateSkillOutput {
|
||||
/// The created skill.
|
||||
pub skill: Skill,
|
||||
}
|
||||
|
||||
/// Creates a skill in the store of its [`SkillScope`].
|
||||
pub struct CreateSkill {
|
||||
skills: Arc<dyn SkillStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
}
|
||||
|
||||
impl CreateSkill {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(skills: Arc<dyn SkillStore>, ids: Arc<dyn IdGenerator>) -> Self {
|
||||
Self { skills, ids }
|
||||
}
|
||||
|
||||
/// Executes creation.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] if `name`/`content` is empty,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: CreateSkillInput) -> Result<CreateSkillOutput, AppError> {
|
||||
let id = SkillId::from_uuid(self.ids.new_uuid());
|
||||
let skill = Skill::new(id, input.name, MarkdownDoc::new(input.content), input.scope)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.skills.save(&skill, &input.project_root).await?;
|
||||
Ok(CreateSkillOutput { skill })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UpdateSkill
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`UpdateSkill::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateSkillInput {
|
||||
/// Scope the skill lives in.
|
||||
pub scope: SkillScope,
|
||||
/// Skill to update.
|
||||
pub skill_id: SkillId,
|
||||
/// New Markdown body.
|
||||
pub content: String,
|
||||
/// Active project root (used only for [`SkillScope::Project`]).
|
||||
pub project_root: ProjectPath,
|
||||
}
|
||||
|
||||
/// Output of [`UpdateSkill::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateSkillOutput {
|
||||
/// The updated skill.
|
||||
pub skill: Skill,
|
||||
}
|
||||
|
||||
/// Replaces a skill's content (re-validating the non-empty invariant).
|
||||
pub struct UpdateSkill {
|
||||
skills: Arc<dyn SkillStore>,
|
||||
}
|
||||
|
||||
impl UpdateSkill {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
||||
Self { skills }
|
||||
}
|
||||
|
||||
/// Executes the update.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the skill is unknown in that scope,
|
||||
/// - [`AppError::Invalid`] if the new content is empty,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: UpdateSkillInput) -> Result<UpdateSkillOutput, AppError> {
|
||||
let current = self
|
||||
.skills
|
||||
.get(input.scope, &input.project_root, input.skill_id)
|
||||
.await?;
|
||||
let updated = current
|
||||
.with_content(MarkdownDoc::new(input.content))
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.skills.save(&updated, &input.project_root).await?;
|
||||
Ok(UpdateSkillOutput { skill: updated })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListSkills / DeleteSkill
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`ListSkills::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListSkillsInput {
|
||||
/// Scope to list.
|
||||
pub scope: SkillScope,
|
||||
/// Active project root (used only for [`SkillScope::Project`]).
|
||||
pub project_root: ProjectPath,
|
||||
}
|
||||
|
||||
/// Output of [`ListSkills::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListSkillsOutput {
|
||||
/// All skills in the requested scope.
|
||||
pub skills: Vec<Skill>,
|
||||
}
|
||||
|
||||
/// Lists the skills in one scope.
|
||||
pub struct ListSkills {
|
||||
skills: Arc<dyn SkillStore>,
|
||||
}
|
||||
|
||||
impl ListSkills {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
||||
Self { skills }
|
||||
}
|
||||
|
||||
/// Lists skills in `input.scope`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: ListSkillsInput) -> Result<ListSkillsOutput, AppError> {
|
||||
Ok(ListSkillsOutput {
|
||||
skills: self.skills.list(input.scope, &input.project_root).await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`DeleteSkill::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteSkillInput {
|
||||
/// Scope the skill lives in.
|
||||
pub scope: SkillScope,
|
||||
/// Skill to delete.
|
||||
pub skill_id: SkillId,
|
||||
/// Active project root (used only for [`SkillScope::Project`]).
|
||||
pub project_root: ProjectPath,
|
||||
}
|
||||
|
||||
/// Deletes a skill from its scope's store.
|
||||
///
|
||||
/// Agents that referenced it keep their [`SkillRef`]; the injection step simply
|
||||
/// finds nothing to resolve for the now-absent skill and skips it.
|
||||
pub struct DeleteSkill {
|
||||
skills: Arc<dyn SkillStore>,
|
||||
}
|
||||
|
||||
impl DeleteSkill {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
|
||||
Self { skills }
|
||||
}
|
||||
|
||||
/// Deletes the skill.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the skill is unknown in that scope,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: DeleteSkillInput) -> Result<(), AppError> {
|
||||
self.skills
|
||||
.delete(input.scope, &input.project_root, input.skill_id)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AssignSkillToAgent / UnassignSkillFromAgent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`AssignSkillToAgent::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AssignSkillToAgentInput {
|
||||
/// The owning project.
|
||||
pub project: Project,
|
||||
/// The agent receiving the skill.
|
||||
pub agent_id: AgentId,
|
||||
/// The skill to assign.
|
||||
pub skill: SkillRef,
|
||||
}
|
||||
|
||||
/// Assigns a skill to an agent by recording a [`SkillRef`] in its manifest entry.
|
||||
/// Idempotent: re-assigning the same skill is a no-op (no duplicate).
|
||||
pub struct AssignSkillToAgent {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl AssignSkillToAgent {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { contexts, events }
|
||||
}
|
||||
|
||||
/// Executes the assignment.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the agent is unknown to the project,
|
||||
/// - [`AppError::Invalid`] if the resulting manifest is invalid,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: AssignSkillToAgentInput) -> Result<(), AppError> {
|
||||
let mut manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
let entry = manifest
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|e| e.agent_id == input.agent_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||
|
||||
// Mutate through the domain entity so the dedup invariant is enforced in
|
||||
// one place, then fold the result back into the manifest entry.
|
||||
let mut agent = entry
|
||||
.to_agent()
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let changed = agent.assign_skill(input.skill);
|
||||
*entry = domain::ManifestEntry::from_agent(&agent);
|
||||
|
||||
if changed {
|
||||
self.persist_and_announce(
|
||||
&input.project,
|
||||
manifest,
|
||||
input.agent_id,
|
||||
input.skill.skill_id,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Saves the manifest and announces the assignment change.
|
||||
async fn persist_and_announce(
|
||||
&self,
|
||||
project: &Project,
|
||||
manifest: AgentManifest,
|
||||
agent_id: AgentId,
|
||||
skill_id: SkillId,
|
||||
assigned: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts.save_manifest(project, &manifest).await?;
|
||||
self.events.publish(DomainEvent::SkillAssigned {
|
||||
agent_id,
|
||||
skill_id,
|
||||
assigned,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`UnassignSkillFromAgent::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnassignSkillFromAgentInput {
|
||||
/// The owning project.
|
||||
pub project: Project,
|
||||
/// The agent losing the skill.
|
||||
pub agent_id: AgentId,
|
||||
/// The skill to unassign.
|
||||
pub skill_id: SkillId,
|
||||
}
|
||||
|
||||
/// Removes a skill assignment from an agent. Idempotent: unassigning a skill the
|
||||
/// agent does not carry is a no-op.
|
||||
pub struct UnassignSkillFromAgent {
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl UnassignSkillFromAgent {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { contexts, events }
|
||||
}
|
||||
|
||||
/// Executes the un-assignment.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the agent is unknown to the project,
|
||||
/// - [`AppError::Invalid`] if the resulting manifest is invalid,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: UnassignSkillFromAgentInput) -> Result<(), AppError> {
|
||||
let mut manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
let entry = manifest
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|e| e.agent_id == input.agent_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||
|
||||
let mut agent = entry
|
||||
.to_agent()
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let changed = agent.unassign_skill(input.skill_id);
|
||||
*entry = domain::ManifestEntry::from_agent(&agent);
|
||||
|
||||
if changed {
|
||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
.await?;
|
||||
self.events.publish(DomainEvent::SkillAssigned {
|
||||
agent_id: input.agent_id,
|
||||
skill_id: input.skill_id,
|
||||
assigned: false,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -286,7 +286,9 @@ impl CreateAgentFromTemplate {
|
||||
entries.push(ManifestEntry::from_agent(&agent));
|
||||
let manifest = AgentManifest::new(manifest.version, entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts.save_manifest(&input.project, &manifest).await?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
.await?;
|
||||
|
||||
// Seed the agent context with the template content.
|
||||
self.contexts
|
||||
@ -477,7 +479,9 @@ impl SyncAgentWithTemplate {
|
||||
// Persist the manifest (revalidated) and overwrite the agent context.
|
||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts.save_manifest(&input.project, &manifest).await?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
.await?;
|
||||
self.contexts
|
||||
.write_context(&input.project, &input.agent_id, &template.content_md)
|
||||
.await?;
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
mod registry;
|
||||
mod usecases;
|
||||
|
||||
pub use registry::TerminalSessions;
|
||||
pub use registry::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions};
|
||||
pub use usecases::{
|
||||
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
|
||||
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, WriteToTerminal, WriteToTerminalInput,
|
||||
|
||||
@ -7,10 +7,11 @@
|
||||
//! application layer rather than the domain or the adapter.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use domain::ports::PtyHandle;
|
||||
use domain::{SessionId, TerminalSession};
|
||||
use domain::conversation::ConversationId;
|
||||
use domain::ports::{AgentSession, PtyHandle};
|
||||
use domain::{AgentId, NodeId, SessionId, SessionKind, TerminalSession};
|
||||
|
||||
/// A registered, live terminal: its PTY handle plus the domain snapshot.
|
||||
#[derive(Debug, Clone)]
|
||||
@ -19,10 +20,49 @@ struct Entry {
|
||||
session: TerminalSession,
|
||||
}
|
||||
|
||||
/// Read-only liveness query over the agents that currently own a live PTY.
|
||||
///
|
||||
/// Abstracted as a trait so use cases that only need to ask "is this agent
|
||||
/// running right now?" (e.g. the close-time snapshot of running agents) depend
|
||||
/// on the *capability*, not on the whole [`TerminalSessions`] registry — and can
|
||||
/// be tested against a trivial fake. [`TerminalSessions`] is the production
|
||||
/// implementation.
|
||||
pub trait LiveAgentRegistry: Send + Sync {
|
||||
/// Whether `agent_id` currently has a live session in the registry.
|
||||
fn is_agent_live(&self, agent_id: &AgentId) -> bool;
|
||||
|
||||
/// Whether `node_id` (a layout leaf) currently hosts a live session.
|
||||
///
|
||||
/// This is the per-cell liveness the close-time snapshot uses: with the
|
||||
/// "one live session per agent" invariant in place, the same agent can be
|
||||
/// pinned on several leaves but be *live* in at most one — so liveness must
|
||||
/// be keyed on the hosting node, not the agent (otherwise a duplicate leaf
|
||||
/// would be wrongly marked as still running).
|
||||
fn is_node_live(&self, node_id: &NodeId) -> bool;
|
||||
}
|
||||
|
||||
/// In-memory registry of active terminal sessions.
|
||||
#[derive(Default)]
|
||||
pub struct TerminalSessions {
|
||||
entries: Mutex<HashMap<SessionId, Entry>>,
|
||||
/// Conversation → live session binding (cadrage C3 §5.2): «1 session vivante /
|
||||
/// **conversation**» (remplace «1 / agent»). Populated by the orchestrator when an
|
||||
/// ask resolves/launches the session for a given thread. Separate from `entries`
|
||||
/// (the domain [`TerminalSession`] does not carry a conversation id).
|
||||
conversations: Mutex<HashMap<ConversationId, SessionId>>,
|
||||
}
|
||||
|
||||
impl LiveAgentRegistry for TerminalSessions {
|
||||
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
|
||||
self.session_for_agent(agent_id).is_some()
|
||||
}
|
||||
|
||||
fn is_node_live(&self, node_id: &NodeId) -> bool {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| m.values().any(|e| e.session.node_id == *node_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalSessions {
|
||||
@ -31,9 +71,55 @@ impl TerminalSessions {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
conversations: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Binds `conversation` to the live `session` (cadrage C3 §5.2). Idempotent:
|
||||
/// re-binding the same conversation overwrites the target session.
|
||||
pub fn bind_conversation(&self, conversation: ConversationId, session: SessionId) {
|
||||
if let Ok(mut m) = self.conversations.lock() {
|
||||
m.insert(conversation, session);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the live [`SessionId`] bound to `conversation`, if any **and** still
|
||||
/// registered (a stale binding to a closed session resolves to `None`).
|
||||
///
|
||||
/// «1 session vivante / conversation» — deterministic, replacing the ambiguous
|
||||
/// per-agent lookup for the orchestrator's ask path.
|
||||
#[must_use]
|
||||
pub fn session_for(&self, conversation: ConversationId) -> Option<SessionId> {
|
||||
let sid = self
|
||||
.conversations
|
||||
.lock()
|
||||
.ok()?
|
||||
.get(&conversation)
|
||||
.copied()?;
|
||||
// Only return it if the session is still live in `entries`.
|
||||
self.entries
|
||||
.lock()
|
||||
.ok()
|
||||
.filter(|m| m.contains_key(&sid))
|
||||
.map(|_| sid)
|
||||
}
|
||||
|
||||
/// Lists every live [`SessionId`] hosting `agent_id` (cadrage C3 §1.2 — an agent
|
||||
/// may take part in several threads, so this is the **plural** of
|
||||
/// [`Self::session_for_agent`]).
|
||||
#[must_use]
|
||||
pub fn sessions_for_agent(&self, agent_id: &AgentId) -> Vec<SessionId> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.filter(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
|
||||
.map(|e| e.session.id)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Inserts a freshly-opened session.
|
||||
pub fn insert(&self, handle: PtyHandle, session: TerminalSession) {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
@ -59,6 +145,85 @@ impl TerminalSessions {
|
||||
.and_then(|m| m.get(id).map(|e| e.session.clone()))
|
||||
}
|
||||
|
||||
/// Returns the [`SessionId`] of the live session hosting a given agent, if any.
|
||||
///
|
||||
/// An agent runs in a session tagged [`SessionKind::Agent`]; this is the
|
||||
/// mapping the orchestrator's `stop_agent` uses to translate an agent id into
|
||||
/// the [`SessionId`] that `CloseTerminal` expects. Returns `None` when the
|
||||
/// agent has no live session (already stopped / never launched).
|
||||
///
|
||||
/// **Unambiguous by construction**: the "one live session per agent"
|
||||
/// invariant (enforced in [`crate::agent::LaunchAgent`]) guarantees at most
|
||||
/// one live session per agent, so the first match is *the* match. `find`
|
||||
/// short-circuits on it.
|
||||
#[must_use]
|
||||
pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
|
||||
self.entries.lock().ok().and_then(|m| {
|
||||
m.values()
|
||||
.find(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
|
||||
.map(|e| e.session.id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the [`NodeId`] of the live cell hosting a given agent, if any.
|
||||
///
|
||||
/// Companion to [`Self::session_for_agent`]: the launch guard needs the
|
||||
/// *host node* of an already-live agent to report it in
|
||||
/// [`crate::error::AppError::AgentAlreadyRunning`]. Unambiguous by the same
|
||||
/// one-live-session-per-agent invariant.
|
||||
#[must_use]
|
||||
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
|
||||
self.entries.lock().ok().and_then(|m| {
|
||||
m.values()
|
||||
.find(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
|
||||
.map(|e| e.session.node_id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Lists every currently-live agent, its current host cell and session id.
|
||||
///
|
||||
/// One `(AgentId, NodeId, SessionId)` tuple per session tagged [`SessionKind::Agent`].
|
||||
/// Used by the `list_live_agents` query so the UI can disable an agent that
|
||||
/// is already running elsewhere (it cannot be launched in a second cell).
|
||||
#[must_use]
|
||||
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.filter_map(|e| match e.session.kind {
|
||||
SessionKind::Agent { agent_id } => {
|
||||
Some((agent_id, e.session.node_id, e.session.id))
|
||||
}
|
||||
SessionKind::Plain => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.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.
|
||||
///
|
||||
/// Used at application shutdown to kill all live PTYs cleanly (the
|
||||
@ -71,8 +236,12 @@ impl TerminalSessions {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Removes a session from the registry, returning its handle if present.
|
||||
/// Removes a session from the registry, returning its handle if present. Also
|
||||
/// drops any conversation binding pointing at it (no stale `session_for`).
|
||||
pub fn remove(&self, id: &SessionId) -> Option<PtyHandle> {
|
||||
if let Ok(mut c) = self.conversations.lock() {
|
||||
c.retain(|_, sid| sid != id);
|
||||
}
|
||||
self.entries
|
||||
.lock()
|
||||
.ok()
|
||||
@ -91,3 +260,254 @@ impl TerminalSessions {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StructuredSessions — le jumeau de TerminalSessions pour les sessions IA (§17.5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Une session structurée enregistrée : la session vivante plus les coordonnées
|
||||
/// (agent + cellule hôte) que [`AgentSession`] ne porte pas lui-même.
|
||||
///
|
||||
/// `AgentSession` n'expose que `id()`/`conversation_id()` ; comme le snapshot
|
||||
/// [`TerminalSession`] côté PTY, on associe ici l'`agent_id` (clé de liveness) et
|
||||
/// le `node_id` (cellule-vue, rebindable) pour offrir la **même** surface que
|
||||
/// [`TerminalSessions`].
|
||||
struct StructuredEntry {
|
||||
/// La session vivante (ressource process/SDK), derrière le port domaine.
|
||||
session: Arc<dyn AgentSession>,
|
||||
/// L'agent IA pilotant cette session (invariant « 1 session vivante/agent »).
|
||||
agent_id: AgentId,
|
||||
/// La cellule (feuille de layout) qui héberge actuellement la vue.
|
||||
node_id: NodeId,
|
||||
}
|
||||
|
||||
/// Registre en mémoire des sessions IA structurées vivantes (ARCHITECTURE §17.5).
|
||||
///
|
||||
/// **Jumeau de [`TerminalSessions`]** : même rôle (état d'exécution applicatif, pas
|
||||
/// du modèle métier — cf. les docs de [`TerminalSessions`]), même surface côté
|
||||
/// liveness/agent (`session_for_agent`, `node_for_agent`, `live_agents`,
|
||||
/// `rebind_agent_node`, `insert`/`remove`/`session`). La seule différence : il
|
||||
/// stocke des `Arc<dyn AgentSession>` (sessions programmatiques) au lieu de
|
||||
/// [`PtyHandle`]/[`TerminalSession`].
|
||||
///
|
||||
/// Respecte l'invariant produit **« 1 session vivante par agent »** : la garde
|
||||
/// d'unicité (généralisée sur les deux registres via [`LiveSessions`]) interroge
|
||||
/// `session_for_agent` avant tout lancement.
|
||||
#[derive(Default)]
|
||||
pub struct StructuredSessions {
|
||||
entries: Mutex<HashMap<SessionId, StructuredEntry>>,
|
||||
}
|
||||
|
||||
impl LiveAgentRegistry for StructuredSessions {
|
||||
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
|
||||
self.session_for_agent(agent_id).is_some()
|
||||
}
|
||||
|
||||
fn is_node_live(&self, node_id: &NodeId) -> bool {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| m.values().any(|e| e.node_id == *node_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl StructuredSessions {
|
||||
/// Crée un registre vide.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enregistre une session fraîchement démarrée pour `agent_id`, hébergée par
|
||||
/// la cellule `node_id`. Clé par l'id de session ([`AgentSession::id`]).
|
||||
pub fn insert(&self, session: Arc<dyn AgentSession>, agent_id: AgentId, node_id: NodeId) {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
let id = session.id();
|
||||
map.insert(
|
||||
id,
|
||||
StructuredEntry {
|
||||
session,
|
||||
agent_id,
|
||||
node_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne la session enregistrée pour un id, si présente.
|
||||
#[must_use]
|
||||
pub fn session(&self, id: &SessionId) -> Option<Arc<dyn AgentSession>> {
|
||||
self.entries
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|m| m.get(id).map(|e| Arc::clone(&e.session)))
|
||||
}
|
||||
|
||||
/// Retourne la session vivante hébergeant `agent_id`, si elle existe.
|
||||
///
|
||||
/// Jumeau de [`TerminalSessions::session_for_agent`] : **non ambigu** par
|
||||
/// l'invariant « 1 session vivante/agent » — le premier match est *le* match.
|
||||
#[must_use]
|
||||
pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<Arc<dyn AgentSession>> {
|
||||
self.entries.lock().ok().and_then(|m| {
|
||||
m.values()
|
||||
.find(|e| &e.agent_id == agent_id)
|
||||
.map(|e| Arc::clone(&e.session))
|
||||
})
|
||||
}
|
||||
|
||||
/// Retourne l'[`SessionId`] de la session vivante hébergeant `agent_id`, si any.
|
||||
#[must_use]
|
||||
pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
|
||||
self.entries.lock().ok().and_then(|m| {
|
||||
m.values()
|
||||
.find(|e| &e.agent_id == agent_id)
|
||||
.map(|e| e.session.id())
|
||||
})
|
||||
}
|
||||
|
||||
/// Retourne le [`NodeId`] de la cellule vivante hébergeant `agent_id`, si any.
|
||||
///
|
||||
/// Jumeau de [`TerminalSessions::node_for_agent`].
|
||||
#[must_use]
|
||||
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
|
||||
self.entries.lock().ok().and_then(|m| {
|
||||
m.values()
|
||||
.find(|e| &e.agent_id == agent_id)
|
||||
.map(|e| e.node_id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Liste chaque agent IA vivant, sa cellule hôte et son id de session.
|
||||
///
|
||||
/// Jumeau de [`TerminalSessions::live_agents`] : un tuple
|
||||
/// `(AgentId, NodeId, SessionId)` par session structurée vivante.
|
||||
#[must_use]
|
||||
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.map(|e| (e.agent_id, e.node_id, e.session.id()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Rebinde la session vivante d'un agent vers une nouvelle cellule-vue sans
|
||||
/// redémarrer la conversation (« la cellule est une vue », §17.6).
|
||||
///
|
||||
/// Jumeau de [`TerminalSessions::rebind_agent_node`] : seul le `node_id`
|
||||
/// change ; la session, son id et sa conversation restent intacts. Retourne la
|
||||
/// session rebindée, ou `None` si l'agent n'a pas de session vivante.
|
||||
#[must_use]
|
||||
pub fn rebind_agent_node(
|
||||
&self,
|
||||
agent_id: &AgentId,
|
||||
node_id: NodeId,
|
||||
) -> Option<Arc<dyn AgentSession>> {
|
||||
self.entries.lock().ok().and_then(|mut m| {
|
||||
let entry = m.values_mut().find(|e| &e.agent_id == agent_id)?;
|
||||
entry.node_id = node_id;
|
||||
Some(Arc::clone(&entry.session))
|
||||
})
|
||||
}
|
||||
|
||||
/// Retire une session du registre, retournant la session si présente (pour que
|
||||
/// l'appelant la `shutdown` hors du verrou).
|
||||
pub fn remove(&self, id: &SessionId) -> Option<Arc<dyn AgentSession>> {
|
||||
self.entries
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut m| m.remove(id).map(|e| e.session))
|
||||
}
|
||||
|
||||
/// Retourne toutes les sessions vivantes (pour un arrêt global propre au
|
||||
/// shutdown applicatif, jumeau de [`TerminalSessions::handles`]).
|
||||
#[must_use]
|
||||
pub fn sessions(&self) -> Vec<Arc<dyn AgentSession>> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| m.values().map(|e| Arc::clone(&e.session)).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Nombre de sessions structurées vivantes.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.lock().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Si le registre est vide.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LiveSessions — agrégateur des deux registres derrière LiveAgentRegistry (§17.5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Agrégateur de liveness sur **les deux** registres (PTY + structuré).
|
||||
///
|
||||
/// Un agent terminal brut vit dans [`TerminalSessions`] ; un agent IA structuré vit
|
||||
/// dans [`StructuredSessions`]. La garde d'unicité et l'orchestrateur (§17.4)
|
||||
/// dépendent de cet agrégateur (ISP : ils ne voient que la capacité « liveness +
|
||||
/// résolution »), et une requête de liveness/agent voit donc les deux registres :
|
||||
/// un agent est vivant s'il a une session vivante dans **l'un OU l'autre**.
|
||||
///
|
||||
/// Implémente [`LiveAgentRegistry`] : le trait existant n'est **pas modifié** (ses
|
||||
/// implémenteurs et appelants actuels — `TerminalSessions`, le snapshot — restent
|
||||
/// inchangés), il est simplement **réalisé par un troisième implémenteur** qui
|
||||
/// agrège, ce qui *généralise* sa portée à l'ensemble PTY+structuré sans régression.
|
||||
pub struct LiveSessions {
|
||||
/// Registre des sessions terminal brut (PTY).
|
||||
pub pty: Arc<TerminalSessions>,
|
||||
/// Registre des sessions IA structurées.
|
||||
pub structured: Arc<StructuredSessions>,
|
||||
}
|
||||
|
||||
impl LiveSessions {
|
||||
/// Construit l'agrégateur à partir des deux registres partagés.
|
||||
#[must_use]
|
||||
pub fn new(pty: Arc<TerminalSessions>, structured: Arc<StructuredSessions>) -> Self {
|
||||
Self { pty, structured }
|
||||
}
|
||||
|
||||
/// L'[`SessionId`] de la session vivante d'un agent, PTY **ou** structurée.
|
||||
#[must_use]
|
||||
pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
|
||||
self.pty
|
||||
.session_for_agent(agent_id)
|
||||
.or_else(|| self.structured.session_id_for_agent(agent_id))
|
||||
}
|
||||
|
||||
/// La cellule hôte de la session vivante d'un agent, PTY **ou** structurée.
|
||||
#[must_use]
|
||||
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
|
||||
self.pty
|
||||
.node_for_agent(agent_id)
|
||||
.or_else(|| self.structured.node_for_agent(agent_id))
|
||||
}
|
||||
|
||||
/// Tous les agents vivants des deux registres (PTY puis structurés).
|
||||
#[must_use]
|
||||
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
|
||||
let mut all = self.pty.live_agents();
|
||||
all.extend(self.structured.live_agents());
|
||||
all
|
||||
}
|
||||
}
|
||||
|
||||
impl LiveAgentRegistry for LiveSessions {
|
||||
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
|
||||
self.pty.is_agent_live(agent_id) || self.structured.is_agent_live(agent_id)
|
||||
}
|
||||
|
||||
fn is_node_live(&self, node_id: &NodeId) -> bool {
|
||||
self.pty.is_node_live(node_id) || self.structured.is_node_live(node_id)
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,10 +67,7 @@ impl OpenTerminal {
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] for a non-absolute cwd or a zero-sized terminal,
|
||||
/// - [`AppError::Process`] if the PTY fails to spawn.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: OpenTerminalInput,
|
||||
) -> Result<OpenTerminalOutput, AppError> {
|
||||
pub async fn execute(&self, input: OpenTerminalInput) -> Result<OpenTerminalOutput, AppError> {
|
||||
let cwd = ProjectPath::new(input.cwd).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let size =
|
||||
PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
@ -82,6 +79,7 @@ impl OpenTerminal {
|
||||
cwd: cwd.clone(),
|
||||
env: Vec::new(),
|
||||
context_plan: None,
|
||||
sandbox: None,
|
||||
};
|
||||
|
||||
// The PTY layer owns the session identity; we adopt the returned handle's
|
||||
|
||||
381
crates/application/tests/agent_inspect.rs
Normal file
381
crates/application/tests/agent_inspect.rs
Normal file
@ -0,0 +1,381 @@
|
||||
//! T7 tests for the [`InspectConversation`] use case (best-effort conversation
|
||||
//! inspection feeding the resume popup).
|
||||
//!
|
||||
//! Each port is faked in-memory:
|
||||
//! - [`FakeContexts`] — an [`AgentContextStore`] holding a manifest seeded with
|
||||
//! one agent,
|
||||
//! - [`FakeProfiles`] — a [`ProfileStore`] returning a fixed profile list,
|
||||
//! - [`FakeInspector`] — a [`SessionInspector`] whose `supports`/`details` are
|
||||
//! configurable, so we can exercise: a supporting inspector returning details,
|
||||
//! one returning `NotFound`, and the empty-`Vec` (no inspector) path.
|
||||
//!
|
||||
//! The contract under test (CONTEXT §T7 Part A): inspection is **best-effort** —
|
||||
//! any miss (no inspector, unsupported profile, `NotFound`/`Read`) degrades to
|
||||
//! empty details, never an error; only a genuine store failure errors.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, ConversationDetails, InspectError, ProfileStore, SessionInspector,
|
||||
StoreError,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{InspectConversation, InspectConversationInput};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FakeContexts (AgentContextStore) — only load_manifest is exercised here
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakeContexts(Arc<Mutex<AgentManifest>>);
|
||||
|
||||
impl FakeContexts {
|
||||
fn with_agent(agent: &Agent) -> Self {
|
||||
Self(Arc::new(Mutex::new(AgentManifest {
|
||||
version: 1,
|
||||
entries: vec![ManifestEntry::from_agent(agent)],
|
||||
})))
|
||||
}
|
||||
fn empty() -> Self {
|
||||
Self(Arc::new(Mutex::new(AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentContextStore for FakeContexts {
|
||||
async fn read_context(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_agent: &AgentId,
|
||||
) -> Result<MarkdownDoc, StoreError> {
|
||||
Ok(MarkdownDoc::new(""))
|
||||
}
|
||||
async fn write_context(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_agent: &AgentId,
|
||||
_md: &MarkdownDoc,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
|
||||
Ok(self.0.lock().unwrap().clone())
|
||||
}
|
||||
async fn save_manifest(
|
||||
&self,
|
||||
_project: &Project,
|
||||
manifest: &AgentManifest,
|
||||
) -> Result<(), StoreError> {
|
||||
*self.0.lock().unwrap() = manifest.clone();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FakeProfiles (ProfileStore)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FakeProfiles(Arc<Vec<AgentProfile>>);
|
||||
|
||||
#[async_trait]
|
||||
impl ProfileStore for FakeProfiles {
|
||||
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
||||
Ok((*self.0).clone())
|
||||
}
|
||||
async fn save(&self, _profile: &AgentProfile) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn is_configured(&self) -> Result<bool, StoreError> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn mark_configured(&self) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FakeInspector (SessionInspector) — configurable support + result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// What the fake inspector returns from `details`.
|
||||
#[derive(Clone)]
|
||||
enum InspectResult {
|
||||
Ok(ConversationDetails),
|
||||
Err(InspectError),
|
||||
}
|
||||
|
||||
/// Shared probe recording the `(conversation_id, cwd)` an inspection was asked
|
||||
/// for (so a test can assert the run dir was routed, not the project root).
|
||||
type SeenProbe = Arc<Mutex<Option<(String, String)>>>;
|
||||
|
||||
struct FakeInspector {
|
||||
supports: bool,
|
||||
result: InspectResult,
|
||||
seen: SeenProbe,
|
||||
}
|
||||
|
||||
impl FakeInspector {
|
||||
fn new(supports: bool, result: InspectResult) -> (Arc<Self>, SeenProbe) {
|
||||
let seen = Arc::new(Mutex::new(None));
|
||||
let me = Arc::new(Self {
|
||||
supports,
|
||||
result,
|
||||
seen: Arc::clone(&seen),
|
||||
});
|
||||
(me, seen)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SessionInspector for FakeInspector {
|
||||
fn supports(&self, _profile: &AgentProfile) -> bool {
|
||||
self.supports
|
||||
}
|
||||
async fn details(
|
||||
&self,
|
||||
_profile: &AgentProfile,
|
||||
conversation_id: &str,
|
||||
cwd: &ProjectPath,
|
||||
) -> Result<ConversationDetails, InspectError> {
|
||||
*self.seen.lock().unwrap() = Some((conversation_id.to_owned(), cwd.as_str().to_owned()));
|
||||
match &self.result {
|
||||
InspectResult::Ok(d) => Ok(d.clone()),
|
||||
InspectResult::Err(e) => Err(e.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
||||
"demo",
|
||||
ProjectPath::new("/home/me/proj").unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn profile(id: ProfileId) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
id,
|
||||
"Claude Code",
|
||||
"claude",
|
||||
Vec::new(),
|
||||
ContextInjection::ConventionFile {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
},
|
||||
Some("claude --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn agent(id: AgentId, profile_id: ProfileId) -> Agent {
|
||||
Agent::new(
|
||||
id,
|
||||
"Bob",
|
||||
"agents/bob.md",
|
||||
profile_id,
|
||||
AgentOrigin::Scratch,
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn input(agent_id: AgentId) -> InspectConversationInput {
|
||||
InspectConversationInput {
|
||||
project: project(),
|
||||
agent_id,
|
||||
conversation_id: "conv-123".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (a) supporting inspector → details propagated
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn supporting_inspector_propagates_details() {
|
||||
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
|
||||
let aid = AgentId::from_uuid(Uuid::from_u128(42));
|
||||
let a = agent(aid, pid);
|
||||
|
||||
let (inspector, seen) = FakeInspector::new(
|
||||
true,
|
||||
InspectResult::Ok(ConversationDetails {
|
||||
last_topic: Some("refactor the parser".to_owned()),
|
||||
token_count: Some(4242),
|
||||
}),
|
||||
);
|
||||
|
||||
let uc = InspectConversation::new(
|
||||
Arc::new(FakeContexts::with_agent(&a)),
|
||||
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
|
||||
vec![inspector],
|
||||
);
|
||||
|
||||
let out = uc.execute(input(aid)).await.unwrap();
|
||||
assert_eq!(
|
||||
out.details.last_topic.as_deref(),
|
||||
Some("refactor the parser")
|
||||
);
|
||||
assert_eq!(out.details.token_count, Some(4242));
|
||||
|
||||
// Routed the conversation id and the agent's isolated run dir (not the root).
|
||||
let (conv, cwd) = seen.lock().unwrap().clone().expect("inspector was called");
|
||||
assert_eq!(conv, "conv-123");
|
||||
assert_eq!(cwd, format!("/home/me/proj/.ideai/run/{aid}"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (b) inspector returns NotFound → empty details, no error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn not_found_degrades_to_empty_details() {
|
||||
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
|
||||
let aid = AgentId::from_uuid(Uuid::from_u128(42));
|
||||
let a = agent(aid, pid);
|
||||
|
||||
let (inspector, _seen) = FakeInspector::new(true, InspectResult::Err(InspectError::NotFound));
|
||||
|
||||
let uc = InspectConversation::new(
|
||||
Arc::new(FakeContexts::with_agent(&a)),
|
||||
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
|
||||
vec![inspector],
|
||||
);
|
||||
|
||||
let out = uc.execute(input(aid)).await.unwrap();
|
||||
assert_eq!(out.details.last_topic, None);
|
||||
assert_eq!(out.details.token_count, None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (b') inspector returns Read error → empty details, no error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_error_degrades_to_empty_details() {
|
||||
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
|
||||
let aid = AgentId::from_uuid(Uuid::from_u128(42));
|
||||
let a = agent(aid, pid);
|
||||
|
||||
let (inspector, _seen) = FakeInspector::new(
|
||||
true,
|
||||
InspectResult::Err(InspectError::Read("boom".to_owned())),
|
||||
);
|
||||
|
||||
let uc = InspectConversation::new(
|
||||
Arc::new(FakeContexts::with_agent(&a)),
|
||||
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
|
||||
vec![inspector],
|
||||
);
|
||||
|
||||
let out = uc.execute(input(aid)).await.unwrap();
|
||||
assert_eq!(
|
||||
out.details,
|
||||
ConversationDetails {
|
||||
last_topic: None,
|
||||
token_count: None
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (c) empty Vec (no inspector) → empty details, no error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_inspector_yields_empty_details() {
|
||||
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
|
||||
let aid = AgentId::from_uuid(Uuid::from_u128(42));
|
||||
let a = agent(aid, pid);
|
||||
|
||||
let uc = InspectConversation::new(
|
||||
Arc::new(FakeContexts::with_agent(&a)),
|
||||
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
let out = uc.execute(input(aid)).await.unwrap();
|
||||
assert_eq!(out.details.last_topic, None);
|
||||
assert_eq!(out.details.token_count, None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (c') an inspector that does NOT support the profile is skipped → empty
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsupported_inspector_is_skipped() {
|
||||
let pid = ProfileId::from_uuid(Uuid::from_u128(7));
|
||||
let aid = AgentId::from_uuid(Uuid::from_u128(42));
|
||||
let a = agent(aid, pid);
|
||||
|
||||
let (inspector, seen) = FakeInspector::new(
|
||||
false, // does not support
|
||||
InspectResult::Ok(ConversationDetails {
|
||||
last_topic: Some("should never surface".to_owned()),
|
||||
token_count: Some(1),
|
||||
}),
|
||||
);
|
||||
|
||||
let uc = InspectConversation::new(
|
||||
Arc::new(FakeContexts::with_agent(&a)),
|
||||
Arc::new(FakeProfiles(Arc::new(vec![profile(pid)]))),
|
||||
vec![inspector],
|
||||
);
|
||||
|
||||
let out = uc.execute(input(aid)).await.unwrap();
|
||||
assert_eq!(out.details.last_topic, None);
|
||||
assert_eq!(out.details.token_count, None);
|
||||
// details() must not have been called.
|
||||
assert!(seen.lock().unwrap().is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Genuine resolution failures still error (unknown agent)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_agent_errors() {
|
||||
let aid = AgentId::from_uuid(Uuid::from_u128(99));
|
||||
let (inspector, _seen) = FakeInspector::new(
|
||||
true,
|
||||
InspectResult::Ok(ConversationDetails {
|
||||
last_topic: None,
|
||||
token_count: None,
|
||||
}),
|
||||
);
|
||||
|
||||
let uc = InspectConversation::new(
|
||||
Arc::new(FakeContexts::empty()),
|
||||
Arc::new(FakeProfiles(Arc::new(Vec::new()))),
|
||||
vec![inspector],
|
||||
);
|
||||
|
||||
let err = uc.execute(input(aid)).await.unwrap_err();
|
||||
// Should be a NOT_FOUND-class error, not a panic / empty success.
|
||||
assert_eq!(err.code(), "NOT_FOUND");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user