69 Commits

Author SHA1 Message Date
3f3504efa3 fix(test): corrige le compteur de gateways du mock (13 → 14)
Le test asseyait « thirteen gateways » alors que la gateway permission
(présente depuis eca2ba9) en porte le total à 14. Compteur périmé sans
rapport avec session-limits, corrigé isolément avant le merge d'intégration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:45:00 +02:00
5d9dd32c29 feat(session-limits): LS8-front — filet humain niveau 3 (saisie d'heure de reprise)
UI permettant à l'humain de renseigner l'heure de reprise quand le
niveau 2 détecte une limite sans heure exploitable.

- ports/index.ts : setResumeAt(agentId, resetsAtMs) sur InputGateway.
- adapters/input.ts : setResumeAt → invoke("set_resume_at").
- adapters/mock/index.ts : MockInputGateway.setResumeAt (resumeArmings[]).
- features/agents/useAgents.ts : action setResumeAt (sans mutation optimiste).
- features/agents/AgentLimitBadge.tsx : formulaire de saisie d'heure sur
  l'état suspected sans heure + helper pur timeInputToEpochMs (TODO LS7 retiré).
- features/agents/AgentsPanel.tsx : câblage onSetResumeAt.

Tests AgentLimitBadge.test.tsx mis à jour au nouveau contrat + couverture LS8 ;
typecheck propre, suite agents verte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:44:54 +02:00
c480d2820a feat(session-limits): LS8-backend — filet humain niveau 3 (set_resume_at)
Permet à l'humain de confirmer/forcer l'heure de reprise quand le
niveau 2 a détecté une limite sans heure exploitable.

- application/agent/session_limit.rs : refactor privé arm_scheduled
  (param resets_at_ms brut) partagé par on_rate_limited + nouvelle
  confirm_human_resume(agent_id, node_id, conversation_id, resets_at_ms)
  (source Human, réutilise la branche Scheduled, annulable).
- app-tauri/commands.rs : commande set_resume_at(agent_id, resets_at_ms)
  (résout node_id via node_for_agent + conversation_id best-effort,
  NOT_FOUND si pas de cellule vivante).
- app-tauri/lib.rs : set_resume_at enregistrée après cancel_resume.

Réutilise les événements existants (AgentRateLimited + AgentResumeScheduled),
aucun nouvel événement. Tests : +6 session_limit_service, +2 wiring, verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:44:45 +02:00
4fad0423e7 feat(session-limits): LS7-front — UI limites de session (badge + compte à rebours + filet humain)
Expose la surface produit des limites de session côté React/TS,
au-dessus du câblage backend (9df5923).

- domain/index.ts : 5 variantes ajoutées au union DomainEvent
  (agentRateLimited / ResumeScheduled / ResumeCancelled / Resumed /
  RateLimitSuspected).
- ports/index.ts : cancelResume(agentId) ajouté à InputGateway.
- adapters/input.ts : TauriInputGateway.cancelResume → invoke("cancel_resume").
- adapters/mock/index.ts : MockInputGateway.cancelResume
  (cancelledResumes / cancelResumeResult).
- features/agents/useAgents.ts : état limitByAgent + action cancelResume.
- features/agents/AgentLimitBadge.tsx (nouveau) : badge + compte à rebours
  + bouton Annuler + helpers purs.
- features/agents/AgentsPanel.tsx : câblage du badge.

Tests : useAgentsLimits.test.tsx (13) + AgentLimitBadge.test.tsx (11),
suite agents 63 tests verts, tsc --noEmit propre.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:06:56 +02:00
9df592389c feat(session-limits): LS7 — câblage backend app-tauri (taps niveaux 1&2 + reprise annulable)
Branche le SessionLimitService dans l'application Tauri et expose la
surface de reprise/annulation au front.

- application/agent/lifecycle.rs : LaunchAgentOutput.profile exposé
  (None sur réattache/idempotent, Some sur lancement effectif).
- application/terminal/registry.rs : StructuredSessions::meta_for_session()
  (lookup agent/node par SessionId pour le tap niveau 1).
- app-tauri/state.rs : ResumeContext(s), AppAgentResumer (impl du port
  AgentResumer au-dessus de LaunchAgent), instanciation + câblage du
  SessionLimitService (TokioScheduler + drain des réveils) dans AppState::build.
- app-tauri/commands.rs : taps niveau 1 (agent_send) et niveau 2
  (launch_agent, parser regex confiné), alimentation de resume_contexts,
  commande cancel_resume.
- app-tauri/lib.rs : enregistrement de cancel_resume dans le handler.
- app-tauri/Cargo.toml : dépendance async-trait.

Tests : session_limit_wiring.rs (2 tests de composition) + meta_for_session
dans structured_registry_d1.rs ; fixtures dto_agents/dto_chat ajustées
(profile: None). Tout vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 07:56:08 +02:00
ea94e756e2 feat(session-limits): LS6 — câblage des événements de limite vers le front
Relaie les 5 nouvelles variantes DomainEvent (AgentRateLimited, ResumeScheduled,
ResumeCancelled, Resumed, RateLimitSuspected) via leurs DTO miroirs et bras From
dans events.rs, et propage ReplyEvent::RateLimited en chunk côté chat.rs pour que
le front soit informé des suspensions/reprises de session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 20:02:29 +02:00
98bfcf4f22 feat(session-limits): LS5 — détecteur niveau 2 déclaratif + parsing temps partagé
Ajoute un détecteur niveau 2 (RateLimitParser, regex confiné à l'infra) qui
repère les mentions de limite de session dans la sortie textuelle, et factorise
le parsing d'heures dans un module pur (timeparse) partagé entre les niveaux 1
et 2. Le détecteur Claude niveau 1 est refactoré vers timeparse (~-121 lignes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 20:02:24 +02:00
9000b4d09f feat(session-limits): LS4 — service application + réconciliation T4
Orchestre la détection et la reprise au niveau application :
- session_limit.rs (nouveau) : SessionLimitService + port AgentResumer +
  const RESUME_PROMPT.
- structured.rs : réconciliation T4 — enum TurnOutcome +
  drain_with_readiness_outcome ; signatures historiques préservées.
- agent/mod.rs + lib.rs : modules et re-exports.

Tests QA (nouveaux) : tests/session_limit_service.rs (9) +
tests/session_limit_t4.rs (7).
`cargo test -p application` = tous binaires verts / 0 failed (16 nouveaux),
zéro régression (drain_with_readiness_lot1 7/7, send_blocking_d1 9/9) ;
builds domaine+infra+application 0 warning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:55:26 +02:00
253310bb3e feat(session-limits): LS3 — port Scheduler + adapter TokioScheduler
Introduit l'abstraction de planification pour la reprise différée à la
levée d'une limite de session :
- domaine : trait Scheduler + enum ScheduledTask (ports.rs), ScheduleId
  via typed_id! (ids.rs), re-exports (lib.rs).
- infra : TokioScheduler (scheduler/mod.rs, nouveau) + pub mod scheduler
  et re-export (lib.rs).

Tests QA inline (#[cfg(test)]) : 7 tests scheduler (3× sans flaky).
`cargo test -p infrastructure` = 195 passed / 0 failed ; domaine + infra
builds 0 warning ; LS1/LS2 toujours verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:07:48 +02:00
a1755e51bc feat(session-limits): LS2 — adapter Claude niveau 1 (infra)
Câble la détection de limite de session dans l'adapter Claude :
- claude.rs : parse_event émet ReplyEvent::RateLimited ; nouvelle fonction
  pure parse_reset_ms + helpers ; parseur ISO maison ; doccomments T4.
- mod.rs : 26 nouveaux tests QA (#[cfg(test)]) + 2 tests existants alignés
  sur le nouveau contrat.
- conformance.rs : RateLimited ajouté aux événements non terminaux autorisés.

`cargo test -p infrastructure` = 188 passed / 0 failed, zéro régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:44:53 +02:00
0bf1eb3b11 feat(session-limits): LS1 — couche domaine (détection + plan de reprise)
Pose les briques pures du domaine pour la gestion des limites de session
des agents (état en mémoire, aucun schéma de persistance modifié) :
- session_limit.rs (nouveau) : SessionLimit, ResumePlan, RateLimitSource,
  plan_resume (calcul du plan de reprise annulable).
- ports.rs : variante ReplyEvent::RateLimited.
- readiness.rs : variante ReadinessSignal::RateLimited + classify.
- profile.rs : RateLimitPattern + champ + builder.
- events.rs : 5 variantes DomainEvent pour le cycle de vie limite/reprise.
- lib.rs : module + re-exports.

Tests QA inline (#[cfg(test)]) : 24 tests dédiés.
`cargo test -p domain` = 165 passed / 0 failed, zéro régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:33:57 +02:00
fa5b826df5 docs(session-limits): cadrage Architect — gestion des limites de session des agents
Pose le cadrage de la feature « Gestion des limites de session des agents »
avant tout code (lots LS1→LS8) :
- ARCHITECTURE.md §21 : détection hiérarchique des limites de session +
  reprise auto annulable (domaine → adapter Claude → port Scheduler →
  service → parser regex → filet humain → app-tauri → frontend) ; état en
  mémoire uniquement, aucun schéma de persistance modifié.
- .ideai/memory/session-limit-handling-design.md : design validé.
- .ideai/memory/MEMORY.md : pointeur vers le design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:25:05 +02:00
401c18ad3c chore(repo): retire les gitlinks de worktree .claude/ committés par accident
Deux entrées gitlink (mode 160000) pointant vers des commits de worktrees
agent locaux avaient été versionnées par erreur. Elles sont déjà couvertes
par .gitignore (.claude/worktrees/) ; on les sort simplement du suivi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:01:19 +02:00
a04fbb7e1c merge(develop): intègre permissions/sandbox OS + pont Codex inter-agents
Intègre dans develop le travail terminé et testé porté par
wip/p8c-checkpoint-before-codex :
- enforcement OS des permissions (Landlock) bout-en-bout (LP4-0→LP4-4)
- pont Codex inter-agents + readiness/heartbeat
- layouts UI responsive + setup agent Git
Tests crates cœur (domain/application/infrastructure) verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:56:34 +02:00
69304b0f6b chore(wip): état runtime .ideai (agents.json, layouts.json)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:56:06 +02:00
64ab3835c7 docs(agents): introduit l'agent Git (contexte + intégration au cycle)
Ajoute le contexte de l'agent Git (.ideai/agents/git.md) garant du dépôt
git local (commits, branches, merges/rebases) et l'inscrit dans le cycle
de dev de CLAUDE.md (§2.4 + étape « décide de la branche » / « merge
éventuel feature/* → develop »). Formalise le modèle main ← develop ←
feature/*.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:56:06 +02:00
00bda6a988 chore(wip): état runtime .ideai (conversations, agents, layouts)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 09:39:07 +02:00
31b636037d fix(ui): layouts responsive pour panneau agents et bandeau d'onglets sidebar
AgentsPanel : formulaire de création et items d'agent passent en colonne,
les libellés/états longs (running in IdeA, profil) wrappent/tronquent au lieu
de déborder. ProjectsView : les onglets de la sidebar wrappent sur plusieurs
lignes pour rester tous visibles, le bouton collapse reste épinglé en haut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 09:39:03 +02:00
40bce5c8bf feat(sandbox): Landlock no-op sous Posture::Allow (allow-by-default)
Un allowlist Landlock ne peut que retirer des accès (deny-by-default). Sous
une fallback Posture::Allow (allow-by-default), enforcer les grants
verrouillerait tout hors du project root — binaire CLI, libs et ~/.<cli> —
provoquant le 'failed to open terminal'. On n'enforce donc rien sous Allow ;
seuls les Deny explicites font foi (advisory via la projection LP3).
Couvre permissions.json persisté avec fallback allow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 09:38:57 +02:00
ab9162f686 docs(memory): consigne l'etat du systeme de permissions/sandbox + risque residuel $HOME
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:51:57 +02:00
6236cd727b feat(permissions): LP4-4 — enforcement Landlock sur le chemin structuré
Étend l'enforcement OS au chemin structuré (sessions Claude/Codex mode JSON),
jusqu'ici seulement advisory. Approche validée par l'Architecte : transposer la
technique du PTY plutôt qu'un pre_exec (rejeté — landlock alloue, deadlock malloc
post-fork en process multithreadé).

Mécanique (cfg(target_os=linux)) : run_turn_sandboxed/drain_sandboxed exécutent
enforce(plan) sur un thread jetable AVANT le spawn std, puis std::process::spawn
depuis ce thread ; l'enfant hérite le domaine Landlock via les credentials de la
tâche (garanti à travers fork/clone/execve, y compris posix_spawn — pas de pre_exec
nécessaire, forbid(unsafe_code) préservé). Fail-closed sur Err d'enforce (aucun
child). Timeout sous sandbox : oneshot killer + tokio::time::timeout → kill → EOF →
reap (pas de zombie/thread bloqué). Chemin non-sandboxé (plan None / pas d'enforcer /
non-Linux) = drain async tokio inchangé.

Contrat : SpawnLine.sandbox ; AgentSessionFactory::start(.., sandbox) ;
StructuredSessionFactory::with_sandbox_enforcer (jumeau du PTY) ; plan calculé en
step 5d de lifecycle relayé à launch_structured ; default_enforcer() injecté au
composition root.

Tests : 7 invariants e2e (parité, companion négatif, fail-closed, no-op natif,
confinement de l'irréversibilité entre tours, timeout, resume préservé) — zéro
token (sh/FakeCli). 80 suites vertes, 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:47:33 +02:00
7e01ac60cb feat(permissions): LP4-3 — activation bout-en-bout de la sandbox OS au runtime
Composition root : PortablePtyAdapter::new() injecte désormais default_enforcer()
(Landlock sur Linux / Noop ailleurs) via with_sandbox_enforcer, dans state.rs et
dans LocalHost (remote/mod.rs).

Launch path : LaunchAgent::execute peuple spec.sandbox via le plan pur
compile_sandbox_plan(effective_permissions, SandboxContext{project_root, run_dir}),
en réutilisant l'EffectivePermissions déjà résolu pour la projection advisory LP3.
Invariant produit conservé : eff == None ⇒ plan None ⇒ comportement natif, aucune
projection OS.

Portée : seul le chemin PTY brut est OS-enforcé ; le chemin structuré (Claude/Codex
mode JSON) porte le plan mais ne l'enforce pas encore (lot ultérieur).

Tests : ajout d'un test d'intégration bout-en-bout (pty/mod.rs, sandbox_e2e_tests)
prouvant qu'un enfant spawné sous un plan RW restreint ne peut PAS écrire hors-grant
(écriture bloquée par le kernel) alors que l'écriture dans le grant passe, plus un
test compagnon anti faux-positif (sans plan ⇒ pas de sandbox). Skip propre si le
kernel n'a pas Landlock. domain+infrastructure+application+app-tauri verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:08:40 +02:00
17ca65ed0f feat(permissions): LP4-1/LP4-2 — enforcer Landlock OS + consommation PTY du plan
LP4-1 : adaptateurs SandboxEnforcer concrets (LandlockSandbox Linux via le LSM
Landlock, NoopSandbox ailleurs) + default_enforcer() cfg-sélectionné, prêts à
être injectés au composition root (LP4-3). Aucun changement de comportement
runtime tant que rien n'est câblé : SpawnSpec.sandbox reste None.

LP4-2 : PortablePtyAdapter consomme SpawnSpec.sandbox via spawn_command_sandboxed
(thread jetable restreint par enforce() puis fork → le domaine Landlock est hérité
par l'enfant à travers execve ; les autres threads d'IdeA ne sont jamais sandboxés).
Builder additif with_sandbox_enforcer ; fail-closed si enforce() échoue.

Tests : domain + infrastructure verts (dont les tests Landlock réels de fencing
read-only/write-only et la confine-au-thread).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:00:47 +02:00
b05d04ab7a feat(permissions): LP4-0 — fondations domaine de l'enforcement OS (pur)
Fondations pures (zéro I/O, zéro dépendance landlock, aucun câblage
runtime — SpawnSpec.sandbox posé mais jamais lu ⇒ zéro régression) de la
voie « airtight » des permissions, complément de la voie projection LP3.

- domain/sandbox.rs : SandboxPlan/PathGrant/PathAccess (RO|RW|EXEC),
  SandboxContext, SandboxKind/Status/Error, port SandboxEnforcer, et la
  fonction pure compile_sandbox_plan(EffectivePermissions → plan OS).
- domain/permission.rs : render_permission_summary (bloc Markdown injecté
  plus tard ; mentionne explicitement fichiers OS-enforced vs commandes
  advisory).
- domain/ports.rs : SpawnSpec.sandbox: Option<SandboxPlan> (None ⇒ natif),
  propagé à tous les sites de construction.

Sémantique de compile_sandbox_plan :
- Invariant produit : eff == None ⇒ None (rien posé ⇒ CLI 100 % native).
- Borne Landlock : seules les capabilities fichier produisent des grants
  (Read→RO, Write/Delete→RW) ; ExecuteBash reste advisory (non verrouillable
  par chemin).
- Deny-wins PAR CLASSE D'ACCÈS (RO/RW), fail-closed intra-classe : un Deny
  ne ferme que les grants de sa propre classe (un Deny Write n'ampute pas un
  Allow Read). Choix retenu pour maximiser l'autonomie des agents : on
  respecte exactement la politique pré-renseignée sans sur-restreindre, donc
  moins de blocages qui forceraient l'agent à redemander l'utilisateur.
- Globs réduits à leur préfixe statique ; grant abandonné si une barrière de
  même classe chevauche (égal/ancêtre/descendant) — sous-approximation
  conservatrice (un sandbox additif ne peut pas carver un deny sous-arbre).

Tests : 16 tests purs sur sandbox + 3 sur render_permission_summary,
cargo test --workspace 100 % vert, 0 ignored.

Reste LP4 : LP4-1 adapter LandlockSandbox + pre_exec PTY (fail-open+warning
sauf posture Deny), LP4-2 câblage application, LP4-3 composition root.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:37:34 +02:00
27597eb64e feat(permissions): voie projection CLI (LP0→LP3) + checkpoint Codex/input
Jalon vert regroupant deux chantiers entrelacés dans le working tree,
indissociables au niveau fichier mais tous deux verts (cargo test
--workspace + tests frontend permissions au vert).

Permissions — voie « projection CLI » (advisory), complète :
- LP0 domaine pur : modèle PermissionSet/EffectivePermissions, resolve
  deny-wins + postures Allow<Ask<Deny (crates/domain/src/permission.rs).
- LP1 store : FsPermissionStore (.ideai/permissions.json).
- LP2 use cases : Get/Update project, Update agent override, Resolve.
- LP3 projecteurs Claude/Codex (settings.local.json / config.toml),
  câblage launch-path + PermissionProjectorRegistry, nettoyage des
  fichiers Replace orphelins au swap de profil (LP3-4), composition root
  + commandes Tauri, UI PermissionsPanel (projet + override agent).
- ports.rs : PermissionStore + FileSystem::remove_file (cleanup au swap).

Reste ouvert (hors scope, marqué dans le code) : LP4 enforcement OS
airtight (Landlock fichiers) + résumé de permissions injecté.

Inclut aussi le chantier Codex/input/sessions structurées en cours
(McpConfigStrategy, StructuredAdapter, gestion d'input) partageant les
mêmes fichiers (lifecycle.rs, commands.rs, dto.rs, state.rs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:39:18 +02:00
46492506e1 fix(orchestrator): garde RAII contre une cible coincée Busy après une délégation interrompue
Une délégation `idea_ask_agent` interrompue/annulée côté demandeur (futur
`ask_agent` *dropped*) laissait la cible en `Busy` à vie : sur un drop, aucune
branche du `select!` ne s'exécute, donc `mark_idle` n'était jamais appelé. Toute
délégation suivante restait en file derrière ce tour fantôme et n'était jamais
livrée au PTY (symptôme : « DevFrontend/QA ne répondent plus », pont MCP pourtant
ESTAB).

Ajoute un garde RAII `BusyTurnGuard` posé juste après l'enqueue sur les deux
chemins (`ask` PTY et `ask_structured`) : son `Drop` fait `cancel_head` +
`mark_idle` quoi qu'il arrive (erreur, timeout, drop), sauf `disarm()` sur la
branche succès. Indispensable d'être un garde et non un `mark_idle` dans les
branches : le cas réel est un futur droppé. Retire les `cancel_head` redondants
des branches erreur/timeout (positionnel/idempotent).

`sweep_stalled` reste advisory (n'appelle jamais `mark_idle`) — non concerné.

Tests: dropped_ask_future_frees_busy_target, second_delegation_delivered_after_dropped_ask,
cancelled_ask_marks_target_idle + 2 tests du garde. cargo test --workspace vert (80 suites).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 19:15:01 +02:00
aa2f67ae89 fix(orchestrator): ne pas câbler with_structured sur l'orchestrateur (régression 0f8ba38)
Régression introduite par 0f8ba38 : le re-câblage de `.with_structured(...)`
sur `OrchestratorService` (pour activer `drain_with_readiness`, readiness lot 1)
faisait emprunter à `ask_agent` la branche structurée `ensure_structured_session`,
qui ne peut JAMAIS aboutir : depuis le lot B-2 (« Option 1 Terminal + MCP »,
eca2ba9) la fabrique structurée est décâblée de `LaunchAgent`, donc aucun agent
n'a de session structurée — tous tournent en PTY brut et la délégation passe par
les outils MCP. Symptôme : `idea_ask_agent` échouait systématiquement avec
« aucune session structurée vivante après lancement » à chaque relance d'IdeA.

Non détecté par les tests : les e2e loopback câblent leur propre service
structuré complet, jamais le composition root réel (gap tests ≠ composition).

Fix : retirer le `.with_structured` du composition root pour que
`self.structured = None` et que `ask_agent` retombe sur le chemin PTY+MCP
fonctionnel (état pré-0f8ba38). `drain_with_readiness` reste dormant tant que
la voie structurée n'est pas réactivée au composition root.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:45:39 +02:00
0f8ba38d51 feat(agents): pont Codex inter-agents + readiness/heartbeat lot 1
Deux chantiers livrés au vert (workspace entier : domain+application+
infrastructure 42 + app-tauri --lib 128, 0 échec).

## Codex inter-agents
- domaine: McpConfigStrategy::TomlConfigHome { target, home_env } +
  toml_config_home(...); AgentProfile::materializes_idea_bridge()
  (whitelist Claude/ConfigFile + Codex/TomlConfigHome); McpServerWiring
  + encodeur TOML.
- application: lifecycle apply_mcp_config bras TomlConfigHome (écrit
  {runDir}/<target>, pousse (home_env, parent) dans spec.env);
  guard_mcp_bridge_supported ré-exprimée via materializes_idea_bridge();
  catalogue Codex porte toml_config_home(".codex/config.toml","CODEX_HOME").
- app-tauri: is_codex_mcp_profile, migrate_codex_run_dir,
  mcp_server_entry_toml.
- tests: matrice domaine TomlConfigHome + round-trip dual Claude/Codex
  sur loopback réel (fakes, zéro token).

## Readiness/heartbeat lot 1
- domaine: readiness.rs — ReadinessPolicy::classify (Final => TurnEnded),
  variantes ReplyEvent::Heartbeat / ToolActivity.
- application: drain_with_readiness consulte la policy et appelle
  mark_idle sur le signal déterministe; branché dans ask_agent.
  Corrige la cause racine: une cible qui ne renvoie qu'un Final (sans
  idea_reply) débloque désormais sa file Busy.
- infrastructure: adapters de session émettent Heartbeat/ToolActivity.
- tests: drain_with_readiness_lot1 (points QA 5 & 6) verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:28:44 +02:00
fdcf16c387 chore(wip): checkpoint P8/C avant chantier Codex inter-agents
Sauvegarde de l'arbre de travail en cours (persistance P8, conversations
C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le
support de la délégation inter-agents pour les profils Codex.

Le round-trip inter-agent question/réponse est couvert sans tokens par
les tests loopback existants (state::mcp_e2e_loopback_tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:42:53 +02:00
4509f0db9d feat(persistence): P8d — swap cross-profile préserve l'id de paire + handoff
Capstone du chantier handoff : un agent change de moteur (Claude↔Codex) en
gardant la continuité du travail.

- clean_conversation → invalidate_engine_link : préserve conversation_id (id de
  paire stable) et n'efface que engine_session_id (lien moteur étranger) ;
  renvoie l'id de paire préservé
- relaunch_if_live relance avec l'id de paire (repli for_pair si session de
  fond) ⇒ handoff P7 réinjecté dans le nouveau moteur, resume P8c routé via
  providers.json[nouveau provider] (vide ⇒ SessionPlan::None : l'ancien
  resumable n'est jamais repassé ; fidélité par le handoff)
- tests : 4 cas swap (préservation id de paire, engine_session_id vidé, handoff
  réinjecté, pas de --resume de l'ancien moteur, repli for_pair) ;
  change_agent_profile 12 verts, agrégat 829 passed 0 failed

Chantier persistance conversationnelle + handoff cross-profile (P1→P8d) complet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 18:52:50 +02:00
d87b8f6ed2 feat(persistence): P8c — routage --resume moteur via providers.json (structuré)
Referme la régression latente de P8a : pour un profil structuré, le resumable
passé au moteur vient de providers.json[provider] (vrai id Claude/Codex), jamais
de l'id de paire désormais porté par la cellule.

- resolve_session_plan devient async + prend root ; branche structurée câblée :
  get(pair, provider_key) ⇒ Resume{engine_id}, sinon None ; repli gracieux sur
  l'ancien comportement si le store n'est pas câblé (zéro régression tests) ;
  branche non structurée strictement inchangée
- tests : 6 cas (claude/codex Resume{engine} avec assert_ne! vs id de paire,
  discrimination de clé provider, store vide/cellule neuve/non-uuid ⇒ None) ;
  structured_launch_d3 22 verts, domain+application+app-tauri sans régression

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 18:42:35 +02:00
9b053216e3 feat(persistence): P8b — écriture providers.json (resumable par provider)
Au lancement structuré, le resumable exposé par le moteur est rangé dans
providers.json sous (id de paire, provider) — best-effort, n'altère jamais le
lancement.

- domaine : StructuredAdapter::provider_key() ⇒ "claude"/"codex" (clé stable
  par famille, lisible, indépendante de l'uuid d'instance)
- application : port ProviderSessionProvider (root par appel) +
  with_provider_session_provider ; helper persist_provider_session dans
  launch_structured (skip si provider/adapter/id moteur absents ou set KO)
- app-tauri : AppProviderSessionProvider (FsProviderSessionStore sur le root)
- tests : 6 cas (nominal claude/codex, no-op sans provider/sans id moteur,
  best-effort, mapping provider_key) ; domain+application verts

Lecture du store pour --resume = P8c.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 18:36:19 +02:00
09cc8f0902 test(persistence): P8a — déterminisme registre + cohérence de clé end-to-end
Scelle la cohérence corrigée en P8a :
- infra (5) : resolve stable à travers 2 instances de registre (restart),
  resolve.id == for_pair == uuid agent, commutativité, distinction des paires
- application (2) : round-trip réel « handoff sauvé sous la clé resolve ↔
  rechargé au relancement de la cellule portant cette clé » + contre-épreuve
  (handoff d'une autre paire ⇒ pas de reprise)

Clarifie : une cellule neuve (conversation_id=None) ne charge pas de handoff ;
la reprise opère à la réouverture. domain/infra/application verts, zéro
régression. Code de prod non modifié.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 18:27:09 +02:00
e583b2b49f feat(persistence): P8a — cohérence de clé conversation (id de paire stable)
Corrige l'incohérence P6/P7 : la cellule et la persistance partagent désormais
le MÊME id de paire IdeA, déterministe et stable au redémarrage — la clé sous
laquelle P6b sauve log/handoff == celle sous laquelle P7 les charge.

- domaine : ConversationId::for_pair(a,b) pur/déterministe (User↔Agent = uuid
  agent ; Agent↔Agent = XOR commutatif) ; LeafCell gagne engine_session_id
  (cache resumable moteur, additif serde default)
- infrastructure : InMemoryConversationRegistry::resolve utilise for_pair au
  lieu de new_random() → id recalculable sans état, identique après restart
- application : launch_structured persiste l'id de paire sur la cellule (et l'id
  moteur sur engine_session_id) ; cellule neuve ⇒ dérive for_pair(User,agent) ;
  resolve_conversation repli factorisé sur for_pair ; withers layout clone-and-
  mutate (préservent les champs additifs)
- app-tauri : engine_session_id propagé dans les DTO

Suites domain/infra/application/app-tauri vertes (assertion structured_launch_d3
recodée sur le contrat). Test déterminisme/restart dédié à ajouter au retour de
quota agent (binôme Test interrompu par limite de session).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 18:10:50 +02:00
19ba77824f docs(architecture): §19.7 cohérence des ids + découpage P8a→P8d
Cadrage du swap cross-profile et correction de l'incohérence de clé P6/P7 :
LeafCell.conversation_id = id de paire IdeA (clé log/handoff, stable au swap),
resumable moteur isolé par provider dans providers.json (+ cache
engine_session_id). §19.6 : P8 éclaté en P8a (prioritaire/bloquant) → P8d.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:47:57 +02:00
c1411d3b69 feat(persistence): P7 — reprise par injection du handoff au lancement
Au (re)lancement, l'agent retrouve sur quoi il travaillait : si la cellule a une
conversation_id avec un handoff, son objectif + résumé sont injectés en dernière
section du convention file (# Reprise de la conversation), best-effort et
provider-indépendant.

- application : port HandoffProvider (root par appel, comme MemoryRecall) ;
  LaunchAgent.with_handoff_provider + resolve_handoff (parse uuid + load,
  jamais bloquant) ; section rendue dans compose_convention_file après la
  mémoire projet
- app-tauri : AppHandoffProvider (FsHandoffStore sur le root du projet) câblé
- tests : 4 purs (objectif présent/None/blanc/absent) + 5 intégration
  (happy path, provider absent, conversation_id None, uuid invalide, sans
  handoff ⇒ lancement OK sans section) ; application 311 + app-tauri 204 verts

Volet --resume/providers.json = P8 (swap cross-profile).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:42:19 +02:00
0bf7a5c43c feat(persistence): P6b — câblage live du checkpoint conversationnel (best-effort)
ask_agent persiste désormais chaque paire dans son conversationId : tour Prompt
à l'enqueue, tour Response au succès — best-effort (un échec de persistance ne
dégrade jamais la délégation).

- application : port RecordTurnProvider (matérialise un RecordTurn sur le bon
  project root — OrchestratorService est mono-instance multi-projets, le log est
  par root) ; wither with_record_turn(provider, Clock) ; helper
  record_turn_best_effort ; horodatage via domain::ports::Clock (pas d'horloge
  infra dans application)
- app-tauri : AppRecordTurnProvider (Fs* sur le root du projet) câblé au
  composition root avec le SystemClock partagé
- tests : 5 cas (paire Prompt→Response, fil A↔B vs User↔B, no-op sans provider,
  ask Ok même si record échoue) ; orchestrator_service 40, application +
  app-tauri verts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:31:13 +02:00
2a5873dcf0 feat(persistence): P6a — use case RecordTurn (checkpoint fin de tour, isolé)
Logique du checkpoint conversationnel isolée et testable, sans câblage live :
append du tour au log canonique → load handoff → fold incrémental (tour neuf
seul) → save. Mapping StoreError→AppError via From existant. Debounce repoussé.

- application/src/conversation/{mod,record}.rs : RecordTurn::record
- tests : 6 cas (incrémentalité prouvée via summarizer-espion, propagation
  d'erreur sans effet de bord, isolation par conversation) ; suite verte

Câblage sur les seams réels (reply/ask) + composition root = P6b.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:20:05 +02:00
f3046f3dd8 feat(persistence): P5 — ProviderSessionStore (resumable_id par provider)
Range le resumable_id du moteur par (conversation, provider) dans
providers.json, support de reprise non exclusif (le handoff reste la source de
fidélité au swap cross-profile) — §19.2/§19.3.

- domaine : port ProviderSessionStore (get/set par provider)
- infra : FsProviderSessionStore — providers.json map plate, set en
  read-modify-write atomique (tmp+rename) sérialisé par conversation
  (coexistence multi-providers garantie), get absent ⇒ None, corrompu ⇒
  StoreError::Serialization

Tests : 6 cas P5 (coexistence claude+codex, 12 set concurrents sans perte,
corruption, isolation) ; cible conversation_log 30 verts, suites complètes
sans régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:14:34 +02:00
75e4f57a71 feat(persistence): couche conversationnelle — cadrage §18/§19 + briques P1→P4
Resync ARCHITECTURE.md (état livré + cadrage persistance/handoff) et premières
briques de la couche de persistance conversationnelle (log canonique par paire
+ handoff incrémental), indépendante du provider — prépare reprise fiable et
handoff cross-profile Claude↔Codex.

ARCHITECTURE.md
- §14.3.2/§17 : M5 marqué livré, verrou « ouvert » périmé, §17 réconcilié
  (vue = terminal de sortie, pas d'UI chat) ; §18 état livré 2026-06-12 ;
  §19 cadrage persistance/handoff (log par paire + handoff, 10 lots P1→P10)

Domaine (conversation_log.rs, pur)
- P1 : ConversationTurn / TurnId / TurnRole + port ConversationLog
- P3 : Handoff + port HandoffStore
- P4 : port HandoffSummarizer (async, seam OCP pour adapter LLM futur)

Infrastructure (conversation_log/)
- P2 : FsConversationLog — JSONL append-only par paire, sync_all (durabilité
  crash), skip ligne corrompue, fichier absent ⇒ vide
- P3 : FsHandoffStore — handoff.md front-matter, write atomique tmp+rename
- P4 : HeuristicHandoffSummarizer — incrémental, zéro modèle/I/O, fenêtre WINDOW

Tests : domaine 12 + infra 24 (conversation_log) verts, suites complètes sans
régression. Cycle dev/test : le binôme a débusqué et corrigé un bug de
durabilité (append sans flush) au passage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:09:35 +02:00
eca2ba95c4 feat(agent): conversation par paire + entrée médiée + pivot terminal/MCP
Coeur inter-agents consolidé et surface front réalignée sur la décision
"terminal natif PTY, pas d'UI chat" (Option 1).

Domaine
- nouveaux modules conversation, mailbox, input, fileguard (ports + types)
- orchestrator/profile/events étendus (conversation par paire, FIFO)

Application / Infrastructure
- orchestrator/service + context_guard : sérialisation FIFO par agent,
  garde RW mémoire/contexte, dispatch ask/reply
- adapters in-memory conversation / mailbox / input / fileguard
- registry session + lifecycle agent durcis (1 agent = 1 session vivante)
- outils MCP idea_* alignés sur le nouveau dispatch

Frontend
- MediatedInput + useAgentBusy : entrée utilisateur médiée par IdeA,
  terminal = vue sortie inchangée
- suppression de la vue chat structurée (AgentChatView) — abandonnée
- adapter input + ports mis à jour

Divers
- .ideai/ : mémoire projet + briefs de cadrage versionnés ;
  requests/ runtime ignoré ; agents projet réels (DevBackend/DevFrontend/QA)

Tests : Rust (domain/application/infrastructure/app-tauri) + front (346) verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 07:33:04 +02:00
5f45c22941 fix(agent): ne câbler le PTY qu'en lancement brut, pas structuré — §17.4/§17.6
Un launch structuré route vers une AgentSession enregistrée dans
StructuredSessions ; son id n'est jamais un PTY vivant. Câbler le bridge PTY
appelait subscribe_output avec un id inconnu de l'adapter → NotFound
("pty handle not found"), faisant échouer tout lancement d'agent structuré.
On ne pompe les octets que sur le chemin PTY brut (structured: None).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:15:36 +02:00
2f20fdbab4 fix(agent): retrait --ask-for-approval invalide pour codex exec 0.137 — §17.9 D3
`codex exec` 0.137 ne connaît pas `--ask-for-approval` (`error: unexpected
argument`) ; ce flag appartient à la commande interactive `codex`, pas à la
sous-commande `exec` déjà non-interactive. Sa présence cassait toute réponse
d'un agent Codex (flux structuré terminé sans Final), bloquant la conversation
inter-agents. argv désormais verrouillé exact dans les tests (16/16 verts).

Découvert et corrigé pendant la validation réelle de la messagerie inter-agents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:15:36 +02:00
cf89b3b9a5 feat(agent): bind transport S-MCP — outils idea_* vivants de bout en bout (M5a-e) — §14.3.1
Dernier kilomètre de l'orchestration native : une CLI MCP réellement lancée
joint le serveur MCP du projet et ses outils idea_* aboutissent au vrai dispatch.

- M5a endpoint loopback par projet (interprocess UDS/named pipe, source unique
  mcp_endpoint, cleanup au close) — zéro port réseau, AppImage/SSH-safe.
- M5b sous-commande `idea mcp-server` : pont stdio↔loopback headless (avant init
  Tauri), handshake {"project","requester"}, endpoint-absent borné, EOF propre.
- M5c McpServerHandle boucle accept + serve_peer par pair ; requester réel
  propagé jusqu'à OrchestratorRequestProcessed (fin du "mcp" figé) ; isolation
  des pairs ; terminaison propre.
- M5d apply_mcp_config écrit la déclaration réelle (current_exe + --endpoint
  mcp_endpoint + --project simple-uuid + --requester) ; McpRuntime injecté comme
  donnée (application ne dépend pas de app-tauri).
- M5e smoke e2e sur vrai loopback : list/ask inline, cible PTY → erreur typée,
  JSON malformé → pas de panic, requester propagé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 18:09:41 +02:00
6ca519b815 feat(agent): robustesse routage ask — fix registre session + concurrence (R0+A0) — §14.3/§15
Stabilise le routage de `ask` avant le bind transport MCP (v5). Invariant
« 1 agent = 1 employé » durci ; un agent traite un tour à la fois.

- R0a garde LaunchAgent : lève AgentAlreadyRunning pour un lancement neuf
  ciblant un agent déjà vivant sur un autre node (PTY + structuré) ; rebind
  seulement même-node ou réattache explicite (conversation_id). Idem spawn_agent.
- R0b list_live_agents agrège PTY + structuré (LiveSessions) + dédup.
- R0c réconciliation des layouts.json à doublons à l'ouverture (host déterministe,
  idempotent) — corrige « une cellule reset au retour d'onglet ».
- R0d UI : option agent désactivée si vivant ailleurs + « aller à la cellule »,
  mapping AGENT_ALREADY_RUNNING.
- A0 sérialisation FIFO des tours par agent_id dans ask_agent (verrou tokio par
  agent ; agents différents en parallèle ; timeout tour 300s, cap attente 600s).

Cadrage : .ideai/briefs/orchestration-v5-transport-bind-cadrage.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:34:57 +02:00
37e72747d3 feat(agent): orchestration v3 — surface MCP model-agnostic (M0→M4) — §14.3
Expose l'orchestration IdeA comme serveur MCP par-dessus le même
OrchestratorService::dispatch, avec repli fichier .ideai/requests pour les
CLI sans MCP. v3 réduite à la surface MCP : la messagerie inter-agents et la
corrélation requête↔réponse étaient déjà résolues par §17 (send_blocking).

- M0 capacité MCP sur le profil (McpCapability/McpConfigStrategy/McpTransport)
- M1 injection conf MCP au LaunchAgent + prose adaptée selon la surface
- M2 serveur/adapter MCP (JSON-RPC 2.0 maison ; outils idea_*) + ListAgents
- M3 câblage par projet (registre mcp_servers jumeau du watcher)
- M4 observabilité UI : OrchestratorRequestProcessed.source = file|mcp + badge

Trois portes d'entrée (fichier, MCP, UI) → un seul dispatch ; aucun nouveau
port applicatif ; MCP confiné à l'adapter infra. Tous lots verts (cycle §3).
Cadrage : .ideai/briefs/orchestration-v3-cadrage.md ; ARCHITECTURE.md §14.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:53:31 +02:00
97daf3fae5 feat(agent): surfacer reply dans le writer wire .response.json (D6b) — §17
Ferme le canal fichier de la messagerie inter-agents : un agent qui délègue
via le protocole .ideai/requests reçoit désormais le CONTENU de la réponse,
plus seulement l'ACK de cycle de vie.

- OrchestratorResponse gagne reply: Option<String> (skip_serializing_if
  None, camelCase). success() porte le reply ; failure() => None.
- dispatch_file passe out.reply (de l'OrchestratorOutcome D6) à success.

Champ additif : un .response.json legacy sans reply reste valide ; une
commande non-ask (agent.run, spawn_agent...) => clé reply absente du JSON.

Tests (QA) : +4 verts — ask => reply présent ET detail coexistent ;
non-ask => clé absente (garde anti-always-green sur l'absence littérale) ;
round-trip legacy ; failure => absent. cargo test --workspace : 824 passed.

Orchestration structurée bouclée : in-process (D6) + protocole fichier (D6b).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:04:13 +02:00
6de4e5a6e0 feat(agent): menu de profils restreint Claude/Codex + retrait custom (D7) — §17
Dernier lot de §17 : seuls les profils pilotables en mode structuré sont
proposés à la sélection/création.

- domain : AgentProfile::is_selectable() = structured_adapter.is_some(),
  source unique de vérité du prédicat.
- infrastructure : AgentSessionFactory::supports délègue à is_selectable
  (supports et is_selectable ne peuvent plus diverger).
- application : selectable_reference_profiles() = reference_profiles()
  filtré ; ReferenceProfiles et FirstRunState exposent la liste filtrée
  (Claude/Codex). reference_profiles() brut reste à 4 (data intacte) ⇒
  un agent Gemini/Aider/custom legacy déjà configuré continue de tourner.
- frontend : bloc AddCustomProfile retiré du wizard first-run, action
  addCustom retirée du viewmodel ; wizard n'affiche que la liste filtrée.

Tests (QA) : is_selectable (table de vérité), cohérence stricte
is_selectable<->supports, liste exposée=2 / data brute=4, garde
anti-régression Vitest sur l'absence du bloc custom — validées par
mutation. cargo test --workspace : 820 passed. npx vitest run : 344 passed.

§17 COMPLET (D0->D7). Suivi restant : D6b (surfacer reply dans le writer
wire .response.json pour la délégation par protocole fichier).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:57:51 +02:00
dd1194abe8 feat(agent): messagerie inter-agents via send_blocking (D6) — §17
Comble le trou historique : un agent qui en interroge un autre reçoit enfin
le CONTENU de la réponse, plus seulement un ACK de cycle de vie.

- domain : OrchestratorCommand::AskAgent { target, task } + action wire
  agent.message (target+task requis) ; DomainEvent::AgentReplied
  { agent_id, reply_len } (bus I/O-free, le contenu remonte par l'outcome).
- application : OrchestratorOutcome gagne reply: Option<String>. Méthode
  ask_agent (§17.4) — cible structurée vivante ⇒ send_blocking direct ;
  cible morte ⇒ LaunchAgent structuré puis send ; agent vivant en PTY ou
  profil sans structured_adapter ⇒ Invalid (non adressable, jamais d'ACK
  trompeur) ; agent inconnu ⇒ NotFound ; service non câblé ⇒ Invalid.
  Timeout (300s) ⇒ erreur typée SANS tuer la session. Succès ⇒ publie
  AgentReplied + reply: Some(content). Injection additive via builders
  with_structured / with_events (call sites legacy intacts).
- app-tauri : state câble with_structured/with_events ; DTO AgentReplied.

Tests (QA) : +14 verts (11 app + 3 domaine), invariants validés par
mutation test (reply!=None, timeout ne shutdown pas). cargo test
--workspace : 817 passed, 0 failed.

Suivi (D6b) : surfacer reply dans le writer wire .response.json
(infrastructure/orchestrator) pour la délégation par protocole fichier.
Reste D7 : menu restreint Claude/Codex + retrait custom.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:44:53 +02:00
5059f37890 feat(agent): vue chat frontend + routage cellKind (D5) — §17
Le frontend consomme l'exécution structurée livrée en D4 :
- AgentChatView : jumeau chat de TerminalView. Accumule les textDelta du
  tour courant, badges toolActivity, fige sur final (le contenu final
  remplace les deltas, pas de double rendu). Saisie Enter/Shift+Enter.
  Au montage : reattach (rejoue le scrollback) ou launch ; au démontage :
  détache seulement (jamais de close). Vue pure pilotée par le port.
- LayoutGrid/LeafView : routage cellKind — AgentChatView si "chat",
  TerminalView sinon (chemin PTY inchangé). Cache cellKindBySession pour
  router correctement après ré-attache/navigation.
- Port AgentGateway : sendPrompt / reattachChat / closeAgentSession ;
  cellKind sur TerminalHandle. Adapter Tauri (invoke + Channel<ReplyChunk>)
  et mock (MockChatSession, _setChatAgents) étendus.
- Types TS mirrors : CellKind, ReplyChunk, ReattachChatDto + cellKind sur
  TerminalSession.

Tests (QA) : 21 Vitest verts — routage chat/pty (non-régression terminal),
accumulation/non-doublon (garde anti-always-green delta!=final), ré-attache
sans re-spawn, envoi Enter vs Shift+Enter, badges toolActivity, mock stream.
npx vitest run : 345 passed, 0 failed. npm run build vert.

Reste D6 (messagerie inter-agents via send_blocking) et D7 (menu restreint
Claude/Codex + retrait custom) pour clore §17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:30:20 +02:00
f4d5727a69 feat(agent): commandes Tauri + ChatBridge streaming (D4) — §17
Expose l'exécution structurée à l'UI (jumeau du chemin PTY) :
- ChatBridge (chat.rs) : pompe ReplyStream -> Channel<ReplyChunk>,
  generation-tracked (anti-double-pompe) ; scrollback de conversation
  côté transport (le port AgentSession est sans mémoire), purgé à la
  fermeture, préservé à la ré-attache.
- Commandes agent_send / reattach_agent_chat / close_agent_session.
- DTO : ReplyChunk (textDelta/toolActivity/final, camelCase tagué kind),
  ReattachChatDto, CellKind {pty,chat} + champ cellKind dérivé sur
  TerminalSessionDto (chat ssi LaunchAgentOutput.structured = Some).
- Wiring composition root : StructuredSessions + ChatBridge dans AppState,
  via la factory déjà injectée (aucun new d'adapter — règle D §17.8).
- From<AgentSessionError> for AppError (mappe sur PROCESS).

Tests (QA) : 35 unitaires verts — generation supersede validé par mutation
test, séquence deltas*+Final, reattach sans re-spawn, teardown shutdown+
unregister, DTO cellKind + round-trip ReplyChunk, non-régression PTY.
cargo test --workspace : 803 passed, 0 failed.

Reste D5 : AgentChatView (frontend) + routage cellKind dans LayoutGrid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:15:08 +02:00
050afa7d24 fix(test): champ structured manquant dans LaunchAgentOutput (build D3 cassé)
Le commit D3 (56913b9) a ajouté le champ structured: Option<...> à
LaunchAgentOutput mais a laissé deux constructions de test sans ce champ,
cassant la compilation du crate de test app-tauri (E0063). Ajoute
structured: None aux deux. Suite workspace de nouveau verte (775/0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:56:44 +02:00
56913b9053 feat(agent): routage LaunchAgent structuré vs PTY + réconciliation A/B (D3) — §17
- LaunchAgent route sur profile.structured_adapter : Some ⇒ AgentSession via
  factory + enregistrement StructuredSessions (aucun pty.spawn) ; None ⇒ PTY
  inchangé. Dépendances structurées injectées par builders additifs
  with_structured (signatures publiques inchangées ⇒ A/B legacy verts).
- LaunchAgentOutput étendu d'un champ optionnel structured (non cassant).
- A (ChangeAgentProfile) : kill polymorphe — session structurée ⇒ shutdown(),
  PTY ⇒ kill ; détection sur les deux registres.
- B : resume_supported vrai pour profil structuré ; resolve_session_plan ⇒
  Resume{conversation_id} (Claude --resume / Codex exec resume).
- Codex : build_spawn_line porte --sandbox workspace-write --ask-for-approval
  never (autonomie d'écriture ; à terme piloté par les permissions).

Tests : structured_launch 8 + codex flags 2 ; application/infrastructure 0 échec.

Reste D4 : ChatBridge (Channel) + commandes agent_send/reattach + StructuredSessions
dans AppState + DTO cellKind/ReplyChunk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:48:25 +02:00
f104682477 fix(agent): adapters Claude/Codex au format CLI réel (spikes S1/S2 résolus)
Formats vérifiés sur les vraies CLI le 2026-06-09 :
- Claude (claude -p … --output-format stream-json --verbose ; reprise --resume
  <session_id>) : parse system/init (session_id), ignore rate_limit_event +
  types inconnus, assistant.content[] ITÉRÉ (fix multi-blocs), result→Final.
  Flag --verbose ajouté à build_spawn_line.
- Codex (codex exec --json --skip-git-repo-check ; reprise codex exec resume
  <thread_id>) : thread.started→conversation_id, item.completed/agent_message
  →Final, autres items→ToolActivity, turn.*/inconnu ignorés.

parse_event émet désormais Vec<ReplyEvent> (multi-blocs) ; drain aplatit.
Scripts fake CLI passés aux lignes réelles. Tests : session 46 + intégration,
workspace vert.

À vérifier à l'intégration (D3) : reprise Codex (ordre d'args), et lancement
Codex avec --sandbox workspace-write + approbation non bloquante (hors parsing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:34:03 +02:00
751d94dd89 feat(agent): adapters structurés Claude/Codex + fake CLI + conformité (D2) — §17
infrastructure/src/session/ : machinerie de process générique (paramétrable par
la commande = seam d'injection du fake CLI), adapters ClaudeSdkSession/
CodexExecSession avec parsing ISOLÉ par adapter (parse_event), factory
StructuredSessionFactory (routage par structured_adapter), FakeCli scriptable +
harnais de conformité Liskov assert_agent_session_contract.

Incarnation « un run par tour » (send relance claude -p / --resume <id>,
continuité via conversation_id — colle au pivot reprise B).

Tests : 41 contre le FAKE CLI (jamais le vrai claude/codex), workspace vert.

Points en attente des spikes S1/S2 (format réel) — n'impactent que parse_event :
- mapping JSON→ReplyEvent Claude (S1) et Codex (S2) sur schémas SUPPOSÉS ;
- Claude multi-blocs : parse_event ne garde que le 1er bloc (à corriger si Claude
  émet plusieurs blocs/message — confirmer S1) ;
- flux sans Final : permissif côté adapter, l'erreur est gérée par send_blocking
  (consommateur). À reconfirmer côté UI streaming (D4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:11:38 +02:00
5e10b5eb42 feat(agent): fondation exécution structurée des agents IA (D0+D1) — §17
Pivot orchestration : agents IA pilotés via leur mode programmatique/JSON
(capture déterministe), au lieu du TUI brut + self-report. §16 (idea/MCP)
marquée remplacée comme voie principale.

- D0 (domaine) : port AgentSession + AgentSessionFactory, types ReplyEvent
  /ReplyStream/AgentSessionError, champ AgentProfile.structured_adapter
  (Option<StructuredAdapter{Claude,Codex}>, skip si None ⇒ zéro régression),
  catalogue Claude/Codex annotés.
- D1 (application) : registre StructuredSessions (jumeau de TerminalSessions),
  agrégateur LiveSessions{pty,structured} derrière LiveAgentRegistry (vivant si
  PTY OU structuré, surface du trait inchangée), helper send_blocking (draine le
  ReplyStream jusqu'au Final, Timeout sans tuer la session).

Tests : domaine 16+2 ; application registre 11 + send_blocking 9 ; workspace 0 échec.
A/B intacts. Aucun adapter concret (D2), pas de Tauri/front.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:54:48 +02:00
7375f706da feat(agent): reprise des sessions au redémarrage (B2) — commande + ResumeProjectPanel
- Tauri : commande list_resumable_agents (projectId) + ResumableAgentListDto
  { resumable } / ResumableAgentDto (camelCase, conversationId omis si None),
  câblage state.rs (réutilise stores + ProfileStore, aucun nouveau port).
- Front : gateway listResumableAgents, ResumeProjectPanel monté à l'ouverture
  de projet (opt-in, FR) : Reprendre (launch_agent nodeId+conversationId),
  Nouvelle conversation (setCellConversation(null) puis launch), Ignorer,
  Tout reprendre/ignorer. resumeSupported=false ⇒ « relance à neuf ».
- doc : §15.2 coquille agents→resumable (alignée use case/back/front).

Tests : app-tauri dto 9/9 ; vitest 324 (+10) ; workspace Rust 0 échec. 0 régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:04:41 +02:00
b82e3e1a40 feat(agent): UI hot-swap de profil (A2) — commande Tauri + sélecteur + dialog
- Tauri : commande change_agent_profile + ChangeAgentProfileRequestDto/Dto
  (camelCase, relaunchedSession omis si None), câblage state.rs par composition.
- Front : gateway changeAgentProfile (adapters Tauri+mock), sélecteur de profil
  par agent, dialog de confirmation FR (« Changer le moteur abandonne l'historique
  de conversation… »), refresh sur event agentProfileChanged.

Tests : app-tauri dto 5/5 ; vitest 314/314 (mapping, payload, dialog gating,
libellé FR, refresh). 0 régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:07:35 +02:00
2433e173a1 feat(agent): backend hot-swap profil (A0+A1) + inventaire reprise session (B1) — L15
Cadrage Architecture §15 (figé) : « agent = entité à session persistante ».

- A0 (domaine) : Agent::with_profile, LayoutTree::leaf, event AgentProfileChanged
  (+ DTO miroir DomainEventDto camelCase).
- A1 (application) : use case ChangeAgentProfile — no-op si profil identique,
  mutation manifeste, nettoyage conversation_id/agent_was_running sur layouts
  persistés, swap à chaud (kill PTY + relance même cellule via composition de
  LaunchAgent), event AgentProfileChanged. Décision : repartir à neuf (on garde
  .md + mémoire, on jette l'historique de conversation).
- B1 (application) : use case ListResumableAgents (lecture seule) — inventaire des
  cellules was_running||conversation_id, resume_supported selon profil, best-effort.

Aucun nouveau port/adapter (composition de l'existant). Hexagonal strict.
Tests : domaine 11 + app-tauri dto + ChangeAgentProfile 9 + ListResumableAgents 8,
suite application complète verte (0 régression).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:55:44 +02:00
62bd5130fb chore(agents): contextes DevBackend, DevFrontend et QA pour le chantier « agent = entité »
Personas durables alignés sur la méthode (cycle dev↔QA), l'architecture
hexagonale réelle (crates/dossiers/commandes) et la roadmap A+B+C.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:55:29 +02:00
785e9935fd feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé
- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs
  (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés,
  environnement local détecté, stratégies compilées). UI EmbedderSettings.
- LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la
  mémoire dépasse le budget de recall sans embedder configuré (event
  EmbedderSuggested, anti-spam 1×/session, « ne plus demander »).
- Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les
  agents/profils au lancement, avant la persona. UI ProjectContextPanel.

Tests : backend workspace vert (0 échec) ; frontend 306/306.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:24:51 +02:00
32398827fb feat(memory): embedders vectoriels réels HTTP + ONNX derrière features (LOT C1)
Remplace les StubEmbedder pour les stratégies localServer/api/localOnnx par de
vrais moteurs, chacun derrière une feature cargo off-by-default — la posture
fondatrice « rien d'imposé, zéro dépendance » (défaut none → rappel naïf) reste
byte-for-byte inchangée.

C1a (feature vector-http, reqwest rustls optional):
- HttpEmbedder couvrant localServer (Ollama/llama.cpp) et api (OpenAI/Voyage…),
  payload OpenAI-compatible /v1/embeddings, ordre restauré par index, bearer
  token lu via env var (jamais en clair), timeout client 30s.
- detect_ollama() pour la détection de l'existant (C3).

C1b (feature vector-onnx, fastembed v5 optional):
- OnnxEmbedder en-process (e5-small, dim 384), init paresseuse + spawn_blocking,
  cache modèle sous <app_data>/embedders/onnx — aucun download au build ni au
  first-run, uniquement à la demande au 1er embed.
- Catalogue RECOMMENDED_ONNX_MODELS + ONNX_CACHE_SUBDIR + onnx_model_is_cached
  exposés (sans feature) pour la config (C2) et la popup (C3).

embedder_from_profile(profile, onnx_cache_dir) dispatche feature-gated ; sans la
feature, retombe sur StubEmbedder (Unsupported) → fallback naïf via
AdaptiveMemoryRecall. Composition root (build_memory_recall) propage le cache dir.

Tests: 10 HTTP + 6 ONNX (dont 2 #[ignore] download réel) + 26 vectoriels, verts
en défaut, --features vector-http et --features vector-onnx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:00:40 +02:00
b39c11a64d fix(agents): enforcer l'invariant « 1 session vivante par agent » (singleton)
Un agent ne peut tourner que dans une seule cellule à la fois. La garde dans
LaunchAgent refuse le spawn si l'agent est déjà vivant dans un autre node
(AGENT_ALREADY_RUNNING) ; idempotent sur le même node ; le chemin resume
(agent mort) reste inchangé. Le node_id est désormais plombé jusqu'au use case.

Corrige le reset asymétrique d'une cellule au changement d'onglet : deux leaves
partageant le même agent id rendaient session_for_agent/is_agent_live/stop_agent
ambigus (cible arbitraire). Le churn reset/reattach déclenchait aussi les accents
mélangés (FIFO intact, non touché).

- snapshot agentWasRunning calculé par node (is_node_live) et non par agent
- commande list_live_agents + live_agents()/node_for_agent()/is_node_live()
- UI : dropdown grise les agents déjà placés ailleurs ; 2e cellule en doublon
  affiche « disponible » au lieu d'une relance fantôme

Tests : cargo test (application + app-tauri) vert ; tsc + vitest vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 14:43:48 +02:00
f3bc3f20d8 feat(memory): clôture du sujet mémoire — bascule adaptative live + panneau UI (§14.5.5)
Pièce 1 (backend) : state.rs câble AdaptiveMemoryRecall via build_memory_recall,
piloté par le profil embedder chargé depuis embedder.json (fallback none). Défaut
none ⇒ NaiveMemoryRecall nu (comportement inchangé, StubEmbedder jamais touché,
zéro dépendance lourde). Instance de recall partagée (RecallMemory + LaunchAgent).
Chargement du profil isolé sur un runtime dédié (évite le block_on imbriqué).

Pièce 2 (frontend) : feature mémoire complète en miroir de skills —
MemoryGateway (port) + TauriMemoryGateway + MockMemoryGateway, types domaine
Memory/MemoryIndexEntry/MemoryType, MemoryPanel/MemoryEditor/useMemory, onglet
sidebar « Memory » dans ProjectsView. CRUD par slug, liens [[slug]] résolus.

Le sujet mémoire est clos : CRUD .md + index + rappel adaptatif + injection à
l'activation des agents + UI de gestion. Embedder concret ONNX/HTTP reste un
follow-up (défaut none = pleinement fonctionnel sans dépendance).

Tests: backend 57 binaires verts, frontend 285 tests verts, typecheck OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:28:27 +02:00
2435857cbf feat(memory): injecter le rappel mémoire dans le convention file à l'activation (§14.5.4)
À l'activation d'un agent, LaunchAgent compose désormais une section
« # Mémoire projet » dans le convention file généré (CLAUDE.md/AGENTS.md…),
au même titre que les skills assignés (§14.2). Les agents lisent ainsi la
mémoire projet sans aucun flag ni mécanisme propre à une CLI.

- LaunchAgent reçoit le port MemoryRecall (Arc<dyn MemoryRecall>), résout le
  rappel (budget AGENT_MEMORY_RECALL_BUDGET=2048, requête = persona de l'agent)
  en best-effort : mémoire vide/absente ou erreur ⇒ section omise, le launch
  n'est jamais bloqué (comme un SkillRef dangling).
- compose_convention_file gagne un argument `memory: &[MemoryIndexEntry]`
  (reste pure) ; section injectée seulement pour la stratégie conventionFile.
- state.rs : une seule instance NaiveMemoryRecall partagée (recall + LaunchAgent).

Tests: 57 binaires verts. compose_convention_file (vide ⇒ inchangé, ordre,
cohabitation skills) + intégration LaunchAgent (section présente / absente /
best-effort sur erreur / pas d'injection en stratégie env).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 09:01:51 +02:00
98a8b7292a feat(memory): système de mémoire projet model-agnostic (L14, LOT A+B+C)
Base de connaissance persistante par projet, indépendante de tout modèle/CLU
et de git. Cadrage archi en §14.5 (ARCHITECTURE.md), cycle Archi→Dev→Test.

LOT A — étage 1 (.md, source de vérité)
  - domaine: entité Memory (+ MemorySlug, MemoryType, MemoryFrontmatter,
    MemoryLink, MemoryIndexEntry), liens [[slug]], index MEMORY.md dérivé
  - port MemoryStore + MemoryError, adapter FsMemoryStore (.ideai/memory/)
  - application: 7 use cases (Create/Update/List/Get/Delete/ReadIndex/
    ResolveLinks), From<MemoryError> for AppError
  - app-tauri: commandes + DTO, events MemorySaved/MemoryDeleted
  - suppression de la variante morte DomainError::MalformedFrontmatter

LOT B — rappel adaptatif (étage 1)
  - port MemoryRecall + MemoryQuery, adapter NaiveMemoryRecall (troncature
    au budget de tokens, court-circuit budget-0), use case RecallMemory

LOT C — étage 2 vectoriel (structure complète, zéro dépendance lourde)
  - port Embedder + EmbedderError, profils déclaratifs EmbedderProfile/
    EmbedderStrategy (embedder.json)
  - VectorMemoryRecall (cosinus, cache .ideai/memory/.index/ gitignoré)
  - AdaptiveMemoryRecall (bascule pure should_use_vector), défaut none
  - HashEmbedder (déterministe, tests), StubEmbedder (onnx/server/api)

Tests: 57 binaires verts, build + clippy --workspace sans warning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 08:47:23 +02:00
3ed0f6b45f feat(terminals): reprise de conversation par cellule + fix ordre d'écriture
Permet de recharger la conversation CLI précédente de chaque cellule à la
réouverture du projet, de façon universelle (indépendant du modèle/CLI).

- profil AgentRuntime: bloc déclaratif optionnel `session { assignFlag, resumeFlag }`
- LeafCell: `conversationId` (persistant, distinct du SessionId PTY) + `agentWasRunning`
- runtime: SessionPlan (None/Assign/Resume) + composition pure des args
- LaunchAgent: décide Assign vs Resume, génère l'UUID, remonte l'id assigné
  (persistance par l'appelant via setCellConversation — découplage SRP)
- close: SnapshotRunningAgents fige `agentWasRunning` avant le kill-all
  (statut clot/en cours universel, sans parsing CLI)
- SessionInspector: port optionnel best-effort + adapter ClaudeTranscriptInspector
- popup de reprise par cellule (statut + sujet/tokens si dispo), intercalée
  avant le Resume auto, jamais sur le chemin reattach

fix(terminals): sérialise les écritures PTY (file FIFO par handle) — corrige
les caractères mélangés/accents dus au réordonnancement des invoke Tauri concurrents

fix(layout): l'opération `move` préservait mal les champs du leaf (perdait `agent`)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:27:08 +02:00
d11eaaa8c0 feat(orchestrator): create_skill action + wire per-project watchers (§14.3)
- domain: OrchestratorCommand::CreateSkill with optional scope field
  (defaults to Project, case-insensitive, UnknownScope on bad value)
- application: dispatch CreateSkill through the CreateSkill use case
- app-tauri: build OrchestratorService and start/stop per-project request
  watchers on open/create/close_project (ensure/stop_orchestrator_watch)
- tests: domain validation, service dispatch, infra watcher e2e,
  app-tauri wiring lifecycle (idempotent, isolated, stop)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:06:41 +02:00
b9fd2fb925 fix(terminals): stop PTY output duplication on re-attach
Each open/reattach spawned a pump thread feeding pty_bridge[session]. The
broadcast hub added a subscriber per attach without dropping the previous one,
and the stale pump thread kept delivering (its send_output still succeeded
against the new channel) — so every byte was delivered twice (more after
further re-attaches). Accumulated on each tab/layout/agent switch; window
resize made it glaring.

- Broadcast::subscribe() is now single-consumer: a new subscription supersedes
  the previous one, ending the old pump thread's stream.
- PtyBridge gains a per-session generation; register() returns it and
  unregister_if(session, gen) only removes when still current, so a dying
  superseded thread can't tear down the channel that replaced it.
- All three attach sites (open/reattach/agent launch) use unregister_if.

Tests: 6 added (generation guard + single-consumer broadcast). Workspace green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:52:24 +02:00
301 changed files with 76536 additions and 1094 deletions

Submodule .claude/worktrees/agent-a2650e91d2bd39ca2 deleted from 480e7c7bbe

Submodule .claude/worktrees/agent-aeb1e862ef04b991b deleted from ef101db9dc

6
.gitignore vendored
View File

@ -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/

View File

@ -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
} }
] ]
} }

View File

@ -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}` |
--- ---

View File

View 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.

View 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
View 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
View File

77
.ideai/agents/qa.md Normal file
View 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.

View File

View 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.*

View 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**.

View 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**.

View 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**.

View 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**.

View 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).

View 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).

View 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.

View 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)`**`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`.

View 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

View File

@ -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).

View File

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

View File

@ -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

View File

@ -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"}

View File

@ -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é. ✅

View File

@ -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

View File

@ -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.

View File

@ -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."}

View File

@ -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
View 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.

View 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.

View 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.

View 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]].

View 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.

View 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".

View 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
View 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"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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 OKfeature 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

File diff suppressed because it is too large Load Diff

View File

@ -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.

View File

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

View 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

View File

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

View File

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

View File

@ -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!())

View File

@ -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()
} }

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

View 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é");
}
}

View File

@ -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

View 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")]);
}

