Compare commits
70 Commits
9b92259429
...
d7041c53ce
| Author | SHA1 | Date | |
|---|---|---|---|
| d7041c53ce | |||
| 3f3504efa3 | |||
| 5d9dd32c29 | |||
| c480d2820a | |||
| 4fad0423e7 | |||
| 9df592389c | |||
| ea94e756e2 | |||
| 98bfcf4f22 | |||
| 9000b4d09f | |||
| 253310bb3e | |||
| a1755e51bc | |||
| 0bf1eb3b11 | |||
| fa5b826df5 | |||
| 401c18ad3c | |||
| a04fbb7e1c | |||
| 69304b0f6b | |||
| 64ab3835c7 | |||
| 00bda6a988 | |||
| 31b636037d | |||
| 40bce5c8bf | |||
| ab9162f686 | |||
| 6236cd727b | |||
| 7e01ac60cb | |||
| 17ca65ed0f | |||
| 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 |
Submodule .claude/worktrees/agent-a2650e91d2bd39ca2 deleted from 480e7c7bbe
Submodule .claude/worktrees/agent-aeb1e862ef04b991b deleted from ef101db9dc
6
.gitignore
vendored
6
.gitignore
vendored
@ -32,6 +32,12 @@ frontend/coverage/
|
|||||||
# Ephemeral per-agent run directories (isolated PTY cwd + generated convention
|
# Ephemeral per-agent run directories (isolated PTY cwd + generated convention
|
||||||
# files), created at activation — not versioned (ARCHITECTURE §9.1 / §14.1).
|
# files), created at activation — not versioned (ARCHITECTURE §9.1 / §14.1).
|
||||||
.ideai/run/
|
.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 ───────────────────────────────────────────────────────────
|
# ─── Editors / OS ───────────────────────────────────────────────────────────
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@ -14,6 +14,34 @@
|
|||||||
"mdPath": "agents/architect.md",
|
"mdPath": "agents/architect.md",
|
||||||
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||||
"synchronized": false
|
"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": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5",
|
||||||
|
"name": "Git",
|
||||||
|
"mdPath": "agents/git.md",
|
||||||
|
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
|
||||||
|
"synchronized": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -338,6 +338,8 @@ RemoteRef =
|
|||||||
| `DetectAgentDrift` | Compare `synced_template_version` vs `template.version`. | `TemplateStore`, `AgentContextStore` |
|
| `DetectAgentDrift` | Compare `synced_template_version` vs `template.version`. | `TemplateStore`, `AgentContextStore` |
|
||||||
| `SyncAgentWithTemplate` | Applique la MAJ template→agent si `synchronized`. | `TemplateStore`, `AgentContextStore`, `EventBus` |
|
| `SyncAgentWithTemplate` | Applique la MAJ template→agent si `synchronized`. | `TemplateStore`, `AgentContextStore`, `EventBus` |
|
||||||
| `LaunchAgent` | Résout profil+contexte, prépare injection, ouvre cellule PTY au bon `cwd`, spawn CLI. | `AgentRuntime`, `AgentContextStore`, `RemoteHost`→`PtyPort`/`FileSystem`, `EventBus` |
|
| `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` |
|
| `OpenTerminal` | Ouvre un PTY simple dans une cellule. | `RemoteHost`→`PtyPort`, `EventBus` |
|
||||||
| `WriteToTerminal` / `ResizeTerminal` / `CloseTerminal` | I/O PTY. | `PtyPort` |
|
| `WriteToTerminal` / `ResizeTerminal` / `CloseTerminal` | I/O PTY. | `PtyPort` |
|
||||||
| `MutateLayout` (split/merge/resize/move) | Applique une opération sur le `LayoutTree` (logique **pure** dans le domaine, persistée ici). | `ProjectStore` (persistance) |
|
| `MutateLayout` (split/merge/resize/move) | Applique une opération sur le `LayoutTree` (logique **pure** dans le domaine, persistée ici). | `ProjectStore` (persistance) |
|
||||||
@ -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` |
|
| 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` |
|
| L10 | **Fenêtres & multi-window** | `Workspace`/`Window`/`Tab`, `MoveTabToNewWindow`, drag d'onglet → nouvelle fenêtre OS Tauri. | `application`, `app-tauri`, `frontend/app` |
|
||||||
| L11 | **Packaging & livraison** | Tauri bundle : NSIS `setup.exe`, **AppImage** multi-distro, CI Linux+Windows. | `app-tauri`, CI |
|
| L11 | **Packaging & livraison** | Tauri bundle : NSIS `setup.exe`, **AppImage** multi-distro, CI Linux+Windows. | `app-tauri`, CI |
|
||||||
|
| 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}` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
0
.ideai/agents/codextest.md
Normal file
0
.ideai/agents/codextest.md
Normal file
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.
|
||||||
116
.ideai/agents/git.md
Normal file
116
.ideai/agents/git.md
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
# Git — Agent de gestion du dépôt git local
|
||||||
|
|
||||||
|
> Tu es l'**agent Git** d'IdeA. Ton unique responsabilité est la **gestion du dépôt
|
||||||
|
> git local** : commits de l'application, création et bascule de branches, merges et
|
||||||
|
> rebases. Tu es le **seul** à décider de la topologie des branches et à manipuler
|
||||||
|
> l'historique local. Main te sollicite ; tu décides et tu exécutes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ton rôle (et ses limites)
|
||||||
|
|
||||||
|
Tu t'occupes **du local du repo git**, rien d'autre :
|
||||||
|
|
||||||
|
- **Commits** : tu transformes le travail réalisé par les agents de dev en commits
|
||||||
|
propres, atomiques, au bon endroit (bonne branche), avec des messages cohérents.
|
||||||
|
- **Branches** : tu **crées, checkout, switch** les branches selon ce qui est en cours.
|
||||||
|
- **Intégration** : tu **merges** et **rebases** les branches entre elles selon le
|
||||||
|
modèle ci-dessous.
|
||||||
|
- Tu **décides** : quand Main t'annonce une nouvelle feature (après cadrage Architect),
|
||||||
|
c'est **toi** qui tranches s'il faut une nouvelle branche, un checkout/switch, ou rien.
|
||||||
|
Après chaque implémentation, Main revient vers toi pour que tu décides si un **merge**
|
||||||
|
doit avoir lieu quelque part, ou non.
|
||||||
|
|
||||||
|
**Hors périmètre :**
|
||||||
|
- Tu **n'écris pas de code de feature** (c'est DevBackend/DevFrontend).
|
||||||
|
- Tu ne fais **aucune action sortante** (`push`, publication, création de PR distante)
|
||||||
|
sans validation explicite de Main / de l'utilisateur. Ton terrain est **local**.
|
||||||
|
- Tu ne prends pas de décision produit/archi : si un choix dépend de l'architecture,
|
||||||
|
tu remontes à Main.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Modèle de branches (git-flow simplifié)
|
||||||
|
|
||||||
|
Le dépôt s'articule autour de trois niveaux :
|
||||||
|
|
||||||
|
```
|
||||||
|
main ← branche de RELEASE. Stable, livrable. On n'y commite jamais en direct.
|
||||||
|
│
|
||||||
|
develop ← branche d'INTÉGRATION. On y merge chaque feature une fois TERMINÉE et VERTE.
|
||||||
|
│
|
||||||
|
feature/* ← une branche PAR nouvelle feature. C'est là que le dev se fait.
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`main`** : reçoit uniquement des releases (merge depuis `develop` quand on décide
|
||||||
|
de livrer). Jamais de dev direct.
|
||||||
|
- **`develop`** : base d'intégration. Toute feature terminée (tests verts) y est mergée.
|
||||||
|
C'est le point de départ de chaque nouvelle branche de feature.
|
||||||
|
- **`feature/<nom-court>`** : une branche par feature, créée **depuis `develop`**. Nom
|
||||||
|
dérivé du sujet de la feature (ex. `feature/sandbox-allow-fallback`,
|
||||||
|
`feature/sidebar-tabs-responsive`).
|
||||||
|
|
||||||
|
> Si le dépôt ne possède pas encore `main`/`develop`, c'est à toi de les établir
|
||||||
|
> proprement (création de `develop` depuis `main`) lors de ta première sollicitation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Le cycle, vu de Git
|
||||||
|
|
||||||
|
Tu interviens à **deux moments** du cycle de dev (cf. CLAUDE.md §3), encadré par Main :
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Main : « nouvelle feature X » (architecture cadrée par Architect)
|
||||||
|
→ TOI : décider de la branche.
|
||||||
|
- nouvelle feature indépendante → créer feature/X depuis develop, switch dessus
|
||||||
|
- reprise/extension d'un travail en cours → rester / switch sur la branche existante
|
||||||
|
- simple correctif sur une feature vivante → rester sur sa branche
|
||||||
|
→ tu annonces à Main sur quelle branche le dev va se faire.
|
||||||
|
|
||||||
|
2. Dev (DevBackend/DevFrontend) + Test (QA) implémentent sur cette branche.
|
||||||
|
|
||||||
|
3. Implémentation terminée → Main revient vers TOI :
|
||||||
|
→ committer le travail (commits atomiques, message clair) sur la branche de feature.
|
||||||
|
→ décider d'un éventuel merge :
|
||||||
|
- feature TERMINÉE et VERTE → merge feature/X → develop
|
||||||
|
(rebase préalable sur develop si l'historique a divergé, pour rester linéaire),
|
||||||
|
puis suppression de la branche de feature si plus utile.
|
||||||
|
- feature pas finie / tests KO → on NE merge PAS, on reste sur feature/X.
|
||||||
|
- décision de release → merge develop → main (sur validation explicite).
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règle d'or partagée** : aucune feature n'est mergée dans `develop` tant que ses
|
||||||
|
**tests ne passent pas**. Si on te demande de merger une feature rouge, tu refuses et
|
||||||
|
tu le dis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Conventions
|
||||||
|
|
||||||
|
- **Messages de commit** : en **français**, style Conventional Commits cohérent avec
|
||||||
|
l'historique : `feat(scope): …`, `fix(scope): …`, `chore(scope): …`, `docs(scope): …`,
|
||||||
|
`refactor(scope): …`. Corps multi-ligne expliquant le **pourquoi** quand utile.
|
||||||
|
- **Atomicité** : un commit = une intention cohérente. Tu sépares le code de feature de
|
||||||
|
l'état runtime (`.ideai/` conversations, layouts, manifestes) et des docs.
|
||||||
|
- **Co-author** : termine les messages de commit par
|
||||||
|
`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` (convention de l'environnement).
|
||||||
|
- **Branches** : `feature/<kebab-case>`, dérivé du sujet. Pas d'espaces, pas de majuscules.
|
||||||
|
- **Historique linéaire** privilégié sur les features : **rebase** avant merge quand la
|
||||||
|
base a avancé ; merge `--no-ff` vers `develop`/`main` pour garder la trace de
|
||||||
|
l'intégration de la feature.
|
||||||
|
- **Pas d'interactif** : pas de `rebase -i` / `add -i` (non supportés dans l'environnement).
|
||||||
|
- **Jamais** d'action destructive hors-projet ni de réécriture d'historique déjà poussé
|
||||||
|
sans validation explicite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Délégation & collaboration
|
||||||
|
|
||||||
|
- Tu réponds à Main via le protocole d'orchestration IdeA (`idea_reply`). Quand Main te
|
||||||
|
délègue une tâche (message `[IdeA · tâche de … · ticket …]`), tu traites puis tu
|
||||||
|
appelles **impérativement** `idea_reply(result=…)`.
|
||||||
|
- Tu rends compte clairement : branche courante, ce que tu as committé (hash + message
|
||||||
|
court), ce que tu as mergé/rebasé, et **ta décision** (pourquoi cette branche, pourquoi
|
||||||
|
ce merge ou ce non-merge).
|
||||||
|
- En cas de conflit de merge/rebase, tu le signales à Main avec le détail ; tu ne forces
|
||||||
|
pas une résolution hasardeuse.
|
||||||
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,14 @@
|
|||||||
|
---
|
||||||
|
upTo: a6169530-fc73-4057-91c6-900da4a611fb
|
||||||
|
objective: [Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu reçois ce message, réponds via idea_reply avec : (1) "DevFrontend OK — pont inter-agent fonctionnel", (2) le résultat de 144 ÷ 12, e
|
||||||
|
---
|
||||||
|
**Objectif :** [Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu reçois ce message, réponds via idea_reply avec : (1) "DevFrontend OK — pont inter-agent fonctionnel", (2) le résultat de 144 ÷ 12, e
|
||||||
|
|
||||||
|
- **Prompt:** [Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu reçois ce message, réponds via idea_reply avec : (1) "DevFrontend OK — pont inter-agent fonctionnel", (2) le résultat de 144 ÷ 12, et (3) l'heure courante. Aucune autre action requise.
|
||||||
|
- **Prompt:** Test de conversation inter-agent. Calcule 123 × 4 et réponds-moi simplement avec le résultat via idea_reply.
|
||||||
|
- **Response:** 123 × 4 = 492
|
||||||
|
- **Prompt:** Le chantier permissions backend (LP3, projection vers les CLI Claude/Codex + swap) est terminé et `cargo test --workspace` est 100% vert. Mais `npx vitest run` côté frontend a 4 échecs, signalés comme pré-existants liés au chantier UI permissions en cours (gateway permissions ajoutée, PermissionsPanel monté dans ProjectsView). Diagnostique-les et corrige-les pour remettre le frontend au vert. Lance d'abord : `cd frontend && npx vitest run` pour voir l'état réel. Les 4 échecs rapportés : 1. `mock.test.ts > exposes all thirteen gateways` — le mock expose désormais une gateway de plus (permissions ⇒ 14). Mets à jour le test ET vérifie que le mock (`frontend/src/adapters/mock/index.ts`) expose bien une gateway permissions cohérente avec le port réel (`TauriPermissionGateway`). Le titre du test (« thirteen ») et le compte attendu doivent refléter le vrai nombre de gateways. 2. `LayoutGrid.chat.test.tsx` (2 échecs) — routage cellule terminal/chat. 3. `agentAlreadyRunning.test.tsx` (1 échec) — notice R0d. POUR CHAQUE échec : déterminer si c'est (a) un test à mettre à jour parce que le comportement/contrat a légitimement changé avec le feature permissions, ou (b) une vraie régression du code à corriger. Explique ton verdict pour chacun. Ne « fais pas passer » un test en masquant un vrai bug — si 2 et 3 ne sont pas liés aux permissions et révèlent une régression réelle, dis-le clairement plutôt que de les bricoler. Vérifie aussi qu'il n'y a pas d'incohérence entre le port permissions (`frontend/src/ports/index.ts`), l'adapter Tauri (`frontend/src/adapters/permission.ts`), le mock, et les types domaine (`frontend/src/domain/index.ts`) — les 4 doivent être alignés. Quand c'est fini, réponds via idea_reply : verdict par échec (test à jour vs régression corrigée), ce que tu as changé, et la SORTIE RÉELLE de `npx vitest run` (doit être 100% vert). Si un échec est une vraie régression hors de ton périmètre permissions, signale-le précisément.
|
||||||
|
- **Prompt:** [Test de communication inter-agent] Ping. Réponds simplement via idea_reply avec « pong DevFrontend OK » pour confirmer que le canal fonctionne.
|
||||||
|
- **Response:** pong DevFrontend OK
|
||||||
|
- **Prompt:** Tâche : nettoyage/correction de bugs d'affichage UI (frontend React/TS). Project root : /home/anthony/Documents/Projects/IdeA. Aucune nouvelle feature, uniquement du fix CSS/layout. Voici 3 problèmes constatés visuellement (captures non transmissibles, je les décris) : PROBLÈME 1 — Barre de navigation horizontale (top bar listant : Projects, Context, Agents, Templates, Skills, Permissions, …). - Cette barre n'est PAS scrollable et n'est PAS correctement mise à l'échelle. Quand il y a trop d'entrées, les derniers items débordent et sont coupés (on voit « Skills » puis « P… » tronqué, « Permissions » coupé). - Attendu : la barre doit gérer le débordement proprement — soit scroll horizontal (overflow-x:auto avec masquage de scrollbar propre), soit wrap/scale correct. Les entrées ne doivent jamais être croppées. Vérifier flex-shrink/min-width des items. PROBLÈME 2 — Panneau « Agents » (liste des agents : Main, Architect, DevBackend, DevFrontend, QA, TestConversation, …). - Les items de la liste s'overlappent (chevauchement vertical) et sont croppés. Le nom de l'agent, le profil (« Claude Code »), l'id de run (longue chaîne en bleu qui wrap mal sur plusieurs lignes), la dropdown de profil et les boutons Stop/Delete/Launch se chevauchent et débordent du panneau. - Attendu : chaque carte d'agent doit avoir une hauteur qui s'adapte à son contenu (pas de height fixe qui cause l'overlap), les éléments alignés proprement, l'id long tronqué avec ellipsis ou wrap contrôlé, et la dropdown + boutons ne doivent pas déborder/chevaucher le texte voisin. Vérifier les position absolute éventuels et les gaps flex/grid. PROBLÈME 3 — Formulaire de création d'agent (champs : Name, Template, Profile, bouton Create). - Les champs du formulaire s'overlappent les uns sur les autres (Name / Template / Profile trop serrés/superposés). - BUG fonctionnel : quand on sélectionne une valeur dans les dropdowns (Template / Profile), la sélection NE s'affiche PAS dans le champ de la droplist (le label sélectionné ne se reflète pas visuellement). À investiguer : binding value/onChange du composant select, ou affichage du label sélectionné si c'est un dropdown custom. - Attendu : formulaire avec espacement correct (pas de chevauchement), et les dropdowns affichent bien la valeur choisie. Merci de : 1. Localiser les composants concernés (top nav bar, panneau Agents, formulaire création d'agent). 2. Corriger le CSS/layout et le bug de binding des dropdowns. 3. Lancer un build frontend pour t'assurer que ça compile (npm run build ou équivalent), sans casser le reste. 4. Me renvoyer via idea_reply la liste des fichiers modifiés + un résumé des corrections, pour que je puisse enchaîner sur le rebuild de l'AppImage.
|
||||||
|
- **Response:** Bugs d'affichage corrigés. Build vert (`npm run build` = tsc --noEmit + vite build OK) et tests verts (`vitest run` agents + projects : 47/47 passent). ## Fichiers modifiés (2) - `frontend/src/features/projects/ProjectsView.tsx` — la « top bar » - `frontend/src/features/agents/AgentsPanel.tsx` — panneau Agents + formulaire de création ## Diagnostic La « top bar » décrite n'est pas une nav OS mais le **strip de tabs du sidebar** (`SIDEBAR_TABS` : Projects, Context, Agents, Templates, Skills, Perms, Memory, Git) dans `ProjectsView.tsx`. Cause racine commune aux 3 problèmes : **largeur trop contrainte** dans le sidebar de 320px (`w-80`). ## Corrections **PROBLÈME 1 — barre de tabs croppée** - Avant : 8 boutons en `flex-1` sans gestion de débordement → les derniers labels coupés. - Après : les tabs sont dans un conteneur `overflow-x-auto` **scrollable horizontalement**, chaque tab passe en `shrink-0 whitespace-nowrap` (plus jamais croppé). Scrollbar masquée proprement (`[scrollbar-width:none]` + `[&::-webkit-scrollbar]:hidden`). Le bouton « collapse » («) reste épinglé à droite, hors zone de scroll. **PROBLÈME 2 — cartes d'agents qui se chevauchent** - Avant : `<li>` en `flex items-center justify-between` → le bloc nom/profil et la colonne de contrôles (dropdown profil + Sync/Stop/Launch/Delete, en `shrink-0`) se disputaient la largeur ; l'id de run long (`running in IdeA · <id>`) wrappait et débordait. - Après : carte en **`flex-col`** — bloc nom/profil/statut en haut, rangée de contrôles en dessous (`flex-wrap`, plus de `shrink-0` qui écrasait le texte). Hauteur adaptative au contenu (plus de chevauchement). L'id long est désormais **tronqué avec ellipsis** (`block w-full min-w-0 truncate`). **PROBLÈME 3 — formulaire de création (champs serrés + dropdowns « vides »)** - Avant : form en `flex flex-wrap items-end` avec 3 champs `min-w-0 flex-1` → selects écrasés à ~90px ; le texte sélectionné (« Claude Code ») était **clippé derrière la flèche native** du select, d'où l'impression que la sélection ne s'affichait pas. - Après : form en **`flex-col gap-3`**, chaque champ en pleine largeur. Le binding des `<select>` était déjà correct (`value`/`onChange` contrôlés, natifs) — c'était bien un problème de largeur, pas de binding. En pleine largeur la valeur choisie s'affiche normalement. Bouton Create passé en `self-end`. Aucune nouvelle dépendance, aucun changement de logique métier ni de contrat IPC/gateway. Les tests s'appuient sur les `aria-label` (inchangés), d'où les 47/47 verts. Tu peux enchaîner sur le rebuild de l'AppImage (rappel mémoire : c'est l'AppImage qui tourne qu'il faut reconstruire, pas seulement les sources).
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
{"id":"f473c5bc-e6b6-4efd-927d-452cb587fac2","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781517530284,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu reçois ce message, réponds via idea_reply avec : (1) \"DevFrontend OK — pont inter-agent fonctionnel\", (2) le résultat de 144 ÷ 12, et (3) l'heure courante. Aucune autre action requise."}
|
||||||
|
{"id":"63f0981d-a083-4b21-97fb-e5d681b99468","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781522526877,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de conversation inter-agent. Calcule 123 × 4 et réponds-moi simplement avec le résultat via idea_reply."}
|
||||||
|
{"id":"223e1427-7aad-4de2-ab3a-ac08f90e1256","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781522534227,"source":{"kind":"agent","agentId":"af7f86da-76bc-48e1-9900-71f45a624800"},"role":"response","text":"123 × 4 = 492"}
|
||||||
|
{"id":"42327a10-fc63-48f7-a943-f89c9647ac42","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781526082185,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Le chantier permissions backend (LP3, projection vers les CLI Claude/Codex + swap) est terminé et `cargo test --workspace` est 100% vert. Mais `npx vitest run` côté frontend a 4 échecs, signalés comme pré-existants liés au chantier UI permissions en cours (gateway permissions ajoutée, PermissionsPanel monté dans ProjectsView). Diagnostique-les et corrige-les pour remettre le frontend au vert.\n\nLance d'abord : `cd frontend && npx vitest run` pour voir l'état réel.\n\nLes 4 échecs rapportés :\n1. `mock.test.ts > exposes all thirteen gateways` — le mock expose désormais une gateway de plus (permissions ⇒ 14). Mets à jour le test ET vérifie que le mock (`frontend/src/adapters/mock/index.ts`) expose bien une gateway permissions cohérente avec le port réel (`TauriPermissionGateway`). Le titre du test (« thirteen ») et le compte attendu doivent refléter le vrai nombre de gateways.\n2. `LayoutGrid.chat.test.tsx` (2 échecs) — routage cellule terminal/chat.\n3. `agentAlreadyRunning.test.tsx` (1 échec) — notice R0d.\n\nPOUR CHAQUE échec : déterminer si c'est (a) un test à mettre à jour parce que le comportement/contrat a légitimement changé avec le feature permissions, ou (b) une vraie régression du code à corriger. Explique ton verdict pour chacun. Ne « fais pas passer » un test en masquant un vrai bug — si 2 et 3 ne sont pas liés aux permissions et révèlent une régression réelle, dis-le clairement plutôt que de les bricoler.\n\nVérifie aussi qu'il n'y a pas d'incohérence entre le port permissions (`frontend/src/ports/index.ts`), l'adapter Tauri (`frontend/src/adapters/permission.ts`), le mock, et les types domaine (`frontend/src/domain/index.ts`) — les 4 doivent être alignés.\n\nQuand c'est fini, réponds via idea_reply : verdict par échec (test à jour vs régression corrigée), ce que tu as changé, et la SORTIE RÉELLE de `npx vitest run` (doit être 100% vert). Si un échec est une vraie régression hors de ton périmètre permissions, signale-le précisément."}
|
||||||
|
{"id":"2a7f8f6d-3163-496a-a5b8-764a4229a40f","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781544382139,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"[Test de communication inter-agent] Ping. Réponds simplement via idea_reply avec « pong DevFrontend OK » pour confirmer que le canal fonctionne."}
|
||||||
|
{"id":"6a286e41-aa26-4bcb-8530-282bd09dde95","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781544388479,"source":{"kind":"agent","agentId":"af7f86da-76bc-48e1-9900-71f45a624800"},"role":"response","text":"pong DevFrontend OK"}
|
||||||
|
{"id":"808e5ed2-57d7-4839-906e-bd66886852b7","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781593284048,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tâche : nettoyage/correction de bugs d'affichage UI (frontend React/TS). Project root : /home/anthony/Documents/Projects/IdeA. Aucune nouvelle feature, uniquement du fix CSS/layout. Voici 3 problèmes constatés visuellement (captures non transmissibles, je les décris) :\n\nPROBLÈME 1 — Barre de navigation horizontale (top bar listant : Projects, Context, Agents, Templates, Skills, Permissions, …).\n- Cette barre n'est PAS scrollable et n'est PAS correctement mise à l'échelle. Quand il y a trop d'entrées, les derniers items débordent et sont coupés (on voit « Skills » puis « P… » tronqué, « Permissions » coupé).\n- Attendu : la barre doit gérer le débordement proprement — soit scroll horizontal (overflow-x:auto avec masquage de scrollbar propre), soit wrap/scale correct. Les entrées ne doivent jamais être croppées. Vérifier flex-shrink/min-width des items.\n\nPROBLÈME 2 — Panneau « Agents » (liste des agents : Main, Architect, DevBackend, DevFrontend, QA, TestConversation, …).\n- Les items de la liste s'overlappent (chevauchement vertical) et sont croppés. Le nom de l'agent, le profil (« Claude Code »), l'id de run (longue chaîne en bleu qui wrap mal sur plusieurs lignes), la dropdown de profil et les boutons Stop/Delete/Launch se chevauchent et débordent du panneau.\n- Attendu : chaque carte d'agent doit avoir une hauteur qui s'adapte à son contenu (pas de height fixe qui cause l'overlap), les éléments alignés proprement, l'id long tronqué avec ellipsis ou wrap contrôlé, et la dropdown + boutons ne doivent pas déborder/chevaucher le texte voisin. Vérifier les position absolute éventuels et les gaps flex/grid.\n\nPROBLÈME 3 — Formulaire de création d'agent (champs : Name, Template, Profile, bouton Create).\n- Les champs du formulaire s'overlappent les uns sur les autres (Name / Template / Profile trop serrés/superposés).\n- BUG fonctionnel : quand on sélectionne une valeur dans les dropdowns (Template / Profile), la sélection NE s'affiche PAS dans le champ de la droplist (le label sélectionné ne se reflète pas visuellement). À investiguer : binding value/onChange du composant select, ou affichage du label sélectionné si c'est un dropdown custom.\n- Attendu : formulaire avec espacement correct (pas de chevauchement), et les dropdowns affichent bien la valeur choisie.\n\nMerci de :\n1. Localiser les composants concernés (top nav bar, panneau Agents, formulaire création d'agent).\n2. Corriger le CSS/layout et le bug de binding des dropdowns.\n3. Lancer un build frontend pour t'assurer que ça compile (npm run build ou équivalent), sans casser le reste.\n4. Me renvoyer via idea_reply la liste des fichiers modifiés + un résumé des corrections, pour que je puisse enchaîner sur le rebuild de l'AppImage."}
|
||||||
|
{"id":"a6169530-fc73-4057-91c6-900da4a611fb","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781593486944,"source":{"kind":"agent","agentId":"af7f86da-76bc-48e1-9900-71f45a624800"},"role":"response","text":"Bugs d'affichage corrigés. Build vert (`npm run build` = tsc --noEmit + vite build OK) et tests verts (`vitest run` agents + projects : 47/47 passent).\n\n## Fichiers modifiés (2)\n- `frontend/src/features/projects/ProjectsView.tsx` — la « top bar »\n- `frontend/src/features/agents/AgentsPanel.tsx` — panneau Agents + formulaire de création\n\n## Diagnostic\nLa « top bar » décrite n'est pas une nav OS mais le **strip de tabs du sidebar** (`SIDEBAR_TABS` : Projects, Context, Agents, Templates, Skills, Perms, Memory, Git) dans `ProjectsView.tsx`. Cause racine commune aux 3 problèmes : **largeur trop contrainte** dans le sidebar de 320px (`w-80`).\n\n## Corrections\n\n**PROBLÈME 1 — barre de tabs croppée**\n- Avant : 8 boutons en `flex-1` sans gestion de débordement → les derniers labels coupés.\n- Après : les tabs sont dans un conteneur `overflow-x-auto` **scrollable horizontalement**, chaque tab passe en `shrink-0 whitespace-nowrap` (plus jamais croppé). Scrollbar masquée proprement (`[scrollbar-width:none]` + `[&::-webkit-scrollbar]:hidden`). Le bouton « collapse » («) reste épinglé à droite, hors zone de scroll.\n\n**PROBLÈME 2 — cartes d'agents qui se chevauchent**\n- Avant : `<li>` en `flex items-center justify-between` → le bloc nom/profil et la colonne de contrôles (dropdown profil + Sync/Stop/Launch/Delete, en `shrink-0`) se disputaient la largeur ; l'id de run long (`running in IdeA · <id>`) wrappait et débordait.\n- Après : carte en **`flex-col`** — bloc nom/profil/statut en haut, rangée de contrôles en dessous (`flex-wrap`, plus de `shrink-0` qui écrasait le texte). Hauteur adaptative au contenu (plus de chevauchement). L'id long est désormais **tronqué avec ellipsis** (`block w-full min-w-0 truncate`).\n\n**PROBLÈME 3 — formulaire de création (champs serrés + dropdowns « vides »)**\n- Avant : form en `flex flex-wrap items-end` avec 3 champs `min-w-0 flex-1` → selects écrasés à ~90px ; le texte sélectionné (« Claude Code ») était **clippé derrière la flèche native** du select, d'où l'impression que la sélection ne s'affichait pas.\n- Après : form en **`flex-col gap-3`**, chaque champ en pleine largeur. Le binding des `<select>` était déjà correct (`value`/`onChange` contrôlés, natifs) — c'était bien un problème de largeur, pas de binding. En pleine largeur la valeur choisie s'affiche normalement. Bouton Create passé en `self-end`.\n\nAucune nouvelle dépendance, aucun changement de logique métier ni de contrat IPC/gateway. Les tests s'appuient sur les `aria-label` (inchangés), d'où les 47/47 verts.\n\nTu peux enchaîner sur le rebuild de l'AppImage (rappel mémoire : c'est l'AppImage qui tourne qu'il faut reconstruire, pas seulement les sources)."}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
upTo: 190cd7b8-23ad-42cb-8873-a17ddddff71d
|
||||||
|
objective: Petit test de communication inter-agents. Calcule 17 × 23 et donne-moi uniquement le résultat numérique. Réponds-moi impérativement via l'outil idea_reply (pas en texte libre).
|
||||||
|
---
|
||||||
|
**Objectif :** Petit test de communication inter-agents. Calcule 17 × 23 et donne-moi uniquement le résultat numérique. Réponds-moi impérativement via l'outil idea_reply (pas en texte libre).
|
||||||
|
|
||||||
|
- **Prompt:** Petit test de communication inter-agents. Calcule 17 × 23 et donne-moi uniquement le résultat numérique. Réponds-moi impérativement via l'outil idea_reply (pas en texte libre).
|
||||||
|
- **Response:** 391
|
||||||
|
- **Prompt:** Test de communication inter-agents. Calcule 17 × 23 et réponds-moi UNIQUEMENT le résultat numérique, impérativement via l'outil idea_reply (jamais en texte libre).
|
||||||
|
- **Prompt:** Test de conversation inter-agent. Peux-tu calculer (347 × 28) + 1569 et me retourner uniquement le résultat numérique avec le détail du calcul ? Réponds via idea_reply.
|
||||||
|
- **Response:** 347 × 28 = 9716 ; 9716 + 1569 = 11285
|
||||||
|
- **Prompt:** Test de conversation inter-agent. Calcule 78 × 9 et réponds-moi simplement avec le résultat via idea_reply.
|
||||||
|
- **Response:** 702
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
{"id":"3cbaced3-dd78-4f48-b168-f09a26d83444","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781384306910,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Petit test de communication inter-agents. Calcule 17 × 23 et donne-moi uniquement le résultat numérique. Réponds-moi impérativement via l'outil idea_reply (pas en texte libre)."}
|
||||||
|
{"id":"2dd9de9a-d59d-493e-bf07-ffccf6addeb1","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781386172145,"source":{"kind":"agent","agentId":"93dc1f5f-921e-44a2-a7e7-5173d87c1a58"},"role":"response","text":"391"}
|
||||||
|
{"id":"34916afd-1054-4257-805f-9c5b3ed5f7f3","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781386172204,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de communication inter-agents. Calcule 17 × 23 et réponds-moi UNIQUEMENT le résultat numérique, impérativement via l'outil idea_reply (jamais en texte libre)."}
|
||||||
|
{"id":"6e037034-c65a-4eca-b494-06420c5eed06","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781386464926,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de conversation inter-agent. Peux-tu calculer (347 × 28) + 1569 et me retourner uniquement le résultat numérique avec le détail du calcul ? Réponds via idea_reply."}
|
||||||
|
{"id":"f82caa94-daba-4171-aa1f-8f69f71671c4","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781386489560,"source":{"kind":"agent","agentId":"93dc1f5f-921e-44a2-a7e7-5173d87c1a58"},"role":"response","text":"347 × 28 = 9716 ; 9716 + 1569 = 11285"}
|
||||||
|
{"id":"6187c292-4d9a-4c07-9381-732486ab6d03","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781522784017,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Test de conversation inter-agent. Calcule 78 × 9 et réponds-moi simplement avec le résultat via idea_reply."}
|
||||||
|
{"id":"190cd7b8-23ad-42cb-8873-a17ddddff71d","conversation":"3512c746-2a8d-06b1-17e4-cfee1fe78c19","atMs":1781522789866,"source":{"kind":"agent","agentId":"93dc1f5f-921e-44a2-a7e7-5173d87c1a58"},"role":"response","text":"702"}
|
||||||
@ -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é. ✅"}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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,7 @@
|
|||||||
|
---
|
||||||
|
upTo: 419c60a5-2aca-4711-ad83-0a185bb3214e
|
||||||
|
objective: Ping de test inter-agent depuis DevBackend — réponds avec un court PONG et l'heure que tu vois.
|
||||||
|
---
|
||||||
|
**Objectif :** Ping de test inter-agent depuis DevBackend — réponds avec un court PONG et l'heure que tu vois.
|
||||||
|
|
||||||
|
- **Prompt:** Ping de test inter-agent depuis DevBackend — réponds avec un court PONG et l'heure que tu vois.
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{"id":"419c60a5-2aca-4711-ad83-0a185bb3214e","conversation":"dd35eeb0-2329-0dfa-3255-df7dba90a0aa","atMs":1781427081894,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"prompt","text":"Ping de test inter-agent depuis DevBackend — réponds avec un court PONG et l'heure que tu vois."}
|
||||||
@ -1,56 +1,45 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"activeId": "9188db80-8535-4786-a20b-3c9a36b222e3",
|
"activeId": "dd38b8ed-7199-48a6-a63f-1d1462ca6a34",
|
||||||
"layouts": [
|
"layouts": [
|
||||||
{
|
{
|
||||||
"id": "9188db80-8535-4786-a20b-3c9a36b222e3",
|
"id": "dd38b8ed-7199-48a6-a63f-1d1462ca6a34",
|
||||||
"name": "Default",
|
"name": "Default",
|
||||||
"kind": "terminal",
|
"kind": "terminal",
|
||||||
"tree": {
|
"tree": {
|
||||||
"root": {
|
"root": {
|
||||||
"type": "split",
|
"type": "split",
|
||||||
"node": {
|
"node": {
|
||||||
"id": "8aca2f93-1a9b-4693-9bba-9a01e130a48c",
|
"id": "2ea41e76-262d-4dc3-a674-348ceac9b543",
|
||||||
"direction": "row",
|
"direction": "row",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"node": {
|
"node": {
|
||||||
"type": "leaf",
|
"type": "leaf",
|
||||||
"node": {
|
"node": {
|
||||||
"id": "d8a86eb1-cd4d-4937-b900-4989da7c868d",
|
"id": "14d20dcb-f243-4381-a130-2d4d3d224f42",
|
||||||
"session": "8a976ad4-72d5-4dfa-9176-b0838e8e7e1d",
|
"session": "4e60d6c2-6be6-499c-a296-577ce1e5ec62",
|
||||||
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641"
|
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641",
|
||||||
|
"agentWasRunning": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"weight": 0.9225053
|
"weight": 1.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"node": {
|
"node": {
|
||||||
"type": "leaf",
|
"type": "leaf",
|
||||||
"node": {
|
"node": {
|
||||||
"id": "6c5be5e7-a54b-468c-a2e2-8ec853629d5e",
|
"id": "71f5cc81-9bef-4b2b-b7dc-2e0a5a4eafcb",
|
||||||
"session": "b6ef5e09-411b-4da4-93a7-d32ecd4bb750"
|
"session": "bb08a08e-71f3-4ab2-842c-71a8da5c74b4",
|
||||||
|
"agent": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"weight": 1.0774947
|
"weight": 1.0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "40ea4fa9-25b3-410b-9d3e-6350834b421b",
|
|
||||||
"name": "Git Graph",
|
|
||||||
"kind": "gitGraph",
|
|
||||||
"tree": {
|
|
||||||
"root": {
|
|
||||||
"type": "leaf",
|
|
||||||
"node": {
|
|
||||||
"id": "c840bfdd-3330-46a3-b727-0799f6853e72"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
8
.ideai/memory/MEMORY.md
Normal file
8
.ideai/memory/MEMORY.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# 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).
|
||||||
|
- [permissions-sandbox-system-state](permissions-sandbox-system-state.md) — Systeme de permissions/sandbox complet (Landlock sur PTY + structure) et le risque residuel $HOME/resume du chemin structure.
|
||||||
|
- [session-limit-handling-design](session-limit-handling-design.md) — Design valide (detecteur hierarchique + reprise auto annulable) pour les limites de session des agents.
|
||||||
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]].
|
||||||
26
.ideai/memory/permissions-sandbox-system-state.md
Normal file
26
.ideai/memory/permissions-sandbox-system-state.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: permissions-sandbox-system-state
|
||||||
|
description: Etat du systeme de permissions/sandbox IdeA (domaine pur -> projection advisory -> enforcement OS Landlock sur PTY ET structure) et l'unique risque residuel.
|
||||||
|
metadata:
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
# Systeme de permissions / sandbox IdeA
|
||||||
|
|
||||||
|
Chantier "permissions des agents" complet bout-en-bout au 2026-06-16, sur la branche `wip/p8c-checkpoint-before-codex`. Lots committes : LP4-0 (`b05d04a`), LP4-1/LP4-2 (`17ca65e`), LP4-3 (`7e01ac6`), LP4-4 (`6236cd7`).
|
||||||
|
|
||||||
|
## Acquis (tout vert, ~80 suites)
|
||||||
|
|
||||||
|
- **Domaine pur** (`crates/domain/src/permission.rs`, `sandbox.rs`) : modele declaratif (`Capability`, `Effect`, `Posture` Allow<Ask<Deny, deny-wins), `resolve()` (invariant produit : rien pose => `None` => CLI garde son prompting natif), `compile_sandbox_plan(eff, ctx) -> Option<SandboxPlan>`, port `SandboxEnforcer`, `render_permission_summary` (honnetete : fichiers = OS-enforced, commandes = advisory).
|
||||||
|
- **Store** : `.ideai/permissions.json` (separe d'`agents.json` : securite projet, pas identite).
|
||||||
|
- **Projection advisory LP3** : projecteurs Claude (`settings.json`) + Codex (sandbox/`config.toml`).
|
||||||
|
- **Enforcement OS LP4** : `LandlockSandbox` (Linux) / `NoopSandbox` / `default_enforcer()`. Actif sur **les deux chemins** : PTY brut (`pty/mod.rs` `spawn_command_sandboxed`) ET structure Claude/Codex JSON (`session/process.rs` `run_turn_sandboxed`).
|
||||||
|
|
||||||
|
## Technique d'enforcement (load-bearing, ne pas casser)
|
||||||
|
|
||||||
|
`enforce(plan)` tourne sur un **thread jetable AVANT le spawn**, puis le child est spawne depuis ce thread => il herite le domaine Landlock via les credentials de la tache (garanti par le noyau a travers fork/clone/execve, y compris posix_spawn). **Pas de `pre_exec`** : `infrastructure` est `#![forbid(unsafe_code)]` (intact), et le pre_exec n'aurait ete qu'une assurance de determinisme, sans valeur de securite ; allouer dans `landlock` post-fork (pre_exec) serait au contraire dangereux (deadlock malloc en process multithreade). Fail-closed : `Err` d'enforce => aucun child. Le garde-fou anti-regression est le test e2e **"ecriture hors-grant bloquee"** (present pour PTY et structure) : tant qu'il est vert, l'heritage tient.
|
||||||
|
|
||||||
|
## Risque residuel a traiter (signale par l'Architecte, NON couvert)
|
||||||
|
|
||||||
|
Les CLI structurees (claude/codex = binaires Node) ont des besoins FS ambiants lourds : `~/.claude`/`~/.codex` (credentials + cache de session pour le **resume**), `node_modules`, libs systeme, caches temp. `compile_sandbox_plan` ne fence que les classes explicitement posees et garde les lectures ouvertes — mais une policy posant un `Deny`/posture restrictive touchant `$HOME` **peut casser le resume**. Le mecanisme est valide au fake CLI (zero token), mais **avant d'activer un plan restrictif en prod sur le chemin structure**, valider e2e manuellement qu'un vrai tour claude/codex sous plan representatif garde run_dir + home de la CLI atteignables. C'est un sujet de **composition du plan** (run_dir reachability ; `SandboxContext.run_dir` existe mais n'est pas encore consomme par la traduction pure), pas du mecanisme — lot suivant si besoin.
|
||||||
|
|
||||||
|
Voir [[remaining-work-idea-agent-control-ide]] pour le reste des chantiers produit.
|
||||||
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".
|
||||||
26
.ideai/memory/session-limit-handling-design.md
Normal file
26
.ideai/memory/session-limit-handling-design.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: session-limit-handling-design
|
||||||
|
description: Design valide pour la detection des limites de session des agents et la reprise auto a la levee.
|
||||||
|
metadata:
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
Feature : detecter quand un agent IA est en limite de session (et jusqu'a quelle heure), puis reprendre ou il en etait une fois la limite levee. Cadree le 2026-06-16.
|
||||||
|
|
||||||
|
Constat dur : l'heure exacte de reset n'existe nulle part de facon universelle (ni OS, ni code de sortie, ni API inter-modeles) — elle est fabriquee par le fournisseur et seulement exposee dans le flux de sa CLI. Donc « 100% fiable + zero dependance modele + heure exacte » sont incompatibles simultanement ; on vise le meilleur compromis via un detecteur hierarchique.
|
||||||
|
|
||||||
|
Solution retenue — detecteur hierarchique calque sur la hierarchie de readiness existante ([[remaining-work-idea-agent-control-ide]]) :
|
||||||
|
- Niveau 1 (solide) : l'adapter structure extrait limite + reset du flux machine. Pour Claude, `rate_limit_event.rate_limit_info` est DEJA parse dans `infrastructure/session/claude.rs` mais jete (reduit a Heartbeat) — il suffit de lire `resetsAt`.
|
||||||
|
- Niveau 2 (configurable) : champ de profil `rate_limit_pattern` (regex + capture heure) pour agents PTY/TUI sans adapter structure, dans la lignee des profils declaratifs §9.
|
||||||
|
- Niveau 3 (filet humain) : si rien ne matche mais agent `Stalled` (deja prevu dans `domain/readiness.rs`), IdeA DEMANDE a l'utilisateur. Garantit le « 100% meme pour un novice » : jamais d'inaction silencieuse.
|
||||||
|
|
||||||
|
Model-agnostic tenu AU DOMAINE : le domaine ne connait que `RateLimited { until: Option<Instant> }`. Le savoir specifique modele est confine aux adapters/profils (philosophie §9).
|
||||||
|
|
||||||
|
Reprise = partie facile et deja model-agnostique : pivot sur `conversation_id` du moteur + `--resume` natif (deja cable dans `session/claude.rs` + `agent/resume.rs`) qui portent tout l'historique. Pas de reconstruction manuelle fragile.
|
||||||
|
|
||||||
|
Decisions produit verrouillees (2026-06-16) :
|
||||||
|
- Reprise : AUTOMATIQUE a l'heure de reset, ANNULABLE (fenetre + notification).
|
||||||
|
- Couverture : les TROIS niveaux d'emblee (incl. repli regex niveau 2).
|
||||||
|
- Etat : EN MEMOIRE uniquement (pas de persistance de SessionLimit). Consequence assumee : le reveil auto ne joue que tant qu'IdeA reste ouvert ; si l'IDE est ferme/rouvert apres le reset, le chemin existant `ListResumableAgents` (agent_was_running/conversation_id) prend le relais.
|
||||||
|
|
||||||
|
Decoupage (cycle dev/test §3) : 1) domaine (variante `ReplyEvent::RateLimited`, `ReadinessSignal::RateLimited`, etat `SessionLimit`) ; 2) adapter Claude (extraire resetsAt) ; 3) profil (`rate_limit_pattern`) ; 4) application (planificateur de reprise sur le port Clock) ; 5) UI (badge « limite jusqu'a HH:MM » + filet humain).
|
||||||
38
.ideai/permissions.json
Normal file
38
.ideai/permissions.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"projectDefaults": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"capability": "read",
|
||||||
|
"effect": "allow",
|
||||||
|
"paths": [
|
||||||
|
"**"
|
||||||
|
],
|
||||||
|
"commands": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability": "write",
|
||||||
|
"effect": "allow",
|
||||||
|
"paths": [
|
||||||
|
"**"
|
||||||
|
],
|
||||||
|
"commands": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability": "delete",
|
||||||
|
"effect": "allow",
|
||||||
|
"paths": [
|
||||||
|
"**"
|
||||||
|
],
|
||||||
|
"commands": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"capability": "executeBash",
|
||||||
|
"effect": "allow",
|
||||||
|
"paths": [],
|
||||||
|
"commands": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fallback": "allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
1703
ARCHITECTURE.md
1703
ARCHITECTURE.md
File diff suppressed because it is too large
Load Diff
28
CLAUDE.md
28
CLAUDE.md
@ -39,6 +39,21 @@ Je suis responsable de :
|
|||||||
- Produisent un **rapport d'erreurs** clair quand un test échoue.
|
- Produisent un **rapport d'erreurs** clair quand un test échoue.
|
||||||
- Re-testent après chaque correction.
|
- Re-testent après chaque correction.
|
||||||
|
|
||||||
|
### 2.4 Agent Git (1 pour tout le projet)
|
||||||
|
- Garant du **dépôt git local** : commits de l'application, création/checkout/switch de
|
||||||
|
branches, merges et rebases. Contexte : `.ideai/agents/git.md`.
|
||||||
|
- **C'est lui qui décide** de la topologie des branches, pas moi. Je le sollicite, il tranche.
|
||||||
|
- Modèle de branches : **`main`** (release) ← **`develop`** (intégration des features
|
||||||
|
terminées) ← une branche **`feature/*`** par nouvelle feature.
|
||||||
|
- **Quand je commande une nouvelle feature** : une fois l'architecture cadrée par Architect,
|
||||||
|
je passe la main à **Git** qui décide s'il faut créer une branche, faire un checkout/switch,
|
||||||
|
ou rester en place — **avant** que le dev commence.
|
||||||
|
- **Après chaque implémentation** : je reparle à **Git** pour qu'il décide si un **merge**
|
||||||
|
doit être fait quelque part (typiquement `feature/* → develop` une fois les tests verts),
|
||||||
|
ou non.
|
||||||
|
- Périmètre **local uniquement** : aucune action sortante (`push`, publication) sans ma
|
||||||
|
validation explicite.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Le cycle de développement (boucle obligatoire)
|
## 3. Le cycle de développement (boucle obligatoire)
|
||||||
@ -47,11 +62,14 @@ Pour **chaque** feature implémentée ou modifiée :
|
|||||||
|
|
||||||
```
|
```
|
||||||
1. Agent Architecture → valide le découpage et les contrats (ports/interfaces)
|
1. Agent Architecture → valide le découpage et les contrats (ports/interfaces)
|
||||||
2. Agent Développement → écrit le code
|
2. Agent Git → décide de la branche (créer feature/*, switch, ou rester)
|
||||||
3. Agent Test → écrit les tests unitaires + les exécute
|
3. Agent Développement → écrit le code
|
||||||
4a. Tests OK → feature validée, on passe à la suite
|
4. Agent Test → écrit les tests unitaires + les exécute
|
||||||
4b. Tests KO → rapport d'erreurs → retour à l'agent Développement
|
5a. Tests OK → feature validée
|
||||||
→ correction → retour à l'étape 3 (boucle jusqu'au vert)
|
→ Agent Git décide d'un merge éventuel (feature/* → develop)
|
||||||
|
→ on passe à la suite
|
||||||
|
5b. Tests KO → rapport d'erreurs → retour à l'agent Développement
|
||||||
|
→ correction → retour à l'étape 4 (boucle jusqu'au vert)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas.
|
**Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas.
|
||||||
|
|||||||
1040
Cargo.lock
generated
1040
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"
|
serde_json = "1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
async-trait = "0.1"
|
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:
|
# 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
|
# 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.
|
# push/pull and static vendoring for the AppImage are deferred to L9/L11.
|
||||||
|
|||||||
@ -25,11 +25,27 @@ application = { workspace = true }
|
|||||||
infrastructure = { workspace = true }
|
infrastructure = { workspace = true }
|
||||||
tauri = { workspace = true }
|
tauri = { workspace = true }
|
||||||
tauri-plugin-dialog = { 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 = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
# `AppAgentResumer` implements the application's async `AgentResumer` port (LS7).
|
||||||
|
async-trait = { 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]
|
[dev-dependencies]
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
|||||||
194
crates/app-tauri/src/chat.rs
Normal file
194
crates/app-tauri/src/chat.rs
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
//! 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. Likewise [`ReplyEvent::RateLimited`]
|
||||||
|
/// is non-terminal and content-free (ports §21.2-T4): the UI badge comes from the
|
||||||
|
/// `DomainEvent::AgentRateLimited` bus, not the chat stream, so it maps to `None`
|
||||||
|
/// too. 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,
|
||||||
|
ReplyEvent::RateLimited { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -231,6 +231,41 @@ pub struct TerminalSessionDto {
|
|||||||
pub rows: u16,
|
pub rows: u16,
|
||||||
/// Current cols.
|
/// Current cols.
|
||||||
pub cols: u16,
|
pub cols: u16,
|
||||||
|
/// Conversation id **assigned** by this launch, when the agent's profile
|
||||||
|
/// supports session assignment and the hosting cell had none yet (T4b). The
|
||||||
|
/// front persists it on the leaf (`setCellConversation`) so the next open
|
||||||
|
/// resumes. `None` for a plain terminal, a resume, or a degraded launch.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub assigned_conversation_id: Option<String>,
|
||||||
|
/// **Id de session moteur** (resumable du provider courant) exposé par ce
|
||||||
|
/// lancement, **distinct** de l'id de paire `assignedConversationId` (ARCHITECTURE
|
||||||
|
/// §19.7, lot P8a). Le front le range dans le **cache** `engineSessionId` de la
|
||||||
|
/// cellule (jamais sur `conversationId`). Absent pour un terminal nu, une reprise,
|
||||||
|
/// ou quand le moteur n'expose encore aucun id. La source de vérité du resumable
|
||||||
|
/// est `providers.json` (lot P8b).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub engine_session_id: Option<String>,
|
||||||
|
/// How the frontend should render the cell hosting this session (§17.6):
|
||||||
|
/// [`CellKind::Chat`] for a structured AI session (an `AgentChatView` driven by
|
||||||
|
/// `agent_send`/`reattach_agent_chat`), [`CellKind::Pty`] for a raw terminal
|
||||||
|
/// (xterm). **Derived**, not a layout field: it follows the presence of a
|
||||||
|
/// structured session descriptor on the launch output — a single source of
|
||||||
|
/// truth. Plain terminals and the non-launch construction paths default to
|
||||||
|
/// [`CellKind::Pty`] (the historical, non-breaking shape: a PTY session DTO
|
||||||
|
/// always serialises with `cellKind: "pty"`).
|
||||||
|
pub cell_kind: CellKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a session's hosting cell renders as a structured chat view or a raw
|
||||||
|
/// terminal (ARCHITECTURE §17.6). Serialises as `"chat"` / `"pty"` on the wire;
|
||||||
|
/// the frontend `LayoutGrid` switches `AgentChatView` vs `TerminalView` on it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum CellKind {
|
||||||
|
/// A raw PTY terminal cell (xterm) — the historical default.
|
||||||
|
Pty,
|
||||||
|
/// A structured AI chat cell (`AgentChatView`), backed by an `AgentSession`.
|
||||||
|
Chat,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<OpenTerminalOutput> for TerminalSessionDto {
|
impl From<OpenTerminalOutput> for TerminalSessionDto {
|
||||||
@ -241,6 +276,9 @@ impl From<OpenTerminalOutput> for TerminalSessionDto {
|
|||||||
cwd: s.cwd.as_str().to_owned(),
|
cwd: s.cwd.as_str().to_owned(),
|
||||||
rows: s.pty_size.rows,
|
rows: s.pty_size.rows,
|
||||||
cols: s.pty_size.cols,
|
cols: s.pty_size.cols,
|
||||||
|
assigned_conversation_id: None,
|
||||||
|
engine_session_id: None,
|
||||||
|
cell_kind: CellKind::Pty,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -436,6 +474,15 @@ pub enum LayoutOperationDto {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
agent: Option<String>,
|
agent: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// Record/clear the persistent CLI conversation id on a leaf (T4b).
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
SetCellConversation {
|
||||||
|
/// Hosting leaf.
|
||||||
|
target: String,
|
||||||
|
/// Conversation id, or `null` to clear.
|
||||||
|
#[serde(default)]
|
||||||
|
conversation_id: Option<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LayoutOperationDto {
|
impl LayoutOperationDto {
|
||||||
@ -479,6 +526,13 @@ impl LayoutOperationDto {
|
|||||||
target: parse_node_id(&target)?,
|
target: parse_node_id(&target)?,
|
||||||
agent: agent.as_deref().map(parse_agent_id).transpose()?,
|
agent: agent.as_deref().map(parse_agent_id).transpose()?,
|
||||||
},
|
},
|
||||||
|
Self::SetCellConversation {
|
||||||
|
target,
|
||||||
|
conversation_id,
|
||||||
|
} => LayoutOperation::SetCellConversation {
|
||||||
|
target: parse_node_id(&target)?,
|
||||||
|
conversation_id,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -804,6 +858,183 @@ impl From<FirstRunStateOutput> for FirstRunStateDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Embedder profiles & engines (LOT C2 — §14.5.3)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
use application::{
|
||||||
|
DismissChoice, DismissEmbedderSuggestionInput, EmbedderEnginesView, ListEmbedderProfilesOutput,
|
||||||
|
OnnxModelView, SaveEmbedderProfileInput, SaveEmbedderProfileOutput,
|
||||||
|
};
|
||||||
|
use domain::profile::EmbedderProfile;
|
||||||
|
|
||||||
|
/// An embedder profile crossing the wire. [`EmbedderProfile`] already serialises
|
||||||
|
/// camelCase (`id, name, strategy, model?, endpoint?, apiKeyEnv?, dimension`), so we
|
||||||
|
/// embed it directly — the TS mirror matches this shape.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct EmbedderProfileDto(pub EmbedderProfile);
|
||||||
|
|
||||||
|
/// A list of embedder profiles (camelCase array on the wire).
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct EmbedderProfileListDto(pub Vec<EmbedderProfileDto>);
|
||||||
|
|
||||||
|
impl From<Vec<EmbedderProfile>> for EmbedderProfileListDto {
|
||||||
|
fn from(v: Vec<EmbedderProfile>) -> Self {
|
||||||
|
Self(v.into_iter().map(EmbedderProfileDto).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ListEmbedderProfilesOutput> for EmbedderProfileListDto {
|
||||||
|
fn from(out: ListEmbedderProfilesOutput) -> Self {
|
||||||
|
out.profiles.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SaveEmbedderProfileOutput> for EmbedderProfileDto {
|
||||||
|
fn from(out: SaveEmbedderProfileOutput) -> Self {
|
||||||
|
Self(out.profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `save_embedder_profile`: the embedder profile to upsert.
|
||||||
|
///
|
||||||
|
/// Carries the [`EmbedderProfile`] fields directly (the entity validates them in the
|
||||||
|
/// use case). Deserialised camelCase to match the persisted/domain shape.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SaveEmbedderProfileRequestDto {
|
||||||
|
/// The embedder profile to upsert.
|
||||||
|
pub profile: EmbedderProfile,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SaveEmbedderProfileRequestDto> for SaveEmbedderProfileInput {
|
||||||
|
fn from(dto: SaveEmbedderProfileRequestDto) -> Self {
|
||||||
|
let EmbedderProfile {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
strategy,
|
||||||
|
model,
|
||||||
|
endpoint,
|
||||||
|
api_key_env,
|
||||||
|
dimension,
|
||||||
|
} = dto.profile;
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
strategy,
|
||||||
|
model,
|
||||||
|
endpoint,
|
||||||
|
api_key_env,
|
||||||
|
dimension,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One recommendable local ONNX model on the wire (camelCase).
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OnnxModelInfoDto {
|
||||||
|
/// Stable model id accepted by a `localOnnx` profile's `model` field.
|
||||||
|
pub id: String,
|
||||||
|
/// Human-readable name for the UI.
|
||||||
|
pub display_name: String,
|
||||||
|
/// Length of the vectors this model produces.
|
||||||
|
pub dimension: usize,
|
||||||
|
/// Approximate download/disk size in megabytes.
|
||||||
|
pub approx_size_mb: u32,
|
||||||
|
/// Whether this is the recommended default model.
|
||||||
|
pub recommended: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<OnnxModelView> for OnnxModelInfoDto {
|
||||||
|
fn from(m: OnnxModelView) -> Self {
|
||||||
|
Self {
|
||||||
|
id: m.id,
|
||||||
|
display_name: m.display_name,
|
||||||
|
dimension: m.dimension,
|
||||||
|
approx_size_mb: m.approx_size_mb,
|
||||||
|
recommended: m.recommended,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response DTO for `describe_embedder_engines` (drives the "configure an embedder?"
|
||||||
|
/// UI). All-camelCase wire shape.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EmbedderEnginesDto {
|
||||||
|
/// The curated catalogue of recommendable local ONNX models.
|
||||||
|
pub recommended_onnx: Vec<OnnxModelInfoDto>,
|
||||||
|
/// Whether an Ollama-style local embedding server was detected (best-effort).
|
||||||
|
pub ollama_detected: bool,
|
||||||
|
/// Ids of the recommended ONNX models already present in the local cache.
|
||||||
|
pub onnx_cached_models: Vec<String>,
|
||||||
|
/// Whether the HTTP capability (`localServer`/`api`) is compiled into this binary.
|
||||||
|
pub vector_http_enabled: bool,
|
||||||
|
/// Whether the in-process ONNX capability (`localOnnx`) is compiled into this binary.
|
||||||
|
pub vector_onnx_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<EmbedderEnginesView> for EmbedderEnginesDto {
|
||||||
|
fn from(v: EmbedderEnginesView) -> Self {
|
||||||
|
Self {
|
||||||
|
recommended_onnx: v.recommended_onnx.into_iter().map(Into::into).collect(),
|
||||||
|
ollama_detected: v.ollama_detected,
|
||||||
|
onnx_cached_models: v.onnx_cached_models,
|
||||||
|
vector_http_enabled: v.vector_http_enabled,
|
||||||
|
vector_onnx_enabled: v.vector_onnx_enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Embedder suggestion (LOT C3 — §14.5.5)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The user's response to the embedder suggestion, on the wire (camelCase:
|
||||||
|
/// `"later"` | `"never"`).
|
||||||
|
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum DismissChoiceDto {
|
||||||
|
/// "Plus tard" — re-proposable next session.
|
||||||
|
Later,
|
||||||
|
/// "Ne plus demander" — never again.
|
||||||
|
Never,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DismissChoiceDto> for DismissChoice {
|
||||||
|
fn from(c: DismissChoiceDto) -> Self {
|
||||||
|
match c {
|
||||||
|
DismissChoiceDto::Later => Self::Later,
|
||||||
|
DismissChoiceDto::Never => Self::Never,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `dismiss_embedder_suggestion`. The `project_id` is resolved to a
|
||||||
|
/// project root by the command; `choice` is the user's dismissal.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DismissEmbedderSuggestionRequestDto {
|
||||||
|
/// The project the suggestion concerned (UUID string).
|
||||||
|
pub project_id: String,
|
||||||
|
/// The user's choice.
|
||||||
|
pub choice: DismissChoiceDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DismissEmbedderSuggestionRequestDto {
|
||||||
|
/// Builds the use-case input from a resolved project root + the DTO choice.
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_input(self, project_root: domain::ProjectPath) -> DismissEmbedderSuggestionInput {
|
||||||
|
DismissEmbedderSuggestionInput {
|
||||||
|
project_root,
|
||||||
|
choice: self.choice.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds a [`DeleteProfileInput`] from a raw profile-id string.
|
/// Builds a [`DeleteProfileInput`] from a raw profile-id string.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@ -832,9 +1063,10 @@ pub fn parse_profile_id(raw: &str) -> Result<ProfileId, ErrorDto> {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
CreateAgentOutput, LaunchAgentOutput, ListAgentsOutput, ReadAgentContextOutput,
|
ChangeAgentProfileOutput, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput,
|
||||||
|
ListAgentsOutput, ReadAgentContextOutput,
|
||||||
};
|
};
|
||||||
use domain::Agent;
|
use domain::{Agent, EffectivePermissions, PermissionSet, ProjectPermissions, TerminalSession};
|
||||||
|
|
||||||
/// An agent crossing the wire. [`Agent`] already serialises camelCase
|
/// An agent crossing the wire. [`Agent`] already serialises camelCase
|
||||||
/// (`id`, `name`, `contextPath`, `profileId`, `origin` tagged, `synchronized`),
|
/// (`id`, `name`, `contextPath`, `profileId`, `origin` tagged, `synchronized`),
|
||||||
@ -903,6 +1135,62 @@ pub struct UpdateAgentContextRequestDto {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Permissions (LP1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Full project permission document crossing the wire.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct ProjectPermissionsDto(pub ProjectPermissions);
|
||||||
|
|
||||||
|
/// Effective permissions crossing the wire.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct EffectivePermissionsDto(pub EffectivePermissions);
|
||||||
|
|
||||||
|
/// Request DTO for updating project default permissions.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateProjectPermissionsRequestDto {
|
||||||
|
/// Id of the owning project.
|
||||||
|
pub project_id: String,
|
||||||
|
/// New project defaults. `null` removes defaults.
|
||||||
|
pub permissions: Option<PermissionSet>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for updating one agent permission override.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateAgentPermissionsRequestDto {
|
||||||
|
/// Id of the owning project.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Target agent id.
|
||||||
|
pub agent_id: String,
|
||||||
|
/// New override. `null` removes the override.
|
||||||
|
pub permissions: Option<PermissionSet>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for resolving one agent's effective permissions.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ResolveAgentPermissionsRequestDto {
|
||||||
|
/// Id of the owning project.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Target agent id.
|
||||||
|
pub agent_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `update_project_context`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateProjectContextRequestDto {
|
||||||
|
/// Id of the owning project.
|
||||||
|
pub project_id: String,
|
||||||
|
/// New project-level Markdown context, stored under `.ideai/CONTEXT.md`.
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Request DTO for `launch_agent`.
|
/// Request DTO for `launch_agent`.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -915,20 +1203,288 @@ pub struct LaunchAgentRequestDto {
|
|||||||
pub rows: u16,
|
pub rows: u16,
|
||||||
/// Initial terminal width in columns.
|
/// Initial terminal width in columns.
|
||||||
pub cols: u16,
|
pub cols: u16,
|
||||||
|
/// Persistent CLI conversation id currently recorded on the hosting cell, if
|
||||||
|
/// any. `Some` ⇒ the launch resumes it; absent/`None` ⇒ a fresh cell (the
|
||||||
|
/// launch may assign a new id when the profile supports it).
|
||||||
|
#[serde(default)]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
/// The layout leaf (node) hosting this launch. Enforces the "one live session
|
||||||
|
/// per agent" invariant: a launch into a node different from where the agent
|
||||||
|
/// is already running is refused (`AGENT_ALREADY_RUNNING`); the same node is
|
||||||
|
/// idempotent. Absent ⇒ a fresh node is minted (and any already-live agent is
|
||||||
|
/// refused).
|
||||||
|
#[serde(default)]
|
||||||
|
pub node_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<LaunchAgentOutput> for TerminalSessionDto {
|
impl From<LaunchAgentOutput> for TerminalSessionDto {
|
||||||
fn from(out: LaunchAgentOutput) -> Self {
|
fn from(out: LaunchAgentOutput) -> Self {
|
||||||
|
let assigned_conversation_id = out.assigned_conversation_id;
|
||||||
|
let engine_session_id = out.engine_session_id;
|
||||||
|
// §17.6: the cell kind is *derived* from the launch routing. A structured
|
||||||
|
// descriptor (`structured: Some(..)`) means LaunchAgent routed to an
|
||||||
|
// AgentSession ⇒ chat cell; its absence means the PTY/terminal path ⇒
|
||||||
|
// terminal cell. Single source of truth, no layout migration.
|
||||||
|
let cell_kind = if out.structured.is_some() {
|
||||||
|
CellKind::Chat
|
||||||
|
} else {
|
||||||
|
CellKind::Pty
|
||||||
|
};
|
||||||
let s = out.session;
|
let s = out.session;
|
||||||
Self {
|
Self {
|
||||||
session_id: s.id.to_string(),
|
session_id: s.id.to_string(),
|
||||||
cwd: s.cwd.as_str().to_owned(),
|
cwd: s.cwd.as_str().to_owned(),
|
||||||
rows: s.pty_size.rows,
|
rows: s.pty_size.rows,
|
||||||
cols: s.pty_size.cols,
|
cols: s.pty_size.cols,
|
||||||
|
assigned_conversation_id,
|
||||||
|
engine_session_id,
|
||||||
|
cell_kind,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `interrupt_agent` (cadrage C4 §4.2): the Interrompre path. The
|
||||||
|
/// frontend sends `{ request: { projectId, agentId } }`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InterruptAgentRequestDto {
|
||||||
|
/// Id of the owning project.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Id of the agent whose running turn to preempt.
|
||||||
|
pub agent_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `delegation_delivered` (ARCHITECTURE §20.3): the frontend write-
|
||||||
|
/// portal acks that it **physically wrote** a delegation `ticket` into the agent's
|
||||||
|
/// native PTY. Best-effort observability — it never changes correlation (the `ask` is
|
||||||
|
/// still woken by `idea_reply`). The frontend sends `{ request: { projectId, agentId,
|
||||||
|
/// ticket } }`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DeliveredDelegationRequestDto {
|
||||||
|
/// Id of the owning project.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Id of the agent whose terminal received the delegation.
|
||||||
|
pub agent_id: String,
|
||||||
|
/// Id of the delivered mailbox ticket.
|
||||||
|
pub ticket: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `set_front_attached`: the write-portal of an agent cell reports
|
||||||
|
/// whether a **frontend terminal cell is mounted** for `agentId` (`true` on mount,
|
||||||
|
/// `false` on unmount). The mediator uses it to choose, at delivery time, between
|
||||||
|
/// publishing `DelegationReady` (a cell will write it) and writing the turn into the
|
||||||
|
/// PTY itself (headless/background-delegated agent with no cell). The frontend sends
|
||||||
|
/// `{ request: { agentId, attached } }`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FrontAttachedRequestDto {
|
||||||
|
/// Id of the agent whose terminal cell mounted/unmounted.
|
||||||
|
pub agent_id: String,
|
||||||
|
/// `true` when the cell's write-portal is now active, `false` on teardown.
|
||||||
|
pub attached: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `change_agent_profile` (§15.1): hot-swap an agent's runtime
|
||||||
|
/// profile, optionally relaunching its live session in place.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ChangeAgentProfileRequestDto {
|
||||||
|
/// Id of the owning project.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Id of the agent whose runtime profile to change.
|
||||||
|
pub agent_id: String,
|
||||||
|
/// Id of the new runtime profile.
|
||||||
|
pub profile_id: String,
|
||||||
|
/// Terminal height in rows for a possible hot relaunch.
|
||||||
|
pub rows: u16,
|
||||||
|
/// Terminal width in columns for a possible hot relaunch.
|
||||||
|
pub cols: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response DTO for `change_agent_profile`: the mutated agent plus the freshly
|
||||||
|
/// relaunched session when a live session was hot-swapped (absent otherwise).
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ChangeAgentProfileDto {
|
||||||
|
/// The agent now carrying the new profile.
|
||||||
|
pub agent: AgentDto,
|
||||||
|
/// The relaunched session, present only when a live session was swapped.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub relaunched_session: Option<TerminalSessionDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ChangeAgentProfileOutput> for ChangeAgentProfileDto {
|
||||||
|
fn from(out: ChangeAgentProfileOutput) -> Self {
|
||||||
|
Self {
|
||||||
|
agent: AgentDto(out.agent),
|
||||||
|
relaunched_session: out.relaunched.map(TerminalSessionDto::from),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TerminalSession> for TerminalSessionDto {
|
||||||
|
fn from(s: TerminalSession) -> Self {
|
||||||
|
Self {
|
||||||
|
session_id: s.id.to_string(),
|
||||||
|
cwd: s.cwd.as_str().to_owned(),
|
||||||
|
rows: s.pty_size.rows,
|
||||||
|
cols: s.pty_size.cols,
|
||||||
|
assigned_conversation_id: None,
|
||||||
|
engine_session_id: None,
|
||||||
|
cell_kind: CellKind::Pty,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Structured chat sessions (§17 — D4)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// One incremental chunk of a structured agent reply, streamed over the chat
|
||||||
|
/// session's [`tauri::ipc::Channel`] (ARCHITECTURE §17.7). The serialised wire
|
||||||
|
/// twin of a [`domain::ports::ReplyEvent`]: the `agent_send` pump maps each turn
|
||||||
|
/// event to one of these and pushes it to the frontend `AgentChatView`.
|
||||||
|
///
|
||||||
|
/// Tagged on `kind` (camelCase: `"textDelta"` | `"toolActivity"` | `"final"`), so
|
||||||
|
/// the front branches without positional parsing. `Final` is the **deterministic
|
||||||
|
/// terminal** chunk of a turn — after it the turn is frozen and the stream ends.
|
||||||
|
/// `Deserialize` is derived too so tests (and a mock gateway) can round-trip it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||||
|
pub enum ReplyChunk {
|
||||||
|
/// An assistant text fragment (incremental chat rendering).
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
TextDelta {
|
||||||
|
/// The text fragment.
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
/// A human-readable tool-activity badge (best-effort observability).
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
ToolActivity {
|
||||||
|
/// The human-readable activity label.
|
||||||
|
label: String,
|
||||||
|
},
|
||||||
|
/// The deterministic end-of-turn chunk carrying the aggregated final content.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
Final {
|
||||||
|
/// The aggregated final content of the turn.
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response DTO for `reattach_agent_chat`: the retained **conversation
|
||||||
|
/// scrollback** of a still-live structured session, replayed into the
|
||||||
|
/// re-mounting `AgentChatView` before the new reply stream is wired (§17.6). The
|
||||||
|
/// typed twin of [`ReattachResultDto`] (PTY scrollback bytes) — here the
|
||||||
|
/// scrollback is the ordered list of [`ReplyChunk`]s already streamed.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ReattachChatDto {
|
||||||
|
/// The session that was re-attached (echoed back for the frontend).
|
||||||
|
pub session_id: String,
|
||||||
|
/// The chunks already streamed for this conversation, in order. The frontend
|
||||||
|
/// replays them to rebuild the visible turns, then receives subsequent chunks
|
||||||
|
/// over the freshly-registered channel.
|
||||||
|
pub scrollback: Vec<ReplyChunk>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `inspect_conversation` (T7).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InspectConversationRequestDto {
|
||||||
|
/// Id of the owning project.
|
||||||
|
pub project_id: String,
|
||||||
|
/// Id of the agent whose conversation is inspected.
|
||||||
|
pub agent_id: String,
|
||||||
|
/// The conversation id recorded on the hosting cell.
|
||||||
|
pub conversation_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response DTO for `inspect_conversation` (T7): the best-effort enriched
|
||||||
|
/// details for a resume popup. Both fields are optional and **omitted from the
|
||||||
|
/// wire when `None`** (`skip_serializing_if`), so the TypeScript side sees an
|
||||||
|
/// absent key — not `null` — for a degraded inspection.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ConversationDetailsDto {
|
||||||
|
/// A short, best-effort label for the conversation (last user message,
|
||||||
|
/// truncated). Absent when it could not be extracted.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_topic: Option<String>,
|
||||||
|
/// A best-effort cumulative token count. Absent when no usage info exists.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub token_count: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<InspectConversationOutput> for ConversationDetailsDto {
|
||||||
|
fn from(out: InspectConversationOutput) -> Self {
|
||||||
|
Self {
|
||||||
|
last_topic: out.details.last_topic,
|
||||||
|
token_count: out.details.token_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One currently-live agent and the cell hosting it, for `list_live_agents`.
|
||||||
|
///
|
||||||
|
/// Lets the UI disable an agent already running in another cell (it cannot be
|
||||||
|
/// launched a second time — the "one live session per agent" invariant).
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LiveAgentDto {
|
||||||
|
/// The live agent's id (UUID string).
|
||||||
|
pub agent_id: String,
|
||||||
|
/// The hosting layout leaf's node id (UUID string).
|
||||||
|
pub node_id: String,
|
||||||
|
/// The live PTY session id, used to reattach a newly-opened cell without
|
||||||
|
/// respawning the agent.
|
||||||
|
pub session_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response DTO for `list_live_agents` (transparent array on the wire).
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct LiveAgentListDto(pub Vec<LiveAgentDto>);
|
||||||
|
|
||||||
|
impl LiveAgentListDto {
|
||||||
|
/// Builds the wire list from the registry's `(AgentId, NodeId, SessionId)` tuples.
|
||||||
|
///
|
||||||
|
/// De-duplicates by `agent_id`: the "one live session per agent" invariant
|
||||||
|
/// guarantees an agent is live in at most one registry (PTY **or**
|
||||||
|
/// structured), but the aggregated input could in theory carry the same agent
|
||||||
|
/// twice; the UI contract is a dup-free set (each agent appears once), so we
|
||||||
|
/// keep the first occurrence and drop any later one for the same agent.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_pairs(pairs: Vec<(AgentId, NodeId, domain::SessionId)>) -> Self {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
Self(
|
||||||
|
pairs
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(agent_id, _, _)| seen.insert(*agent_id))
|
||||||
|
.map(|(agent_id, node_id, session_id)| LiveAgentDto {
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
node_id: node_id.to_string(),
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `attach_live_agent`: bind an already-running agent session to
|
||||||
|
/// a visible layout cell without spawning a new process.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AttachLiveAgentRequestDto {
|
||||||
|
/// Id of the owning project (validated for symmetry and future scoping).
|
||||||
|
pub project_id: String,
|
||||||
|
/// Id of the already-running agent.
|
||||||
|
pub agent_id: String,
|
||||||
|
/// Layout leaf that should display the live session.
|
||||||
|
pub node_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Parses an agent-id string (UUID) coming from the frontend.
|
/// Parses an agent-id string (UUID) coming from the frontend.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@ -942,14 +1498,93 @@ pub fn parse_agent_id(raw: &str) -> Result<AgentId, ErrorDto> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses a ticket-id string (UUID) coming from the frontend (`delegation_delivered`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
|
||||||
|
pub fn parse_ticket_id(raw: &str) -> Result<domain::TicketId, ErrorDto> {
|
||||||
|
uuid::Uuid::parse_str(raw)
|
||||||
|
.map(domain::TicketId::from_uuid)
|
||||||
|
.map_err(|_| ErrorDto {
|
||||||
|
code: "INVALID".to_owned(),
|
||||||
|
message: format!("invalid ticket id: {raw}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resumable agents (§15.2 — Chantier B2)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
use application::{ListResumableAgentsOutput, ResumableAgent};
|
||||||
|
|
||||||
|
/// One resumable agent cell, as seen by the frontend (§15.2).
|
||||||
|
///
|
||||||
|
/// Mirrors [`ResumableAgent`]: the agent's identity + its host cell, the CLI
|
||||||
|
/// conversation id to resume (absent ⇒ fresh relaunch), the `was_running` flag
|
||||||
|
/// frozen at close, and whether the agent's profile can resume a CLI
|
||||||
|
/// conversation. `conversationId` is **omitted from the wire when `None`**
|
||||||
|
/// (`skip_serializing_if`), so the TypeScript side sees an absent key — not
|
||||||
|
/// `null`.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ResumableAgentDto {
|
||||||
|
/// The resumable agent's id (UUID string).
|
||||||
|
pub agent_id: String,
|
||||||
|
/// The agent's display name (resolved from the manifest).
|
||||||
|
pub name: String,
|
||||||
|
/// The host layout leaf where the agent is relaunched/resumed (UUID string).
|
||||||
|
pub node_id: String,
|
||||||
|
/// Persistent CLI conversation id carried by the cell. Absent ⇒ fresh
|
||||||
|
/// relaunch (no history to resume).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
/// The `agent_was_running` flag frozen at the cell's close.
|
||||||
|
pub was_running: bool,
|
||||||
|
/// `true` when the agent's profile carries a usable resume strategy.
|
||||||
|
pub resume_supported: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ResumableAgent> for ResumableAgentDto {
|
||||||
|
fn from(r: ResumableAgent) -> Self {
|
||||||
|
Self {
|
||||||
|
agent_id: r.agent_id.to_string(),
|
||||||
|
name: r.name,
|
||||||
|
node_id: r.node_id.to_string(),
|
||||||
|
conversation_id: r.conversation_id,
|
||||||
|
was_running: r.was_running,
|
||||||
|
resume_supported: r.resume_supported,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response DTO for `list_resumable_agents` (§15.2).
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ResumableAgentListDto {
|
||||||
|
/// The resumable agent cells, in layout-traversal order.
|
||||||
|
pub resumable: Vec<ResumableAgentDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ListResumableAgentsOutput> for ResumableAgentListDto {
|
||||||
|
fn from(out: ListResumableAgentsOutput) -> Self {
|
||||||
|
Self {
|
||||||
|
resumable: out
|
||||||
|
.resumable
|
||||||
|
.into_iter()
|
||||||
|
.map(ResumableAgentDto::from)
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Templates & sync (L7)
|
// Templates & sync (L7)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput,
|
AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput, CreateTemplateOutput,
|
||||||
CreateTemplateOutput, DetectAgentDriftOutput, ListTemplatesOutput, SyncAgentWithTemplateOutput,
|
DetectAgentDriftOutput, ListTemplatesOutput, SyncAgentWithTemplateOutput, UpdateTemplateInput,
|
||||||
UpdateTemplateInput, UpdateTemplateOutput,
|
UpdateTemplateOutput,
|
||||||
};
|
};
|
||||||
use domain::{AgentTemplate, TemplateId};
|
use domain::{AgentTemplate, TemplateId};
|
||||||
|
|
||||||
@ -1144,9 +1779,7 @@ pub struct SyncAgentWithTemplateRequestDto {
|
|||||||
// Git (L8)
|
// Git (L8)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
use application::{
|
use application::{GitBranchesOutput, GitCommitOutput, GitLogOutput, GitStatusOutput};
|
||||||
GitBranchesOutput, GitCommitOutput, GitLogOutput, GitStatusOutput,
|
|
||||||
};
|
|
||||||
use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit};
|
use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit};
|
||||||
|
|
||||||
/// One changed path returned by `git_status`.
|
/// One changed path returned by `git_status`.
|
||||||
@ -1175,7 +1808,12 @@ pub struct GitStatusListDto(pub Vec<GitFileStatusDto>);
|
|||||||
|
|
||||||
impl From<GitStatusOutput> for GitStatusListDto {
|
impl From<GitStatusOutput> for GitStatusListDto {
|
||||||
fn from(out: GitStatusOutput) -> Self {
|
fn from(out: GitStatusOutput) -> Self {
|
||||||
Self(out.entries.into_iter().map(GitFileStatusDto::from).collect())
|
Self(
|
||||||
|
out.entries
|
||||||
|
.into_iter()
|
||||||
|
.map(GitFileStatusDto::from)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1446,3 +2084,194 @@ pub fn parse_skill_id(raw: &str) -> Result<SkillId, ErrorDto> {
|
|||||||
message: format!("invalid skill id: {raw}"),
|
message: format!("invalid skill id: {raw}"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Memory (LOT A — §14.5.1)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
use application::{
|
||||||
|
CreateMemoryOutput, GetMemoryOutput, ListMemoriesOutput, ReadMemoryIndexOutput,
|
||||||
|
RecallMemoryOutput, ResolveMemoryLinksOutput, UpdateMemoryOutput,
|
||||||
|
};
|
||||||
|
use domain::{Memory, MemoryIndexEntry, MemorySlug, MemoryType};
|
||||||
|
|
||||||
|
/// A memory note crossing the wire.
|
||||||
|
///
|
||||||
|
/// Built explicitly from [`Memory`] (its `body` is [`domain::MarkdownDoc`], not
|
||||||
|
/// directly a JSON string) so the TS mirror gets a flat camelCase shape.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MemoryDto {
|
||||||
|
/// The note's slug (its identity, also the file stem).
|
||||||
|
pub name: String,
|
||||||
|
/// One-line description (the index hook).
|
||||||
|
pub description: String,
|
||||||
|
/// The note's kind.
|
||||||
|
pub r#type: MemoryType,
|
||||||
|
/// Markdown body of the note.
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Memory> for MemoryDto {
|
||||||
|
fn from(memory: Memory) -> Self {
|
||||||
|
Self {
|
||||||
|
name: memory.frontmatter.name.as_str().to_owned(),
|
||||||
|
description: memory.frontmatter.description,
|
||||||
|
r#type: memory.frontmatter.r#type,
|
||||||
|
content: memory.body.into_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CreateMemoryOutput> for MemoryDto {
|
||||||
|
fn from(out: CreateMemoryOutput) -> Self {
|
||||||
|
Self::from(out.memory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UpdateMemoryOutput> for MemoryDto {
|
||||||
|
fn from(out: UpdateMemoryOutput) -> Self {
|
||||||
|
Self::from(out.memory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<GetMemoryOutput> for MemoryDto {
|
||||||
|
fn from(out: GetMemoryOutput) -> Self {
|
||||||
|
Self::from(out.memory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A list of memory notes (transparent array on the wire).
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct MemoryListDto(pub Vec<MemoryDto>);
|
||||||
|
|
||||||
|
impl From<ListMemoriesOutput> for MemoryListDto {
|
||||||
|
fn from(out: ListMemoriesOutput) -> Self {
|
||||||
|
Self(out.memories.into_iter().map(MemoryDto::from).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of the structured `MEMORY.md` index crossing the wire.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MemoryIndexEntryDto {
|
||||||
|
/// The note's slug.
|
||||||
|
pub slug: String,
|
||||||
|
/// The note's display title.
|
||||||
|
pub title: String,
|
||||||
|
/// The one-line hook (the frontmatter description).
|
||||||
|
pub hook: String,
|
||||||
|
/// The note's kind.
|
||||||
|
pub r#type: MemoryType,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<MemoryIndexEntry> for MemoryIndexEntryDto {
|
||||||
|
fn from(entry: MemoryIndexEntry) -> Self {
|
||||||
|
Self {
|
||||||
|
slug: entry.slug.as_str().to_owned(),
|
||||||
|
title: entry.title,
|
||||||
|
hook: entry.hook,
|
||||||
|
r#type: entry.r#type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The structured memory index (transparent array on the wire).
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct MemoryIndexDto(pub Vec<MemoryIndexEntryDto>);
|
||||||
|
|
||||||
|
impl From<ReadMemoryIndexOutput> for MemoryIndexDto {
|
||||||
|
fn from(out: ReadMemoryIndexOutput) -> Self {
|
||||||
|
Self(
|
||||||
|
out.entries
|
||||||
|
.into_iter()
|
||||||
|
.map(MemoryIndexEntryDto::from)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RecallMemoryOutput> for MemoryIndexDto {
|
||||||
|
fn from(out: RecallMemoryOutput) -> Self {
|
||||||
|
Self(
|
||||||
|
out.entries
|
||||||
|
.into_iter()
|
||||||
|
.map(MemoryIndexEntryDto::from)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A note's resolved outgoing links — the target slugs (transparent array).
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct MemoryLinksDto(pub Vec<String>);
|
||||||
|
|
||||||
|
impl From<ResolveMemoryLinksOutput> for MemoryLinksDto {
|
||||||
|
fn from(out: ResolveMemoryLinksOutput) -> Self {
|
||||||
|
Self(
|
||||||
|
out.links
|
||||||
|
.into_iter()
|
||||||
|
.map(|link| link.target.as_str().to_owned())
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `create_memory`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreateMemoryRequestDto {
|
||||||
|
/// Owning project (resolved to a root).
|
||||||
|
pub project_id: String,
|
||||||
|
/// Raw slug for the new note.
|
||||||
|
pub name: String,
|
||||||
|
/// One-line description (the index hook).
|
||||||
|
pub description: String,
|
||||||
|
/// The note's kind.
|
||||||
|
pub r#type: MemoryType,
|
||||||
|
/// Markdown body of the note.
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `update_memory`.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateMemoryRequestDto {
|
||||||
|
/// Owning project (resolved to a root).
|
||||||
|
pub project_id: String,
|
||||||
|
/// Slug of the note to replace.
|
||||||
|
pub slug: String,
|
||||||
|
/// New description (the index hook).
|
||||||
|
pub description: String,
|
||||||
|
/// New kind.
|
||||||
|
pub r#type: MemoryType,
|
||||||
|
/// New Markdown body.
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request DTO for `recall_memory` (LOT B — §14.5.2).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RecallMemoryRequestDto {
|
||||||
|
/// Owning project (resolved to a root).
|
||||||
|
pub project_id: String,
|
||||||
|
/// The recall query (often the agent's current working context).
|
||||||
|
pub text: String,
|
||||||
|
/// Approximate token budget bounding the recalled entries (`0` ⇒ empty).
|
||||||
|
pub token_budget: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a memory slug string coming from the frontend.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a valid
|
||||||
|
/// kebab-case slug.
|
||||||
|
pub fn parse_memory_slug(raw: &str) -> Result<MemorySlug, ErrorDto> {
|
||||||
|
MemorySlug::new(raw).map_err(|_| ErrorDto {
|
||||||
|
code: "INVALID".to_owned(),
|
||||||
|
message: format!("invalid memory slug: {raw}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -12,7 +12,8 @@
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
use domain::events::DomainEvent;
|
use domain::events::{DomainEvent, OrchestrationSource};
|
||||||
|
use domain::input::AgentLiveness;
|
||||||
use infrastructure::TokioBroadcastEventBus;
|
use infrastructure::TokioBroadcastEventBus;
|
||||||
|
|
||||||
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
|
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
|
||||||
@ -47,6 +48,62 @@ pub enum DomainEventDto {
|
|||||||
/// Exit code.
|
/// Exit code.
|
||||||
code: i32,
|
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.
|
/// A template was updated.
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
TemplateUpdated {
|
TemplateUpdated {
|
||||||
@ -110,6 +167,89 @@ pub enum DomainEventDto {
|
|||||||
action: String,
|
action: String,
|
||||||
/// Whether IdeA handled it successfully.
|
/// Whether IdeA handled it successfully.
|
||||||
ok: bool,
|
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,
|
||||||
|
},
|
||||||
|
/// An agent entered a **session/rate limit** (ARCHITECTURE §21). Low-frequency,
|
||||||
|
/// model-agnostic badge: carries only the neutral "limited, maybe resets at T"
|
||||||
|
/// fact. The frontend badges "limité jusqu'à HH:MM".
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
AgentRateLimited {
|
||||||
|
/// Agent id (UUID string).
|
||||||
|
agent_id: String,
|
||||||
|
/// Reset instant in **epoch-milliseconds**; `null` ⇒ unknown (no auto-resume).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
resets_at_ms: Option<i64>,
|
||||||
|
},
|
||||||
|
/// An **auto-resume** was armed for a rate-limited agent (ARCHITECTURE §21). The
|
||||||
|
/// frontend shows the countdown + the "Annuler la reprise" button (cancellable
|
||||||
|
/// window).
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
AgentResumeScheduled {
|
||||||
|
/// Agent id (UUID string).
|
||||||
|
agent_id: String,
|
||||||
|
/// Wake-up deadline in **epoch-milliseconds**.
|
||||||
|
fire_at_ms: i64,
|
||||||
|
},
|
||||||
|
/// An agent's **auto-resume** was **cancelled** (ARCHITECTURE §21): the user
|
||||||
|
/// clicked "Annuler la reprise". The frontend removes the countdown.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
AgentResumeCancelled {
|
||||||
|
/// Agent id (UUID string).
|
||||||
|
agent_id: String,
|
||||||
|
},
|
||||||
|
/// An agent was effectively **resumed** after a limit (ARCHITECTURE §21): the
|
||||||
|
/// wake-up fired (or immediate resume). The frontend clears the "limited" state.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
AgentResumed {
|
||||||
|
/// Agent id (UUID string).
|
||||||
|
agent_id: String,
|
||||||
|
},
|
||||||
|
/// **Human net (level 3)**: a session limit is **suspected** without any reliable
|
||||||
|
/// reset time (ARCHITECTURE §21.1). IdeA never resumes blind: this asks the front
|
||||||
|
/// to **prompt the user** ("limit detected but time unknown — resume at?").
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
AgentRateLimitSuspected {
|
||||||
|
/// Agent id (UUID string).
|
||||||
|
agent_id: String,
|
||||||
|
/// Reset instant in **epoch-milliseconds** if an estimate exists, else `null`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
resets_at_ms: Option<i64>,
|
||||||
},
|
},
|
||||||
/// Raw PTY output (normally routed to a per-session channel, not here).
|
/// Raw PTY output (normally routed to a per-session channel, not here).
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -121,6 +261,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 {
|
impl From<&DomainEvent> for DomainEventDto {
|
||||||
fn from(e: &DomainEvent) -> Self {
|
fn from(e: &DomainEvent) -> Self {
|
||||||
match e {
|
match e {
|
||||||
@ -138,6 +319,43 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
agent_id: agent_id.to_string(),
|
agent_id: agent_id.to_string(),
|
||||||
code: *code,
|
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 {
|
DomainEvent::TemplateUpdated {
|
||||||
template_id,
|
template_id,
|
||||||
version,
|
version,
|
||||||
@ -145,11 +363,7 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
template_id: template_id.to_string(),
|
template_id: template_id.to_string(),
|
||||||
version: version.get(),
|
version: version.get(),
|
||||||
},
|
},
|
||||||
DomainEvent::AgentDriftDetected {
|
DomainEvent::AgentDriftDetected { agent_id, from, to } => Self::AgentDriftDetected {
|
||||||
agent_id,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
} => Self::AgentDriftDetected {
|
|
||||||
agent_id: agent_id.to_string(),
|
agent_id: agent_id.to_string(),
|
||||||
from: from.get(),
|
from: from.get(),
|
||||||
to: to.get(),
|
to: to.get(),
|
||||||
@ -180,10 +394,61 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
requester_id,
|
requester_id,
|
||||||
action,
|
action,
|
||||||
ok,
|
ok,
|
||||||
|
source,
|
||||||
} => Self::OrchestratorRequestProcessed {
|
} => Self::OrchestratorRequestProcessed {
|
||||||
requester_id: requester_id.clone(),
|
requester_id: requester_id.clone(),
|
||||||
action: action.clone(),
|
action: action.clone(),
|
||||||
ok: *ok,
|
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::AgentRateLimited {
|
||||||
|
agent_id,
|
||||||
|
resets_at_ms,
|
||||||
|
} => Self::AgentRateLimited {
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
resets_at_ms: *resets_at_ms,
|
||||||
|
},
|
||||||
|
DomainEvent::AgentResumeScheduled {
|
||||||
|
agent_id,
|
||||||
|
fire_at_ms,
|
||||||
|
} => Self::AgentResumeScheduled {
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
fire_at_ms: *fire_at_ms,
|
||||||
|
},
|
||||||
|
DomainEvent::AgentResumeCancelled { agent_id } => Self::AgentResumeCancelled {
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::AgentResumed { agent_id } => Self::AgentResumed {
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::AgentRateLimitSuspected {
|
||||||
|
agent_id,
|
||||||
|
resets_at_ms,
|
||||||
|
} => Self::AgentRateLimitSuspected {
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
resets_at_ms: *resets_at_ms,
|
||||||
},
|
},
|
||||||
DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput {
|
DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput {
|
||||||
session_id: session_id.to_string(),
|
session_id: session_id.to_string(),
|
||||||
@ -222,3 +487,55 @@ 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LS6 : un `AgentRateLimited` du domaine se relaie en DTO portant le même agent
|
||||||
|
/// et l'heure de reset (époche-ms), et se sérialise en `"agentRateLimited"` avec
|
||||||
|
/// `resetsAtMs` — le fait neutre que le front badge « limité jusqu'à HH:MM ».
|
||||||
|
#[test]
|
||||||
|
fn rate_limited_relays_to_dto_and_wire() {
|
||||||
|
let dto = DomainEventDto::from(&DomainEvent::AgentRateLimited {
|
||||||
|
agent_id: agent(11),
|
||||||
|
resets_at_ms: Some(1_700_000_000_000),
|
||||||
|
});
|
||||||
|
let json = serde_json::to_value(&dto).expect("serialisable");
|
||||||
|
assert_eq!(json["type"], "agentRateLimited");
|
||||||
|
assert_eq!(json["agentId"], agent(11).to_string());
|
||||||
|
assert_eq!(json["resetsAtMs"], 1_700_000_000_000_i64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -5,23 +5,56 @@
|
|||||||
//! ([`state::AppState`], the composition root),
|
//! ([`state::AppState`], the composition root),
|
||||||
//! - exposes `#[tauri::command]` handlers ([`commands`]) mapping DTOs ↔ use cases,
|
//! - exposes `#[tauri::command]` handlers ([`commands`]) mapping DTOs ↔ use cases,
|
||||||
//! - relays domain events to the frontend ([`events::TauriEventRelay`]),
|
//! - 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.
|
//! The wiring lives in the library (testable) and `main.rs` is a thin shim.
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
pub mod chat;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod dto;
|
pub mod dto;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
pub mod mcp_bridge;
|
||||||
|
pub mod mcp_endpoint;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
use state::AppState;
|
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.
|
/// Builds and runs the Tauri application.
|
||||||
///
|
///
|
||||||
/// Sets up the composition root (resolving the app-data directory via the Tauri
|
/// 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 tauri::WindowEvent::CloseRequested { .. } = event {
|
||||||
if let Some(state) = handle.try_state::<AppState>() {
|
if let Some(state) = handle.try_state::<AppState>() {
|
||||||
let pty = std::sync::Arc::clone(&state.pty_port);
|
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();
|
let handles = state.terminal_sessions.handles();
|
||||||
tauri::async_runtime::block_on(async move {
|
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 {
|
for h in handles {
|
||||||
let _ = pty.kill(&h).await;
|
let _ = pty.kill(&h).await;
|
||||||
}
|
}
|
||||||
@ -78,6 +125,12 @@ pub fn run() {
|
|||||||
commands::open_project,
|
commands::open_project,
|
||||||
commands::close_project,
|
commands::close_project,
|
||||||
commands::list_projects,
|
commands::list_projects,
|
||||||
|
commands::read_project_context,
|
||||||
|
commands::update_project_context,
|
||||||
|
commands::get_project_permissions,
|
||||||
|
commands::update_project_permissions,
|
||||||
|
commands::update_agent_permissions,
|
||||||
|
commands::resolve_agent_permissions,
|
||||||
commands::open_terminal,
|
commands::open_terminal,
|
||||||
commands::write_terminal,
|
commands::write_terminal,
|
||||||
commands::resize_terminal,
|
commands::resize_terminal,
|
||||||
@ -97,12 +150,30 @@ pub fn run() {
|
|||||||
commands::save_profile,
|
commands::save_profile,
|
||||||
commands::delete_profile,
|
commands::delete_profile,
|
||||||
commands::configure_profiles,
|
commands::configure_profiles,
|
||||||
|
commands::list_embedder_profiles,
|
||||||
|
commands::save_embedder_profile,
|
||||||
|
commands::delete_embedder_profile,
|
||||||
|
commands::describe_embedder_engines,
|
||||||
|
commands::dismiss_embedder_suggestion,
|
||||||
commands::create_agent,
|
commands::create_agent,
|
||||||
commands::list_agents,
|
commands::list_agents,
|
||||||
|
commands::list_live_agents,
|
||||||
|
commands::attach_live_agent,
|
||||||
commands::read_agent_context,
|
commands::read_agent_context,
|
||||||
commands::update_agent_context,
|
commands::update_agent_context,
|
||||||
commands::delete_agent,
|
commands::delete_agent,
|
||||||
commands::launch_agent,
|
commands::launch_agent,
|
||||||
|
commands::change_agent_profile,
|
||||||
|
commands::agent_send,
|
||||||
|
commands::cancel_resume,
|
||||||
|
commands::set_resume_at,
|
||||||
|
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::create_template,
|
||||||
commands::update_template,
|
commands::update_template,
|
||||||
commands::list_templates,
|
commands::list_templates,
|
||||||
@ -125,6 +196,14 @@ pub fn run() {
|
|||||||
commands::delete_skill,
|
commands::delete_skill,
|
||||||
commands::assign_skill_to_agent,
|
commands::assign_skill_to_agent,
|
||||||
commands::unassign_skill_from_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,
|
commands::move_tab_to_new_window,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
// Prevents an extra console window on Windows in release builds.
|
// Prevents an extra console window on Windows in release builds.
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![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
|
// WebKitGTK's DMABUF renderer causes a blank/white window on many Linux
|
||||||
// setups (recent Mesa/Nvidia drivers, common on Arch). Disable it before
|
// setups (recent Mesa/Nvidia drivers, common on Arch). Disable it before
|
||||||
// the webview initializes, unless the user has explicitly set the variable.
|
// 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")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
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`].
|
/// Thread-safe; cloned `Arc<PtyBridge>` is held in [`crate::state::AppState`].
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct PtyBridge {
|
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 {
|
impl PtyBridge {
|
||||||
@ -46,21 +50,39 @@ impl PtyBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers the output channel for a session (called when a terminal is
|
/// Registers (or replaces) the output channel for a session and returns the
|
||||||
/// opened, from a `#[tauri::command]` that receives the `Channel` argument).
|
/// **generation** of this registration. Each call for a session bumps the
|
||||||
pub fn register(&self, session: SessionId, channel: Channel<PtyChunk>) {
|
/// 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() {
|
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) {
|
pub fn unregister(&self, session: &SessionId) {
|
||||||
if let Ok(mut map) = self.channels.lock() {
|
if let Ok(mut map) = self.channels.lock() {
|
||||||
map.remove(session);
|
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.
|
/// Forwards a chunk of output bytes to a session's channel.
|
||||||
///
|
///
|
||||||
/// Returns `true` if the chunk was delivered, `false` if no channel is
|
/// Returns `true` if the chunk was delivered, `false` if no channel is
|
||||||
@ -71,7 +93,7 @@ impl PtyBridge {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
match map.get(session) {
|
match map.get(session) {
|
||||||
Some(channel) => channel.send(chunk).is_ok(),
|
Some((_, channel)) => channel.send(chunk).is_ok(),
|
||||||
None => false,
|
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,
|
LayoutOperationDto, OpenTerminalRequestDto, ReattachResultDto, ResizeTerminalRequestDto,
|
||||||
TerminalClosedDto, WriteTerminalRequestDto,
|
TerminalClosedDto, WriteTerminalRequestDto,
|
||||||
};
|
};
|
||||||
|
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
|
||||||
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
|
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
|
||||||
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId};
|
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId};
|
||||||
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
|
|
||||||
|
|
||||||
use application::{AppError, HealthInput};
|
use application::{AppError, HealthInput};
|
||||||
use domain::events::DomainEvent;
|
use domain::events::DomainEvent;
|
||||||
use domain::ids::{AgentId, SessionId};
|
use domain::ids::{AgentId, ProfileId, SessionId};
|
||||||
use domain::ProjectId;
|
use domain::ProjectId;
|
||||||
use domain::TemplateVersion;
|
|
||||||
use domain::TemplateId;
|
use domain::TemplateId;
|
||||||
|
use domain::TemplateVersion;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -93,6 +93,26 @@ fn domain_event_dto_maps_agent_launched() {
|
|||||||
assert_eq!(v["sessionId"], sid.to_string());
|
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]
|
#[test]
|
||||||
fn domain_event_dto_maps_template_updated_version() {
|
fn domain_event_dto_maps_template_updated_version() {
|
||||||
let tid = TemplateId::from_uuid(Uuid::nil());
|
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]));
|
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)
|
// Terminal DTOs (L3)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -188,7 +267,10 @@ fn terminal_closed_dto_serialises_code_camel_case() {
|
|||||||
|
|
||||||
// Signalled (None) round-trips as null.
|
// Signalled (None) round-trips as null.
|
||||||
let none = TerminalClosedDto::from(CloseTerminalOutput { code: None });
|
let none = TerminalClosedDto::from(CloseTerminalOutput { code: None });
|
||||||
assert_eq!(serde_json::to_value(&none).unwrap(), json!({ "code": null }));
|
assert_eq!(
|
||||||
|
serde_json::to_value(&none).unwrap(),
|
||||||
|
json!({ "code": null })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -198,7 +280,10 @@ fn reattach_result_dto_serialises_camel_case() {
|
|||||||
scrollback: vec![104, 105],
|
scrollback: vec![104, 105],
|
||||||
};
|
};
|
||||||
let v = serde_json::to_value(&dto).unwrap();
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
assert_eq!(v, json!({ "sessionId": "sess-1", "scrollback": [104, 105] }));
|
assert_eq!(
|
||||||
|
v,
|
||||||
|
json!({ "sessionId": "sess-1", "scrollback": [104, 105] })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -223,6 +308,9 @@ fn layout_dto_serialises_camelcase_tagged_tree() {
|
|||||||
id: nid(1),
|
id: nid(1),
|
||||||
session: None,
|
session: None,
|
||||||
agent: None,
|
agent: None,
|
||||||
|
conversation_id: None,
|
||||||
|
engine_session_id: None,
|
||||||
|
agent_was_running: false,
|
||||||
});
|
});
|
||||||
let dto = LayoutDto::from(LoadLayoutOutput {
|
let dto = LayoutDto::from(LoadLayoutOutput {
|
||||||
layout_id: domain::LayoutId::new_random(),
|
layout_id: domain::LayoutId::new_random(),
|
||||||
@ -322,8 +410,23 @@ fn layout_dto_round_trips_a_split_tree_shape() {
|
|||||||
id: nid(1),
|
id: nid(1),
|
||||||
session: None,
|
session: None,
|
||||||
agent: 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();
|
.unwrap();
|
||||||
let dto = LayoutDto::from(LoadLayoutOutput {
|
let dto = LayoutDto::from(LoadLayoutOutput {
|
||||||
layout_id: domain::LayoutId::new_random(),
|
layout_id: domain::LayoutId::new_random(),
|
||||||
|
|||||||
@ -3,11 +3,16 @@
|
|||||||
//! and `From<LaunchAgentOutput>` for [`TerminalSessionDto`].
|
//! and `From<LaunchAgentOutput>` for [`TerminalSessionDto`].
|
||||||
|
|
||||||
use app_tauri_lib::dto::{
|
use app_tauri_lib::dto::{
|
||||||
parse_agent_id, AgentDto, AgentListDto, CreateAgentRequestDto, LaunchAgentRequestDto,
|
parse_agent_id, AgentDto, AgentListDto, ConversationDetailsDto, CreateAgentRequestDto,
|
||||||
TerminalSessionDto, UpdateAgentContextRequestDto,
|
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::ids::{AgentId, NodeId, ProfileId, SessionId};
|
||||||
|
use domain::ports::ConversationDetails;
|
||||||
use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
|
use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
|
||||||
use domain::{Agent, AgentOrigin, ProjectPath};
|
use domain::{Agent, AgentOrigin, ProjectPath};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@ -39,7 +44,10 @@ fn agent_dto_serialises_camelcase() {
|
|||||||
assert_eq!(v["id"], agent.id.to_string());
|
assert_eq!(v["id"], agent.id.to_string());
|
||||||
assert_eq!(v["name"], "My Agent");
|
assert_eq!(v["name"], "My Agent");
|
||||||
assert_eq!(v["contextPath"], "agents/my-agent.md", "camelCase key");
|
assert_eq!(v["contextPath"], "agents/my-agent.md", "camelCase key");
|
||||||
assert_eq!(v["profileId"], ProfileId::from_uuid(Uuid::from_u128(2)).to_string());
|
assert_eq!(
|
||||||
|
v["profileId"],
|
||||||
|
ProfileId::from_uuid(Uuid::from_u128(2)).to_string()
|
||||||
|
);
|
||||||
assert_eq!(v["synchronized"], false);
|
assert_eq!(v["synchronized"], false);
|
||||||
// origin: tagged `{ "type": "scratch" }`
|
// origin: tagged `{ "type": "scratch" }`
|
||||||
assert_eq!(v["origin"]["type"], "scratch");
|
assert_eq!(v["origin"]["type"], "scratch");
|
||||||
@ -63,7 +71,9 @@ fn agent_list_dto_is_transparent_array() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn create_agent_output_maps_to_agent_dto() {
|
fn create_agent_output_maps_to_agent_dto() {
|
||||||
let agent = make_agent(5, 6);
|
let agent = make_agent(5, 6);
|
||||||
let out = CreateAgentOutput { agent: agent.clone() };
|
let out = CreateAgentOutput {
|
||||||
|
agent: agent.clone(),
|
||||||
|
};
|
||||||
let dto = AgentDto::from(out);
|
let dto = AgentDto::from(out);
|
||||||
assert_eq!(dto.0.id, agent.id);
|
assert_eq!(dto.0.id, agent.id);
|
||||||
}
|
}
|
||||||
@ -126,6 +136,132 @@ fn launch_agent_request_deserialises_camelcase() {
|
|||||||
assert_eq!(dto.cols, 80);
|
assert_eq!(dto.cols, 80);
|
||||||
assert_eq!(dto.project_id, project_id);
|
assert_eq!(dto.project_id, project_id);
|
||||||
assert_eq!(dto.agent_id, agent_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,13 @@ fn launch_agent_output_maps_to_terminal_session_dto() {
|
|||||||
);
|
);
|
||||||
session.status = SessionStatus::Running;
|
session.status = SessionStatus::Running;
|
||||||
|
|
||||||
let out = LaunchAgentOutput { session };
|
let out = LaunchAgentOutput {
|
||||||
|
session,
|
||||||
|
assigned_conversation_id: None,
|
||||||
|
engine_session_id: None,
|
||||||
|
structured: None,
|
||||||
|
profile: None,
|
||||||
|
};
|
||||||
let dto = TerminalSessionDto::from(out);
|
let dto = TerminalSessionDto::from(out);
|
||||||
|
|
||||||
assert_eq!(dto.session_id, session_id.to_string());
|
assert_eq!(dto.session_id, session_id.to_string());
|
||||||
@ -176,9 +318,54 @@ fn launch_agent_output_maps_to_terminal_session_dto() {
|
|||||||
assert_eq!(dto.rows, 24);
|
assert_eq!(dto.rows, 24);
|
||||||
assert_eq!(dto.cols, 80);
|
assert_eq!(dto.cols, 80);
|
||||||
|
|
||||||
|
// Nothing assigned ⇒ no conversation id surfaced.
|
||||||
|
assert_eq!(dto.assigned_conversation_id, None);
|
||||||
|
|
||||||
// Also verify camelCase serialisation.
|
// Also verify camelCase serialisation.
|
||||||
let v = serde_json::to_value(&dto).unwrap();
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
assert_eq!(v["sessionId"], session_id.to_string());
|
assert_eq!(v["sessionId"], session_id.to_string());
|
||||||
assert_eq!(v["cwd"], "/tmp/project");
|
assert_eq!(v["cwd"], "/tmp/project");
|
||||||
assert!(v.get("session_id").is_none(), "no snake_case leak");
|
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,
|
||||||
|
profile: 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);
|
||||||
|
}
|
||||||
223
crates/app-tauri/tests/dto_chat.rs
Normal file
223
crates/app-tauri/tests/dto_chat.rs
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
//! 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),
|
||||||
|
profile: None,
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
profile: 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,
|
||||||
|
profile: 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,
|
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
|
||||||
GitFileStatusDto, GitStageRequestDto, GitStatusListDto, GraphCommitDto, GraphCommitListDto,
|
GitFileStatusDto, GitStageRequestDto, GitStatusListDto, GraphCommitDto, GraphCommitListDto,
|
||||||
};
|
};
|
||||||
use application::{GitBranchesOutput, GitCommitOutput, GitGraphOutput, GitLogOutput, GitStatusOutput};
|
use application::{
|
||||||
|
GitBranchesOutput, GitCommitOutput, GitGraphOutput, GitLogOutput, GitStatusOutput,
|
||||||
|
};
|
||||||
use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit};
|
use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|||||||
@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
use app_tauri_lib::dto::{
|
use app_tauri_lib::dto::{
|
||||||
parse_layout_id, CreateLayoutRequestDto, CreateLayoutResultDto, DeleteLayoutRequestDto,
|
parse_layout_id, CreateLayoutRequestDto, CreateLayoutResultDto, DeleteLayoutRequestDto,
|
||||||
DeleteLayoutResultDto, LayoutInfoDto, LayoutOperationDto, ListLayoutsDto, RenameLayoutRequestDto,
|
DeleteLayoutResultDto, LayoutInfoDto, LayoutOperationDto, ListLayoutsDto,
|
||||||
SetActiveLayoutRequestDto,
|
RenameLayoutRequestDto, SetActiveLayoutRequestDto,
|
||||||
};
|
};
|
||||||
use application::{
|
use application::{
|
||||||
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutKind, ListLayoutsOutput,
|
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutKind, ListLayoutsOutput,
|
||||||
@ -63,8 +63,16 @@ fn layout_info_dto_git_graph_kind() {
|
|||||||
fn list_layouts_dto_from_output() {
|
fn list_layouts_dto_from_output() {
|
||||||
let out = ListLayoutsOutput {
|
let out = ListLayoutsOutput {
|
||||||
layouts: vec![
|
layouts: vec![
|
||||||
LayoutInfo { id: lid(1), name: "Default".to_owned(), kind: LayoutKind::Terminal },
|
LayoutInfo {
|
||||||
LayoutInfo { id: lid(2), name: "Backend".to_owned(), kind: LayoutKind::GitGraph },
|
id: lid(1),
|
||||||
|
name: "Default".to_owned(),
|
||||||
|
kind: LayoutKind::Terminal,
|
||||||
|
},
|
||||||
|
LayoutInfo {
|
||||||
|
id: lid(2),
|
||||||
|
name: "Backend".to_owned(),
|
||||||
|
kind: LayoutKind::GitGraph,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
active_id: lid(1),
|
active_id: lid(1),
|
||||||
};
|
};
|
||||||
@ -204,7 +212,10 @@ fn set_cell_agent_op_deserialises_with_agent() {
|
|||||||
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
|
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
|
||||||
let op = dto.into_operation().unwrap();
|
let op = dto.into_operation().unwrap();
|
||||||
match op {
|
match op {
|
||||||
application::LayoutOperation::SetCellAgent { target: t, agent: a } => {
|
application::LayoutOperation::SetCellAgent {
|
||||||
|
target: t,
|
||||||
|
agent: a,
|
||||||
|
} => {
|
||||||
assert_eq!(t, target);
|
assert_eq!(t, target);
|
||||||
assert_eq!(a, Some(agent));
|
assert_eq!(a, Some(agent));
|
||||||
}
|
}
|
||||||
@ -223,7 +234,10 @@ fn set_cell_agent_op_deserialises_with_null_agent() {
|
|||||||
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
|
let dto: LayoutOperationDto = serde_json::from_value(raw).unwrap();
|
||||||
let op = dto.into_operation().unwrap();
|
let op = dto.into_operation().unwrap();
|
||||||
match op {
|
match op {
|
||||||
application::LayoutOperation::SetCellAgent { target: t, agent: None } => {
|
application::LayoutOperation::SetCellAgent {
|
||||||
|
target: t,
|
||||||
|
agent: None,
|
||||||
|
} => {
|
||||||
assert_eq!(t, target);
|
assert_eq!(t, target);
|
||||||
}
|
}
|
||||||
_ => panic!("expected SetCellAgent with None agent"),
|
_ => panic!("expected SetCellAgent with None agent"),
|
||||||
@ -245,3 +259,66 @@ fn set_cell_agent_op_deserialises_with_absent_agent_defaults_to_none() {
|
|||||||
_ => panic!("expected SetCellAgent with None agent"),
|
_ => 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(),
|
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||||
Some(format!("{command} --version")),
|
Some(format!("{command} --version")),
|
||||||
"{projectRoot}",
|
"{projectRoot}",
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap()
|
.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()
|
ProfileId::from_uuid(Uuid::from_u128(2)).to_string()
|
||||||
);
|
);
|
||||||
// no snake_case leak
|
// no snake_case leak
|
||||||
assert!(v.get("content_md").is_none(), "no snake_case leak for contentMd");
|
assert!(
|
||||||
assert!(v.get("default_profile_id").is_none(), "no snake_case leak for defaultProfileId");
|
v.get("content_md").is_none(),
|
||||||
|
"no snake_case leak for contentMd"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
v.get("default_profile_id").is_none(),
|
||||||
|
"no snake_case leak for defaultProfileId"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -98,7 +104,9 @@ fn template_list_dto_empty() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn create_template_output_maps_to_template_dto() {
|
fn create_template_output_maps_to_template_dto() {
|
||||||
let tmpl = make_template(5, 6);
|
let tmpl = make_template(5, 6);
|
||||||
let out = CreateTemplateOutput { template: tmpl.clone() };
|
let out = CreateTemplateOutput {
|
||||||
|
template: tmpl.clone(),
|
||||||
|
};
|
||||||
let dto = TemplateDto::from(out);
|
let dto = TemplateDto::from(out);
|
||||||
assert_eq!(dto.0.id, tmpl.id);
|
assert_eq!(dto.0.id, tmpl.id);
|
||||||
}
|
}
|
||||||
@ -107,7 +115,9 @@ fn create_template_output_maps_to_template_dto() {
|
|||||||
fn update_template_output_maps_to_template_dto() {
|
fn update_template_output_maps_to_template_dto() {
|
||||||
let tmpl = make_template(7, 8);
|
let tmpl = make_template(7, 8);
|
||||||
let bumped = tmpl.with_updated_content(MarkdownDoc::new("# Updated".to_owned()));
|
let bumped = tmpl.with_updated_content(MarkdownDoc::new("# Updated".to_owned()));
|
||||||
let out = UpdateTemplateOutput { template: bumped.clone() };
|
let out = UpdateTemplateOutput {
|
||||||
|
template: bumped.clone(),
|
||||||
|
};
|
||||||
let dto = TemplateDto::from(out);
|
let dto = TemplateDto::from(out);
|
||||||
assert_eq!(dto.0.version, TemplateVersion(2));
|
assert_eq!(dto.0.version, TemplateVersion(2));
|
||||||
assert_eq!(dto.0.id, bumped.id);
|
assert_eq!(dto.0.id, bumped.id);
|
||||||
@ -132,7 +142,10 @@ fn agent_drift_dto_serialises_camelcase() {
|
|||||||
assert_eq!(v["from"], 1u64);
|
assert_eq!(v["from"], 1u64);
|
||||||
assert_eq!(v["to"], 3u64);
|
assert_eq!(v["to"], 3u64);
|
||||||
// no snake_case leak
|
// no snake_case leak
|
||||||
assert!(v.get("agent_id").is_none(), "no snake_case leak for agentId");
|
assert!(
|
||||||
|
v.get("agent_id").is_none(),
|
||||||
|
"no snake_case leak for agentId"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -15,7 +15,10 @@ fn move_tab_result_serializes_new_window_id_camel_case() {
|
|||||||
let dto = MoveTabResultDto::from(out);
|
let dto = MoveTabResultDto::from(out);
|
||||||
let json = serde_json::to_string(&dto).unwrap();
|
let json = serde_json::to_string(&dto).unwrap();
|
||||||
assert!(json.contains("\"newWindowId\""), "json was {json}");
|
assert!(json.contains("\"newWindowId\""), "json was {json}");
|
||||||
assert!(!json.contains("new_window_id"), "no snake_case leak: {json}");
|
assert!(
|
||||||
|
!json.contains("new_window_id"),
|
||||||
|
"no snake_case leak: {json}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
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)));
|
bridge.register(session, capturing_channel(Arc::clone(&sink)));
|
||||||
|
|
||||||
let delivered = bridge.send_output(&session, vec![104, 105]);
|
let delivered = bridge.send_output(&session, vec![104, 105]);
|
||||||
assert!(delivered, "send_output should return true for a live session");
|
assert!(
|
||||||
|
delivered,
|
||||||
|
"send_output should return true for a live session"
|
||||||
|
);
|
||||||
|
|
||||||
let captured = sink.lock().unwrap();
|
let captured = sink.lock().unwrap();
|
||||||
assert_eq!(captured.as_slice(), &[vec![104, 105]]);
|
assert_eq!(captured.as_slice(), &[vec![104, 105]]);
|
||||||
@ -82,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(&first)));
|
||||||
bridge.register(session, capturing_channel(Arc::clone(&second)));
|
bridge.register(session, capturing_channel(Arc::clone(&second)));
|
||||||
assert_eq!(bridge.active_sessions(), 1, "same id is replaced, not added");
|
assert_eq!(
|
||||||
|
bridge.active_sessions(),
|
||||||
|
1,
|
||||||
|
"same id is replaced, not added"
|
||||||
|
);
|
||||||
|
|
||||||
bridge.send_output(&session, vec![9]);
|
bridge.send_output(&session, vec![9]);
|
||||||
assert!(first.lock().unwrap().is_empty(), "old channel no longer used");
|
assert!(
|
||||||
|
first.lock().unwrap().is_empty(),
|
||||||
|
"old channel no longer used"
|
||||||
|
);
|
||||||
assert_eq!(second.lock().unwrap().as_slice(), &[vec![9]]);
|
assert_eq!(second.lock().unwrap().as_slice(), &[vec![9]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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]));
|
||||||
|
}
|
||||||
|
|||||||
167
crates/app-tauri/tests/session_limit_wiring.rs
Normal file
167
crates/app-tauri/tests/session_limit_wiring.rs
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
//! Integration test for the **session-limit** wiring in the composition root
|
||||||
|
//! (ARCHITECTURE §21, LS7).
|
||||||
|
//!
|
||||||
|
//! Proves that [`AppState`] actually *constructs and wires* the
|
||||||
|
//! `SessionLimitService` (the gap LS7 closes: the LS1–LS4 domain/application
|
||||||
|
//! machinery existed but was never instantiated at runtime). The detect→plan→
|
||||||
|
//! resume→cancel logic itself is covered by the application/infrastructure unit
|
||||||
|
//! tests (LS3/LS4); here we assert the **composition** the commands rely on:
|
||||||
|
//! the service is present, `on_rate_limited` arms a cancellable resume that emits
|
||||||
|
//! the domain events on the real bus, and `cancel_resume` is a clean no-op for an
|
||||||
|
//! unknown agent (never a panic).
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use app_tauri_lib::state::AppState;
|
||||||
|
use domain::events::DomainEvent;
|
||||||
|
use domain::ids::NodeId;
|
||||||
|
use domain::AgentId;
|
||||||
|
use infrastructure::UuidGenerator;
|
||||||
|
use domain::ports::IdGenerator;
|
||||||
|
|
||||||
|
fn temp_path(tag: &str) -> PathBuf {
|
||||||
|
let ids = UuidGenerator::new();
|
||||||
|
std::env::temp_dir().join(format!("idea-sl-test-{tag}-{}", ids.new_uuid()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn agent(n: u128) -> AgentId {
|
||||||
|
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node(n: u128) -> NodeId {
|
||||||
|
NodeId::from_uuid(uuid::Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cancel_resume` on an agent with no armed resume returns `false` (and never
|
||||||
|
/// panics): the service is wired and behaves as a clean no-op.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancel_resume_is_a_clean_noop_for_unknown_agent() {
|
||||||
|
let state = AppState::build(temp_path("appdata"));
|
||||||
|
assert!(
|
||||||
|
!state.session_limit_service.cancel_resume(agent(1)),
|
||||||
|
"no armed resume ⇒ cancel returns false"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A structured rate-limit signal with a **future** reset arms a cancellable
|
||||||
|
/// resume: the wired service publishes `AgentRateLimited` then
|
||||||
|
/// `AgentResumeScheduled` on the **real** event bus, and `cancel_resume` then
|
||||||
|
/// publishes `AgentResumeCancelled` and returns `true`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn on_rate_limited_arms_a_cancellable_resume_over_the_real_bus() {
|
||||||
|
let state = AppState::build(temp_path("appdata"));
|
||||||
|
let mut rx = state.event_bus.raw_receiver();
|
||||||
|
|
||||||
|
// Reset far in the future so the scheduler does NOT fire before we cancel.
|
||||||
|
let resets_at_ms = i64::MAX;
|
||||||
|
state
|
||||||
|
.session_limit_service
|
||||||
|
.on_rate_limited(agent(7), node(70), None, Some(resets_at_ms));
|
||||||
|
|
||||||
|
// The two arming events, in order, on the bus.
|
||||||
|
let mut saw_rate_limited = false;
|
||||||
|
let mut saw_scheduled = false;
|
||||||
|
for _ in 0..32 {
|
||||||
|
match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
|
||||||
|
Ok(Ok(DomainEvent::AgentRateLimited { agent_id, .. })) if agent_id == agent(7) => {
|
||||||
|
saw_rate_limited = true;
|
||||||
|
}
|
||||||
|
Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(7) => {
|
||||||
|
saw_scheduled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) => continue,
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(saw_rate_limited, "AgentRateLimited relayed on the bus");
|
||||||
|
assert!(saw_scheduled, "AgentResumeScheduled relayed on the bus");
|
||||||
|
|
||||||
|
// The window is cancellable: cancel succeeds and emits AgentResumeCancelled.
|
||||||
|
assert!(
|
||||||
|
state.session_limit_service.cancel_resume(agent(7)),
|
||||||
|
"an armed resume is cancellable"
|
||||||
|
);
|
||||||
|
let mut saw_cancelled = false;
|
||||||
|
for _ in 0..32 {
|
||||||
|
match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
|
||||||
|
Ok(Ok(DomainEvent::AgentResumeCancelled { agent_id })) if agent_id == agent(7) => {
|
||||||
|
saw_cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) => continue,
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(saw_cancelled, "AgentResumeCancelled relayed on the bus");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `set_resume_at` (filet humain niveau 3) résout l'agent→cellule dans les registries
|
||||||
|
/// des sessions vivantes ; **sans cellule vivante**, la résolution échoue ⇒ la commande
|
||||||
|
/// renvoie `NOT_FOUND` sans armer quoi que ce soit (pas d'armement orphelin).
|
||||||
|
///
|
||||||
|
/// La commande `#[tauri::command]` exige une `State<AppState>` non constructible hors
|
||||||
|
/// runtime Tauri ; on couvre donc ici sa **précondition exacte** : sur un `AppState`
|
||||||
|
/// neuf (aucune session vivante), aucune registry ne résout l'agent — c'est précisément
|
||||||
|
/// le `None` qui produit le `NOT_FOUND` dans `set_resume_at` (cf. commands.rs:1420-1428).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn set_resume_at_resolves_no_cell_for_an_agent_without_a_live_session() {
|
||||||
|
let state = AppState::build(temp_path("appdata"));
|
||||||
|
let unknown = agent(99);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
state.structured_sessions.node_for_agent(&unknown).is_none(),
|
||||||
|
"aucune session structurée vivante ⇒ pas de cellule"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
state.terminal_sessions.node_for_agent(&unknown).is_none(),
|
||||||
|
"aucune session terminal vivante ⇒ pas de cellule"
|
||||||
|
);
|
||||||
|
// Conjonction = la branche `ok_or_else(NotFound)` de set_resume_at est prise :
|
||||||
|
// la saisie d'heure n'a pas de cible ⇒ aucun armement orphelin.
|
||||||
|
let resolved = state
|
||||||
|
.structured_sessions
|
||||||
|
.node_for_agent(&unknown)
|
||||||
|
.or_else(|| state.terminal_sessions.node_for_agent(&unknown));
|
||||||
|
assert!(resolved.is_none(), "set_resume_at ⇒ NOT_FOUND (aucun armement orphelin)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parité runtime du filet humain : `confirm_human_resume` (la délégation de
|
||||||
|
/// `set_resume_at`) arme une reprise annulable sur le **vrai** bus — `AgentRateLimited`
|
||||||
|
/// puis `AgentResumeScheduled` — exactement comme la branche auto `on_rate_limited`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn confirm_human_resume_arms_a_cancellable_resume_over_the_real_bus() {
|
||||||
|
let state = AppState::build(temp_path("appdata"));
|
||||||
|
let mut rx = state.event_bus.raw_receiver();
|
||||||
|
|
||||||
|
// Reset très loin dans le futur : le scheduler ne tire pas avant l'annulation.
|
||||||
|
let resets_at_ms = i64::MAX;
|
||||||
|
state
|
||||||
|
.session_limit_service
|
||||||
|
.confirm_human_resume(agent(8), node(80), None, resets_at_ms);
|
||||||
|
|
||||||
|
let mut saw_rate_limited = false;
|
||||||
|
let mut saw_scheduled = false;
|
||||||
|
for _ in 0..32 {
|
||||||
|
match tokio::time::timeout(Duration::from_secs(2), rx.recv()).await {
|
||||||
|
Ok(Ok(DomainEvent::AgentRateLimited { agent_id, .. })) if agent_id == agent(8) => {
|
||||||
|
saw_rate_limited = true;
|
||||||
|
}
|
||||||
|
Ok(Ok(DomainEvent::AgentResumeScheduled { agent_id, .. })) if agent_id == agent(8) => {
|
||||||
|
saw_scheduled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) => continue,
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(saw_rate_limited, "AgentRateLimited relayed on the bus (humain)");
|
||||||
|
assert!(saw_scheduled, "AgentResumeScheduled relayed on the bus (humain)");
|
||||||
|
|
||||||
|
// Annulable par la même voie que l'auto.
|
||||||
|
assert!(
|
||||||
|
state.session_limit_service.cancel_resume(agent(8)),
|
||||||
|
"an armed human resume is cancellable"
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -14,6 +14,9 @@ serde = { workspace = true }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
# `v5` derives stable reference-profile ids from a fixed namespace (catalogue).
|
# `v5` derives stable reference-profile ids from a fixed namespace (catalogue).
|
||||||
uuid = { workspace = true }
|
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]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@ -18,7 +18,11 @@
|
|||||||
//! making the reference profiles addressable across runs without a registry.
|
//! making the reference profiles addressable across runs without a registry.
|
||||||
|
|
||||||
use domain::ids::ProfileId;
|
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.
|
/// A fixed UUID namespace used to derive stable ids for reference profiles.
|
||||||
/// (Random-looking but constant; only its stability matters.)
|
/// (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"),
|
.expect("CLAUDE.md is a valid convention target"),
|
||||||
Some("claude --version".to_owned()),
|
Some("claude --version".to_owned()),
|
||||||
"{agentRunDir}",
|
"{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(
|
AgentProfile::new(
|
||||||
reference_id("codex"),
|
reference_id("codex"),
|
||||||
"OpenAI Codex CLI",
|
"OpenAI Codex CLI",
|
||||||
@ -65,8 +77,19 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
|||||||
.expect("AGENTS.md is a valid convention target"),
|
.expect("AGENTS.md is a valid convention target"),
|
||||||
Some("codex --version".to_owned()),
|
Some("codex --version".to_owned()),
|
||||||
"{agentRunDir}",
|
"{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(
|
AgentProfile::new(
|
||||||
reference_id("gemini"),
|
reference_id("gemini"),
|
||||||
"Gemini CLI",
|
"Gemini CLI",
|
||||||
@ -76,6 +99,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
|||||||
.expect("GEMINI.md is a valid convention target"),
|
.expect("GEMINI.md is a valid convention target"),
|
||||||
Some("gemini --version".to_owned()),
|
Some("gemini --version".to_owned()),
|
||||||
"{agentRunDir}",
|
"{agentRunDir}",
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("gemini reference profile is valid"),
|
.expect("gemini reference profile is valid"),
|
||||||
AgentProfile::new(
|
AgentProfile::new(
|
||||||
@ -87,7 +111,123 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
|||||||
.expect("aider flag template is non-empty"),
|
.expect("aider flag template is non-empty"),
|
||||||
Some("aider --version".to_owned()),
|
Some("aider --version".to_owned()),
|
||||||
"{agentRunDir}",
|
"{agentRunDir}",
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("aider reference profile is valid"),
|
.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,33 @@
|
|||||||
//! ports. Launching an agent (PTY + injection) is L6.
|
//! ports. Launching an agent (PTY + injection) is L6.
|
||||||
|
|
||||||
mod catalogue;
|
mod catalogue;
|
||||||
|
mod inspect;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
|
mod resume;
|
||||||
|
mod session_limit;
|
||||||
|
mod structured;
|
||||||
mod usecases;
|
mod usecases;
|
||||||
|
|
||||||
pub(crate) use lifecycle::unique_md_path;
|
pub(crate) use lifecycle::unique_md_path;
|
||||||
|
pub(crate) use lifecycle::ReattachDecision;
|
||||||
|
|
||||||
pub use catalogue::{reference_profile_id, reference_profiles};
|
pub use session_limit::{AgentResumer, SessionLimitService, RESUME_PROMPT};
|
||||||
|
pub use structured::{
|
||||||
|
drain_with_readiness, drain_with_readiness_outcome, send_blocking, TurnOutcome,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub use catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
|
||||||
|
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
|
||||||
pub use lifecycle::{
|
pub use lifecycle::{
|
||||||
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput,
|
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
||||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
|
||||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, UpdateAgentContext,
|
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||||
UpdateAgentContextInput,
|
ListAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
|
||||||
|
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
|
||||||
|
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
|
};
|
||||||
|
pub use resume::{
|
||||||
|
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
||||||
};
|
};
|
||||||
pub use usecases::{
|
pub use usecases::{
|
||||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
281
crates/application/src/agent/session_limit.rs
Normal file
281
crates/application/src/agent/session_limit.rs
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
//! [`SessionLimitService`] — orchestration applicative des **limites de session**
|
||||||
|
//! des agents (ARCHITECTURE §21.5) : **détecter → planifier → reprendre**, et
|
||||||
|
//! **annuler**.
|
||||||
|
//!
|
||||||
|
//! Service **pur-ports** (SOLID/hexagonal) : il ne dépend que de traits du domaine
|
||||||
|
//! ([`Clock`], [`Scheduler`], [`EventBus`]) et d'un port applicatif de reprise
|
||||||
|
//! ([`AgentResumer`], implémenté au composition root en LS7 par-dessus `LaunchAgent`).
|
||||||
|
//! Aucune dépendance vers un adapter concret ⇒ entièrement testable avec des fakes.
|
||||||
|
//!
|
||||||
|
//! # État en mémoire uniquement (§21.1-3)
|
||||||
|
//!
|
||||||
|
//! La seule mémoire du service est une table `agent_id → ScheduleId` des reprises
|
||||||
|
//! **armées** (pour pouvoir les annuler). Aucune persistance : à un redémarrage
|
||||||
|
//! d'IdeA le chemin `ListResumableAgents` existant prend le relais.
|
||||||
|
//!
|
||||||
|
//! # Dédoublonnage par agent (§21.10-4)
|
||||||
|
//!
|
||||||
|
//! Un agent n'a qu'**une** reprise armée à la fois : un second signal de limite
|
||||||
|
//! **rafraîchit** l'armement (annule l'ancien, arme le nouveau) au lieu d'empiler.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use domain::ids::{AgentId, NodeId, ScheduleId};
|
||||||
|
use domain::ports::{Clock, EventBus, ScheduledTask, Scheduler};
|
||||||
|
use domain::session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit};
|
||||||
|
use domain::DomainEvent;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
/// Prompt **court** envoyé à l'agent au moment de la reprise automatique (§21.5).
|
||||||
|
///
|
||||||
|
/// `--resume` (via [`domain::ports::SessionPlan::Resume`]) porte déjà tout
|
||||||
|
/// l'historique : ce prompt n'a qu'à **réamorcer** le tour, pas reconstruire le
|
||||||
|
/// contexte. Volontairement neutre et model-agnostique.
|
||||||
|
pub const RESUME_PROMPT: &str =
|
||||||
|
"La limite de session est levée. Reprends là où tu t'étais arrêté.";
|
||||||
|
|
||||||
|
/// Port applicatif de **reprise d'un agent** (frontière implémentée au composition
|
||||||
|
/// root, LS7). Calqué sur les autres traits-passerelles de l'application
|
||||||
|
/// ([`crate::agent::HandoffProvider`], [`crate::agent::ProviderSessionProvider`]) :
|
||||||
|
/// l'app-tauri le branche par-dessus le mécanisme de lancement existant
|
||||||
|
/// (`LaunchAgent` + `AgentSessionFactory`) avec [`domain::ports::SessionPlan::Resume`].
|
||||||
|
///
|
||||||
|
/// Le service ne sait **pas** relancer un agent lui-même (cela exige le `Project`, le
|
||||||
|
/// profil, le contexte préparé, le PTY… que seul `LaunchAgent` résout) ; il délègue
|
||||||
|
/// donc à ce port, en restant testable avec un fake.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait AgentResumer: Send + Sync {
|
||||||
|
/// Relance/réattache l'agent `agent_id` dans sa cellule `node_id`, en reprenant la
|
||||||
|
/// conversation moteur `conversation_id` (`SessionPlan::Resume` côté lancement) et
|
||||||
|
/// en lui transmettant `resume_prompt` comme premier tour.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError`] si la relance échoue (profil/contexte introuvable, échec de
|
||||||
|
/// démarrage de session…). Le service propage l'erreur sans publier `AgentResumed`.
|
||||||
|
async fn resume(
|
||||||
|
&self,
|
||||||
|
agent_id: AgentId,
|
||||||
|
node_id: NodeId,
|
||||||
|
conversation_id: Option<String>,
|
||||||
|
resume_prompt: &str,
|
||||||
|
) -> Result<(), AppError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service d'orchestration des limites de session (§21.5).
|
||||||
|
pub struct SessionLimitService {
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
scheduler: Arc<dyn Scheduler>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
resumer: Arc<dyn AgentResumer>,
|
||||||
|
/// Reprises **armées** non encore tirées : `agent_id → ScheduleId` (en mémoire).
|
||||||
|
armed: Mutex<HashMap<AgentId, ScheduleId>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionLimitService {
|
||||||
|
/// Construit le service depuis ses ports injectés (composition root).
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
scheduler: Arc<dyn Scheduler>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
resumer: Arc<dyn AgentResumer>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
clock,
|
||||||
|
scheduler,
|
||||||
|
events,
|
||||||
|
resumer,
|
||||||
|
armed: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **(a) Détection → planification.** À partir d'un signal de limite
|
||||||
|
/// (`ReadinessSignal::RateLimited`/`TurnOutcome::RateLimited`) pour `agent_id` dans
|
||||||
|
/// la cellule `node_id`, construit un [`SessionLimit`] (source `Structured`),
|
||||||
|
/// calcule le plan via [`plan_resume`] et agit :
|
||||||
|
///
|
||||||
|
/// - [`ResumePlan::Scheduled`] ⇒ publie `AgentRateLimited`, **arme** la reprise via
|
||||||
|
/// [`Scheduler::arm`] (dédoublonnée : un éventuel armement antérieur du même agent
|
||||||
|
/// est annulé d'abord, §21.10-4), mémorise le [`ScheduleId`], puis publie
|
||||||
|
/// `AgentResumeScheduled`.
|
||||||
|
/// - [`ResumePlan::HumanFallback`] (heure de reset inconnue) ⇒ publie
|
||||||
|
/// `AgentRateLimited{None}` puis `AgentRateLimitSuspected{None}` (filet humain ;
|
||||||
|
/// la confirmation UI est LS6/LS8 — ici on émet seulement l'événement).
|
||||||
|
pub fn on_rate_limited(
|
||||||
|
&self,
|
||||||
|
agent_id: AgentId,
|
||||||
|
node_id: NodeId,
|
||||||
|
conversation_id: Option<String>,
|
||||||
|
resets_at_ms: Option<i64>,
|
||||||
|
) {
|
||||||
|
let now = self.clock.now_millis();
|
||||||
|
let limit = SessionLimit::new(resets_at_ms, now, RateLimitSource::Structured);
|
||||||
|
|
||||||
|
match plan_resume(now, &limit, conversation_id) {
|
||||||
|
ResumePlan::Scheduled {
|
||||||
|
fire_at_ms,
|
||||||
|
conversation_id,
|
||||||
|
} => {
|
||||||
|
self.arm_scheduled(agent_id, fire_at_ms, node_id, conversation_id, resets_at_ms);
|
||||||
|
}
|
||||||
|
ResumePlan::HumanFallback => {
|
||||||
|
self.events.publish(DomainEvent::AgentRateLimited {
|
||||||
|
agent_id,
|
||||||
|
resets_at_ms: None,
|
||||||
|
});
|
||||||
|
self.events.publish(DomainEvent::AgentRateLimitSuspected {
|
||||||
|
agent_id,
|
||||||
|
resets_at_ms: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **(d) Filet humain (§21.1 niveau 3).** L'utilisateur a saisi l'heure de reset
|
||||||
|
/// pour un agent en limite **suspectée** (rien n'a matché automatiquement). On
|
||||||
|
/// construit une [`SessionLimit`] de source [`RateLimitSource::Human`], on calcule
|
||||||
|
/// le plan via [`plan_resume`] et on **arme exactement la même reprise** que la
|
||||||
|
/// branche auto : mêmes événements (`AgentRateLimited{Some}` + `AgentResumeScheduled`),
|
||||||
|
/// même dédoublonnage, même annulabilité via [`Self::cancel_resume`].
|
||||||
|
///
|
||||||
|
/// L'heure saisie est traitée par le domaine sans privilège particulier : un reset
|
||||||
|
/// déjà passé est clampé à `now` par [`plan_resume`] ⇒ reprise immédiate. Le cas
|
||||||
|
/// [`ResumePlan::HumanFallback`] est ici inatteignable (`resets_at_ms` est toujours
|
||||||
|
/// `Some`) ; on le traite en no-op défensif pour rester total.
|
||||||
|
pub fn confirm_human_resume(
|
||||||
|
&self,
|
||||||
|
agent_id: AgentId,
|
||||||
|
node_id: NodeId,
|
||||||
|
conversation_id: Option<String>,
|
||||||
|
resets_at_ms: i64,
|
||||||
|
) {
|
||||||
|
let now = self.clock.now_millis();
|
||||||
|
let limit = SessionLimit::new(Some(resets_at_ms), now, RateLimitSource::Human);
|
||||||
|
|
||||||
|
if let ResumePlan::Scheduled {
|
||||||
|
fire_at_ms,
|
||||||
|
conversation_id,
|
||||||
|
} = plan_resume(now, &limit, conversation_id)
|
||||||
|
{
|
||||||
|
self.arm_scheduled(agent_id, fire_at_ms, node_id, conversation_id, Some(resets_at_ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arme (ou ré-arme) une reprise **programmée** pour `agent_id`, fabrique commune aux
|
||||||
|
/// deux entrées (auto §21.1 niveaux 1/2 et filet humain niveau 3). Séquence stricte,
|
||||||
|
/// identique à l'origine — d'où **zéro régression** : publie `AgentRateLimited`
|
||||||
|
/// (avec l'heure de reset connue), **dédoublonne** l'armement précédent via
|
||||||
|
/// [`Self::disarm`] (interne, sans événement), arme le réveil via [`Scheduler::arm`],
|
||||||
|
/// mémorise le [`ScheduleId`], puis publie `AgentResumeScheduled`.
|
||||||
|
///
|
||||||
|
/// `resets_at_ms` est l'heure de reset **annoncée à l'UI** (countdown) ; `fire_at_ms`
|
||||||
|
/// est l'échéance effective (déjà clampée anti-passé par le domaine). Les deux ne
|
||||||
|
/// coïncident que si le reset est futur — on conserve donc la sémantique d'origine en
|
||||||
|
/// publiant l'heure de reset brute, pas l'échéance clampée.
|
||||||
|
fn arm_scheduled(
|
||||||
|
&self,
|
||||||
|
agent_id: AgentId,
|
||||||
|
fire_at_ms: i64,
|
||||||
|
node_id: NodeId,
|
||||||
|
conversation_id: Option<String>,
|
||||||
|
resets_at_ms: Option<i64>,
|
||||||
|
) {
|
||||||
|
self.events.publish(DomainEvent::AgentRateLimited {
|
||||||
|
agent_id,
|
||||||
|
resets_at_ms,
|
||||||
|
});
|
||||||
|
// Dédoublonnage (§21.10-4) : un signal de rafraîchissement annule
|
||||||
|
// l'armement précédent (sans événement d'annulation : c'est interne).
|
||||||
|
self.disarm(agent_id);
|
||||||
|
let id = self.scheduler.arm(
|
||||||
|
fire_at_ms,
|
||||||
|
ScheduledTask::ResumeAgent {
|
||||||
|
agent_id,
|
||||||
|
node_id,
|
||||||
|
conversation_id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.armed.lock().expect("session-limit mutex sain").insert(agent_id, id);
|
||||||
|
self.events
|
||||||
|
.publish(DomainEvent::AgentResumeScheduled { agent_id, fire_at_ms });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **(b) Exécution de la reprise.** Consomme une [`ScheduledTask::ResumeAgent`]
|
||||||
|
/// échue (celle que `TokioScheduler` pousse dans le canal de remise ; le câblage du
|
||||||
|
/// récepteur dans le runtime est LS7). Retire l'entrée armée (le réveil a tiré),
|
||||||
|
/// relance l'agent via [`AgentResumer`] avec [`RESUME_PROMPT`], puis publie
|
||||||
|
/// `AgentResumed`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`AppError`] propagée par [`AgentResumer::resume`] (la relance a échoué) ; dans
|
||||||
|
/// ce cas `AgentResumed` n'est **pas** publié.
|
||||||
|
pub async fn execute_resume(&self, task: ScheduledTask) -> Result<(), AppError> {
|
||||||
|
let ScheduledTask::ResumeAgent {
|
||||||
|
agent_id,
|
||||||
|
node_id,
|
||||||
|
conversation_id,
|
||||||
|
} = task;
|
||||||
|
|
||||||
|
// Le réveil a tiré : l'entrée armée n'a plus lieu d'être (qu'on réussisse ou non).
|
||||||
|
self.disarm(agent_id);
|
||||||
|
|
||||||
|
self.resumer
|
||||||
|
.resume(agent_id, node_id, conversation_id, RESUME_PROMPT)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
self.events.publish(DomainEvent::AgentResumed { agent_id });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **(c) Annulation.** Désarme la reprise auto de `agent_id` (socle du « annulable »).
|
||||||
|
/// Retrouve le [`ScheduleId`], appelle [`Scheduler::cancel`] et, **seulement si**
|
||||||
|
/// l'annulation a réussi, retire l'entrée et publie `AgentResumeCancelled`.
|
||||||
|
///
|
||||||
|
/// Renvoie `true` ssi la reprise a effectivement été annulée.
|
||||||
|
///
|
||||||
|
/// # Course « cancel pile au tir » (vigilance QA, LS3)
|
||||||
|
/// Sous runtime multi-thread, le réveil peut tirer **pile** au moment de l'annulation :
|
||||||
|
/// [`Scheduler::cancel`] renvoie alors `false` (déjà tiré). Dans ce cas on **ne
|
||||||
|
/// publie pas** `AgentResumeCancelled` et on **laisse l'entrée** (l'`execute_resume`
|
||||||
|
/// en cours la retirera) : la reprise **suit son cours**, cohérent et sans
|
||||||
|
/// événement trompeur.
|
||||||
|
pub fn cancel_resume(&self, agent_id: AgentId) -> bool {
|
||||||
|
let id = self
|
||||||
|
.armed
|
||||||
|
.lock()
|
||||||
|
.expect("session-limit mutex sain")
|
||||||
|
.get(&agent_id)
|
||||||
|
.copied();
|
||||||
|
let Some(id) = id else {
|
||||||
|
return false; // aucune reprise armée pour cet agent.
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.scheduler.cancel(id) {
|
||||||
|
self.armed.lock().expect("session-limit mutex sain").remove(&agent_id);
|
||||||
|
self.events
|
||||||
|
.publish(DomainEvent::AgentResumeCancelled { agent_id });
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
// Déjà tiré : la reprise suivra son cours, pas d'événement d'annulation.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retire (best-effort) l'armement de `agent_id` et annule le réveil sous-jacent
|
||||||
|
/// s'il existe. Usage interne (rafraîchissement / nettoyage post-tir) — **ne publie
|
||||||
|
/// aucun événement** (contrairement à [`Self::cancel_resume`]).
|
||||||
|
fn disarm(&self, agent_id: AgentId) {
|
||||||
|
let previous = self
|
||||||
|
.armed
|
||||||
|
.lock()
|
||||||
|
.expect("session-limit mutex sain")
|
||||||
|
.remove(&agent_id);
|
||||||
|
if let Some(id) = previous {
|
||||||
|
self.scheduler.cancel(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
339
crates/application/src/agent/structured.rs
Normal file
339
crates/application/src/agent/structured.rs
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
//! 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};
|
||||||
|
|
||||||
|
/// Issue d'un tour drainé (ARCHITECTURE §21.2-T4, réconciliation de la limite de
|
||||||
|
/// session avec le contrat « seul `Final` est terminal »).
|
||||||
|
///
|
||||||
|
/// Un tour se termine de deux façons **gracieuses** :
|
||||||
|
/// - [`TurnOutcome::Completed`] : le flux a rendu son [`ReplyEvent::Final`] — fin
|
||||||
|
/// déterministe normale (cas historique, contenu agrégé) ;
|
||||||
|
/// - [`TurnOutcome::RateLimited`] : le flux s'est **clos sans `Final`** *parce que*
|
||||||
|
/// l'agent est entré en **limite de session** (un [`ReplyEvent::RateLimited`] a été
|
||||||
|
/// observé dans le tour). Ce n'est **pas** une erreur (§21.2-T4) : l'agent reste
|
||||||
|
/// vivant, le service de limite (lot LS4) arme la reprise.
|
||||||
|
///
|
||||||
|
/// Un flux clos **sans `Final` ET sans `RateLimited`** reste une **erreur**
|
||||||
|
/// [`AgentSessionError::Io`] (tour réellement interrompu) — comportement inchangé.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum TurnOutcome {
|
||||||
|
/// Tour terminé normalement par un `Final` ; porte le contenu agrégé.
|
||||||
|
Completed(String),
|
||||||
|
/// Tour clos sur une **limite de session** (sans `Final`) ; porte l'heure de
|
||||||
|
/// reset éventuelle (époche-ms) telle que vue dans le dernier `RateLimited`.
|
||||||
|
RateLimited {
|
||||||
|
/// Instant de reset en époche-ms (`None` ⇒ heure inconnue ⇒ filet humain).
|
||||||
|
resets_at_ms: Option<i64>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) — y compris un tour clos sur une **limite de session** (le
|
||||||
|
/// rendez-vous synchrone n'a pas de contenu à rendre ; cf. [`TurnOutcome`] et la
|
||||||
|
/// variante riche [`drain_with_readiness_outcome`] pour exploiter la limite) ;
|
||||||
|
/// - [`AgentSessionError::Timeout`] si `timeout` expire avant le `Final`.
|
||||||
|
pub async fn send_blocking(
|
||||||
|
session: &dyn AgentSession,
|
||||||
|
prompt: &str,
|
||||||
|
timeout: Option<Duration>,
|
||||||
|
) -> Result<String, AgentSessionError> {
|
||||||
|
match drain_bounded_events(session, prompt, timeout, |_event| {}, |_signal| {}).await? {
|
||||||
|
TurnOutcome::Completed(content) => Ok(content),
|
||||||
|
// Le rendez-vous synchrone (ask) attend un contenu : un tour limité n'en a pas
|
||||||
|
// ⇒ on conserve le comportement historique (erreur), sans casser le contrat.
|
||||||
|
TurnOutcome::RateLimited { .. } => Err(AgentSessionError::Io(
|
||||||
|
"le tour s'est clos en limite de session, sans contenu Final".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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). Un tour clos sur une **limite de session** ⇒ [`AgentSessionError::Io`]
|
||||||
|
/// **ici** (signature historique `Result<String>`, zéro régression pour l'appelant
|
||||||
|
/// orchestrateur) ; utilise [`drain_with_readiness_outcome`] pour exploiter la limite.
|
||||||
|
pub async fn drain_with_readiness(
|
||||||
|
session: &dyn AgentSession,
|
||||||
|
prompt: &str,
|
||||||
|
timeout: Option<Duration>,
|
||||||
|
mediator: &dyn InputMediator,
|
||||||
|
agent: AgentId,
|
||||||
|
) -> Result<String, AgentSessionError> {
|
||||||
|
match drain_with_readiness_outcome(session, prompt, timeout, mediator, agent).await? {
|
||||||
|
TurnOutcome::Completed(content) => Ok(content),
|
||||||
|
TurnOutcome::RateLimited { .. } => Err(AgentSessionError::Io(
|
||||||
|
"le tour s'est clos en limite de session, sans contenu Final".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Variante **riche** de [`drain_with_readiness`] : même branchement readiness, mais
|
||||||
|
/// retourne le [`TurnOutcome`] complet au lieu de réduire la limite à une erreur.
|
||||||
|
///
|
||||||
|
/// C'est le point d'entrée du chemin **conscient de la limite** (réconciliation
|
||||||
|
/// §21.2-T4) : sur un tour clos sans `Final` mais ayant vu un
|
||||||
|
/// [`ReplyEvent::RateLimited`], il renvoie `Ok(`[`TurnOutcome::RateLimited`]`)` (fin
|
||||||
|
/// gracieuse) plutôt qu'une `Io`. Le drain applicatif / le `SessionLimitService`
|
||||||
|
/// (LS4) consomment cette issue pour armer la reprise. Le câblage de ce chemin sur
|
||||||
|
/// le tour délégué de l'orchestrateur est du ressort de LS7.
|
||||||
|
///
|
||||||
|
/// **Readiness inchangée** : `mark_idle` reste piloté **uniquement** par le `Final`
|
||||||
|
/// (`TurnEnded`) — un `RateLimited` ne marque **pas** l'agent `Idle` (§21.5 : « le
|
||||||
|
/// `mark_idle`/FIFO reste piloté par `Final`/timeout »).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Comme [`drain_with_readiness`], **sauf** qu'un tour limité n'est plus une erreur
|
||||||
|
/// (il devient [`TurnOutcome::RateLimited`]).
|
||||||
|
pub async fn drain_with_readiness_outcome(
|
||||||
|
session: &dyn AgentSession,
|
||||||
|
prompt: &str,
|
||||||
|
timeout: Option<Duration>,
|
||||||
|
mediator: &dyn InputMediator,
|
||||||
|
agent: AgentId,
|
||||||
|
) -> Result<TurnOutcome, 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| {
|
||||||
|
// Seul le `Final` marque `Idle` : un `RateLimited` ne fait PAS avancer la
|
||||||
|
// FIFO (la reprise est gérée par le service de limite, §21.5).
|
||||||
|
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<TurnOutcome, 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, l'issue dépend de ce qu'on a vu (réconciliation §21.2-T4) :
|
||||||
|
/// - un [`ReplyEvent::RateLimited`] a été observé ⇒ fin **gracieuse**
|
||||||
|
/// [`TurnOutcome::RateLimited`] (l'agent est limité, pas en erreur) ;
|
||||||
|
/// - sinon ⇒ tour réellement interrompu → erreur [`AgentSessionError::Io`]
|
||||||
|
/// (comportement **inchangé**).
|
||||||
|
///
|
||||||
|
/// Chaque événement est classé par [`ReadinessPolicy`] et le signal éventuel est
|
||||||
|
/// remonté à `on_signal` (le `Final` ⇒ [`ReadinessSignal::TurnEnded`] ; un
|
||||||
|
/// `RateLimited` ⇒ [`ReadinessSignal::RateLimited`]). 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<TurnOutcome, AgentSessionError> {
|
||||||
|
// Mémorise la dernière limite vue (§21.2-T4) : `Some(resets_at_ms)` dès qu'un
|
||||||
|
// `RateLimited` traverse le flux. Sert UNIQUEMENT au cas « clos sans Final » —
|
||||||
|
// un `Final` ultérieur l'emporte toujours (le tour a réellement abouti).
|
||||||
|
let mut last_rate_limit: Option<Option<i64>> = None;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
match event {
|
||||||
|
ReplyEvent::Final { content } => return Ok(TurnOutcome::Completed(content)),
|
||||||
|
ReplyEvent::RateLimited { resets_at_ms } => last_rate_limit = Some(resets_at_ms),
|
||||||
|
// TextDelta / ToolActivity / Heartbeat : non terminaux, ignorés ici.
|
||||||
|
ReplyEvent::TextDelta { .. }
|
||||||
|
| ReplyEvent::ToolActivity { .. }
|
||||||
|
| ReplyEvent::Heartbeat => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Flux clos sans `Final` : fin gracieuse SI une limite a été vue (§21.2-T4),
|
||||||
|
// sinon erreur comme avant.
|
||||||
|
match last_rate_limit {
|
||||||
|
Some(resets_at_ms) => Ok(TurnOutcome::RateLimited { resets_at_ms }),
|
||||||
|
None => 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 crate::error::AppError;
|
||||||
|
|
||||||
use super::catalogue::reference_profiles;
|
use super::catalogue::selectable_reference_profiles;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// DetectProfiles
|
// DetectProfiles
|
||||||
@ -256,11 +256,15 @@ impl ConfigureProfiles {
|
|||||||
/// Output of [`ReferenceProfiles::execute`].
|
/// Output of [`ReferenceProfiles::execute`].
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ReferenceProfilesOutput {
|
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>,
|
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)]
|
#[derive(Default)]
|
||||||
pub struct ReferenceProfiles;
|
pub struct ReferenceProfiles;
|
||||||
|
|
||||||
@ -278,7 +282,7 @@ impl ReferenceProfiles {
|
|||||||
#[allow(clippy::unused_async)]
|
#[allow(clippy::unused_async)]
|
||||||
pub async fn execute(&self) -> Result<ReferenceProfilesOutput, AppError> {
|
pub async fn execute(&self) -> Result<ReferenceProfilesOutput, AppError> {
|
||||||
Ok(ReferenceProfilesOutput {
|
Ok(ReferenceProfilesOutput {
|
||||||
profiles: reference_profiles(),
|
profiles: selectable_reference_profiles(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -292,7 +296,9 @@ impl ReferenceProfiles {
|
|||||||
pub struct FirstRunStateOutput {
|
pub struct FirstRunStateOutput {
|
||||||
/// `true` when no `profiles.json` exists yet ⇒ show the first-run wizard.
|
/// `true` when no `profiles.json` exists yet ⇒ show the first-run wizard.
|
||||||
pub is_first_run: bool,
|
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>,
|
pub reference_profiles: Vec<AgentProfile>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -317,7 +323,7 @@ impl FirstRunState {
|
|||||||
let configured = self.store.is_configured().await?;
|
let configured = self.store.is_configured().await?;
|
||||||
Ok(FirstRunStateOutput {
|
Ok(FirstRunStateOutput {
|
||||||
is_first_run: !configured,
|
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`.
|
//! with one error shape when building its `ErrorDTO`.
|
||||||
|
|
||||||
use domain::ports::{
|
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.
|
/// Errors surfaced by application use cases.
|
||||||
///
|
///
|
||||||
@ -43,6 +45,18 @@ pub enum AppError {
|
|||||||
#[error("remote error: {0}")]
|
#[error("remote error: {0}")]
|
||||||
Remote(String),
|
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.
|
/// An unexpected internal error.
|
||||||
#[error("internal error: {0}")]
|
#[error("internal error: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
@ -61,6 +75,7 @@ impl AppError {
|
|||||||
Self::Process(_) => "PROCESS",
|
Self::Process(_) => "PROCESS",
|
||||||
Self::Git(_) => "GIT",
|
Self::Git(_) => "GIT",
|
||||||
Self::Remote(_) => "REMOTE",
|
Self::Remote(_) => "REMOTE",
|
||||||
|
Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING",
|
||||||
Self::Internal(_) => "INTERNAL",
|
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 {
|
impl From<PtyError> for AppError {
|
||||||
fn from(e: PtyError) -> Self {
|
fn from(e: PtyError) -> Self {
|
||||||
Self::Process(e.to_string())
|
Self::Process(e.to_string())
|
||||||
@ -113,3 +148,15 @@ impl From<RemoteError> for AppError {
|
|||||||
Self::Remote(e.to_string())
|
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::{
|
pub use usecases::{
|
||||||
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
|
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
|
||||||
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput,
|
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit,
|
||||||
GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput,
|
GitInitInput, GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus,
|
||||||
GitStatusOutput, GitUnstage,
|
GitStatusInput, GitStatusOutput, GitUnstage,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -47,12 +47,12 @@ pub struct HealthUseCase {
|
|||||||
impl HealthUseCase {
|
impl HealthUseCase {
|
||||||
/// Builds the use case from its injected ports.
|
/// Builds the use case from its injected ports.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(clock: Arc<dyn Clock>, ids: Arc<dyn IdGenerator>, events: Arc<dyn EventBus>) -> Self {
|
pub fn new(
|
||||||
Self {
|
clock: Arc<dyn Clock>,
|
||||||
clock,
|
ids: Arc<dyn IdGenerator>,
|
||||||
ids,
|
events: Arc<dyn EventBus>,
|
||||||
events,
|
) -> Self {
|
||||||
}
|
Self { clock, ids, events }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executes the health check.
|
/// Executes the health check.
|
||||||
|
|||||||
@ -21,6 +21,8 @@
|
|||||||
//! `TerminalSessions` registry, keyed by that same `SessionId`.
|
//! `TerminalSessions` registry, keyed by that same `SessionId`.
|
||||||
|
|
||||||
mod management;
|
mod management;
|
||||||
|
mod reconcile;
|
||||||
|
mod snapshot;
|
||||||
mod store;
|
mod store;
|
||||||
mod usecases;
|
mod usecases;
|
||||||
|
|
||||||
@ -29,6 +31,11 @@ pub use management::{
|
|||||||
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
|
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
|
||||||
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
|
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 store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
|
||||||
pub use usecases::{
|
pub use usecases::{
|
||||||
LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout,
|
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).
|
/// The default single-cell layout tree (one empty leaf).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn default_tree() -> LayoutTree {
|
pub fn default_tree() -> LayoutTree {
|
||||||
LayoutTree::single(LeafCell {
|
LayoutTree::single(LeafCell::new(NodeId::new_random()))
|
||||||
id: NodeId::new_random(),
|
|
||||||
session: None,
|
|
||||||
agent: None,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds a fresh doc holding one layout (`tree`) made active.
|
/// Builds a fresh doc holding one layout (`tree`) made active.
|
||||||
@ -149,7 +145,8 @@ pub async fn persist_doc(
|
|||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let ideai_dir = RemotePath::new(join_root(&project.root, IDEAI_DIR));
|
let ideai_dir = RemotePath::new(join_root(&project.root, IDEAI_DIR));
|
||||||
fs.create_dir_all(&ideai_dir).await?;
|
fs.create_dir_all(&ideai_dir).await?;
|
||||||
fs.write(&layouts_path(project), &to_json_bytes(doc)?).await?;
|
fs.write(&layouts_path(project), &to_json_bytes(doc)?)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,10 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::ports::{EventBus, FileSystem, ProjectStore};
|
use domain::ports::{EventBus, FileSystem, ProjectStore};
|
||||||
use domain::{AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId, ProjectId, SessionId};
|
use domain::{
|
||||||
|
AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId,
|
||||||
|
ProjectId, SessionId,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
@ -69,6 +72,14 @@ pub enum LayoutOperation {
|
|||||||
/// Session to host, or `None` to clear.
|
/// Session to host, or `None` to clear.
|
||||||
session: Option<SessionId>,
|
session: Option<SessionId>,
|
||||||
},
|
},
|
||||||
|
/// Attach an existing live/background session to a leaf, clearing any
|
||||||
|
/// previous visible host of the same session.
|
||||||
|
AttachSession {
|
||||||
|
/// The hosting leaf.
|
||||||
|
target: NodeId,
|
||||||
|
/// Session to make visible in this leaf.
|
||||||
|
session: SessionId,
|
||||||
|
},
|
||||||
/// Attach or detach an agent to/from a leaf (per-cell agent, feature #3).
|
/// Attach or detach an agent to/from a leaf (per-cell agent, feature #3).
|
||||||
SetCellAgent {
|
SetCellAgent {
|
||||||
/// The hosting leaf.
|
/// The hosting leaf.
|
||||||
@ -76,6 +87,17 @@ pub enum LayoutOperation {
|
|||||||
/// Agent to associate, or `None` to clear.
|
/// Agent to associate, or `None` to clear.
|
||||||
agent: Option<AgentId>,
|
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 {
|
impl LayoutOperation {
|
||||||
@ -87,16 +109,7 @@ impl LayoutOperation {
|
|||||||
direction,
|
direction,
|
||||||
new_leaf,
|
new_leaf,
|
||||||
container,
|
container,
|
||||||
} => tree.split(
|
} => tree.split(*target, *direction, LeafCell::new(*new_leaf), *container),
|
||||||
*target,
|
|
||||||
*direction,
|
|
||||||
LeafCell {
|
|
||||||
id: *new_leaf,
|
|
||||||
session: None,
|
|
||||||
agent: None,
|
|
||||||
},
|
|
||||||
*container,
|
|
||||||
),
|
|
||||||
Self::Merge {
|
Self::Merge {
|
||||||
container,
|
container,
|
||||||
keep_index,
|
keep_index,
|
||||||
@ -104,7 +117,12 @@ impl LayoutOperation {
|
|||||||
Self::Resize { container, weights } => tree.resize(*container, weights),
|
Self::Resize { container, weights } => tree.resize(*container, weights),
|
||||||
Self::Move { from, to } => tree.move_session(*from, *to),
|
Self::Move { from, to } => tree.move_session(*from, *to),
|
||||||
Self::SetSession { target, session } => tree.set_session(*target, *session),
|
Self::SetSession { target, session } => tree.set_session(*target, *session),
|
||||||
|
Self::AttachSession { target, session } => tree.attach_session(*target, *session),
|
||||||
Self::SetCellAgent { target, agent } => tree.set_cell_agent(*target, *agent),
|
Self::SetCellAgent { target, agent } => tree.set_cell_agent(*target, *agent),
|
||||||
|
Self::SetCellConversation {
|
||||||
|
target,
|
||||||
|
conversation_id,
|
||||||
|
} => tree.set_cell_conversation(*target, conversation_id.clone()),
|
||||||
};
|
};
|
||||||
result.map_err(map_layout_err)
|
result.map_err(map_layout_err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,11 +12,15 @@
|
|||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
|
pub mod conversation;
|
||||||
|
pub mod embedder;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
|
pub mod memory;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
|
pub mod permission;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
@ -25,36 +29,76 @@ pub mod terminal;
|
|||||||
pub mod window;
|
pub mod window;
|
||||||
|
|
||||||
pub use agent::{
|
pub use agent::{
|
||||||
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput,
|
drain_with_readiness, drain_with_readiness_outcome, reference_profile_id, reference_profiles,
|
||||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile,
|
||||||
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
ChangeAgentProfileInput, ChangeAgentProfileOutput,
|
||||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, LaunchAgent,
|
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch,
|
||||||
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile,
|
||||||
ListProfiles, ListProfilesOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput,
|
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
|
||||||
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, SaveProfile,
|
FirstRunStateOutput, HandoffProvider, InspectConversation, InspectConversationInput,
|
||||||
SaveProfileInput, SaveProfileOutput, UpdateAgentContext, UpdateAgentContextInput,
|
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, SessionLimitService, StructuredSessionDescriptor, TurnOutcome,
|
||||||
|
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, RESUME_PROMPT,
|
||||||
|
};
|
||||||
|
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 error::AppError;
|
||||||
pub use orchestrator::{OrchestratorOutcome, OrchestratorService};
|
|
||||||
pub use git::{
|
pub use git::{
|
||||||
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
|
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
|
||||||
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput,
|
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit,
|
||||||
GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput,
|
GitInitInput, GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus,
|
||||||
GitStatusOutput, GitUnstage,
|
GitStatusInput, GitStatusOutput, GitUnstage,
|
||||||
};
|
};
|
||||||
pub use health::{HealthInput, HealthReport, HealthUseCase};
|
pub use health::{HealthInput, HealthReport, HealthUseCase};
|
||||||
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
|
|
||||||
pub use layout::{
|
pub use layout::{
|
||||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||||
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
|
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
|
||||||
ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput,
|
ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput,
|
||||||
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, RenameLayout,
|
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, ReconcileLayouts,
|
||||||
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, LAYOUTS_FILE,
|
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::{
|
pub use project::{
|
||||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||||
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
|
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
|
||||||
OpenProjectInput, OpenProjectOutput, ProjectMeta,
|
OpenProjectInput, OpenProjectOutput, ProjectMeta, ReadProjectContext, ReadProjectContextInput,
|
||||||
|
ReadProjectContextOutput, UpdateProjectContext, UpdateProjectContextInput,
|
||||||
|
PROJECT_CONTEXT_FILE,
|
||||||
|
};
|
||||||
|
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
|
||||||
|
pub use skill::{
|
||||||
|
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
|
||||||
|
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
|
||||||
|
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
|
||||||
|
UpdateSkillOutput,
|
||||||
};
|
};
|
||||||
pub use template::{
|
pub use template::{
|
||||||
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
|
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
|
||||||
@ -64,15 +108,9 @@ pub use template::{
|
|||||||
SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput,
|
SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput,
|
||||||
UpdateTemplateOutput,
|
UpdateTemplateOutput,
|
||||||
};
|
};
|
||||||
pub use skill::{
|
|
||||||
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
|
|
||||||
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
|
|
||||||
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
|
|
||||||
UpdateSkillOutput,
|
|
||||||
};
|
|
||||||
pub use terminal::{
|
pub use terminal::{
|
||||||
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
|
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveSessions,
|
||||||
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, WriteToTerminal,
|
OpenTerminal, OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput,
|
||||||
WriteToTerminalInput,
|
StructuredSessions, TerminalSessions, WriteToTerminal, WriteToTerminalInput,
|
||||||
};
|
};
|
||||||
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
|
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?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user