View File

@ -7,16 +7,16 @@ use app_tauri_lib::dto::{
LayoutOperationDto, OpenTerminalRequestDto, ReattachResultDto, ResizeTerminalRequestDto, LayoutOperationDto, OpenTerminalRequestDto, ReattachResultDto, ResizeTerminalRequestDto,
TerminalClosedDto, WriteTerminalRequestDto, TerminalClosedDto, WriteTerminalRequestDto,
}; };
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput}; use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId}; use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId};
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
use application::{AppError, HealthInput}; use application::{AppError, HealthInput};
use domain::events::DomainEvent; use domain::events::DomainEvent;
use domain::ids::{AgentId, SessionId}; use domain::ids::{AgentId, 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(),

View File

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

View 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);
}

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

View File

@ -6,7 +6,9 @@ use app_tauri_lib::dto::{
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
GitFileStatusDto, GitStageRequestDto, GitStatusListDto, GraphCommitDto, GraphCommitListDto, GitFileStatusDto, GitStageRequestDto, GitStatusListDto, GraphCommitDto, GraphCommitListDto,
}; };
use application::{GitBranchesOutput, GitCommitOutput, GitGraphOutput, GitLogOutput, GitStatusOutput}; use application::{
GitBranchesOutput, GitCommitOutput, GitGraphOutput, GitLogOutput, GitStatusOutput,
};
use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit}; use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit};
use serde_json::json; use serde_json::json;
use uuid::Uuid; use uuid::Uuid;

View File

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

View File

@ -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()
} }

View 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());
}

View File

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

View File

@ -15,7 +15,10 @@ fn move_tab_result_serializes_new_window_id_camel_case() {
let dto = MoveTabResultDto::from(out); let dto = MoveTabResultDto::from(out);
let json = serde_json::to_string(&dto).unwrap(); let json = serde_json::to_string(&dto).unwrap();
assert!(json.contains("\"newWindowId\""), "json was {json}"); assert!(json.contains("\"newWindowId\""), "json was {json}");
assert!(!json.contains("new_window_id"), "no snake_case leak: {json}"); assert!(
!json.contains("new_window_id"),
"no snake_case leak: {json}"
);
} }
#[test] #[test]

View File

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

View 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);
}

View File

@ -47,7 +47,10 @@ fn send_output_delivers_bytes_to_registered_channel() {
bridge.register(session, capturing_channel(Arc::clone(&sink))); bridge.register(session, capturing_channel(Arc::clone(&sink)));
let delivered = bridge.send_output(&session, vec![104, 105]); let delivered = bridge.send_output(&session, vec![104, 105]);
assert!(delivered, "send_output should return true for a live session"); assert!(
delivered,
"send_output should return true for a live session"
);
let captured = sink.lock().unwrap(); let captured = sink.lock().unwrap();
assert_eq!(captured.as_slice(), &[vec![104, 105]]); assert_eq!(captured.as_slice(), &[vec![104, 105]]);
@ -82,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]));
}

View 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 LS1LS4 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"
);
}

View File

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

View File

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

View 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

View File

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

View 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 })
}
}

View 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);
}
}
}

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

View File

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

View 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;

View 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(())
}
}

View File

@ -0,0 +1,24 @@
//! Embedder configuration use cases (LOT C2 — §14.5.3).
//!
//! CRUD over the declarative [`domain::profile::EmbedderProfile`]s
//! ([`ListEmbedderProfiles`], [`SaveEmbedderProfile`], [`DeleteEmbedderProfile`])
//! plus a read-only description of the available engines ([`DescribeEmbedderEngines`]):
//! the recommended local ONNX models, the detected local environment, and which
//! strategies are actually compiled into this binary.
//!
//! A changed embedder takes effect at the **next IDE start** (the composition root
//! freezes the recall for the session): these use cases only persist/inspect config,
//! they never reconfigure a live recall.
mod suggestion;
mod usecases;
pub use suggestion::{
CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput,
DismissChoice, DismissEmbedderSuggestion, DismissEmbedderSuggestionInput, SuggestedThisSession,
};
pub use usecases::{
DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines,
EmbedderEnginesView, ListEmbedderProfiles, ListEmbedderProfilesOutput, OnnxModelView,
SaveEmbedderProfile, SaveEmbedderProfileInput, SaveEmbedderProfileOutput,
};

View File

@ -0,0 +1,269 @@
//! Contextual embedder-suggestion use cases (LOT C3, ARCHITECTURE §14.5.5).
//!
//! - [`CheckEmbedderSuggestion`] — the **best-effort** check run when an agent
//! reads the project memory at activation: the *first* time a project's memory
//! outgrows the recall budget while no embedder is configured (strategy `none`),
//! it publishes a one-time [`DomainEvent::EmbedderSuggested`]. Anti-spam: at most
//! once per session per project, and never again once the user chose
//! [`EmbedderPromptDismissal::Never`].
//! - [`DismissEmbedderSuggestion`] — persists the user's "plus tard" / "ne plus
//! demander" response.
//!
//! By construction this never blocks the activation flow: every error degrades to
//! "no suggestion", and a `none`-strategy/under-budget project is a cheap no-op.
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use domain::ports::{
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore,
EventBus, MemoryStore,
};
use domain::profile::EmbedderStrategy;
use domain::{DomainEvent, ProjectId, ProjectPath};
use crate::error::AppError;
/// In-memory "already suggested this session" guard, shared with the composition
/// root (held in `AppState`). One [`ProjectId`] per project that has already seen
/// the suggestion since the IDE started. Plain std types so the application stays
/// dependency-free and the guard is trivially testable.
pub type SuggestedThisSession = Arc<Mutex<HashSet<ProjectId>>>;
/// Sums the approximate token size of the index, mirroring the infrastructure
/// `index_token_size` so the application does not depend on the infra crate. One
/// entry's payload is its rendered index line (title + hook + slug), and the rule
/// only needs a *monotone* measure consistent with the recall's budget unit
/// (~1 token / 4 chars), so we count characters / 4 (min 1 per entry).
fn index_token_size(entries: &[domain::MemoryIndexEntry]) -> usize {
entries
.iter()
.map(|e| {
let chars = e.title.len() + e.hook.len() + e.slug.as_str().len();
(chars / 4).max(1)
})
.sum()
}
// ---------------------------------------------------------------------------
// CheckEmbedderSuggestion
// ---------------------------------------------------------------------------
/// Input for [`CheckEmbedderSuggestion::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckEmbedderSuggestionInput {
/// The project whose memory is being read at agent activation.
pub project_id: ProjectId,
/// That project's root (where its `.ideai/memory/` lives).
pub project_root: ProjectPath,
}
/// Output of [`CheckEmbedderSuggestion::execute`]: whether a suggestion event was
/// published on this call (useful for tests; the caller ignores it — the flow is
/// best-effort).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CheckEmbedderSuggestionOutput {
/// `true` iff an [`DomainEvent::EmbedderSuggested`] was published.
pub suggested: bool,
}
/// Publishes a one-time embedder suggestion the first time a project's memory
/// outgrows the recall budget while no embedder is configured (LOT C3).
///
/// Composes — each port only for its slice (Interface Segregation):
/// - [`EmbedderProfileStore`] to read the active strategy (no non-`none` profile ⇒
/// strategy `none`),
/// - [`MemoryStore`] to measure the memory index size,
/// - [`EmbedderPromptStore`] for the persistent dismissal (`never` ⇒ silence),
/// - [`EmbedderEnvInspector`] to enrich the event with the detected environment,
/// - [`EventBus`] to publish, plus the shared [`SuggestedThisSession`] guard.
///
/// **Never blocks the caller**: any port error degrades to "no suggestion".
pub struct CheckEmbedderSuggestion {
profiles: Arc<dyn EmbedderProfileStore>,
memories: Arc<dyn MemoryStore>,
prompts: Arc<dyn EmbedderPromptStore>,
inspector: Arc<dyn EmbedderEnvInspector>,
events: Arc<dyn EventBus>,
suggested_this_session: SuggestedThisSession,
/// Recall token budget the memory must exceed (injected so it stays aligned
/// with [`crate::AGENT_MEMORY_RECALL_BUDGET`] without re-hardcoding it).
budget: usize,
/// Compiled-in HTTP capability flag (carried into the event).
vector_http_enabled: bool,
/// Compiled-in ONNX capability flag (carried into the event).
vector_onnx_enabled: bool,
}
impl CheckEmbedderSuggestion {
/// Builds the use case from its ports + the session guard + the budget and
/// compiled-capability flags (the latter injected at the composition root, the
/// application stays infra-free).
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new(
profiles: Arc<dyn EmbedderProfileStore>,
memories: Arc<dyn MemoryStore>,
prompts: Arc<dyn EmbedderPromptStore>,
inspector: Arc<dyn EmbedderEnvInspector>,
events: Arc<dyn EventBus>,
suggested_this_session: SuggestedThisSession,
budget: usize,
vector_http_enabled: bool,
vector_onnx_enabled: bool,
) -> Self {
Self {
profiles,
memories,
prompts,
inspector,
events,
suggested_this_session,
budget,
vector_http_enabled,
vector_onnx_enabled,
}
}
/// Whether any configured profile selects a non-`none` strategy. No profile (or
/// a store error) ⇒ `none` (the dependency-free default posture).
async fn strategy_is_none(&self) -> bool {
match self.profiles.list().await {
Ok(profiles) => !profiles
.iter()
.any(|p| p.strategy != EmbedderStrategy::None),
// A store failure must not turn into a suggestion: behave as configured
// (i.e. *not* `none`) so we stay silent.
Err(_) => false,
}
}
/// Runs the check best-effort.
///
/// Order (each step short-circuits to "no suggestion"):
/// 1. already suggested this session ⇒ stop (no I/O beyond the guard);
/// 2. a non-`none` strategy is configured ⇒ stop;
/// 3. the persisted dismissal is `never` ⇒ stop;
/// 4. the memory index size does not exceed the budget ⇒ stop;
/// 5. otherwise mark the session guard, probe the environment, and publish
/// [`DomainEvent::EmbedderSuggested`].
///
/// # Errors
/// Never in practice — the signature keeps a uniform `Result` with the other
/// use cases; every failing port read degrades to `Ok(suggested: false)`.
pub async fn execute(
&self,
input: CheckEmbedderSuggestionInput,
) -> Result<CheckEmbedderSuggestionOutput, AppError> {
let not_suggested = Ok(CheckEmbedderSuggestionOutput { suggested: false });
// 1. Once per session per project (cheap guard check before any I/O).
if self
.suggested_this_session
.lock()
.map(|set| set.contains(&input.project_id))
.unwrap_or(true)
{
return not_suggested;
}
// 2. Only when no embedder is configured (strategy `none`).
if !self.strategy_is_none().await {
return not_suggested;
}
// 3. Persistent "ne plus demander" silences the suggestion forever.
if matches!(
self.prompts.read(&input.project_root).await,
Ok(Some(EmbedderPromptDismissal::Never))
) {
return not_suggested;
}
// 4. Memory must have outgrown the recall budget (a fresh project has 0).
let size = match self.memories.read_index(&input.project_root).await {
Ok(entries) => index_token_size(&entries),
Err(_) => return not_suggested,
};
if size <= self.budget {
return not_suggested;
}
// 5. Mark the guard *before* publishing so a concurrent activation cannot
// double-fire; if another thread won the race, stay silent.
match self.suggested_this_session.lock() {
Ok(mut set) => {
if !set.insert(input.project_id) {
return not_suggested;
}
}
Err(_) => return not_suggested,
}
let report = self.inspector.inspect().await;
self.events.publish(DomainEvent::EmbedderSuggested {
project_id: input.project_id,
ollama_detected: report.ollama_detected,
onnx_cached: report.onnx_cached_models,
vector_http_enabled: self.vector_http_enabled,
vector_onnx_enabled: self.vector_onnx_enabled,
});
Ok(CheckEmbedderSuggestionOutput { suggested: true })
}
}
// ---------------------------------------------------------------------------
// DismissEmbedderSuggestion
// ---------------------------------------------------------------------------
/// The user's response to the embedder suggestion popup.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DismissChoice {
/// "Plus tard" — re-proposable on a future session.
Later,
/// "Ne plus demander" — never again (persistent).
Never,
}
impl From<DismissChoice> for EmbedderPromptDismissal {
fn from(c: DismissChoice) -> Self {
match c {
DismissChoice::Later => Self::Later,
DismissChoice::Never => Self::Never,
}
}
}
/// Input for [`DismissEmbedderSuggestion::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DismissEmbedderSuggestionInput {
/// The project the suggestion concerned.
pub project_root: ProjectPath,
/// The user's choice.
pub choice: DismissChoice,
}
/// Persists the user's dismissal of the embedder suggestion (LOT C3).
pub struct DismissEmbedderSuggestion {
prompts: Arc<dyn EmbedderPromptStore>,
}
impl DismissEmbedderSuggestion {
/// Builds the use case from the [`EmbedderPromptStore`] port.
#[must_use]
pub fn new(prompts: Arc<dyn EmbedderPromptStore>) -> Self {
Self { prompts }
}
/// Writes the dismissal state. A `later` keeps the suggestion re-proposable on
/// a future session; a `never` silences it for good.
///
/// # Errors
/// [`AppError::Store`] on a persistence failure.
pub async fn execute(&self, input: DismissEmbedderSuggestionInput) -> Result<(), AppError> {
self.prompts
.write(&input.project_root, input.choice.into())
.await?;
Ok(())
}
}

View File

@ -0,0 +1,245 @@
//! Embedder configuration use cases (LOT C2). Each is a single-responsibility
//! struct carrying its ports as `Arc<dyn Port>` and exposing one `execute`.
//!
//! - [`ListEmbedderProfiles`] / [`SaveEmbedderProfile`] / [`DeleteEmbedderProfile`]
//! — CRUD over the persisted [`EmbedderProfile`]s through the
//! [`EmbedderProfileStore`] port.
//! - [`DescribeEmbedderEngines`] — a read-only view of the engines available to the
//! "configure an embedder?" UI: the recommended ONNX model catalogue, a best-effort
//! snapshot of the local environment ([`EmbedderEnvInspector`]), and which strategies
//! are actually compiled into this binary.
//!
//! Hexagonal boundary: the application depends on the domain ports and on plain
//! data only. The static engine catalogue and the compiled-capability flags are
//! **injected** at the composition root (the infrastructure owns `reqwest`/`fastembed`,
//! never the application).
use std::sync::Arc;
use domain::ports::{EmbedderEnvInspector, EmbedderProfileStore};
use domain::profile::{EmbedderProfile, EmbedderStrategy};
use crate::error::AppError;
// ---------------------------------------------------------------------------
// ListEmbedderProfiles
// ---------------------------------------------------------------------------
/// Output of [`ListEmbedderProfiles::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListEmbedderProfilesOutput {
/// All configured embedder profiles (empty when none configured ⇒ `none`).
pub profiles: Vec<EmbedderProfile>,
}
/// Lists the configured embedder profiles from the store.
pub struct ListEmbedderProfiles {
store: Arc<dyn EmbedderProfileStore>,
}
impl ListEmbedderProfiles {
/// Builds the use case from the [`EmbedderProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn EmbedderProfileStore>) -> Self {
Self { store }
}
/// Lists configured embedder profiles.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(&self) -> Result<ListEmbedderProfilesOutput, AppError> {
Ok(ListEmbedderProfilesOutput {
profiles: self.store.list().await?,
})
}
}
// ---------------------------------------------------------------------------
// SaveEmbedderProfile
// ---------------------------------------------------------------------------
/// Input for [`SaveEmbedderProfile::execute`]: the raw fields of the profile to
/// upsert (validated into an [`EmbedderProfile`] entity).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveEmbedderProfileInput {
/// Stable identifier (e.g. `"local-onnx-minilm"`). Non-empty.
pub id: String,
/// Display name. Non-empty.
pub name: String,
/// Embedding strategy driving which concrete adapter is used.
pub strategy: EmbedderStrategy,
/// Model identifier, when the strategy needs one.
pub model: Option<String>,
/// Endpoint URL for a server/API strategy.
pub endpoint: Option<String>,
/// Name of the env var carrying the API key (never the key itself).
pub api_key_env: Option<String>,
/// Length of the vectors this engine produces. Non-zero.
pub dimension: usize,
}
/// Output of [`SaveEmbedderProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveEmbedderProfileOutput {
/// The saved (validated) profile, echoed back.
pub profile: EmbedderProfile,
}
/// Persists (creates or replaces by id) a single embedder profile, after building
/// and validating the entity.
pub struct SaveEmbedderProfile {
store: Arc<dyn EmbedderProfileStore>,
}
impl SaveEmbedderProfile {
/// Builds the use case from the [`EmbedderProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn EmbedderProfileStore>) -> Self {
Self { store }
}
/// Validates then saves the profile.
///
/// # Errors
/// - [`AppError::Invalid`] if the profile's invariants are violated (empty
/// `id`/`name`, zero `dimension`),
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: SaveEmbedderProfileInput,
) -> Result<SaveEmbedderProfileOutput, AppError> {
let profile = EmbedderProfile::new(
input.id,
input.name,
input.strategy,
input.model,
input.endpoint,
input.api_key_env,
input.dimension,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.store.save(&profile).await?;
Ok(SaveEmbedderProfileOutput { profile })
}
}
// ---------------------------------------------------------------------------
// DeleteEmbedderProfile
// ---------------------------------------------------------------------------
/// Input for [`DeleteEmbedderProfile::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteEmbedderProfileInput {
/// Id of the embedder profile to delete.
pub id: String,
}
/// Deletes an embedder profile by id.
pub struct DeleteEmbedderProfile {
store: Arc<dyn EmbedderProfileStore>,
}
impl DeleteEmbedderProfile {
/// Builds the use case from the [`EmbedderProfileStore`] port.
#[must_use]
pub fn new(store: Arc<dyn EmbedderProfileStore>) -> Self {
Self { store }
}
/// Deletes the profile.
///
/// # Errors
/// [`AppError::NotFound`] if the id is unknown, [`AppError::Store`] on
/// persistence failure.
pub async fn execute(&self, input: DeleteEmbedderProfileInput) -> Result<(), AppError> {
self.store.delete(&input.id).await?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// DescribeEmbedderEngines
// ---------------------------------------------------------------------------
/// A recommendable local ONNX model, as a plain application value (mirrors the
/// infrastructure `OnnxModelInfo` data, injected at the composition root so the
/// application never depends on the infrastructure crate).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OnnxModelView {
/// Stable model id accepted by a `localOnnx` profile's `model` field.
pub id: String,
/// Human-readable name for the UI.
pub display_name: String,
/// Length of the vectors this model produces.
pub dimension: usize,
/// Approximate download/disk size in megabytes.
pub approx_size_mb: u32,
/// Whether this is the recommended default model.
pub recommended: bool,
}
/// A read-only description of the embedding engines available to the
/// "configure an embedder?" UI (C2/C3).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmbedderEnginesView {
/// The curated catalogue of recommendable local ONNX models.
pub recommended_onnx: Vec<OnnxModelView>,
/// Whether an Ollama-style local embedding server was detected (best-effort).
pub ollama_detected: bool,
/// Ids of the recommended ONNX models already present in the local cache.
pub onnx_cached_models: Vec<String>,
/// Whether the HTTP capability (`localServer`/`api`) is compiled into this binary.
pub vector_http_enabled: bool,
/// Whether the in-process ONNX capability (`localOnnx`) is compiled into this binary.
pub vector_onnx_enabled: bool,
}
/// Describes the engines available to the embedder-configuration UI: the static
/// ONNX catalogue + compiled-capability flags (injected at construction), enriched
/// with a best-effort live snapshot of the local environment via the
/// [`EmbedderEnvInspector`] port. Read-only — emits no event, never fails on the
/// environment probe (the port is best-effort by contract).
pub struct DescribeEmbedderEngines {
inspector: Arc<dyn EmbedderEnvInspector>,
recommended_onnx: Vec<OnnxModelView>,
vector_http_enabled: bool,
vector_onnx_enabled: bool,
}
impl DescribeEmbedderEngines {
/// Builds the use case from the [`EmbedderEnvInspector`] port plus the static
/// engine catalogue and compiled-capability flags (data owned by infrastructure
/// and injected at the composition root, so the application stays infra-free).
#[must_use]
pub fn new(
inspector: Arc<dyn EmbedderEnvInspector>,
recommended_onnx: Vec<OnnxModelView>,
vector_http_enabled: bool,
vector_onnx_enabled: bool,
) -> Self {
Self {
inspector,
recommended_onnx,
vector_http_enabled,
vector_onnx_enabled,
}
}
/// Returns the engines view. Infallible in practice: the environment probe is
/// best-effort and degrades to "nothing detected".
///
/// # Errors
/// Never; the `Result` keeps the call site uniform with the other use cases.
#[allow(clippy::unused_async)]
pub async fn execute(&self) -> Result<EmbedderEnginesView, AppError> {
let report = self.inspector.inspect().await;
Ok(EmbedderEnginesView {
recommended_onnx: self.recommended_onnx.clone(),
ollama_detected: report.ollama_detected,
onnx_cached_models: report.onnx_cached_models,
vector_http_enabled: self.vector_http_enabled,
vector_onnx_enabled: self.vector_onnx_enabled,
})
}
}

View File

@ -6,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())
}
}

View File

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

View File

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

View File

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

View 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 })
}
}

View 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)
}
}

View File

@ -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(())
} }

View File

@ -8,7 +8,10 @@
use std::sync::Arc; use std::sync::Arc;
use domain::ports::{EventBus, FileSystem, ProjectStore}; use domain::ports::{EventBus, FileSystem, ProjectStore};
use domain::{AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId, ProjectId, SessionId}; use domain::{
AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId,
ProjectId, SessionId,
};
use crate::error::AppError; use crate::error::AppError;
@ -69,6 +72,14 @@ pub enum LayoutOperation {
/// Session to host, or `None` to clear. /// Session to host, or `None` to clear.
session: Option<SessionId>, session: Option<SessionId>,
}, },
/// Attach an existing live/background session to a leaf, clearing any
/// previous visible host of the same session.
AttachSession {
/// The hosting leaf.
target: NodeId,
/// Session to make visible in this leaf.
session: SessionId,
},
/// Attach or detach an agent to/from a leaf (per-cell agent, feature #3). /// Attach or detach an agent to/from a leaf (per-cell agent, feature #3).
SetCellAgent { SetCellAgent {
/// The hosting leaf. /// The hosting leaf.
@ -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)
} }

View File

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

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

View 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