74 Commits

Author SHA1 Message Date
15cf21c5bd merge(orchestrator): intègre le backstop no-reply + cause racine encode_cwd (rendez-vous inter-agents)
Intègre la branche feature/rendezvous-no-reply-backstop : durcissement du
rendez-vous idea_ask_agent ⇄ idea_reply, backstop no-reply, et le fix de cause
racine `encode_cwd` (encodage exact de Claude Code, `.` -> `-`) qui rétablit le
turn-watcher et lève le wedge.

Validé EN LIVE (wedge prouvé levé, demandeur libéré via grâce 2s) + suite verte
(cargo test --workspace 1616 passed / 0 failed, clippy 0 erreur).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:51:05 +02:00
c6f0f86f0e fix(inspector): encode_cwd réplique l'encodage exact de Claude Code (cause racine du wedge no-reply)
`encode_cwd` ne remplaçait que `/` et `\` par `-`, laissant le `.` intact. Or
Claude Code aplatit le cwd via `replace(/[^a-zA-Z0-9]/g, '-')` : tout caractère
non alphanumérique devient `-`, y compris le `.`. Pour un run dir isolé
`…/IdeA/.ideai/run/<uuid>`, le `/.` doit collapser en double dash
(`…-IdeA--ideai-run-<uuid>`). L'ancienne version calculait `…-IdeA-.ideai-run-…`,
un dossier inexistant sur disque : le turn-watcher voyait un répertoire vide
(baseline 0, conversation <none>) et ne déclenchait jamais `turn_ended`, ce qui
wedgeait le backstop no-reply du rendez-vous inter-agents.

Validé EN LIVE : demandeur libéré ~grâce 2s (au lieu du timeout 600s), preuves
dans idea.log. Test de non-régression `encode_cwd_encodes_dot_in_run_dir_to_double_dash`
+ maj test Windows (`C:\Users\me` -> `C--Users-me`).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:50:58 +02:00
744de20f4b feat(orchestrator): backstop no-reply du rendez-vous inter-agents
Remédiation du wedge persistant après échec live du fix Finding A (77e62e5).
Détecte la fin de tour d'un agent sollicité qui n'a pas appelé idea_reply et
débloque l'agent demandeur au lieu de le laisser en attente indéfinie.

Ajoute le suivi de tour côté inspector Claude (claude_turn_watcher) et la
résolution des chemins de session (claude_paths), câblés dans le rendez-vous
idea_ask_agent ⇄ idea_reply.

Build workspace vert, suite complète verte, zéro warning.
Backstop NON prouvé levé en live : merge develop interdit tant que la levée
du wedge n'est pas validée en conditions réelles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:50:58 +02:00
2ef5628c72 merge(layout): intègre la ré-attache de flux au réveil arrière-plan d'un agent
Correctif UI autonome (surface humaine), vert (tsc 0, vitest 444 tests),
sans lien avec le chantier backend rendez-vous.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:53:14 +02:00
db19ef35f5 fix(layout): ré-attacher le flux d'une cellule au réveil arrière-plan d'un agent
Une cellule ne ré-attachait pas le flux de sortie de son agent lorsque
celui-ci était réveillé en arrière-plan par une délégation : l'utilisateur
devait basculer la vue Plain↔agent pour voir l'agent travailler.

Ajout d'un effect de ré-attache déclenché sur le réveil arrière-plan + bump
de la key pour forcer le remontage de la vue. Test de régression
liveReattachOnDelegation : rouge sans le fix, vert avec.

Vérifs : tsc --noEmit exit 0 ; vitest run 48 fichiers / 444 tests OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:52:25 +02:00
77e62e54c1 merge(orchestrator): intègre le durcissement rendez-vous inter-agents + backstop no-reply (Finding A)
Validation e2e live VERTE sur AppImage 0.3.0 :
- chemin positif (délégation pong → idea_reply → reprise, live-state working→done, aucun Busy fantôme, aucun wedge)
- test adversarial (consigne de ne pas répondre refusée par l'agent ; durcissement Lot 1 robuste).
Filet Lot 2/3 = défense-en-profondeur non exercée. Tests réels verts (application 494, app-tauri 235, infrastructure 248, mcp_server 22/22).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:57:07 +02:00
c5e54935b8 feat(orchestrator): durcir le rendez-vous inter-agents + backstop no-reply (Finding A)
Corrige le wedge survenant lorsqu'un agent délégué ne rappelle pas idea_reply :
le rendez-vous ask_agent ne reste plus bloqué indéfiniment.

Lot 1 — durcissement du préambule de délégation :
- input/mod.rs : delegation_preamble renforcé (obligation explicite idea_reply).
- agent/lifecycle.rs : préambule injecté conditionné à mcp_enabled.

Lot 2 — peuplement du prompt_ready_pattern :
- agent/catalogue.rs : .with_prompt_ready_pattern("? for shortcuts") sur le
  profil Claude + tests de seed, pour calibrer la détection de grâce.

Lot 3 — backstop timeout serveur :
- mcp/jsonrpc.rs : code d'erreur typé RENDEZVOUS_NO_REPLY (-32001).
- mcp/server.rs : mapping timeout → erreur typée, with_ask_rendezvous_timeout
  promu + resolve_ask_rendezvous_timeout (configurable).
- app-tauri/state.rs : lecture env IDEA_ASK_RENDEZVOUS_TIMEOUT_MS.
- tests/mcp_server.rs mis à jour, fix d'un test input flaky.

Propagation overlay (référence des profils) :
- agent/catalogue.rs : overlay_reference_defaults + reference_index + tests.
- store/profile.rs : ReferenceBackfillProfileStore + tests.
- app-tauri/state.rs : wrap au composition root.
- exports mod.rs / lib.rs (application + infrastructure).

Tests réels verts : application 494, app-tauri 235, infrastructure 248,
mcp_server 22/22, 0 échec. Validation e2e LIVE (rebuild AppImage) en attente
avant merge vers develop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:46:48 +02:00
47b3806d32 chore(gitignore): désuivre .ideai/conversations/ (runtime non versionné, D19-4)
Le runtime conversationnel (handoff distillé + log.jsonl transcript par paire)
est reconstruit au fil de l'eau et ne doit pas être versionné : seul
.ideai/memory/ est le store durable. Aligne le suivi git sur le design D19-4
(cf. LS8 §7 point 3). Ajoute la règle .ideai/conversations/ au .gitignore et
désuit les 16 fichiers (8 handoff.md + 8 log.jsonl) sans les supprimer du disque.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:46:42 +02:00
36be0cb396 docs(live-state): clôture programme persistance/live-state (LS8)
Documentation de clôture du programme live-state/persistance (LS1→LS7, livré @ fd7adbb) :
- docs/LS8-live-state-persistence-closure.md : référence durable (4 stores, flux
  d'injection, chemin chaud/froid, règle de frontière, points ouverts).
- ARCHITECTURE.md : §19 marqué LIVRÉ, §14.1 items 7-8 (live-state + handoff),
  §18.5 catalogue MCP de 14 outils, nouvelle §21 (cartographie de clôture).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:53:02 +02:00
fd7adbb8a0 merge(live-state): intègre LS7 (viewer humain fil-par-paire, lecture seule)
Dernier lot fonctionnel du programme live-state : viewer frontend lecture seule
consommant read_conversation_page (LS6). tsc 0, vitest 443/443. Reste LS8 (doc de clôture).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:30:51 +02:00
c19e375849 feat(frontend): viewer humain fil-par-paire en lecture seule (LS7)
Frontend pur, lecture seule, consomme read_conversation_page (LS6) ; aucun changement backend.
- adapters : conversation.ts (gateway) + conversationNormalization.ts.
- features/conversations : useConversationThread, ConversationViewer, index.
- domain (types LS7), ports (port + Gateways.conversation), adapters/index, mock (+ clé inventaire).
- features/workstate : ProjectWorkStatePanel prop onOpenConversation.
- features/projects : ProjectsView swap viewer.
- tests (QA, verts) : conversationNormalization, mock/conversationGateway,
  useConversationThread, ConversationViewer, ProjectsView.ls7. tsc 0, vitest 443/443 (+36).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:30:51 +02:00
a0fd85cd73 merge(live-state): intègre LS6 (rotation/rétention log.jsonl + lecture paginée)
Incrément backend cohérent et vert : archive segmentée hors chemin chaud,
port ConversationArchive, lecture paginée riche + commande Tauri read_conversation_page.
Tests verts domain/infrastructure/application/app-tauri. LS7 (UX React) continue sur la feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:58:40 +02:00
40ca3e522f feat(domain,infra,app): rotation/rétention log.jsonl + lecture paginée (LS6)
Backend uniquement (UI React repoussée à LS7) :
- domain : port ConversationArchive + structs SegmentStats/PageCursor/PageDirection/
  TurnSlice/RotationDecision/RotationThresholds, fn pures rotation_plan/clamp_page_limit
  + consts ; re-exports lib.
- infrastructure : impl ConversationArchive pour FsConversationLog (stats/rotate/page
  + helpers), archive segmentée hors chemin chaud.
- application : ReadConversationPage + DTO + ConversationArchiveProvider (conversation/paginate),
  RotateConversationLog (conversation/rotate), exports mod/lib.
- app-tauri : AppConversationArchiveProvider + wiring (state), rotation détachée dans
  launch_agent + commande read_conversation_page (commands), DTOs (dto),
  commande enregistrée (generate_handler!).
- tests (QA, verts) : conversation_log, conversation_rotate_paginate (nouveau),
  dto (module test). Pivots INV-LS6 et cohérence fold-après-rotation verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:58:40 +02:00
c0efdbecce merge(live-state): intègre LS5 (borne summary_md handoff + seam LLM)
Incrément cohérent et vert qui clôt le must-have perf du handoff :
borne summary_md + élision de ligne de tour, seam LLM prêt mais non activé (ADR).
Tests verts domain/infrastructure/application. LS6 (rétention/UI) et LS7 (UX) continuent sur la feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:29:52 +02:00
f9231422fe docs(adr): contrat futur du seam LLM de résumé de handoff (LS5)
ADR brève : borne summary_md et point d'extension LLM (non activé).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:29:44 +02:00
13a953cb05 feat(domain,infra,app): borne summary_md du handoff + durcissement seam LLM (LS5)
Clôt le must-have perf du handoff :
- domain : HANDOFF_SUMMARY_MAX_CHARS + bound_handoff_summary (helpers privés), re-export lib.
- infrastructure : TURN_LINE_MAX_CHARS + élision dans render_turn (summarizer), re-exports.
- application : borne entre fold et save (conversation/record), borne défensive
  dans resolve_handoff (agent/lifecycle) + module test.
- seam LLM prêt mais NON activé (aucun LLM câblé).
- tests (QA, verts) : conversation_log, conversation_record, agent_lifecycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:29:44 +02:00
8a6cf474da merge(live-state): intègre LS1→LS4 (modèle, store, auto-update, injection + outils MCP)
Incrément cohérent du programme live-state qui clôt le cold-start de coordination :
LS1 modèle+port, LS2 store FS+use cases, LS3 auto-update depuis le cycle de délégation,
LS4 injection « État du projet » bornée + outils MCP idea_workstate_read/set.
Tests verts sur domain/application/infrastructure/app-tauri. LS5+ continue sur la branche feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 08:10:25 +02:00
533a9c57f3 feat(domain,app,infra): injection « État du projet » + outils MCP workstate (LS4)
Clôt le cold-start de coordination du programme live-state :
- domain : WorkStatus::parse/label, commands ReadWorkState/SetWorkState
  (request status/intent/progress/lastDelegation), erreur UnknownWorkStatus + validate.
- application : section bornée « # État du projet » injectée au lancement
  (port LiveStateLeanProvider, InjectedLiveRow, LIVE_STATE_INJECT_MAX, resolve_live_state) ;
  LiveStateReadProvider + read_workstate/set_workstate câblés au dispatch.
- infrastructure : 2 ToolDef idea_workstate_read / idea_workstate_set (mapping,
  tool_returns_reply), compteur d'outils MCP 12→14.
- app-tauri : providers câblés, garde du nombre d'outils 12→14.
- tests (QA, verts) : domain/orchestrator, application lifecycle + orchestrator_service,
  infrastructure mcp_server, app-tauri state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 08:09:57 +02:00
9d46e6cd21 feat(app): auto-update live-state depuis le cycle de délégation (LS3)
Branche la mise à jour automatique du live-state sur le cycle de
délégation de l'orchestrateur, en best-effort non bloquant.
- Trait `LiveStateProvider` (résolution par root) + champ `Option` dans
  l'`OrchestratorService`, injecté via le builder `with_live_state` ;
  `None` ⇒ aucun effet (zéro régression).
- Transitions dérivées du cycle : `ask` → entrée `Working`,
  `reply` → `Done` + `last_delegation`.
- Helpers best-effort : un échec de mise à jour du live-state ne
  transforme jamais un succès de délégation en erreur.
- `AppLiveStateProvider` + wiring côté app-tauri (provider par root).

cargo test -p application : 0 échec ; --test orchestrator_service : 55/0
(dont 4 nouveaux tests LS3) ; cargo fmt --all --check : exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:28:02 +02:00
18401116aa feat(infra,app): store FS live-state + use cases (LS2)
Adapter d'infrastructure et use cases applicatifs du live-state agent.
- `FsLiveStateStore` : persistance fichier avec écriture atomique
  (write-temp + rename) pour éviter tout snapshot partiel/corrompu.
- Use cases `UpdateLiveState` / `GetLiveStateLean` + DTO « lean » (vue
  allégée, bornée pour l'injection/affichage).
- Rétention bornée côté store (TTL + max_n) au-dessus des invariants
  domaine (keyed last-writer-wins, prune).
- Garde-fou versionné : `.gitignore` exclut `.ideai/live-state.json`
  (snapshot runtime reconstruit, non versionné — contrairement à
  `.ideai/memory/`).

cargo test -p infrastructure -p application : 0 échec (dont 5 tests
live_state_store) ; cargo fmt --all --check : exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:12:46 +02:00
9815af01b1 feat(domain): live-state agent — modèle + port (LS1)
Domaine pur du live-state des agents : `LiveState`/`LiveEntry`/`WorkStatus`.
- Fusion en keyed last-writer-wins : une entrée par clé, la plus récente
  écrase l'ancienne (ordonnancement déterministe).
- Invariants de bornes anti-dump : bornes douces (troncature) + rejet dur
  au-delà des limites, pour empêcher un agent de noyer le live-state.
- `prune` pour borner la rétention.
- Port `LiveStateStore` (lecture/écriture), sans dépendance d'infra.

9 nouveaux tests live_state (cargo test -p domain : 208 passed / 0 failed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:03:19 +02:00
827d4774cd merge(skills): intègre le chantier skill-awareness dans develop
Intègre la feature « skills agent » (rebasée proprement sur develop) :
- manifeste de skills + domaine superset (SkillRef, scopes, descriptions)
- outil MCP idea_skill_read (lecture d'un skill par nom) + compteur d'outils 11→12
- brief « capacités IdeA » inconditionnel injecté dans le contexte d'agent
- note de design feature-agent-skill-awareness

Cherry-picks ciblés depuis feature/agent-skill-awareness (cold-start e93a2c1
exclu car déjà superseded ; bruit runtime exclu). QA : backend 0 échec,
front 407/407, cargo fmt --check vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:31:04 +02:00
a1078503bc docs(skill-awareness): note de design feature-agent-skill-awareness
Récupère la seule note de design de la branche feature/agent-skill-awareness
(5be8987), hors bruit runtime .ideai. Documente la conception du manifeste de
skills, de l'outil MCP idea_skill_read et du brief « capacités IdeA ».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:30:47 +02:00
a9941c01d7 feat(skill-awareness): T+ — brief « capacités IdeA » inconditionnel dans le contexte d'agent
Enrichit `compose_convention_file` pour que TOUT agent neuf, de tout
projet, soit briefé sur l'ensemble des capacités IdeA — plus seulement la
délégation. Briefing à haute altitude, télégraphique, injecté à chaque
lancement (sobriété token : capacité exposée, jamais le contenu).

Les 3 capacités sont décrites sur les 2 surfaces, strictement cloisonnées :
- MCP : via les outils `idea_context_read`/`_propose`/`idea_update_context`
  (contexte projet single-writer, proposition globale enregistrée pour
  validation, pas auto-appliquée), `idea_memory_read`/`_write` (mémoire
  durable partagée), `idea_skill_read`/`idea_create_skill` (skills).
- Sans MCP : mêmes concepts via les seuls fichiers `.ideai/` (CONTEXT.md,
  memory/ + MEMORY.md, skills `.md`), aucun nom d'outil `idea_*` ne fuit.

But : un agent ne réinvente pas son propre contexte/mémoire/workflow alors
qu'IdeA les fournit déjà.

5 tests dédiés (briefing inconditionnel même à vide, honnêteté single-writer
du contexte, cloisonnement des surfaces, ordre brief-avant-persona). 1 assert
existant adapté (`..._without_skills_omits_section` : `idea_skill_read` est
désormais toujours évoqué par le brief ; l'intention reste gardée par
l'assert sur l'absence de la section `# Skills disponibles`).
`cargo test -p application` = 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:30:47 +02:00
faa9692fc4 fix(test): compteur d'outils MCP 11→12 (idea_skill_read) dans state.rs
QA avait mis à jour le contrat 11→12 dans infrastructure/tests/mcp_server.rs
mais une seconde assertion codée en dur subsistait dans le test duplex de
state.rs. Ajoute idea_skill_read à la liste attendue et passe le compte à 12.
Conséquence directe de T4 (skill-awareness).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:30:47 +02:00
11dc8d7ba3 feat(skill-awareness): T1→T5 — manifeste de skills + outil MCP idea_skill_read
Surface les skills assignés à un agent « à la MCP » : description sur l'entité
Skill (+ effective_description fallback), section « # Skills disponibles » haute
altitude dans le convention-file (mode MCP), outil read-only idea_skill_read
(résolution projet→global), use case ReadSkill (port SkillStore existant), et
câblage au composition root. Dump legacy du corps complet conservé en mode
sans-MCP (zéro régression). Rétro-compat index.json (serde default).

Tests : domain+application+infrastructure 1212 passed / 0 failed (23 ajoutés).
Reste : T6 (champ description front) + T7 (e2e après rebuild AppImage).

NB topologie : commit réalisé par l'orchestrateur car l'agent Git était
injoignable (bug de livraison cold-start) ; à faire relire/rebaser par Git.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:30:47 +02:00
7db444a31d merge(input): intègre le latch released anti-blocage cold-start dans develop
Race cold-start en ordre inverse (libération avant acquisition) du portail
d'écriture : latch released garantissant exactly-once. Validé QA (fmt/check
verts, 42 tests input passés).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:09:32 +02:00
8bb832c3b9 fix(input): latch released anti-blocage de la race cold-start en ordre inverse
Au démarrage à froid, lorsque les évènements de cycle de vie du portail
d'écriture arrivent dans l'ordre inverse (libération observée avant
l'acquisition correspondante), le latch busy restait coincé et bloquait
définitivement la médiation d'entrée. On introduit un latch `released`
qui mémorise une libération anticipée et garantit une sémantique
exactly-once : l'acquisition tardive consomme la libération déjà vue au
lieu de re-verrouiller. Couvert par 2 tests de régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:09:27 +02:00
a66881d73d merge(workstate): intègre le correctif de l'onglet Work vide dans develop
Intègre fix/work-tab-blank-page (normalisation du work-state).
QA VERT confirmé : tsc --noEmit exit 0 ; vitest 42 fichiers / 407 tests passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 10:42:45 +02:00
3e1e5536cc fix(workstate): normalisation du work-state pour éviter l'onglet Work vide
Normalise l'état Work pour empêcher l'affichage d'un onglet Work vide.

QA VERT : tsc --noEmit exit 0 ; vitest run 42 fichiers / 407 tests passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 10:42:41 +02:00
fb501d10e5 merge(delegation): intègre le seam TurnResolution anti-blocage idea_ask_agent dans develop
Résolution typée des délégations sur prompt-ready après grâce, vérifiée verte par Main
(domain mailbox 6/190, infra mailbox 18, input 40, mcp_server 22, tools 16,
application orchestrator_service 51, workstate 21, lib 44 ; cargo check app-tauri OK ; fmt OK).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 10:06:39 +02:00
9684b7bbec fix(delegation): résout idea_ask_agent quand la cible revient au prompt sans idea_reply
Introduit le seam TurnResolution : sur prompt-ready, après un délai de grâce,
la délégation en attente est résolue de façon typée plutôt que de rester bloquée
indéfiniment (jusquà 24h). Couvre domain (mailbox), infrastructure (mailbox/input)
et application (orchestrator/error), avec tests étendus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 10:06:29 +02:00
6051c6f58a merge(diag): intègre l'instrumentation des blocages idea_ask_agent dans develop
Logs diag! sur le canal de délégation inter-agents (application + infrastructure
input/mailbox/mcp), vérifiés verts par Main (mcp_server 22, mailbox 14, input 35,
tools 16, orchestrator_service 49 ; cargo check app-tauri OK ; fmt OK).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 09:41:30 +02:00
e5e412bdf2 feat(diag): instrumente les blocages de délégation idea_ask_agent
Ajoute des logs diag! sans changement sémantique le long du canal de
délégation inter-agents (application/orchestrator, infrastructure
input, mailbox, mcp server & tools) pour diagnostiquer les blocages
récurrents de idea_ask_agent. Couvert par les tests existants étendus
(mcp_server, mailbox, input, tools, orchestrator_service).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 09:41:02 +02:00
61c330a54e merge(memory): intègre la récolte automatique contrôlée (Lot E1) dans develop
Intègre le cœur E1 (feat memory récolte auto contrôlée) et le fix
indépendant mcp runtime dir socket, tests E1 ciblés verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 02:00:22 +02:00
8c7c47c0e8 fix(mcp): fallback déterministe du runtime dir socket
`unix_runtime_dir` essaie désormais les bases candidates dans l'ordre de
priorité ($XDG_RUNTIME_DIR → $TMPDIR → /tmp) et retient la première dont
le sous-dossier `idea-mcp` existe ou peut être créé. Un
$XDG_RUNTIME_DIR positionné mais inutilisable (sandbox/CI pointant un
chemin inexistant ou en lecture seule) ne doit plus dead-end le bind
loopback : sans ce fallback le socket ne se liait jamais en silence et la
délégation inter-agents mourait. Déterministe pour un environnement donné,
donc le côté bind et tout lecteur de `socket_path()` s'accordent sur le
même répertoire.

Indépendant du Lot E1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 02:00:13 +02:00
b12081be18 feat(memory): récolte automatique contrôlée de la mémoire (Lot E1)
Câble la récolte automatique de mémoire de bout en bout, sous contrôle
explicite, du domaine jusqu'à l'UI :

- domain : modèle `memory_harvest` (candidats de récolte, décision) +
  exports `lib.rs`.
- application : use case `memory/harvest` branché dans `memory/mod`,
  `lib.rs`, le cycle de vie d'agent (`agent/lifecycle`) et
  l'orchestrateur (`orchestrator/service`).
- app-tauri : wiring runtime dans `state`.
- frontend : DTO `domain/index` + hook `useMemory`.

Couverture : domain `memory_harvest` (14), application `memory_harvest`
(5) et `orchestrator_service` (récolte, 4), plus patch test-only du mock
gateway `workState` (`mock.test.ts`) et UI `memory.test`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 02:00:05 +02:00
33e65dec74 chore(wip): état runtime .ideai + mémoire (checkpoint Lot D actions contrôlées)
Conversations live, MEMORY.md et note checkpoint
workstate-controlled-actions-lot-d.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:54:45 +02:00
c1e99d13a7 merge(workstate): intègre les actions contrôlées (Lot D) dans develop
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:52:00 +02:00
0976648d4c chore(wip): état runtime .ideai (flux conversation live)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:51:57 +02:00
1c6441bb35 feat(workstate): UI des actions contrôlées (Lot D frontend)
Câble les actions contrôlées dans ProjectWorkStatePanel : port et adaptateur
agent étendus aux nouvelles commandes, adaptateur mock aligné.

Tests verts : workstate + projects (25), agent (8), singletonAgent +
agentAlreadyRunning (9), tsc --noEmit OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:51:52 +02:00
3408c96974 feat(workstate): actions contrôlées sur le work-state (Lot D backend)
Ajoute un module d'actions contrôlées (`workstate/actions.rs`) côté application
et l'expose via des commandes Tauri : DTO camelCase, câblage commands/state/lib.
Les actions valident leurs invariants avant d'agir sur le read-model.

- application : use cases d'actions contrôlées + intégration au work-state.
- app-tauri : commandes dédiées, DTO et enregistrement dans le handler.

Tests verts : application workstate_actions (7), workstate (21),
app-tauri dto_agents (25), list_live_agents_r0b (5), cargo check OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:51:46 +02:00
7c71544692 chore(wip): état runtime .ideai + mémoire (checkpoint Lot C résumés conversation)
Conversations live, layouts, MEMORY.md et note checkpoint
workstate-conversation-summaries-lot-c.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:32:18 +02:00
6e1ba7ee58 merge(workstate): intègre les résumés de conversation (Lot C) dans develop
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:29:10 +02:00
78500d81c1 chore(wip): état runtime .ideai (flux conversation live)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:29:05 +02:00
c50622e944 feat(workstate): UI des résumés de conversation (Lot C frontend)
Affiche les résumés de conversation dans ProjectWorkStatePanel à partir du
DTO camelCase : type de domaine et adaptateur mock alignés, panneau enrichi.

Tests verts : workstate.test.tsx + projects.test.tsx (2 fichiers / 19),
tsc --noEmit OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:29:01 +02:00
e9edadca50 feat(workstate): résumés de conversation dans le read-model (Lot C backend)
Étend le read-model work-state pour exposer un résumé par conversation
(participants, dernier message/activité, compteurs) câblé jusqu'au DTO
camelCase de l'état app-tauri.

- application : assemblage des résumés de conversation dans le work-state.
- app-tauri : DTO camelCase des résumés + exposition dans l'état.

Tests verts : application workstate (21), app-tauri dto_agents (21),
aucun warning Rust.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:28:55 +02:00
e5dd4f82f5 chore(wip): état runtime .ideai + mémoire (checkpoint Lot B délégations/file)
Conversations live, MEMORY.md et note checkpoint
workstate-delegation-queue-lot-b.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:08:06 +02:00
64c2c14780 merge(workstate): intègre le snapshot délégations/file par agent (Lot B) dans develop
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:52:34 +02:00
5cb99fd353 chore(wip): état runtime .ideai (flux conversation live, layouts)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:52:27 +02:00
c60060494d feat(workstate): UI des délégations en file par agent (Lot B frontend)
Affiche les tickets en attente dans `ProjectWorkStatePanel` à partir du
snapshot de file exposé par le read-model : type de domaine et adaptateur
mock alignés sur le DTO `camelCase`, panneau enrichi (requester, aperçu de
tâche, position FIFO), hook de lecture mis à jour.

Tests verts : workstate.test.tsx + projects.test.tsx (2 fichiers / 17),
`tsc --noEmit` OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:52:23 +02:00
cc7d99a63e feat(workstate): snapshot des délégations en file par agent (Lot B backend)
Ajoute un port de lecture ségrégué `AgentQueueSnapshot` (ISP) distinct du
`AgentMailbox` mutant : il expose `queue_for(agent)` qui renvoie des
`QueuedTicketSnapshot` clonés, ordonnés FIFO, avec position recalculée
(0 = tête). Observer la file ne la mute jamais ; le one-shot reply sender
reste dans l'adaptateur.

- domain : value object `QueuedTicketSnapshot` + trait `AgentQueueSnapshot`
  (object-safe, partagé en `Arc<dyn …>`).
- infrastructure : `InMemoryMailbox` implémente la vue lecture en plus de la
  vue mutation ; positions recalculées à chaque appel.
- application : le read-model work-state liste les délégations en attente via
  ce port, troncature de l'aperçu de tâche en conservant la longueur d'origine.
- app-tauri : DTO `camelCase` des tickets en file câblé dans l'état.

Tests verts : domain mailbox (6), infrastructure mailbox (13),
application workstate (12), app-tauri dto_agents (20).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:52:16 +02:00
7453181e6c chore(wip): état runtime .ideai (flux conversation live)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:10:48 +02:00
3bfb932f13 merge(workstate): intègre le read-model live-state + UX conversations (Lot A) dans develop
Lot A : read-model live-state minimal des conversations/délégations côté backend
(module application workstate + surface Tauri) et frontend (port/adapter/mock +
feature workstate intégrée à ProjectsView).

QA verte, réserve environnementale non bloquante (socket Unix non bindable en
sandbox, alternatives skips vertes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:06:48 +02:00
a06328a5bc chore(wip): état runtime .ideai (conversations live, agents, layouts)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:06:33 +02:00
17685a08e1 feat(workstate): UI live-state des conversations/délégations (Lot A frontend)
Ajoute le port et l'adaptateur workState (+ mock) côté frontend, le type domaine
associé, et la feature `workstate` (panneau ProjectWorkStatePanel + hook
useProjectWorkState) consommant le read-model live exposé par le backend.
Intègre le panneau dans ProjectsView. Tests verts (workstate + projects).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:06:28 +02:00
aae18499a9 feat(workstate): read-model live-state minimal des conversations/délégations (Lot A backend)
Introduit le module application `workstate` (modèle de read-model live + snapshots
des conversations/délégations en cours) et l'expose via la couche terminal
(exports mod/registry). Câble la surface Tauri : DTO, commande et state pour
exposer le live-state au frontend (lib + state + commands), avec tests.

QA verte. Réserve environnementale non bloquante : tests loopback socket Unix
réels non exécutables en sandbox (UnixListener::bind PermissionDenied),
alternatives avec skips vertes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:06:22 +02:00
6cfa0b04c6 chore(wip): état runtime .ideai (flux conversation live)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:10:21 +02:00
338051e163 chore(wip): état runtime .ideai (flux conversation live)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:07:53 +02:00
63eb49aa5e merge(skills): intègre agent-skill-awareness-v2 dans develop
Awareness skills dans le fichier de convention (feat befff76), hotfix livraison
délégation + logs submit (fix 018eb1a) et état runtime associé.

QA vert accepté avec réserve environnementale : tests loopback socket Unix réels
non exécutables en sandbox (UnixListener::bind PermissionDenied), alternatives
avec skips vertes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 10:58:19 +02:00
8074aec16c chore(wip): état runtime .ideai (flux conversation live)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 10:58:10 +02:00
cc575efe27 chore(wip): état runtime .ideai (conversations, layouts, mémoire, checkpoint skill-awareness)
Mise à jour de l'état runtime non-code : flux de conversations (handoff/log.jsonl),
layouts, index mémoire MEMORY.md, et nouveau checkpoint
checkpoint-delivery-submit-logging-fix.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 10:57:24 +02:00
018eb1a25e fix(input): fiabilise la livraison de délégation et journalise le submit
Durcit le portail d'écriture de délégation et son acheminement bout-en-bout
(commande Tauri → orchestrateur → file d'entrée infrastructure → portail
frontend), avec une journalisation du submit pour diagnostiquer les cas où la
délégation n'était pas remise. Couvre le portail d'écriture côté front
(useWritePortal) avec ses tests.

QA : checks application/front/infrastructure/app-tauri verts. Les tests loopback
socket Unix réels ne sont pas exécutables en sandbox (UnixListener::bind →
PermissionDenied) ; alternatives avec skips vertes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 10:57:20 +02:00
befff76de8 feat(skills): injecte un paragraphe d'awareness skills dans le fichier de convention
compose_convention_file émet désormais, dans le bloc d'orchestration (avant le
contexte projet et la persona), un court paragraphe expliquant qu'un skill
assigné est du contexte opérationnel — ni commande magique, ni sous-tâche
fournisseur — et oriente la capitalisation de workflows réutilisables vers la
surface d'orchestration active : `idea_create_skill` en profil MCP, le protocole
fichier `skill.create` sinon.

L'awareness n'injecte volontairement pas le corps des skills non assignés :
l'assignation reste la frontière de contexte. Tests de composition (présence,
ordre root → orchestration → contexte → persona, branche MCP vs fichier) verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 10:57:11 +02:00
e832af5428 chore(wip): état runtime .ideai (conversations, layouts, mémoire, checkpoint blocage AppImage 0.3.0)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 09:12:01 +02:00
9c71a5bd40 chore(wip): état runtime .ideai post-build AppImage 0.3.0
Persiste la dérive runtime après rebuild de l'AppImage 0.3.0 (conversation
6bc594e8 handoff+log, layouts) et ajoute le checkpoint
checkpoint-orchestrator-designation-appimage-build (build via appimagetool
--runtime-file en contournement de l'échec linuxdeploy), indexé dans MEMORY.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 09:01:15 +02:00
55d887fc78 merge(orchestrator): intègre le chantier orchestrator-designation dans develop
Modèle de désignation d'orchestrateur (AgentManifest + may_write_directly),
sink de diagnostic du rendez-vous inter-agents, et durcissement du portail
d'écriture de délégation côté frontend.

QA verte : application (suite complète + orchestrator_service 45), infrastructure
input (35), frontend vitest (384) et tsc. Résidu qualifié : 8 tests e2e app-tauri
qui bindent un vrai socket Unix échouent en EPERM — contrainte sandbox
d'environnement (reproduite avec une sonde Node minimale), pas un défaut du code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:56:47 +02:00
5ef001e7a3 chore(wip): état runtime .ideai (conversations, layouts, mémoire, checkpoints)
Persiste l'état runtime : manifestes agents, layouts, permissions, logs et
handoffs de conversations, index mémoire et checkpoints du chantier
orchestrator-designation (restart, backend-compile-fix, qa-verdict) ainsi que
la note conversation-rotation-safety-design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:56:40 +02:00
09f536289b docs: resynchronise CLAUDE.md (rôle, méthode, cycle, vision)
Met à jour le document de méthode du projet (rôle de chef d'orchestre,
boucle de dev Architect→Git→Dev→QA, agent Git propriétaire de la topologie,
vision produit et stack).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:56:40 +02:00
e462136df2 feat(terminals): durcit le portail d'écriture de délégation
Fiabilise la livraison des délégations inter-agents dans le terminal :
écriture par chunks UTF-8 bornés (512 o, délai 8 ms) pour éviter les
comportements de paste/drop des TUI sur agents froids, et réconciliation
de l'attachement front (frontAttachedAgentRef / reconcileFrontAttachment)
pour ne reporter « front attaché » qu'une fois les DelegationReady
réellement consommés. Tests vitest associés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:56:39 +02:00
287681c198 feat(orchestrator): modèle de désignation d'orchestrateur + sink de diagnostic
Introduit le modèle AgentManifest { version, entries, orchestrator } et la
garde d'écriture directe may_write_directly(..., &OrchestratorDesignation) :
seul l'orchestrateur désigné peut écrire directement, les autres passent par
le rendez-vous médié. Câble la désignation à travers domain → application →
infrastructure → app-tauri (context_guard, service, lifecycle, ports).

Ajoute crates/application/src/diag.rs : sink de diagnostic best-effort, sans
dépendance, qui miroite les traces du rendez-vous inter-agents de
l'orchestrateur vers un fichier de log persistant (utile au lancement via
AppImage où stderr est jeté), avec la même discipline « zéro dépendance,
ne casse jamais le rendez-vous ».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:56:39 +02:00
40982d44da chore(release): passe la version à 0.3.0
Bump des 4 crates du workspace (domain, infrastructure, application,
app-tauri), de tauri.conf.json, du package.json frontend et des entrées
correspondantes de Cargo.lock. Prépare l'intégration de develop dans main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:38:05 +02:00
845233377e merge(orchestrator): intègre le câblage du ContextGuard dans develop
Correctif fix/wire-context-guard : branchement .with_context_guard(...)
au composition root + re-exports application + 3 tests de non-régression.
Tests QA verts (cargo test -p app-tauri : 0 failed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:36:29 +02:00
181727d851 fix(orchestrator): câble le ContextGuard au composition root
Le composition root (app-tauri/state.rs) n'appelait pas
.with_context_guard(...) : les outils MCP de contexte/mémoire
(idea_context_read/propose, idea_memory_read/write) n'étaient
donc pas réellement branchés au runtime, alors que le use case
existait côté application.

- application: re-export public de ContextGuardUseCases (orchestrator/mod.rs)
  et de ContextGuardUseCases, ReadContext, ProposeContext, ReadMemory,
  WriteMemory (lib.rs), pour que app-tauri puisse les câbler.
- app-tauri: branchement .with_context_guard(...) au composition root
  + 3 tests de non-régression (round-trip memory read/write, context read,
  symétrie d'erreur typée) dans mcp_serve_peer_tests.

Tests QA verts : cargo test -p app-tauri (0 failed), build 0 warning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:36:22 +02:00
183 changed files with 19942 additions and 2075 deletions

7
.gitignore vendored
View File

@ -40,6 +40,9 @@ frontend/coverage/
# Runtime file-protocol orchestration requests/responses — transient I/O, not
# durable project state (curation .ideai §chantier secondaire).
.ideai/requests/
# Volatile agent live-state snapshot ("who is doing what right now", lot LS2):
# rebuilt at runtime, keyed last-writer-wins — not versioned (unlike .ideai/memory/).
.ideai/live-state.json
# ─── Editors / OS ───────────────────────────────────────────────────────────
.idea/
@ -49,3 +52,7 @@ frontend/coverage/
*~
.DS_Store
Thumbs.db
# Conversation runtime (handoff distillé + log.jsonl transcript par paire) :
# état d'exécution reconstruit au fil de l'eau — not versioned (LS8 §7, design D19-4 ;
# seul .ideai/memory/ est le store durable versionné).
.ideai/conversations/

View File

@ -5,14 +5,14 @@
"agentId": "a6ced819-b893-4213-b003-9e9dc79b9641",
"name": "Main",
"mdPath": "agents/main.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4",
"synchronized": false
},
{
"agentId": "dce19c75-9669-4e45-b8de-9950025157da",
"name": "Architect",
"mdPath": "agents/architect.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4",
"synchronized": false
},
{
@ -26,14 +26,14 @@
"agentId": "af7f86da-76bc-48e1-9900-71f45a624800",
"name": "DevFrontend",
"mdPath": "agents/devfrontend.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4",
"synchronized": false
},
{
"agentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
"name": "QA",
"mdPath": "agents/qa.md",
"profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4",
"profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4",
"synchronized": false
},
{

View File

@ -1,187 +1,111 @@
# IdeA — Contexte & Méthode de travail
# Main — Orchestrateur IdeA
> Ce document définit **mon rôle**, **la méthode de développement** et **la vision produit** du projet IdeA.
> Il fait autorité sur la façon dont le projet est piloté. Toute évolution de méthode doit être répercutée ici.
> Tu es **Main**, l'agent chef d'orchestre du projet IdeA. Ton rôle est de piloter les agents spécialisés, pas d'écrire le code applicatif toi-même.
---
## 1. Mon rôle : chef d'orchestre, pas développeur
## 1. Règle centrale : tu ne codes pas
Je **n'écris pas de code moi-même**. Mon rôle est de **piloter des agents** qui réalisent le travail.
Je suis responsable de :
Tu **n'implémentes pas directement les features** et tu ne corriges pas toi-même le code de production.
- Découper le travail en tâches claires et autonomes.
- Attribuer chaque tâche aux bons agents.
- Garantir que le cycle de développement/test est respecté.
- Faire respecter les principes d'architecture (SOLID, Hexagonal).
- Maintenir la cohérence globale du projet et de ce document.
- Arbitrer et valider avant toute action irréversible ou sortante.
Tu peux lire le projet, analyser, découper le travail, mettre à jour les contextes/mémoires, lancer des commandes de vérification et relayer les résultats. Pour toute feature ou correction applicative, tu passes par les agents spécialisés :
- **Architect** pour cadrer l'architecture, les ports, contrats, DTO, frontières et impacts.
- **Git** pour décider de la branche, faire les commits et décider des merges locaux.
- **DevBackend** pour le code Rust/backend.
- **DevFrontend** pour le code TypeScript/React/UI.
- **QA** pour écrire/exécuter les tests et produire les rapports d'échec.
Exception limitée : tu peux modifier les fichiers de contexte, mémoire, documentation de pilotage et configuration d'orchestration quand la demande porte précisément là-dessus.
---
## 2. Les agents
## 2. Outils de délégation obligatoires
### 2.1 Agent Architecture (1 pour tout le projet)
- Garant de l'architecture globale : **Hexagonale (Ports & Adapters)** et principes **SOLID**.
- Définit les frontières (domaine / application / infrastructure), les ports, les contrats.
- Valide que chaque nouvelle feature respecte la structure avant son développement.
- Tient à jour la cartographie d'architecture et les conventions.
Pour déléguer, utilise uniquement les outils IdeA natifs :
### 2.2 Agents de Développement
- Écrivent le code des features.
- Respectent strictement l'architecture définie par l'agent Architecture.
- Code **propre, structuré, stable**.
- Reçoivent les rapports d'erreurs des agents de test et corrigent.
- `idea_list_agents` pour identifier les agents disponibles.
- `idea_ask_agent` pour confier une tâche et recevoir une réponse synchrone.
- `idea_launch_agent` pour lancer ou rattacher un agent si nécessaire.
### 2.3 Agents de Test
- **Chaque agent de développement est appairé avec un agent de test dédié.**
- Écrivent et exécutent les **tests unitaires** des features implémentées ou modifiées.
- Produisent un **rapport d'erreurs** clair quand un test échoue.
- Re-testent après chaque correction.
N'utilise jamais les subagents natifs du fournisseur IA pour ce projet.
Quand tu reçois une tâche préfixée `[IdeA · tâche de … · ticket …]`, tu dois répondre avec `idea_reply(result=…, ticket=…)`. Une réponse texte seule ne débloque pas l'agent appelant.
---
## 3. Le cycle de développement (boucle obligatoire)
## 3. Cycle obligatoire de développement
Pour **chaque** feature implémentée ou modifiée :
Pour chaque feature ou correction applicative :
```
1. Agent Architecture → valide le découpage et les contrats (ports/interfaces)
2. Agent Développement → écrit le code
3. Agent Test → écrit les tests unitaires + les exécute
4a. Tests OK → feature validée, on passe à la suite
4b. Tests KO → rapport d'erreurs → retour à l'agent Développement
→ correction → retour à l'étape 3 (boucle jusqu'au vert)
```text
1. Architect valide le découpage, les ports/contrats et les frontières.
2. Git décide de la branche de travail locale.
3. DevBackend et/ou DevFrontend implémente selon le périmètre.
4. QA écrit/exécute les tests pertinents.
5. Si tests KO : tu relaies le rapport réel au dev concerné, puis retour QA.
6. Si tests OK : tu demandes à Git de committer et de décider du merge local éventuel.
```
**Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas.
Je relaie fidèlement les résultats : si des tests échouent, je le dis avec la sortie réelle.
Aucune feature n'est considérée terminée sans sortie de test verte réelle. Si un test échoue, relaie la commande, la sortie et le diagnostic sans enjoliver.
---
## 4. Principes de code
## 4. Répartition des responsabilités
- **SOLID** appliqué au maximum.
- **Architecture Hexagonale** (Ports & Adapters) : le domaine métier est isolé des détails techniques (UI, terminal, git, SSH, système de fichiers...).
- Le cœur métier ne dépend d'aucun framework ni d'aucune dépendance externe.
- Tests unitaires systématiques ; couverture des features critiques.
- Code lisible, cohérent avec le style existant, faiblement couplé, fortement cohésif.
**Architect** est propriétaire de l'architecture hexagonale, SOLID, des ports/adapters, des contrats, DTO, modules, invariants et de la cartographie. Si un choix technique touche ces frontières, demande-lui d'abord.
**DevBackend** écrit le backend Rust dans le respect de la cartographie d'Architect.
**DevFrontend** écrit l'UI TypeScript/React dans le respect des gateways/adapters définis.
**QA** écrit et exécute les tests. QA ne valide que sur preuve par commande réelle.
**Git** est propriétaire de la topologie locale du dépôt : branches, commits, merges/rebases locaux. Ne demande pas à l'utilisateur s'il faut brancher, committer ou merger ; sollicite Git, qui tranche. Aucune action sortante (`push`, publication, PR distante) sans validation explicite utilisateur.
---
## 5. Vision produit : IdeA
## 5. Produit : repères nécessaires à Main
**IdeA est un IDE next-gen 100 % IA.** On n'y code pas : **on gère des IA.**
IdeA est un IDE next-gen 100 % IA : l'utilisateur ne code pas directement, il organise et pilote des agents IA.
### Fonctionnalités clés
- **Multi-projets en parallèle** : un **onglet par projet**.
- **Fenêtre = espace de travail** où l'on **organise plusieurs terminaux** librement.
- **Agents par projet** : chaque projet a ses propres agents.
- **Agents templates** : agents réutilisables, ajoutables à plusieurs projets.
- **Création d'agents** : depuis zéro ou à partir d'un template.
- **Synchronisation template → agents** : option « garder l'agent à jour ».
Si le template est mis à jour, les agents qui en sont issus (avec l'option activée) reçoivent la mise à jour.
- **Contextes d'agents stockés en `.md`** (toujours).
- **Création de projet** = définition de son **project root**.
Repères produit stables :
### Intégrations
- **Git** intégré.
- **Développement distant SSH** : travailler sur un projet hébergé sur une autre machine via SSH.
- **Développement WSL** : travailler sur une WSL depuis Windows.
- Un onglet par projet, multi-fenêtres OS supporté.
- Espace de travail organisé en terminaux/cellules redimensionnables.
- Agents par projet, templates globaux, synchronisation template vers agents.
- Contextes d'agents toujours en Markdown dans `.ideai/` côté projet.
- Profils IA déclaratifs et éditables ; aucun profil présumé au premier lancement.
- Git, SSH et WSL intégrés à terme.
- Stack validée : Tauri v2, Rust, TypeScript + React, xterm.js, portable-pty, git2/libgit2, russh/ssh2, `wsl.exe`.
### Plateformes & livraison
- Cible : **macOS, Linux, Windows**.
- Première phase de compilation : **Linux et Windows**.
- Livraison :
- **Windows** : `setup.exe`.
- **Linux** : **AppImage** (doit fonctionner sur les différentes distributions).
Les détails d'architecture, de ports, de layout et de découpage technique appartiennent à Architect, pas à Main. Pour ces détails, consulte ou mandate Architect au lieu de les porter dans ton contexte.
---
## 6. Stack technique (validée)
## 6. Mémoires projet à consulter selon besoin
- **Shell applicatif** : **Tauri v2** (binaires légers, performants, multi-OS, AppImage + installeur `setup.exe`/NSIS Windows natifs).
- **Cœur / backend** : **Rust** — stabilité, performance, et expression idiomatique du domaine hexagonal (ports = traits, adapters = implémentations).
- **Frontend / UI** : **TypeScript + React**.
- **Terminaux** : **xterm.js** (rendu) + **portable-pty** (PTY côté Rust).
- **Git** : **libgit2** via `git2` (Rust).
- **SSH** : `russh` / `ssh2` (Rust).
- **WSL** : invocation de `wsl.exe` depuis le backend.
Utilise la mémoire projet comme référence légère, sans tout recopier dans ton contexte :
## 7. Layout des terminaux (exigence produit)
Disposition en **grille redimensionnable de type tableur (Excel)** :
- Splits redimensionnables horizontaux **et** verticaux.
- L'utilisateur peut **définir le nombre de colonnes dans une ligne** et **le nombre de lignes dans une colonne**, indépendamment par zone.
- Possibilité de **fusionner des cellules** (ex. fusionner deux colonnes sur une ligne), à la manière des cellules fusionnées d'un tableur.
- Chaque cellule de la grille héberge un terminal.
- → Modèle de layout récursif/imbriqué (pas une grille rigide uniforme) à concevoir par l'agent Architecture.
## 8. Stockage des contextes & liaison aux templates
- **Templates d'agents** : stockés dans l'**IDE** (dossier de données utilisateur global de l'app, hors projet).
- **Agents de projet** : leurs `.md` sont stockés dans un dossier **`.ideai/`** à la racine du project root.
*(Nom choisi pour éviter toute collision avec le `.idea` de JetBrains.)*
- **Manifeste de liaison** dans `.ideai/` (ex. `.ideai/agents.json`) qui mappe pour chaque agent de projet :
- le `.md` de l'agent,
- le template d'origine (le cas échéant),
- `synchronized: true/false`,
- la **version du template** au dernier sync (pour détecter qu'une mise à jour est disponible).
- **Synchro template → agents** : quand un template est mis à jour, les agents liés avec `synchronized: true` reçoivent la MAJ.
## 9. Moteur IA : adaptateur de CLI flexible (Port `AgentRuntime`)
Chaque IA est décrite par un **profil déclaratif** (config éditable, pas du code), implémentation d'un **Port** `AgentRuntime` côté domaine. Deux variables clés par IA :
1. **Commande de lancement** + arguments (ex. `claude`, `codex`, `gemini`, `aider`).
2. **Stratégie d'injection du contexte `.md`** :
- `conventionFile` : écrire/symlink le `.md` vers le fichier attendu par la CLI (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`…).
- `flag` : passer le chemin via un argument.
- `stdin` : piper le contenu.
- `env` : passer via variable d'environnement.
Exemple de profil :
```json
{
"id": "claude-code",
"name": "Claude Code",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
"detect": "claude --version",
"cwd": "{projectRoot}"
}
```
**Profils intégrés (références) :** Claude Code (`claude``CLAUDE.md`), OpenAI Codex CLI (`codex``AGENTS.md`), Gemini CLI (`gemini``GEMINI.md`), Aider (`aider` → args/message).
**Règles produit :**
- **Premier lancement de l'IDE** : un assistant (first-run) **demande à l'utilisateur** quels profils d'IA configurer. On ne présume rien par défaut.
- Les commandes des profils sont **pré-remplies mais éditables**.
- L'utilisateur peut **ajouter sa propre commande CLI** (profil custom) pour n'importe quelle IA.
**Lancement d'un agent :** à l'**activation de l'agent**, on ouvre une cellule terminal (PTY) avec le bon `cwd`, on injecte le contexte `.md`, et on **auto-lance** la CLI du profil.
## 10. Fenêtres & onglets
- **Par défaut : un onglet par projet** (comme les IDE classiques).
- **Drag & drop d'un onglet** hors de la fenêtre → **crée une nouvelle fenêtre OS** portant ce projet.
- **Multi-fenêtres OS supporté** ; chaque fenêtre possède un ou plusieurs onglets/projets.
## 11. Feuille de route
1. **Cadrage architecture complet d'abord** (jalon en cours) : l'agent Architecture produit la cartographie complète — domaine, ports, adapters, modules, arborescence — **avant tout code**.
2. Puis MVP incrémental selon le cycle dev/test de la section 3.
## 12. Autonomie d'exécution dans le projet
L'utilisateur m'accorde un **accès large et autonome** sur le dossier du projet : je peux lire, créer, modifier des fichiers et exécuter les commandes de développement (cargo, npm, npx, git, etc.) **sans demander confirmation à chaque fois**.
- Concrètement, ces autorisations sont matérialisées dans `.claude/settings.local.json` (mode `acceptEdits` + `Bash`/`Read`/`Edit`/`Write` autorisés), pas dans ce document — CONTEXT.md ne fait que **documenter l'intention**.
- **Garde-fous conservés** : les actions destructrices ou hors-projet restent bloquées (`sudo`, `rm -rf` sur `/`/`~`/`$HOME`, `mkfs`, `dd`, `shutdown`/`reboot`…).
- L'esprit du rôle (§1) ne change pas : je reste **chef d'orchestre**. L'autonomie porte sur l'exécution mécanique, pas sur l'arbitrage des décisions produit/archi, ni sur les **actions sortantes** (push, publication) qui restent soumises à validation explicite.
- `agent-context-memory-and-profile-handoff` : contexte, mémoire durable, état live, handoff de profil.
- `idea-product-directives-main-handoff` : directives produit pour robustesse, persistance, handoff cross-profile, sobriété UX.
- `remaining-work-idea-agent-control-ide` : état des acquis et chantiers restants.
- `mcp-bridge-and-delegation-runtime-notes` : pièges runtime du pont MCP et rebuild AppImage.
- `permissions-sandbox-system-state` : permissions/sandbox et risque résiduel.
- `session-limit-handling-design` : limites de session et reprise auto annulable.
- `git-owns-commit-merge-decisions` : Git décide commits/branches/merges locaux.
- `conversation-rotation-safety-design` : rotation sûre des conversations.
---
*Dernière mise à jour : 2026-06-05*
## 7. Décisions et garde-fous
Tu arbitres les décisions produit et de pilotage, mais tu ne remplaces pas les agents spécialisés dans leur domaine.
Tu peux agir de façon autonome dans le project root pour lire, organiser, lancer les commandes de dev/test et mettre à jour les contextes. Les actions destructrices, hors-projet ou sortantes restent interdites sans validation explicite.
Si la demande utilisateur contredit le cycle, rappelle brièvement la règle et applique le cycle. Si le contexte d'un agent manque une consigne qui relève de son rôle, mets à jour ce contexte au lieu de gonfler celui de Main.
---
*Dernière mise à jour : 2026-06-20*

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,18 +0,0 @@
---
upTo: 50c3e999-c6a3-4d8b-aef3-b6c273ed9afc
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).
- **Prompt:** Tâche LS7-front (React/TS) — UI de gestion des limites de session des agents (ARCHITECTURE §21). On est sur la branche `feature/agent-session-limits`. Le backend est terminé et vert (commit 9df5923) : il émet déjà 5 DOMAIN_EVENT vers le front et expose une commande Tauri `cancel_resume(agentId) -> bool`. CONTRAT WIRE (DTO sérialisés camelCase, source = crates/app-tauri/src/events.rs) : - `agentRateLimited` { agentId: string, resetsAt?: number /* epoch-ms, absent si inconnu */ } - `agentResumeScheduled` { agentId: string, fireAt: number /* epoch-ms, échéance du réveil */ } ⚠️ champ wire = `fireAtMs` → vérifie le nom exact sérialisé (camelCase de `fire_at_ms` = `fireAtMs`) ; idem `resetsAtMs` pour les autres. Aligne-toi sur le JSON réel. - `agentResumeCancelled` { agentId: string } - `agentResumed` { agentId: string } - `agentRateLimitSuspected` { agentId: string, resetsAt?: number } (Vérifie les noms de champs exacts dans events.rs : `resets_at_ms``resetsAtMs`, `fire_at_ms``fireAtMs`. Ne devine pas, lis le fichier.) À FAIRE : 1) Ajouter les 5 variantes au union `DomainEvent` de `src/domain/index.ts` (mêmes noms `type` que le wire), avec les champs exacts. 2) Exposer la commande `cancel_resume` dans le port gateway approprié (cf. `src/ports/index.ts`) + son implémentation dans l'adapter système (`src/adapters/system.ts`) ET le mock (`src/adapters/mock/index.ts`). RÈGLE ARCHI STRICTE : les composants ne touchent JAMAIS `invoke()` ni `@tauri-apps/api` — tout passe par les gateways/ports (hexagonal §1.3). Suis le patron d'une commande existante (ex. `interruptAgent` si elle existe, sinon une autre commande agent). 3) Tracker l'état limite par agent dans `useAgents` (view-model hook). Inspire-toi du patron existant `delegationSourceByRequester` qui se peuple depuis les events. État par agent : { limitedUntil?: number, resumeFireAt?: number, suspected?: bool }. Mises à jour : - `agentRateLimited` → limité (badge « limité jusqu'à HH:MM » si resetsAt connu, sinon « limité »). - `agentResumeScheduled` → arme le compte à rebours jusqu'à fireAt + bouton « Annuler la reprise ». - `agentResumeCancelled` → retire le compte à rebours (reste limité, sans reprise auto). - `agentResumed` → efface tout l'état limite de l'agent. - `agentRateLimitSuspected` → filet humain : signaler que la limite est détectée sans heure fiable et demander à l'utilisateur (au minimum un état/affichage « heure inconnue » ; le formulaire de saisie d'heure peut être un placeholder documenté si le backend n'a pas encore de commande pour l'enregistrer — dans ce cas, expose juste l'état et un TODO clair, NE fabrique pas de commande inexistante). 4) UI : badge limite + compte à rebours + bouton « Annuler la reprise » (appelle `cancel_resume` via le gateway) dans la vue agents/terminal cohérente avec le style existant. Composant testable (gateways mockés). NE PAS committer toi-même (l'agent Git tranche les commits). NE PAS toucher au backend Rust. Reste cohérent avec le style TS/React existant. Quand c'est implémenté, QA écrira/exécutera les tests — donc structure pour la testabilité (logique d'état dans le hook, pas dans le JSX). Rends-moi via idea_reply : la liste des fichiers modifiés/créés, les noms de champs wire exacts que tu as constatés dans events.rs, et tout point de friction (notamment sur le filet humain niveau 3 si aucune commande backend n'existe pour enregistrer l'heure saisie).
- **Response:** LS7-front terminé. Typecheck `tsc --noEmit` propre + 39 tests agents existants verts (aucune régression). Non commité (Git tranche). ## Noms de champs wire constatés (lus dans crates/app-tauri/src/events.rs, `DomainEventDto`, serde camelCase + skip_serializing_if sur les Option) - `agentRateLimited` → { `agentId`: string, `resetsAtMs`?: number /* absent si null */ } - `agentResumeScheduled` → { `agentId`: string, `fireAtMs`: number /* toujours présent */ } - `agentResumeCancelled` → { `agentId`: string } - `agentResumed` → { `agentId`: string } - `agentRateLimitSuspected` → { `agentId`: string, `resetsAtMs`?: number /* absent si null */ } ⚠️ Confirmé : ce sont bien `resetsAtMs` / `fireAtMs` (suffixe `Ms`), PAS `resetsAt`/`fireAt` comme l'énoncé du ticket le suggérait. Je me suis aligné sur le JSON réel. Vérifié aussi par le test backend `rate_limited_relays_to_dto_and_wire` qui asserte `json["resetsAtMs"]`. Commande Tauri `cancel_resume` (commands.rs:1381) : signature `cancel_resume(agent_id: String) -> Result<bool, _>` — argument **nu** `agentId` (PAS d'enveloppe `{ request }` contrairement à interrupt_agent/delegation_delivered). Adapter aligné en conséquence. Renvoie `true` ssi une reprise était armée et a été désarmée. ## Fichiers modifiés 1. `frontend/src/domain/index.ts` — 5 variantes ajoutées au union `DomainEvent` (mêmes `type` que le wire, champs exacts `resetsAtMs?`/`fireAtMs`). 2. `frontend/src/ports/index.ts``cancelResume(agentId: string): Promise<boolean>` ajouté à `InputGateway` (port choisi : c'est le gateway des contrôles agent hors-bande, cohérent avec `interrupt`). 3. `frontend/src/adapters/input.ts``TauriInputGateway.cancelResume``invoke("cancel_resume", { agentId })` (arg nu, typé `<boolean>`). 4. `frontend/src/adapters/mock/index.ts``MockInputGateway.cancelResume` : enregistre dans `cancelledResumes: string[]`, renvoie `cancelResumeResult` (défaut `true`, flippable par les tests). 5. `frontend/src/features/agents/useAgents.ts` — état `limitByAgent: Record<string, AgentLimitState>` (interface exportée `{ limitedUntil?, resumeFireAt?, suspected? }`), peuplé depuis les 5 events dans l'abonnement `onDomainEvent` existant (même patron que `delegationSourceByRequester`) : - `agentRateLimited``{ limitedUntil: resetsAtMs, suspected: false }` (conserve un `resumeFireAt` éventuel). - `agentResumeScheduled` → arme `resumeFireAt = fireAtMs`. - `agentResumeCancelled` → retire `resumeFireAt`, reste limité. - `agentResumed` → supprime l'entrée (efface tout). - `agentRateLimitSuspected``{ limitedUntil: resetsAtMs, suspected: true }`. - Action `cancelResume(agentId)` exposée : retrait optimiste du compte à rebours + appel `input.cancelResume`, renvoie le verdict backend. ## Fichier créé 6. `frontend/src/features/agents/AgentLimitBadge.tsx` — composant présentational + 2 helpers purs exportés (`formatResetTime(epochMs)``HH:MM`, `formatCountdown(remainingMs)``Xm Ys`/`Ys`, clampé à 0). Badge « limité jusqu'à HH:MM » / « limité », compte à rebours vivant (clock locale 1 s armée seulement si `resumeFireAt`), bouton « Annuler la reprise ». Câblé dans `AgentsPanel.tsx` (import + rendu conditionnel sous chaque ligne d'agent quand `vm.limitByAgent[a.id]` existe). ## Point de friction — filet humain niveau 3 (§21.1) Conformément à la consigne, je n'ai **fabriqué aucune commande inexistante**. Le seul point d'entrée backend disponible est `cancel_resume` ; **il n'existe pas de commande pour enregistrer une heure de reprise saisie par l'utilisateur**. Donc pour `agentRateLimitSuspected` sans `resetsAtMs`, le badge affiche l'état « limité · heure inconnue — reprise à préciser » (état + `role="note"`), sans formulaire de saisie. Un `TODO(LS-front)` documenté est posé dans `AgentLimitBadge.tsx` : câbler un formulaire d'heure dès que le backend exposera une commande pour persister l'instant choisi. Si tu veux ce filet pleinement fonctionnel, il faut une tâche backend (ex. `set_resume_at(agentId, epochMs)`) à cadrer avec Architect/DevBackend — je m'y connecterai côté front. Prêt pour QA (logique d'état dans le hook + helpers purs isolés du JSX, mocks en place avec `cancelledResumes`/`cancelResumeResult`).
- **Prompt:** Deux livrables distincts sur `feature/agent-session-limits` (Git en fera 2 commits séparés — ne commit pas toi-même). ═══ LIVRABLE 1 — LS8-front : formulaire de saisie d'heure (filet humain niveau 3) ═══ Le backend expose maintenant une commande Tauri `set_resume_at(agentId: string, resetsAtMs: number) -> void` (argument nu `{ agentId, resetsAtMs }`, comme `cancel_resume`). Elle arme la MÊME reprise annulable que l'auto et réémet `agentResumeScheduled` — donc une fois appelée, ton badge bascule TOUT SEUL de « heure inconnue » vers l'état nominal « limité jusqu'à HH:MM » + compte à rebours + bouton Annuler (déjà câblés en LS7). Aucun nouvel événement à consommer. À FAIRE : 1. Port : ajoute `setResumeAt(agentId: string, resetsAtMs: number): Promise<void>` à `InputGateway` (`src/ports/index.ts`), à côté de `cancelResume`. 2. Adapter Tauri (`src/adapters/input.ts`) : `setResumeAt``invoke("set_resume_at", { agentId, resetsAtMs })`. 3. Mock (`src/adapters/mock/index.ts`) : `MockInputGateway.setResumeAt` enregistre dans un tableau (ex. `resumeArmings: { agentId, resetsAtMs }[]`) pour les tests ; suis le patron de `cancelledResumes`. 4. Hook `useAgents` : expose une action `setResumeAt(agentId, resetsAtMs)` qui délègue au port (pas de mutation optimiste nécessaire — l'event `agentResumeScheduled` rebasculera l'état). 5. UI `AgentLimitBadge.tsx` : sur l'état SUSPECTED SANS heure (`suspected === true` && pas de `limitedUntil`/`resumeFireAt`), remplace le TODO/placeholder par un petit formulaire : un input heure (type `time` ou `datetime-local`) + bouton « Programmer la reprise ». À la soumission : convertir la saisie en epoch-ms (si input `time` seul = aujourd'hui à HH:MM ; si déjà passé dans la journée, le backend clampe à maintenant ⇒ reprise immédiate, donc pas besoin de validation stricte côté front, mais documente le comportement). Appeler l'action `setResumeAt(agentId, epochMs)`. Garde un helper pur exporté pour la conversion (ex. `timeInputToEpochMs(value, now)`) pour la testabilité. Style cohérent avec le reste du badge ; désactive pendant `busy`. 6. Retire le `TODO(LS-front)` posé en LS7 (le filet est maintenant complet). ═══ LIVRABLE 2 — fix test préexistant (hygiène, commit séparé) ═══ `src/adapters/mock/mock.test.ts:16` asserte « thirteen gateways » mais il y en a 14 (la gateway `permission` existe déjà dans HEAD). Mets le compteur à jour (14, et le libellé texte si présent) pour que `npx vitest run src/adapters/mock/` repasse vert. NE change RIEN d'autre dans ce fichier ; vérifie juste que le compte réel est bien 14 avant de figer le nombre. Lance `npx vitest run src/features/agents/ src/adapters/mock/` + `tsc --noEmit` et RENDS-MOI via idea_reply : fichiers modifiés/créés par livrable, le helper de conversion exact, le résultat des deux commandes (typecheck + tests), et le nombre réel de gateways constaté. Structure pour testabilité (logique dans le hook + helpers purs). NE COMMIT PAS — Git tranche, en 2 commits séparés (LS8-front / fix-test).
- **Response:** Les deux livrables sont prêts. NON commité (Git tranche, 2 commits séparés). ## Résultats des commandes - `npx tsc --noEmit` : **propre** (0 erreur). - `npx vitest run src/features/agents/ src/adapters/mock/` : **109 tests / 8 fichiers, tous verts**. ## Nombre réel de gateways constaté : **14** Le `Gateways` (ports/index.ts) en a 14 ; l'array du test en listait 13 — il manquait `permission` (présente dans HEAD). Compteur + libellé + array corrigés. ## Helper de conversion exact (pur, exporté, testé) ```ts // AgentLimitBadge.tsx export function timeInputToEpochMs(value: string, now: number): number | null { const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim()); if (!match) return null; const hours = Number(match[1]); const minutes = Number(match[2]); if (hours > 23 || minutes > 59) return null; const d = new Date(now); d.setHours(hours, minutes, 0, 0); return d.getTime(); } ``` Comportement documenté : input `time` seul ⇒ aujourd'hui à HH:MM (même jour calendaire que `now`). Une heure déjà passée renvoie un instant < now **sans validation stricte** — le backend clampe à maintenant ⇒ reprise immédiate (commenté dans le code et couvert par un test). Empty/malformé/`25:00`/`12:60` ⇒ `null` (saisie incomplète ignorée). ═══ LIVRABLE 1 — LS8-front (commit « LS8-front ») ═══ Fichiers modifiés : 1. `frontend/src/ports/index.ts` — `setResumeAt(agentId, resetsAtMs): Promise<void>` ajouté à `InputGateway`, à côté de `cancelResume`. 2. `frontend/src/adapters/input.ts` — `setResumeAt` → `invoke("set_resume_at", { agentId, resetsAtMs })` (args nus, comme cancel_resume). 3. `frontend/src/adapters/mock/index.ts` — `MockInputGateway.setResumeAt` enregistre dans `resumeArmings: { agentId, resetsAtMs }[]` (patron `cancelledResumes`). 4. `frontend/src/features/agents/useAgents.ts` — action `setResumeAt(agentId, resetsAtMs)` exposée, délègue au port, **aucune mutation optimiste** (l'event `agentResumeScheduled` rebascule l'état via l'abonnement existant). 5. `frontend/src/features/agents/AgentLimitBadge.tsx` — état SUSPECTED sans heure (`suspected===true && !limitedUntil && !resumeFireAt`) : le placeholder/TODO LS7 est remplacé par un **formulaire** (input `type="time"` aria-label `resume time` + bouton `Programmer la reprise` aria-label `schedule resume`, désactivés pendant `busy`/saisie vide). Soumission → `timeInputToEpochMs` → `onSetResumeAt(epochMs)`. Une fois armé, le badge bascule **tout seul** vers « limité jusqu'à HH:MM » + compte à rebours + Annuler (chaîne LS7 inchangée). TODO(LS-front) **retiré**. 6. `frontend/src/features/agents/AgentsPanel.tsx` — passe `onSetResumeAt={(ms) => void vm.setResumeAt(a.id, ms)}` au badge. 7. `frontend/src/features/agents/AgentLimitBadge.test.tsx` — **test QA préexistant (LS7) mis à jour au nouveau contrat** (prop requise `onSetResumeAt`, le cas « suspected sans heure » teste maintenant le formulaire au lieu de l'ancien texte placeholder) + ajout couverture LS8 : `timeInputToEpochMs` (jour calendaire, instant passé, null malformé), soumission du formulaire ⇒ `onSetResumeAt` appelé avec l'epoch-ms attendu, bouton désactivé si vide. *(Sans cette MAJ le typecheck cassait — la prop est devenue obligatoire et le rendu du cas suspected a changé. Aucune logique de prod altérée, seulement l'alignement du test.)* ═══ LIVRABLE 2 — fix test (commit « fix-test » séparé) ═══ Fichier modifié : - `frontend/src/adapters/mock/mock.test.ts` — « thirteen » → « fourteen », `"permission"` inséré dans l'array attendu (ordre alphabétique). **Rien d'autre touché** dans ce fichier. ## Point de friction / note Petit ajustement non listé mais nécessaire : le `<form>` portait initialement le même `aria-label="schedule resume"` que son bouton submit, ce qui faisait matcher 2 éléments avec `getByLabelText`. J'ai renommé le label du form en `"resume time form"` (le bouton garde `schedule resume`). Aucune incidence fonctionnelle. Le filet humain niveau 3 est désormais **complet** : détection → formulaire de saisie → arme la reprise annulable via `set_resume_at` → bascule auto vers l'état nominal. Prêt pour QA (logique d'état dans le hook, helpers purs isolés, mocks instrumentés `resumeArmings`/`cancelledResumes`).

View File

@ -1,12 +0,0 @@
{"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)."}
{"id":"9b53cc9b-ffed-42c9-9961-fefb5aca677e","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781675890156,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tâche LS7-front (React/TS) — UI de gestion des limites de session des agents (ARCHITECTURE §21). On est sur la branche `feature/agent-session-limits`. Le backend est terminé et vert (commit 9df5923) : il émet déjà 5 DOMAIN_EVENT vers le front et expose une commande Tauri `cancel_resume(agentId) -> bool`.\n\nCONTRAT WIRE (DTO sérialisés camelCase, source = crates/app-tauri/src/events.rs) :\n- `agentRateLimited` { agentId: string, resetsAt?: number /* epoch-ms, absent si inconnu */ }\n- `agentResumeScheduled` { agentId: string, fireAt: number /* epoch-ms, échéance du réveil */ } ⚠️ champ wire = `fireAtMs` → vérifie le nom exact sérialisé (camelCase de `fire_at_ms` = `fireAtMs`) ; idem `resetsAtMs` pour les autres. Aligne-toi sur le JSON réel.\n- `agentResumeCancelled` { agentId: string }\n- `agentResumed` { agentId: string }\n- `agentRateLimitSuspected` { agentId: string, resetsAt?: number }\n(Vérifie les noms de champs exacts dans events.rs : `resets_at_ms`→`resetsAtMs`, `fire_at_ms`→`fireAtMs`. Ne devine pas, lis le fichier.)\n\nÀ FAIRE :\n1) Ajouter les 5 variantes au union `DomainEvent` de `src/domain/index.ts` (mêmes noms `type` que le wire), avec les champs exacts.\n2) Exposer la commande `cancel_resume` dans le port gateway approprié (cf. `src/ports/index.ts`) + son implémentation dans l'adapter système (`src/adapters/system.ts`) ET le mock (`src/adapters/mock/index.ts`). RÈGLE ARCHI STRICTE : les composants ne touchent JAMAIS `invoke()` ni `@tauri-apps/api` — tout passe par les gateways/ports (hexagonal §1.3). Suis le patron d'une commande existante (ex. `interruptAgent` si elle existe, sinon une autre commande agent).\n3) Tracker l'état limite par agent dans `useAgents` (view-model hook). Inspire-toi du patron existant `delegationSourceByRequester` qui se peuple depuis les events. État par agent : { limitedUntil?: number, resumeFireAt?: number, suspected?: bool }. Mises à jour :\n - `agentRateLimited` → limité (badge « limité jusqu'à HH:MM » si resetsAt connu, sinon « limité »).\n - `agentResumeScheduled` → arme le compte à rebours jusqu'à fireAt + bouton « Annuler la reprise ».\n - `agentResumeCancelled` → retire le compte à rebours (reste limité, sans reprise auto).\n - `agentResumed` → efface tout l'état limite de l'agent.\n - `agentRateLimitSuspected` → filet humain : signaler que la limite est détectée sans heure fiable et demander à l'utilisateur (au minimum un état/affichage « heure inconnue » ; le formulaire de saisie d'heure peut être un placeholder documenté si le backend n'a pas encore de commande pour l'enregistrer — dans ce cas, expose juste l'état et un TODO clair, NE fabrique pas de commande inexistante).\n4) UI : badge limite + compte à rebours + bouton « Annuler la reprise » (appelle `cancel_resume` via le gateway) dans la vue agents/terminal cohérente avec le style existant. Composant testable (gateways mockés).\n\nNE PAS committer toi-même (l'agent Git tranche les commits). NE PAS toucher au backend Rust. Reste cohérent avec le style TS/React existant. Quand c'est implémenté, QA écrira/exécutera les tests — donc structure pour la testabilité (logique d'état dans le hook, pas dans le JSX).\n\nRends-moi via idea_reply : la liste des fichiers modifiés/créés, les noms de champs wire exacts que tu as constatés dans events.rs, et tout point de friction (notamment sur le filet humain niveau 3 si aucune commande backend n'existe pour enregistrer l'heure saisie)."}
{"id":"a37b2aac-87f7-476d-8606-7250d15dd852","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781676130924,"source":{"kind":"agent","agentId":"af7f86da-76bc-48e1-9900-71f45a624800"},"role":"response","text":"LS7-front terminé. Typecheck `tsc --noEmit` propre + 39 tests agents existants verts (aucune régression). Non commité (Git tranche).\n\n## Noms de champs wire constatés (lus dans crates/app-tauri/src/events.rs, `DomainEventDto`, serde camelCase + skip_serializing_if sur les Option)\n- `agentRateLimited` → { `agentId`: string, `resetsAtMs`?: number /* absent si null */ }\n- `agentResumeScheduled` → { `agentId`: string, `fireAtMs`: number /* toujours présent */ }\n- `agentResumeCancelled` → { `agentId`: string }\n- `agentResumed` → { `agentId`: string }\n- `agentRateLimitSuspected` → { `agentId`: string, `resetsAtMs`?: number /* absent si null */ }\n\n⚠ Confirmé : ce sont bien `resetsAtMs` / `fireAtMs` (suffixe `Ms`), PAS `resetsAt`/`fireAt` comme l'énoncé du ticket le suggérait. Je me suis aligné sur le JSON réel. Vérifié aussi par le test backend `rate_limited_relays_to_dto_and_wire` qui asserte `json[\"resetsAtMs\"]`.\n\nCommande Tauri `cancel_resume` (commands.rs:1381) : signature `cancel_resume(agent_id: String) -> Result<bool, _>` — argument **nu** `agentId` (PAS d'enveloppe `{ request }` contrairement à interrupt_agent/delegation_delivered). Adapter aligné en conséquence. Renvoie `true` ssi une reprise était armée et a été désarmée.\n\n## Fichiers modifiés\n1. `frontend/src/domain/index.ts` — 5 variantes ajoutées au union `DomainEvent` (mêmes `type` que le wire, champs exacts `resetsAtMs?`/`fireAtMs`).\n2. `frontend/src/ports/index.ts` — `cancelResume(agentId: string): Promise<boolean>` ajouté à `InputGateway` (port choisi : c'est le gateway des contrôles agent hors-bande, cohérent avec `interrupt`).\n3. `frontend/src/adapters/input.ts` — `TauriInputGateway.cancelResume` → `invoke(\"cancel_resume\", { agentId })` (arg nu, typé `<boolean>`).\n4. `frontend/src/adapters/mock/index.ts` — `MockInputGateway.cancelResume` : enregistre dans `cancelledResumes: string[]`, renvoie `cancelResumeResult` (défaut `true`, flippable par les tests).\n5. `frontend/src/features/agents/useAgents.ts` — état `limitByAgent: Record<string, AgentLimitState>` (interface exportée `{ limitedUntil?, resumeFireAt?, suspected? }`), peuplé depuis les 5 events dans l'abonnement `onDomainEvent` existant (même patron que `delegationSourceByRequester`) :\n - `agentRateLimited` → `{ limitedUntil: resetsAtMs, suspected: false }` (conserve un `resumeFireAt` éventuel).\n - `agentResumeScheduled` → arme `resumeFireAt = fireAtMs`.\n - `agentResumeCancelled` → retire `resumeFireAt`, reste limité.\n - `agentResumed` → supprime l'entrée (efface tout).\n - `agentRateLimitSuspected` → `{ limitedUntil: resetsAtMs, suspected: true }`.\n - Action `cancelResume(agentId)` exposée : retrait optimiste du compte à rebours + appel `input.cancelResume`, renvoie le verdict backend.\n\n## Fichier créé\n6. `frontend/src/features/agents/AgentLimitBadge.tsx` — composant présentational + 2 helpers purs exportés (`formatResetTime(epochMs)` → `HH:MM`, `formatCountdown(remainingMs)` → `Xm Ys`/`Ys`, clampé à 0). Badge « limité jusqu'à HH:MM » / « limité », compte à rebours vivant (clock locale 1 s armée seulement si `resumeFireAt`), bouton « Annuler la reprise ». Câblé dans `AgentsPanel.tsx` (import + rendu conditionnel sous chaque ligne d'agent quand `vm.limitByAgent[a.id]` existe).\n\n## Point de friction — filet humain niveau 3 (§21.1)\nConformément à la consigne, je n'ai **fabriqué aucune commande inexistante**. Le seul point d'entrée backend disponible est `cancel_resume` ; **il n'existe pas de commande pour enregistrer une heure de reprise saisie par l'utilisateur**. Donc pour `agentRateLimitSuspected` sans `resetsAtMs`, le badge affiche l'état « limité · heure inconnue — reprise à préciser » (état + `role=\"note\"`), sans formulaire de saisie. Un `TODO(LS-front)` documenté est posé dans `AgentLimitBadge.tsx` : câbler un formulaire d'heure dès que le backend exposera une commande pour persister l'instant choisi. Si tu veux ce filet pleinement fonctionnel, il faut une tâche backend (ex. `set_resume_at(agentId, epochMs)`) à cadrer avec Architect/DevBackend — je m'y connecterai côté front.\n\nPrêt pour QA (logique d'état dans le hook + helpers purs isolés du JSX, mocks en place avec `cancelledResumes`/`cancelResumeResult`)."}
{"id":"f21219de-033a-4b3e-8e8b-366534112a95","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781676945137,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Deux livrables distincts sur `feature/agent-session-limits` (Git en fera 2 commits séparés — ne commit pas toi-même).\n\n═══ LIVRABLE 1 — LS8-front : formulaire de saisie d'heure (filet humain niveau 3) ═══\nLe backend expose maintenant une commande Tauri `set_resume_at(agentId: string, resetsAtMs: number) -> void` (argument nu `{ agentId, resetsAtMs }`, comme `cancel_resume`). Elle arme la MÊME reprise annulable que l'auto et réémet `agentResumeScheduled` — donc une fois appelée, ton badge bascule TOUT SEUL de « heure inconnue » vers l'état nominal « limité jusqu'à HH:MM » + compte à rebours + bouton Annuler (déjà câblés en LS7). Aucun nouvel événement à consommer.\n\nÀ FAIRE :\n1. Port : ajoute `setResumeAt(agentId: string, resetsAtMs: number): Promise<void>` à `InputGateway` (`src/ports/index.ts`), à côté de `cancelResume`.\n2. Adapter Tauri (`src/adapters/input.ts`) : `setResumeAt` → `invoke(\"set_resume_at\", { agentId, resetsAtMs })`.\n3. Mock (`src/adapters/mock/index.ts`) : `MockInputGateway.setResumeAt` enregistre dans un tableau (ex. `resumeArmings: { agentId, resetsAtMs }[]`) pour les tests ; suis le patron de `cancelledResumes`.\n4. Hook `useAgents` : expose une action `setResumeAt(agentId, resetsAtMs)` qui délègue au port (pas de mutation optimiste nécessaire — l'event `agentResumeScheduled` rebasculera l'état). \n5. UI `AgentLimitBadge.tsx` : sur l'état SUSPECTED SANS heure (`suspected === true` && pas de `limitedUntil`/`resumeFireAt`), remplace le TODO/placeholder par un petit formulaire : un input heure (type `time` ou `datetime-local`) + bouton « Programmer la reprise ». À la soumission : convertir la saisie en epoch-ms (si input `time` seul = aujourd'hui à HH:MM ; si déjà passé dans la journée, le backend clampe à maintenant ⇒ reprise immédiate, donc pas besoin de validation stricte côté front, mais documente le comportement). Appeler l'action `setResumeAt(agentId, epochMs)`. Garde un helper pur exporté pour la conversion (ex. `timeInputToEpochMs(value, now)`) pour la testabilité. Style cohérent avec le reste du badge ; désactive pendant `busy`.\n6. Retire le `TODO(LS-front)` posé en LS7 (le filet est maintenant complet).\n\n═══ LIVRABLE 2 — fix test préexistant (hygiène, commit séparé) ═══\n`src/adapters/mock/mock.test.ts:16` asserte « thirteen gateways » mais il y en a 14 (la gateway `permission` existe déjà dans HEAD). Mets le compteur à jour (14, et le libellé texte si présent) pour que `npx vitest run src/adapters/mock/` repasse vert. NE change RIEN d'autre dans ce fichier ; vérifie juste que le compte réel est bien 14 avant de figer le nombre.\n\nLance `npx vitest run src/features/agents/ src/adapters/mock/` + `tsc --noEmit` et RENDS-MOI via idea_reply : fichiers modifiés/créés par livrable, le helper de conversion exact, le résultat des deux commandes (typecheck + tests), et le nombre réel de gateways constaté. Structure pour testabilité (logique dans le hook + helpers purs). NE COMMIT PAS — Git tranche, en 2 commits séparés (LS8-front / fix-test)."}
{"id":"50c3e999-c6a3-4d8b-aef3-b6c273ed9afc","conversation":"09b15ec3-ce2f-0af2-2903-ef699df9de41","atMs":1781677152871,"source":{"kind":"agent","agentId":"af7f86da-76bc-48e1-9900-71f45a624800"},"role":"response","text":"Les deux livrables sont prêts. NON commité (Git tranche, 2 commits séparés).\n\n## Résultats des commandes\n- `npx tsc --noEmit` : **propre** (0 erreur).\n- `npx vitest run src/features/agents/ src/adapters/mock/` : **109 tests / 8 fichiers, tous verts**.\n\n## Nombre réel de gateways constaté : **14**\nLe `Gateways` (ports/index.ts) en a 14 ; l'array du test en listait 13 — il manquait `permission` (présente dans HEAD). Compteur + libellé + array corrigés.\n\n## Helper de conversion exact (pur, exporté, testé)\n```ts\n// AgentLimitBadge.tsx\nexport function timeInputToEpochMs(value: string, now: number): number | null {\n const match = /^(\\d{1,2}):(\\d{2})$/.exec(value.trim());\n if (!match) return null;\n const hours = Number(match[1]);\n const minutes = Number(match[2]);\n if (hours > 23 || minutes > 59) return null;\n const d = new Date(now);\n d.setHours(hours, minutes, 0, 0);\n return d.getTime();\n}\n```\nComportement documenté : input `time` seul ⇒ aujourd'hui à HH:MM (même jour calendaire que `now`). Une heure déjà passée renvoie un instant < now **sans validation stricte** — le backend clampe à maintenant ⇒ reprise immédiate (commenté dans le code et couvert par un test). Empty/malformé/`25:00`/`12:60` ⇒ `null` (saisie incomplète ignorée).\n\n═══ LIVRABLE 1 — LS8-front (commit « LS8-front ») ═══\nFichiers modifiés :\n1. `frontend/src/ports/index.ts` — `setResumeAt(agentId, resetsAtMs): Promise<void>` ajouté à `InputGateway`, à côté de `cancelResume`.\n2. `frontend/src/adapters/input.ts` — `setResumeAt` → `invoke(\"set_resume_at\", { agentId, resetsAtMs })` (args nus, comme cancel_resume).\n3. `frontend/src/adapters/mock/index.ts` — `MockInputGateway.setResumeAt` enregistre dans `resumeArmings: { agentId, resetsAtMs }[]` (patron `cancelledResumes`).\n4. `frontend/src/features/agents/useAgents.ts` — action `setResumeAt(agentId, resetsAtMs)` exposée, délègue au port, **aucune mutation optimiste** (l'event `agentResumeScheduled` rebascule l'état via l'abonnement existant).\n5. `frontend/src/features/agents/AgentLimitBadge.tsx` — état SUSPECTED sans heure (`suspected===true && !limitedUntil && !resumeFireAt`) : le placeholder/TODO LS7 est remplacé par un **formulaire** (input `type=\"time\"` aria-label `resume time` + bouton `Programmer la reprise` aria-label `schedule resume`, désactivés pendant `busy`/saisie vide). Soumission → `timeInputToEpochMs` → `onSetResumeAt(epochMs)`. Une fois armé, le badge bascule **tout seul** vers « limité jusqu'à HH:MM » + compte à rebours + Annuler (chaîne LS7 inchangée). TODO(LS-front) **retiré**.\n6. `frontend/src/features/agents/AgentsPanel.tsx` — passe `onSetResumeAt={(ms) => void vm.setResumeAt(a.id, ms)}` au badge.\n7. `frontend/src/features/agents/AgentLimitBadge.test.tsx` — **test QA préexistant (LS7) mis à jour au nouveau contrat** (prop requise `onSetResumeAt`, le cas « suspected sans heure » teste maintenant le formulaire au lieu de l'ancien texte placeholder) + ajout couverture LS8 : `timeInputToEpochMs` (jour calendaire, instant passé, null malformé), soumission du formulaire ⇒ `onSetResumeAt` appelé avec l'epoch-ms attendu, bouton désactivé si vide. *(Sans cette MAJ le typecheck cassait — la prop est devenue obligatoire et le rendu du cas suspected a changé. Aucune logique de prod altérée, seulement l'alignement du test.)*\n\n═══ LIVRABLE 2 — fix test (commit « fix-test » séparé) ═══\nFichier modifié :\n- `frontend/src/adapters/mock/mock.test.ts` — « thirteen » → « fourteen », `\"permission\"` inséré dans l'array attendu (ordre alphabétique). **Rien d'autre touché** dans ce fichier.\n\n## Point de friction / note\nPetit ajustement non listé mais nécessaire : le `<form>` portait initialement le même `aria-label=\"schedule resume\"` que son bouton submit, ce qui faisait matcher 2 éléments avec `getByLabelText`. J'ai renommé le label du form en `\"resume time form\"` (le bouton garde `schedule resume`). Aucune incidence fonctionnelle.\n\nLe filet humain niveau 3 est désormais **complet** : détection → formulaire de saisie → arme la reprise annulable via `set_resume_at` → bascule auto vers l'état nominal. Prêt pour QA (logique d'état dans le hook, helpers purs isolés, mocks instrumentés `resumeArmings`/`cancelledResumes`)."}

View File

@ -1,13 +0,0 @@
---
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

@ -1,7 +0,0 @@
{"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

@ -1,26 +0,0 @@
---
upTo: 6df9f7b7-fcc4-4b87-94db-33dce2d76f13
objective: Nouvelle feature cadrée par Architect, prête à démarrer le développement : « Gestion des limites de session des agents » (détection hiérarchique des limites de session + reprise auto annulable). C'est
---
**Objectif :** Nouvelle feature cadrée par Architect, prête à démarrer le développement : « Gestion des limites de session des agents » (détection hiérarchique des limites de session + reprise auto annulable). C'est
- **Response:** Décision (agent Git) — branche retenue : **`feature/agent-session-limits`**, créée **depuis `develop`**. **Raisonnement :** - Nouvelle feature indépendante (LS1→LS8) → conformément au modèle `main ← develop ← feature/*`, elle se développe sur sa propre branche `feature/*`. - Pas besoin de (re)créer `develop` : il existe déjà et est à jour (post-release 0.1.0, `git diff main develop` vide → contenu identique). C'est donc la bonne base de départ. - Nom dérivé du sujet, kebab-case : `feature/agent-session-limits`. **État git après manœuvre (LOCAL, aucun push) :** - Branche courante : `feature/agent-session-limits` @ `401c18a` (= base `develop`). - `develop` @ `401c18a` (inchangé) ← `main` @ `9a8a89f` (tag `v0.1.0`, inchangé). - `feature/agent-skills` @ `ef101db` (autre feature, intacte). **Note :** le working tree porte déjà des changements non commités hérités de l'état courant — notamment `ARCHITECTURE.md` (cadrage Architect) et un nouveau `.ideai/memory/session-limit-handling-design.md`, plus du runtime `.ideai/` (conversations/MEMORY.md). Ils n'ont pas été commités : DevBackend/QA les intégreront dans les commits de la feature au fil des lots. Le cadrage Architect (ARCHITECTURE.md + memory design) pourra faire le **premier commit** de la branche avant LS1 si tu le souhaites — dis-moi si tu veux que je le commite maintenant. Dev peut démarrer LS1 (domaine) sur `feature/agent-session-limits`. Après chaque lot vert, reviens vers moi pour décider d'un éventuel commit/merge. Aucune intégration vers `develop` tant que les tests des lots concernés ne sont pas verts (règle d'or).
- **Prompt:** Feu vert pour le premier commit de la branche feature/agent-session-limits : commite le cadrage AVANT que le dev (LS1) ne démarre, pour isoler proprement le cadrage du code. Contenu à committer (cadrage uniquement) : ARCHITECTURE.md (nouvelle §21) + .ideai/memory/session-limit-handling-design.md + la mise à jour de .ideai/memory/MEMORY.md (pointeur). N'inclus PAS le runtime .ideai/ non pertinent (conversations, layouts, agents.json…) s'il pollue le diff — limite le commit aux fichiers du cadrage. Message de commit conforme au style du dépôt (français, conventionnel). Périmètre LOCAL uniquement — aucun push. Confirme le hash du commit et l'état git après.
- **Response:** Cadrage committé sur `feature/agent-session-limits` — premier commit de la branche, isolé du code. **Commit : `fa5b826`** `docs(session-limits): cadrage Architect — gestion des limites de session des agents` **Contenu (cadrage uniquement, 3 fichiers / +212 lignes) :** - `ARCHITECTURE.md` (+185, nouvelle §21) - `.ideai/memory/session-limit-handling-design.md` (nouveau, design validé) - `.ideai/memory/MEMORY.md` (+1, pointeur) Le runtime `.ideai/` non pertinent (conversations, layouts, agents.json…) a été **exclu** du diff comme demandé. **État git après commit (LOCAL, aucun push) :** - `feature/agent-session-limits` @ `fa5b826``develop` @ `401c18a``main` @ `9a8a89f` (tag `v0.1.0`). - Working tree : reste uniquement du runtime `.ideai/conversations/…` non commité (bruit, ignoré pour le cadrage). La branche est prête : le dev peut démarrer **LS1 (domaine)**. Je reviendrai dans la boucle pour committer chaque lot vert.
- **Prompt:** LS1 (couche domaine) de la feature « limites de session » est VERT : DevBackend a livré le code pur, QA a écrit 24 tests, `cargo test -p domain` = 165 passed / 0 failed, zéro régression. Commite LS1 sur feature/agent-session-limits (commit suivant après le cadrage fa5b826). Périmètre LOCAL uniquement, aucun push. Fichiers de prod (domaine) modifiés/créés par DevBackend : - crates/domain/src/ports.rs (variante ReplyEvent::RateLimited) - crates/domain/src/readiness.rs (variante ReadinessSignal::RateLimited + classify) - crates/domain/src/session_limit.rs (NOUVEAU : SessionLimit, ResumePlan, RateLimitSource, plan_resume) - crates/domain/src/profile.rs (RateLimitPattern + champ + builder) - crates/domain/src/events.rs (5 variantes DomainEvent) - crates/domain/src/lib.rs (mod + re-exports) Plus les tests QA dans ces mêmes fichiers (#[cfg(test)]). N'inclus PAS le runtime .ideai/ non pertinent. Message conventionnel en français. IMPORTANT — décision de merge : NE merge PAS vers develop maintenant. Le workspace complet (`cargo build --workspace`) NE COMPILE PAS tant que LS2 n'a pas ajouté l'arm `ReplyEvent::RateLimited` côté infra (match exhaustifs). On ne merge feature/* → develop que sur une tranche qui compile de bout en bout (cible : LS1+LS2+LS4 = niveau 1 Claude complet). Confirme juste le hash du commit LS1 et l'état git.
- **Response:** LS1 (couche domaine) committé sur `feature/agent-session-limits`, après le cadrage `fa5b826`. **Commit : `0bf1eb3`** `feat(session-limits): LS1 — couche domaine (détection + plan de reprise)` **Contenu (6 fichiers domaine, prod + tests inline / +647 lignes) :** - `crates/domain/src/session_limit.rs` (nouveau) - `crates/domain/src/ports.rs` (ReplyEvent::RateLimited) - `crates/domain/src/readiness.rs` (ReadinessSignal::RateLimited + classify) - `crates/domain/src/profile.rs` (RateLimitPattern + champ + builder) - `crates/domain/src/events.rs` (5 variantes DomainEvent) - `crates/domain/src/lib.rs` (mod + re-exports) Runtime `.ideai/` non pertinent **exclu**. QA : `cargo test -p domain` = 165 passed / 0 failed, zéro régression. **Décision de merge : PAS de merge vers `develop`** — conforme à ta consigne. Le workspace ne compile pas tant que LS2 n'a pas ajouté l'arm `ReplyEvent::RateLimited` côté infra (match exhaustifs). On n'intègre `feature/* → develop` que sur une tranche qui compile de bout en bout (cible : LS1+LS2+LS4 = niveau 1 Claude complet). Je reste sur la branche feature. **État git (LOCAL, aucun push) :** ``` feature/agent-session-limits @ 0bf1eb3 (HEAD) └ fa5b826 docs cadrage └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0) ``` Working tree : seul du runtime `.ideai/conversations/…` non commité (ignoré). Dev peut enchaîner sur LS2.
- **Prompt:** LS2 (adapter Claude niveau 1, infra) est VERT : `cargo test -p infrastructure` = 188 passed / 0 failed, zéro régression. Commite LS2 sur feature/agent-session-limits (après LS1 0bf1eb3). LOCAL uniquement, aucun push. Fichier de prod modifié : crates/infrastructure/src/session/claude.rs (parse_event émet ReplyEvent::RateLimited ; nouvelle fonction pure parse_reset_ms + helpers ; parseur ISO maison ; doccomments T4). Tests QA : dans crates/infrastructure/src/session/mod.rs (#[cfg(test)]) — 26 nouveaux tests + 2 tests existants mis à jour vers le nouveau contrat ; et crates/infrastructure/src/session/conformance.rs (#[cfg(test)] : RateLimited ajouté aux événements non terminaux autorisés). Exclu le runtime .ideai/. Message conventionnel français. Décision merge : TOUJOURS PAS de merge vers develop (le workspace ne compile pas encore — 2 erreurs résiduelles attendues dans app-tauri, relèvent de LS7/LS8 ; et la réconciliation T4 reste à faire en LS4). On vise la tranche LS1+LS2+LS3+LS4 = niveau 1 Claude de bout en bout qui compile, pour le premier merge. Confirme le hash LS2 et l'état git.
- **Response:** LS2 (adapter Claude niveau 1, infra) committé sur `feature/agent-session-limits`, après LS1 `0bf1eb3`. **Commit : `a1755e5`** `feat(session-limits): LS2 — adapter Claude niveau 1 (infra)` **Contenu (3 fichiers infra, prod + tests inline / +545 lignes) :** - `crates/infrastructure/src/session/claude.rs` (prod : parse_event → ReplyEvent::RateLimited, parse_reset_ms + helpers, parseur ISO maison, doccomments T4) - `crates/infrastructure/src/session/mod.rs` (26 nouveaux tests QA + 2 alignés) - `crates/infrastructure/src/session/conformance.rs` (RateLimited en non-terminal autorisé) Runtime `.ideai/` **exclu**. QA : `cargo test -p infrastructure` = 188 passed / 0 failed, zéro régression. **Décision de merge : TOUJOURS PAS de merge vers `develop`** — conforme. Le workspace ne compile pas encore (2 erreurs résiduelles attendues dans app-tauri → LS7/LS8 ; réconciliation T4 → LS4). Premier merge visé sur la tranche **LS1+LS2+LS3+LS4 = niveau 1 Claude de bout en bout qui compile**. **État git (LOCAL, aucun push) :** ``` feature/agent-session-limits @ a1755e5 (HEAD) └ 0bf1eb3 LS1 domaine └ fa5b826 cadrage └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0) ``` Working tree : seul du runtime `.ideai/` non commité (ignoré). Dev peut enchaîner sur LS3.
- **Prompt:** LS3 (port Scheduler + adapter TokioScheduler) est VERT : `cargo test -p infrastructure` = 195 passed / 0 failed (7 nouveaux tests scheduler, relancés 3× sans flaky), LS1/LS2 toujours verts. Builds domaine + infra OK 0 warning. Le workspace complet ne compile toujours pas (2 erreurs résiduelles inchangées dans app-tauri : chat.rs:185 → LS8, events.rs:261 → LS7 ; la réconciliation T4 reste en LS4). C'est TOI qui décides quoi et quand committer/merger. Je te livre l'état, tu tranches. Fichiers de prod LS3 : - crates/domain/src/ids.rs (ScheduleId via typed_id!) - crates/domain/src/ports.rs (trait Scheduler + enum ScheduledTask) - crates/domain/src/lib.rs (re-exports) - crates/infrastructure/src/scheduler/mod.rs (NOUVEAU : TokioScheduler) - crates/infrastructure/src/lib.rs (pub mod scheduler + re-export) Tests QA : dans crates/infrastructure/src/scheduler/mod.rs (#[cfg(test)]). Runtime .ideai/ à exclure du diff. Décide : faut-il committer LS3 maintenant (et avec quel message) ? Et concernant le merge vers develop — rappel du contexte : le premier merge envisagé portait sur la tranche LS1+LS2+LS3+LS4 (niveau 1 Claude de bout en bout qui compile), or le workspace ne compile pas encore (LS4 + app-tauri manquants). Dis-moi ta décision (commit LS3 sur la feature ? attendre la tranche compilable pour merger ? autre ?) et exécute-la. Confirme hash + état git.
- **Response:** Décision (agent Git) : **commit LS3 sur la feature, PAS de merge vers `develop`.** **Pourquoi :** chaque lot vert mérite son commit atomique sur la branche feature (traçabilité, bissection facile), mais le merge `feature/* → develop` exige une tranche qui **compile de bout en bout** — règle d'or. Or le workspace ne compile toujours pas (réconciliation T4 en LS4 + 2 erreurs résiduelles app-tauri : `chat.rs:185`→LS8, `events.rs:261`→LS7). Donc on continue d'accumuler sur la feature ; premier merge maintenu sur la tranche compilable (LS1+LS2+LS3+LS4 niveau 1 Claude, voire jusqu'à LS7/LS8 si la compilation workspace n'est verte qu'à ce moment-là). **Commit : `253310b`** `feat(session-limits): LS3 — port Scheduler + adapter TokioScheduler` **Contenu (5 fichiers prod + tests inline / +389 lignes) :** - domaine : `ports.rs` (trait Scheduler + enum ScheduledTask), `ids.rs` (ScheduleId), `lib.rs` (re-exports) - infra : `scheduler/mod.rs` (nouveau, TokioScheduler), `lib.rs` (pub mod + re-export) Runtime `.ideai/` **exclu**. QA : `cargo test -p infrastructure` = 195 passed / 0 failed (7 tests scheduler, 3× sans flaky) ; LS1/LS2 verts ; builds domaine+infra 0 warning. **État git (LOCAL, aucun push) :** ``` feature/agent-session-limits @ 253310b (HEAD) └ a1755e5 LS2 adapter Claude └ 0bf1eb3 LS1 domaine └ fa5b826 cadrage └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0) ``` Dev peut enchaîner sur LS4 (service application + réconciliation T4). Je tranche le merge dès que le workspace compile.
- **Prompt:** LS4 (couche application : SessionLimitService + réconciliation T4) est VERT : `cargo test -p application` = tous binaires verts / 0 failed (16 nouveaux tests), zéro régression confirmée (drain_with_readiness_lot1 7/7, send_blocking_d1 9/9, reste du crate vert). Builds domaine+infra+application 0 warning. Le workspace complet ne compile TOUJOURS pas (inchangé : app-tauri chat.rs:185 → LS8, events.rs:261 → LS7). C'est toi qui décides quoi/quand committer/merger. État livré : Fichiers de prod LS4 : - crates/application/src/agent/session_limit.rs (NOUVEAU : SessionLimitService + port AgentResumer + const RESUME_PROMPT) - crates/application/src/agent/structured.rs (réconciliation T4 : enum TurnOutcome + drain_with_readiness_outcome ; signatures historiques préservées) - crates/application/src/agent/mod.rs (mod + re-exports) - crates/application/src/lib.rs (re-exports) Tests QA : crates/application/tests/session_limit_service.rs (9) + crates/application/tests/session_limit_t4.rs (7) (NOUVEAUX). Runtime .ideai/ à exclure. Décide et exécute (commit LS4 sur la feature ? message ? merge ou pas ?). Pour mémoire : le workspace ne compile de bout en bout qu'après LS7 (events app-tauri) + LS8 (chat.rs) ; le premier merge vers develop reste donc subordonné à ta règle d'or « tranche qui compile ». Confirme hash + état git.
- **Response:** Décision (agent Git) : **commit LS4 sur la feature, PAS de merge vers `develop`.** **Commit : `9000b4d`** `feat(session-limits): LS4 — service application + réconciliation T4` **Contenu (6 fichiers, 4 prod + 2 tests / +987 lignes) :** - `crates/application/src/agent/session_limit.rs` (nouveau : SessionLimitService + port AgentResumer + RESUME_PROMPT) - `crates/application/src/agent/structured.rs` (T4 : enum TurnOutcome + drain_with_readiness_outcome, signatures historiques préservées) - `crates/application/src/agent/mod.rs` + `crates/application/src/lib.rs` (modules/re-exports) - `crates/application/tests/session_limit_service.rs` (9) + `crates/application/tests/session_limit_t4.rs` (7) — nouveaux Runtime `.ideai/` **exclu**. QA : `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. **Décision de merge : toujours NON.** La tranche LS1→LS4 est verte par crate, mais `cargo build --workspace` échoue encore (app-tauri : `chat.rs:185`→LS8, `events.rs:261`→LS7). Ma règle d'or impose une tranche qui **compile de bout en bout** avant tout `feature/* → develop`. Comme tu me l'as confirmé, le workspace ne sera vert qu'après LS7+LS8 → **le premier merge se fera donc sur LS1→LS8** (et non LS1→LS4 comme envisagé initialement). On accumule jusque-là sur la branche feature. **État git (LOCAL, aucun push) :** ``` feature/agent-session-limits @ 9000b4d (HEAD) └ 253310b LS3 · a1755e5 LS2 · 0bf1eb3 LS1 · fa5b826 cadrage └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0) ``` Dev peut enchaîner sur LS5 (parser regex). Je tranche le merge dès `cargo build --workspace` vert.
- **Prompt:** LS5 (parser regex niveau 2, infra) est VERT : `cargo test -p infrastructure` = 221 passed / 0 failed (26 nouveaux), 0 warning, LS2/LS3 toujours verts (refactor timeparse non régressif). C'est toi qui décides commit/merge. Fichiers de prod LS5 : - crates/infrastructure/Cargo.toml (dépendance regex = "1", résout 1.12.3 déjà dans Cargo.lock) - crates/infrastructure/src/ratelimit/mod.rs (NOUVEAU : RateLimitParser + applies) - crates/infrastructure/src/timeparse.rs (NOUVEAU : parsing d'heure pur partagé, factorisé depuis LS2) - crates/infrastructure/src/session/claude.rs (délègue à timeparse — refactor) - crates/infrastructure/src/lib.rs (pub mod ratelimit + timeparse, re-export) Modifs de TEST QA : crates/infrastructure/src/ratelimit/mod.rs (#[cfg(test)] 15 tests), crates/infrastructure/src/timeparse.rs (#[cfg(test)] 11 tests), crates/infrastructure/src/scheduler/mod.rs (2 warnings irrefutable if let corrigés dans le code de test). NOTE : Cargo.lock peut être inchangé (regex déjà verrouillée) — inclus-le seulement s'il bouge. Runtime .ideai/ à exclure. Décide et exécute (commit LS5 ? message ? merge ?). Pour mémoire le workspace ne compile de bout en bout qu'après LS7+LS8. Confirme hash + état git.
- **Prompt:** Feature session-limits, suite. Project root: /home/anthony/Documents/Projects/IdeA. Deux lots sont terminés et VERTS (workspace compile, `cargo test --workspace` tout vert, clippy propre sur le nouveau code) mais NON committés : - LS5 — détecteur niveau 2 déclaratif + parsing temps partagé. Fichiers : nouveau `crates/infrastructure/src/ratelimit/mod.rs` (RateLimitParser, regex confiné infra), nouveau `crates/infrastructure/src/timeparse.rs` (parsing d'heures pur, partagé niveau 1/2), modifs `crates/infrastructure/src/session/claude.rs` (refactor vers timeparse, ~-121 lignes), `crates/infrastructure/src/scheduler/mod.rs`, `crates/infrastructure/src/lib.rs` (exports), `crates/infrastructure/Cargo.toml` (dép `regex`), `Cargo.lock`. - LS6 — câblage des 5 nouvelles variantes DomainEvent (AgentRateLimited/ResumeScheduled/ResumeCancelled/Resumed/RateLimitSuspected) + ReplyEvent::RateLimited vers le front. Fichiers : `crates/app-tauri/src/events.rs` (DTO miroir + bras From + test relais), `crates/app-tauri/src/chat.rs` (chunk_from_event). Note : l'arbre contient aussi des fichiers runtime .ideai/ modifiés (conversations/*/handoff.md, log.jsonl, layouts.json, memory/) et un nouveau memory `git-owns-commit-merge-decisions.md` — à toi de juger ce qui entre ou non dans le(s) commit(s) applicatif(s) (rappel : les LS précédents committaient uniquement le code applicatif, pas le bruit runtime). La branche courante est détachée (HEAD), pas sur develop. À toi de décider : topologie de branche, découpage en un ou deux commits (LS5 puis LS6, ou groupés), messages dans le style des commits feat(session-limits) existants, et si un merge feature/* → develop doit suivre. Périmètre LOCAL uniquement (aucun push). Exécute ta décision et rends via idea_reply le détail (branche, sha, messages, état final de `git status`).
- **Response:** Fait. Décisions et exécution : **Branche** — Contrairement à ce qui était annoncé, HEAD n'était pas détachée : on était bien sur `feature/agent-session-limits` (la branche de la feature). Aucune création/bascule nécessaire, le dev se poursuit dessus. Rien à corriger. **Découpage — 2 commits atomiques** (LS5 puis LS6, séparés car deux intentions distinctes : couche infra de détection vs câblage événementiel vers le front) : 1. `98bfcf4` — feat(session-limits): LS5 — détecteur niveau 2 déclaratif + parsing temps partagé Fichiers : `crates/infrastructure/src/ratelimit/mod.rs` (nouv.), `crates/infrastructure/src/timeparse.rs` (nouv.), `crates/infrastructure/src/session/claude.rs`, `crates/infrastructure/src/scheduler/mod.rs`, `crates/infrastructure/src/lib.rs`, `crates/infrastructure/Cargo.toml`, `Cargo.lock`. 2. `ea94e75` — feat(session-limits): LS6 — câblage des événements de limite vers le front Fichiers : `crates/app-tauri/src/events.rs`, `crates/app-tauri/src/chat.rs`. Messages en français, style Conventional Commits cohérent avec LS1→LS4, corps expliquant le pourquoi, co-author inclus. **Bruit runtime exclu** — Non committé, comme pour les LS précédents : `.ideai/conversations/*` (handoff.md, log.jsonl), `.ideai/layouts.json`, `.ideai/memory/` (dont MEMORY.md et le nouveau `git-owns-commit-merge-decisions.md`), et la nouvelle conversation `6bc594e8-…`. Ce sont des artefacts d'exécution, pas du code applicatif. **Merge feature/* → develop : NON.** La feature session-limits est livrée par incréments (série LS en cours) ; LS5/LS6 sont verts mais internes (détecteur niveau 2 + relais d'événements) sans surface produit complète. On reste sur `feature/agent-session-limits`. Le merge vers `develop` se fera quand la feature sera terminée et toujours verte. **Périmètre** — 100% local, aucun push. **État final `git status`** : working tree propre côté applicatif ; ne subsistent que les fichiers runtime `.ideai/` modifiés/non suivis (volontairement hors commit). Derniers commits : ea94e75 LS6 / 98bfcf4 LS5 / 9000b4d LS4.
- **Prompt:** Slice LS7 (backend / câblage app-tauri) de la feature session-limits terminée et verte. On est sur la branche feature/agent-session-limits (HEAD détaché ? `git status` montre HEAD), dernier commit ea94e75 (LS6). Contenu à committer (tout est vert, aucun warning) : - crates/application/src/agent/lifecycle.rs : `LaunchAgentOutput.profile` exposé (None sur réattache/idempotent, Some sur lancement effectif). - crates/application/src/terminal/registry.rs : `StructuredSessions::meta_for_session()` (lookup agent/node par SessionId pour le tap niveau 1). - crates/app-tauri/src/state.rs : `ResumeContext`/`ResumeContexts`, `AppAgentResumer` (impl port `AgentResumer` au-dessus de LaunchAgent), instanciation+câblage du `SessionLimitService` (TokioScheduler + drain des réveils) dans `AppState::build`. - crates/app-tauri/src/commands.rs : taps niveau 1 (agent_send) et niveau 2 (launch_agent, parser regex confiné), alimentation de `resume_contexts`, nouvelle commande `cancel_resume`. - crates/app-tauri/src/lib.rs : enregistrement de `cancel_resume` dans le handler. - crates/app-tauri/Cargo.toml : dépendance `async-trait`. - crates/app-tauri/tests/session_limit_wiring.rs : 2 tests d'intégration (composition) — verts. - crates/app-tauri/tests/dto_agents.rs + dto_chat.rs : ajustement `profile: None` dans les fixtures. - crates/application/tests/structured_registry_d1.rs : test de `meta_for_session`. Hors périmètre code (à NE PAS committer dans ce commit applicatif) : .ideai/conversations/**, .ideai/layouts.json, .ideai/memory/** — ce sont des artefacts runtime/mémoire. Questions : 1) Faut-il committer cette slice LS7-backend maintenant, et sous quel message ? 2) Reste-t-on sur feature/agent-session-limits ? 3) Décides-tu d'un merge vers develop, ou on attend LS7-front (React/TS, badge + compte à rebours + filet humain) qui complète la surface produit ? Tranche et exécute le commit applicatif si tu juges que oui.
- **Response:** Décision Git — LS7 backend committée, on reste en feature, pas de merge maintenant. État initial : on était bien **sur la branche** `feature/agent-session-limits` (pas de HEAD détaché — le « HEAD » de ton `git status` était juste l'en-tête francisé), HEAD = ea94e75 (LS6). 1) ✅ Commit fait. Hash **9df5923** : `feat(session-limits): LS7 — câblage backend app-tauri (taps niveaux 1&2 + reprise annulable)` - 10 fichiers, +406/3, dont la création de `tests/session_limit_wiring.rs`. - Périmètre strictement applicatif : lifecycle.rs, registry.rs, state.rs, commands.rs, lib.rs, Cargo.toml + les 4 fichiers de tests. - **Exclus comme demandé** (restés non-stagés, ce sont des artefacts runtime/mémoire) : `.ideai/conversations/**`, `.ideai/layouts.json`, `.ideai/memory/**` (dont le nouveau `git-owns-commit-merge-decisions.md` et le `6bc594e8…/` non suivi). Commit atomique = code seulement. 2) ✅ On **reste sur `feature/agent-session-limits`**. La feature n'est pas terminée côté produit (il manque LS7-front), donc on continue dessus. 3) ❌ **Pas de merge vers `develop` maintenant.** Raison : la slice est verte mais la surface produit session-limits est **incomplète sans LS7-front** (badge + compte à rebours + filet humain). Merger le backend seul livrerait dans `develop` une mécanique de reprise annulable sans son UI de contrôle/annulation visible — état intermédiaire que je ne veux pas figer dans l'intégration. **On attend LS7-front.** Une fois le front implémenté et la suite complète verte, reviens vers moi : je rebase si besoin sur `develop`, puis merge `--no-ff` la feature entière d'un coup (backend + front cohérents). Prochaine étape côté toi : enchaîner LS7-front (React/TS) sur cette même branche. Quand c'est vert, ping-moi pour le merge d'intégration.
- **Prompt:** Slice LS7-front (React/TS, UI limites de session) terminée et VERTE sur `feature/agent-session-limits`. État tests : `npx vitest run src/features/agents/` → 4 fichiers, 63 tests passés (39 existants + 24 nouveaux), 0 régression. Typecheck `tsc --noEmit` propre. Fichiers à committer (frontend/ uniquement) : - src/domain/index.ts — 5 variantes ajoutées au union DomainEvent (agentRateLimited/ResumeScheduled/ResumeCancelled/Resumed/RateLimitSuspected). - src/ports/index.ts — `cancelResume(agentId)` ajouté à InputGateway. - src/adapters/input.ts — TauriInputGateway.cancelResume → invoke("cancel_resume", { agentId }). - src/adapters/mock/index.ts — MockInputGateway.cancelResume (cancelledResumes/cancelResumeResult). - src/features/agents/useAgents.ts — état limitByAgent + action cancelResume. - src/features/agents/AgentLimitBadge.tsx — NOUVEAU (badge + compte à rebours + bouton Annuler + helpers purs). - src/features/agents/AgentsPanel.tsx — câblage du badge. - src/features/agents/useAgentsLimits.test.tsx — NOUVEAU (13 tests, QA). - src/features/agents/AgentLimitBadge.test.tsx — NOUVEAU (11 tests, QA). Hors périmètre (NE PAS committer) : .ideai/** (artefacts runtime/mémoire). Aucun fichier backend touché. NOTE : il existe un test PRÉEXISTANT rouge SANS rapport avec cette slice — `src/adapters/mock/mock.test.ts:16` asserte « thirteen gateways » alors qu'il y en a 14 (gateway `permission` déjà présente dans HEAD committé eca2ba9). Compteur périmé, pas causé par LS7-front. À toi de voir si tu veux que je fasse corriger ce compteur (tâche séparée DevFrontend) avant/après — dis-moi. Questions : 1) Committe la slice LS7-front maintenant ? Sous quel message ? 2) Le merge --no-ff de la feature complète vers develop : on attend que je tranche le dernier point de scope (filet humain niveau 3 — voir consultation Architect en cours : peut nécessiter une slice backend+front supplémentaire `set_resume_at`), donc NE merge pas encore vers develop — confirme que tu te limites au commit de cette slice et qu'on garde le merge pour quand je te recontacte « feature complète ». Tranche et exécute le commit.
- **Response:** Décision Git — LS7-front committée, pas de merge develop (j'attends ton « feature complète »). 1) ✅ Commit fait. Hash **4fad042** : `feat(session-limits): LS7-front — UI limites de session (badge + compte à rebours + filet humain)` - 9 fichiers, +702/1, dont 3 créations (AgentLimitBadge.tsx + ses 2 fichiers de tests). - Périmètre strictement frontend : domain/index.ts, ports/index.ts, adapters/input.ts, adapters/mock/index.ts, useAgents.ts, AgentLimitBadge.tsx, AgentsPanel.tsx + les 2 tests. - **Exclus comme demandé** (non-stagés) : `.ideai/**` (conversations, layouts, memory) et `node_modules/` (non suivi — au passage, il traîne en untracked ; à gitignorer dans une tâche d'hygiène séparée si ce n'est pas déjà couvert, mais hors périmètre ici). Aucun fichier backend touché. Commit atomique = frontend seulement. 2) ✅ Confirmé : **je me limite à ce commit, pas de merge vers `develop`.** Je garde le `--no-ff` d'intégration de la feature complète pour quand tu me recontactes « feature complète », une fois tranché le scope du filet humain niveau 3 (slice `set_resume_at` éventuelle, cf. consultation Architect). Concernant le **test préexistant rouge** `mock.test.ts:16` (« thirteen gateways » → 14 réelles, gateway `permission` déjà dans HEAD eca2ba9) : compteur périmé sans rapport avec LS7. Mon avis Git : à corriger **avant le merge d'intégration**, en **commit séparé** (`fix(test): …` ou `chore(test): …`), pour ne pas merger `develop` avec une suite rouge. Lance une tâche DevFrontend dédiée quand tu veux ; tu peux l'enchaîner maintenant ou la grouper avec l'éventuelle slice `set_resume_at` — dans tous les cas elle aura son propre commit, et je ne mergerai pas tant que toute la suite n'est pas verte. État branche : `feature/agent-session-limits` → … ea94e75 (LS6) · 9df5923 (LS7 back) · **4fad042 (LS7 front, HEAD)**.
- **Prompt:** Feature session-limits COMPLÈTE (3 niveaux) et verte. Reste à committer 2 slices, puis on peut envisager le merge d'intégration `feature/agent-session-limits → develop` que tu gardais en attente. État branche actuel : … 9df5923 (LS7 back) · 4fad042 (LS7 front, HEAD). Travail NON committé depuis, à committer maintenant en 2 commits séparés : ═══ COMMIT A — LS8-backend (filet humain niveau 3, backend) ═══ Implémenté par DevBackend, testé par QA (application: 15 tests session_limit_service / app-tauri: 4 wiring, + régressions vertes, 0 failed). Fichiers : - crates/application/src/agent/session_limit.rs — refactor privé `arm_scheduled` (param `resets_at_ms` brut ajouté) partagé par `on_rate_limited` + nouvelle `pub fn confirm_human_resume(agent_id, node_id, conversation_id, resets_at_ms: i64)` (source Human, réutilise la branche Scheduled, annulable). - crates/app-tauri/src/commands.rs — nouvelle commande `set_resume_at(agent_id, resets_at_ms) -> Result<(), ErrorDto>` (résout node_id via node_for_agent + conversation_id best-effort, NOT_FOUND si pas de cellule vivante). - crates/app-tauri/src/lib.rs — `set_resume_at` enregistrée après `cancel_resume`. - crates/application/tests/session_limit_service.rs — +6 tests (QA). - crates/app-tauri/tests/session_limit_wiring.rs — +2 tests (QA). Aucun événement nouveau (réutilise AgentRateLimited + AgentResumeScheduled). ═══ COMMIT B — LS8-front + fix test (DevFrontend a demandé 2 commits ; à toi de voir si tu sépares ou regroupes) ═══ LS8-front (typecheck propre, 109 tests verts) : - frontend/src/ports/index.ts — `setResumeAt(agentId, resetsAtMs)` sur InputGateway. - frontend/src/adapters/input.ts — `setResumeAt` → invoke("set_resume_at", { agentId, resetsAtMs }). - frontend/src/adapters/mock/index.ts — MockInputGateway.setResumeAt (resumeArmings[]). - frontend/src/features/agents/useAgents.ts — action setResumeAt (sans mutation optimiste). - frontend/src/features/agents/AgentLimitBadge.tsx — formulaire de saisie d'heure sur l'état suspected sans heure + helper pur `timeInputToEpochMs` ; TODO LS7 retiré. - frontend/src/features/agents/AgentsPanel.tsx — câblage onSetResumeAt. - frontend/src/features/agents/AgentLimitBadge.test.tsx — tests mis à jour au nouveau contrat + couverture LS8. Fix test hygiène (DevFrontend recommande commit SÉPARÉ) : - frontend/src/adapters/mock/mock.test.ts — compteur gateways 13→14 (« thirteen »→« fourteen » + `permission` dans l'array). Vérifié : 14 gateways réelles. Hors périmètre (NE PAS committer) : .ideai/** (runtime/mémoire), node_modules/ (untracked — DevFrontend signalait qu'il traîne, à gitignorer dans une tâche d'hygiène séparée, hors périmètre ici). QUESTIONS : 1) Committe COMMIT A puis les commits front (LS8-front + fix-test, séparés ou groupés selon ton jugement). Donne-moi les hashes + messages. 2) La suite complète est maintenant verte (Rust: domain/application/app-tauri ; front: agents + adapters/mock 109 tests) et la surface produit session-limits est complète sur les 3 niveaux. Décides-tu MAINTENANT le merge `--no-ff` vers develop (rebase si besoin) ? Si oui, exécute-le et donne-moi le résultat. Si tu veux un dernier garde-fou (ex. run de la suite complète des deux côtés avant merge), dis-le et je le fais lancer.
- **Response:** ✅ 3 commits + merge d'intégration faits. La feature session-limits est intégrée dans `develop`. ═══ 1) Les 3 commits (sur feature/agent-session-limits) ═══ J'ai séparé le fix-test de la feature (hygiène isolée, comme recommandé par DevFrontend) → 3 commits : - **c480d28** `feat(session-limits): LS8-backend — filet humain niveau 3 (set_resume_at)` (session_limit.rs + commands.rs + lib.rs + tests service/wiring ; 5 fichiers, +394/18) - **5d9dd32** `feat(session-limits): LS8-front — filet humain niveau 3 (saisie d'heure de reprise)` (ports/input/mock + useAgents + AgentLimitBadge(.tsx/.test) + AgentsPanel ; 7 fichiers, +222/26) - **3f3504e** `fix(test): corrige le compteur de gateways du mock (13 → 14)` (mock.test.ts isolé ; 1 fichier, +2/1) Artefacts `.ideai/**` et `node_modules/` exclus comme demandé. ═══ 2) Merge `--no-ff` → develop : OUI, exécuté ═══ Décision : la surface produit est complète sur les 3 niveaux et tout est vert des deux côtés → je merge. Pas de rebase nécessaire : `feature` était **12 ahead / 0 behind** `develop` (aucune divergence, base linéaire). - Merge commit : **d7041c5** `merge(session-limits): intégration de la feature limites de session (3 niveaux)` — stratégie ort, **0 conflit**, 47 fichiers / +5157/45 (cumul LS2→LS8). - `develop` est maintenant à **d7041c5**, contenant toute la chaîne LS2 (a1755e5) → LS8 (3f3504e). - La branche `feature/agent-session-limits` est **conservée** (pas supprimée) : je la garde tant que tu n'as pas confirmé que l'intégration est définitivement validée. Dis-moi si tu veux que je la supprime maintenant. Note d'exécution : les artefacts runtime `.ideai/**` non committés bloquaient le switch de branche → je les ai **stashés le temps du merge puis restaurés** (stash poppé, working tree intact, rien perdu). Aucun de ces artefacts n'est entré dans les commits applicatifs ni dans le merge. ⚠️ Périmètre **strictement local** : aucun `push`. `develop` local est en avance de 58 commits sur `origin/develop` — je ne pousse rien sans ta validation explicite. Prochaines options possibles, à ta main : suppression de `feature/agent-session-limits`, et/ou push de `develop` vers origin (action sortante → nécessite ton feu vert).

View File

@ -1,21 +0,0 @@
{"id":"28eded2d-7fd7-48cf-bc4a-c38a89ed06e3","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781612485902,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Nouvelle feature cadrée par Architect, prête à démarrer le développement : « Gestion des limites de session des agents » (détection hiérarchique des limites de session + reprise auto annulable). C'est toi qui décides de la topologie de branche — je te sollicite, tu tranches.\n\nContexte dépôt : HEAD actuel sur le commit de release 0.1.0 (9a8a89f « release(0.1.0) : intégration de develop »). Modèle de branches du projet : main (release) ← develop (intégration) ← feature/* (une par feature).\n\nLa feature sera livrée en lots ordonnés LS1→LS8 (domaine → adapter Claude → port Scheduler → service application → parser regex → filet humain → app-tauri → frontend), chacun passant par le cycle dev/test (tests verts avant de continuer). État en mémoire uniquement, aucun schéma de persistance modifié.\n\nDécide et exécute (périmètre LOCAL uniquement — aucun push) : faut-il créer une branche feature/* (et depuis quelle base — develop ?), faut-il d'abord (re)créer/checkout develop depuis main, ou rester en place ? Donne-moi le nom de branche retenu et confirme l'état git après ta manœuvre."}
{"id":"4309abd6-8ded-4641-9a46-af523f0f1af6","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781612520039,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Décision (agent Git) — branche retenue : **`feature/agent-session-limits`**, créée **depuis `develop`**.\n\n**Raisonnement :**\n- Nouvelle feature indépendante (LS1→LS8) → conformément au modèle `main ← develop ← feature/*`, elle se développe sur sa propre branche `feature/*`.\n- Pas besoin de (re)créer `develop` : il existe déjà et est à jour (post-release 0.1.0, `git diff main develop` vide → contenu identique). C'est donc la bonne base de départ.\n- Nom dérivé du sujet, kebab-case : `feature/agent-session-limits`.\n\n**État git après manœuvre (LOCAL, aucun push) :**\n- Branche courante : `feature/agent-session-limits` @ `401c18a` (= base `develop`).\n- `develop` @ `401c18a` (inchangé) ← `main` @ `9a8a89f` (tag `v0.1.0`, inchangé).\n- `feature/agent-skills` @ `ef101db` (autre feature, intacte).\n\n**Note :** le working tree porte déjà des changements non commités hérités de l'état courant — notamment `ARCHITECTURE.md` (cadrage Architect) et un nouveau `.ideai/memory/session-limit-handling-design.md`, plus du runtime `.ideai/` (conversations/MEMORY.md). Ils n'ont pas été commités : DevBackend/QA les intégreront dans les commits de la feature au fil des lots. Le cadrage Architect (ARCHITECTURE.md + memory design) pourra faire le **premier commit** de la branche avant LS1 si tu le souhaites — dis-moi si tu veux que je le commite maintenant.\n\nDev peut démarrer LS1 (domaine) sur `feature/agent-session-limits`. Après chaque lot vert, reviens vers moi pour décider d'un éventuel commit/merge. Aucune intégration vers `develop` tant que les tests des lots concernés ne sont pas verts (règle d'or)."}
{"id":"8edd3bc3-3024-46b6-929b-2aac1c1f7fdc","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781612678980,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Feu vert pour le premier commit de la branche feature/agent-session-limits : commite le cadrage AVANT que le dev (LS1) ne démarre, pour isoler proprement le cadrage du code.\n\nContenu à committer (cadrage uniquement) : ARCHITECTURE.md (nouvelle §21) + .ideai/memory/session-limit-handling-design.md + la mise à jour de .ideai/memory/MEMORY.md (pointeur).\n\nN'inclus PAS le runtime .ideai/ non pertinent (conversations, layouts, agents.json…) s'il pollue le diff — limite le commit aux fichiers du cadrage. Message de commit conforme au style du dépôt (français, conventionnel). Périmètre LOCAL uniquement — aucun push. Confirme le hash du commit et l'état git après."}
{"id":"bdcd087a-9481-4a06-a8a9-20881cb93077","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781612714298,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Cadrage committé sur `feature/agent-session-limits` — premier commit de la branche, isolé du code.\n\n**Commit : `fa5b826`**\n`docs(session-limits): cadrage Architect — gestion des limites de session des agents`\n\n**Contenu (cadrage uniquement, 3 fichiers / +212 lignes) :**\n- `ARCHITECTURE.md` (+185, nouvelle §21)\n- `.ideai/memory/session-limit-handling-design.md` (nouveau, design validé)\n- `.ideai/memory/MEMORY.md` (+1, pointeur)\n\nLe runtime `.ideai/` non pertinent (conversations, layouts, agents.json…) a été **exclu** du diff comme demandé.\n\n**État git après commit (LOCAL, aucun push) :**\n- `feature/agent-session-limits` @ `fa5b826` ← `develop` @ `401c18a` ← `main` @ `9a8a89f` (tag `v0.1.0`).\n- Working tree : reste uniquement du runtime `.ideai/conversations/…` non commité (bruit, ignoré pour le cadrage).\n\nLa branche est prête : le dev peut démarrer **LS1 (domaine)**. Je reviendrai dans la boucle pour committer chaque lot vert."}
{"id":"45759b66-4412-4756-aa61-9f2388dae1b4","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781613219445,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"LS1 (couche domaine) de la feature « limites de session » est VERT : DevBackend a livré le code pur, QA a écrit 24 tests, `cargo test -p domain` = 165 passed / 0 failed, zéro régression.\n\nCommite LS1 sur feature/agent-session-limits (commit suivant après le cadrage fa5b826). Périmètre LOCAL uniquement, aucun push.\n\nFichiers de prod (domaine) modifiés/créés par DevBackend :\n- crates/domain/src/ports.rs (variante ReplyEvent::RateLimited)\n- crates/domain/src/readiness.rs (variante ReadinessSignal::RateLimited + classify)\n- crates/domain/src/session_limit.rs (NOUVEAU : SessionLimit, ResumePlan, RateLimitSource, plan_resume)\n- crates/domain/src/profile.rs (RateLimitPattern + champ + builder)\n- crates/domain/src/events.rs (5 variantes DomainEvent)\n- crates/domain/src/lib.rs (mod + re-exports)\nPlus les tests QA dans ces mêmes fichiers (#[cfg(test)]).\n\nN'inclus PAS le runtime .ideai/ non pertinent. Message conventionnel en français.\n\nIMPORTANT — décision de merge : NE merge PAS vers develop maintenant. Le workspace complet (`cargo build --workspace`) NE COMPILE PAS tant que LS2 n'a pas ajouté l'arm `ReplyEvent::RateLimited` côté infra (match exhaustifs). On ne merge feature/* → develop que sur une tranche qui compile de bout en bout (cible : LS1+LS2+LS4 = niveau 1 Claude complet). Confirme juste le hash du commit LS1 et l'état git."}
{"id":"6f923008-d23b-4c0f-8118-0b0bd82baeb1","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781613250449,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"LS1 (couche domaine) committé sur `feature/agent-session-limits`, après le cadrage `fa5b826`.\n\n**Commit : `0bf1eb3`**\n`feat(session-limits): LS1 — couche domaine (détection + plan de reprise)`\n\n**Contenu (6 fichiers domaine, prod + tests inline / +647 lignes) :**\n- `crates/domain/src/session_limit.rs` (nouveau)\n- `crates/domain/src/ports.rs` (ReplyEvent::RateLimited)\n- `crates/domain/src/readiness.rs` (ReadinessSignal::RateLimited + classify)\n- `crates/domain/src/profile.rs` (RateLimitPattern + champ + builder)\n- `crates/domain/src/events.rs` (5 variantes DomainEvent)\n- `crates/domain/src/lib.rs` (mod + re-exports)\n\nRuntime `.ideai/` non pertinent **exclu**.\nQA : `cargo test -p domain` = 165 passed / 0 failed, zéro régression.\n\n**Décision de merge : PAS de merge vers `develop`** — conforme à ta consigne. Le workspace ne compile pas tant que LS2 n'a pas ajouté l'arm `ReplyEvent::RateLimited` côté infra (match exhaustifs). On n'intègre `feature/* → develop` que sur une tranche qui compile de bout en bout (cible : LS1+LS2+LS4 = niveau 1 Claude complet). Je reste sur la branche feature.\n\n**État git (LOCAL, aucun push) :**\n```\nfeature/agent-session-limits @ 0bf1eb3 (HEAD)\n └ fa5b826 docs cadrage\n └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0)\n```\nWorking tree : seul du runtime `.ideai/conversations/…` non commité (ignoré). Dev peut enchaîner sur LS2."}
{"id":"8cd389e5-7f84-4737-af3a-207438d2fc62","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781613878608,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"LS2 (adapter Claude niveau 1, infra) est VERT : `cargo test -p infrastructure` = 188 passed / 0 failed, zéro régression. Commite LS2 sur feature/agent-session-limits (après LS1 0bf1eb3). LOCAL uniquement, aucun push.\n\nFichier de prod modifié : crates/infrastructure/src/session/claude.rs (parse_event émet ReplyEvent::RateLimited ; nouvelle fonction pure parse_reset_ms + helpers ; parseur ISO maison ; doccomments T4).\nTests QA : dans crates/infrastructure/src/session/mod.rs (#[cfg(test)]) — 26 nouveaux tests + 2 tests existants mis à jour vers le nouveau contrat ; et crates/infrastructure/src/session/conformance.rs (#[cfg(test)] : RateLimited ajouté aux événements non terminaux autorisés).\n\nExclu le runtime .ideai/. Message conventionnel français.\n\nDécision merge : TOUJOURS PAS de merge vers develop (le workspace ne compile pas encore — 2 erreurs résiduelles attendues dans app-tauri, relèvent de LS7/LS8 ; et la réconciliation T4 reste à faire en LS4). On vise la tranche LS1+LS2+LS3+LS4 = niveau 1 Claude de bout en bout qui compile, pour le premier merge. Confirme le hash LS2 et l'état git."}
{"id":"38531764-1e1c-414c-93a9-266cf6847444","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781613904809,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"LS2 (adapter Claude niveau 1, infra) committé sur `feature/agent-session-limits`, après LS1 `0bf1eb3`.\n\n**Commit : `a1755e5`**\n`feat(session-limits): LS2 — adapter Claude niveau 1 (infra)`\n\n**Contenu (3 fichiers infra, prod + tests inline / +545 lignes) :**\n- `crates/infrastructure/src/session/claude.rs` (prod : parse_event → ReplyEvent::RateLimited, parse_reset_ms + helpers, parseur ISO maison, doccomments T4)\n- `crates/infrastructure/src/session/mod.rs` (26 nouveaux tests QA + 2 alignés)\n- `crates/infrastructure/src/session/conformance.rs` (RateLimited en non-terminal autorisé)\n\nRuntime `.ideai/` **exclu**.\nQA : `cargo test -p infrastructure` = 188 passed / 0 failed, zéro régression.\n\n**Décision de merge : TOUJOURS PAS de merge vers `develop`** — conforme. Le workspace ne compile pas encore (2 erreurs résiduelles attendues dans app-tauri → LS7/LS8 ; réconciliation T4 → LS4). Premier merge visé sur la tranche **LS1+LS2+LS3+LS4 = niveau 1 Claude de bout en bout qui compile**.\n\n**État git (LOCAL, aucun push) :**\n```\nfeature/agent-session-limits @ a1755e5 (HEAD)\n └ 0bf1eb3 LS1 domaine\n └ fa5b826 cadrage\n └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0)\n```\nWorking tree : seul du runtime `.ideai/` non commité (ignoré). Dev peut enchaîner sur LS3."}
{"id":"49eb0b0d-ee02-4497-b676-127436f10222","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781615248432,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"LS3 (port Scheduler + adapter TokioScheduler) est VERT : `cargo test -p infrastructure` = 195 passed / 0 failed (7 nouveaux tests scheduler, relancés 3× sans flaky), LS1/LS2 toujours verts. Builds domaine + infra OK 0 warning. Le workspace complet ne compile toujours pas (2 erreurs résiduelles inchangées dans app-tauri : chat.rs:185 → LS8, events.rs:261 → LS7 ; la réconciliation T4 reste en LS4).\n\nC'est TOI qui décides quoi et quand committer/merger. Je te livre l'état, tu tranches.\n\nFichiers de prod LS3 :\n- crates/domain/src/ids.rs (ScheduleId via typed_id!)\n- crates/domain/src/ports.rs (trait Scheduler + enum ScheduledTask)\n- crates/domain/src/lib.rs (re-exports)\n- crates/infrastructure/src/scheduler/mod.rs (NOUVEAU : TokioScheduler)\n- crates/infrastructure/src/lib.rs (pub mod scheduler + re-export)\nTests QA : dans crates/infrastructure/src/scheduler/mod.rs (#[cfg(test)]).\nRuntime .ideai/ à exclure du diff.\n\nDécide : faut-il committer LS3 maintenant (et avec quel message) ? Et concernant le merge vers develop — rappel du contexte : le premier merge envisagé portait sur la tranche LS1+LS2+LS3+LS4 (niveau 1 Claude de bout en bout qui compile), or le workspace ne compile pas encore (LS4 + app-tauri manquants). Dis-moi ta décision (commit LS3 sur la feature ? attendre la tranche compilable pour merger ? autre ?) et exécute-la. Confirme hash + état git."}
{"id":"acc7dd08-37bf-422e-8962-00778f60de6c","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781615283463,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Décision (agent Git) : **commit LS3 sur la feature, PAS de merge vers `develop`.**\n\n**Pourquoi :** chaque lot vert mérite son commit atomique sur la branche feature (traçabilité, bissection facile), mais le merge `feature/* → develop` exige une tranche qui **compile de bout en bout** — règle d'or. Or le workspace ne compile toujours pas (réconciliation T4 en LS4 + 2 erreurs résiduelles app-tauri : `chat.rs:185`→LS8, `events.rs:261`→LS7). Donc on continue d'accumuler sur la feature ; premier merge maintenu sur la tranche compilable (LS1+LS2+LS3+LS4 niveau 1 Claude, voire jusqu'à LS7/LS8 si la compilation workspace n'est verte qu'à ce moment-là).\n\n**Commit : `253310b`**\n`feat(session-limits): LS3 — port Scheduler + adapter TokioScheduler`\n\n**Contenu (5 fichiers prod + tests inline / +389 lignes) :**\n- domaine : `ports.rs` (trait Scheduler + enum ScheduledTask), `ids.rs` (ScheduleId), `lib.rs` (re-exports)\n- infra : `scheduler/mod.rs` (nouveau, TokioScheduler), `lib.rs` (pub mod + re-export)\n\nRuntime `.ideai/` **exclu**.\nQA : `cargo test -p infrastructure` = 195 passed / 0 failed (7 tests scheduler, 3× sans flaky) ; LS1/LS2 verts ; builds domaine+infra 0 warning.\n\n**État git (LOCAL, aucun push) :**\n```\nfeature/agent-session-limits @ 253310b (HEAD)\n └ a1755e5 LS2 adapter Claude\n └ 0bf1eb3 LS1 domaine\n └ fa5b826 cadrage\n └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0)\n```\nDev peut enchaîner sur LS4 (service application + réconciliation T4). Je tranche le merge dès que le workspace compile."}
{"id":"0465aea2-3a1c-486a-9afb-4ad04f91d6d0","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781628910047,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"LS4 (couche application : SessionLimitService + réconciliation T4) est VERT : `cargo test -p application` = tous binaires verts / 0 failed (16 nouveaux tests), zéro régression confirmée (drain_with_readiness_lot1 7/7, send_blocking_d1 9/9, reste du crate vert). Builds domaine+infra+application 0 warning. Le workspace complet ne compile TOUJOURS pas (inchangé : app-tauri chat.rs:185 → LS8, events.rs:261 → LS7).\n\nC'est toi qui décides quoi/quand committer/merger. État livré :\n\nFichiers de prod LS4 :\n- crates/application/src/agent/session_limit.rs (NOUVEAU : SessionLimitService + port AgentResumer + const RESUME_PROMPT)\n- crates/application/src/agent/structured.rs (réconciliation T4 : enum TurnOutcome + drain_with_readiness_outcome ; signatures historiques préservées)\n- crates/application/src/agent/mod.rs (mod + re-exports)\n- crates/application/src/lib.rs (re-exports)\nTests QA : crates/application/tests/session_limit_service.rs (9) + crates/application/tests/session_limit_t4.rs (7) (NOUVEAUX).\nRuntime .ideai/ à exclure.\n\nDécide et exécute (commit LS4 sur la feature ? message ? merge ou pas ?). Pour mémoire : le workspace ne compile de bout en bout qu'après LS7 (events app-tauri) + LS8 (chat.rs) ; le premier merge vers develop reste donc subordonné à ta règle d'or « tranche qui compile ». Confirme hash + état git."}
{"id":"f88d6a5a-0e2b-4cd8-bb09-aa4534bfb453","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781628940614,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Décision (agent Git) : **commit LS4 sur la feature, PAS de merge vers `develop`.**\n\n**Commit : `9000b4d`**\n`feat(session-limits): LS4 — service application + réconciliation T4`\n\n**Contenu (6 fichiers, 4 prod + 2 tests / +987 lignes) :**\n- `crates/application/src/agent/session_limit.rs` (nouveau : SessionLimitService + port AgentResumer + RESUME_PROMPT)\n- `crates/application/src/agent/structured.rs` (T4 : enum TurnOutcome + drain_with_readiness_outcome, signatures historiques préservées)\n- `crates/application/src/agent/mod.rs` + `crates/application/src/lib.rs` (modules/re-exports)\n- `crates/application/tests/session_limit_service.rs` (9) + `crates/application/tests/session_limit_t4.rs` (7) — nouveaux\n\nRuntime `.ideai/` **exclu**.\nQA : `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.\n\n**Décision de merge : toujours NON.** La tranche LS1→LS4 est verte par crate, mais `cargo build --workspace` échoue encore (app-tauri : `chat.rs:185`→LS8, `events.rs:261`→LS7). Ma règle d'or impose une tranche qui **compile de bout en bout** avant tout `feature/* → develop`. Comme tu me l'as confirmé, le workspace ne sera vert qu'après LS7+LS8 → **le premier merge se fera donc sur LS1→LS8** (et non LS1→LS4 comme envisagé initialement). On accumule jusque-là sur la branche feature.\n\n**État git (LOCAL, aucun push) :**\n```\nfeature/agent-session-limits @ 9000b4d (HEAD)\n └ 253310b LS3 · a1755e5 LS2 · 0bf1eb3 LS1 · fa5b826 cadrage\n └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0)\n```\nDev peut enchaîner sur LS5 (parser regex). Je tranche le merge dès `cargo build --workspace` vert."}
{"id":"dedd197e-7de5-4e64-8217-54be77e1e653","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781629554554,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"LS5 (parser regex niveau 2, infra) est VERT : `cargo test -p infrastructure` = 221 passed / 0 failed (26 nouveaux), 0 warning, LS2/LS3 toujours verts (refactor timeparse non régressif). C'est toi qui décides commit/merge.\n\nFichiers de prod LS5 :\n- crates/infrastructure/Cargo.toml (dépendance regex = \"1\", résout 1.12.3 déjà dans Cargo.lock)\n- crates/infrastructure/src/ratelimit/mod.rs (NOUVEAU : RateLimitParser + applies)\n- crates/infrastructure/src/timeparse.rs (NOUVEAU : parsing d'heure pur partagé, factorisé depuis LS2)\n- crates/infrastructure/src/session/claude.rs (délègue à timeparse — refactor)\n- crates/infrastructure/src/lib.rs (pub mod ratelimit + timeparse, re-export)\nModifs de TEST QA : crates/infrastructure/src/ratelimit/mod.rs (#[cfg(test)] 15 tests), crates/infrastructure/src/timeparse.rs (#[cfg(test)] 11 tests), crates/infrastructure/src/scheduler/mod.rs (2 warnings irrefutable if let corrigés dans le code de test).\nNOTE : Cargo.lock peut être inchangé (regex déjà verrouillée) — inclus-le seulement s'il bouge.\nRuntime .ideai/ à exclure.\n\nDécide et exécute (commit LS5 ? message ? merge ?). Pour mémoire le workspace ne compile de bout en bout qu'après LS7+LS8. Confirme hash + état git."}
{"id":"050c6d11-d9bb-4e0f-8bc8-cebae970cbe3","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781632920754,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Feature session-limits, suite. Project root: /home/anthony/Documents/Projects/IdeA. Deux lots sont terminés et VERTS (workspace compile, `cargo test --workspace` tout vert, clippy propre sur le nouveau code) mais NON committés :\n\n- LS5 — détecteur niveau 2 déclaratif + parsing temps partagé. Fichiers : nouveau `crates/infrastructure/src/ratelimit/mod.rs` (RateLimitParser, regex confiné infra), nouveau `crates/infrastructure/src/timeparse.rs` (parsing d'heures pur, partagé niveau 1/2), modifs `crates/infrastructure/src/session/claude.rs` (refactor vers timeparse, ~-121 lignes), `crates/infrastructure/src/scheduler/mod.rs`, `crates/infrastructure/src/lib.rs` (exports), `crates/infrastructure/Cargo.toml` (dép `regex`), `Cargo.lock`.\n- LS6 — câblage des 5 nouvelles variantes DomainEvent (AgentRateLimited/ResumeScheduled/ResumeCancelled/Resumed/RateLimitSuspected) + ReplyEvent::RateLimited vers le front. Fichiers : `crates/app-tauri/src/events.rs` (DTO miroir + bras From + test relais), `crates/app-tauri/src/chat.rs` (chunk_from_event).\n\nNote : l'arbre contient aussi des fichiers runtime .ideai/ modifiés (conversations/*/handoff.md, log.jsonl, layouts.json, memory/) et un nouveau memory `git-owns-commit-merge-decisions.md` — à toi de juger ce qui entre ou non dans le(s) commit(s) applicatif(s) (rappel : les LS précédents committaient uniquement le code applicatif, pas le bruit runtime).\n\nLa branche courante est détachée (HEAD), pas sur develop. À toi de décider : topologie de branche, découpage en un ou deux commits (LS5 puis LS6, ou groupés), messages dans le style des commits feat(session-limits) existants, et si un merge feature/* → develop doit suivre. Périmètre LOCAL uniquement (aucun push). Exécute ta décision et rends via idea_reply le détail (branche, sha, messages, état final de `git status`)."}
{"id":"8f8eec4e-104d-424d-b061-39fc93ad1bde","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781632975720,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Fait. Décisions et exécution :\n\n**Branche** — Contrairement à ce qui était annoncé, HEAD n'était pas détachée : on était bien sur `feature/agent-session-limits` (la branche de la feature). Aucune création/bascule nécessaire, le dev se poursuit dessus. Rien à corriger.\n\n**Découpage — 2 commits atomiques** (LS5 puis LS6, séparés car deux intentions distinctes : couche infra de détection vs câblage événementiel vers le front) :\n\n1. `98bfcf4` — feat(session-limits): LS5 — détecteur niveau 2 déclaratif + parsing temps partagé\n Fichiers : `crates/infrastructure/src/ratelimit/mod.rs` (nouv.), `crates/infrastructure/src/timeparse.rs` (nouv.), `crates/infrastructure/src/session/claude.rs`, `crates/infrastructure/src/scheduler/mod.rs`, `crates/infrastructure/src/lib.rs`, `crates/infrastructure/Cargo.toml`, `Cargo.lock`.\n\n2. `ea94e75` — feat(session-limits): LS6 — câblage des événements de limite vers le front\n Fichiers : `crates/app-tauri/src/events.rs`, `crates/app-tauri/src/chat.rs`.\n\nMessages en français, style Conventional Commits cohérent avec LS1→LS4, corps expliquant le pourquoi, co-author inclus.\n\n**Bruit runtime exclu** — Non committé, comme pour les LS précédents : `.ideai/conversations/*` (handoff.md, log.jsonl), `.ideai/layouts.json`, `.ideai/memory/` (dont MEMORY.md et le nouveau `git-owns-commit-merge-decisions.md`), et la nouvelle conversation `6bc594e8-…`. Ce sont des artefacts d'exécution, pas du code applicatif.\n\n**Merge feature/* → develop : NON.** La feature session-limits est livrée par incréments (série LS en cours) ; LS5/LS6 sont verts mais internes (détecteur niveau 2 + relais d'événements) sans surface produit complète. On reste sur `feature/agent-session-limits`. Le merge vers `develop` se fera quand la feature sera terminée et toujours verte.\n\n**Périmètre** — 100% local, aucun push.\n\n**État final `git status`** : working tree propre côté applicatif ; ne subsistent que les fichiers runtime `.ideai/` modifiés/non suivis (volontairement hors commit). Derniers commits :\nea94e75 LS6 / 98bfcf4 LS5 / 9000b4d LS4."}
{"id":"9c73943b-e258-4c2b-a124-f2eafffe3c5a","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781675740301,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Slice LS7 (backend / câblage app-tauri) de la feature session-limits terminée et verte. On est sur la branche feature/agent-session-limits (HEAD détaché ? `git status` montre HEAD), dernier commit ea94e75 (LS6).\n\nContenu à committer (tout est vert, aucun warning) :\n- crates/application/src/agent/lifecycle.rs : `LaunchAgentOutput.profile` exposé (None sur réattache/idempotent, Some sur lancement effectif).\n- crates/application/src/terminal/registry.rs : `StructuredSessions::meta_for_session()` (lookup agent/node par SessionId pour le tap niveau 1).\n- crates/app-tauri/src/state.rs : `ResumeContext`/`ResumeContexts`, `AppAgentResumer` (impl port `AgentResumer` au-dessus de LaunchAgent), instanciation+câblage du `SessionLimitService` (TokioScheduler + drain des réveils) dans `AppState::build`.\n- crates/app-tauri/src/commands.rs : taps niveau 1 (agent_send) et niveau 2 (launch_agent, parser regex confiné), alimentation de `resume_contexts`, nouvelle commande `cancel_resume`.\n- crates/app-tauri/src/lib.rs : enregistrement de `cancel_resume` dans le handler.\n- crates/app-tauri/Cargo.toml : dépendance `async-trait`.\n- crates/app-tauri/tests/session_limit_wiring.rs : 2 tests d'intégration (composition) — verts.\n- crates/app-tauri/tests/dto_agents.rs + dto_chat.rs : ajustement `profile: None` dans les fixtures.\n- crates/application/tests/structured_registry_d1.rs : test de `meta_for_session`.\n\nHors périmètre code (à NE PAS committer dans ce commit applicatif) : .ideai/conversations/**, .ideai/layouts.json, .ideai/memory/** — ce sont des artefacts runtime/mémoire.\n\nQuestions : 1) Faut-il committer cette slice LS7-backend maintenant, et sous quel message ? 2) Reste-t-on sur feature/agent-session-limits ? 3) Décides-tu d'un merge vers develop, ou on attend LS7-front (React/TS, badge + compte à rebours + filet humain) qui complète la surface produit ? Tranche et exécute le commit applicatif si tu juges que oui."}
{"id":"42185422-903a-4c25-8139-b63748f6dd73","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781675787986,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Décision Git — LS7 backend committée, on reste en feature, pas de merge maintenant.\n\nÉtat initial : on était bien **sur la branche** `feature/agent-session-limits` (pas de HEAD détaché — le « HEAD » de ton `git status` était juste l'en-tête francisé), HEAD = ea94e75 (LS6).\n\n1) ✅ Commit fait. Hash **9df5923** :\n`feat(session-limits): LS7 — câblage backend app-tauri (taps niveaux 1&2 + reprise annulable)`\n- 10 fichiers, +406/3, dont la création de `tests/session_limit_wiring.rs`.\n- Périmètre strictement applicatif : lifecycle.rs, registry.rs, state.rs, commands.rs, lib.rs, Cargo.toml + les 4 fichiers de tests.\n- **Exclus comme demandé** (restés non-stagés, ce sont des artefacts runtime/mémoire) : `.ideai/conversations/**`, `.ideai/layouts.json`, `.ideai/memory/**` (dont le nouveau `git-owns-commit-merge-decisions.md` et le `6bc594e8…/` non suivi). Commit atomique = code seulement.\n\n2) ✅ On **reste sur `feature/agent-session-limits`**. La feature n'est pas terminée côté produit (il manque LS7-front), donc on continue dessus.\n\n3) ❌ **Pas de merge vers `develop` maintenant.** Raison : la slice est verte mais la surface produit session-limits est **incomplète sans LS7-front** (badge + compte à rebours + filet humain). Merger le backend seul livrerait dans `develop` une mécanique de reprise annulable sans son UI de contrôle/annulation visible — état intermédiaire que je ne veux pas figer dans l'intégration. **On attend LS7-front.** Une fois le front implémenté et la suite complète verte, reviens vers moi : je rebase si besoin sur `develop`, puis merge `--no-ff` la feature entière d'un coup (backend + front cohérents).\n\nProchaine étape côté toi : enchaîner LS7-front (React/TS) sur cette même branche. Quand c'est vert, ping-moi pour le merge d'intégration."}
{"id":"4d281c8d-2a92-45b8-ac22-9a8fac5aafde","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781676394147,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Slice LS7-front (React/TS, UI limites de session) terminée et VERTE sur `feature/agent-session-limits`.\n\nÉtat tests : `npx vitest run src/features/agents/` → 4 fichiers, 63 tests passés (39 existants + 24 nouveaux), 0 régression. Typecheck `tsc --noEmit` propre.\n\nFichiers à committer (frontend/ uniquement) :\n- src/domain/index.ts — 5 variantes ajoutées au union DomainEvent (agentRateLimited/ResumeScheduled/ResumeCancelled/Resumed/RateLimitSuspected).\n- src/ports/index.ts — `cancelResume(agentId)` ajouté à InputGateway.\n- src/adapters/input.ts — TauriInputGateway.cancelResume → invoke(\"cancel_resume\", { agentId }).\n- src/adapters/mock/index.ts — MockInputGateway.cancelResume (cancelledResumes/cancelResumeResult).\n- src/features/agents/useAgents.ts — état limitByAgent + action cancelResume.\n- src/features/agents/AgentLimitBadge.tsx — NOUVEAU (badge + compte à rebours + bouton Annuler + helpers purs).\n- src/features/agents/AgentsPanel.tsx — câblage du badge.\n- src/features/agents/useAgentsLimits.test.tsx — NOUVEAU (13 tests, QA).\n- src/features/agents/AgentLimitBadge.test.tsx — NOUVEAU (11 tests, QA).\n\nHors périmètre (NE PAS committer) : .ideai/** (artefacts runtime/mémoire). Aucun fichier backend touché.\n\nNOTE : il existe un test PRÉEXISTANT rouge SANS rapport avec cette slice — `src/adapters/mock/mock.test.ts:16` asserte « thirteen gateways » alors qu'il y en a 14 (gateway `permission` déjà présente dans HEAD committé eca2ba9). Compteur périmé, pas causé par LS7-front. À toi de voir si tu veux que je fasse corriger ce compteur (tâche séparée DevFrontend) avant/après — dis-moi.\n\nQuestions : 1) Committe la slice LS7-front maintenant ? Sous quel message ? 2) Le merge --no-ff de la feature complète vers develop : on attend que je tranche le dernier point de scope (filet humain niveau 3 — voir consultation Architect en cours : peut nécessiter une slice backend+front supplémentaire `set_resume_at`), donc NE merge pas encore vers develop — confirme que tu te limites au commit de cette slice et qu'on garde le merge pour quand je te recontacte « feature complète ». Tranche et exécute le commit."}
{"id":"21dcba39-7c5a-4191-8ddb-f13ab7eed54e","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781676433750,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Décision Git — LS7-front committée, pas de merge develop (j'attends ton « feature complète »).\n\n1) ✅ Commit fait. Hash **4fad042** :\n`feat(session-limits): LS7-front — UI limites de session (badge + compte à rebours + filet humain)`\n- 9 fichiers, +702/1, dont 3 créations (AgentLimitBadge.tsx + ses 2 fichiers de tests).\n- Périmètre strictement frontend : domain/index.ts, ports/index.ts, adapters/input.ts, adapters/mock/index.ts, useAgents.ts, AgentLimitBadge.tsx, AgentsPanel.tsx + les 2 tests.\n- **Exclus comme demandé** (non-stagés) : `.ideai/**` (conversations, layouts, memory) et `node_modules/` (non suivi — au passage, il traîne en untracked ; à gitignorer dans une tâche d'hygiène séparée si ce n'est pas déjà couvert, mais hors périmètre ici). Aucun fichier backend touché. Commit atomique = frontend seulement.\n\n2) ✅ Confirmé : **je me limite à ce commit, pas de merge vers `develop`.** Je garde le `--no-ff` d'intégration de la feature complète pour quand tu me recontactes « feature complète », une fois tranché le scope du filet humain niveau 3 (slice `set_resume_at` éventuelle, cf. consultation Architect).\n\nConcernant le **test préexistant rouge** `mock.test.ts:16` (« thirteen gateways » → 14 réelles, gateway `permission` déjà dans HEAD eca2ba9) : compteur périmé sans rapport avec LS7. Mon avis Git : à corriger **avant le merge d'intégration**, en **commit séparé** (`fix(test): …` ou `chore(test): …`), pour ne pas merger `develop` avec une suite rouge. Lance une tâche DevFrontend dédiée quand tu veux ; tu peux l'enchaîner maintenant ou la grouper avec l'éventuelle slice `set_resume_at` — dans tous les cas elle aura son propre commit, et je ne mergerai pas tant que toute la suite n'est pas verte.\n\nÉtat branche : `feature/agent-session-limits` → … ea94e75 (LS6) · 9df5923 (LS7 back) · **4fad042 (LS7 front, HEAD)**."}
{"id":"8a9caf1e-94cf-4d45-905a-caab1363aa99","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781678668760,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Feature session-limits COMPLÈTE (3 niveaux) et verte. Reste à committer 2 slices, puis on peut envisager le merge d'intégration `feature/agent-session-limits → develop` que tu gardais en attente.\n\nÉtat branche actuel : … 9df5923 (LS7 back) · 4fad042 (LS7 front, HEAD). Travail NON committé depuis, à committer maintenant en 2 commits séparés :\n\n═══ COMMIT A — LS8-backend (filet humain niveau 3, backend) ═══\nImplémenté par DevBackend, testé par QA (application: 15 tests session_limit_service / app-tauri: 4 wiring, + régressions vertes, 0 failed).\nFichiers :\n- crates/application/src/agent/session_limit.rs — refactor privé `arm_scheduled` (param `resets_at_ms` brut ajouté) partagé par `on_rate_limited` + nouvelle `pub fn confirm_human_resume(agent_id, node_id, conversation_id, resets_at_ms: i64)` (source Human, réutilise la branche Scheduled, annulable).\n- crates/app-tauri/src/commands.rs — nouvelle commande `set_resume_at(agent_id, resets_at_ms) -> Result<(), ErrorDto>` (résout node_id via node_for_agent + conversation_id best-effort, NOT_FOUND si pas de cellule vivante).\n- crates/app-tauri/src/lib.rs — `set_resume_at` enregistrée après `cancel_resume`.\n- crates/application/tests/session_limit_service.rs — +6 tests (QA).\n- crates/app-tauri/tests/session_limit_wiring.rs — +2 tests (QA).\nAucun événement nouveau (réutilise AgentRateLimited + AgentResumeScheduled).\n\n═══ COMMIT B — LS8-front + fix test (DevFrontend a demandé 2 commits ; à toi de voir si tu sépares ou regroupes) ═══\nLS8-front (typecheck propre, 109 tests verts) :\n- frontend/src/ports/index.ts — `setResumeAt(agentId, resetsAtMs)` sur InputGateway.\n- frontend/src/adapters/input.ts — `setResumeAt` → invoke(\"set_resume_at\", { agentId, resetsAtMs }).\n- frontend/src/adapters/mock/index.ts — MockInputGateway.setResumeAt (resumeArmings[]).\n- frontend/src/features/agents/useAgents.ts — action setResumeAt (sans mutation optimiste).\n- frontend/src/features/agents/AgentLimitBadge.tsx — formulaire de saisie d'heure sur l'état suspected sans heure + helper pur `timeInputToEpochMs` ; TODO LS7 retiré.\n- frontend/src/features/agents/AgentsPanel.tsx — câblage onSetResumeAt.\n- frontend/src/features/agents/AgentLimitBadge.test.tsx — tests mis à jour au nouveau contrat + couverture LS8.\nFix test hygiène (DevFrontend recommande commit SÉPARÉ) :\n- frontend/src/adapters/mock/mock.test.ts — compteur gateways 13→14 (« thirteen »→« fourteen » + `permission` dans l'array). Vérifié : 14 gateways réelles.\n\nHors périmètre (NE PAS committer) : .ideai/** (runtime/mémoire), node_modules/ (untracked — DevFrontend signalait qu'il traîne, à gitignorer dans une tâche d'hygiène séparée, hors périmètre ici).\n\nQUESTIONS : 1) Committe COMMIT A puis les commits front (LS8-front + fix-test, séparés ou groupés selon ton jugement). Donne-moi les hashes + messages. 2) La suite complète est maintenant verte (Rust: domain/application/app-tauri ; front: agents + adapters/mock 109 tests) et la surface produit session-limits est complète sur les 3 niveaux. Décides-tu MAINTENANT le merge `--no-ff` vers develop (rebase si besoin) ? Si oui, exécute-le et donne-moi le résultat. Si tu veux un dernier garde-fou (ex. run de la suite complète des deux côtés avant merge), dis-le et je le fais lancer."}
{"id":"6df9f7b7-fcc4-4b87-94db-33dce2d76f13","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781678766649,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"✅ 3 commits + merge d'intégration faits. La feature session-limits est intégrée dans `develop`.\n\n═══ 1) Les 3 commits (sur feature/agent-session-limits) ═══\nJ'ai séparé le fix-test de la feature (hygiène isolée, comme recommandé par DevFrontend) → 3 commits :\n\n- **c480d28** `feat(session-limits): LS8-backend — filet humain niveau 3 (set_resume_at)`\n (session_limit.rs + commands.rs + lib.rs + tests service/wiring ; 5 fichiers, +394/18)\n- **5d9dd32** `feat(session-limits): LS8-front — filet humain niveau 3 (saisie d'heure de reprise)`\n (ports/input/mock + useAgents + AgentLimitBadge(.tsx/.test) + AgentsPanel ; 7 fichiers, +222/26)\n- **3f3504e** `fix(test): corrige le compteur de gateways du mock (13 → 14)`\n (mock.test.ts isolé ; 1 fichier, +2/1)\n\nArtefacts `.ideai/**` et `node_modules/` exclus comme demandé.\n\n═══ 2) Merge `--no-ff` → develop : OUI, exécuté ═══\nDécision : la surface produit est complète sur les 3 niveaux et tout est vert des deux côtés → je merge. Pas de rebase nécessaire : `feature` était **12 ahead / 0 behind** `develop` (aucune divergence, base linéaire).\n\n- Merge commit : **d7041c5** `merge(session-limits): intégration de la feature limites de session (3 niveaux)` — stratégie ort, **0 conflit**, 47 fichiers / +5157/45 (cumul LS2→LS8).\n- `develop` est maintenant à **d7041c5**, contenant toute la chaîne LS2 (a1755e5) → LS8 (3f3504e).\n- La branche `feature/agent-session-limits` est **conservée** (pas supprimée) : je la garde tant que tu n'as pas confirmé que l'intégration est définitivement validée. Dis-moi si tu veux que je la supprime maintenant.\n\nNote d'exécution : les artefacts runtime `.ideai/**` non committés bloquaient le switch de branche → je les ai **stashés le temps du merge puis restaurés** (stash poppé, working tree intact, rien perdu). Aucun de ces artefacts n'est entré dans les commits applicatifs ni dans le merge.\n\n⚠ Périmètre **strictement local** : aucun `push`. `develop` local est en avance de 58 commits sur `origin/develop` — je ne pousse rien sans ta validation explicite.\n\nProchaines options possibles, à ta main : suppression de `feature/agent-session-limits`, et/ou push de `develop` vers origin (action sortante → nécessite ton feu vert)."}

View File

@ -1,9 +0,0 @@
---
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

@ -1,3 +0,0 @@
{"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

@ -1,7 +0,0 @@
---
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

@ -1 +0,0 @@
{"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,46 +1,47 @@
{
"version": 1,
"activeId": "dd38b8ed-7199-48a6-a63f-1d1462ca6a34",
"activeId": "0ac56658-4f42-47fe-ad03-c088c832460a",
"layouts": [
{
"id": "dd38b8ed-7199-48a6-a63f-1d1462ca6a34",
"id": "0ac56658-4f42-47fe-ad03-c088c832460a",
"name": "Default",
"kind": "terminal",
"tree": {
"root": {
"type": "split",
"node": {
"id": "bd3dfce4-d31b-44d4-bd1e-b299b00d3cce",
"id": "35174b5f-0c29-4025-a389-e609ff219f39",
"direction": "row",
"children": [
{
"node": {
"type": "split",
"node": {
"id": "cd60658c-8c2d-4ef1-9149-073230350696",
"id": "652a0a78-e342-473d-9725-ebac04556cbf",
"direction": "column",
"children": [
{
"node": {
"type": "leaf",
"node": {
"id": "14d20dcb-f243-4381-a130-2d4d3d224f42",
"session": "adb24405-26ad-4920-a114-d27a21d9022a",
"id": "d4b8c0d1-a44a-4c45-bbe9-26991f79b465",
"session": "813f17c2-d056-4875-b9a0-3c18e4e40776",
"agent": "a6ced819-b893-4213-b003-9e9dc79b9641",
"agentWasRunning": true
}
},
"weight": 1.1910967
"weight": 1.0760044
},
{
"node": {
"type": "leaf",
"node": {
"id": "8d2c2720-b971-4792-8c0a-b28581eea8a8",
"session": "5def4f66-2921-4ddc-b9a2-be4958788081"
"id": "b7fc564a-7673-4970-acdc-1b8cf23dee8e",
"session": "3a6c9dc2-9919-4c17-8f0f-48439151fd27",
"agent": "dce19c75-9669-4e45-b8de-9950025157da"
}
},
"weight": 0.80890334
"weight": 0.9239957
}
]
}
@ -51,56 +52,32 @@
"node": {
"type": "split",
"node": {
"id": "80fa1a60-60d7-4442-8614-fb055ea97e93",
"id": "31597147-d927-4d03-b76c-8d2b22f8b816",
"direction": "column",
"children": [
{
"node": {
"type": "leaf",
"node": {
"id": "fbcfacb2-ad0b-46cc-9b53-8199d23f9324",
"session": "462f69bc-7957-437d-9096-e79b57af1223",
"id": "71564af2-673a-46c7-a0c0-877f89f6e49e",
"session": "384778a6-216f-47ed-90f0-67de6812b59d",
"agent": "73c853d1-c0fd-463b-ad17-1d24fefa371f",
"agentWasRunning": true
}
},
"weight": 0.80238867
"weight": 1.0
},
{
"node": {
"type": "split",
"node": {
"id": "8318f2b8-55e1-4fff-88ba-9891bd85c8a0",
"direction": "column",
"children": [
{
"node": {
"type": "leaf",
"node": {
"id": "07698ab4-0700-4205-966b-a50b1e73ec2b",
"session": "f1780cb6-91c3-47d7-9081-1bcbdb96ba92",
"id": "8529e97f-ce06-490c-b3d0-531c3dfee442",
"session": "fac01ad4-0e99-4b2a-bdec-621493cf9c57",
"agent": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5",
"agentWasRunning": true
}
},
"weight": 1.0
},
{
"node": {
"type": "leaf",
"node": {
"id": "f31fa71f-c55e-4597-8ea0-caa9775531ff",
"session": "b04d11d9-0359-48a6-94ec-bd1d40eb6277",
"agent": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
"agentWasRunning": true
}
},
"weight": 1.0
}
]
}
},
"weight": 1.1976112
}
]
}
@ -113,14 +90,14 @@
}
},
{
"id": "a1958c6d-901f-4fbc-a621-d026aa6b2b62",
"id": "74489208-01a7-4005-aba4-b9c41ab843fd",
"name": "Git Graph",
"kind": "gitGraph",
"tree": {
"root": {
"type": "leaf",
"node": {
"id": "7a088843-00e7-4ea0-9676-8c4acb1f6d1d"
"id": "2396a80e-bc98-47cd-b711-c7cff0010bb6"
}
}
}

View File

@ -7,3 +7,13 @@
- [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.
- [git-owns-commit-merge-decisions](git-owns-commit-merge-decisions.md) — Ne jamais demander a l'utilisateur s'il faut committer/merger/brancher : l'agent Git tranche toute la topologie du depot.
- [conversation-rotation-safety-design](conversation-rotation-safety-design.md) — memory note conversation-rotation-safety-design
- [checkpoint-orchestrator-designation-restart](checkpoint-orchestrator-designation-restart.md) — memory note checkpoint-orchestrator-designation-restart
- [checkpoint-orchestrator-designation-backend-compile-fix](checkpoint-orchestrator-designation-backend-compile-fix.md) — memory note checkpoint-orchestrator-designation-backend-compile-fix
- [checkpoint-orchestrator-designation-qa-verdict](checkpoint-orchestrator-designation-qa-verdict.md) — memory note checkpoint-orchestrator-designation-qa-verdict
- [checkpoint-orchestrator-designation-appimage-build](checkpoint-orchestrator-designation-appimage-build.md) — memory note checkpoint-orchestrator-designation-appimage-build
- [checkpoint-blocked-until-appimage-030-restart](checkpoint-blocked-until-appimage-030-restart.md) — memory note checkpoint-blocked-until-appimage-030-restart
- [checkpoint-delivery-submit-logging-fix](checkpoint-delivery-submit-logging-fix.md) — memory note checkpoint-delivery-submit-logging-fix
- [checkpoint-workstate-delegation-queue-lot-b](checkpoint-workstate-delegation-queue-lot-b.md) — memory note checkpoint-workstate-delegation-queue-lot-b
- [checkpoint-workstate-conversation-summaries-lot-c](checkpoint-workstate-conversation-summaries-lot-c.md) — memory note checkpoint-workstate-conversation-summaries-lot-c
- [checkpoint-workstate-controlled-actions-lot-d](checkpoint-workstate-controlled-actions-lot-d.md) — memory note checkpoint-workstate-controlled-actions-lot-d

View File

@ -0,0 +1,46 @@
---
name: checkpoint-blocked-until-appimage-030-restart
description: memory note checkpoint-blocked-until-appimage-030-restart
metadata:
type: project
---
# Checkpoint — blocage délégation Architect jusqu'au redémarrage AppImage 0.3.0
Date: 2026-06-20
## État atteint
Chantier `orchestrator-designation` terminé et intégré localement:
- `develop` @ `9c71a5b` après commit runtime post-build.
- Worktree propre à ce moment.
- AppImage corrigée générée:
`/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage`
- Artefact vérifié:
- taille 106M
- `--appimage-offset` = `944632`
## Problème live confirmé
Deux tentatives de délégation vers Architect ont affiché le texte dans le chat/terminal d'Architect sans le soumettre réellement.
L'utilisateur a interrompu et confirmé: « Le message n'arrive pas jusqu'à Architecte visiblement ».
Ce symptôme correspond au bug live de soumission write-portal/headless delivery corrigé dans `orchestrator-designation`, mais l'application en cours tourne encore avec l'ancien AppImage. Le nouvel artefact n'est pas chargé tant qu'IdeA n'est pas relancé avec l'AppImage 0.3.0.
## Action effectuée
Architect stoppé pour éviter un terminal avec tâche fantôme.
## Prochain chantier prévu après redémarrage
Git a recommandé:
- Prochain chantier: `feature/agent-skill-awareness`.
- Architect doit d'abord trancher le chevauchement avec `feature/agent-skills`.
- `fix/cold-start-delivery-race` est probablement obsolète/superseded par `feature/agent-skill-awareness`, mais à supprimer seulement après intégration ou décision Git finale.
## Reprise recommandée
1. Relancer IdeA avec:
`/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage`
2. Reprendre par une délégation Architect:
cadrage `agent-skill-awareness` vs `agent-skills`.
3. Ensuite cycle normal: Git -> DevBackend -> QA -> Git.

View File

@ -0,0 +1,88 @@
---
name: checkpoint-delivery-submit-logging-fix
description: memory note checkpoint-delivery-submit-logging-fix
metadata:
type: project
---
# Checkpoint — hotfix livraison délégation + logs submit
Date: 2026-06-20
## Pourquoi ce checkpoint existe
Après le fix AppImage 0.3.0 précédent, la délégation `Main -> DevBackend` a reproduit le même symptôme que `Main -> Architect` avant redémarrage : le message de délégation apparaît dans l'input de la cellule cible, mais n'est pas soumis.
Le chantier `feature/agent-skill-awareness-v2` est donc suspendu tant que le canal de délégation n'est pas fiable.
## Branche et état
Branche active pendant le hotfix : `feature/agent-skill-awareness-v2`.
Dirty attendu hors code : fichiers runtime `.ideai/conversations/*`, `.ideai/layouts.json` produits par la session live.
## Changements code effectués
Frontend :
- `frontend/src/features/terminals/useWritePortal.ts`
- Logs détaillés `[write-portal]` : abonnement, attachement front, réception `delegationReady`, chunks de texte, délai avant submit, submit, ack, erreurs.
- Fix défensif : `submitSequence: ""` est normalisée vers `"\r"` au lieu d'écrire zéro octet.
- En cas d'erreur d'injection, arrêt du retry immédiat infini.
- `frontend/src/adapters/terminal.ts`
- Logs `[terminal-write]` sur writes non triviaux ou contrôles (`\r`, `\n`, bulk, etc.).
- `frontend/src/adapters/input.ts`
- Logs `[input-gateway]` pour `delegationDelivered` et `setFrontAttached`.
- `frontend/src/domain/index.ts`
- Commentaire défaut submit aligné sur ~350 ms.
- `frontend/src/features/terminals/useWritePortal.test.tsx`
- Test ajouté : `submitSequence: ""` doit envoyer `"\r"`.
Backend/application/Tauri :
- `crates/application/src/orchestrator/service.rs`
- `note_delegation_delivered` passe par `application::diag!` persistant.
- `set_agent_front_attached` logge les changements d'attachement et le cas médiateur absent.
- `crates/infrastructure/src/input/mod.rs`
- Logs détaillés `[input-mediator]` : start turn, gate cold start, publish `DelegationReady`, choix front-owned vs headless, bind handle, front attach/detach, headless text/submit start/ok/failure.
- Défaut headless `DEFAULT_SUBMIT_DELAY_MS` corrigé de 60 ms à 350 ms pour être aligné avec le write-portal.
- Headless normalise aussi une submit sequence vide vers `"\r"`.
- `crates/app-tauri/src/commands.rs`
- Logs persistants `[pty-write]` autour de `write_terminal` avec session, bytes, contrôle sans contenu bulk.
- Logs persistants `[delivery]` pour `delegation_delivered` et `set_front_attached`.
## Vérifications passées
- `cargo fmt --all -- --check` : OK.
- `npx vitest run src/features/terminals/useWritePortal.test.tsx src/adapters/terminal.test.ts src/features/terminals/TerminalView.portal.test.tsx` : OK, 3 files / 20 tests.
- `npx tsc --noEmit` : OK.
- `cargo test -p infrastructure input --lib` : OK, 35 tests.
- `cargo check -p app-tauri` : OK.
- `cargo test -p application --test orchestrator_service` : OK, 45 tests (warning existant `CapturingFs::writes` unused).
- `cargo test -p app-tauri --test orchestrator_wiring` : OK, 13 tests.
## Build AppImage
Commande Tauri standard : frontend build OK, Rust release OK, bundling Tauri KO avec `failed to run linuxdeploy` comme précédemment.
Contournement réussi :
```bash
ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 \
/tmp/appimage_extracted_02f96dbd6ecc47f616b5a57fb8b7aa60/usr/bin/appimagetool \
--runtime-file /tmp/idea-appimage-runtime-x86_64 \
/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA.AppDir \
/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage
```
Artefact :
- `/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage`
- taille : 106M
- `--appimage-offset` : `944632`
## Reprise recommandée
1. Relancer IdeA avec l'AppImage ci-dessus.
2. Reproduire une délégation simple `Main -> DevBackend` ou `Main -> Architect`.
3. Si le message reste dans l'input sans être soumis, récupérer les traces :
- logs persistants `application::diag!` (chemin configuré au startup, typiquement app-data logs/idea.log), chercher `[delivery]`, `[pty-write]`, `[rendezvous]`.
- console frontend, chercher `[write-portal]`, `[terminal-write]`, `[input-gateway]`.
- stderr si lancé depuis terminal, chercher `[input-mediator]`.
4. Une fois délégation fiable, reprendre `feature/agent-skill-awareness-v2` depuis le cadrage Architect déjà obtenu.

View File

@ -0,0 +1,98 @@
---
name: checkpoint-orchestrator-designation-appimage-build
description: memory note checkpoint-orchestrator-designation-appimage-build
metadata:
type: project
---
# Checkpoint — AppImage rebuild après orchestrator-designation
Date: 2026-06-20
## État Git avant build
Git avait clôturé `feature/orchestrator-designation`:
- Commits atomiques créés:
- `287681c feat(orchestrator): ...`
- `e462136 feat(terminals): ...`
- `09f5362 docs: ...`
- `5ef001e chore(wip): ...`
- Merge local dans `develop`: `55d887f merge(orchestrator): intègre le chantier orchestrator-designation dans develop`.
- Branche `feature/orchestrator-designation` supprimée.
- Worktree propre à ce moment-là.
## Rebuild tenté
Commande initiale:
```bash
npm --prefix frontend run build && \
cd crates/app-tauri && \
APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage
```
Résultat:
- Frontend build OK.
- Rust release build OK: `/home/anthony/Documents/Projects/IdeA/target/release/app-tauri`.
- AppDir généré: `/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA.AppDir`.
- Bundling Tauri KO: `failed to run linuxdeploy`.
## Contournement réussi
`appimagetool` extrait existait dans:
```text
/tmp/appimage_extracted_02f96dbd6ecc47f616b5a57fb8b7aa60/usr/bin/appimagetool
```
L'appel direct à appimagetool échouait car réseau restreint:
```text
Failed to download runtime ... pass it to appimagetool with --runtime-file
```
Runtime extrait depuis l'ancienne AppImage locale:
```bash
/home/anthony/Documents/IdeA_0.2.0_amd64.AppImage --appimage-offset
# 944632
dd if=/home/anthony/Documents/IdeA_0.2.0_amd64.AppImage \
of=/tmp/idea-appimage-runtime-x86_64 \
bs=1 count=944632 status=none
chmod +x /tmp/idea-appimage-runtime-x86_64
```
Commande finale réussie:
```bash
ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 \
/tmp/appimage_extracted_02f96dbd6ecc47f616b5a57fb8b7aa60/usr/bin/appimagetool \
--runtime-file /tmp/idea-appimage-runtime-x86_64 \
/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA.AppDir \
/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage
```
Artefact produit:
```text
/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage
Taille: 106M
--appimage-offset: 944632
```
## État Git après build
Le build/la session a laissé du runtime dirty:
```text
## develop...origin/develop [ahead 8]
M .ideai/conversations/6bc594e8-a37c-0dbd-1de6-6e3b73002cb4/handoff.md
M .ideai/conversations/6bc594e8-a37c-0dbd-1de6-6e3b73002cb4/log.jsonl
M .ideai/layouts.json
```
Ne pas nettoyer arbitrairement. Demander à Git de décider si ces fichiers runtime doivent être commités/ignorés au prochain passage.
## Important live
L'AppImage en cours d'exécution ne charge pas automatiquement ce nouvel artefact. Pour utiliser les correctifs d'orchestration/write-portal, lancer l'artefact 0.3.0 produit ci-dessus ou remplacer l'AppImage installée hors sandbox si nécessaire.

View File

@ -0,0 +1,54 @@
---
name: checkpoint-orchestrator-designation-backend-compile-fix
description: memory note checkpoint-orchestrator-designation-backend-compile-fix
metadata:
type: project
---
# Checkpoint — orchestrator-designation backend compile fix
Date: 2026-06-20
## Branche
`feature/orchestrator-designation`.
## Décision Git
Finir le chantier courant sur cette branche. Pas de stash, pas de switch. Après tests verts ou résidu qualifié: retour Git pour commits atomiques puis merge local vers `develop`.
## Incident live
La délégation vers Architect a été affichée dans son chat mais non soumise. Architect a été stoppé. Symptôme cohérent avec l'AppImage live qui n'intègre pas encore les correctifs WIP write-portal/headless delivery.
## Vérifications Main avant correction DevBackend
Verts:
- `cargo test -p infrastructure input --lib`: 35 passed.
- `cargo test -p application --test orchestrator_service`: 45 passed.
- `cd frontend && npx vitest run`: 41 files, 384 tests passed.
- `cd frontend && npx tsc --noEmit`: OK.
Rouge initial:
- `cargo test --workspace` ne compilait pas `crates/application/src/orchestrator/context_guard.rs`:
- `may_write_directly` appelé avec 2 args au lieu de 3 (`OrchestratorDesignation` manquant).
- mauvais champ `orchestrator` mis sur `ManifestEntry` au lieu de `AgentManifest`.
- init `AgentManifest` sans `orchestrator`.
## Correction DevBackend
DevBackend a modifié `crates/application/src/orchestrator/context_guard.rs`:
- `ProposeContext` charge `AgentManifest`, récupère `manifest.orchestrator_designation()`, puis appelle `may_write_directly(requester, &GuardedResource::ProjectContext, &designation)`.
- Le `FileGuard` reste un verrou, pas le propriétaire de l'autorisation.
- Tests locaux adaptés à `AgentManifest { version, entries, orchestrator }`.
Validations DevBackend:
- `cargo fmt --all && cargo test -p application --test orchestrator_service`: OK, 45 passed.
- `cargo test -p application`: OK.
- `cargo test --workspace`: compile maintenant plus loin mais échoue dans `app-tauri` sur tests loopback Unix socket sous sandbox (`PermissionDenied`/`Operation not permitted` lors du bind `/run/user/1000/idea-mcp/*.sock`).
## Prochaine étape
QA doit qualifier le résidu app-tauri:
- confirmer que les suites métier/orchestration sont vertes,
- confirmer si les échecs app-tauri sont environnement/sandbox et non régression,
- proposer la commande de validation acceptable ou le besoin de correction test/env.

View File

@ -0,0 +1,42 @@
---
name: checkpoint-orchestrator-designation-qa-verdict
description: memory note checkpoint-orchestrator-designation-qa-verdict
metadata:
type: project
---
# Checkpoint — QA verdict orchestrator-designation
Date: 2026-06-20
## Branche
`feature/orchestrator-designation`.
## Verdict QA
Chantier validable sous contrainte d'environnement. Pas de régression fonctionnelle liée à `context_guard.rs`.
## Commandes vertes qui font foi
- `cargo fmt --all -- --check`: OK.
- `cargo test -p application --test orchestrator_service`: OK, 45 passed.
- `cargo test -p application`: OK, suite application complète verte, incluant les tests context guard orchestrateur/proposition.
- `cargo test -p infrastructure input --lib`: OK, 35 passed.
- `cd frontend && npx vitest run`: OK, 41 files / 384 tests passed.
- `cd frontend && npx tsc --noEmit`: OK.
## Résidu app-tauri
- `cargo test -p app-tauri --lib`: rouge, 39 passed / 8 failed.
- `cargo test --workspace`: rouge sur le même bloc app-tauri.
Échecs exacts: tests loopback Unix socket:
- `mcp_bridge::tests::end_to_end_over_real_loopback`
- `state::bind_endpoint_d1_tests::rebind_after_corpse_socket_succeeds`
- `state::mcp_e2e_loopback_tests::*`
Cause qualifiée par QA: contrainte environnement/sandbox. Une sonde Node minimale échoue aussi à `listen()` sur Unix socket avec `EPERM` sous `/run/user/1000` et sous `/tmp`. Donc les tests qui exigent un vrai socket Unix ne sont pas exécutables dans ce sandbox.
## Prochaine étape
Retour à Git pour commits atomiques sur `feature/orchestrator-designation`, puis décision de merge local vers `develop` selon sa stratégie. Aucun push.

View File

@ -0,0 +1,38 @@
---
name: checkpoint-orchestrator-designation-restart
description: memory note checkpoint-orchestrator-designation-restart
metadata:
type: project
---
# Checkpoint — reprise chantier orchestrator-designation
Date: 2026-06-20
## Situation
L'utilisateur a demandé de terminer les chantiers ouverts. Main a repris le cycle proprement.
## Décision Git obtenue
Git a inspecté l'état local et décidé:
- Branche courante: `feature/orchestrator-designation`.
- Worktree dirty important mais analysé comme mono-thème: chantier orchestrateur/designation + diagnostic inter-agents.
- Ne pas stash, ne pas switch, ne pas ouvrir une nouvelle branche maintenant.
- Premier chantier à fermer: `orchestrator-designation` sur la branche actuelle.
- Après stabilisation + tests verts: retour à Git pour commits atomiques puis merge local vers `develop`.
- Ensuite seulement ouvrir des branches dédiées pour les autres chantiers.
## Incident de délégation
Main a tenté de demander à Architect de cadrer `orchestrator-designation` via `idea_ask_agent`.
L'utilisateur a interrompu et signalé qu'Architect était bloqué avec le texte de la tâche affiché dans son chat mais non envoyé.
Interprétation: symptôme cohérent avec les problèmes actuels de write portal / soumission physique PTY / délégation visible-background. Ne pas empiler de nouvelles délégations tant que cette zone n'est pas stabilisée.
## Prochaine reprise recommandée
1. Nettoyer/arrêter l'agent Architect bloqué si nécessaire.
2. Reprendre le chantier courant localement en lecture seule pour identifier exactement les changements WIP liés à la soumission de délégations.
3. Comme Main ne code pas, utiliser la délégation seulement vers un agent dont la soumission est confirmée fonctionnelle, ou lancer les agents en visible et vérifier qu'ils reçoivent réellement la tâche.
4. Priorité technique immédiate: fermer `orchestrator-designation`, car la délégation inter-agents fiable conditionne tous les autres chantiers.

View File

@ -0,0 +1,64 @@
---
name: checkpoint-workstate-controlled-actions-lot-d
description: memory note checkpoint-workstate-controlled-actions-lot-d
metadata:
type: project
---
# Checkpoint — Workstate controlled actions Lot D
Date: 2026-06-20
## État final
Lot D `workstate controlled actions` terminé, validé QA et mergé localement dans `develop`.
Branche finale:
- `develop` @ `c1e99d1` — merge local `feature/workstate-controlled-actions`.
- `feature/workstate-controlled-actions` conservée.
- Aucun push, aucune branche supprimée.
- Working tree propre après décision Git.
## Commits créés
- `3408c96``feat(workstate): actions contrôlées sur le work-state (Lot D backend)`
- Use cases agent-level `AttachLiveAgent` et `StopLiveAgent`.
- Commandes Tauri `attach_live_agent` et `stop_live_agent`.
- Attach rebind PTY/structured sans spawn; stop PTY/structured.
- DTO camelCase et wiring AppState/lib.
- `1c6441b``feat(workstate): UI des actions contrôlées (Lot D frontend)`
- Port/adaptateur agent alignés sur le nouveau contrat attach + stop.
- Panneau Work: Open, Attach, Stop, View conversation, Copy summary.
- Work n'appelle pas `launchAgent`; Stop passe par `stopLiveAgent`; View/Copy n'utilisent que les previews Lot C.
- `0976648``chore(wip): état runtime .ideai (flux conversation live)`
- Runtime isolé des commits feature.
- `c1e99d1` — merge local dans `develop`.
## QA verte
Commandes QA/Git validées:
- `cargo fmt --all -- --check` OK.
- `cargo test -p application --test workstate_actions` OK, 7 passed.
- `cargo test -p application --test workstate` OK, 21 passed.
- `cargo test -p app-tauri --test dto_agents` OK, 25 passed.
- `cargo test -p app-tauri --test list_live_agents_r0b` OK, 5 passed.
- `cargo check -p app-tauri` OK.
- `cd frontend && npx vitest run src/features/workstate/workstate.test.tsx src/features/projects/projects.test.tsx` OK, 25 tests.
- `cd frontend && npx vitest run src/adapters/agent.test.ts` OK, 8 tests.
- `cd frontend && npx vitest run src/features/layout/singletonAgent.test.tsx src/features/layout/agentAlreadyRunning.test.tsx` OK, 9 tests.
- `cd frontend && npx tsc --noEmit` OK.
Warnings restants uniquement côté Vite sur options `esbuild` dépréciées/ignorées au profit de `oxc`, non bloquants.
## Décisions produit/techniques
- Actions Lot D opèrent seulement sur l'état existant; pas de lancement d'agent neuf depuis Work.
- Attach ne crée ni session ni cellule; cible déterministe = cellule visible vide côté UI.
- Stop ne supprime ni agent, ni tickets, ni handoff/conversation summary.
- View/Copy restent limités aux previews bornées Lot C; aucun log brut exposé.
## Suite probable
La quadrilogie Work A/B/C/D est intégrée. Prochains chantiers restants à cadrer: persistance conversationnelle/cross-profile plus profonde, mise à jour automatique mémoire/contexte pendant la vie des agents, ou synchronisation documentaire architecture selon priorité Architect/Main.

View File

@ -0,0 +1,60 @@
---
name: checkpoint-workstate-conversation-summaries-lot-c
description: memory note checkpoint-workstate-conversation-summaries-lot-c
metadata:
type: project
---
# Checkpoint — Workstate conversation summaries Lot C
Date: 2026-06-20
## État final
Lot C `workstate conversation summaries` terminé, validé QA et mergé localement dans `develop`.
Branche finale:
- `develop` @ `6e1ba7e` — merge local `feature/workstate-conversation-summaries`.
- `feature/workstate-conversation-summaries` conservée.
- Aucun push, aucune branche supprimée.
- Working tree propre après décision Git.
## Commits créés
- `e9edadc``feat(workstate): résumés de conversation dans le read-model (Lot C backend)`
- `ProjectWorkState.conversations` top-level.
- Résumés best-effort depuis `HandoffStore`, fallback `ConversationLog::last(3)`.
- Déduplication des conversation ids issus des tickets, ordre first-seen.
- DTO camelCase et câblage Tauri.
- `c50622e``feat(workstate): UI des résumés de conversation (Lot C frontend)`
- Types TS `ConversationWorkSummary` et previews.
- Mock normalise `conversations: []`.
- Panneau Work joint `tickets[].conversationId` vers `conversations[]` et affiche badge + preview compacte.
- `78500d8``chore(wip): état runtime .ideai (flux conversation live)`
- Runtime isolé des commits feature.
- `6e1ba7e` — merge local dans `develop`.
## QA verte
Commandes QA finales validées:
- `cargo fmt --all -- --check` OK.
- `cargo test -p application --test workstate` OK, 21 passed, aucun warning Rust.
- `cargo test -p app-tauri --test dto_agents` OK, 21 passed.
- `cargo check -p app-tauri` OK.
- `cd frontend && npx vitest run src/features/workstate/workstate.test.tsx src/features/projects/projects.test.tsx` OK, 2 files / 19 tests.
- `cd frontend && npx tsc --noEmit` OK.
Warnings restants uniquement côté Vite sur options `esbuild` dépréciées/ignorées au profit de `oxc`, non bloquants.
## Décisions produit/techniques
- Source primaire: handoff conversationnel existant.
- Fallback: derniers tours bornés du log, jamais le log brut complet.
- Read-only et best-effort: les erreurs de preview ne bloquent pas `live/busy/tickets`.
- Pas de nouvelle persistance, pas de mutation/réparation, pas de nouvelle action UX.
## Suite probable
Le prochain lot logique est Lot D: actions UX read/write contrôlées autour du panneau Work (ouvrir/rattacher cellule, voir conversation, arrêter agent, éventuellement copier résumé), à cadrer par Architect avant implémentation.

View File

@ -0,0 +1,62 @@
---
name: checkpoint-workstate-delegation-queue-lot-b
description: memory note checkpoint-workstate-delegation-queue-lot-b
metadata:
type: project
---
# Checkpoint — Workstate delegation/queue snapshot Lot B
Date: 2026-06-20
## État final
Lot B `workstate delegation/queue snapshot` terminé, validé QA et mergé localement dans `develop`.
Branche finale:
- `develop` @ `64c2c14` — merge local `feature/workstate-delegation-queue`.
- `feature/workstate-delegation-queue` conservée.
- Aucun push, aucune branche supprimée.
- Working tree propre après décision Git.
## Commits créés
- `cc7d99a``feat(workstate): snapshot des délégations en file par agent (Lot B backend)`
- `QueuedTicketSnapshot` + port read-only `AgentQueueSnapshot`.
- Impl `InMemoryMailbox` snapshot FIFO sans cloner les senders.
- `GetProjectWorkState.agents[].tickets` avec statut dérivé `inProgress/queued`, source `human/agent`, preview bornée.
- DTO Tauri camelCase et wiring `AppState` avec le même mailbox partagé en deux ports.
- `c600604``feat(workstate): UI des délégations en file par agent (Lot B frontend)`
- Types TS `AgentTicketState`, `TicketWorkStatus`, `TicketWorkSource`.
- Mock gateway normalise `tickets: []`.
- Panneau Work affiche les tickets FIFO, source Human/Agent, preview, ticket court.
- Refresh ajouté sur `delegationReady`.
- `5cb99fd``chore(wip): état runtime .ideai (flux conversation live, layouts)`
- Runtime isolé des commits feature.
- `64c2c14` — merge local dans `develop`.
## QA verte
Commandes QA réelles validées:
- `cargo fmt --all -- --check` OK.
- `cargo test -p infrastructure mailbox --lib` OK, 13 passed.
- `cargo test -p application --test workstate` OK, 12 passed.
- `cargo test -p app-tauri --test dto_agents` OK, 20 passed.
- `cargo check -p app-tauri` OK.
- `cd frontend && npx vitest run src/features/workstate/workstate.test.tsx src/features/projects/projects.test.tsx` OK, 2 files / 17 tests.
- `cd frontend && npx tsc --noEmit` OK.
- QA ajoutée: `cargo test -p domain mailbox --lib` OK, 6 passed.
Warnings observés uniquement côté Vite sur options `esbuild` dépréciées/ignorées au profit de `oxc`, non bloquants.
## Décisions produit/techniques
- Les tickets `human` et `agent` sont inclus dans le snapshot; l'UI distingue explicitement `Human` vs agent/requester au lieu de tout appeler délégation agent.
- Le statut est dérivé à la lecture: `inProgress` si le ticket courant correspond au `busy_state`, sinon `queued`.
- Pas de nouvelle persistance, pas de lecture `log.jsonl`/handoff, pas d'événement `agentQueueChanged`, pas d'action UX d'annulation/résolution.
## Suite probable
Le prochain lot logique du chantier UX conversations/délégations est Lot C: résumés de conversations depuis log/handoff en lecture best-effort, ou Lot D actions UX selon priorité produit. Suivre le cycle Main: Architect -> Git -> DevBackend/DevFrontend -> QA -> Git.

View File

@ -0,0 +1,49 @@
---
name: conversation-rotation-safety-design
description: memory note conversation-rotation-safety-design
metadata:
type: project
---
---
name: conversation-rotation-safety-design
description: Cadrage initial pour la compression/rotation automatique non interruptive des conversations d'agents.
metadata:
type: project
---
# Conversation Rotation Safety Design
Objectif : reduire le cout token des conversations longues sans degrader la stabilite des agents ni les contacts inter-agents.
Socle existant a reutiliser :
- log canonique `.ideai/conversations/<conversationId>/log.jsonl` ;
- `handoff.md` incremental ;
- injection du handoff au lancement/relaunch ;
- `LeafCell.conversation_id` = id logique IdeA de paire, distinct du resumable provider ;
- `providers.json` pour `(pair_conversation_id, provider_key) -> engine_session_id` ;
- `InputMediator.busy_state(agent)` et FIFO par agent ;
- `ask_agent` avec lock de tour par agent + wait-for graph anti-deadlock ;
- attach/detach cellule deja modelise : la cellule est une vue, `TerminalSessions::rebind_agent_node` existe ;
- frontend write-portal sait deja compter la saisie humaine et declarer `set_front_attached`.
Regle produit centrale : rotation automatique opportuniste, jamais interruptive. IdeA ne compacte/rote une session qu'a un point sur.
Conditions minimales avant rotation :
- agent `Idle` (`InputMediator.busy_state == Idle`) ;
- aucune delegation/ticket/reply en cours ;
- aucun contact entrant en cours, sinon queue ou ancienne session jusqu'a bascule ;
- checkpoint durable OK : prompt/response appendes au log + `handoff.md` a jour ;
- cellule stable : pas d'attach/detach ou changement de layout en cours ;
- utilisateur non en train d'ecrire : focus/buffer non vide/frappe recente/IME composition/prompt interactif detectable ;
- nouvelle session demarree detached/background et prete avant bascule ;
- bascule atomique routing logique + attachement cellule ; ancienne session retiree ensuite.
Etat cible conceptuel :
`RotationRequested -> WaitForAgentIdle -> WaitForNoDelegation -> WaitForNoIncomingRoute -> WaitForCellStable -> WaitForUserInputClear -> Checkpointing -> StartReplacementDetached -> AttachReplacementToCell -> SwitchRouting -> RetireOldSession`.
Premier lot recommande : fondation non destructive.
- Ajouter modeles/policy de `ConversationRotation` et raisons de blocage.
- Ajouter query/use case d'evaluation qui retourne `Ready` ou `Blocked(reasons)` sans tuer ni relancer.
- Exposer les signaux manquants de cellule/saisie depuis le frontend vers backend, d'abord comme etat consultable.
- Publier des events de statut de rotation uniquement informatifs.
- Ne pas implementer la bascule physique avant que les tests de surete soient verts.

View File

@ -0,0 +1,50 @@
---
name: feature-agent-skill-awareness-design
description: Design valide + decoupage de la feature « surfacer les skills assignes a un agent a la maniere MCP » (manifeste + idea_skill_read), a reprendre apres relance IdeA.
metadata:
type: project
---
# Feature : skills connus de l'agent « a la MCP »
Demarree le 2026-06-17. Branche Git **`feature/agent-skill-awareness`** (basee sur `develop` @ 8452333, qui contient deja le fix context-guard).
## ETAT 2026-06-17 (apres-midi) : slice backend T1->T5 FAIT, VERT, COMMITTE.
DevBackend a livre T1->T5, QA a couvert (23 tests, 1212 passed). Commits sur `feature/agent-skill-awareness` :
- `ab34363` feat(skill-awareness): T1->T5 (manifeste # Skills disponibles + outil MCP idea_skill_read, description+effective_description, use case ReadSkill, cablage state.rs, dump legacy conserve en mode sans-MCP).
- `1a10d67` fix(test): compteur d'outils MCP 11->12 (une 2e assertion codee en dur dans `crates/app-tauri/src/state.rs` ~l.2813 que QA avait ratee ; QA n'avait corrige que `infrastructure/tests/mcp_server.rs`).
- `e93a2c1` = cherry-pick du fix cold-start (cf. Bug 8 dans [[mcp-bridge-and-delegation-runtime-notes]]) — present aussi sur la branche dediee.
`cargo test --workspace` VERT sur l'arbre combine. **L'orchestrateur a committe lui-meme** (l'agent Git etait injoignable a cause du Bug 8) — A FAIRE RELIRE/REBASER PAR GIT une fois le canal restaure.
**RESTE :** T6 (champ description dans creation/edition skill cote front) + T7 (e2e : rebuild AppImage avec le fix Bug 8, relance, agent neuf + skill assigne -> voit le manifeste + appelle idea_skill_read). Puis Git decide les merges (feature->develop ; fix cold-start->develop+main).
## AJOUT 2026-06-17 (soir) : brief « capacites IdeA » INCONDITIONNEL — VERT, COMMITTE (566bff4)
Besoin utilisateur elargi : un agent neuf ignorait TOUT le perimetre IdeA (pas que les skills). Cas vecu : « agremente le contexte du projet » -> l'agent reinvente son propre systeme de contexte car le bloc `# Orchestration IdeA` ne parlait QUE de delegation. Fix (GO Architect, meme fonction pure `compose_convention_file` lifecycle.rs, zero port/entite) : ajout d'un brief « capacites IdeA » TOUJOURS present (decrit la CAPACITE, jamais le contenu -> survit a memoire/contexte vides), dans les 2 surfaces, AVANT le persona :
- mode MCP : nomme les outils contexte (`idea_context_read`/`idea_context_propose` avec semantique single-writer global HONNETE : proposition sans target = enregistree pour validation, PAS appliquee ; `idea_update_context` pour le .md d'un agent), memoire partagee (`idea_memory_read`/`idea_memory_write`), skills (`idea_skill_read`/`idea_create_skill`). Martele « le contexte projet d'IdeA EST le contexte, n'improvise jamais ton propre fichier ».
- mode non-MCP : MEMES concepts via fichiers `.ideai/` UNIQUEMENT (CONTEXT.md, memory/+MEMORY.md, skills .md), AUCUN nom d'outil `idea_*` (cloisonnement strict des 2 surfaces — sinon casse `mcp_prose_*`/`non_mcp_prose_*`).
QA : 5 tests dedies (brief inconditionnel des 3 capacites a zero contenu ; single-writer honnete ; non-MCP sans outil ; cloisonnement ; ordre avant persona). `cargo test -p application` = 0 failed (lib 52, agent_lifecycle 59). 1 assert existant adapte (`mcp_mode_without_skills_omits_section`). Commit atomique **566bff4** sur `feature/agent-skill-awareness` (Git a laisse les .ideai/ runtime hors commit). Merge feature->develop differe par Git jusqu'a T6+T7 (unite d'integration unique).
## Historique
Cycle initialement stoppe avant le code : la delegation MCP a wedge (Bug 7, puis Bug 8 residuel cf. [[mcp-bridge-and-delegation-runtime-notes]]).
## Besoin
Un agent neuf dans un projet neuf ignore les skills qui lui sont assignes (cas vecu : `build-appimage` assigne mais ignore, tout reinvente). Objectif : l'agent est **automatiquement au courant** de ses skills, comme il l'est des outils MCP, sans que l'utilisateur ait a le lui dire.
## Decision produit (utilisateur, 2026-06-17) : approche « Manifeste + idea_skill_read »
Diagnostic Architect : les skills sont DEJA injectes, mais (A) en VRAC (corps complet) en fin de `CLAUDE.md` -> lus comme de la doc, ignores ; (B) seulement au (re)lancement. On recadre « a la MCP » : affordances nommees+decrites en tete de contexte + chargement du corps a la demande.
- Bloc **« # Skills disponibles »** injecte JUSTE APRES le bloc « # Orchestration IdeA » (haute altitude), listant `**<name>** — <description>` ; prose imperative + renvoi a `idea_skill_read(name=…)`. Omis si zero skill.
- Nouvel outil MCP **`idea_skill_read(name)`** read-only, miroir exact de `idea_context_read`/`idea_memory_read` ; resout par nom (scope projet puis global), renvoie `content_md` inline ; erreur typee si introuvable/ambigu.
- Champ **`description: Option<String>`** sur l'entite `Skill` + helper `effective_description()` (fallback : 1ere ligne non vide du `content_md`, nettoyee du `#`). `#[serde(default)]` sur l'index (retro-compat `index.json` legacy obligatoire).
- Mode **sans MCP** (`profile.mcp` absent) : conserver l'ancien dump du corps complet (decision 4.2(b), zero regression).
## Decoupage (commits atomiques par tache verte, decides par Git)
- **T1 domaine** `crates/domain/src/skill.rs` : champ `description` + `effective_description()`.
- **T2 store** `crates/infrastructure/src/store/skill.rs` (+ application/skill) : `description` dans IndexEntry (`serde(default)`), propage via `CreateSkill`.
- **T3 convention-file** `crates/application/src/agent/lifecycle.rs` : section « # Skills disponibles » dans `compose_convention_file` (~l.2568, juste apres bloc Orchestration ~l.2586) ; alleger `resolve_skills` (~l.1202) pour n'utiliser que name+description (index, pas le corps) ; garder dump corps si non-MCP.
- **T4 outil MCP** `crates/infrastructure/src/orchestrator/mcp/tools.rs` + commande `OrchestratorCommand::ReadSkill { name, requester }` + methode `read_skill` dans `OrchestratorService` + petit use case `ReadSkill` (compose le port `SkillStore` existant, AUCUN nouveau port).
- **T5 cablage** `crates/app-tauri/src/state.rs` : injecter `ReadSkill` dans `OrchestratorService` (builder additif, comme le fix context-guard) + re-exports application (orchestrator/mod.rs + lib.rs).
- **T6 front (differable)** `frontend` : champ description dans creation/edition de skill.
- **T7 e2e** : rebuild AppImage + relance, agent neuf + skill assigne -> voit le manifeste + appelle `idea_skill_read`.
## Pieges (Architect)
Retro-compat `index.json` (serde default) ; resolution de nom ambigue (projet d'abord, erreur typee sinon) ; un skill assigne en cours de session reste invisible jusqu'au relaunch (limite inherente, comme MCP) ; pas de nouveau port, domaine = +1 champ, composition root seul point de cablage. GO Architect.

View File

@ -34,5 +34,121 @@
}
],
"fallback": "allow"
},
"agents": [
{
"agentId": "a6ced819-b893-4213-b003-9e9dc79b9641",
"permissions": {
"rules": [
{
"capability": "read",
"effect": "allow",
"paths": [
"**"
],
"commands": []
},
{
"capability": "write",
"effect": "deny",
"paths": [
"**"
],
"commands": []
},
{
"capability": "delete",
"effect": "allow",
"paths": [
"**"
],
"commands": []
},
{
"capability": "executeBash",
"effect": "allow",
"paths": [],
"commands": []
}
],
"fallback": "allow"
}
},
{
"agentId": "dce19c75-9669-4e45-b8de-9950025157da",
"permissions": {
"rules": [
{
"capability": "read",
"effect": "allow",
"paths": [
"**"
],
"commands": []
},
{
"capability": "write",
"effect": "deny",
"paths": [
"**"
],
"commands": []
},
{
"capability": "delete",
"effect": "deny",
"paths": [
"**"
],
"commands": []
},
{
"capability": "executeBash",
"effect": "allow",
"paths": [],
"commands": []
}
],
"fallback": "allow"
}
},
{
"agentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5",
"permissions": {
"rules": [
{
"capability": "read",
"effect": "allow",
"paths": [
"**"
],
"commands": []
},
{
"capability": "write",
"effect": "deny",
"paths": [
"**"
],
"commands": []
},
{
"capability": "delete",
"effect": "deny",
"paths": [
"**"
],
"commands": []
},
{
"capability": "executeBash",
"effect": "allow",
"paths": [],
"commands": []
}
],
"fallback": "allow"
}
}
]
}

View File

@ -647,11 +647,13 @@ IdeA/
**Convention file généré par IdeA** : IdeA écrit dans ce dossier le fichier conventionnel attendu par le profil (`CLAUDE.md`, `AGENTS.md`, etc.). Ce fichier contient :
1. Le **chemin absolu du project root** (pour que l'agent sache où opérer).
2. Le contrat d'**orchestration IdeA** (délégation via `.ideai/requests`, pas via les subagents natifs du fournisseur).
2. Le contrat d'**orchestration IdeA** + brief capacités (délégation via outils `idea_*` en surface MCP, sinon via `.ideai/requests` — jamais les subagents natifs du fournisseur).
3. Le **contexte projet partagé** (`.ideai/CONTEXT.md`), si présent.
4. La **persona/rôle** de l'agent (son `.md` dans `.ideai/agents/`).
5. Les **skills actifs** assignés à cet agent (voir §14.2).
6. Le **rappel mémoire** du projet (index/hooks), si présent (voir §14.5.4).
6. Le **rappel mémoire** du projet (index/hooks — pointeurs), si présent (voir §14.5.4).
7. L'**état du projet** (section `# État du projet`) — projection *live-state* maigre « qui fait quoi maintenant », bornée (cap `LIVE_STATE_INJECT_MAX`, agent lancé exclu, ordre manifeste, vide ⇒ omise) (LS4 ; voir §21 et `docs/LS8`).
8. La **reprise de la conversation** (section `# Reprise de la conversation`) — *handoff* distillé du fil, **borné** (`HANDOFF_SUMMARY_MAX_CHARS=4096`) à l'écriture **et** à l'injection (LS5 ; voir §21). Omise sans handoff.
**Avantages** :
- Zéro collision entre agents, même N instances du même profil.
@ -1926,16 +1928,16 @@ Nouvelles commandes (jumelles des commandes PTY existantes ; réutilisent `resol
- **Vivant de bout en bout** : `apply_mcp_config` matérialise `.mcp.json` dans le **run dir isolé** de l'agent **AVANT** le split structuré/PTY (`crates/application/src/agent/lifecycle.rs` ~1094) ⇒ Claude/Codex le lisent **nativement**. La déclaration porte l'**exe réel** injecté par `McpRuntime` (`$APPIMAGE` sinon `current_exe`, `crates/app-tauri/src/mcp_endpoint.rs:139` `idea_exe_path`), l'**endpoint loopback** du projet, le `--project` et le `--requester` (agent réel, fin du `"mcp"` figé).
- **Endpoint loopback** = **UDS** (Linux/macOS) / **named pipe** (Windows), **zéro port réseau** ; source de vérité unique `mcp_endpoint(project_id)` (`mcp_endpoint.rs`), bindé à l'open / fermé au close (`crates/app-tauri/src/state.rs` `ensure_mcp_server`/`bind_endpoint`). **Fix D1** : cadavre `.sock` (run SIGKILL) **unlinké avant bind** (`state.rs` ~1019-1033) — sinon `EADDRINUSE`.
- **Serveur** `McpServer` (`crates/infrastructure/src/orchestrator/mcp/server.rs`) = **jumeau du `FsOrchestratorWatcher`** : autre porte sur le **même** `OrchestratorService::dispatch`, `serve(conn)` **par pair**. Transport `StdioTransport` (JSON Lines stdin/stdout du pont) + `MemoryTransport` (tests, sans socket ni process). Pont = sous-commande `mcp-server` du binaire app-tauri (`mcp_bridge.rs`).
- **Outils** `idea_ask_agent` / `idea_reply` / `idea_list_agents` (`mcp/tools.rs`) mappés 1:1 vers `OrchestratorCommand` ; `dispatch` appelé **à l'identique** par les trois portes (fichier, MCP, UI).
- **Outils** (`mcp/tools.rs`) — **catalogue de 14**, tous mappés 1:1 vers `OrchestratorCommand` et servis par le **même** `dispatch` (les trois portes : fichier, MCP, UI) : `idea_list_agents`, `idea_ask_agent`, `idea_reply`, `idea_launch_agent`, `idea_stop_agent`, `idea_update_context`, `idea_create_skill` (7 base) · `idea_context_read`, `idea_context_propose`, `idea_memory_read`, `idea_memory_write` (4 FileGuard C7) · `idea_skill_read` (skill-awareness) · `idea_workstate_read`, `idea_workstate_set` (LS4 ; `set` n'écrit que la ligne de l'agent courant, via l'identité handshake).
### 18.6 Invariant « 1 agent = 1 session vivante » (livré)
- Registres `TerminalSessions` + `StructuredSessions` (`crates/application/src/terminal/registry.rs`) agrégés en `LiveSessions` (PTY+structuré). `session_for_agent` (singulier, **non ambigu**) **+** `sessions_for_agent` (pluriel). Garde reattach `Rebind`/`Refuse`/`Idempotent` dans `LaunchAgent` (`lifecycle.rs`). Ancienne ambiguïté `session-registry-agent-ambiguity` = **fermée par construction**.
---
## 19. Cadrage — couche de persistance conversationnelle + handoff cross-profile incrémental (chantier à découper, PAS d'implémentation)
## 19. Persistance conversationnelle + handoff cross-profile incrémental (cadrage — LIVRÉ P1→P8 + LS1→LS7)
> **Cadrage architecture** (le prochain chantier prioritaire après robustesse : **persistance/reprise → handoff cross-profile**). Produit les **ports**, les **adapters**, les **frontières** et un **découpage en lots testables**. **Aucun code applicatif ici** : la doc est livrable, les lots seront confiés aux binômes Dev/Test (cycle §3).
> **⚠️ STATUT : LIVRÉ (clôture programme live-state/persistance, @ `fd7adbb`).** Ce qui suit était le **cadrage** ; il est désormais **implémenté** (P1→P8 du handoff, puis LS1→LS7 : live-state, borne `summary_md`, rotation/pagination, viewer). La **cartographie de clôture** (4 stores, flux d'injection, chemin chaud vs froid, ce qui reste ouvert) fait foi en **§21** et dans [`docs/LS8-live-state-persistence-closure.md`](docs/LS8-live-state-persistence-closure.md). Précisions sur l'état réel : (1) **P10 (`LlmHandoffSummarizer`)** reste **non activé** — le défaut runtime est l'heuristique borné (LS5, ADR `docs/adr/LS5-handoff-summary-bound-and-llm-seam.md`) ; (2) la **borne `summary_md`** (LS5 : `HANDOFF_SUMMARY_MAX_CHARS=4096`, `bound_handoff_summary`, `TURN_LINE_MAX_CHARS=240`) est appliquée à l'écriture **et** à l'injection ; (3) la **rotation/rétention** du `log.jsonl` (LS6 : archive segmentée, port `ConversationArchive`, lecture paginée, invariant INV-LS6) et le **live-state** (LS1→LS4) ont été ajoutés **au-delà** de ce cadrage initial. Le texte ci-dessous est conservé comme **genèse** ; en cas de divergence, **§21 + `docs/LS8` font foi**.
### 19.0 Problème & objectif produit
Aujourd'hui la continuité d'une conversation repose sur le **`resumable_id` CLI** (`Conversation.resumable_id`, profil `SessionStrategy{assign_flag,resume_flag}`) : au redémarrage on **rejoue la session du provider** (`--resume <id>`). Deux trous :
@ -2345,4 +2347,32 @@ pub enum ScheduledTask {
---
## 21. Clôture du programme live-state / persistance (LS1→LS7) — cartographie des 4 stores
> **Référence durable de clôture** (programme livré @ `fd7adbb`). Détail complet, acquis lot par lot et points ouverts : [`docs/LS8-live-state-persistence-closure.md`](docs/LS8-live-state-persistence-closure.md). §21 fait foi sur la séparation des stores et le flux d'injection.
### 21.1 Quatre stores disjoints (frontière gravée)
| Store | Fichier(s) | Nature | Surface | Versionné | Injecté agent |
|---|---|---|---|---|---|
| Mémoire projet | `.ideai/memory/*.md` + `MEMORY.md` | Savoir stable, curé, low-noise | Agent + humain | Oui | Oui (index/hooks — pointeurs) |
| Handoff | `.ideai/conversations/<id>/handoff.md` | Reprise par fil = dérivé distillé **borné** (≤4096) | Agent (distillé) | Non | Oui (`# Reprise de la conversation`, borné LS5) |
| Transcript | `.ideai/conversations/<id>/log.jsonl` (+ `log.N.jsonl`) | Journal append-only **riche**, source de vérité | **Humain seul** | cf. point ouvert | **JAMAIS** |
| Live-state | `.ideai/live-state.json` | Coordination transitoire **maigre**, keyed LWW, prune TTL+max | Agent (maigre) + humain | Non (gitignoré) | Oui (`# État du projet`, borné LS4) |
**Règle** : surface AGENT = borné/distillé/pointeur ; surface HUMAINE = riche. Le **transcript append-only ne franchit JAMAIS** vers un contexte agent ; seul le **handoff distillé** passe la frontière (borné des deux côtés). Le live-state est keyed last-writer-wins (jamais d'append).
### 21.2 Injection au lancement (ordre `compose_convention_file`)
`# Project root` → `# Orchestration IdeA` (+ capacités) → `# Skills disponibles` → `# Contexte projet` → persona → `# Mémoire projet` (pointeurs) → **`# État du projet`** (live-state lean, LS4) → **`# Reprise de la conversation`** (handoff borné, LS5). Sections vides omises.
### 21.3 Chemin chaud vs froid
- **Chaud** (cheap, jamais ralenti) : `append` (O(1)+fsync), `fold` incrémental + `bound_handoff_summary`, auto-update live-state best-effort sur `ask`/`reply`. **Aucun LLM.**
- **Froid** : rotation `RotateConversationLog` (à la reprise, best-effort, idempotente, **INV-LS6** : jamais d'élagage d'un tour d'id ≥ `up_to`), lecture paginée `read_conversation_page` (viewer humain), `GetLiveStateLean` (prune-on-read).
### 21.4 Reste ouvert
(1) activation réelle du seam LLM (non activé, défaut heuristique, contrat ADR LS5) ; (2) balayage périodique de rotation (idempotent, non câblé) ; (3) discordance D19-4 vs `.gitignore` sur `.ideai/conversations/` (à trancher Git/Main) ; (4) intégration MCP e2e UX ; (5) évolutions multi-fenêtres du registre de sessions ; (6) auto-update mémoire/contexte *en cours* de session. Détail : `docs/LS8` §7.
*Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.*

246
CLAUDE.md
View File

@ -1,205 +1,93 @@
# IdeA — Contexte & Méthode de travail
# IdeA — Contexte projet global
> Ce document définit **mon rôle**, **la méthode de développement** et **la vision produit** du projet IdeA.
> Il fait autorité sur la façon dont le projet est piloté. Toute évolution de méthode doit être répercutée ici.
> Contexte commun minimal du projet IdeA. Les détails spécialisés doivent vivre dans le contexte de l'agent propriétaire, pas ici.
---
## 1. Mon rôle : chef d'orchestre, pas développeur
## 1. Project root
Je **n'écris pas de code moi-même**. Mon rôle est de **piloter des agents** qui réalisent le travail.
Je suis responsable de :
Le project root est :
- Découper le travail en tâches claires et autonomes.
- Attribuer chaque tâche aux bons agents.
- Garantir que le cycle de développement/test est respecté.
- Faire respecter les principes d'architecture (SOLID, Hexagonal).
- Maintenir la cohérence globale du projet et de ce document.
- Arbitrer et valider avant toute action irréversible ou sortante.
---
## 2. Les agents
### 2.1 Agent Architecture (1 pour tout le projet)
- Garant de l'architecture globale : **Hexagonale (Ports & Adapters)** et principes **SOLID**.
- Définit les frontières (domaine / application / infrastructure), les ports, les contrats.
- Valide que chaque nouvelle feature respecte la structure avant son développement.
- Tient à jour la cartographie d'architecture et les conventions.
### 2.2 Agents de Développement
- Écrivent le code des features.
- Respectent strictement l'architecture définie par l'agent Architecture.
- Code **propre, structuré, stable**.
- Reçoivent les rapports d'erreurs des agents de test et corrigent.
### 2.3 Agents de Test
- **Chaque agent de développement est appairé avec un agent de test dédié.**
- Écrivent et exécutent les **tests unitaires** des features implémentées ou modifiées.
- Produisent un **rapport d'erreurs** clair quand un test échoue.
- 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)
Pour **chaque** feature implémentée ou modifiée :
```
1. Agent Architecture → valide le découpage et les contrats (ports/interfaces)
2. Agent Git → décide de la branche (créer feature/*, switch, ou rester)
3. Agent Développement → écrit le code
4. Agent Test → écrit les tests unitaires + les exécute
5a. Tests OK → feature validée
→ 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)
```text
/home/anthony/Documents/Projects/IdeA
```
**Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas.
Je relaie fidèlement les résultats : si des tests échouent, je le dis avec la sortie réelle.
Les agents peuvent être lancés depuis un dossier d'exécution isolé `.ideai/run/<agent>/`, mais leurs travaux portent sur le project root.
---
## 4. Principes de code
## 2. Orchestration IdeA
- **SOLID** appliqué au maximum.
- **Architecture Hexagonale** (Ports & Adapters) : le domaine métier est isolé des détails techniques (UI, terminal, git, SSH, système de fichiers...).
- Le cœur métier ne dépend d'aucun framework ni d'aucune dépendance externe.
- Tests unitaires systématiques ; couverture des features critiques.
- Code lisible, cohérent avec le style existant, faiblement couplé, fortement cohésif.
Les agents collaborent via les outils IdeA natifs :
- `idea_list_agents` pour lister les agents.
- `idea_ask_agent` pour déléguer une tâche et attendre la réponse.
- `idea_launch_agent` pour lancer ou rattacher un agent.
- `idea_reply` obligatoire pour répondre à une tâche déléguée préfixée `[IdeA · tâche … · ticket …]`.
Ne jamais utiliser les subagents natifs du fournisseur IA pour déléguer dans ce projet.
---
## 5. Vision produit : IdeA
## 3. Rôles
**IdeA est un IDE next-gen 100 % IA.** On n'y code pas : **on gère des IA.**
### Fonctionnalités clés
- **Multi-projets en parallèle** : un **onglet par projet**.
- **Fenêtre = espace de travail** où l'on **organise plusieurs terminaux** librement.
- **Agents par projet** : chaque projet a ses propres agents.
- **Agents templates** : agents réutilisables, ajoutables à plusieurs projets.
- **Création d'agents** : depuis zéro ou à partir d'un template.
- **Synchronisation template → agents** : option « garder l'agent à jour ».
Si le template est mis à jour, les agents qui en sont issus (avec l'option activée) reçoivent la mise à jour.
- **Contextes d'agents stockés en `.md`** (toujours).
- **Création de projet** = définition de son **project root**.
### Intégrations
- **Git** intégré.
- **Développement distant SSH** : travailler sur un projet hébergé sur une autre machine via SSH.
- **Développement WSL** : travailler sur une WSL depuis Windows.
### Plateformes & livraison
- Cible : **macOS, Linux, Windows**.
- Première phase de compilation : **Linux et Windows**.
- Livraison :
- **Windows** : `setup.exe`.
- **Linux** : **AppImage** (doit fonctionner sur les différentes distributions).
- **Main** : chef d'orchestre. Il découpe, délègue, relaie les résultats, arbitre le produit et garantit le cycle. Il ne code pas les features.
- **Architect** : propriétaire de l'architecture hexagonale, SOLID, ports/adapters, contrats, DTO, invariants et cartographie.
- **DevBackend** : implémentation backend Rust selon les contrats validés par Architect.
- **DevFrontend** : implémentation UI TypeScript/React selon les contrats validés par Architect.
- **QA** : tests unitaires/intégration ciblés, exécution réelle, rapports d'échec, re-test jusqu'au vert.
- **Git** : propriétaire des branches, commits, merges/rebases locaux. Aucune action sortante sans validation explicite.
---
## 6. Stack technique (validée)
## 4. Cycle obligatoire
- **Shell applicatif** : **Tauri v2** (binaires légers, performants, multi-OS, AppImage + installeur `setup.exe`/NSIS Windows natifs).
- **Cœur / backend** : **Rust** — stabilité, performance, et expression idiomatique du domaine hexagonal (ports = traits, adapters = implémentations).
- **Frontend / UI** : **TypeScript + React**.
- **Terminaux** : **xterm.js** (rendu) + **portable-pty** (PTY côté Rust).
- **Git** : **libgit2** via `git2` (Rust).
- **SSH** : `russh` / `ssh2` (Rust).
- **WSL** : invocation de `wsl.exe` depuis le backend.
Pour toute feature ou correction applicative :
## 7. Layout des terminaux (exigence produit)
Disposition en **grille redimensionnable de type tableur (Excel)** :
- Splits redimensionnables horizontaux **et** verticaux.
- L'utilisateur peut **définir le nombre de colonnes dans une ligne** et **le nombre de lignes dans une colonne**, indépendamment par zone.
- Possibilité de **fusionner des cellules** (ex. fusionner deux colonnes sur une ligne), à la manière des cellules fusionnées d'un tableur.
- Chaque cellule de la grille héberge un terminal.
- → Modèle de layout récursif/imbriqué (pas une grille rigide uniforme) à concevoir par l'agent Architecture.
## 8. Stockage des contextes & liaison aux templates
- **Templates d'agents** : stockés dans l'**IDE** (dossier de données utilisateur global de l'app, hors projet).
- **Agents de projet** : leurs `.md` sont stockés dans un dossier **`.ideai/`** à la racine du project root.
*(Nom choisi pour éviter toute collision avec le `.idea` de JetBrains.)*
- **Manifeste de liaison** dans `.ideai/` (ex. `.ideai/agents.json`) qui mappe pour chaque agent de projet :
- le `.md` de l'agent,
- le template d'origine (le cas échéant),
- `synchronized: true/false`,
- la **version du template** au dernier sync (pour détecter qu'une mise à jour est disponible).
- **Synchro template → agents** : quand un template est mis à jour, les agents liés avec `synchronized: true` reçoivent la MAJ.
## 9. Moteur IA : adaptateur de CLI flexible (Port `AgentRuntime`)
Chaque IA est décrite par un **profil déclaratif** (config éditable, pas du code), implémentation d'un **Port** `AgentRuntime` côté domaine. Deux variables clés par IA :
1. **Commande de lancement** + arguments (ex. `claude`, `codex`, `gemini`, `aider`).
2. **Stratégie d'injection du contexte `.md`** :
- `conventionFile` : écrire/symlink le `.md` vers le fichier attendu par la CLI (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`…).
- `flag` : passer le chemin via un argument.
- `stdin` : piper le contenu.
- `env` : passer via variable d'environnement.
Exemple de profil :
```json
{
"id": "claude-code",
"name": "Claude Code",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
"detect": "claude --version",
"cwd": "{projectRoot}"
}
```text
1. Architect cadre ou valide l'architecture et les contrats.
2. Git décide de la branche locale.
3. DevBackend et/ou DevFrontend implémente.
4. QA écrit/exécute les tests.
5. Si KO : Main relaie le rapport réel au dev, puis retour QA.
6. Si OK : Git committe et décide du merge local éventuel.
```
**Profils intégrés (références) :** Claude Code (`claude``CLAUDE.md`), OpenAI Codex CLI (`codex``AGENTS.md`), Gemini CLI (`gemini``GEMINI.md`), Aider (`aider` → args/message).
**Règles produit :**
- **Premier lancement de l'IDE** : un assistant (first-run) **demande à l'utilisateur** quels profils d'IA configurer. On ne présume rien par défaut.
- Les commandes des profils sont **pré-remplies mais éditables**.
- L'utilisateur peut **ajouter sa propre commande CLI** (profil custom) pour n'importe quelle IA.
**Lancement d'un agent :** à l'**activation de l'agent**, on ouvre une cellule terminal (PTY) avec le bon `cwd`, on injecte le contexte `.md`, et on **auto-lance** la CLI du profil.
## 10. Fenêtres & onglets
- **Par défaut : un onglet par projet** (comme les IDE classiques).
- **Drag & drop d'un onglet** hors de la fenêtre → **crée une nouvelle fenêtre OS** portant ce projet.
- **Multi-fenêtres OS supporté** ; chaque fenêtre possède un ou plusieurs onglets/projets.
## 11. Feuille de route
1. **Cadrage architecture complet d'abord** (jalon en cours) : l'agent Architecture produit la cartographie complète — domaine, ports, adapters, modules, arborescence — **avant tout code**.
2. Puis MVP incrémental selon le cycle dev/test de la section 3.
## 12. Autonomie d'exécution dans le projet
L'utilisateur m'accorde un **accès large et autonome** sur le dossier du projet : je peux lire, créer, modifier des fichiers et exécuter les commandes de développement (cargo, npm, npx, git, etc.) **sans demander confirmation à chaque fois**.
- Concrètement, ces autorisations sont matérialisées dans `.claude/settings.local.json` (mode `acceptEdits` + `Bash`/`Read`/`Edit`/`Write` autorisés), pas dans ce document — CONTEXT.md ne fait que **documenter l'intention**.
- **Garde-fous conservés** : les actions destructrices ou hors-projet restent bloquées (`sudo`, `rm -rf` sur `/`/`~`/`$HOME`, `mkfs`, `dd`, `shutdown`/`reboot`…).
- L'esprit du rôle (§1) ne change pas : je reste **chef d'orchestre**. L'autonomie porte sur l'exécution mécanique, pas sur l'arbitrage des décisions produit/archi, ni sur les **actions sortantes** (push, publication) qui restent soumises à validation explicite.
Une feature n'est terminée que lorsque les tests pertinents sont verts avec sortie réelle.
---
*Dernière mise à jour : 2026-06-05*
## 5. Produit : repères communs
IdeA est un IDE next-gen 100 % IA : l'utilisateur y pilote des agents IA plutôt que coder directement.
Repères stables :
- Un onglet par projet, multi-fenêtres OS à terme.
- Workspace organisé en terminaux/cellules redimensionnables.
- Agents par projet, templates globaux, synchronisation template → agents.
- Contextes d'agents en Markdown dans `.ideai/`.
- Profils IA déclaratifs et éditables, configurés au premier lancement.
- Git, SSH et WSL intégrés.
- Stack validée : Tauri v2, Rust, TypeScript + React, xterm.js, portable-pty, git2/libgit2, russh/ssh2, `wsl.exe`.
Les détails d'architecture et de découpage technique appartiennent au contexte d'Architect. Les détails de développement et de test appartiennent aux contextes DevBackend, DevFrontend et QA.
---
## 6. Mémoire projet
Consulter la mémoire projet selon le besoin au lieu de recopier tous les détails ici :
- `agent-context-memory-and-profile-handoff`
- `idea-product-directives-main-handoff`
- `remaining-work-idea-agent-control-ide`
- `mcp-bridge-and-delegation-runtime-notes`
- `permissions-sandbox-system-state`
- `session-limit-handling-design`
- `git-owns-commit-merge-decisions`
- `conversation-rotation-safety-design`
---
*Dernière mise à jour : 2026-06-20*

8
Cargo.lock generated
View File

@ -69,7 +69,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "app-tauri"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"application",
"async-trait",
@ -88,7 +88,7 @@ dependencies = [
[[package]]
name = "application"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"async-trait",
"domain",
@ -880,7 +880,7 @@ dependencies = [
[[package]]
name = "domain"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"async-trait",
"serde",
@ -1936,7 +1936,7 @@ dependencies = [
[[package]]
name = "infrastructure"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"application",
"async-trait",

View File

@ -1,6 +1,6 @@
[package]
name = "app-tauri"
version = "0.2.0"
version = "0.3.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true

View File

@ -9,17 +9,19 @@ use tauri::State;
use crate::dto::DismissEmbedderSuggestionRequestDto;
use application::{
AppError, AssignSkillToAgentInput, ChangeAgentProfileInput, CloseProjectInput,
CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput, DeleteAgentInput,
DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput, DeleteSkillInput,
DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput, GitBranchesInput, GitCheckoutInput,
GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput,
ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput,
McpRuntime, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadMemoryIndexInput,
ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, RenameLayoutInput,
ResolveAgentPermissionsInput, ResolveMemoryLinksInput, SetActiveLayoutInput,
SnapshotRunningAgentsInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
AppError, AssignSkillToAgentInput, AttachLiveAgentInput, ChangeAgentProfileInput,
CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput,
DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput,
DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput,
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput,
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
LaunchAgentInput, ListAgentsInput, ListLayoutsInput, ListMemoriesInput,
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime,
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput,
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
RenameLayoutInput, ResolveAgentPermissionsInput, ResolveMemoryLinksInput,
RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
UpdateAgentContextInput, UpdateAgentPermissionsInput, UpdateMemoryInput,
UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
};
@ -29,26 +31,27 @@ use crate::dto::{
parse_agent_id, parse_close_terminal, parse_delete_profile, parse_layout_id, parse_memory_slug,
parse_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id,
parse_template_id, parse_ticket_id, AgentDriftListDto, AgentDto, AgentListDto,
AssignSkillRequestDto, AttachLiveAgentRequestDto, ChangeAgentProfileDto,
ChangeAgentProfileRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ConfigureProfilesRequestDto,
ConversationDetailsDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto,
CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto,
CreateSkillRequestDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitDto,
GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
ProjectPermissionsDto, ReadAgentContextResponseDto, ReattachChatDto, ReattachResultDto,
RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto, SkillListDto,
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
TerminalClosedDto, TerminalSessionDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto,
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
WriteTerminalRequestDto,
@ -365,7 +368,46 @@ pub fn write_terminal(
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let input = request.into_input()?;
state.write_terminal.execute(input).map_err(ErrorDto::from)
let session_id = input.session_id;
let bytes = input.data.len();
let control = describe_terminal_write(&input.data);
application::diag!(
"[pty-write] write_terminal start: session={session_id} bytes={bytes} control={control}"
);
match state.write_terminal.execute(input) {
Ok(()) => {
application::diag!(
"[pty-write] write_terminal ok: session={session_id} bytes={bytes} control={control}"
);
Ok(())
}
Err(e) => {
application::diag!(
"[pty-write] write_terminal failed: session={session_id} bytes={bytes} \
control={control} error={e}"
);
Err(ErrorDto::from(e))
}
}
}
fn describe_terminal_write(data: &[u8]) -> String {
if data.is_empty() {
return "<empty>".to_owned();
}
if data.len() > 16 {
return "<bulk>".to_owned();
}
data.iter()
.map(|byte| match *byte {
b'\r' => "\\r".to_owned(),
b'\n' => "\\n".to_owned(),
0x7f => "\\x7f".to_owned(),
byte if byte < 0x20 => format!("\\x{byte:02x}"),
byte => char::from(byte).to_string(),
})
.collect::<Vec<_>>()
.join("")
}
/// `resize_terminal` — resize a live PTY.
@ -930,6 +972,58 @@ pub async fn list_agents(
.map_err(ErrorDto::from)
}
/// `get_project_work_state` — read-only live/busy state for manifest agents.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on manifest I/O failure).
#[tauri::command]
pub async fn get_project_work_state(
project_id: String,
state: State<'_, AppState>,
) -> Result<ProjectWorkStateDto, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
state
.get_project_work_state
.execute(GetProjectWorkStateInput { project })
.await
.map(ProjectWorkStateDto::from)
.map_err(ErrorDto::from)
}
/// `read_conversation_page` — human, paginated read of a conversation's **full**
/// transcript (lot LS6). Archive-aware (segments + active), text never truncated.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed project/conversation id,
/// `NOT_FOUND` if the project is unknown, `STORE` on log I/O failure).
#[tauri::command]
pub async fn read_conversation_page(
request: ReadConversationPageRequestDto,
state: State<'_, AppState>,
) -> Result<TurnPageDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let conversation = uuid::Uuid::parse_str(&request.conversation_id)
.map(domain::ConversationId::from_uuid)
.map_err(|_| ErrorDto {
code: "INVALID".to_owned(),
message: format!("invalid conversation id: {}", request.conversation_id),
})?;
let cursor = request.cursor();
let limit = request.limit.unwrap_or(0);
state
.read_conversation_page
.execute(ReadConversationPageInput {
project_root: project.root,
conversation,
cursor,
limit,
})
.await
.map(TurnPageDto::from)
.map_err(ErrorDto::from)
}
/// `list_live_agents` — list every agent that currently owns a live session
/// (raw PTY **or** structured/chat) and the cell hosting each, so the UI can
/// disable an agent already running in another cell (the "one live session per
@ -979,20 +1073,45 @@ pub fn list_live_agents(
pub async fn attach_live_agent(
request: AttachLiveAgentRequestDto,
state: State<'_, AppState>,
) -> Result<LiveAgentListDto, ErrorDto> {
let _project = resolve_project(&request.project_id, &state).await?;
) -> Result<AttachLiveAgentResponseDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
let node_id = parse_node_id(&request.node_id)?;
let session = state
.terminal_sessions
.rebind_agent_node(&agent_id, node_id)
.ok_or_else(|| AppError::NotFound(format!("running session for agent {agent_id}")))?;
Ok(LiveAgentListDto::from_pairs(vec![(
state
.attach_live_agent
.execute(AttachLiveAgentInput {
project,
agent_id,
session.node_id,
session.id,
)]))
node_id,
})
.map(AttachLiveAgentResponseDto::from)
.map_err(ErrorDto::from)
}
/// `stop_live_agent` — tear down an already-running agent's live session by agent
/// id, polymorphically (PTY kill or structured shutdown), without removing the
/// agent, its tickets, conversation summary or handoff.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
/// project/agent is unknown or the agent has no live session, `PROCESS` on a
/// kill/shutdown failure).
#[tauri::command]
pub async fn stop_live_agent(
request: StopLiveAgentRequestDto,
state: State<'_, AppState>,
) -> Result<StopLiveAgentResponseDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
// Backstop no-reply : arrêter l'observateur de fin de tour de l'agent (le handle est
// droppé ⇒ polling stoppé) avant de démonter sa session.
state.stop_turn_watch(agent_id);
state
.stop_live_agent
.execute(StopLiveAgentInput { project, agent_id })
.await
.map(StopLiveAgentResponseDto::from)
.map_err(ErrorDto::from)
}
/// `read_agent_context` — read an agent's Markdown context.
@ -1141,6 +1260,12 @@ pub async fn launch_agent(
// agent so an auto-resume (which only carries agent/node/conversation) can recompose a
// full `LaunchAgentInput`. Cloned here — `project` is moved into the launch below.
let resume_project = project.clone();
// Lot LS6 — project root captured before `project` moves, for the off-hot-path log
// rotation triggered at thread (re)open below.
let rotation_root = project.root.clone();
// Backstop no-reply — project root captured before `project` moves, to arm the
// end-of-turn transcript watcher for the launched agent (cf. `AppState::arm_turn_watch`).
let watch_root = project.root.clone();
let output = state
.launch_agent
@ -1169,6 +1294,41 @@ pub async fn launch_agent(
);
}
// Backstop no-reply : armer l'observateur de fin de tour transcript pour la cible si
// son profil est supporté (Claude). Idempotent par remplacement à chaque (re)lancement
// effectif (profil résolu présent) ; no-op sur reattach (profil `None`) — le handle
// posé au 1er lancement persiste. cwd = run dir isolé de l'agent.
if let Some(profile) = output.profile.as_ref() {
state.arm_turn_watch(
&watch_root,
agent_id,
profile,
output.assigned_conversation_id.clone(),
);
}
// Lot LS6 — rotation **best-effort** du log, déclenchée à la (re)ouverture du fil et
// **hors chemin chaud** : détachée (`tokio::spawn`) pour n'ajouter aucune latence au
// launch, erreurs **avalées** (la rotation ne doit jamais casser une reprise). Un
// `append` ne déclenche JAMAIS la rotation. Skippée si la cellule ne porte pas une
// `conversation_id` UUID (rien à roter).
if let Some(conversation) = request
.conversation_id
.as_deref()
.and_then(|raw| uuid::Uuid::parse_str(raw).ok())
.map(domain::ConversationId::from_uuid)
{
let rotate = std::sync::Arc::clone(&state.rotate_conversation_log);
tokio::spawn(async move {
let _ = rotate
.execute(RotateConversationLogInput {
project_root: rotation_root,
conversation,
})
.await;
});
}
let session_id = output.session.id;
// Host cell of the freshly (re)launched session — the pivot the level-2 tap reports
// to the service alongside the agent.
@ -1217,8 +1377,8 @@ pub async fn launch_agent(
// est un raté best-effort connu pour LS7.
if let Some(parser) = &rate_limit_parser {
let text = String::from_utf8_lossy(&chunk);
if let Some(limit) =
parser.detect(&text, domain::ports::Clock::now_millis(&*detect_clock))
if let Some(limit) = parser
.detect(&text, domain::ports::Clock::now_millis(&*detect_clock))
{
service.on_rate_limited(
agent_id,
@ -1378,10 +1538,7 @@ pub async fn agent_send(
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed agent id).
#[tauri::command]
pub async fn cancel_resume(
agent_id: String,
state: State<'_, AppState>,
) -> Result<bool, ErrorDto> {
pub async fn cancel_resume(agent_id: String, state: State<'_, AppState>) -> Result<bool, ErrorDto> {
let id = parse_agent_id(&agent_id)?;
Ok(state.session_limit_service.cancel_resume(id))
}
@ -1483,6 +1640,10 @@ pub async fn delegation_delivered(
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
let ticket = parse_ticket_id(&request.ticket)?;
application::diag!(
"[delivery] delegation_delivered command: project={} agent={agent_id} ticket={ticket}",
project.id
);
state
.orchestrator_service
.note_delegation_delivered(&project, agent_id, ticket);
@ -1506,6 +1667,10 @@ pub async fn set_front_attached(
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let agent_id = parse_agent_id(&request.agent_id)?;
application::diag!(
"[delivery] set_front_attached command: agent={agent_id} attached={}",
request.attached
);
state
.orchestrator_service
.set_agent_front_attached(agent_id, request.attached);
@ -2032,6 +2197,10 @@ pub async fn create_skill(
.create_skill
.execute(CreateSkillInput {
name: request.name,
// Description is set via the dedicated frontend field (T6); the create
// path stays None for now so the affordance falls back to the body's
// first line (see `Skill::effective_description`).
description: None,
content: request.content,
scope: request.scope,
project_root: project.root,

View File

@ -9,10 +9,13 @@
use serde::{Deserialize, Serialize};
use application::{
AppError, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, HealthReport,
LayoutKind, ListProjectsOutput, OpenProjectOutput,
AgentTicketState, AppError, AttachLiveAgentOutput, ConversationPreviewStatus,
ConversationTurnWorkPreview, ConversationWorkSummary, CreateProjectInput, CreateProjectOutput,
GitGraphOutput, HealthInput, HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind,
OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus,
TurnPage, TurnSource, TurnView,
};
use domain::{Project, ProjectId};
use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole};
/// Request DTO for the `health` command.
#[derive(Debug, Clone, Default, Deserialize)]
@ -1472,6 +1475,270 @@ impl LiveAgentListDto {
}
}
/// Runtime family of a live work-state session.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum LiveWorkSessionKindDto {
/// Raw PTY-backed CLI session.
Pty,
/// Structured agent-session backend.
Structured,
}
impl From<LiveSessionKind> for LiveWorkSessionKindDto {
fn from(kind: LiveSessionKind) -> Self {
match kind {
LiveSessionKind::Pty => Self::Pty,
LiveSessionKind::Structured => Self::Structured,
}
}
}
/// Live session coordinates in the project work-state read model.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveWorkSessionDto {
/// The layout node currently hosting the session view.
pub node_id: String,
/// The live session id.
pub session_id: String,
/// Runtime family that owns the session.
pub kind: LiveWorkSessionKindDto,
}
/// Derived processing status of a queued ticket.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum TicketWorkStatusDto {
/// The agent's current busy turn is running this ticket.
InProgress,
/// Waiting behind the head / the agent is idle.
Queued,
}
impl From<TicketWorkStatus> for TicketWorkStatusDto {
fn from(status: TicketWorkStatus) -> Self {
match status {
TicketWorkStatus::InProgress => Self::InProgress,
TicketWorkStatus::Queued => Self::Queued,
}
}
}
/// Origin of a queued ticket (human operator or a delegating agent).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum TicketWorkSourceDto {
/// The human operator.
Human,
/// Another agent delegating via `idea_ask_agent`.
#[serde(rename_all = "camelCase")]
Agent {
/// The delegating agent id.
agent_id: String,
},
}
impl From<TicketWorkSource> for TicketWorkSourceDto {
fn from(source: TicketWorkSource) -> Self {
match source {
TicketWorkSource::Human => Self::Human,
TicketWorkSource::Agent { agent_id } => Self::Agent {
agent_id: agent_id.to_string(),
},
}
}
}
/// One queued/in-progress delegation ticket for an agent.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentTicketStateDto {
/// Stable id of the queued ticket.
pub ticket_id: String,
/// Conversation thread this task enters.
pub conversation_id: String,
/// FIFO position at snapshot time (`0` = head).
pub position: u32,
/// Derived status (in-progress vs queued).
pub status: TicketWorkStatusDto,
/// Origin of the ticket.
pub source: TicketWorkSourceDto,
/// Display label of the requester.
pub requester_label: String,
/// Bounded excerpt of the task.
pub task_preview: String,
/// Character length of the original (un-truncated) task.
pub task_len: usize,
}
impl From<AgentTicketState> for AgentTicketStateDto {
fn from(ticket: AgentTicketState) -> Self {
Self {
ticket_id: ticket.ticket_id.to_string(),
conversation_id: ticket.conversation_id.to_string(),
position: ticket.position,
status: ticket.status.into(),
source: ticket.source.into(),
requester_label: ticket.requester_label,
task_preview: ticket.task_preview,
task_len: ticket.task_len,
}
}
}
/// One manifest agent's current live/busy state.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentWorkStateDto {
/// Agent id.
pub agent_id: String,
/// Agent display name.
pub name: String,
/// Runtime profile id assigned to the agent.
pub profile_id: String,
/// Live session, if any.
pub live: Option<LiveWorkSessionDto>,
/// Current mediated-input busy state.
pub busy: AgentBusyState,
/// Pending/in-progress delegation tickets, in FIFO order.
pub tickets: Vec<AgentTicketStateDto>,
}
/// How much of a [`ConversationWorkSummaryDto`] could be derived, best-effort.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ConversationPreviewStatusDto {
/// A handoff was present and usable.
Ready,
/// No handoff yet; `recentTurns` may carry the log fallback.
Missing,
/// The handoff was unreadable but the log fallback was readable.
Partial,
/// Neither the handoff nor the log could be read.
Unavailable,
}
impl From<ConversationPreviewStatus> for ConversationPreviewStatusDto {
fn from(status: ConversationPreviewStatus) -> Self {
match status {
ConversationPreviewStatus::Ready => Self::Ready,
ConversationPreviewStatus::Missing => Self::Missing,
ConversationPreviewStatus::Partial => Self::Partial,
ConversationPreviewStatus::Unavailable => Self::Unavailable,
}
}
}
/// One recent turn surfaced in a conversation summary's log fallback.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversationTurnWorkPreviewDto {
/// Nature of the turn (`prompt`/`response`/`toolActivity`).
pub role: TurnRole,
/// Origin of the turn (human operator or a delegating agent).
pub source: TicketWorkSourceDto,
/// Timestamp (epoch milliseconds) of the turn.
pub at_ms: u64,
/// Bounded excerpt of the turn text.
pub text_preview: String,
/// Character length of the original (un-truncated) turn text.
pub text_len: usize,
}
impl From<ConversationTurnWorkPreview> for ConversationTurnWorkPreviewDto {
fn from(turn: ConversationTurnWorkPreview) -> Self {
Self {
role: turn.role,
source: turn.source.into(),
at_ms: turn.at_ms,
text_preview: turn.text_preview,
text_len: turn.text_len,
}
}
}
/// Best-effort, read-only summary of one conversation visible through the tickets.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversationWorkSummaryDto {
/// The conversation (pair) this summary describes.
pub conversation_id: String,
/// How much could be derived.
pub status: ConversationPreviewStatusDto,
/// Bounded excerpt of the handoff objective, when present.
pub objective_preview: Option<String>,
/// Bounded excerpt of the handoff summary, when present.
pub summary_preview: Option<String>,
/// Character length of the original (un-truncated) handoff summary (`0` when none).
pub summary_len: usize,
/// Cursor (last turn id) covered by the handoff summary, when present.
pub up_to: Option<String>,
/// Bounded, recent turns from the log fallback.
pub recent_turns: Vec<ConversationTurnWorkPreviewDto>,
}
impl From<ConversationWorkSummary> for ConversationWorkSummaryDto {
fn from(summary: ConversationWorkSummary) -> Self {
Self {
conversation_id: summary.conversation_id.to_string(),
status: summary.status.into(),
objective_preview: summary.objective_preview,
summary_preview: summary.summary_preview,
summary_len: summary.summary_len,
up_to: summary.up_to.map(|cursor| cursor.to_string()),
recent_turns: summary
.recent_turns
.into_iter()
.map(ConversationTurnWorkPreviewDto::from)
.collect(),
}
}
}
/// Project-level read model for conversation/delegation UX.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectWorkStateDto {
/// Manifest agents in manifest order, enriched with live/busy state.
pub agents: Vec<AgentWorkStateDto>,
/// Best-effort summaries of the conversations referenced by the tickets,
/// joined frontend-side via `tickets[].conversationId`.
pub conversations: Vec<ConversationWorkSummaryDto>,
}
impl From<ProjectWorkState> for ProjectWorkStateDto {
fn from(state: ProjectWorkState) -> Self {
Self {
agents: state
.agents
.into_iter()
.map(|agent| AgentWorkStateDto {
agent_id: agent.agent_id.to_string(),
name: agent.name,
profile_id: agent.profile_id.to_string(),
live: agent.live.map(|live| LiveWorkSessionDto {
node_id: live.node_id.to_string(),
session_id: live.session_id.to_string(),
kind: live.kind.into(),
}),
busy: agent.busy,
tickets: agent
.tickets
.into_iter()
.map(AgentTicketStateDto::from)
.collect(),
})
.collect(),
conversations: state
.conversations
.into_iter()
.map(ConversationWorkSummaryDto::from)
.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)]
@ -1485,6 +1752,64 @@ pub struct AttachLiveAgentRequestDto {
pub node_id: String,
}
/// Response DTO for `attach_live_agent`: the rebound live session's coordinates.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AttachLiveAgentResponseDto {
/// The rebound agent's id.
pub agent_id: String,
/// The node now hosting the session view.
pub node_id: String,
/// The (unchanged) live session id.
pub session_id: String,
/// Runtime family that owns the session (`pty`/`structured`).
pub kind: LiveWorkSessionKindDto,
}
impl From<AttachLiveAgentOutput> for AttachLiveAgentResponseDto {
fn from(out: AttachLiveAgentOutput) -> Self {
Self {
agent_id: out.agent_id.to_string(),
node_id: out.node_id.to_string(),
session_id: out.session_id.to_string(),
kind: out.kind.into(),
}
}
}
/// Request DTO for `stop_live_agent`: tear down an already-running agent's live
/// session by agent id (no spawn, agent entity preserved).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StopLiveAgentRequestDto {
/// Id of the owning project (validated for symmetry and future scoping).
pub project_id: String,
/// Id of the running agent to stop.
pub agent_id: String,
}
/// Response DTO for `stop_live_agent`: the torn-down session's coordinates.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StopLiveAgentResponseDto {
/// The stopped agent's id.
pub agent_id: String,
/// The id of the session that was torn down.
pub session_id: String,
/// Runtime family that owned the session (`pty`/`structured`).
pub kind: LiveWorkSessionKindDto,
}
impl From<StopLiveAgentOutput> for StopLiveAgentResponseDto {
fn from(out: StopLiveAgentOutput) -> Self {
Self {
agent_id: out.agent_id.to_string(),
session_id: out.session_id.to_string(),
kind: out.kind.into(),
}
}
}
/// Parses an agent-id string (UUID) coming from the frontend.
///
/// # Errors
@ -2275,3 +2600,249 @@ pub fn parse_memory_slug(raw: &str) -> Result<MemorySlug, ErrorDto> {
message: format!("invalid memory slug: {raw}"),
})
}
// ── Conversation pagination (lot LS6) ────────────────────────────────────────
/// Request DTO for `read_conversation_page` — a human, paginated transcript read.
///
/// `anchor` is a turn id to paginate around (omit to start from a thread end);
/// `direction` is `"forward"` (towards newer) or `"backward"` (towards older,
/// default — the human view opens on the latest page); `limit` is clamped by the
/// domain to `[1, 200]` (omit/`0` ⇒ default 50).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadConversationPageRequestDto {
/// The owning project's id.
pub project_id: String,
/// The conversation (pair) id to read.
pub conversation_id: String,
/// Optional anchor turn id; omit to start from a thread end.
#[serde(default)]
pub anchor: Option<String>,
/// Pagination direction (`"forward"` or `"backward"`); defaults to backward.
#[serde(default)]
pub direction: Option<String>,
/// Requested page size (omit/`0` ⇒ default; clamped to `[1, 200]`).
#[serde(default)]
pub limit: Option<usize>,
}
impl ReadConversationPageRequestDto {
/// Builds the domain [`PageCursor`] from the request (default direction backward,
/// an unparseable anchor degrades to `None` = a thread-end page).
#[must_use]
pub fn cursor(&self) -> PageCursor {
let direction = match self.direction.as_deref() {
Some(d) if d.eq_ignore_ascii_case("forward") => PageDirection::Forward,
_ => PageDirection::Backward,
};
let anchor = self
.anchor
.as_deref()
.and_then(|raw| uuid::Uuid::parse_str(raw).ok())
.map(domain::TurnId::from_uuid);
PageCursor { anchor, direction }
}
}
/// Origin of a turn in the human transcript view (mirrors [`TurnSource`]).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum TurnSourceDto {
/// The human operator.
Human,
/// Another agent (delegation via `idea_ask_agent`).
#[serde(rename_all = "camelCase")]
Agent {
/// The originating agent id.
agent_id: String,
},
}
impl From<TurnSource> for TurnSourceDto {
fn from(source: TurnSource) -> Self {
match source {
TurnSource::Human => Self::Human,
TurnSource::Agent { agent_id } => Self::Agent {
agent_id: agent_id.to_string(),
},
}
}
}
/// One turn in the human transcript — **full text, never truncated**.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TurnViewDto {
/// Stable turn id (also the pagination anchor).
pub id: String,
/// Timestamp (epoch milliseconds).
pub at_ms: u64,
/// Turn nature (prompt, response, tool activity).
pub role: TurnRole,
/// Origin (human or delegating agent).
pub source: TurnSourceDto,
/// The turn's **complete** text (not truncated).
pub text: String,
/// Character length of the text.
pub text_len: usize,
}
impl From<TurnView> for TurnViewDto {
fn from(turn: TurnView) -> Self {
Self {
id: turn.id.to_string(),
at_ms: turn.at_ms,
role: turn.role,
source: turn.source.into(),
text: turn.text,
text_len: turn.text_len,
}
}
}
/// A page of the human transcript, oldest-to-newest.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TurnPageDto {
/// The page's turns, oldest to newest.
pub turns: Vec<TurnViewDto>,
/// Whether more turns exist beyond the page in the travel direction.
pub has_more: bool,
/// Last turn id of the page (anchor for the next request), or `None` if empty.
pub next_anchor: Option<String>,
}
impl From<TurnPage> for TurnPageDto {
fn from(page: TurnPage) -> Self {
Self {
turns: page.turns.into_iter().map(TurnViewDto::from).collect(),
has_more: page.has_more,
next_anchor: page.next_anchor.map(|id| id.to_string()),
}
}
}
#[cfg(test)]
mod ls6_pagination_tests {
//! LS6 — DTO de la lecture humaine paginée : parsing du curseur + mapping riche
//! `TurnPage → TurnPageDto` (texte complet non borné, source Human/Agent, `has_more`,
//! `next_anchor`). C'est le « round-trip d'une page » testable sans `State` Tauri.
use super::*;
use application::{TurnPage, TurnSource, TurnView};
fn tid(n: u128) -> domain::TurnId {
domain::TurnId::from_uuid(uuid::Uuid::from_u128(n))
}
#[test]
fn request_cursor_defaults_to_backward_and_parses_anchor() {
// Pas de direction ⇒ backward (la vue humaine ouvre sur la page la plus récente).
let req = ReadConversationPageRequestDto {
project_id: "p".into(),
conversation_id: "c".into(),
anchor: None,
direction: None,
limit: None,
};
let cur = req.cursor();
assert_eq!(cur.direction, PageDirection::Backward);
assert_eq!(cur.anchor, None);
// Forward (insensible à la casse) + ancre UUID valide.
let u = uuid::Uuid::from_u128(9);
let req = ReadConversationPageRequestDto {
project_id: "p".into(),
conversation_id: "c".into(),
anchor: Some(u.to_string()),
direction: Some("Forward".into()),
limit: Some(10),
};
let cur = req.cursor();
assert_eq!(cur.direction, PageDirection::Forward);
assert_eq!(cur.anchor, Some(domain::TurnId::from_uuid(u)));
// Ancre non-UUID ⇒ dégrade en None (page depuis un bout du fil), jamais d'erreur.
let req = ReadConversationPageRequestDto {
project_id: "p".into(),
conversation_id: "c".into(),
anchor: Some("not-a-uuid".into()),
direction: Some("backward".into()),
limit: None,
};
assert_eq!(req.cursor().anchor, None);
}
#[test]
fn turn_page_dto_round_trips_full_text_and_maps_sources() {
let big = "Z".repeat(40_000);
let page = TurnPage {
turns: vec![
TurnView {
id: tid(1),
at_ms: 1_700_000_000_000,
role: TurnRole::Prompt,
source: TurnSource::Human,
text: big.clone(),
text_len: big.chars().count(),
},
TurnView {
id: tid(2),
at_ms: 1_700_000_000_001,
role: TurnRole::Response,
source: TurnSource::Agent {
agent_id: domain::AgentId::from_uuid(uuid::Uuid::from_u128(7)),
},
text: "réponse".to_owned(),
text_len: 7,
},
],
has_more: true,
next_anchor: Some(tid(2)),
};
let dto = TurnPageDto::from(page);
// Texte complet préservé (non borné) + text_len.
assert_eq!(dto.turns[0].text.chars().count(), 40_000);
assert_eq!(dto.turns[0].text_len, 40_000);
// next_anchor stringifié, has_more porté.
assert!(dto.has_more);
assert_eq!(
dto.next_anchor.as_deref(),
Some(tid(2).to_string().as_str())
);
// Round-trip JSON (la « page » telle qu'envoyée au front) : camelCase + source.
let json = serde_json::to_value(&dto).unwrap();
assert_eq!(json["hasMore"], serde_json::json!(true));
assert_eq!(json["nextAnchor"], serde_json::json!(tid(2).to_string()));
assert_eq!(
json["turns"][0]["source"]["kind"],
serde_json::json!("human")
);
assert_eq!(
json["turns"][1]["source"]["kind"],
serde_json::json!("agent")
);
assert_eq!(
json["turns"][1]["source"]["agentId"],
serde_json::json!(uuid::Uuid::from_u128(7).to_string())
);
// Le texte intégral survit au passage JSON.
assert_eq!(
json["turns"][0]["text"].as_str().unwrap().chars().count(),
40_000
);
}
#[test]
fn empty_turn_page_dto_has_no_next_anchor() {
let dto = TurnPageDto::from(TurnPage {
turns: Vec::new(),
has_more: false,
next_anchor: None,
});
assert!(dto.turns.is_empty() && !dto.has_more && dto.next_anchor.is_none());
}
}

View File

@ -172,6 +172,16 @@ pub enum DomainEventDto {
/// badge the source.
source: OrchestrationSourceDto,
},
/// The project's orchestrator designation changed (T1).
#[serde(rename_all = "camelCase")]
OrchestratorChanged {
/// The project whose designation changed.
project_id: String,
/// The newly designated orchestrator agent, or `None` for the default
/// (the oldest agent orchestrates).
#[serde(skip_serializing_if = "Option::is_none")]
orchestrator: Option<String>,
},
/// A memory note was created or updated.
#[serde(rename_all = "camelCase")]
MemorySaved {
@ -401,6 +411,13 @@ impl From<&DomainEvent> for DomainEventDto {
ok: *ok,
source: (*source).into(),
},
DomainEvent::OrchestratorChanged {
project_id,
orchestrator,
} => Self::OrchestratorChanged {
project_id: project_id.to_string(),
orchestrator: orchestrator.as_ref().map(|a| a.to_string()),
},
DomainEvent::MemorySaved { slug } => Self::MemorySaved {
slug: slug.as_str().to_string(),
},

View File

@ -75,6 +75,12 @@ pub fn run() {
.path()
.app_data_dir()
.expect("failed to resolve the app data directory");
// Point the orchestrator's best-effort diagnostics at a persistent file
// (`<app-data>/logs/idea.log`) so inter-agent rendezvous beacons survive a
// click-launched AppImage (whose stderr is otherwise discarded). Best-effort:
// if the file can't be opened the beacons simply stay on stderr.
application::diag::set_log_path(app_data_dir.join("logs").join("idea.log"));
application::diag!("[startup] IdeA launched; diagnostics log armed");
let app_state = AppState::build(app_data_dir);
// Wire the domain event bus → Tauri events relay.
@ -157,8 +163,11 @@ pub fn run() {
commands::dismiss_embedder_suggestion,
commands::create_agent,
commands::list_agents,
commands::get_project_work_state,
commands::read_conversation_page,
commands::list_live_agents,
commands::attach_live_agent,
commands::stop_live_agent,
commands::read_agent_context,
commands::update_agent_context,
commands::delete_agent,

View File

@ -508,8 +508,14 @@ mod tests {
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}");
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

View File

@ -87,13 +87,29 @@ impl McpEndpoint {
/// 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).
///
/// The candidate bases are tried **in priority order**, returning the first whose
/// `idea-mcp` subdir exists or can be created. A set-but-unusable `$XDG_RUNTIME_DIR`
/// (e.g. a sandbox/CI where it points at a non-existent or read-only path) must not
/// dead-end the loopback bind: without this fallback the socket silently never
/// binds and inter-agent delegation dies. Deterministic for a given environment, so
/// the bind side and any `socket_path()` reader always agree on the same directory.
#[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")
let candidates = [
std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from),
std::env::var_os("TMPDIR").map(PathBuf::from),
Some(PathBuf::from("/tmp")),
];
for base in candidates.into_iter().flatten() {
let dir = base.join("idea-mcp");
if dir.is_dir() || std::fs::create_dir_all(&dir).is_ok() {
return dir;
}
}
// Last resort: keep the historical `/tmp` target even if creation just failed
// (the bind will surface the real error rather than silently picking nowhere).
PathBuf::from("/tmp").join("idea-mcp")
}
/// **Single source of truth.** Computes the deterministic loopback endpoint of a

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "IdeA",
"version": "0.2.0",
"version": "0.3.0",
"identifier": "app.idea.ide",
"build": {
"frontendDist": "../../frontend/dist",

View File

@ -9,7 +9,9 @@ use app_tauri_lib::dto::{
};
use application::AppError;
use application::{
CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, ListAgentsOutput,
AgentTicketState, AgentWorkState, CreateAgentOutput, InspectConversationOutput,
LaunchAgentOutput, ListAgentsOutput, LiveSessionKind, LiveWorkSession, ProjectWorkState,
TicketWorkSource, TicketWorkStatus,
};
use domain::ids::{AgentId, NodeId, ProfileId, SessionId};
use domain::ports::ConversationDetails;
@ -197,6 +199,187 @@ fn live_agent_list_dto_serialises_camelcase_array() {
assert!(arr[0].get("node_id").is_none());
}
#[test]
fn project_work_state_dto_serialises_live_and_busy_camelcase() {
let agent = AgentId::from_uuid(Uuid::from_u128(11));
let profile = ProfileId::from_uuid(Uuid::from_u128(12));
let node = NodeId::from_uuid(Uuid::from_u128(13));
let session = SessionId::from_uuid(Uuid::from_u128(14));
let ticket = domain::TicketId::from_uuid(Uuid::from_u128(15));
let dto = app_tauri_lib::dto::ProjectWorkStateDto::from(ProjectWorkState {
agents: vec![AgentWorkState {
agent_id: agent,
name: "Worker".to_owned(),
profile_id: profile,
live: Some(LiveWorkSession {
node_id: node,
session_id: session,
kind: LiveSessionKind::Structured,
}),
busy: domain::AgentBusyState::Busy {
ticket,
since_ms: 1_234,
},
tickets: vec![],
}],
conversations: vec![],
});
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["agents"][0]["agentId"], agent.to_string());
assert_eq!(v["agents"][0]["profileId"], profile.to_string());
assert_eq!(v["agents"][0]["live"]["nodeId"], node.to_string());
assert_eq!(v["agents"][0]["live"]["sessionId"], session.to_string());
assert_eq!(v["agents"][0]["live"]["kind"], "structured");
assert_eq!(v["agents"][0]["busy"]["state"], "busy");
assert_eq!(v["agents"][0]["busy"]["ticket"], ticket.to_string());
assert_eq!(v["agents"][0]["busy"]["sinceMs"], 1_234);
assert_eq!(v["agents"][0]["tickets"], json!([]));
assert!(v["agents"][0].get("agent_id").is_none());
assert!(v["agents"][0]["live"].get("session_id").is_none());
assert!(v["agents"][0]["busy"].get("since_ms").is_none());
}
#[test]
fn project_work_state_dto_serialises_tickets_camelcase() {
let agent = AgentId::from_uuid(Uuid::from_u128(11));
let profile = ProfileId::from_uuid(Uuid::from_u128(12));
let requester = AgentId::from_uuid(Uuid::from_u128(20));
let head_ticket = domain::TicketId::from_uuid(Uuid::from_u128(30));
let next_ticket = domain::TicketId::from_uuid(Uuid::from_u128(31));
let conversation = domain::ConversationId::from_uuid(Uuid::from_u128(40));
let dto = app_tauri_lib::dto::ProjectWorkStateDto::from(ProjectWorkState {
agents: vec![AgentWorkState {
agent_id: agent,
name: "Worker".to_owned(),
profile_id: profile,
live: None,
busy: domain::AgentBusyState::Idle,
tickets: vec![
AgentTicketState {
ticket_id: head_ticket,
conversation_id: conversation,
position: 0,
status: TicketWorkStatus::InProgress,
source: TicketWorkSource::Agent {
agent_id: requester,
},
requester_label: "Main".to_owned(),
task_preview: "Analyser le module".to_owned(),
task_len: 842,
},
AgentTicketState {
ticket_id: next_ticket,
conversation_id: conversation,
position: 1,
status: TicketWorkStatus::Queued,
source: TicketWorkSource::Human,
requester_label: "User".to_owned(),
task_preview: "Vérifier".to_owned(),
task_len: 8,
},
],
}],
conversations: vec![],
});
let v = serde_json::to_value(&dto).unwrap();
let head = &v["agents"][0]["tickets"][0];
assert_eq!(head["ticketId"], head_ticket.to_string());
assert_eq!(head["conversationId"], conversation.to_string());
assert_eq!(head["position"], 0);
assert_eq!(head["status"], "inProgress");
assert_eq!(head["source"]["kind"], "agent");
assert_eq!(head["source"]["agentId"], requester.to_string());
assert_eq!(head["requesterLabel"], "Main");
assert_eq!(head["taskPreview"], "Analyser le module");
assert_eq!(head["taskLen"], 842);
// snake_case must not leak.
assert!(head.get("ticket_id").is_none());
assert!(head.get("task_preview").is_none());
assert!(head["source"].get("agent_id").is_none());
let next = &v["agents"][0]["tickets"][1];
assert_eq!(next["status"], "queued");
assert_eq!(next["source"]["kind"], "human");
assert!(next["source"].get("agentId").is_none());
assert_eq!(next["requesterLabel"], "User");
}
#[test]
fn project_work_state_dto_serialises_conversations_camelcase() {
use application::{
ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary,
};
use domain::{ConversationId, TurnId, TurnRole};
let requester = AgentId::from_uuid(Uuid::from_u128(20));
let ready_conv = ConversationId::from_uuid(Uuid::from_u128(40));
let missing_conv = ConversationId::from_uuid(Uuid::from_u128(41));
let up_to = TurnId::from_uuid(Uuid::from_u128(50));
let dto = app_tauri_lib::dto::ProjectWorkStateDto::from(ProjectWorkState {
agents: vec![],
conversations: vec![
ConversationWorkSummary {
conversation_id: ready_conv,
status: ConversationPreviewStatus::Ready,
objective_preview: Some("Livrer le lot C".to_owned()),
summary_preview: Some("Résumé du fil".to_owned()),
summary_len: 1024,
up_to: Some(up_to),
recent_turns: vec![],
},
ConversationWorkSummary {
conversation_id: missing_conv,
status: ConversationPreviewStatus::Missing,
objective_preview: None,
summary_preview: None,
summary_len: 0,
up_to: None,
recent_turns: vec![ConversationTurnWorkPreview {
role: TurnRole::Prompt,
source: TicketWorkSource::Agent {
agent_id: requester,
},
at_ms: 1_700,
text_preview: "Analyse le module".to_owned(),
text_len: 17,
}],
},
],
});
let v = serde_json::to_value(&dto).unwrap();
let ready = &v["conversations"][0];
assert_eq!(ready["conversationId"], ready_conv.to_string());
assert_eq!(ready["status"], "ready");
assert_eq!(ready["objectivePreview"], "Livrer le lot C");
assert_eq!(ready["summaryPreview"], "Résumé du fil");
assert_eq!(ready["summaryLen"], 1024);
assert_eq!(ready["upTo"], up_to.to_string());
assert_eq!(ready["recentTurns"], json!([]));
// No snake_case leak.
assert!(ready.get("conversation_id").is_none());
assert!(ready.get("summary_preview").is_none());
assert!(ready.get("up_to").is_none());
let missing = &v["conversations"][1];
assert_eq!(missing["status"], "missing");
// Absent previews surface as null (predictable, not omitted).
assert!(missing["summaryPreview"].is_null());
assert!(missing["upTo"].is_null());
let turn = &missing["recentTurns"][0];
assert_eq!(turn["role"], "prompt");
assert_eq!(turn["source"]["kind"], "agent");
assert_eq!(turn["source"]["agentId"], requester.to_string());
assert_eq!(turn["atMs"], 1_700);
assert_eq!(turn["textPreview"], "Analyse le module");
assert_eq!(turn["textLen"], 17);
assert!(turn.get("at_ms").is_none());
assert!(turn.get("text_preview").is_none());
}
#[test]
fn launch_agent_request_carries_conversation_id_for_resume() {
let raw = json!({
@ -369,3 +552,82 @@ fn launch_agent_output_propagates_assigned_conversation_id() {
"no snake_case leak"
);
}
// ---------------------------------------------------------------------------
// Lot D — attach/stop live agent DTOs (controlled actions)
// ---------------------------------------------------------------------------
#[test]
fn attach_live_agent_request_deserialises_camelcase() {
use app_tauri_lib::dto::AttachLiveAgentRequestDto;
let raw = json!({
"projectId": Uuid::from_u128(1).to_string(),
"agentId": Uuid::from_u128(2).to_string(),
"nodeId": Uuid::from_u128(3).to_string()
});
let dto: AttachLiveAgentRequestDto = 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.node_id, Uuid::from_u128(3).to_string());
}
#[test]
fn attach_live_agent_response_serialises_camelcase_with_kind() {
use app_tauri_lib::dto::AttachLiveAgentResponseDto;
use application::{AttachLiveAgentOutput, LiveSessionKind};
let agent = AgentId::from_uuid(Uuid::from_u128(11));
let node = NodeId::from_uuid(Uuid::from_u128(12));
let session = SessionId::from_uuid(Uuid::from_u128(13));
let dto = AttachLiveAgentResponseDto::from(AttachLiveAgentOutput {
agent_id: agent,
node_id: node,
session_id: session,
kind: LiveSessionKind::Pty,
});
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["agentId"], agent.to_string());
assert_eq!(v["nodeId"], node.to_string());
assert_eq!(v["sessionId"], session.to_string());
assert_eq!(v["kind"], "pty");
// No snake_case leak.
assert!(v.get("agent_id").is_none());
assert!(v.get("session_id").is_none());
}
#[test]
fn stop_live_agent_request_deserialises_camelcase() {
use app_tauri_lib::dto::StopLiveAgentRequestDto;
let raw = json!({
"projectId": Uuid::from_u128(1).to_string(),
"agentId": Uuid::from_u128(2).to_string()
});
let dto: StopLiveAgentRequestDto = 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());
}
#[test]
fn stop_live_agent_response_serialises_camelcase_with_kind() {
use app_tauri_lib::dto::StopLiveAgentResponseDto;
use application::{LiveSessionKind, StopLiveAgentOutput};
let agent = AgentId::from_uuid(Uuid::from_u128(11));
let session = SessionId::from_uuid(Uuid::from_u128(13));
let dto = StopLiveAgentResponseDto::from(StopLiveAgentOutput {
agent_id: agent,
session_id: session,
kind: LiveSessionKind::Structured,
});
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["agentId"], agent.to_string());
assert_eq!(v["sessionId"], session.to_string());
assert_eq!(v["kind"], "structured");
// Stop response carries no node id (the session is gone).
assert!(v.get("nodeId").is_none());
assert!(v.get("session_id").is_none());
}

View File

@ -16,9 +16,9 @@ use std::time::Duration;
use app_tauri_lib::state::AppState;
use domain::events::DomainEvent;
use domain::ids::NodeId;
use domain::ports::IdGenerator;
use domain::AgentId;
use infrastructure::UuidGenerator;
use domain::ports::IdGenerator;
fn temp_path(tag: &str) -> PathBuf {
let ids = UuidGenerator::new();
@ -124,7 +124,10 @@ async fn set_resume_at_resolves_no_cell_for_an_agent_without_a_live_session() {
.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)");
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
@ -156,8 +159,14 @@ async fn confirm_human_resume_arms_a_cancellable_resume_over_the_real_bus() {
_ => break,
}
}
assert!(saw_rate_limited, "AgentRateLimited relayed on the bus (humain)");
assert!(saw_scheduled, "AgentResumeScheduled relayed on the bus (humain)");
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!(

View File

@ -1,6 +1,6 @@
[package]
name = "application"
version = "0.2.0"
version = "0.3.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true

View File

@ -24,6 +24,11 @@ use domain::profile::{
StructuredAdapter,
};
/// Codex's interactive TUI is sensitive to receiving a large pasted block and the
/// submit key too close together. Keep the default conservative so delegated
/// prompts are actually submitted instead of remaining in the input editor.
pub const CODEX_SUBMIT_DELAY_MS: u32 = 350;
/// A fixed UUID namespace used to derive stable ids for reference profiles.
/// (Random-looking but constant; only its stability matters.)
const REFERENCE_NAMESPACE: uuid::Uuid = uuid::uuid!("6f9b1d2a-7c34-4e58-9a1b-2c3d4e5f6a7b");
@ -82,6 +87,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
.expect("codex reference profile is valid")
.with_structured_adapter(StructuredAdapter::Codex)
.with_projector(ProjectorKey::Codex)
.with_submit_delay_ms(CODEX_SUBMIT_DELAY_MS)
.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

File diff suppressed because it is too large Load Diff

View File

@ -22,15 +22,19 @@ 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 catalogue::{
reference_profile_id, reference_profiles,
selectable_reference_profiles, CODEX_SUBMIT_DELAY_MS,
};
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
pub use lifecycle::{
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
ListAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
InjectedLiveRow, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
ListAgentsOutput, LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry,
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET, LIVE_STATE_INJECT_MAX,
};
pub use resume::{
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,

View File

@ -35,8 +35,7 @@ use crate::error::AppError;
/// `--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é.";
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
@ -161,7 +160,13 @@ impl SessionLimitService {
conversation_id,
} = plan_resume(now, &limit, conversation_id)
{
self.arm_scheduled(agent_id, fire_at_ms, node_id, conversation_id, Some(resets_at_ms));
self.arm_scheduled(
agent_id,
fire_at_ms,
node_id,
conversation_id,
Some(resets_at_ms),
);
}
}
@ -199,9 +204,14 @@ impl SessionLimitService {
conversation_id,
},
);
self.armed.lock().expect("session-limit mutex sain").insert(agent_id, id);
self.events
.publish(DomainEvent::AgentResumeScheduled { agent_id, fire_at_ms });
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`]
@ -255,7 +265,10 @@ impl SessionLimitService {
};
if self.scheduler.cancel(id) {
self.armed.lock().expect("session-limit mutex sain").remove(&agent_id);
self.armed
.lock()
.expect("session-limit mutex sain")
.remove(&agent_id);
self.events
.publish(DomainEvent::AgentResumeCancelled { agent_id });
true

View File

@ -14,8 +14,8 @@
use std::time::Duration;
use domain::input::InputMediator;
use domain::ids::AgentId;
use domain::input::InputMediator;
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
use domain::readiness::{ReadinessPolicy, ReadinessSignal};
@ -178,16 +178,15 @@ async fn drain_bounded_events(
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),
)
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,
}
}
@ -302,11 +301,10 @@ mod tests {
AgentBusyState::Idle
}
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
fn bind_handle_with_prompt(
fn bind_handle_with_submit(
&self,
_agent: AgentId,
_handle: PtyHandle,
_pattern: Option<String>,
_submit: SubmitConfig,
) {
}
@ -317,7 +315,9 @@ mod tests {
let session = FakeSession {
events: vec![
ReplyEvent::TextDelta { text: "a".into() },
ReplyEvent::ToolActivity { label: "lit".into() },
ReplyEvent::ToolActivity {
label: "lit".into(),
},
ReplyEvent::Heartbeat,
ReplyEvent::Final {
content: "fini".into(),

View File

@ -13,6 +13,13 @@
//! the PTY or `app-tauri` (that is P6b). It is fully testable with in-memory fakes
//! of the three ports.
mod paginate;
mod record;
mod rotate;
pub use paginate::{
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, TurnPage,
TurnSource, TurnView,
};
pub use record::RecordTurn;
pub use rotate::{RotateConversationLog, RotateConversationLogInput};

View File

@ -0,0 +1,148 @@
//! [`ReadConversationPage`] — lecture **humaine** paginée d'une conversation (lot LS6).
//!
//! Lecture archive-aware (segments d'archive + actif) exposée comme un **DTO humain
//! riche** : contrairement au handoff (résumé borné) ou au read-model work-state
//! (aperçus tronqués), la page humaine porte le **texte complet, non borné** de chaque
//! tour — c'est la vue « transcript » destinée à l'utilisateur (le React arrive en LS7).
//!
//! Hors chemin chaud : composé du seul port [`ConversationArchive`] (via un provider par
//! root), jamais d'`append`/de rotation ici.
use std::sync::Arc;
use domain::project::ProjectPath;
use domain::{AgentId, ConversationArchive, ConversationId, PageCursor, TurnId, TurnRole};
use crate::error::AppError;
/// Fournit le [`ConversationArchive`] **lié au project root** courant (lot LS6),
/// calqué sur [`crate::HandoffProvider`]/[`crate::RecordTurnProvider`].
///
/// Les use cases d'archivage/pagination ([`crate::RotateConversationLog`],
/// [`ReadConversationPage`]) sont **uniques** pour tous les projets, alors que les logs
/// sont **par project root** (`<root>/.ideai/conversations/`) et l'adapter `Fs*` fixe sa
/// racine à la construction. Ce port matérialise un archive ciblant le **bon** dossier à
/// chaque appel. `None` ⇒ pas d'archivage/pagination (best-effort absent). Implémenté
/// dans `app-tauri` (seul détenteur des adapters `Fs*`).
pub trait ConversationArchiveProvider: Send + Sync {
/// Construit le [`ConversationArchive`] dont la persistance cible `root`, ou `None`.
fn conversation_archive_for(&self, root: &ProjectPath) -> Option<Arc<dyn ConversationArchive>>;
}
/// Origine d'un tour, pour la vue humaine paginée (miroir de [`domain::input::InputSource`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TurnSource {
/// L'opérateur humain.
Human,
/// Un autre agent (délégation via `idea_ask_agent`).
Agent {
/// L'agent à l'origine du tour.
agent_id: AgentId,
},
}
impl From<domain::input::InputSource> for TurnSource {
fn from(source: domain::input::InputSource) -> Self {
match source {
domain::input::InputSource::Human => Self::Human,
domain::input::InputSource::Agent { agent_id } => Self::Agent { agent_id },
}
}
}
/// Un tour projeté pour la vue humaine (lot LS6) — **texte complet, non borné**.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TurnView {
/// Identifiant stable du tour (sert aussi d'ancre de pagination).
pub id: TurnId,
/// Horodatage (epoch millisecondes).
pub at_ms: u64,
/// Nature du tour (invite, réponse, activité outillée).
pub role: TurnRole,
/// Origine (humain ou agent délégant).
pub source: TurnSource,
/// Le **texte intégral** du tour (jamais tronqué — c'est la vue transcript).
pub text: String,
/// Longueur (en caractères) du texte, pour l'UI.
pub text_len: usize,
}
impl From<domain::ConversationTurn> for TurnView {
fn from(turn: domain::ConversationTurn) -> Self {
let text_len = turn.text.chars().count();
Self {
id: turn.id,
at_ms: turn.at_ms,
role: turn.role,
source: turn.source.into(),
text: turn.text,
text_len,
}
}
}
/// Une page de tours pour la vue humaine (lot LS6).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TurnPage {
/// Les tours de la page, **du plus ancien au plus récent**.
pub turns: Vec<TurnView>,
/// `true` s'il existe d'autres tours au-delà de la page dans le sens de progression.
pub has_more: bool,
/// L'id du **dernier** tour de la page (ancre pour la requête suivante) ; `None` si
/// la page est vide.
pub next_anchor: Option<TurnId>,
}
/// Entrée de [`ReadConversationPage::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadConversationPageInput {
/// Le project root dont dériver l'archive.
pub project_root: ProjectPath,
/// La conversation (paire) à lire.
pub conversation: ConversationId,
/// Le curseur (ancre + sens) de pagination.
pub cursor: PageCursor,
/// La taille de page demandée (`0` ⇒ défaut ; clampée par le domaine).
pub limit: usize,
}
/// Use case de lecture humaine paginée (lot LS6) — consomme le seul
/// [`ConversationArchive`] (résolu par root via le provider).
pub struct ReadConversationPage {
archives: Arc<dyn ConversationArchiveProvider>,
}
impl ReadConversationPage {
/// Construit le use case à partir du provider d'archive par root.
#[must_use]
pub fn new(archives: Arc<dyn ConversationArchiveProvider>) -> Self {
Self { archives }
}
/// Lit une page de `conversation`, mappée vers le DTO humain riche.
///
/// Provider d'archive absent pour ce root ⇒ page **vide** (best-effort, jamais une
/// erreur dure). Le texte de chaque tour est renvoyé **intégralement**.
///
/// # Errors
/// [`AppError::Store`] si la lecture du log échoue.
pub async fn execute(&self, input: ReadConversationPageInput) -> Result<TurnPage, AppError> {
let Some(archive) = self.archives.conversation_archive_for(&input.project_root) else {
return Ok(TurnPage {
turns: Vec::new(),
has_more: false,
next_anchor: None,
});
};
let slice = archive
.page(input.conversation, input.cursor, input.limit)
.await?;
let next_anchor = slice.turns.last().map(|t| t.id);
let turns = slice.turns.into_iter().map(TurnView::from).collect();
Ok(TurnPage {
turns,
has_more: slice.has_more,
next_anchor,
})
}
}

View File

@ -2,7 +2,10 @@
use std::sync::Arc;
use domain::{ConversationId, ConversationLog, ConversationTurn, HandoffStore, HandoffSummarizer};
use domain::{
bound_handoff_summary, ConversationId, ConversationLog, ConversationTurn, HandoffStore,
HandoffSummarizer, HANDOFF_SUMMARY_MAX_CHARS,
};
use crate::error::AppError;
@ -63,6 +66,9 @@ impl RecordTurn {
/// 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) ;
/// 3b. **bound** the folded handoff's summary to [`HANDOFF_SUMMARY_MAX_CHARS`] — a
/// **universal cap independent of the summarizer** (heuristic *or* a future LLM):
/// idempotent, never rejecting, so it never blocks the checkpoint (LS5) ;
/// 4. **save** the advanced handoff.
///
/// The append happens **first** so the durable source of truth is never behind
@ -92,6 +98,13 @@ impl RecordTurn {
.fold(prev, std::slice::from_ref(&turn))
.await;
// 3b. **Universal bound** (LS5): clamp the resume summary to
// `HANDOFF_SUMMARY_MAX_CHARS`, *independently of the summarizer*. Whatever
// produced the handoff — the heuristic or a future LLM summarizer — the
// persisted (and later injected) summary is size-bounded. Idempotent and
// never rejecting, so it can never block the checkpoint.
let handoff = bound_handoff_summary(handoff, HANDOFF_SUMMARY_MAX_CHARS);
// 4. Persist the advanced handoff (overwrites the previous resume point).
self.handoffs.save(conversation, handoff).await?;

View File

@ -0,0 +1,95 @@
//! [`RotateConversationLog`] — rotation **best-effort** du log d'une conversation (lot LS6).
//!
//! Déclenchée **hors chemin chaud** (à la reprise/ouverture d'un fil, jamais par un
//! `append`), elle borne le segment actif en archivant sa tête froide. L'invariant pivot
//! (INV-LS6) est garanti par le **plancher** : `keep_from = up_to` du handoff courant ⇒
//! aucun tour `≥ up_to` n'est jamais archivé, donc `read(since = up_to)` (fold incrémental)
//! et la reprise restent corrects.
//!
//! Best-effort strict : pas de handoff (ou `up_to` nil) ⇒ skip ; toute erreur store est
//! typée et **avalée par l'appelant** (le câblage app-tauri), jamais propagée en échec de
//! reprise.
use std::sync::Arc;
use domain::project::ProjectPath;
use domain::{rotation_plan, ConversationId, RotationDecision, RotationThresholds};
use crate::conversation::ConversationArchiveProvider;
use crate::error::AppError;
use crate::HandoffProvider;
/// Entrée de [`RotateConversationLog::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RotateConversationLogInput {
/// Le project root dont dériver l'archive et le handoff.
pub project_root: ProjectPath,
/// La conversation (paire) à roter.
pub conversation: ConversationId,
}
/// Use case de rotation du log (lot LS6) — consomme un [`ConversationArchive`] et le
/// [`HandoffStore`] (pour lire `floor = up_to`), tous deux résolus par root.
///
/// [`ConversationArchive`]: domain::ConversationArchive
/// [`HandoffStore`]: domain::HandoffStore
pub struct RotateConversationLog {
archives: Arc<dyn ConversationArchiveProvider>,
handoffs: Arc<dyn HandoffProvider>,
thresholds: RotationThresholds,
}
impl RotateConversationLog {
/// Construit le use case à partir des providers par root (seuils par défaut).
#[must_use]
pub fn new(
archives: Arc<dyn ConversationArchiveProvider>,
handoffs: Arc<dyn HandoffProvider>,
) -> Self {
Self {
archives,
handoffs,
thresholds: RotationThresholds::defaults(),
}
}
/// Applique la politique de rotation à `conversation`, si nécessaire.
///
/// Étapes : résoudre l'archive + le handoff store du root ; lire le plancher
/// `floor = up_to` (pas de handoff ⇒ `None` ⇒ skip) ; lire les `stats` bon marché du
/// segment actif ; décider via [`rotation_plan`] ; archiver
/// (`rotate(keep_from = up_to)`) seulement si la décision est `Archive`.
///
/// # Errors
/// [`AppError::Store`] si la lecture du handoff/stats ou la rotation échoue. L'appelant
/// (câblage) **avale** cette erreur : la rotation ne doit jamais casser une reprise.
pub async fn execute(&self, input: RotateConversationLogInput) -> Result<(), AppError> {
// Sans provider d'archive/handoff câblé pour ce root ⇒ rien à faire (best-effort).
let Some(archive) = self.archives.conversation_archive_for(&input.project_root) else {
return Ok(());
};
let Some(handoff_store) = self.handoffs.handoff_store_for(&input.project_root) else {
return Ok(());
};
// Plancher = `up_to` du handoff courant (pas de handoff ⇒ None ⇒ skip plus bas).
let floor = handoff_store
.load(input.conversation)
.await?
.map(|handoff| handoff.up_to);
let stats = archive.stats(input.conversation).await?;
match rotation_plan(
stats.active_turns,
stats.active_bytes,
floor,
self.thresholds,
) {
RotationDecision::Skip => Ok(()),
RotationDecision::Archive { keep_from } => archive
.rotate(input.conversation, keep_from)
.await
.map_err(AppError::from),
}
}
}

View File

@ -0,0 +1,74 @@
//! Lightweight, dependency-free diagnostics sink for the orchestrator rendezvous.
//!
//! The orchestrator already emits best-effort traces through `eprintln!` (see
//! [`crate::orchestrator`]). When IdeA is launched by **clicking the AppImage**,
//! that stderr is discarded — so an inter-agent block (an `ask` that never gets
//! its `idea_reply`) leaves no retrievable trace. This module mirrors those
//! traces to a **persistent log file** the user can hand back for diagnosis,
//! while keeping the exact same "zero new dependency, never breaks the
//! rendezvous" discipline: every write is best-effort and infallible.
//!
//! - [`set_log_path`] is called once by the composition root (`app-tauri`) with
//! `<app-data>/logs/idea.log`. Until then (and in tests) writes go to stderr
//! only.
//! - [`diag`] (and the [`diag!`] macro) timestamps a line with epoch-millis — the
//! same clock as the conversation logs' `atMs` — and appends it to the file,
//! always also echoing to stderr so a terminal launch and the test suite still
//! see it.
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
/// The resolved log file path, set once at startup. `None` ⇒ stderr-only.
static LOG_PATH: OnceLock<PathBuf> = OnceLock::new();
/// Serialises concurrent appends so interleaved rendezvous beacons stay on their
/// own lines (the orchestrator handles many connections in parallel).
static WRITE_LOCK: Mutex<()> = Mutex::new(());
/// Points the diagnostics sink at `path`, creating its parent directory.
///
/// Idempotent and infallible: a second call (or a failure to create the
/// directory) is ignored — diagnostics must never break startup. Called by the
/// composition root once the app-data directory is known.
pub fn set_log_path(path: PathBuf) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = LOG_PATH.set(path);
}
/// Current epoch-millis (same clock as the conversation logs' `atMs`).
fn now_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
/// Appends one timestamped diagnostic line. Best-effort and infallible: a missing
/// path or an I/O error is swallowed (the line still reaches stderr). Never call
/// in a hot loop — these are lifecycle beacons, not a metrics stream.
pub fn diag(msg: impl std::fmt::Display) {
let line = format!("{} {msg}", now_ms());
// Always echo to stderr: keeps a terminal launch and the test suite working,
// and preserves the pre-existing `eprintln!` behaviour for these beacons.
eprintln!("{line}");
if let Some(path) = LOG_PATH.get() {
let _guard = WRITE_LOCK.lock();
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(f, "{line}");
}
}
}
/// `diag!("...", ...)` — formats then forwards to [`diag`]. Mirrors `eprintln!`.
#[macro_export]
macro_rules! diag {
($($arg:tt)*) => {
$crate::diag::diag(format!($($arg)*))
};
}

View File

@ -57,6 +57,18 @@ pub enum AppError {
node_id: NodeId,
},
/// The delegation target finished its turn and **returned to its prompt without
/// calling `idea_reply`**, so the rendezvous could produce no answer. Surfaced
/// promptly (after a short grace window) instead of blocking the caller until the
/// long safety timeout. **Retryable**: the caller may re-ask, ideally reminding the
/// target to render its result via `idea_reply` (never plain text). Carries the
/// target's display name.
#[error(
"agent {0} returned to its prompt without calling idea_reply (no answer was \
rendered); retry the request and ensure the agent replies via idea_reply"
)]
TargetReturnedNoReply(String),
/// An unexpected internal error.
#[error("internal error: {0}")]
Internal(String),
@ -76,6 +88,7 @@ impl AppError {
Self::Git(_) => "GIT",
Self::Remote(_) => "REMOTE",
Self::AgentAlreadyRunning { .. } => "AGENT_ALREADY_RUNNING",
Self::TargetReturnedNoReply(_) => "TARGET_RETURNED_NO_REPLY",
Self::Internal(_) => "INTERNAL",
}
}

View File

@ -13,6 +13,7 @@
pub mod agent;
pub mod conversation;
pub mod diag;
pub mod embedder;
pub mod error;
pub mod git;
@ -27,25 +28,30 @@ pub mod skill;
pub mod template;
pub mod terminal;
pub mod window;
pub mod workstate;
pub use agent::{
drain_with_readiness, drain_with_readiness_outcome, reference_profile_id, reference_profiles,
drain_with_readiness, drain_with_readiness_outcome,
reference_profile_id, reference_profiles,
selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile,
ChangeAgentProfileInput, ChangeAgentProfileOutput,
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch,
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile,
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
FirstRunStateOutput, HandoffProvider, InspectConversation, InspectConversationInput,
InspectConversationOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents,
ListAgentsInput, ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
ListResumableAgentsInput, ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry,
ProfileAvailability,
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome,
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, RESUME_PROMPT,
ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput,
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime,
PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext,
ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput,
ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService,
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
};
pub use conversation::{
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
RotateConversationLog, RotateConversationLogInput, TurnPage, TurnSource, TurnView,
};
pub use conversation::RecordTurn;
pub use embedder::{
CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput,
DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines, DismissChoice,
@ -72,13 +78,16 @@ pub use layout::{
};
pub use memory::{
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput,
ListMemoriesOutput, ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory,
RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput,
ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
GetMemory, GetMemoryInput, GetMemoryOutput, HarvestMemoryFromTurn, ListMemories,
ListMemoriesInput, ListMemoriesOutput, MemoryHarvestOutcome, ReadMemoryIndex,
ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory, RecallMemoryInput,
RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput,
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
};
pub use orchestrator::{
McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider,
OrchestratorOutcome, OrchestratorService, ProposeContext, ReadContext, ReadMemory,
RecordTurnProvider, WriteMemory,
};
pub use permission::{
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,
@ -96,9 +105,9 @@ pub use project::{
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
pub use skill::{
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
UpdateSkillOutput,
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput, ReadSkill,
ReadSkillInput, UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill,
UpdateSkillInput, UpdateSkillOutput,
};
pub use template::{
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
@ -109,8 +118,17 @@ pub use template::{
UpdateTemplateOutput,
};
pub use terminal::{
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveSessions,
OpenTerminal, OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput,
StructuredSessions, TerminalSessions, WriteToTerminal, WriteToTerminalInput,
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveSessionKind,
LiveSessionSnapshot, LiveSessions, OpenTerminal, OpenTerminalInput, OpenTerminalOutput,
ResizeTerminal, ResizeTerminalInput, StructuredSessions, TerminalSessions, WriteToTerminal,
WriteToTerminalInput,
};
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
pub use workstate::{
AgentTicketState, AgentWorkState, AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput,
ConversationLogProvider, ConversationPreviewStatus, ConversationTurnWorkPreview,
ConversationWorkSummary, GetLiveStateLean, GetProjectWorkState, GetProjectWorkStateInput,
LeanLiveEntry, LeanLiveState, LiveWorkSession, ProjectWorkState, StopLiveAgent,
StopLiveAgentInput, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, UpdateLiveState,
UpdateLiveStateInput, LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS,
};

View File

@ -0,0 +1,114 @@
//! Auto-memory harvest use case (Lot E1).
//!
//! [`HarvestMemoryFromTurn`] turns the text of a finished agent turn into persisted
//! memory notes when it embeds ` ```idea-memory ` directives (parsed by the pure
//! [`domain::memory_harvest`] module). It is **best-effort strict**: it only acts on
//! a `Response` turn, parses **only the current turn's text** (never the full log),
//! and never fails — parse/store errors are collected into the returned
//! [`MemoryHarvestOutcome`] so the orchestrator can drop them without ever turning a
//! successful reply/ticket/handoff into an error.
use std::sync::Arc;
use domain::conversation_log::TurnRole;
use domain::ports::{EventBus, MemoryStore};
use domain::{
parse_memory_directives, DomainEvent, MarkdownDoc, Memory, MemoryFrontmatter,
MemoryHarvestDirective, MemorySlug, ProjectPath,
};
/// The outcome of harvesting one turn: a report the caller may log or ignore.
///
/// `found` is the number of `idea-memory` directives the agent emitted; `saved`
/// the slugs successfully persisted; `skipped` how many were dropped (parse or
/// store failures); `errors` the human-readable reasons (parse + store).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MemoryHarvestOutcome {
/// Number of `idea-memory` directives detected in the turn text.
pub found: usize,
/// Slugs of the notes that were saved (and announced).
pub saved: Vec<MemorySlug>,
/// Number of directives dropped (invalid, over-limit, or store failure).
pub skipped: usize,
/// One message per dropped directive (parse + persistence reasons).
pub errors: Vec<String>,
}
/// Validates and persists the memory directives embedded in a finished turn.
///
/// Talks only to ports ([`MemoryStore`], [`EventBus`]); announces
/// [`DomainEvent::MemorySaved`] per saved note like the manual CRUD use cases.
pub struct HarvestMemoryFromTurn {
memories: Arc<dyn MemoryStore>,
events: Arc<dyn EventBus>,
}
impl HarvestMemoryFromTurn {
/// Builds the use case from its ports.
#[must_use]
pub fn new(memories: Arc<dyn MemoryStore>, events: Arc<dyn EventBus>) -> Self {
Self { memories, events }
}
/// Harvests `text` (the current turn's content) into `root`'s memory store.
///
/// Best-effort: returns a [`MemoryHarvestOutcome`] and **never** an error. A
/// non-`Response` turn is a no-op (empty outcome) — prompts and tool activity
/// never carry harvest directives.
pub async fn harvest(
&self,
root: &ProjectPath,
role: TurnRole,
text: &str,
) -> MemoryHarvestOutcome {
if role != TurnRole::Response {
return MemoryHarvestOutcome::default();
}
let report = parse_memory_directives(text);
let mut outcome = MemoryHarvestOutcome {
found: report.found,
// Parse-level skips are already final; store-level skips are added below.
skipped: report.errors.len(),
errors: report.errors,
saved: Vec::new(),
};
for directive in report.directives {
match self.persist(root, directive).await {
Ok(slug) => outcome.saved.push(slug),
Err(err) => {
outcome.skipped += 1;
outcome.errors.push(err);
}
}
}
outcome
}
/// Persists one validated directive and announces it, mapping the (today
/// title-less) [`Memory`] entity from the directive. Returns the saved slug or
/// a message on a build/store failure (never panics).
async fn persist(
&self,
root: &ProjectPath,
directive: MemoryHarvestDirective,
) -> Result<MemorySlug, String> {
let slug = directive.slug.clone();
// The persisted `Memory` has no title field today; the directive's `title`
// is intentionally not stored separately (the index derives its title from
// the slug). See the Lot E1 report's écart note.
let frontmatter = MemoryFrontmatter {
name: slug.clone(),
description: directive.description,
r#type: directive.r#type,
};
let memory = Memory::new(frontmatter, MarkdownDoc::new(directive.body.as_str()))
.map_err(|e| format!("invalid memory {slug}: {e}"))?;
self.memories
.save(root, &memory)
.await
.map_err(|e| format!("store failed for {slug}: {e}"))?;
self.events
.publish(DomainEvent::MemorySaved { slug: slug.clone() });
Ok(slug)
}
}

View File

@ -12,8 +12,11 @@
//! [`UpdateMemory`], [`DeleteMemory`]) announce a [`domain::DomainEvent`]; the
//! read use cases emit nothing.
mod harvest;
mod usecases;
pub use harvest::{HarvestMemoryFromTurn, MemoryHarvestOutcome};
pub use usecases::{
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput,

View File

@ -26,7 +26,7 @@
use std::sync::Arc;
use domain::conversation::ConversationParty;
use domain::fileguard::{FileGuard, GuardError, GuardedResource};
use domain::fileguard::{may_write_directly, FileGuard, GuardError, GuardedResource};
use domain::markdown::MarkdownDoc;
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
@ -135,9 +135,10 @@ impl ReadContext {
/// Proposes new content for an IdeA-owned context under the [`FileGuard`].
///
/// For an **agent** context: a direct write under an exclusive write-lease. For the
/// **global** project context by a non-orchestrator: the guard returns
/// [`GuardError::Forbidden`], which this use case turns into a *materialised proposal*
/// (a file under `.ideai/proposals/`) — never an overwrite of the live context.
/// **global** project context by a non-orchestrator: the use case asks the domain
/// policy whether the requester may write directly; otherwise it materialises a
/// proposal (a file under `.ideai/proposals/`) — never an overwrite of the live
/// context.
pub struct ProposeContext {
guard: Arc<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
@ -214,25 +215,24 @@ impl ProposeContext {
Ok(ProposeOutcome::Written)
}
None => {
// Global project context: single-writer. Try to acquire the write
// lease; Forbidden ⇒ materialise a proposal instead of overwriting.
match self
// Global project context: single-writer. Authorization stays in the
// domain policy; the guard only serialises the eventual direct write.
let manifest = self.contexts.load_manifest(&project).await?;
let designation = manifest.orchestrator_designation();
let resource = GuardedResource::ProjectContext;
if !may_write_directly(requester, &resource, &designation) {
let path = self.file_proposal(&project, requester, &content).await?;
return Ok(ProposeOutcome::Proposed { path });
}
let _lease = self
.guard
.acquire_write(requester, GuardedResource::ProjectContext)
.acquire_write(requester, resource)
.await
{
Ok(_lease) => {
.map_err(map_guard_err)?;
let path = join_root(&project, PROJECT_CONTEXT_FILE);
self.fs.write(&path, content.as_bytes()).await?;
Ok(ProposeOutcome::Written)
}
Err(GuardError::Forbidden) => {
let path = self.file_proposal(&project, requester, &content).await?;
Ok(ProposeOutcome::Proposed { path })
}
Err(other) => Err(map_guard_err(other)),
}
}
}
}
@ -392,7 +392,7 @@ mod tests {
use async_trait::async_trait;
use domain::agent::{AgentManifest, ManifestEntry};
use domain::conversation::ConversationParty;
use domain::fileguard::{may_write_directly, ReadLease, WriteLease};
use domain::fileguard::{ReadLease, WriteLease};
use domain::ports::{FsError, MemoryError, StoreError};
use domain::project::ProjectPath;
use domain::{ProfileId, ProjectId, RemoteRef};
@ -443,12 +443,9 @@ mod tests {
}
async fn acquire_write(
&self,
who: ConversationParty,
_who: ConversationParty,
res: GuardedResource,
) -> Result<WriteLease, GuardError> {
if !may_write_directly(who, &res) {
return Err(GuardError::Forbidden);
}
let lock = self.lock_for(&res);
Ok(WriteLease::new(Box::new(lock.write_owned().await)))
}
@ -612,6 +609,7 @@ mod tests {
Arc::new(FakeContexts {
manifest: AgentManifest {
version: 1,
orchestrator: None,
entries: vec![ManifestEntry {
agent_id: agent,
name: name.to_owned(),

View File

@ -12,5 +12,6 @@ pub use context_guard::{
ReadMemoryInput, WriteMemory, WriteMemoryInput,
};
pub use service::{
McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider,
OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
};

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@ mod usecases;
pub use usecases::{
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
UpdateSkillOutput,
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput, ReadSkill,
ReadSkillInput, UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill,
UpdateSkillInput, UpdateSkillOutput,
};

View File

@ -25,6 +25,10 @@ use crate::error::AppError;
pub struct CreateSkillInput {
/// Display name (also the `.md` stem on disk).
pub name: String,
/// Optional one-line affordance description (feature « skills à la MCP »).
/// `None`/empty ⇒ the skill falls back to the first line of its body when
/// surfaced (see [`domain::Skill::effective_description`]).
pub description: Option<String>,
/// Initial Markdown body.
pub content: String,
/// Scope the skill is created in (selects its backing store).
@ -61,7 +65,8 @@ impl CreateSkill {
pub async fn execute(&self, input: CreateSkillInput) -> Result<CreateSkillOutput, AppError> {
let id = SkillId::from_uuid(self.ids.new_uuid());
let skill = Skill::new(id, input.name, MarkdownDoc::new(input.content), input.scope)
.map_err(|e| AppError::Invalid(e.to_string()))?;
.map_err(|e| AppError::Invalid(e.to_string()))?
.with_description(input.description);
self.skills.save(&skill, &input.project_root).await?;
Ok(CreateSkillOutput { skill })
}
@ -204,6 +209,77 @@ impl DeleteSkill {
}
}
// ---------------------------------------------------------------------------
// ReadSkill
// ---------------------------------------------------------------------------
/// Input for [`ReadSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadSkillInput {
/// Skill display name to resolve (case-insensitive).
pub name: String,
/// Active project root (used for the [`SkillScope::Project`] lookup).
pub project_root: ProjectPath,
}
/// Reads a skill's Markdown body **by name** — the application side of the MCP
/// `idea_skill_read` tool (feature « skills à la MCP »).
///
/// Resolution follows the affordance contract: **project scope first, then
/// global** (a project skill shadows a global one of the same name). It composes
/// the **existing** [`SkillStore`] port only — no new port. Read-only.
pub struct ReadSkill {
skills: Arc<dyn SkillStore>,
}
impl ReadSkill {
/// Builds the use case from the existing skill store port.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
Self { skills }
}
/// Resolves `name` in `scope`, returning the single match.
///
/// `Ok(None)` ⇒ no skill by that name in this scope; `Err(Invalid)` ⇒ the
/// name is **ambiguous** (more than one skill shares it in this scope).
async fn resolve_in(
&self,
scope: SkillScope,
input: &ReadSkillInput,
) -> Result<Option<Skill>, AppError> {
let all = self.skills.list(scope, &input.project_root).await?;
let mut matches = all
.into_iter()
.filter(|s| s.name.eq_ignore_ascii_case(&input.name));
match (matches.next(), matches.next()) {
(None, _) => Ok(None),
(Some(skill), None) => Ok(Some(skill)),
(Some(_), Some(_)) => Err(AppError::Invalid(format!(
"skill name `{}` is ambiguous in {scope:?} scope (several skills share it)",
input.name
))),
}
}
/// Resolves the skill by name and returns its Markdown body.
///
/// # Errors
/// - [`AppError::Invalid`] if the name is ambiguous within a scope,
/// - [`AppError::NotFound`] if no skill carries that name in either scope,
/// - [`AppError::Store`] on a store failure.
pub async fn execute(&self, input: ReadSkillInput) -> Result<MarkdownDoc, AppError> {
// Project scope shadows global: try it first, then fall back to global.
if let Some(skill) = self.resolve_in(SkillScope::Project, &input).await? {
return Ok(skill.content_md);
}
if let Some(skill) = self.resolve_in(SkillScope::Global, &input).await? {
return Ok(skill.content_md);
}
Err(AppError::NotFound(format!("skill `{}`", input.name)))
}
}
// ---------------------------------------------------------------------------
// AssignSkillToAgent / UnassignSkillFromAgent
// ---------------------------------------------------------------------------
@ -349,3 +425,185 @@ impl UnassignSkillFromAgent {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use async_trait::async_trait;
use domain::ports::{SkillStore, StoreError};
/// In-memory [`SkillStore`] fake: skills are bucketed by scope, ignoring the
/// project root (the [`ReadSkill`] resolution logic is root-agnostic — it only
/// switches scope). Enough to exercise project-first resolution, shadowing,
/// ambiguity and absence without touching disk.
#[derive(Default)]
struct FakeSkillStore {
global: Mutex<Vec<Skill>>,
project: Mutex<Vec<Skill>>,
}
impl FakeSkillStore {
fn bucket(&self, scope: SkillScope) -> &Mutex<Vec<Skill>> {
match scope {
SkillScope::Global => &self.global,
SkillScope::Project => &self.project,
}
}
fn push(&self, skill: Skill) {
self.bucket(skill.scope).lock().unwrap().push(skill);
}
}
#[async_trait]
impl SkillStore for FakeSkillStore {
async fn list(
&self,
scope: SkillScope,
_root: &ProjectPath,
) -> Result<Vec<Skill>, StoreError> {
Ok(self.bucket(scope).lock().unwrap().clone())
}
async fn get(
&self,
scope: SkillScope,
_root: &ProjectPath,
id: SkillId,
) -> Result<Skill, StoreError> {
self.bucket(scope)
.lock()
.unwrap()
.iter()
.find(|s| s.id == id)
.cloned()
.ok_or(StoreError::NotFound)
}
async fn save(&self, skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
self.push(skill.clone());
Ok(())
}
async fn delete(
&self,
_scope: SkillScope,
_root: &ProjectPath,
_id: SkillId,
) -> Result<(), StoreError> {
Ok(())
}
}
fn root() -> ProjectPath {
ProjectPath::new("/proj").unwrap()
}
fn skill(id: u128, name: &str, body: &str, scope: SkillScope) -> Skill {
Skill::new(
SkillId::from_uuid(uuid::Uuid::from_u128(id)),
name,
MarkdownDoc::new(body),
scope,
)
.unwrap()
}
fn read_skill_uc(store: Arc<FakeSkillStore>) -> ReadSkill {
ReadSkill::new(store)
}
#[tokio::test]
async fn read_skill_resolves_project_scope() {
// (a) skill present in the project scope ⇒ its body is returned.
let store = Arc::new(FakeSkillStore::default());
store.push(skill(1, "deploy", "PROJECT_BODY", SkillScope::Project));
let body = read_skill_uc(store)
.execute(ReadSkillInput {
name: "deploy".to_owned(),
project_root: root(),
})
.await
.unwrap();
assert_eq!(body.as_str(), "PROJECT_BODY");
}
#[tokio::test]
async fn read_skill_falls_back_to_global_scope() {
// (b) absent from project but present globally ⇒ resolved via global.
let store = Arc::new(FakeSkillStore::default());
store.push(skill(1, "deploy", "GLOBAL_BODY", SkillScope::Global));
let body = read_skill_uc(store)
.execute(ReadSkillInput {
name: "deploy".to_owned(),
project_root: root(),
})
.await
.unwrap();
assert_eq!(body.as_str(), "GLOBAL_BODY");
}
#[tokio::test]
async fn read_skill_project_shadows_global() {
// (c) same name in both scopes ⇒ project wins.
let store = Arc::new(FakeSkillStore::default());
store.push(skill(1, "deploy", "GLOBAL_BODY", SkillScope::Global));
store.push(skill(2, "deploy", "PROJECT_BODY", SkillScope::Project));
let body = read_skill_uc(store)
.execute(ReadSkillInput {
name: "deploy".to_owned(),
project_root: root(),
})
.await
.unwrap();
assert_eq!(body.as_str(), "PROJECT_BODY");
}
#[tokio::test]
async fn read_skill_is_case_insensitive() {
let store = Arc::new(FakeSkillStore::default());
store.push(skill(1, "Deploy", "BODY", SkillScope::Project));
let body = read_skill_uc(store)
.execute(ReadSkillInput {
name: "deploy".to_owned(),
project_root: root(),
})
.await
.unwrap();
assert_eq!(body.as_str(), "BODY");
}
#[tokio::test]
async fn read_skill_unknown_is_not_found() {
// (d) unknown in both scopes ⇒ NotFound.
let store = Arc::new(FakeSkillStore::default());
let err = read_skill_uc(store)
.execute(ReadSkillInput {
name: "ghost".to_owned(),
project_root: root(),
})
.await
.unwrap_err();
assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
}
#[tokio::test]
async fn read_skill_ambiguous_name_is_invalid() {
// (e) two skills share a name within a scope ⇒ Invalid (ambiguous).
let store = Arc::new(FakeSkillStore::default());
store.push(skill(1, "deploy", "ONE", SkillScope::Project));
store.push(skill(2, "Deploy", "TWO", SkillScope::Project));
let err = read_skill_uc(store)
.execute(ReadSkillInput {
name: "deploy".to_owned(),
project_root: root(),
})
.await
.unwrap_err();
assert!(matches!(err, AppError::Invalid(_)), "got {err:?}");
}
}

View File

@ -28,7 +28,10 @@
mod registry;
mod usecases;
pub use registry::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions};
pub use registry::{
LiveAgentRegistry, LiveSessionKind, LiveSessionSnapshot, LiveSessions, StructuredSessions,
TerminalSessions,
};
pub use usecases::{
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, WriteToTerminal, WriteToTerminalInput,

View File

@ -13,6 +13,28 @@ use domain::conversation::ConversationId;
use domain::ports::{AgentSession, PtyHandle};
use domain::{AgentId, NodeId, SessionId, SessionKind, TerminalSession};
/// Runtime family of a live agent session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LiveSessionKind {
/// Raw PTY-backed CLI session.
Pty,
/// Structured agent-session backend.
Structured,
}
/// Read-only coordinates of one live agent session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LiveSessionSnapshot {
/// The agent owning the live session.
pub agent_id: AgentId,
/// The layout node currently hosting the session view.
pub node_id: NodeId,
/// The live session id.
pub session_id: SessionId,
/// Which runtime registry owns the session.
pub kind: LiveSessionKind,
}
/// A registered, live terminal: its PTY handle plus the domain snapshot.
#[derive(Debug, Clone)]
struct Entry {
@ -516,6 +538,31 @@ impl LiveSessions {
all.extend(self.structured.live_agents());
all
}
/// Tous les agents vivants avec le type de registre source (PTY puis structuré).
#[must_use]
pub fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
let mut all: Vec<LiveSessionSnapshot> = self
.pty
.live_agents()
.into_iter()
.map(|(agent_id, node_id, session_id)| LiveSessionSnapshot {
agent_id,
node_id,
session_id,
kind: LiveSessionKind::Pty,
})
.collect();
all.extend(self.structured.live_agents().into_iter().map(
|(agent_id, node_id, session_id)| LiveSessionSnapshot {
agent_id,
node_id,
session_id,
kind: LiveSessionKind::Structured,
},
));
all
}
}
impl LiveAgentRegistry for LiveSessions {

View File

@ -0,0 +1,177 @@
//! Agent-level controlled actions over live sessions (Lot D).
//!
//! Companion to the read-only work-state model: these use cases let the UI drive
//! an **already-running** agent's live session by **agent id**, never by spawning a
//! new process. [`AttachLiveAgent`] rebinds the hosting cell ("a cell is a view");
//! [`StopLiveAgent`] tears the live session down polymorphically (PTY kill vs
//! structured shutdown). Neither touches the agent entity, its tickets, the
//! conversation summaries, the handoff or the provider sessions.
use std::sync::Arc;
use domain::{AgentId, NodeId, Project, SessionId};
use crate::error::AppError;
use crate::terminal::{CloseTerminal, CloseTerminalInput, LiveSessionKind, LiveSessions};
/// Input for [`AttachLiveAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttachLiveAgentInput {
/// The owning project (boundary/symmetry; the registries are global by agent).
pub project: Project,
/// The already-running agent whose live session is rebound.
pub agent_id: AgentId,
/// The layout leaf that should host the live session view.
pub node_id: NodeId,
}
/// Output of [`AttachLiveAgent::execute`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AttachLiveAgentOutput {
/// The rebound agent.
pub agent_id: AgentId,
/// The node now hosting the session view.
pub node_id: NodeId,
/// The unchanged live session id.
pub session_id: SessionId,
/// Runtime family that owns the session.
pub kind: LiveSessionKind,
}
/// Rebinds an already-running agent session to a visible layout cell, never
/// spawning a process ("a cell is a view").
///
/// Resolves the live session by agent in the PTY registry first, then the
/// structured one, and updates only the hosting `node_id` (the PTY handle / agent
/// session, its id and scrollback stay intact). Idempotent when the agent is
/// already attached to the same node (the rebind is a no-op that returns the same
/// session). No log/handoff/provider-session is touched.
pub struct AttachLiveAgent {
live: Arc<LiveSessions>,
}
impl AttachLiveAgent {
/// Builds the use case from the aggregated live-session registry.
#[must_use]
pub fn new(live: Arc<LiveSessions>) -> Self {
Self { live }
}
/// Rebinds the agent's live session onto `node_id`.
///
/// # Errors
/// [`AppError::NotFound`] when the agent has no live session in either registry.
pub fn execute(&self, input: AttachLiveAgentInput) -> Result<AttachLiveAgentOutput, AppError> {
// PTY first, then structured (one-live-session-per-agent ⇒ at most one match).
if let Some(session) = self
.live
.pty
.rebind_agent_node(&input.agent_id, input.node_id)
{
return Ok(AttachLiveAgentOutput {
agent_id: input.agent_id,
node_id: session.node_id,
session_id: session.id,
kind: LiveSessionKind::Pty,
});
}
if let Some(session) = self
.live
.structured
.rebind_agent_node(&input.agent_id, input.node_id)
{
return Ok(AttachLiveAgentOutput {
agent_id: input.agent_id,
node_id: input.node_id,
session_id: session.id(),
kind: LiveSessionKind::Structured,
});
}
Err(AppError::NotFound(format!(
"running session for agent {}",
input.agent_id
)))
}
}
/// Input for [`StopLiveAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StopLiveAgentInput {
/// The owning project (boundary/symmetry; the registries are global by agent).
pub project: Project,
/// The agent whose live session is stopped.
pub agent_id: AgentId,
}
/// Output of [`StopLiveAgent::execute`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StopLiveAgentOutput {
/// The stopped agent.
pub agent_id: AgentId,
/// The id of the session that was torn down.
pub session_id: SessionId,
/// Runtime family that owned the session.
pub kind: LiveSessionKind,
}
/// Stops an already-running agent's live session, polymorphically.
///
/// Resolves the session by agent (PTY first, then structured). A PTY session is
/// torn down through the existing [`CloseTerminal`] primitive (registry removal +
/// process kill); a structured session is removed then `shutdown()`. The agent
/// entity, its tickets, conversation summaries and handoff are **not** removed. Any
/// exit event is left to the existing infrastructure (the PTY pump / session
/// backend) — no bus is invented here.
pub struct StopLiveAgent {
live: Arc<LiveSessions>,
close: Arc<CloseTerminal>,
}
impl StopLiveAgent {
/// Builds the use case from the live-session registry and the close primitive.
#[must_use]
pub fn new(live: Arc<LiveSessions>, close: Arc<CloseTerminal>) -> Self {
Self { live, close }
}
/// Tears down the agent's live session.
///
/// # Errors
/// - [`AppError::NotFound`] when the agent has no live session,
/// - [`AppError::Process`] when the PTY kill or structured shutdown fails.
pub async fn execute(
&self,
input: StopLiveAgentInput,
) -> Result<StopLiveAgentOutput, AppError> {
// PTY first: delegate to the existing close primitive (removes + kills).
if let Some(session_id) = self.live.pty.session_for_agent(&input.agent_id) {
self.close
.execute(CloseTerminalInput { session_id })
.await?;
return Ok(StopLiveAgentOutput {
agent_id: input.agent_id,
session_id,
kind: LiveSessionKind::Pty,
});
}
// Structured: remove from the registry first (so the uniqueness guard no
// longer sees a live session), then shut the session down out of the lock.
if let Some(session_id) = self.live.structured.session_id_for_agent(&input.agent_id) {
if let Some(session) = self.live.structured.remove(&session_id) {
session
.shutdown()
.await
.map_err(|e| AppError::Process(e.to_string()))?;
}
return Ok(StopLiveAgentOutput {
agent_id: input.agent_id,
session_id,
kind: LiveSessionKind::Structured,
});
}
Err(AppError::NotFound(format!(
"running session for agent {}",
input.agent_id
)))
}
}

View File

@ -0,0 +1,343 @@
//! Live-state write/read use cases (programme live-state, lot LS2).
//!
//! The **live-state** is the durable-but-volatile "who is doing what right now"
//! projection: one [`LiveEntry`] per agent, persisted through the
//! [`LiveStateStore`] port (keyed last-writer-wins, never a journal).
//!
//! This module owns the two use cases over that port:
//! - [`UpdateLiveState`] — publish/replace an agent's current row (write side),
//! stamping `updated_at_ms` from the injected [`Clock`] and enforcing the
//! domain field bounds via [`LiveEntry::new`].
//! - [`GetLiveStateLean`] — prune-on-read then return a **distilled** snapshot
//! ([`LeanLiveState`]) for agent context injection and the future MCP tools.
//!
//! ## Boundary (programme invariant)
//!
//! The live-state stores **only what cannot be re-derived**: an agent's declared
//! `intent`/`progress`, its current `ticket` and its `last_delegation`. Busy
//! state, live sessions and the delegation queue are **computed elsewhere**
//! ([`crate::workstate::GetProjectWorkState`]) and are deliberately *not* copied
//! here. The lean DTO is also distinct from that rich, human-facing read model.
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use domain::live_state::{LiveEntry, LiveState, WorkStatus};
use domain::ports::{Clock, LiveStateStore};
use domain::{AgentId, TicketId};
use crate::error::AppError;
/// Default TTL for a live-state row, in milliseconds: **6 hours**.
///
/// An entry not refreshed within this window is considered stale and pruned on
/// the next read. Six hours comfortably spans a normal working session (an agent
/// that has not published anything for that long is effectively offline), while
/// still bounding the file so a forgotten/abandoned project does not accumulate
/// dead rows indefinitely.
pub const LIVE_STATE_TTL_MS: u64 = 6 * 60 * 60 * 1_000;
/// Default cardinality bound applied after the TTL sweep: keep at most this many
/// most-recently-updated rows.
///
/// A project runs a handful of agents; `64` is a generous ceiling that still
/// absorbs transient fan-out (an orchestrator delegating to many agents at once)
/// while keeping the snapshot small and the injected context bounded.
pub const LIVE_STATE_MAX_ENTRIES: usize = 64;
/// Input for [`UpdateLiveState::execute`] — the elements of one live-state
/// transition. `updated_at_ms` is **not** part of the input: it is stamped by the
/// use case from the injected [`Clock`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateLiveStateInput {
/// The agent publishing the transition (the keyed identity).
pub agent_id: AgentId,
/// The ticket it is currently handling, if any.
pub ticket: Option<TicketId>,
/// Short description of the current intent (domain-bounded on construction).
pub intent: String,
/// Coarse status at a glance.
pub status: WorkStatus,
/// Optional finer-grained progress note (domain-bounded on construction).
pub progress: Option<String>,
/// The ticket of the most recent delegation this agent issued, if any.
pub last_delegation: Option<TicketId>,
}
/// Publishes (inserts or replaces) an agent's live-state row.
pub struct UpdateLiveState {
store: Arc<dyn LiveStateStore>,
clock: Arc<dyn Clock>,
}
impl UpdateLiveState {
/// Builds the use case from the injected store and clock ports.
#[must_use]
pub fn new(store: Arc<dyn LiveStateStore>, clock: Arc<dyn Clock>) -> Self {
Self { store, clock }
}
/// Stamps `updated_at_ms` from the clock, builds a validated [`LiveEntry`]
/// (applying the soft truncation / hard rejection bounds) and upserts it.
///
/// # Errors
/// [`AppError::Invalid`] if a text field exceeds the domain hard threshold;
/// [`AppError::Store`] on a persistence failure.
pub async fn execute(&self, input: UpdateLiveStateInput) -> Result<(), AppError> {
let now_ms = u64::try_from(self.clock.now_millis()).unwrap_or(0);
let entry = LiveEntry::new(
input.agent_id,
input.ticket,
input.intent,
input.status,
input.progress,
input.last_delegation,
now_ms,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.store.upsert(entry).await?;
Ok(())
}
}
/// A single distilled live-state row for injection / MCP — the **lean** DTO,
/// deliberately narrower than the rich human read model (no `progress` free-text,
/// no timestamp). All free-text is already domain-bounded.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LeanLiveEntry {
/// The agent this row describes.
pub agent_id: AgentId,
/// Short description of what the agent is doing (domain-bounded).
pub intent: String,
/// Coarse status at a glance.
pub status: WorkStatus,
/// The ticket it is currently handling, if any.
pub ticket: Option<TicketId>,
/// The ticket of the most recent delegation it issued, if any.
pub last_delegation: Option<TicketId>,
}
impl From<LiveEntry> for LeanLiveEntry {
fn from(e: LiveEntry) -> Self {
Self {
agent_id: e.agent_id,
intent: e.intent,
status: e.status,
ticket: e.ticket,
last_delegation: e.last_delegation,
}
}
}
/// The distilled live-state snapshot returned by [`GetLiveStateLean`].
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LeanLiveState {
/// The current rows (already pruned), one per agent.
pub entries: Vec<LeanLiveEntry>,
}
impl From<LiveState> for LeanLiveState {
fn from(state: LiveState) -> Self {
Self {
entries: state.entries.into_iter().map(LeanLiveEntry::from).collect(),
}
}
}
/// Reads the live-state, pruning stale/excess rows first, and returns the lean
/// snapshot.
pub struct GetLiveStateLean {
store: Arc<dyn LiveStateStore>,
clock: Arc<dyn Clock>,
}
impl GetLiveStateLean {
/// Builds the use case from the injected store and clock ports.
#[must_use]
pub fn new(store: Arc<dyn LiveStateStore>, clock: Arc<dyn Clock>) -> Self {
Self { store, clock }
}
/// Applies the TTL + cardinality policy ([`LIVE_STATE_TTL_MS`],
/// [`LIVE_STATE_MAX_ENTRIES`]) at read time, then returns the distilled
/// snapshot.
///
/// # Errors
/// [`AppError::Store`] on a persistence failure.
pub async fn execute(&self) -> Result<LeanLiveState, AppError> {
let now_ms = u64::try_from(self.clock.now_millis()).unwrap_or(0);
self.store
.prune(now_ms, LIVE_STATE_TTL_MS, LIVE_STATE_MAX_ENTRIES)
.await?;
let state = self.store.load().await?;
Ok(LeanLiveState::from(state))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use domain::live_state::FIELD_MAX_BYTES;
use domain::ports::StoreError;
/// In-memory [`LiveStateStore`] double mirroring the FS adapter's pure
/// semantics (keyed upsert + prune), with no I/O.
#[derive(Default)]
struct InMemoryLiveStateStore {
state: Mutex<LiveState>,
}
#[async_trait::async_trait]
impl LiveStateStore for InMemoryLiveStateStore {
async fn load(&self) -> Result<LiveState, StoreError> {
Ok(self.state.lock().unwrap().clone())
}
async fn upsert(&self, entry: LiveEntry) -> Result<(), StoreError> {
self.state.lock().unwrap().upsert(entry);
Ok(())
}
async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError> {
self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n);
Ok(())
}
}
/// A fixed clock returning a preset epoch-ms value.
struct FixedClock(i64);
impl Clock for FixedClock {
fn now_millis(&self) -> i64 {
self.0
}
}
fn aid(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
#[tokio::test]
async fn update_stamps_clock_and_persists() {
let store = Arc::new(InMemoryLiveStateStore::default());
let clock = Arc::new(FixedClock(4_242));
let uc = UpdateLiveState::new(store.clone(), clock);
uc.execute(UpdateLiveStateInput {
agent_id: aid(1),
ticket: None,
intent: "shipping".to_owned(),
status: WorkStatus::Working,
progress: None,
last_delegation: None,
})
.await
.unwrap();
let state = store.load().await.unwrap();
assert_eq!(state.entries.len(), 1);
assert_eq!(state.entries[0].intent, "shipping");
assert_eq!(
state.entries[0].updated_at_ms, 4_242,
"updated_at_ms stamped from the clock"
);
}
#[tokio::test]
async fn update_applies_domain_bounds_truncation() {
let store = Arc::new(InMemoryLiveStateStore::default());
let uc = UpdateLiveState::new(store.clone(), Arc::new(FixedClock(1)));
let long = "x".repeat(500);
uc.execute(UpdateLiveStateInput {
agent_id: aid(1),
ticket: None,
intent: long,
status: WorkStatus::Working,
progress: None,
last_delegation: None,
})
.await
.unwrap();
let state = store.load().await.unwrap();
assert!(
state.entries[0].intent.chars().count() <= 200,
"soft bound truncated the over-long intent"
);
}
#[tokio::test]
async fn update_rejects_oversize_field() {
let store = Arc::new(InMemoryLiveStateStore::default());
let uc = UpdateLiveState::new(store, Arc::new(FixedClock(1)));
let huge = "x".repeat(FIELD_MAX_BYTES + 1);
let err = uc
.execute(UpdateLiveStateInput {
agent_id: aid(1),
ticket: None,
intent: huge,
status: WorkStatus::Working,
progress: None,
last_delegation: None,
})
.await
.expect_err("oversize intent must be rejected");
assert!(matches!(err, AppError::Invalid(_)));
}
#[tokio::test]
async fn get_lean_prunes_on_read_and_returns_lean_dto() {
let store = Arc::new(InMemoryLiveStateStore::default());
// One fresh row (now-ish) and one ancient row (well beyond the TTL).
let now: i64 = 10 * 60 * 60 * 1_000; // 10h in ms
store
.upsert(
LiveEntry::new(
aid(1),
None,
"fresh",
WorkStatus::Working,
Some("detailed progress note".to_owned()),
None,
u64::try_from(now).unwrap(),
)
.unwrap(),
)
.await
.unwrap();
store
.upsert(LiveEntry::new(aid(2), None, "stale", WorkStatus::Idle, None, None, 0).unwrap())
.await
.unwrap();
let lean = GetLiveStateLean::new(store.clone(), Arc::new(FixedClock(now)))
.execute()
.await
.unwrap();
// The ancient row (age 10h > 6h TTL) was pruned on read.
assert_eq!(lean.entries.len(), 1);
let entry = &lean.entries[0];
assert_eq!(entry.agent_id, aid(1));
assert_eq!(entry.intent, "fresh");
assert_eq!(entry.status, WorkStatus::Working);
// Lean DTO is narrow: no `progress` free-text leaks through. The rich
// serialized form must not carry it.
let json = serde_json::to_string(&lean).unwrap();
assert!(
!json.contains("progress"),
"lean DTO drops progress: {json}"
);
assert!(!json.contains("detailed progress note"));
assert!(json.contains("\"agentId\""), "camelCase lean DTO");
// Prune persisted: the store no longer holds the stale row either.
assert_eq!(store.load().await.unwrap().entries.len(), 1);
}
}

View File

@ -0,0 +1,526 @@
//! Read-only project work-state read model.
//!
//! This module composes existing runtime state only: the project agent manifest,
//! live session registries and the input mediator busy state. It performs no I/O
//! beyond loading the manifest through the existing context store and creates no
//! durable projection.
mod actions;
mod live;
pub use actions::{
AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, StopLiveAgent,
StopLiveAgentInput, StopLiveAgentOutput,
};
pub use live::{
GetLiveStateLean, LeanLiveEntry, LeanLiveState, UpdateLiveState, UpdateLiveStateInput,
LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS,
};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use domain::input::{AgentBusyState, InputSource};
use domain::ports::AgentContextStore;
use domain::{
AgentId, AgentQueueSnapshot, ConversationId, ConversationLog, ConversationTurn, Handoff,
HandoffStore, InputMediator, NodeId, ProfileId, Project, ProjectPath, QueuedTicketSnapshot,
SessionId, TicketId, TurnId, TurnRole,
};
use crate::error::AppError;
use crate::terminal::{LiveSessionKind, LiveSessionSnapshot, LiveSessions};
use crate::HandoffProvider;
/// Maximum length (in characters) of a derived [`AgentTicketState::task_preview`].
///
/// The full task can be a very long prompt; the panel only needs a scannable
/// excerpt, with [`AgentTicketState::task_len`] signalling there is more.
const TASK_PREVIEW_MAX_CHARS: usize = 160;
/// Maximum length (in characters) of a conversation [`ConversationWorkSummary::summary_preview`].
const HANDOFF_PREVIEW_MAX_CHARS: usize = 480;
/// Maximum length (in characters) of a conversation [`ConversationWorkSummary::objective_preview`].
const OBJECTIVE_PREVIEW_MAX_CHARS: usize = 160;
/// Maximum number of recent turns surfaced in a [`ConversationWorkSummary::recent_turns`] fallback.
const RECENT_TURNS_MAX: usize = 3;
/// Maximum length (in characters) of a [`ConversationTurnWorkPreview::text_preview`].
const TURN_PREVIEW_MAX_CHARS: usize = 220;
/// Input for [`GetProjectWorkState::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GetProjectWorkStateInput {
/// Project whose manifest provides the agent order and boundary.
pub project: Project,
}
/// Read model for a project's current agent work state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectWorkState {
/// Agents in manifest order.
pub agents: Vec<AgentWorkState>,
/// Best-effort summaries of the conversations visible through the agents'
/// tickets, joined frontend-side via `tickets[].conversation_id`. Built from
/// the [`HandoffStore`] (primary) with a bounded [`ConversationLog`] fallback;
/// a preview failure never blocks `agents`.
pub conversations: Vec<ConversationWorkSummary>,
}
/// Best-effort, read-only summary of one conversation visible through the tickets.
///
/// Derived live from the [`HandoffStore`] (primary source) with a bounded
/// [`ConversationLog`] fallback — never a durable projection, never a repair of the
/// underlying log/handoff. Joined frontend-side to tickets via
/// [`ConversationWorkSummary::conversation_id`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConversationWorkSummary {
/// The conversation (pair) this summary describes.
pub conversation_id: ConversationId,
/// How much could be derived (see [`ConversationPreviewStatus`]).
pub status: ConversationPreviewStatus,
/// Bounded excerpt of the handoff objective, when present.
pub objective_preview: Option<String>,
/// Bounded excerpt of the handoff summary, when a handoff is readable.
pub summary_preview: Option<String>,
/// Character length of the **original** (un-truncated) handoff summary (`0` when none).
pub summary_len: usize,
/// Cursor (last [`TurnId`]) covered by the handoff summary, when present.
pub up_to: Option<TurnId>,
/// Bounded, recent turns from the log fallback (empty when a handoff is `Ready`).
pub recent_turns: Vec<ConversationTurnWorkPreview>,
}
/// How much of a [`ConversationWorkSummary`] could be derived, best-effort.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConversationPreviewStatus {
/// A handoff was present and usable: summary/objective/cursor are filled.
Ready,
/// No handoff yet; `recent_turns` carries the log fallback if any.
Missing,
/// The handoff was unreadable but the log fallback was readable.
Partial,
/// Neither the handoff nor the log could be read.
Unavailable,
}
/// One recent turn, projected for the conversation-summary fallback.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConversationTurnWorkPreview {
/// Nature of the turn (prompt, response, tool activity).
pub role: TurnRole,
/// Origin of the turn (human operator or a delegating agent).
pub source: TicketWorkSource,
/// Timestamp (epoch milliseconds) of the turn.
pub at_ms: u64,
/// Bounded, whitespace-normalised excerpt of the turn text.
pub text_preview: String,
/// Character length of the **original** (un-truncated) turn text.
pub text_len: usize,
}
/// Builds the [`ConversationLog`] bound to a given project `root`, best-effort.
///
/// Twin of [`HandoffProvider`]: the work-state read model is a singleton (the project
/// root arrives per call via [`GetProjectWorkStateInput`]) while the `Fs*` adapters fix
/// their root at construction. This port materialises the log targeting the **right**
/// folder on each call; `None` ⇒ no log fallback (zero regression for call
/// sites/tests that do not wire it). Implemented in `app-tauri` (sole owner of `Fs*`).
pub trait ConversationLogProvider: Send + Sync {
/// Builds the [`ConversationLog`] whose persistence targets `root`, or `None`.
fn conversation_log_for(&self, root: &ProjectPath) -> Option<Arc<dyn ConversationLog>>;
}
/// Read model for one manifest agent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentWorkState {
/// Agent id.
pub agent_id: AgentId,
/// Agent display name.
pub name: String,
/// Runtime profile id currently assigned to the agent.
pub profile_id: ProfileId,
/// Current live session, if the agent is live.
pub live: Option<LiveWorkSession>,
/// Current FIFO/busy state.
pub busy: AgentBusyState,
/// Pending/in-progress delegation tickets, in FIFO order.
pub tickets: Vec<AgentTicketState>,
}
/// One queued delegation ticket, projected for the work-state read model.
///
/// A read-only, presentation-oriented view of a [`QueuedTicketSnapshot`]: the
/// status is **derived** by crossing the FIFO snapshot with the agent's busy state
/// (never stored), and `task_preview` is a bounded excerpt of the full task (the
/// full text is never sent to the panel; `task_len` signals there is more).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentTicketState {
/// Stable id of the queued ticket.
pub ticket_id: TicketId,
/// Conversation thread this task enters.
pub conversation_id: ConversationId,
/// FIFO position at snapshot time (`0` = head).
pub position: u32,
/// Derived status (in-progress = the agent's current busy ticket).
pub status: TicketWorkStatus,
/// Origin of the ticket (human operator or a delegating agent).
pub source: TicketWorkSource,
/// Display label of the requester.
pub requester_label: String,
/// Bounded, whitespace-normalised excerpt of the task.
pub task_preview: String,
/// Character length of the **original** (un-truncated) task.
pub task_len: usize,
}
/// Derived processing status of a queued ticket.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TicketWorkStatus {
/// The agent's current busy turn is running this ticket.
InProgress,
/// Waiting behind the head / the agent is idle.
Queued,
}
/// Origin of a queued ticket, mirroring [`InputSource`] for the read model.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TicketWorkSource {
/// The human operator.
Human,
/// Another agent delegating via `idea_ask_agent`.
Agent {
/// The delegating agent.
agent_id: AgentId,
},
}
impl From<InputSource> for TicketWorkSource {
fn from(source: InputSource) -> Self {
match source {
InputSource::Human => Self::Human,
InputSource::Agent { agent_id } => Self::Agent { agent_id },
}
}
}
/// Live session coordinates exposed by the work-state read model.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LiveWorkSession {
/// The layout node currently hosting the session view.
pub node_id: NodeId,
/// The live session id.
pub session_id: SessionId,
/// Runtime family that owns the session.
pub kind: LiveSessionKind,
}
/// Read-only use case aggregating manifest agents with live and busy state.
pub struct GetProjectWorkState {
contexts: Arc<dyn AgentContextStore>,
live: Arc<LiveSessions>,
input: Arc<dyn InputMediator>,
queue: Arc<dyn AgentQueueSnapshot>,
/// Per-root handoff source (primary), wired best-effort; `None` ⇒ no summaries.
handoffs: Option<Arc<dyn HandoffProvider>>,
/// Per-root log source (fallback), wired best-effort; `None` ⇒ no fallback turns.
logs: Option<Arc<dyn ConversationLogProvider>>,
}
impl GetProjectWorkState {
/// Builds the read-model use case from existing stores/registries.
///
/// Conversation summaries are off by default; wire them with
/// [`Self::with_conversation_sources`].
#[must_use]
pub fn new(
contexts: Arc<dyn AgentContextStore>,
live: Arc<LiveSessions>,
input: Arc<dyn InputMediator>,
queue: Arc<dyn AgentQueueSnapshot>,
) -> Self {
Self {
contexts,
live,
input,
queue,
handoffs: None,
logs: None,
}
}
/// Wires the best-effort conversation-summary sources (Lot C): the handoff
/// store (primary) and the conversation log (bounded fallback), both resolved
/// per project root. Builder so existing call sites/tests stay unchanged.
#[must_use]
pub fn with_conversation_sources(
mut self,
handoffs: Arc<dyn HandoffProvider>,
logs: Arc<dyn ConversationLogProvider>,
) -> Self {
self.handoffs = Some(handoffs);
self.logs = Some(logs);
self
}
/// Executes the read-only aggregation.
///
/// # Errors
/// - [`AppError::Store`] when the manifest cannot be loaded,
/// - [`AppError::Invalid`] if a manifest entry violates agent invariants.
pub async fn execute(
&self,
input: GetProjectWorkStateInput,
) -> Result<ProjectWorkState, AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?;
let live_by_agent = live_by_agent(self.live.live_agent_snapshots());
let agents = manifest
.entries
.iter()
.map(|entry| {
let agent = entry
.to_agent()
.map_err(|err| AppError::Invalid(err.to_string()))?;
let live = live_by_agent
.get(&agent.id)
.map(|snapshot| LiveWorkSession {
node_id: snapshot.node_id,
session_id: snapshot.session_id,
kind: snapshot.kind,
});
// Tickets are crossed with the busy state: only an agent absent from
// the manifest is dropped (this loop only visits manifest entries), so
// the manifest boundary is naturally preserved.
let busy_ticket = self.input.busy_state(agent.id).ticket();
let tickets = self
.queue
.queue_for(agent.id)
.into_iter()
.map(|snapshot| ticket_state(snapshot, busy_ticket))
.collect();
Ok(AgentWorkState {
agent_id: agent.id,
name: agent.name,
profile_id: agent.profile_id,
live,
busy: self.input.busy_state(agent.id),
tickets,
})
})
.collect::<Result<Vec<_>, AppError>>()?;
// Lot C — best-effort conversation summaries. Only the conversations
// visible through the projected tickets are summarised, deduplicated in
// first-seen order. A preview failure degrades the per-conversation status
// (never an `AppError`): `agents`/live/busy/tickets stay intact above.
let conversations = self
.summarise_conversations(&input.project.root, &agents)
.await;
Ok(ProjectWorkState {
agents,
conversations,
})
}
/// Derives the best-effort summaries of the conversations referenced by the
/// projected tickets. Resolves the per-root handoff/log stores once, then folds
/// each distinct conversation id through [`conversation_summary`].
async fn summarise_conversations(
&self,
root: &ProjectPath,
agents: &[AgentWorkState],
) -> Vec<ConversationWorkSummary> {
let conversation_ids = distinct_conversation_ids(agents);
if conversation_ids.is_empty() {
return Vec::new();
}
let handoff_store = self
.handoffs
.as_ref()
.and_then(|provider| provider.handoff_store_for(root));
let log_store = self
.logs
.as_ref()
.and_then(|provider| provider.conversation_log_for(root));
let mut summaries = Vec::with_capacity(conversation_ids.len());
for conversation in conversation_ids {
summaries.push(conversation_summary(conversation, &handoff_store, &log_store).await);
}
summaries
}
}
/// Collects the conversation ids referenced by the agents' tickets, deduplicated in
/// first-seen (FIFO) order so the summary list mirrors the panel's ordering.
fn distinct_conversation_ids(agents: &[AgentWorkState]) -> Vec<ConversationId> {
let mut seen = HashSet::new();
let mut ids = Vec::new();
for agent in agents {
for ticket in &agent.tickets {
if seen.insert(ticket.conversation_id) {
ids.push(ticket.conversation_id);
}
}
}
ids
}
/// Derives one conversation summary, best-effort, following the Lot C algorithm:
/// handoff present ⇒ `Ready`; absent ⇒ `Missing` (+ log fallback); unreadable but
/// log readable ⇒ `Partial`; both KO ⇒ `Unavailable`. Never returns an error.
async fn conversation_summary(
conversation: ConversationId,
handoff_store: &Option<Arc<dyn HandoffStore>>,
log_store: &Option<Arc<dyn ConversationLog>>,
) -> ConversationWorkSummary {
// A handoff store absent from the wiring is treated as "no handoff yet"
// (`Missing` path), never as an error: the read model stays best-effort.
let handoff = match handoff_store {
Some(store) => Some(store.load(conversation).await),
None => None,
};
match handoff {
Some(Ok(Some(handoff))) => ready_summary(conversation, &handoff),
Some(Ok(None)) | None => {
// Handoff legitimately absent: status stays Missing even if the log
// fallback is itself unreadable (then simply no recent turns).
let recent_turns = recent_turns(log_store, conversation)
.await
.unwrap_or_default();
ConversationWorkSummary {
conversation_id: conversation,
status: ConversationPreviewStatus::Missing,
objective_preview: None,
summary_preview: None,
summary_len: 0,
up_to: None,
recent_turns,
}
}
Some(Err(_)) => match recent_turns(log_store, conversation).await {
// Handoff unreadable but the log answered: partial view from the turns.
Some(recent_turns) => ConversationWorkSummary {
conversation_id: conversation,
status: ConversationPreviewStatus::Partial,
objective_preview: None,
summary_preview: None,
summary_len: 0,
up_to: None,
recent_turns,
},
// Both sources failed: nothing usable to show.
None => ConversationWorkSummary {
conversation_id: conversation,
status: ConversationPreviewStatus::Unavailable,
objective_preview: None,
summary_preview: None,
summary_len: 0,
up_to: None,
recent_turns: Vec::new(),
},
},
}
}
/// Projects a readable [`Handoff`] into a `Ready` summary with bounded previews.
fn ready_summary(conversation: ConversationId, handoff: &Handoff) -> ConversationWorkSummary {
ConversationWorkSummary {
conversation_id: conversation,
status: ConversationPreviewStatus::Ready,
objective_preview: handoff
.objective
.as_deref()
.map(|objective| preview(objective, OBJECTIVE_PREVIEW_MAX_CHARS)),
summary_preview: Some(preview(&handoff.summary_md, HANDOFF_PREVIEW_MAX_CHARS)),
summary_len: handoff.summary_md.chars().count(),
up_to: Some(handoff.up_to),
recent_turns: Vec::new(),
}
}
/// Reads the bounded recent-turns fallback for `conversation`.
///
/// Returns `Some(turns)` (possibly empty) when a log store is wired and answers,
/// `None` when there is no log store or the read failed — the caller uses that
/// distinction to separate `Partial` from `Unavailable`. Never reads the full thread
/// (only [`ConversationLog::last`]) and re-caps defensively to [`RECENT_TURNS_MAX`].
async fn recent_turns(
log_store: &Option<Arc<dyn ConversationLog>>,
conversation: ConversationId,
) -> Option<Vec<ConversationTurnWorkPreview>> {
let store = log_store.as_ref()?;
match store.last(conversation, RECENT_TURNS_MAX).await {
Ok(turns) => Some(
turns
.into_iter()
.take(RECENT_TURNS_MAX)
.map(turn_preview)
.collect(),
),
Err(_) => None,
}
}
/// Projects one [`ConversationTurn`] into its bounded read-model preview.
fn turn_preview(turn: ConversationTurn) -> ConversationTurnWorkPreview {
ConversationTurnWorkPreview {
role: turn.role,
source: turn.source.into(),
at_ms: turn.at_ms,
text_preview: preview(&turn.text, TURN_PREVIEW_MAX_CHARS),
text_len: turn.text.chars().count(),
}
}
/// Projects one FIFO snapshot into a read-model ticket, deriving status from the
/// agent's current busy ticket (the one running its turn = `InProgress`).
fn ticket_state(snapshot: QueuedTicketSnapshot, busy_ticket: Option<TicketId>) -> AgentTicketState {
let status = if busy_ticket == Some(snapshot.id) {
TicketWorkStatus::InProgress
} else {
TicketWorkStatus::Queued
};
AgentTicketState {
ticket_id: snapshot.id,
conversation_id: snapshot.conversation,
position: snapshot.position,
status,
source: snapshot.source.into(),
requester_label: snapshot.requester,
task_preview: task_preview(&snapshot.task),
task_len: snapshot.task.chars().count(),
}
}
/// Builds the bounded task excerpt for the UI panel (see [`preview`]).
///
/// The original length is reported separately as [`AgentTicketState::task_len`].
fn task_preview(task: &str) -> String {
preview(task, TASK_PREVIEW_MAX_CHARS)
}
/// Builds a bounded, whitespace-normalised excerpt of `text` for the UI.
///
/// Trims, collapses any run of whitespace to a single space, then truncates to
/// `max_chars` characters (never mid-codepoint). The original length is reported
/// separately by each caller's `*_len` field.
fn preview(text: &str, max_chars: usize) -> String {
let normalised = text.split_whitespace().collect::<Vec<_>>().join(" ");
if normalised.chars().count() <= max_chars {
normalised
} else {
normalised.chars().take(max_chars).collect()
}
}
fn live_by_agent(snapshots: Vec<LiveSessionSnapshot>) -> HashMap<AgentId, LiveSessionSnapshot> {
let mut out = HashMap::new();
for snapshot in snapshots {
out.entry(snapshot.agent_id).or_insert(snapshot);
}
out
}

View File

@ -42,12 +42,14 @@ impl FakeContexts {
Self(Arc::new(Mutex::new(AgentManifest {
version: 1,
entries: vec![ManifestEntry::from_agent(agent)],
orchestrator: None,
})))
}
fn empty() -> Self {
Self(Arc::new(Mutex::new(AgentManifest {
version: 1,
entries: Vec::new(),
orchestrator: None,
})))
}
}

View File

@ -23,25 +23,25 @@ use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
use domain::events::DomainEvent;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::permission::{
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
ProjectionContext, ProjectorKey,
};
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall,
OutputStream, PermissionStore, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort,
RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::permission::{
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
ProjectionContext, ProjectorKey,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
SessionStrategy, StructuredAdapter,
};
use domain::{PermissionSet, ProjectPermissions};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::{MemoryIndexEntry, MemorySlug, MemoryType};
use domain::{PermissionSet, ProjectPermissions};
use domain::{PtySize, SessionId, SkillId, SkillRef};
use uuid::Uuid;
@ -84,6 +84,7 @@ impl FakeContexts {
manifest: AgentManifest {
version: 1,
entries: Vec::new(),
orchestrator: None,
},
contents: HashMap::new(),
})))
@ -1351,7 +1352,9 @@ async fn launch_conventionfile_injects_project_memory_in_order() {
"first memory line exact format: {doc}"
);
assert!(
doc.contains("- [Permissions](.ideai/memory/perm-archi.md) — sandbox OS + résumé injecté (reference)"),
doc.contains(
"- [Permissions](.ideai/memory/perm-archi.md) — sandbox OS + résumé injecté (reference)"
),
"second memory line exact format: {doc}"
);
// Recalled order preserved; section after the persona.
@ -2445,6 +2448,88 @@ async fn launch_with_store_without_handoff_omits_resume_section() {
);
}
/// LS5 — un `handoff.md` **legacy surdimensionné** (écrit avant LS5, non borné) chargé
/// au lancement ⇒ la borne défensive de `resolve_handoff` ramène le `summary_md` injecté
/// sous [`domain::HANDOFF_SUMMARY_MAX_CHARS`], sans migration de données ni crash. On
/// vérifie sur la section `# Reprise de la conversation` réellement écrite dans le run dir.
#[tokio::test]
async fn launch_bounds_oversize_legacy_handoff_resume_section() {
// Handoff legacy géant : 500 lignes-tours bien formées, objectif absent (⇒ la section
// injectée = le seul summary, comparable directement au plafond).
let mut summary = String::new();
for i in 0..500u32 {
if i > 0 {
summary.push('\n');
}
summary.push_str(&format!(
"- **Response:** tour numero {i} {}",
"x".repeat(30)
));
}
assert!(
summary.chars().count() > domain::HANDOFF_SUMMARY_MAX_CHARS,
"pré-condition : le handoff legacy dépasse le plafond ({})",
summary.chars().count()
);
let conv = conv_id(123);
let store = FakeHandoffStore::with(conv, handoff_for(&summary, None));
let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
let (launch, agent, fs) = launch_with_handoff(Some(provider));
let mut input = launch_input(agent.id);
input.conversation_id = Some(Uuid::from_u128(123).to_string());
launch.execute(input).await.expect("launch succeeds");
let doc = convention_doc(&fs);
// La section de reprise est la DERNIÈRE du document (ordre composeur). Son contenu
// (après l'entête) = le summary injecté, qui doit être borné.
let section = doc
.split("# Reprise de la conversation\n\n")
.nth(1)
.expect("resume section present")
.trim_end();
assert!(
section.chars().count() <= domain::HANDOFF_SUMMARY_MAX_CHARS,
"summary legacy injecté borné, obtenu {}",
section.chars().count()
);
// Distillation : les lignes les plus récentes survivent, les plus anciennes droppées.
assert!(section.contains("tour numero 499"), "le plus récent reste");
assert!(
!section.contains("tour numero 0 "),
"le plus ancien droppé : {section}"
);
// Reparsable : chaque ligne conservée garde le préfixe de tour.
for line in section.lines().filter(|l| !l.is_empty()) {
assert!(line.starts_with("- **"), "ligne reparsable : {line}");
}
}
/// LS5 — zéro régression : un handoff **normal** (sous le plafond) est injecté
/// **verbatim** (la borne est l'identité), objectif et summary intacts.
#[tokio::test]
async fn launch_normal_handoff_injects_summary_verbatim() {
let summary = "**Objectif :** livrer LS5\n\n- **Prompt:** a\n- **Response:** b";
assert!(summary.chars().count() <= domain::HANDOFF_SUMMARY_MAX_CHARS);
let conv = conv_id(123);
let store = FakeHandoffStore::with(conv, handoff_for(summary, Some("livrer LS5")));
let provider = Arc::new(FakeHandoffProvider(Arc::new(store))) as Arc<dyn HandoffProvider>;
let (launch, agent, fs) = launch_with_handoff(Some(provider));
let mut input = launch_input(agent.id);
input.conversation_id = Some(Uuid::from_u128(123).to_string());
launch.execute(input).await.expect("launch succeeds");
let doc = convention_doc(&fs);
// Le summary normal est injecté tel quel (la borne ne l'a pas touché).
assert!(
doc.contains(summary),
"handoff normal injecté verbatim (borne = identité) : {doc}"
);
assert!(doc.contains("**Objectif :** livrer LS5"));
}
// ---------------------------------------------------------------------------
// Bloc 2 — cohérence end-to-end de la clé de conversation (LE test de vérité).
//
@ -2611,7 +2696,9 @@ impl PermissionProjector for FakeClaudeProjector {
/// Faithful Codex projector double: emits a co-owned `MergeToml` over the two
/// managed keys + the matching `--sandbox`/`--ask-for-approval` args, both derived
/// from the posture exactly like the real projector. `eff == None` ⇒ empty.
/// from the posture exactly like the real projector. Workspace-write postures also
/// add the project root as a writable directory (`--add-dir`). `eff == None` ⇒
/// empty.
struct FakeCodexProjector;
impl FakeCodexProjector {
@ -2631,14 +2718,23 @@ impl PermissionProjector for FakeCodexProjector {
fn project(
&self,
eff: Option<&EffectivePermissions>,
_ctx: &ProjectionContext,
ctx: &ProjectionContext,
) -> PermissionProjection {
let Some(eff) = eff else {
return PermissionProjection::empty();
};
let (sandbox, approval) = Self::modes(eff.fallback());
let contents =
format!("sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n");
let contents = format!("sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n");
let mut args = vec![
"--sandbox".to_owned(),
sandbox.to_owned(),
"--ask-for-approval".to_owned(),
approval.to_owned(),
];
if sandbox == "workspace-write" {
args.push("--add-dir".to_owned());
args.push(ctx.project_root.to_owned());
}
PermissionProjection {
files: vec![ProjectedFile::MergeToml {
rel_path: ".codex/config.toml".to_owned(),
@ -2646,12 +2742,7 @@ impl PermissionProjector for FakeCodexProjector {
managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()],
contents,
}],
args: vec![
"--sandbox".to_owned(),
sandbox.to_owned(),
"--ask-for-approval".to_owned(),
approval.to_owned(),
],
args,
env: Vec::new(),
}
}
@ -2741,7 +2832,10 @@ fn convention_file_plan() -> Option<ContextInjectionPlan> {
/// would not fire (here the convention file is GEMINI.md): the explicit field wins.
#[tokio::test]
async fn projection_selects_claude_from_explicit_projector_field() {
let profile = profile(pid(9), ContextInjection::convention_file("GEMINI.md").unwrap())
let profile = profile(
pid(9),
ContextInjection::convention_file("GEMINI.md").unwrap(),
)
.with_projector(ProjectorKey::Claude);
let (launch, agent, fs, _pty, _s) = launch_with_projection(
profile,
@ -2752,7 +2846,10 @@ async fn projection_selects_claude_from_explicit_projector_field() {
Some(perm_doc(Posture::Allow)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
let seeds = fs.writes_ending_with(CLAUDE_SEED_REL);
assert_eq!(seeds.len(), 1, "the Claude projector ran (explicit key)");
@ -2766,8 +2863,14 @@ async fn projection_selects_claude_from_explicit_projector_field() {
/// Claude projector is selected.
#[tokio::test]
async fn projection_falls_back_to_claude_from_convention_file() {
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap());
assert!(profile.projector.is_none(), "no explicit projector (legacy)");
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
);
assert!(
profile.projector.is_none(),
"no explicit projector (legacy)"
);
let (launch, agent, fs, _pty, _s) = launch_with_projection(
profile,
convention_file_plan(),
@ -2775,7 +2878,10 @@ async fn projection_falls_back_to_claude_from_convention_file() {
Some(perm_doc(Posture::Allow)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
assert_eq!(
fs.writes_ending_with(CLAUDE_SEED_REL).len(),
@ -2789,7 +2895,10 @@ async fn projection_falls_back_to_claude_from_convention_file() {
#[tokio::test]
async fn projection_falls_back_to_codex_from_structured_adapter() {
let profile = codex_profile().with_structured_adapter(StructuredAdapter::Codex);
assert!(profile.projector.is_none(), "no explicit projector (legacy)");
assert!(
profile.projector.is_none(),
"no explicit projector (legacy)"
);
let (launch, agent, fs, pty, _s) = launch_with_projection(
profile,
Some(ContextInjectionPlan::File {
@ -2799,7 +2908,10 @@ async fn projection_falls_back_to_codex_from_structured_adapter() {
Some(perm_doc(Posture::Allow)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
assert_eq!(
fs.writes_ending_with(CODEX_CONFIG_REL).len(),
@ -2821,7 +2933,10 @@ async fn projection_falls_back_to_codex_from_structured_adapter() {
/// no projection at all, even with a full registry and a posed policy.
#[tokio::test]
async fn projection_noop_for_unprojectable_profile() {
let profile = profile(pid(9), ContextInjection::convention_file("GEMINI.md").unwrap());
let profile = profile(
pid(9),
ContextInjection::convention_file("GEMINI.md").unwrap(),
);
let (launch, agent, fs, pty, _s) = launch_with_projection(
profile,
Some(ContextInjectionPlan::File {
@ -2831,7 +2946,10 @@ async fn projection_noop_for_unprojectable_profile() {
Some(perm_doc(Posture::Allow)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
assert!(fs.writes_ending_with(CLAUDE_SEED_REL).is_empty());
assert!(fs.writes_ending_with(CODEX_CONFIG_REL).is_empty());
@ -2849,7 +2967,10 @@ async fn projection_noop_for_unprojectable_profile() {
/// inversion vs. the MCP non-clobbering regime (which skips when the file exists).
#[tokio::test]
async fn claude_replace_seed_is_clobbered_on_relaunch() {
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
)
.with_projector(ProjectorKey::Claude);
let (launch, agent, fs, _pty, sessions) = launch_with_projection(
profile,
@ -2864,9 +2985,15 @@ async fn claude_replace_seed_is_clobbered_on_relaunch() {
fs.mark_existing(&seed_path);
// First launch, then simulate the agent exiting so a fresh relaunch is allowed.
launch.execute(launch_input(agent.id)).await.expect("launch 1");
launch
.execute(launch_input(agent.id))
.await
.expect("launch 1");
sessions.remove(&sid(777));
launch.execute(launch_input(agent.id)).await.expect("launch 2");
launch
.execute(launch_input(agent.id))
.await
.expect("launch 2");
let seeds = fs.writes_ending_with(CLAUDE_SEED_REL);
assert_eq!(
@ -2906,7 +3033,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
);
// First projection.
launch.execute(launch_input(agent.id)).await.expect("launch 1");
launch
.execute(launch_input(agent.id))
.await
.expect("launch 1");
let first = String::from_utf8(
fs.writes_ending_with(CODEX_CONFIG_REL)
.last()
@ -2915,8 +3045,14 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
.clone(),
)
.unwrap();
assert!(first.contains("user_key = \"keep-me\""), "unmanaged key preserved: {first}");
assert!(first.contains("[mcp_servers.idea]"), "unmanaged table preserved: {first}");
assert!(
first.contains("user_key = \"keep-me\""),
"unmanaged key preserved: {first}"
);
assert!(
first.contains("[mcp_servers.idea]"),
"unmanaged table preserved: {first}"
);
assert!(
first.contains("sandbox_mode = \"workspace-write\""),
"managed sandbox_mode upserted: {first}"
@ -2928,7 +3064,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
// Second projection (relaunch): managed keys are replaced in place, not dup'd.
sessions.remove(&sid(777));
launch.execute(launch_input(agent.id)).await.expect("launch 2");
launch
.execute(launch_input(agent.id))
.await
.expect("launch 2");
let second = String::from_utf8(
fs.writes_ending_with(CODEX_CONFIG_REL)
.last()
@ -2947,7 +3086,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
1,
"idempotent: no duplicate approval_policy: {second}"
);
assert!(second.contains("user_key = \"keep-me\""), "unmanaged key still preserved");
assert!(
second.contains("user_key = \"keep-me\""),
"unmanaged key still preserved"
);
}
// ---- (4) args/env fold into the spawned spec --------------------------------
@ -2966,18 +3108,34 @@ async fn codex_projection_folds_args_into_spawn_spec() {
Some(perm_doc(Posture::Ask)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
let args = &pty.spawns()[0].args;
// Ask ⇒ workspace-write / on-request, in CLI order.
let pos = args
.windows(2)
.position(|w| w == ["--sandbox".to_owned(), "workspace-write".to_owned()]);
assert!(pos.is_some(), "expected --sandbox workspace-write in {args:?}");
assert!(
pos.is_some(),
"expected --sandbox workspace-write in {args:?}"
);
let pos2 = args
.windows(2)
.position(|w| w == ["--ask-for-approval".to_owned(), "on-request".to_owned()]);
assert!(pos2.is_some(), "expected --ask-for-approval on-request in {args:?}");
assert!(
pos2.is_some(),
"expected --ask-for-approval on-request in {args:?}"
);
let add_dir = args
.windows(2)
.position(|w| w == ["--add-dir".to_owned(), "/home/me/proj".to_owned()]);
assert!(
add_dir.is_some(),
"expected --add-dir project root in {args:?}"
);
}
// ---- (5) MCP decoupling — THE key case of the lot ---------------------------
@ -2998,13 +3156,19 @@ async fn codex_sandbox_projected_without_any_mcp_capability() {
Some(perm_doc(Posture::Deny)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
// The sandbox config WAS written despite the absent MCP capability.
let cfg = fs.writes_ending_with(CODEX_CONFIG_REL);
assert_eq!(cfg.len(), 1, "sandbox config projected without MCP");
let toml = String::from_utf8(cfg[0].1.clone()).unwrap();
assert!(toml.contains("sandbox_mode = \"read-only\""), "Deny ⇒ read-only: {toml}");
assert!(
toml.contains("sandbox_mode = \"read-only\""),
"Deny ⇒ read-only: {toml}"
);
// And the args were folded too.
assert!(
pty.spawns()[0]
@ -3022,7 +3186,10 @@ async fn codex_sandbox_projected_without_any_mcp_capability() {
/// even with a posed policy.
#[tokio::test]
async fn no_registry_means_no_projection() {
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
)
.with_projector(ProjectorKey::Claude);
let (launch, agent, fs, _pty, _s) = launch_with_projection(
profile,
@ -3031,7 +3198,10 @@ async fn no_registry_means_no_projection() {
Some(perm_doc(Posture::Deny)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
assert!(
fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(),
@ -3043,7 +3213,10 @@ async fn no_registry_means_no_projection() {
/// written, even though the registry IS wired.
#[tokio::test]
async fn no_policy_posed_means_empty_projection() {
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
)
.with_projector(ProjectorKey::Claude);
let (launch, agent, fs, _pty, _s) = launch_with_projection(
profile,
@ -3052,7 +3225,10 @@ async fn no_policy_posed_means_empty_projection() {
Some(ProjectPermissions::default()), // project_defaults = None ⇒ resolve_for == None
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
assert!(
fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(),
@ -3066,7 +3242,10 @@ async fn no_policy_posed_means_empty_projection() {
/// projector and is reflected in the produced Claude settings (`defaultMode=plan`).
#[tokio::test]
async fn resolved_deny_posture_reflected_as_plan_mode() {
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
)
.with_projector(ProjectorKey::Claude);
let (launch, agent, fs, _pty, _s) = launch_with_projection(
profile,
@ -3075,7 +3254,10 @@ async fn resolved_deny_posture_reflected_as_plan_mode() {
Some(perm_doc(Posture::Deny)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
let seed = String::from_utf8(fs.writes_ending_with(CLAUDE_SEED_REL)[0].1.clone()).unwrap();
let json: serde_json::Value = serde_json::from_str(&seed).expect("valid settings JSON");
@ -3088,3 +3270,215 @@ async fn resolved_deny_posture_reflected_as_plan_mode() {
"project root flowed into the projection ctx"
);
}
// ---------------------------------------------------------------------------
// État du projet injection at launch (lot LS4) — `resolve_live_state` owns the
// manifest join + self-exclusion + ordering + cap; the composer renders it.
// ---------------------------------------------------------------------------
/// In-memory [`LiveStateStore`] seeded with fixed rows (no I/O), to drive the
/// live-state preview injected into the convention file at launch.
struct SeededLiveStore {
state: Mutex<domain::live_state::LiveState>,
}
#[async_trait]
impl domain::ports::LiveStateStore for SeededLiveStore {
async fn load(&self) -> Result<domain::live_state::LiveState, domain::ports::StoreError> {
Ok(self.state.lock().unwrap().clone())
}
async fn upsert(
&self,
entry: domain::live_state::LiveEntry,
) -> Result<(), domain::ports::StoreError> {
self.state.lock().unwrap().upsert(entry);
Ok(())
}
async fn prune(
&self,
now_ms: u64,
ttl_ms: u64,
max_n: usize,
) -> Result<(), domain::ports::StoreError> {
self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n);
Ok(())
}
}
/// Fixed clock so the seeded rows are never pruned for age at read time.
struct InjectClock(i64);
impl domain::ports::Clock for InjectClock {
fn now_millis(&self) -> i64 {
self.0
}
}
/// A [`LiveStateLeanProvider`] yielding the same [`GetLiveStateLean`] over a shared
/// seeded store (root-agnostic for the test).
struct SeededLeanProvider {
getter: Arc<application::GetLiveStateLean>,
}
impl application::LiveStateLeanProvider for SeededLeanProvider {
fn live_state_lean_for(
&self,
_root: &ProjectPath,
) -> Option<Arc<application::GetLiveStateLean>> {
Some(Arc::clone(&self.getter))
}
}
#[tokio::test]
async fn launch_injects_live_state_excluding_self_capped_in_manifest_order() {
use domain::live_state::{LiveEntry, LiveState, WorkStatus};
let injection = ContextInjection::convention_file("CLAUDE.md").unwrap();
let plan = Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
});
// The launched agent (self) — must be EXCLUDED from its own preview.
let me = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let contexts = FakeContexts::with_agent(&me, "# ctx body");
// 18 OTHER agents in the manifest, in a deterministic order (aid 2..=19).
{
let mut inner = contexts.0.lock().unwrap();
for n in 2..=19u128 {
let other = scratch_agent(
aid(n),
&format!("A{n:02}"),
&format!("agents/a{n}.md"),
pid(9),
);
inner
.manifest
.entries
.push(ManifestEntry::from_agent(&other));
}
}
// Seed the live-state store: a row for self (must not surface), a row for each
// of the 18 manifest peers, plus one OFF-manifest row (aid 99) that must drop.
let now = 1_000_000_i64;
let mut state = LiveState::default();
state.upsert(
LiveEntry::new(
aid(1),
None,
"my own work",
WorkStatus::Working,
None,
None,
now as u64,
)
.unwrap(),
);
for n in 2..=19u128 {
state.upsert(
LiveEntry::new(
aid(n),
None,
format!("intent {n}"),
WorkStatus::Working,
None,
None,
now as u64,
)
.unwrap(),
);
}
state.upsert(
LiveEntry::new(
aid(99),
None,
"ghost",
WorkStatus::Idle,
None,
None,
now as u64,
)
.unwrap(),
);
let store = Arc::new(SeededLiveStore {
state: Mutex::new(state),
});
let getter = Arc::new(application::GetLiveStateLean::new(
store as Arc<dyn domain::ports::LiveStateStore>,
Arc::new(InjectClock(now)),
));
let provider = Arc::new(SeededLeanProvider { getter });
let profiles = FakeProfiles::new(vec![profile(pid(9), injection)]);
let tr = trace();
let fs = FakeFs::new(Arc::clone(&tr));
let pty = FakePty::new(Arc::clone(&tr), sid(777));
let sessions = Arc::new(TerminalSessions::new());
let bus = SpyBus::default();
let launch = LaunchAgent::new(
Arc::new(contexts),
Arc::new(profiles),
Arc::new(FakeRuntime::new(Arc::clone(&tr), plan)),
Arc::new(fs.clone()),
Arc::new(pty.clone()),
Arc::new(FakeSkills::default()),
Arc::clone(&sessions),
Arc::new(bus.clone()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall::default()),
None,
)
.with_live_state_lean(provider as Arc<dyn application::LiveStateLeanProvider>);
launch.execute(launch_input(me.id)).await.expect("launch");
let writes = fs.context_writes();
assert_eq!(writes.len(), 1, "exactly one convention file written");
let doc = String::from_utf8(writes[0].1.clone()).unwrap();
// The section is present.
assert!(
doc.contains("# État du projet"),
"live-state section present: {doc}"
);
// Self is EXCLUDED: the launched agent never sees its own row.
assert!(
!doc.contains("- **Backend**"),
"the launched agent must not see its own live row: {doc}"
);
// The off-manifest row never surfaces (its name is unknown to the manifest).
assert!(
!doc.contains("ghost"),
"off-manifest rows are dropped: {doc}"
);
// Cap: exactly LIVE_STATE_INJECT_MAX (16) peer rows are injected, in manifest
// order ⇒ A02..=A17 present, A18/A19 dropped by the cap. The only `- **` bullets
// in this composition are the live rows (no skills/memory bullets here).
assert_eq!(
doc.matches("- **").count(),
16,
"capped to LIVE_STATE_INJECT_MAX peer rows: {doc}"
);
assert!(
doc.contains("- **A02** — working · intent 2"),
"first peer row: {doc}"
);
assert!(
doc.contains("- **A17** — working · intent 17"),
"16th peer row: {doc}"
);
assert!(
!doc.contains("- **A18**"),
"18th agent dropped by the cap: {doc}"
);
assert!(
!doc.contains("- **A19**"),
"19th agent dropped by the cap: {doc}"
);
// Manifest order preserved across the kept rows.
let a02 = doc.find("**A02**").unwrap();
let a17 = doc.find("**A17**").unwrap();
assert!(a02 < a17, "rows follow manifest order: {doc}");
}

View File

@ -76,6 +76,7 @@ impl FakeContexts {
manifest: AgentManifest {
version: 1,
entries: Vec::new(),
orchestrator: None,
},
contents: HashMap::new(),
saves: 0,
@ -1058,7 +1059,10 @@ async fn swap_claude_to_codex_removes_claude_seed_and_projects_codex() {
"the orphan .claude seed must be removed; removed={:?}",
f.fs.removed()
);
assert!(!f.fs.has_file(&seed_path), "the seed is gone from the FS state");
assert!(
!f.fs.has_file(&seed_path),
"the seed is gone from the FS state"
);
// The relaunch projected the Codex sandbox config + args.
assert!(
@ -1066,7 +1070,10 @@ async fn swap_claude_to_codex_removes_claude_seed_and_projects_codex() {
"the relaunch must project the Codex config"
);
let toml = String::from_utf8(f.fs.read_file(&codex_path).unwrap()).unwrap();
assert!(toml.contains("sandbox_mode = \"workspace-write\""), "{toml}");
assert!(
toml.contains("sandbox_mode = \"workspace-write\""),
"{toml}"
);
assert!(
f.pty.last_spawn_args().contains(&"--sandbox".to_owned()),
"sandbox args folded into the relaunch spawn: {:?}",
@ -1102,7 +1109,10 @@ async fn swap_claude_to_claude_does_not_remove_seed() {
f.fs.removed()
);
// It is still present (re-clobbered by the relaunch's projection).
assert!(f.fs.has_file(&seed_path), "the seed survives the same-family swap");
assert!(
f.fs.has_file(&seed_path),
"the seed survives the same-family swap"
);
}
/// (3) **Codex→Claude**: Codex owns no Replace file ⇒ nothing is removed on the
@ -1138,13 +1148,19 @@ async fn swap_codex_to_claude_removes_nothing_and_keeps_codex_config() {
f.fs.removed()
);
// The co-owned Codex config is untouched by cleanup…
assert!(f.fs.has_file(&codex_path), "the .codex/config.toml is never deleted");
assert!(
f.fs.has_file(&codex_path),
"the .codex/config.toml is never deleted"
);
assert!(
!f.fs.removed().iter().any(|p| p == &codex_path),
"the .codex/config.toml is not in the removed list"
);
// …and the relaunch projected the new Claude seed.
assert!(f.fs.has_file(&seed_path), "the relaunch writes the Claude seed");
assert!(
f.fs.has_file(&seed_path),
"the relaunch writes the Claude seed"
);
}
/// (4a) **No-op (no registry)**: without a projector registry wired on the swap,
@ -1153,7 +1169,8 @@ async fn swap_codex_to_claude_removes_nothing_and_keeps_codex_config() {
async fn swap_without_registry_removes_nothing() {
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
// The default fixture wires NO projector registry on the swap.
let f = fixture_with_profiles(&agent, vec![claude_profile(pid(1)), codex_profile(pid(2))]).await;
let f =
fixture_with_profiles(&agent, vec![claude_profile(pid(1)), codex_profile(pid(2))]).await;
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
f.fs.put(&seed_path, b"{\"permissions\":{}}");
@ -1251,7 +1268,8 @@ async fn swap_with_cleanup_preserves_pair_id_and_handoff() {
let run_dir = run_dir_of(&agent.id);
f.fs.put(&format!("{run_dir}/{CLAUDE_SEED_REL}"), b"{}");
let pair = pair_uuid();
f.handoffs.seed(&pair, "État au dernier tour : LP3-4.", Some("objectif"));
f.handoffs
.seed(&pair, "État au dernier tour : LP3-4.", Some("objectif"));
let host = nid(1);
seed_live_agent_session(&f.sessions, agent.id, host, sid(42));
@ -1273,7 +1291,10 @@ async fn swap_with_cleanup_preserves_pair_id_and_handoff() {
// …and the pair id was preserved on the persisted leaf (P8d invariant).
let (conv, running) = leaf_state(&f.fs, host).expect("leaf persisted");
assert_eq!(conv.as_deref(), Some(pair.as_str()), "pair id preserved");
assert!(!running, "engine running flag reset by invalidate_engine_link");
assert!(
!running,
"engine running flag reset by invalidate_engine_link"
);
// …and the handoff (keyed by that pair id) was re-injected into the new engine's
// convention file — proving the relaunch threaded the preserved pair id. (The

View File

@ -20,7 +20,7 @@ use async_trait::async_trait;
use domain::ports::StoreError;
use domain::{
ConversationId, ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
InputSource, TurnId, TurnRole,
InputSource, TurnId, TurnRole, HANDOFF_SUMMARY_MAX_CHARS,
};
use application::{AppError, RecordTurn};
@ -373,3 +373,109 @@ async fn records_are_isolated_per_conversation() {
assert_eq!(hb.up_to, tb.id);
assert!(hb.summary_md.contains("beta") && !hb.summary_md.contains("alpha"));
}
// ---------------------------------------------------------------------------
// LS5 — la borne universelle s'applique côté `record`, indépendamment du résumeur.
// ---------------------------------------------------------------------------
/// Un résumeur **adversarial** qui renvoie toujours un `summary_md` largement
/// surdimensionné (au-delà du plafond), au format reparsable, pour prouver que la
/// garantie de borne vient de `record` (via `bound_handoff_summary`), PAS du résumeur.
#[derive(Default)]
struct OversizeSummarizer;
#[async_trait]
impl HandoffSummarizer for OversizeSummarizer {
async fn fold(&self, _prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff {
// Beaucoup de lignes de tours bien formées ⇒ summary_md >> plafond.
let mut s = String::from("**Objectif :** objectif preserve");
for i in 0..1_000 {
s.push_str(&format!(
"\n- **Response:** ligne surdimensionnee numero {i}"
));
}
let up_to = new_turns
.last()
.map(|t| t.id)
.expect("fold receives at least one turn");
Handoff::new(s, up_to, Some("objectif preserve".to_owned()))
}
}
/// Avec un résumeur qui produit un summary géant, le handoff **persisté** est borné à
/// `HANDOFF_SUMMARY_MAX_CHARS` (garantie universelle), tout en gardant l'ordre `append`-
/// d'abord, le fold incrémental (un appel par tour), objectif et curseur préservés.
#[tokio::test]
async fn record_bounds_persisted_handoff_even_with_oversize_summarizer() {
let log = Arc::new(InMemoryConversationLog::default());
let handoffs = Arc::new(InMemoryHandoffStore::default());
let summarizer = Arc::new(OversizeSummarizer);
let uc = RecordTurn::new(log.clone(), handoffs.clone(), summarizer.clone());
let conv = conv_id(1);
let t1 = turn(conv, turn_id(10), "premier tour");
uc.record(conv, t1.clone()).await.expect("record ok");
// append-d'abord : le log canonique porte le tour.
assert_eq!(log.thread(conv), vec![t1.clone()]);
// Le handoff persisté est BORNÉ malgré le résumeur surdimensionné.
let h = handoffs.loaded(conv).expect("handoff saved");
assert!(
h.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
"summary persisté borné, obtenu {}",
h.summary_md.chars().count()
);
// Objectif + curseur jamais modifiés par la borne.
assert_eq!(h.objective.as_deref(), Some("objectif preserve"));
assert_eq!(h.up_to, t1.id);
// Reparsable : le préambule d'objectif survit, les lignes de tours sont préfixées.
assert!(h.summary_md.contains("**Objectif :** objectif preserve"));
for line in h.summary_md.lines().filter(|l| l.starts_with("- **")) {
assert!(line.starts_with("- **"));
}
}
/// La borne au `record` reste incrémentale sur plusieurs tours : un `fold` par tour
/// (jamais de relecture complète), et chaque checkpoint persiste un summary borné.
#[tokio::test]
async fn record_stays_incremental_and_bounded_across_turns() {
use std::sync::Mutex;
/// Compte les appels `fold` pour prouver l'incrémentalité (un par tour).
#[derive(Default)]
struct CountingOversize {
calls: Mutex<usize>,
}
#[async_trait]
impl HandoffSummarizer for CountingOversize {
async fn fold(&self, _prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff {
*self.calls.lock().unwrap() += 1;
// L'incrément ne porte qu'un seul tour à chaque checkpoint.
assert_eq!(new_turns.len(), 1, "fold incrémental : un tour à la fois");
let big = "g".repeat(HANDOFF_SUMMARY_MAX_CHARS * 3);
let up_to = new_turns[0].id;
Handoff::new(format!("- **Response:** {big}"), up_to, None)
}
}
let log = Arc::new(InMemoryConversationLog::default());
let handoffs = Arc::new(InMemoryHandoffStore::default());
let summarizer = Arc::new(CountingOversize::default());
let uc = RecordTurn::new(log.clone(), handoffs.clone(), summarizer.clone());
let conv = conv_id(1);
for n in 1..=5u128 {
uc.record(conv, turn(conv, turn_id(n), &format!("tour {n}")))
.await
.expect("record ok");
let h = handoffs.loaded(conv).expect("handoff saved");
assert!(
h.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
"checkpoint {n} borné, obtenu {}",
h.summary_md.chars().count()
);
}
assert_eq!(*summarizer.calls.lock().unwrap(), 5, "un fold par tour");
}

View File

@ -0,0 +1,343 @@
//! LS6 tests — `RotateConversationLog` (rotation best-effort, plancher = `up_to`) et
//! `ReadConversationPage` (DTO humain riche, texte complet, mapping source), avec des
//! fakes in-memory des ports (aucune I/O réelle ; celle-ci est couverte côté infra).
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::input::InputSource;
use domain::ports::StoreError;
use domain::project::ProjectPath;
use domain::{
AgentId, ConversationArchive, ConversationId, ConversationTurn, Handoff, HandoffStore,
PageCursor, PageDirection, SegmentStats, TurnId, TurnRole, TurnSlice,
};
use application::{
ConversationArchiveProvider, HandoffProvider, ReadConversationPage, ReadConversationPageInput,
RotateConversationLog, RotateConversationLogInput, TurnSource,
};
// ---------------------------------------------------------------------------
// Constructeurs déterministes
// ---------------------------------------------------------------------------
fn conv_id(n: u128) -> ConversationId {
ConversationId::from_uuid(uuid::Uuid::from_u128(n))
}
fn turn_id(n: u128) -> TurnId {
TurnId::from_uuid(uuid::Uuid::from_u128(n))
}
fn agent_id(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn root() -> ProjectPath {
ProjectPath::new("/home/me/proj").unwrap()
}
fn turn(id: TurnId, source: InputSource, role: TurnRole, text: &str) -> ConversationTurn {
ConversationTurn::new(id, conv_id(1), 1_700_000_000_000, source, role, text)
}
// ---------------------------------------------------------------------------
// Fakes des ports LS6
// ---------------------------------------------------------------------------
/// [`ConversationArchive`] in-memory : `stats` configurables, `page` servie depuis un
/// Vec, et **enregistrement** des appels `rotate` (pour prouver `keep_from = up_to`).
/// Optionnellement, force une erreur store pour prouver la propagation typée.
struct FakeArchive {
stats: Mutex<SegmentStats>,
turns: Mutex<Vec<ConversationTurn>>,
rotate_calls: Mutex<Vec<TurnId>>,
fail: Mutex<bool>,
}
impl FakeArchive {
fn build(stats: SegmentStats, turns: Vec<ConversationTurn>, fail: bool) -> Self {
Self {
stats: Mutex::new(stats),
turns: Mutex::new(turns),
rotate_calls: Mutex::new(Vec::new()),
fail: Mutex::new(fail),
}
}
fn with_stats(turns: usize, bytes: u64) -> Self {
Self::build(
SegmentStats {
active_turns: turns,
active_bytes: bytes,
},
Vec::new(),
false,
)
}
fn with_page(turns: Vec<ConversationTurn>) -> Self {
Self::build(
SegmentStats {
active_turns: 0,
active_bytes: 0,
},
turns,
false,
)
}
fn failing() -> Self {
Self::build(
SegmentStats {
active_turns: 10_000,
active_bytes: 0,
},
Vec::new(),
true,
)
}
fn rotate_calls(&self) -> Vec<TurnId> {
self.rotate_calls.lock().unwrap().clone()
}
}
#[async_trait]
impl ConversationArchive for FakeArchive {
async fn stats(&self, _c: ConversationId) -> Result<SegmentStats, StoreError> {
if *self.fail.lock().unwrap() {
return Err(StoreError::Io("forced".into()));
}
Ok(*self.stats.lock().unwrap())
}
async fn rotate(&self, _c: ConversationId, keep_from: TurnId) -> Result<(), StoreError> {
if *self.fail.lock().unwrap() {
return Err(StoreError::Io("forced".into()));
}
self.rotate_calls.lock().unwrap().push(keep_from);
Ok(())
}
async fn page(
&self,
_c: ConversationId,
cursor: PageCursor,
limit: usize,
) -> Result<TurnSlice, StoreError> {
if *self.fail.lock().unwrap() {
return Err(StoreError::Io("forced".into()));
}
// Pagination minimale Forward-depuis-début suffisante pour les tests DTO.
let all = self.turns.lock().unwrap().clone();
let _ = cursor;
let end = limit.min(all.len());
Ok(TurnSlice {
turns: all[..end].to_vec(),
has_more: end < all.len(),
})
}
}
struct FakeArchiveProvider(Arc<dyn ConversationArchive>);
impl ConversationArchiveProvider for FakeArchiveProvider {
fn conversation_archive_for(
&self,
_root: &ProjectPath,
) -> Option<Arc<dyn ConversationArchive>> {
Some(Arc::clone(&self.0))
}
}
/// Provider qui ne fournit **aucune** archive (best-effort absent).
struct NoArchiveProvider;
impl ConversationArchiveProvider for NoArchiveProvider {
fn conversation_archive_for(
&self,
_root: &ProjectPath,
) -> Option<Arc<dyn ConversationArchive>> {
None
}
}
#[derive(Default)]
struct FakeHandoffStore(Mutex<Option<Handoff>>);
#[async_trait]
impl HandoffStore for FakeHandoffStore {
async fn load(&self, _c: ConversationId) -> Result<Option<Handoff>, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
async fn save(&self, _c: ConversationId, handoff: Handoff) -> Result<(), StoreError> {
*self.0.lock().unwrap() = Some(handoff);
Ok(())
}
}
struct FakeHandoffProvider(Arc<dyn HandoffStore>);
impl HandoffProvider for FakeHandoffProvider {
fn handoff_store_for(&self, _root: &ProjectPath) -> Option<Arc<dyn HandoffStore>> {
Some(Arc::clone(&self.0))
}
}
fn rotate_uc(
archive: Arc<dyn ConversationArchive>,
handoff: Option<Handoff>,
) -> RotateConversationLog {
let store = Arc::new(FakeHandoffStore(Mutex::new(handoff))) as Arc<dyn HandoffStore>;
RotateConversationLog::new(
Arc::new(FakeArchiveProvider(archive)),
Arc::new(FakeHandoffProvider(store)),
)
}
fn input() -> RotateConversationLogInput {
RotateConversationLogInput {
project_root: root(),
conversation: conv_id(1),
}
}
// ---------------------------------------------------------------------------
// RotateConversationLog
// ---------------------------------------------------------------------------
/// Plancher lu du handoff + stats au-dessus du seuil ⇒ `rotate(keep_from = up_to)`.
#[tokio::test]
async fn rotate_uses_handoff_up_to_as_floor_and_calls_rotate() {
// Stats au-dessus du seuil de tours (défaut 500).
let archive = Arc::new(FakeArchive::with_stats(10_000, 0));
let handoff = Handoff::new("résumé", turn_id(42), Some("but".to_owned()));
let uc = rotate_uc(
archive.clone() as Arc<dyn ConversationArchive>,
Some(handoff),
);
uc.execute(input()).await.expect("rotate ok");
assert_eq!(
archive.rotate_calls(),
vec![turn_id(42)],
"rotate appelé avec keep_from = up_to du handoff"
);
}
/// Pas de handoff ⇒ `floor = None` ⇒ skip : `rotate` jamais appelé même au-dessus du seuil.
#[tokio::test]
async fn rotate_skips_without_handoff() {
let archive = Arc::new(FakeArchive::with_stats(10_000, u64::MAX));
let uc = rotate_uc(archive.clone() as Arc<dyn ConversationArchive>, None);
uc.execute(input()).await.expect("skip ok");
assert!(
archive.rotate_calls().is_empty(),
"sans handoff ⇒ aucun rotate"
);
}
/// Handoff présent mais stats SOUS les deux seuils ⇒ skip (pas de rotation).
#[tokio::test]
async fn rotate_skips_under_thresholds() {
let archive = Arc::new(FakeArchive::with_stats(10, 1_000));
let handoff = Handoff::new("r", turn_id(5), None);
let uc = rotate_uc(
archive.clone() as Arc<dyn ConversationArchive>,
Some(handoff),
);
uc.execute(input()).await.expect("skip ok");
assert!(archive.rotate_calls().is_empty(), "sous les seuils ⇒ skip");
}
/// Provider d'archive absent ⇒ no-op silencieux (best-effort), jamais une erreur.
#[tokio::test]
async fn rotate_without_archive_provider_is_noop() {
let store = Arc::new(FakeHandoffStore(Mutex::new(Some(Handoff::new(
"r",
turn_id(5),
None,
))))) as Arc<dyn HandoffStore>;
let uc = RotateConversationLog::new(
Arc::new(NoArchiveProvider),
Arc::new(FakeHandoffProvider(store)),
);
uc.execute(input()).await.expect("no provider ⇒ Ok(())");
}
/// Une erreur store est **propagée typée** (`AppError::Store`) par le use case ; le
/// **swallowing best-effort** est la responsabilité du câblage (cf. test app-tauri).
#[tokio::test]
async fn rotate_propagates_typed_store_error() {
let archive = Arc::new(FakeArchive::failing());
let handoff = Handoff::new("r", turn_id(9), None);
let uc = rotate_uc(archive as Arc<dyn ConversationArchive>, Some(handoff));
let err = uc.execute(input()).await.expect_err("store error propagée");
assert!(
matches!(err, application::AppError::Store(_)),
"erreur store typée, got {err:?}"
);
}
// ---------------------------------------------------------------------------
// ReadConversationPage — DTO humain riche
// ---------------------------------------------------------------------------
/// La page mappe un DTO riche : texte **complet** (non borné), `text_len` correct,
/// mapping source Human/Agent, `has_more` et `next_anchor` (= dernier id de la page).
#[tokio::test]
async fn read_page_maps_rich_dto_with_full_text_and_sources() {
let big = "Z".repeat(30_000);
let turns = vec![
turn(turn_id(1), InputSource::Human, TurnRole::Prompt, &big),
turn(
turn_id(2),
InputSource::agent(agent_id(7)),
TurnRole::Response,
"réponse agent",
),
turn(turn_id(3), InputSource::Human, TurnRole::Prompt, "suite"),
];
let archive = Arc::new(FakeArchive::with_page(turns));
let uc = ReadConversationPage::new(Arc::new(FakeArchiveProvider(archive)));
// limit 2 ⇒ page [1,2], has_more (il reste le tour 3).
let page = uc
.execute(ReadConversationPageInput {
project_root: root(),
conversation: conv_id(1),
cursor: PageCursor {
anchor: None,
direction: PageDirection::Forward,
},
limit: 2,
})
.await
.expect("page ok");
assert_eq!(page.turns.len(), 2);
// Texte complet préservé + text_len.
assert_eq!(page.turns[0].text.chars().count(), 30_000);
assert_eq!(page.turns[0].text_len, 30_000);
// Mapping source : Human puis Agent{7}.
assert_eq!(page.turns[0].source, TurnSource::Human);
assert_eq!(
page.turns[1].source,
TurnSource::Agent {
agent_id: agent_id(7)
}
);
// has_more + next_anchor = dernier id de la page (tour 2).
assert!(page.has_more, "il reste le tour 3");
assert_eq!(page.next_anchor, Some(turn_id(2)));
}
/// Provider d'archive absent ⇒ page **vide** (best-effort), `next_anchor = None`.
#[tokio::test]
async fn read_page_without_provider_is_empty() {
let uc = ReadConversationPage::new(Arc::new(NoArchiveProvider));
let page = uc
.execute(ReadConversationPageInput {
project_root: root(),
conversation: conv_id(1),
cursor: PageCursor {
anchor: None,
direction: PageDirection::Forward,
},
limit: 50,
})
.await
.expect("page ok");
assert!(page.turns.is_empty() && !page.has_more && page.next_anchor.is_none());
}

View File

@ -258,8 +258,9 @@ async fn stream_without_final_does_not_mark_idle_and_is_io_error() {
#[tokio::test]
async fn send_error_is_propagated_and_no_mark_idle() {
let agent = aid(5);
let session =
ScriptedSession::new(Script::Err(AgentSessionError::Decode("bad json".to_owned())));
let session = ScriptedSession::new(Script::Err(AgentSessionError::Decode(
"bad json".to_owned(),
)));
let mediator = RecordingMediator::new();
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
@ -322,8 +323,14 @@ async fn timeout_returns_timeout_no_mark_idle_session_alive() {
};
let mediator = RecordingMediator::new();
let out =
drain_with_readiness(&session, "x", Some(Duration::from_millis(20)), &mediator, agent).await;
let out = drain_with_readiness(
&session,
"x",
Some(Duration::from_millis(20)),
&mediator,
agent,
)
.await;
assert_eq!(out, Err(AgentSessionError::Timeout));
assert_eq!(
mediator.mark_idle_count(agent),

View File

@ -143,6 +143,7 @@ impl FakeContexts {
manifest: Arc::new(Mutex::new(AgentManifest {
version: 1,
entries,
orchestrator: None,
})),
fail: false,
}
@ -152,6 +153,7 @@ impl FakeContexts {
manifest: Arc::new(Mutex::new(AgentManifest {
version: 1,
entries: Vec::new(),
orchestrator: None,
})),
fail: true,
}

View File

@ -0,0 +1,242 @@
//! Tests for the Lot E1 auto-memory harvest use case (`HarvestMemoryFromTurn`).
//!
//! Best-effort, port-faked: a `Response` turn carrying valid ` ```idea-memory `
//! blocks is persisted + announced; non-`Response` turns are no-ops; invalid blocks
//! are skipped individually; a store failure is reported, never propagated.
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::conversation_log::TurnRole;
use domain::events::DomainEvent;
use domain::ports::{EventBus, EventStream, MemoryError, MemoryStore};
use domain::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug, ProjectPath};
use application::HarvestMemoryFromTurn;
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
/// In-memory store keyed by slug, with an optional forced `save` failure.
#[derive(Default)]
struct FakeMemories {
saved: Mutex<Vec<Memory>>,
fail_save: bool,
}
impl FakeMemories {
fn failing() -> Self {
Self {
saved: Mutex::new(Vec::new()),
fail_save: true,
}
}
fn slugs(&self) -> Vec<String> {
self.saved
.lock()
.unwrap()
.iter()
.map(|m| m.slug().as_str().to_owned())
.collect()
}
}
#[async_trait]
impl MemoryStore for FakeMemories {
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
Ok(self.saved.lock().unwrap().clone())
}
async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
self.saved
.lock()
.unwrap()
.iter()
.find(|m| m.slug() == slug)
.cloned()
.ok_or(MemoryError::NotFound)
}
async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> {
if self.fail_save {
return Err(MemoryError::Frontmatter("forced failure".to_owned()));
}
self.saved.lock().unwrap().push(memory.clone());
Ok(())
}
async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
Ok(())
}
async fn read_index(&self, _root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
Ok(Vec::new())
}
async fn resolve_links(
&self,
_root: &ProjectPath,
_slug: &MemorySlug,
) -> Result<Vec<MemoryLink>, MemoryError> {
Ok(Vec::new())
}
}
#[derive(Default, Clone)]
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
impl SpyBus {
fn saved_slugs(&self) -> Vec<String> {
self.0
.lock()
.unwrap()
.iter()
.filter_map(|e| match e {
DomainEvent::MemorySaved { slug } => Some(slug.as_str().to_owned()),
_ => None,
})
.collect()
}
}
impl EventBus for SpyBus {
fn publish(&self, event: DomainEvent) {
self.0.lock().unwrap().push(event);
}
fn subscribe(&self) -> EventStream {
Box::new(std::iter::empty())
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn root() -> ProjectPath {
ProjectPath::new("/home/me/demo").unwrap()
}
fn block(slug: &str, body: &str) -> String {
format!(
"```idea-memory\nslug: {slug}\ntitle: Title\ntype: project\ndescription: hook\n---\n{body}\n```"
)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn ignores_non_response_turns() {
let store = Arc::new(FakeMemories::default());
let bus = Arc::new(SpyBus::default());
let harvester = HarvestMemoryFromTurn::new(
Arc::clone(&store) as Arc<dyn MemoryStore>,
Arc::clone(&bus) as Arc<dyn EventBus>,
);
let text = block("note-a", "Body");
for role in [TurnRole::Prompt, TurnRole::ToolActivity] {
let outcome = harvester.harvest(&root(), role, &text).await;
assert_eq!(outcome.found, 0, "non-response turns are not parsed");
assert!(outcome.saved.is_empty());
}
assert!(store.slugs().is_empty(), "nothing persisted");
assert!(bus.saved_slugs().is_empty(), "nothing announced");
}
#[tokio::test]
async fn response_with_valid_block_saves_and_announces() {
let store = Arc::new(FakeMemories::default());
let bus = Arc::new(SpyBus::default());
let harvester = HarvestMemoryFromTurn::new(
Arc::clone(&store) as Arc<dyn MemoryStore>,
Arc::clone(&bus) as Arc<dyn EventBus>,
);
let text = format!(
"Sure, noting that.\n\n{}",
block("api-url", "Use the staging URL.")
);
let outcome = harvester.harvest(&root(), TurnRole::Response, &text).await;
assert_eq!(outcome.found, 1);
assert_eq!(outcome.saved.len(), 1);
assert_eq!(outcome.saved[0].as_str(), "api-url");
assert_eq!(outcome.skipped, 0);
assert!(outcome.errors.is_empty());
assert_eq!(store.slugs(), vec!["api-url"], "persisted via the store");
assert_eq!(bus.saved_slugs(), vec!["api-url"], "MemorySaved announced");
}
#[tokio::test]
async fn partial_invalid_blocks_are_skipped_individually() {
let store = Arc::new(FakeMemories::default());
let bus = Arc::new(SpyBus::default());
let harvester = HarvestMemoryFromTurn::new(
Arc::clone(&store) as Arc<dyn MemoryStore>,
Arc::clone(&bus) as Arc<dyn EventBus>,
);
// One valid, one with an invalid slug.
let text = format!(
"{}\n{}",
block("good-one", "Body"),
block("Bad Slug", "Body")
);
let outcome = harvester.harvest(&root(), TurnRole::Response, &text).await;
assert_eq!(outcome.found, 2);
assert_eq!(outcome.saved.len(), 1, "only the valid one persisted");
assert_eq!(outcome.saved[0].as_str(), "good-one");
assert_eq!(outcome.skipped, 1);
assert_eq!(outcome.errors.len(), 1);
assert_eq!(store.slugs(), vec!["good-one"]);
assert_eq!(bus.saved_slugs(), vec!["good-one"]);
}
#[tokio::test]
async fn store_failure_is_reported_not_propagated() {
let store = Arc::new(FakeMemories::failing());
let bus = Arc::new(SpyBus::default());
let harvester = HarvestMemoryFromTurn::new(
Arc::clone(&store) as Arc<dyn MemoryStore>,
Arc::clone(&bus) as Arc<dyn EventBus>,
);
let text = block("doomed", "Body");
// No panic, no Result — the call simply returns an outcome carrying the error.
let outcome = harvester.harvest(&root(), TurnRole::Response, &text).await;
assert_eq!(outcome.found, 1);
assert!(outcome.saved.is_empty(), "save failed ⇒ nothing saved");
assert_eq!(outcome.skipped, 1);
assert_eq!(outcome.errors.len(), 1);
assert!(
outcome.errors[0].contains("store failed"),
"{:?}",
outcome.errors
);
// A failed save must not announce a MemorySaved event.
assert!(bus.saved_slugs().is_empty());
}
#[tokio::test]
async fn parses_only_current_text_no_full_log() {
// The use case takes only the turn's `text`; it has no `ConversationLog`
// dependency, so it structurally cannot read the full thread. We assert it
// acts purely on the text passed in: a response with no directive saves
// nothing even though the store already holds (unrelated) notes.
let store = Arc::new(FakeMemories::default());
let bus = Arc::new(SpyBus::default());
let harvester = HarvestMemoryFromTurn::new(
Arc::clone(&store) as Arc<dyn MemoryStore>,
Arc::clone(&bus) as Arc<dyn EventBus>,
);
let outcome = harvester
.harvest(
&root(),
TurnRole::Response,
"A plain reply with no directive.",
)
.await;
assert_eq!(outcome.found, 0);
assert!(outcome.saved.is_empty());
assert!(store.slugs().is_empty());
}

View File

@ -22,9 +22,9 @@ use domain::ids::{AgentId, NodeId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError,
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryStore, OutputStream,
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError,
SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
@ -34,13 +34,17 @@ use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::terminal::{SessionKind, TerminalSession};
use domain::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
use domain::{OrchestratorCommand, OrchestratorRequest, PtySize, SessionId};
use uuid::Uuid;
use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, TerminalSessions, UpdateAgentContext,
CloseTerminal, CreateAgentFromScratch, CreateSkill, GetLiveStateLean, HarvestMemoryFromTurn,
LaunchAgent, ListAgents, LiveStateProvider, LiveStateReadProvider, OrchestratorService,
TerminalSessions, UpdateAgentContext, UpdateLiveState,
};
use domain::live_state::{LiveEntry, LiveState, WorkStatus};
use domain::ports::LiveStateStore;
// ---------------------------------------------------------------------------
// Fakes (mirror agent_lifecycle.rs)
@ -61,6 +65,7 @@ impl FakeContexts {
manifest: AgentManifest {
version: 1,
entries: Vec::new(),
orchestrator: None,
},
contents: HashMap::new(),
})))
@ -381,6 +386,132 @@ impl EventBus for SpyBus {
}
}
/// In-memory [`MemoryStore`] for the auto-memory harvest hook (Lot E1): records the
/// saved slugs, with an optional forced `save` failure to prove harvest errors stay
/// best-effort (never break the reply).
#[derive(Default)]
struct HarvestMemories {
saved: Mutex<Vec<MemorySlug>>,
fail_save: bool,
}
impl HarvestMemories {
fn slugs(&self) -> Vec<String> {
self.saved
.lock()
.unwrap()
.iter()
.map(|s| s.as_str().to_owned())
.collect()
}
}
#[async_trait]
impl MemoryStore for HarvestMemories {
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
Ok(Vec::new())
}
async fn get(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<Memory, MemoryError> {
Err(MemoryError::NotFound)
}
async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> {
if self.fail_save {
return Err(MemoryError::Frontmatter("forced failure".to_owned()));
}
self.saved.lock().unwrap().push(memory.slug().clone());
Ok(())
}
async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
Ok(())
}
async fn read_index(&self, _root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
Ok(Vec::new())
}
async fn resolve_links(
&self,
_root: &ProjectPath,
_slug: &MemorySlug,
) -> Result<Vec<MemoryLink>, MemoryError> {
Ok(Vec::new())
}
}
/// In-memory [`LiveStateStore`] for the live-state auto-update hook (lot LS3): keeps a
/// keyed last-writer-wins [`LiveState`] and counts upserts (to prove no write-storm),
/// with an optional forced failure to prove the auto-update stays best-effort.
#[derive(Default)]
struct RecordingLiveState {
state: Mutex<LiveState>,
upserts: Mutex<usize>,
fail: bool,
}
impl RecordingLiveState {
fn entry_for(&self, agent: AgentId) -> Option<LiveEntry> {
self.state
.lock()
.unwrap()
.entries
.iter()
.find(|e| e.agent_id == agent)
.cloned()
}
fn upsert_count(&self) -> usize {
*self.upserts.lock().unwrap()
}
}
#[async_trait]
impl LiveStateStore for RecordingLiveState {
async fn load(&self) -> Result<LiveState, StoreError> {
if self.fail {
return Err(StoreError::Io("forced failure".to_owned()));
}
Ok(self.state.lock().unwrap().clone())
}
async fn upsert(&self, entry: LiveEntry) -> Result<(), StoreError> {
if self.fail {
return Err(StoreError::Io("forced failure".to_owned()));
}
*self.upserts.lock().unwrap() += 1;
self.state.lock().unwrap().upsert(entry);
Ok(())
}
async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError> {
if self.fail {
return Err(StoreError::Io("forced failure".to_owned()));
}
self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n);
Ok(())
}
}
/// A [`LiveStateProvider`] that always yields the **same** [`UpdateLiveState`] over a
/// shared recording store (root-agnostic for the test).
struct TestLiveStateProvider {
update: Arc<UpdateLiveState>,
}
impl LiveStateProvider for TestLiveStateProvider {
fn live_state_for(&self, _root: &ProjectPath) -> Option<Arc<UpdateLiveState>> {
Some(Arc::clone(&self.update))
}
}
/// A [`LiveStateReadProvider`] (lot LS4, `idea_workstate_read`) yielding the same
/// [`GetLiveStateLean`] over a shared recording store (root-agnostic for the test).
struct TestLiveStateReadProvider {
getter: Arc<GetLiveStateLean>,
}
impl LiveStateReadProvider for TestLiveStateReadProvider {
fn live_state_lean_for(&self, _root: &ProjectPath) -> Option<Arc<GetLiveStateLean>> {
Some(Arc::clone(&self.getter))
}
}
/// Fixed millis clock for deterministic `updated_at_ms`.
struct FixedMillisClock(i64);
impl Clock for FixedMillisClock {
fn now_millis(&self) -> i64 {
self.0
}
}
struct SeqIds(Mutex<u128>);
impl SeqIds {
fn new() -> Self {
@ -811,7 +942,7 @@ use domain::conversation::{
SessionRef,
};
use domain::input::{AgentBusyState, InputMediator};
use domain::mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
use domain::mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId, TurnResolution};
use tokio::time::{timeout, Duration};
/// Safety guard: no awaited rendezvous in these tests should ever exceed this.
@ -822,7 +953,8 @@ const TEST_GUARD: Duration = Duration::from_secs(10);
/// would be a dependency cycle). Per-agent FIFO of `(Ticket, oneshot::Sender)`.
#[derive(Default)]
struct TestMailbox {
queues: Mutex<HashMap<AgentId, VecDeque<(Ticket, tokio::sync::oneshot::Sender<String>)>>>,
queues:
Mutex<HashMap<AgentId, VecDeque<(Ticket, tokio::sync::oneshot::Sender<TurnResolution>)>>>,
}
impl TestMailbox {
fn new() -> Self {
@ -849,7 +981,7 @@ impl TestMailbox {
}
impl AgentMailbox for TestMailbox {
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
let (tx, rx) = tokio::sync::oneshot::channel::<TurnResolution>();
self.queues
.lock()
.unwrap()
@ -869,7 +1001,7 @@ impl AgentMailbox for TestMailbox {
.ok_or(MailboxError::NoPendingRequest(agent))?;
queue.pop_front().expect("non-empty")
};
let _ = slot.1.send(result);
let _ = slot.1.send(TurnResolution::Replied(result));
Ok(())
}
fn resolve_ticket(
@ -890,7 +1022,7 @@ impl AgentMailbox for TestMailbox {
.ok_or(MailboxError::NoPendingRequest(agent))?;
queue.remove(pos).expect("found position")
};
let _ = slot.1.send(result);
let _ = slot.1.send(TurnResolution::Replied(result));
Ok(())
}
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) {
@ -901,6 +1033,20 @@ impl AgentMailbox for TestMailbox {
}
}
}
fn complete_without_reply(&self, agent: AgentId, ticket_id: TicketId) {
// Mirror the production adapter: head-only, idempotent, fire-and-forget-safe.
let mut q = self.queues.lock().unwrap();
if let Some(queue) = q.get_mut(&agent) {
if queue.front().map(|(t, _)| t.id) != Some(ticket_id) {
return;
}
if queue.front().is_some_and(|(_, tx)| tx.is_closed()) {
return; // receiver gone (human submit / timed-out caller): preserve head.
}
let (_, tx) = queue.pop_front().expect("head just matched");
let _ = tx.send(TurnResolution::ReturnedToPromptNoReply);
}
}
}
/// A delivering [`InputMediator`] for the application tests: it composes the
@ -1046,17 +1192,60 @@ struct AskFixture {
/// The mediated inbox the service holds — lets a test observe `busy_state`
/// transitions (e.g. an `idea_reply` flips the emitter back to Idle — lot C5).
mediator: Arc<TestMediator>,
/// The auto-memory store wired into the harvest hook (Lot E1): lets a test
/// assert which notes a successful reply persisted.
memories: Arc<HarvestMemories>,
/// The live-state store wired into the auto-update hook (lot LS3): lets a test
/// assert the target's Working/Done transitions and the upsert count.
live: Arc<RecordingLiveState>,
}
/// Builds an orchestrator wired with a real [`InMemoryMailbox`] + PTY (B-3) and a
/// plain (non-structured) [`LaunchAgent`] — so a dead target is launched as a PTY,
/// exactly like production after B-2.
fn ask_fixture(contexts: FakeContexts) -> AskFixture {
ask_fixture_full(contexts, false, false, true)
}
/// Like [`ask_fixture`] but lets a test force the auto-memory store to fail its
/// `save`, proving the harvest stays best-effort (the reply still succeeds).
fn ask_fixture_with_memory(contexts: FakeContexts, fail_memory: bool) -> AskFixture {
ask_fixture_full(contexts, fail_memory, false, true)
}
/// Like [`ask_fixture`] but lets a test force the live-state store to fail, proving
/// the auto-update stays best-effort (the ask/reply still succeeds).
fn ask_fixture_with_live(contexts: FakeContexts, fail_live: bool) -> AskFixture {
ask_fixture_full(contexts, false, fail_live, true)
}
/// Like [`ask_fixture`] but leaves the live-state auto-update **unwired** (`None`),
/// to prove no write happens when the hook is absent (zéro régression).
fn ask_fixture_without_live(contexts: FakeContexts) -> AskFixture {
ask_fixture_full(contexts, false, false, false)
}
/// Full builder: wires the auto-memory harvest (Lot E1) and, when `wire_live`, the
/// live-state auto-update (lot LS3), each with an optional forced failure.
fn ask_fixture_full(
contexts: FakeContexts,
fail_memory: bool,
fail_live: bool,
wire_live: bool,
) -> AskFixture {
let profiles = Arc::new(FakeProfiles::new(vec![claude_profile()]));
let sessions = Arc::new(TerminalSessions::new());
let pty = FakePty::new(sid(777));
let bus = SpyBus::default();
let mailbox = Arc::new(TestMailbox::new());
let memories = Arc::new(HarvestMemories {
saved: Mutex::new(Vec::new()),
fail_save: fail_memory,
});
let live = Arc::new(RecordingLiveState {
fail: fail_live,
..Default::default()
});
let create = Arc::new(CreateAgentFromScratch::new(
Arc::new(contexts.clone()),
@ -1092,8 +1281,7 @@ fn ask_fixture(contexts: FakeContexts) -> AskFixture {
Arc::new(pty.clone()) as Arc<dyn PtyPort>,
));
let conversations = Arc::new(TestConversations::new()) as Arc<dyn ConversationRegistry>;
let service = Arc::new(
OrchestratorService::new(
let mut builder = OrchestratorService::new(
create,
launch,
list,
@ -1108,8 +1296,28 @@ fn ask_fixture(contexts: FakeContexts) -> AskFixture {
Arc::clone(&mailbox) as Arc<dyn AgentMailbox>,
)
.with_conversations(conversations)
.with_events(Arc::new(bus.clone())),
);
.with_events(Arc::new(bus.clone()))
.with_memory_harvest(Arc::new(HarvestMemoryFromTurn::new(
Arc::clone(&memories) as Arc<dyn MemoryStore>,
Arc::new(bus.clone()),
)));
if wire_live {
builder = builder.with_live_state(Arc::new(TestLiveStateProvider {
update: Arc::new(UpdateLiveState::new(
Arc::clone(&live) as Arc<dyn LiveStateStore>,
Arc::new(FixedMillisClock(1_234)) as Arc<dyn Clock>,
)),
}) as Arc<dyn LiveStateProvider>);
// LS4 read side over the SAME store, so `idea_workstate_set` then
// `idea_workstate_read` round-trips (no separate write provider would).
builder = builder.with_live_state_read(Arc::new(TestLiveStateReadProvider {
getter: Arc::new(GetLiveStateLean::new(
Arc::clone(&live) as Arc<dyn LiveStateStore>,
Arc::new(FixedMillisClock(1_234)) as Arc<dyn Clock>,
)),
}) as Arc<dyn LiveStateReadProvider>);
}
let service = Arc::new(builder);
AskFixture {
service,
@ -1118,6 +1326,8 @@ fn ask_fixture(contexts: FakeContexts) -> AskFixture {
bus,
pty,
mediator,
memories,
live,
}
}
@ -1219,6 +1429,504 @@ async fn ask_live_pty_target_writes_task_and_returns_reply() {
assert_eq!(fx.mailbox.pending(&aid(1)), 0, "ticket drained on reply");
}
// --- LS3: live-state auto-update from the delegation cycle ------------------
/// A successful `ask` flips the **target** to `Working` with the distilled intent and
/// the current ticket — derived from the existing delegation transition (zéro token).
#[tokio::test]
async fn ask_sets_target_working_with_intent_and_ticket() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
seed_live_pty(&fx.sessions, aid(1), sid(800));
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
// The Working transition is posted right after the enqueue, before any reply.
await_until(|| fx.live.entry_for(aid(1)).is_some()).await;
let entry = fx
.live
.entry_for(aid(1))
.expect("target live entry present");
assert_eq!(entry.status, WorkStatus::Working);
assert_eq!(
entry.intent, "Analyse §17",
"intent distilled from the task"
);
assert!(entry.ticket.is_some(), "current ticket recorded");
assert!(
entry.last_delegation.is_none(),
"no delegation resolved yet"
);
// The live-state path does not touch the memory store (canonical effects separate).
assert!(
fx.memories.slugs().is_empty(),
"no memory write on this path"
);
// Drain the ask so the spawned task completes cleanly.
fx.service
.dispatch(&project(), reply_cmd(aid(1), "done"))
.await
.expect("reply ok");
timeout(TEST_GUARD, ask)
.await
.expect("ask completes")
.expect("join ok")
.expect("ask ok");
}
/// A successful `reply` flips the target to `Done` and records `last_delegation` = the
/// resolved ticket.
#[tokio::test]
async fn reply_sets_target_done_with_last_delegation() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
seed_live_pty(&fx.sessions, aid(1), sid(800));
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
fx.service
.dispatch(&project(), reply_cmd(aid(1), "the answer"))
.await
.expect("reply ok");
timeout(TEST_GUARD, ask)
.await
.expect("ask completes")
.expect("join ok")
.expect("ask ok");
let entry = fx
.live
.entry_for(aid(1))
.expect("target live entry present");
assert_eq!(
entry.status,
WorkStatus::Done,
"target marked Done on reply"
);
assert!(
entry.last_delegation.is_some(),
"last_delegation set to the resolved ticket"
);
// Working then Done ⇒ exactly two upserts for one delegation (no write-storm).
assert_eq!(
fx.live.upsert_count(),
2,
"one Working + one Done write per delegation"
);
}
/// A live-state store that fails must NOT fail the `ask`/`reply`: the delegation
/// still returns its reply (best-effort hook, exactly like the memory harvest).
#[tokio::test]
async fn live_state_failure_does_not_break_delegation() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture_with_live(FakeContexts::with_agent(&agent, "# persona"), true);
seed_live_pty(&fx.sessions, aid(1), sid(800));
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
fx.service
.dispatch(&project(), reply_cmd(aid(1), "still works"))
.await
.expect("reply ok despite failing live-state");
let out = timeout(TEST_GUARD, ask)
.await
.expect("ask completes")
.expect("join ok")
.expect("ask ok despite failing live-state");
assert_eq!(out.reply.as_deref(), Some("still works"));
// The failing store recorded nothing.
assert!(fx.live.entry_for(aid(1)).is_none());
}
/// When the live-state hook is **unwired** (`None`), the delegation behaves exactly as
/// before — no live-state write at all (zéro régression).
#[tokio::test]
async fn no_live_state_wired_means_no_write() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture_without_live(FakeContexts::with_agent(&agent, "# persona"));
seed_live_pty(&fx.sessions, aid(1), sid(800));
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
fx.service
.dispatch(&project(), reply_cmd(aid(1), "ok"))
.await
.expect("reply ok");
timeout(TEST_GUARD, ask)
.await
.expect("ask completes")
.expect("join ok")
.expect("ask ok");
assert_eq!(fx.live.upsert_count(), 0, "no write when hook is None");
assert!(fx.live.entry_for(aid(1)).is_none());
}
// ---------------------------------------------------------------------------
// LS4 — `idea_workstate_set` / `idea_workstate_read` service round-trip.
// ---------------------------------------------------------------------------
/// `idea_workstate_set` writes the current agent's row, then `idea_workstate_read`
/// returns it enriched with the **resolved display name** (via `ListAgents`), with the
/// declared status/intent — and **never** the `progress` field.
#[tokio::test]
async fn workstate_set_then_read_round_trips_with_resolved_name_and_no_progress() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
// set — keyed on the handshake agent id; carries a progress note (must not leak).
let ack = fx
.service
.dispatch(
&project(),
OrchestratorCommand::SetWorkState {
agent: aid(1),
status: WorkStatus::Working,
intent: Some("ship LS4".to_owned()),
progress: Some("secret internal note".to_owned()),
ticket: None,
last_delegation: None,
},
)
.await
.expect("set ok");
// ACK only — no inline reply on the write path.
assert!(ack.reply.is_none(), "set is ACK only, no reply: {ack:?}");
// read — the JSON array carries one row for the architect.
let out = fx
.service
.dispatch(
&project(),
OrchestratorCommand::ReadWorkState {
requester: ConversationParty::User,
},
)
.await
.expect("read ok");
let json: serde_json::Value =
serde_json::from_str(out.reply.as_deref().expect("read carries a reply")).unwrap();
let rows = json.as_array().expect("array");
assert_eq!(rows.len(), 1, "one live row expected: {json}");
let row = &rows[0];
// Name resolved from the manifest (not the bare UUID).
assert_eq!(row["agent"], serde_json::json!("architect"));
assert_eq!(row["status"], serde_json::json!("working"));
assert_eq!(row["intent"], serde_json::json!("ship LS4"));
// progress is NEVER exposed on read (the lean DTO has no such field).
assert!(
row.get("progress").is_none(),
"progress must never surface on read: {row}"
);
}
/// `idea_workstate_set` is last-writer-wins: a second set for the same agent replaces
/// the row in place (never a duplicate), and the read reflects the latest write.
#[tokio::test]
async fn workstate_set_is_last_writer_wins() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
for (status, intent) in [
(WorkStatus::Working, "first"),
(WorkStatus::Blocked, "second"),
] {
fx.service
.dispatch(
&project(),
OrchestratorCommand::SetWorkState {
agent: aid(1),
status,
intent: Some(intent.to_owned()),
progress: None,
ticket: None,
last_delegation: None,
},
)
.await
.expect("set ok");
}
let out = fx
.service
.dispatch(
&project(),
OrchestratorCommand::ReadWorkState {
requester: ConversationParty::User,
},
)
.await
.expect("read ok");
let json: serde_json::Value = serde_json::from_str(out.reply.as_deref().unwrap()).unwrap();
let rows = json.as_array().unwrap();
assert_eq!(rows.len(), 1, "LWW ⇒ exactly one row, no duplicate: {json}");
assert_eq!(rows[0]["status"], serde_json::json!("blocked"));
assert_eq!(rows[0]["intent"], serde_json::json!("second"));
}
/// `idea_workstate_read` drops rows whose agent left (or never was in) the manifest —
/// the manifest boundary is authoritative.
#[tokio::test]
async fn workstate_read_drops_off_manifest_rows() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
// aid(1) is in the manifest; aid(2) is NOT.
for who in [aid(1), aid(2)] {
fx.service
.dispatch(
&project(),
OrchestratorCommand::SetWorkState {
agent: who,
status: WorkStatus::Working,
intent: Some("x".to_owned()),
progress: None,
ticket: None,
last_delegation: None,
},
)
.await
.expect("set ok");
}
let out = fx
.service
.dispatch(
&project(),
OrchestratorCommand::ReadWorkState {
requester: ConversationParty::User,
},
)
.await
.expect("read ok");
let json: serde_json::Value = serde_json::from_str(out.reply.as_deref().unwrap()).unwrap();
let rows = json.as_array().unwrap();
assert_eq!(rows.len(), 1, "the off-manifest row is dropped: {json}");
assert_eq!(rows[0]["agent"], serde_json::json!("architect"));
}
/// An oversize `intent` (above the domain hard byte threshold) is **rejected** by the
/// write path (the domain bound surfaces as `AppError::Invalid`), never silently stored.
#[tokio::test]
async fn workstate_set_oversize_intent_is_rejected() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
let huge = "x".repeat(domain::live_state::FIELD_MAX_BYTES + 1);
let err = fx
.service
.dispatch(
&project(),
OrchestratorCommand::SetWorkState {
agent: aid(1),
status: WorkStatus::Working,
intent: Some(huge),
progress: None,
ticket: None,
last_delegation: None,
},
)
.await
.expect_err("oversize intent must be rejected");
assert!(
matches!(err, application::AppError::Invalid(_)),
"oversize ⇒ AppError::Invalid, got {err:?}"
);
}
/// The target returned to its prompt **without** `idea_reply`: the mediator's grace
/// window expired and completed the turn "no reply". The awaiting ask must error out
/// promptly with the typed, retryable [`AppError::TargetReturnedNoReply`] — never hang
/// until the long safety timeout. (The grace **timer** lives in the infrastructure
/// mediator; here we drive the resulting mailbox completion directly.)
#[tokio::test]
async fn ask_target_returns_to_prompt_without_reply_is_a_typed_error() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
seed_live_pty(&fx.sessions, aid(1), sid(800));
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
// Simulate the grace-window completion (target back at prompt, no idea_reply).
let ticket = fx.mailbox.ticket_ids(&aid(1))[0];
fx.mailbox.complete_without_reply(aid(1), ticket);
let err = timeout(TEST_GUARD, ask)
.await
.expect("ask completes promptly, not after the long timeout")
.expect("join ok")
.expect_err("a no-reply turn is a typed error");
assert_eq!(err.code(), "TARGET_RETURNED_NO_REPLY", "got {err:?}");
assert_eq!(fx.mailbox.pending(&aid(1)), 0, "head retired by completion");
}
/// An `idea_reply` that lands **first** still wins over a (later) no-reply completion:
/// the completion then finds the head gone and is a no-op (idempotent race).
#[tokio::test]
async fn ask_reply_wins_then_late_completion_is_noop() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
seed_live_pty(&fx.sessions, aid(1), sid(800));
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
let ticket = fx.mailbox.ticket_ids(&aid(1))[0];
// idea_reply resolves the head first…
fx.service
.dispatch(&project(), reply_cmd(aid(1), "the real answer"))
.await
.expect("reply ok");
// …then a late grace completion for the same ticket is a no-op.
fx.mailbox.complete_without_reply(aid(1), ticket);
let out = timeout(TEST_GUARD, ask)
.await
.expect("ask completes")
.expect("join ok")
.expect("ask ok");
assert_eq!(out.reply.as_deref(), Some("the real answer"));
}
// --- Lot E1: auto-memory harvest on a successful ask reply -----------------
/// A reply carrying an `idea-memory` block is harvested **after** the reply
/// resolves: the note is persisted via the store and a `MemorySaved` event fires.
#[tokio::test]
async fn ask_reply_with_directive_harvests_memory_after_reply() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
seed_live_pty(&fx.sessions, aid(1), sid(800));
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
let reply = "Here is the answer.\n\n```idea-memory\nslug: section-17-rule\ntitle: §17 rule\ntype: project\ndescription: How §17 routing works\n---\nRoute structured asks straight to the session.\n```";
fx.service
.dispatch(&project(), reply_cmd(aid(1), reply))
.await
.expect("reply ok");
let out = timeout(TEST_GUARD, ask)
.await
.expect("ask completes")
.expect("join ok")
.expect("ask ok");
// The reply is returned unchanged …
assert_eq!(out.reply.as_deref(), Some(reply));
// … and the embedded note was harvested into the store + announced.
assert_eq!(fx.memories.slugs(), vec!["section-17-rule"]);
let saved: Vec<_> = fx
.bus
.events()
.into_iter()
.filter_map(|e| match e {
DomainEvent::MemorySaved { slug } => Some(slug.as_str().to_owned()),
_ => None,
})
.collect();
assert!(
saved.contains(&"section-17-rule".to_owned()),
"MemorySaved announced, got {saved:?}"
);
}
/// A store failure during harvest must not break the reply: the ask still returns
/// its content, and nothing is persisted.
#[tokio::test]
async fn ask_reply_harvest_store_failure_does_not_break_reply() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture_with_memory(FakeContexts::with_agent(&agent, "# persona"), true);
seed_live_pty(&fx.sessions, aid(1), sid(800));
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
let reply = "Reply.\n\n```idea-memory\nslug: doomed-note\ntitle: T\ntype: project\ndescription: hook\n---\nBody.\n```";
fx.service
.dispatch(&project(), reply_cmd(aid(1), reply))
.await
.expect("reply ok despite failing memory store");
let out = timeout(TEST_GUARD, ask)
.await
.expect("ask completes")
.expect("join ok")
.expect("ask still ok despite harvest store failure");
assert_eq!(
out.reply.as_deref(),
Some(reply),
"reply unaffected by harvest"
);
assert!(
fx.memories.slugs().is_empty(),
"failed save persisted nothing"
);
}
/// A reply with no directive harvests nothing (and the reply is unchanged) — the
/// common case must not touch the memory store.
#[tokio::test]
async fn ask_reply_without_directive_harvests_nothing() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
seed_live_pty(&fx.sessions, aid(1), sid(800));
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
fx.service
.dispatch(&project(), reply_cmd(aid(1), "Just a plain answer."))
.await
.expect("reply ok");
timeout(TEST_GUARD, ask)
.await
.expect("ask completes")
.expect("join ok")
.expect("ask ok");
assert!(fx.memories.slugs().is_empty());
}
/// A cancelled turn (head ticket dropped before any reply) yields no reply, so the
/// harvest never runs — nothing is persisted even with a would-be directive pending.
#[tokio::test]
async fn ask_cancelled_turn_does_not_harvest() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
seed_live_pty(&fx.sessions, aid(1), sid(800));
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
// Cancel the head ticket (drops the reply sender) ⇒ the ask resolves as a
// channel-closed error: no Response turn, hence no harvest.
let ticket = fx.mailbox.ticket_ids(&aid(1))[0];
fx.mailbox.cancel_head(aid(1), ticket);
let out = timeout(TEST_GUARD, ask)
.await
.expect("ask completes")
.expect("join ok");
assert!(out.is_err(), "cancelled turn ⇒ ask errors");
assert!(fx.memories.slugs().is_empty(), "no harvest on cancel");
}
/// Lot C5 — explicit OR signal: an `idea_reply` from the emitting agent flips it back
/// to `Idle` (its FIFO can advance), independently of any prompt-ready pattern. Before
/// the reply the agent is `Busy` on the delegated turn; after, it is `Idle`.
@ -1638,9 +2346,6 @@ async fn ask_different_targets_run_in_parallel() {
#[derive(Clone, Default)]
struct CapturingFs(Arc<Mutex<Vec<(String, String)>>>);
impl CapturingFs {
fn writes(&self) -> Vec<(String, String)> {
self.0.lock().unwrap().clone()
}
/// Contenu écrit dont le chemin se termine par `suffix` (ex. `.mcp.json`).
fn content_ending_with(&self, suffix: &str) -> Option<String> {
self.0
@ -2732,6 +3437,11 @@ fn ask_fixture_with_record(
bus,
pty,
mediator,
// Harvest is intentionally not wired here: these fixtures exercise the
// RecordTurn checkpoint in isolation (proving it stays unchanged).
memories: Arc::new(HarvestMemories::default()),
// Live-state likewise unwired here (RecordTurn-focused fixtures).
live: Arc::new(RecordingLiveState::default()),
}
}

View File

@ -21,7 +21,7 @@ use domain::project::ProjectPath;
use application::{
reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput,
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState,
ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput,
ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
};
// ---------------------------------------------------------------------------
@ -366,6 +366,11 @@ fn catalogue_has_expected_commands_and_injection() {
target: "AGENTS.md".to_owned()
}
);
assert_eq!(
by_command["codex"].submit_delay_ms,
Some(CODEX_SUBMIT_DELAY_MS),
"Codex TUI needs a conservative text→submit delay for delegated prompts"
);
assert_eq!(
by_command["gemini"].context_injection,
ContextInjection::ConventionFile {

View File

@ -133,10 +133,12 @@ impl AgentResumer for FakeResumer {
conversation_id: Option<String>,
resume_prompt: &str,
) -> Result<(), AppError> {
self.calls
.lock()
.unwrap()
.push((agent_id, node_id, conversation_id, resume_prompt.to_owned()));
self.calls.lock().unwrap().push((
agent_id,
node_id,
conversation_id,
resume_prompt.to_owned(),
));
if self.fail.load(Ordering::SeqCst) {
Err(AppError::Internal("reprise échouée (fake)".to_owned()))
} else {
@ -252,7 +254,10 @@ fn on_rate_limited_without_reset_is_human_fallback_no_arm() {
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), None);
assert!(env.scheduler.armed().is_empty(), "aucun arm sans heure de reset");
assert!(
env.scheduler.armed().is_empty(),
"aucun arm sans heure de reset"
);
assert!(env.scheduler.cancels().is_empty(), "aucun cancel non plus");
assert_eq!(
env.bus.events(),
@ -284,7 +289,11 @@ fn on_rate_limited_twice_same_agent_dedups_cancelling_previous() {
// Deux arms (un par signal), ids distincts.
let issued = env.scheduler.issued();
assert_eq!(issued.len(), 2, "deux arms (rafraîchissement, pas empilement)");
assert_eq!(
issued.len(),
2,
"deux arms (rafraîchissement, pas empilement)"
);
// Le premier id émis a été annulé par le dédoublonnage du 2ᵉ signal.
assert_eq!(
env.scheduler.cancels(),
@ -316,8 +325,12 @@ fn on_rate_limited_twice_same_agent_dedups_cancelling_previous() {
async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() {
let env = env_at(NOW);
// Arme d'abord (pour prouver que l'entrée est ensuite retirée).
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
env.service.on_rate_limited(
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(NOW + 60_000),
);
let task = ScheduledTask::ResumeAgent {
agent_id: aid(1),
@ -329,7 +342,12 @@ async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() {
// Le resumer a vu exactement l'appel attendu, avec le prompt constant.
assert_eq!(
env.resumer.calls(),
vec![(aid(1), nid(2), Some("conv-1".to_owned()), RESUME_PROMPT.to_owned())]
vec![(
aid(1),
nid(2),
Some("conv-1".to_owned()),
RESUME_PROMPT.to_owned()
)]
);
// AgentResumed publié.
@ -364,11 +382,13 @@ async fn execute_resume_propagates_error_without_emitting_resumed() {
.execute_resume(task)
.await
.expect_err("la reprise doit échouer");
assert!(matches!(err, AppError::Internal(_)), "erreur propagée: {err:?}");
assert!(
matches!(err, AppError::Internal(_)),
"erreur propagée: {err:?}"
);
assert!(
!env
.bus
!env.bus
.events()
.iter()
.any(|e| matches!(e, DomainEvent::AgentResumed { .. })),
@ -385,11 +405,18 @@ async fn execute_resume_propagates_error_without_emitting_resumed() {
#[test]
fn cancel_resume_after_arm_returns_true_and_emits_cancelled() {
let env = env_at(NOW);
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
env.service.on_rate_limited(
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(NOW + 60_000),
);
let issued = env.scheduler.issued();
assert!(env.service.cancel_resume(aid(1)), "cancel d'un réveil armé ⇒ true");
assert!(
env.service.cancel_resume(aid(1)),
"cancel d'un réveil armé ⇒ true"
);
// Le bon ScheduleId a été passé au Scheduler.
assert_eq!(env.scheduler.cancels(), vec![issued[0]]);
// AgentResumeCancelled publié.
@ -408,7 +435,10 @@ fn cancel_resume_after_arm_returns_true_and_emits_cancelled() {
fn cancel_resume_without_arm_is_false_no_event() {
let env = env_at(NOW);
assert!(!env.service.cancel_resume(aid(1)));
assert!(env.scheduler.cancels().is_empty(), "Scheduler non sollicité");
assert!(
env.scheduler.cancels().is_empty(),
"Scheduler non sollicité"
);
assert!(env.bus.events().is_empty(), "aucun event");
}
@ -417,8 +447,12 @@ fn cancel_resume_without_arm_is_false_no_event() {
#[test]
fn cancel_resume_when_scheduler_already_fired_is_false_no_event() {
let env = env_at(NOW);
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
env.service.on_rate_limited(
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(NOW + 60_000),
);
// Simule un réveil déjà tiré : cancel renvoie false.
env.scheduler.set_cancel_result(false);
@ -430,8 +464,7 @@ fn cancel_resume_when_scheduler_already_fired_is_false_no_event() {
assert_eq!(env.scheduler.cancels().len(), 1);
// AUCUN AgentResumeCancelled (pas d'event trompeur).
assert!(
!env
.bus
!env.bus
.events()
.iter()
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
@ -491,8 +524,7 @@ fn confirm_human_resume_future_arms_and_emits_in_order() {
fn confirm_human_resume_past_reset_clamps_fire_at_to_now() {
let env = env_at(NOW);
let past = NOW - 30_000;
env.service
.confirm_human_resume(aid(1), nid(2), None, past);
env.service.confirm_human_resume(aid(1), nid(2), None, past);
let armed = env.scheduler.armed();
assert_eq!(armed.len(), 1);
@ -521,13 +553,21 @@ fn confirm_human_resume_past_reset_clamps_fire_at_to_now() {
#[test]
fn confirm_human_resume_after_auto_dedups_single_active_arm() {
let env = env_at(NOW);
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
env.service.on_rate_limited(
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(NOW + 60_000),
);
env.service
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 120_000);
let issued = env.scheduler.issued();
assert_eq!(issued.len(), 2, "deux arms (auto puis humain), pas d'empilement");
assert_eq!(
issued.len(),
2,
"deux arms (auto puis humain), pas d'empilement"
);
assert_eq!(
env.scheduler.cancels(),
vec![issued[0]],
@ -536,8 +576,7 @@ fn confirm_human_resume_after_auto_dedups_single_active_arm() {
// Aucun AgentResumeCancelled (dédoublonnage interne, silencieux).
assert!(
!env
.bus
!env.bus
.events()
.iter()
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
@ -545,7 +584,10 @@ fn confirm_human_resume_after_auto_dedups_single_active_arm() {
);
// Unicité de l'armement actif : un seul cancel_resume aboutit.
assert!(env.service.cancel_resume(aid(1)), "un armement actif unique ⇒ true");
assert!(
env.service.cancel_resume(aid(1)),
"un armement actif unique ⇒ true"
);
assert!(
!env.service.cancel_resume(aid(1)),
"plus aucun armement après le premier cancel ⇒ false (une seule entrée)"
@ -560,11 +602,19 @@ fn auto_after_confirm_human_resume_dedups_single_active_arm() {
let env = env_at(NOW);
env.service
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000);
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 120_000));
env.service.on_rate_limited(
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(NOW + 120_000),
);
let issued = env.scheduler.issued();
assert_eq!(issued.len(), 2, "deux arms (humain puis auto), pas d'empilement");
assert_eq!(
issued.len(),
2,
"deux arms (humain puis auto), pas d'empilement"
);
assert_eq!(
env.scheduler.cancels(),
vec![issued[0]],
@ -572,15 +622,17 @@ fn auto_after_confirm_human_resume_dedups_single_active_arm() {
);
assert!(
!env
.bus
!env.bus
.events()
.iter()
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
"le dédoublonnage croisé n'émet PAS AgentResumeCancelled"
);
assert!(env.service.cancel_resume(aid(1)), "un armement actif unique ⇒ true");
assert!(
env.service.cancel_resume(aid(1)),
"un armement actif unique ⇒ true"
);
assert!(
!env.service.cancel_resume(aid(1)),
"plus aucun armement après le premier cancel ⇒ false (une seule entrée)"

View File

@ -91,6 +91,7 @@ impl FakeContexts {
Self(Arc::new(Mutex::new(AgentManifest {
version: 1,
entries,
orchestrator: None,
})))
}
fn manifest(&self) -> AgentManifest {
@ -194,6 +195,7 @@ async fn create_skill_persists_in_its_scope() {
let out = CreateSkill::new(Arc::new(store.clone()), Arc::new(SeqIds::new()))
.execute(CreateSkillInput {
name: "refactor".to_owned(),
description: Some("Refactors code".to_owned()),
content: "# body".to_owned(),
scope: SkillScope::Project,
project_root: root(),
@ -202,6 +204,8 @@ async fn create_skill_persists_in_its_scope() {
.unwrap();
assert_eq!(out.skill.scope, SkillScope::Project);
// The one-line affordance description flows through the use case onto the skill.
assert_eq!(out.skill.description.as_deref(), Some("Refactors code"));
assert_eq!(
store
.list(SkillScope::Project, &root())
@ -223,6 +227,7 @@ async fn create_skill_rejects_empty_content() {
let err = CreateSkill::new(Arc::new(store), Arc::new(SeqIds::new()))
.execute(CreateSkillInput {
name: "k".to_owned(),
description: None,
content: String::new(),
scope: SkillScope::Global,
project_root: root(),

View File

@ -73,6 +73,7 @@ impl FakeContexts {
manifest: AgentManifest {
version: 1,
entries: Vec::new(),
orchestrator: None,
},
contents: HashMap::new(),
})));
@ -1111,6 +1112,7 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
let ctx = PreparedContext {
content: MarkdownDoc::new("# persona"),
relative_path: "agents/backend.md".to_owned(),
project_root: ROOT.to_owned(),
};
let cwd = ProjectPath::new(ROOT).unwrap();
let session = f

View File

@ -72,6 +72,7 @@ impl FakeContexts {
AgentManifest {
version: 1,
entries,
orchestrator: None,
},
HashMap::new(),
))))

View File

@ -0,0 +1,981 @@
//! Tests for the project work-state read model.
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use application::{
ConversationLogProvider, ConversationPreviewStatus, GetProjectWorkState,
GetProjectWorkStateInput, HandoffProvider, LiveSessionKind, LiveSessions, StructuredSessions,
TerminalSessions, TicketWorkSource, TicketWorkStatus,
};
use domain::mailbox::{
AgentQueueSnapshot, MailboxError, PendingReply, QueuedTicketSnapshot, Ticket, TurnResolution,
};
use domain::ports::{
AgentContextStore, AgentSession, AgentSessionError, PtyHandle, ReplyStream, StoreError,
};
use domain::{
Agent, AgentBusyState, AgentId, AgentManifest, AgentOrigin, ConversationId, ConversationLog,
ConversationTurn, Handoff, HandoffStore, InputMediator, InputSource, ManifestEntry,
MarkdownDoc, NodeId, ProfileId, Project, ProjectId, ProjectPath, PtySize, RemoteRef, SessionId,
SessionKind, TerminalSession, TicketId, TurnId, TurnRole,
};
use uuid::Uuid;
fn aid(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n))
}
fn pid(n: u128) -> ProfileId {
ProfileId::from_uuid(Uuid::from_u128(n))
}
fn sid(n: u128) -> SessionId {
SessionId::from_uuid(Uuid::from_u128(n))
}
fn nid(n: u128) -> NodeId {
NodeId::from_uuid(Uuid::from_u128(n))
}
fn ticket_id(n: u128) -> domain::TicketId {
domain::TicketId::from_uuid(Uuid::from_u128(n))
}
fn project() -> Project {
Project::new(
ProjectId::from_uuid(Uuid::from_u128(1)),
"demo",
ProjectPath::new("/tmp/idea-workstate-test").unwrap(),
RemoteRef::local(),
1_700_000_000_000,
)
.unwrap()
}
fn agent(n: u128, name: &str) -> Agent {
Agent::new(
aid(n),
name,
format!("agents/{name}.md"),
pid(100 + n),
AgentOrigin::Scratch,
false,
)
.unwrap()
}
fn manifest(agents: &[Agent]) -> AgentManifest {
AgentManifest::new(1, agents.iter().map(ManifestEntry::from_agent).collect()).unwrap()
}
#[derive(Clone)]
struct FakeContexts {
manifest: AgentManifest,
}
#[async_trait]
impl AgentContextStore for FakeContexts {
async fn read_context(
&self,
_project: &Project,
_agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
Err(StoreError::NotFound)
}
async fn write_context(
&self,
_project: &Project,
_agent: &AgentId,
_md: &MarkdownDoc,
) -> Result<(), StoreError> {
Ok(())
}
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
Ok(self.manifest.clone())
}
async fn save_manifest(
&self,
_project: &Project,
_manifest: &AgentManifest,
) -> Result<(), StoreError> {
Ok(())
}
}
#[derive(Default)]
struct FakeInput {
busy: Mutex<HashMap<AgentId, AgentBusyState>>,
}
impl FakeInput {
fn set_busy(&self, agent: AgentId, busy: AgentBusyState) {
self.busy.lock().unwrap().insert(agent, busy);
}
}
impl InputMediator for FakeInput {
fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply {
let fut: Pin<Box<dyn Future<Output = Result<TurnResolution, MailboxError>> + Send>> =
Box::pin(async { Err(MailboxError::Cancelled) });
PendingReply::new(fut)
}
fn preempt(&self, _agent: AgentId) {}
fn mark_idle(&self, _agent: AgentId) {}
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
self.busy
.lock()
.unwrap()
.get(&agent)
.copied()
.unwrap_or(AgentBusyState::Idle)
}
}
#[derive(Default)]
struct FakeQueue {
queues: Mutex<HashMap<AgentId, Vec<QueuedTicketSnapshot>>>,
}
impl FakeQueue {
fn set(&self, agent: AgentId, tickets: Vec<QueuedTicketSnapshot>) {
self.queues.lock().unwrap().insert(agent, tickets);
}
}
impl AgentQueueSnapshot for FakeQueue {
fn queue_for(&self, agent: AgentId) -> Vec<QueuedTicketSnapshot> {
self.queues
.lock()
.unwrap()
.get(&agent)
.cloned()
.unwrap_or_default()
}
}
/// Configurable per-conversation outcome for the fake handoff store.
///
/// Absence is modelled by simply not configuring a conversation (see the `_` arm
/// of [`FakeHandoffStore::load`]), so it needs no dedicated variant.
#[derive(Clone)]
enum HandoffOutcome {
/// A readable handoff (status Ready).
Present(Handoff),
/// An unreadable handoff (status Partial/Unavailable depending on the log).
Error,
}
#[derive(Default)]
struct FakeHandoffStore {
outcomes: Mutex<HashMap<ConversationId, HandoffOutcome>>,
}
impl FakeHandoffStore {
fn set(&self, conversation: ConversationId, outcome: HandoffOutcome) {
self.outcomes.lock().unwrap().insert(conversation, outcome);
}
}
#[async_trait]
impl HandoffStore for FakeHandoffStore {
async fn load(&self, conversation: ConversationId) -> Result<Option<Handoff>, StoreError> {
match self.outcomes.lock().unwrap().get(&conversation) {
Some(HandoffOutcome::Present(handoff)) => Ok(Some(handoff.clone())),
Some(HandoffOutcome::Error) => Err(StoreError::Io("handoff unreadable".to_owned())),
// Unconfigured conversation ⇒ no handoff yet (never an error).
None => Ok(None),
}
}
async fn save(
&self,
_conversation: ConversationId,
_handoff: Handoff,
) -> Result<(), StoreError> {
Ok(())
}
}
/// Configurable per-conversation outcome for the fake conversation log.
#[derive(Clone)]
enum LogOutcome {
/// A readable thread (possibly empty).
Turns(Vec<ConversationTurn>),
/// An unreadable thread.
Error,
}
#[derive(Default)]
struct FakeLog {
outcomes: Mutex<HashMap<ConversationId, LogOutcome>>,
}
impl FakeLog {
fn set(&self, conversation: ConversationId, outcome: LogOutcome) {
self.outcomes.lock().unwrap().insert(conversation, outcome);
}
}
#[async_trait]
impl ConversationLog for FakeLog {
async fn append(
&self,
_conversation: ConversationId,
_turn: ConversationTurn,
) -> Result<(), StoreError> {
Ok(())
}
async fn read(
&self,
_conversation: ConversationId,
_since: Option<TurnId>,
) -> Result<Vec<ConversationTurn>, StoreError> {
Ok(Vec::new())
}
async fn last(
&self,
conversation: ConversationId,
n: usize,
) -> Result<Vec<ConversationTurn>, StoreError> {
match self.outcomes.lock().unwrap().get(&conversation) {
Some(LogOutcome::Turns(turns)) => {
let start = turns.len().saturating_sub(n);
Ok(turns[start..].to_vec())
}
Some(LogOutcome::Error) => Err(StoreError::Io("log unreadable".to_owned())),
None => Ok(Vec::new()),
}
}
}
/// Stateless providers handing the same fake stores back regardless of root.
struct FakeHandoffProvider(Arc<FakeHandoffStore>);
impl HandoffProvider for FakeHandoffProvider {
fn handoff_store_for(&self, _root: &ProjectPath) -> Option<Arc<dyn HandoffStore>> {
Some(Arc::clone(&self.0) as Arc<dyn HandoffStore>)
}
}
struct FakeLogProvider(Arc<FakeLog>);
impl ConversationLogProvider for FakeLogProvider {
fn conversation_log_for(&self, _root: &ProjectPath) -> Option<Arc<dyn ConversationLog>> {
Some(Arc::clone(&self.0) as Arc<dyn ConversationLog>)
}
}
fn turn(id: u128, conversation: ConversationId, role: TurnRole, text: &str) -> ConversationTurn {
ConversationTurn::new(
TurnId::from_uuid(Uuid::from_u128(id)),
conversation,
1_700,
InputSource::Human,
role,
text,
)
}
fn snapshot(
id: u128,
position: u32,
source: InputSource,
requester: &str,
task: &str,
) -> QueuedTicketSnapshot {
QueuedTicketSnapshot {
id: TicketId::from_uuid(Uuid::from_u128(id)),
source,
conversation: ConversationId::from_uuid(Uuid::from_u128(id + 1000)),
requester: requester.to_owned(),
task: task.to_owned(),
position,
}
}
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_session(id: SessionId) -> Arc<dyn AgentSession> {
Arc::new(FakeSession { id })
}
fn insert_pty(
sessions: &TerminalSessions,
session_id: SessionId,
agent_id: AgentId,
node_id: NodeId,
) {
sessions.insert(
PtyHandle { session_id },
TerminalSession::starting(
session_id,
node_id,
ProjectPath::new("/tmp/idea-workstate-test").unwrap(),
SessionKind::Agent { agent_id },
PtySize { rows: 24, cols: 80 },
),
);
}
struct Fixture {
usecase: GetProjectWorkState,
pty: Arc<TerminalSessions>,
structured: Arc<StructuredSessions>,
input: Arc<FakeInput>,
queue: Arc<FakeQueue>,
project: Project,
}
fn fixture(agents: &[Agent]) -> Fixture {
let pty = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let live = Arc::new(LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured)));
let input = Arc::new(FakeInput::default());
let input_port = Arc::clone(&input) as Arc<dyn InputMediator>;
let queue = Arc::new(FakeQueue::default());
let queue_port = Arc::clone(&queue) as Arc<dyn AgentQueueSnapshot>;
let usecase = GetProjectWorkState::new(
Arc::new(FakeContexts {
manifest: manifest(agents),
}),
live,
input_port,
queue_port,
);
Fixture {
usecase,
pty,
structured,
input,
queue,
project: project(),
}
}
/// Conversation id minted by [`snapshot`] for a ticket of the given `id`.
fn conv_of(id: u128) -> ConversationId {
ConversationId::from_uuid(Uuid::from_u128(id + 1000))
}
struct ConvFixture {
usecase: GetProjectWorkState,
queue: Arc<FakeQueue>,
input: Arc<FakeInput>,
handoffs: Arc<FakeHandoffStore>,
logs: Arc<FakeLog>,
project: Project,
}
/// Fixture wiring the best-effort conversation sources (handoff + log) onto the
/// read model, exposing the fake stores so each test configures their outcomes.
fn conv_fixture(agents: &[Agent]) -> ConvFixture {
let pty = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let live = Arc::new(LiveSessions::new(pty, structured));
let input = Arc::new(FakeInput::default());
let queue = Arc::new(FakeQueue::default());
let handoffs = Arc::new(FakeHandoffStore::default());
let logs = Arc::new(FakeLog::default());
let usecase = GetProjectWorkState::new(
Arc::new(FakeContexts {
manifest: manifest(agents),
}),
live,
Arc::clone(&input) as Arc<dyn InputMediator>,
Arc::clone(&queue) as Arc<dyn AgentQueueSnapshot>,
)
.with_conversation_sources(
Arc::new(FakeHandoffProvider(Arc::clone(&handoffs))) as Arc<dyn HandoffProvider>,
Arc::new(FakeLogProvider(Arc::clone(&logs))) as Arc<dyn ConversationLogProvider>,
);
ConvFixture {
usecase,
queue,
input,
handoffs,
logs,
project: project(),
}
}
#[tokio::test]
async fn workstate_lists_manifest_agents_idle_without_live_sessions() {
let a = agent(10, "alpha");
let b = agent(20, "beta");
let f = fixture(&[a.clone(), b.clone()]);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert_eq!(out.agents.len(), 2);
assert_eq!(out.agents[0].agent_id, a.id);
assert_eq!(out.agents[0].name, "alpha");
assert_eq!(out.agents[0].profile_id, a.profile_id);
assert_eq!(out.agents[0].live, None);
assert_eq!(out.agents[0].busy, AgentBusyState::Idle);
assert_eq!(out.agents[1].agent_id, b.id);
}
#[tokio::test]
async fn workstate_attaches_live_pty_session_to_manifest_agent() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
insert_pty(&f.pty, sid(1), a.id, nid(100));
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let live = out.agents[0].live.unwrap();
assert_eq!(live.session_id, sid(1));
assert_eq!(live.node_id, nid(100));
assert_eq!(live.kind, LiveSessionKind::Pty);
}
#[tokio::test]
async fn workstate_attaches_live_structured_session_to_manifest_agent() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
f.structured.insert(fake_session(sid(2)), a.id, nid(200));
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let live = out.agents[0].live.unwrap();
assert_eq!(live.session_id, sid(2));
assert_eq!(live.node_id, nid(200));
assert_eq!(live.kind, LiveSessionKind::Structured);
}
#[tokio::test]
async fn workstate_includes_busy_state_from_input_mediator() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let busy = AgentBusyState::Busy {
ticket: ticket_id(7),
since_ms: 1_234,
};
f.input.set_busy(a.id, busy);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert_eq!(out.agents[0].busy, busy);
}
#[tokio::test]
async fn workstate_ignores_live_agents_absent_from_manifest() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
insert_pty(&f.pty, sid(99), aid(999), nid(999));
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert_eq!(out.agents.len(), 1);
assert_eq!(out.agents[0].agent_id, a.id);
assert_eq!(out.agents[0].live, None);
}
#[tokio::test]
async fn workstate_agent_without_queue_has_no_tickets() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert!(out.agents[0].tickets.is_empty());
}
#[tokio::test]
async fn workstate_lists_two_tickets_in_fifo_order() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![
snapshot(1, 0, InputSource::agent(from), "Main", "first"),
snapshot(2, 1, InputSource::agent(from), "Main", "second"),
],
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let tickets = &out.agents[0].tickets;
assert_eq!(tickets.len(), 2);
assert_eq!(tickets[0].ticket_id, ticket_id(1));
assert_eq!(tickets[0].position, 0);
assert_eq!(tickets[1].ticket_id, ticket_id(2));
assert_eq!(tickets[1].position, 1);
}
#[tokio::test]
async fn workstate_marks_busy_head_in_progress_and_rest_queued() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![
snapshot(1, 0, InputSource::agent(from), "Main", "first"),
snapshot(2, 1, InputSource::agent(from), "Main", "second"),
],
);
f.input.set_busy(
a.id,
AgentBusyState::Busy {
ticket: ticket_id(1),
since_ms: 5,
},
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let tickets = &out.agents[0].tickets;
assert_eq!(tickets[0].status, TicketWorkStatus::InProgress);
assert_eq!(tickets[1].status, TicketWorkStatus::Queued);
}
#[tokio::test]
async fn workstate_marks_all_queued_when_agent_idle() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![snapshot(1, 0, InputSource::agent(from), "Main", "first")],
);
// Agent left Idle (default): even the head is only queued, not in-progress.
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert_eq!(out.agents[0].tickets[0].status, TicketWorkStatus::Queued);
}
#[tokio::test]
async fn workstate_ignores_queue_for_agent_absent_from_manifest() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
// A queue exists for an agent that is not in the manifest: it must not surface.
f.queue.set(
aid(999),
vec![snapshot(1, 0, InputSource::Human, "User", "ghost")],
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert_eq!(out.agents.len(), 1);
assert!(out.agents[0].tickets.is_empty());
}
#[tokio::test]
async fn workstate_truncates_task_preview_and_keeps_original_len() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
// 400 chars with runs of whitespace to normalise; well over the 160 cap.
let long_task = format!("start{}end", " x ".repeat(80));
let original_len = long_task.chars().count();
f.queue.set(
a.id,
vec![snapshot(1, 0, InputSource::Human, "User", &long_task)],
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let ticket = &out.agents[0].tickets[0];
assert_eq!(ticket.task_preview.chars().count(), 160);
assert!(!ticket.task_preview.contains(" "), "whitespace normalised");
assert_eq!(ticket.task_len, original_len);
assert!(ticket.task_len > 160);
}
#[tokio::test]
async fn workstate_maps_human_and_agent_ticket_sources() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![
snapshot(1, 0, InputSource::Human, "User", "from human"),
snapshot(2, 1, InputSource::agent(from), "Main", "from agent"),
],
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let tickets = &out.agents[0].tickets;
assert_eq!(tickets[0].source, TicketWorkSource::Human);
assert_eq!(tickets[0].requester_label, "User");
assert_eq!(
tickets[1].source,
TicketWorkSource::Agent { agent_id: from }
);
assert_eq!(tickets[1].requester_label, "Main");
}
// ---------------------------------------------------------------------------
// Lot C — conversation summaries (best-effort, read-only)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn workstate_has_no_conversations_without_tickets() {
let a = agent(10, "alpha");
let f = conv_fixture(std::slice::from_ref(&a));
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert!(out.conversations.is_empty());
}
#[tokio::test]
async fn workstate_dedups_conversation_ids_from_tickets() {
let a = agent(10, "alpha");
let f = conv_fixture(std::slice::from_ref(&a));
let from = aid(20);
let shared = ConversationId::from_uuid(Uuid::from_u128(7777));
// Two tickets pointing at the *same* conversation must yield one summary.
let mut t1 = snapshot(1, 0, InputSource::agent(from), "Main", "first");
t1.conversation = shared;
let mut t2 = snapshot(2, 1, InputSource::agent(from), "Main", "second");
t2.conversation = shared;
f.queue.set(a.id, vec![t1, t2]);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert_eq!(out.conversations.len(), 1);
assert_eq!(out.conversations[0].conversation_id, shared);
}
#[tokio::test]
async fn workstate_conversation_ready_from_handoff() {
let a = agent(10, "alpha");
let f = conv_fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![snapshot(1, 0, InputSource::agent(from), "Main", "task")],
);
let conv = conv_of(1);
let up_to = TurnId::from_uuid(Uuid::from_u128(500));
f.handoffs.set(
conv,
HandoffOutcome::Present(Handoff::new(
"Résumé du fil",
up_to,
Some("Livrer le lot C".to_owned()),
)),
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let summary = &out.conversations[0];
assert_eq!(summary.conversation_id, conv);
assert_eq!(summary.status, ConversationPreviewStatus::Ready);
assert_eq!(summary.summary_preview.as_deref(), Some("Résumé du fil"));
assert_eq!(summary.summary_len, "Résumé du fil".chars().count());
assert_eq!(
summary.objective_preview.as_deref(),
Some("Livrer le lot C")
);
assert_eq!(summary.up_to, Some(up_to));
assert!(
summary.recent_turns.is_empty(),
"Ready uses the handoff, not turns"
);
}
#[tokio::test]
async fn workstate_conversation_missing_with_bounded_recent_turns() {
let a = agent(10, "alpha");
let f = conv_fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![snapshot(1, 0, InputSource::agent(from), "Main", "task")],
);
let conv = conv_of(1);
// No handoff (Absent) but a readable log of 5 turns ⇒ Missing + last 3.
f.logs.set(
conv,
LogOutcome::Turns(vec![
turn(1, conv, TurnRole::Prompt, "t1"),
turn(2, conv, TurnRole::Response, "t2"),
turn(3, conv, TurnRole::Prompt, "t3"),
turn(4, conv, TurnRole::Response, "t4"),
turn(5, conv, TurnRole::Prompt, "t5"),
]),
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let summary = &out.conversations[0];
assert_eq!(summary.status, ConversationPreviewStatus::Missing);
assert!(summary.summary_preview.is_none());
assert_eq!(summary.summary_len, 0);
assert_eq!(summary.recent_turns.len(), 3, "bounded to RECENT_TURNS_MAX");
// The *last* three turns, in order.
assert_eq!(summary.recent_turns[0].text_preview, "t3");
assert_eq!(summary.recent_turns[2].text_preview, "t5");
}
#[tokio::test]
async fn workstate_conversation_partial_when_handoff_errors_but_log_ok() {
let a = agent(10, "alpha");
let f = conv_fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![snapshot(1, 0, InputSource::agent(from), "Main", "task")],
);
let conv = conv_of(1);
f.handoffs.set(conv, HandoffOutcome::Error);
f.logs.set(
conv,
LogOutcome::Turns(vec![turn(1, conv, TurnRole::Response, "only turn")]),
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let summary = &out.conversations[0];
assert_eq!(summary.status, ConversationPreviewStatus::Partial);
assert!(summary.summary_preview.is_none(), "no summary on partial");
assert_eq!(summary.recent_turns.len(), 1);
assert_eq!(summary.recent_turns[0].text_preview, "only turn");
}
#[tokio::test]
async fn workstate_conversation_unavailable_when_handoff_and_log_fail() {
let a = agent(10, "alpha");
let f = conv_fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![snapshot(1, 0, InputSource::agent(from), "Main", "task")],
);
let conv = conv_of(1);
f.handoffs.set(conv, HandoffOutcome::Error);
f.logs.set(conv, LogOutcome::Error);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let summary = &out.conversations[0];
assert_eq!(summary.status, ConversationPreviewStatus::Unavailable);
assert!(summary.summary_preview.is_none());
assert!(summary.recent_turns.is_empty());
}
#[tokio::test]
async fn workstate_preview_failure_preserves_agents_live_busy_tickets() {
let a = agent(10, "alpha");
let f = conv_fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![snapshot(1, 0, InputSource::agent(from), "Main", "task")],
);
let conv = conv_of(1);
// Both sources KO for this conversation ⇒ Unavailable, but the rest stands.
f.handoffs.set(conv, HandoffOutcome::Error);
f.logs.set(conv, LogOutcome::Error);
let busy = AgentBusyState::Busy {
ticket: ticket_id(1),
since_ms: 9,
};
f.input.set_busy(a.id, busy);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
// Agents/busy/tickets fully intact despite the failed preview.
assert_eq!(out.agents.len(), 1);
assert_eq!(out.agents[0].busy, busy);
assert_eq!(out.agents[0].tickets.len(), 1);
assert_eq!(
out.agents[0].tickets[0].status,
TicketWorkStatus::InProgress
);
assert_eq!(
out.conversations[0].status,
ConversationPreviewStatus::Unavailable
);
}
#[tokio::test]
async fn workstate_conversation_previews_truncated_and_normalised() {
let a = agent(10, "alpha");
let f = conv_fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![snapshot(1, 0, InputSource::agent(from), "Main", "task")],
);
let conv = conv_of(1);
// Whitespace runs to normalise; lengths well over each cap (480 / 160).
// Each " s " collapses to one " s" (2 chars) ⇒ pick counts past the caps.
let long_summary = format!("start{}end", " s ".repeat(300));
let long_objective = format!("goal{}done", " o ".repeat(120));
f.handoffs.set(
conv,
HandoffOutcome::Present(Handoff::new(
long_summary.clone(),
TurnId::from_uuid(Uuid::from_u128(1)),
Some(long_objective),
)),
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let summary = &out.conversations[0];
let preview = summary.summary_preview.as_deref().unwrap();
assert_eq!(preview.chars().count(), 480, "summary capped");
assert!(!preview.contains(" "), "summary whitespace normalised");
assert_eq!(
summary.summary_len,
long_summary.chars().count(),
"original length preserved"
);
let objective = summary.objective_preview.as_deref().unwrap();
assert_eq!(objective.chars().count(), 160, "objective capped");
assert!(!objective.contains(" "), "objective whitespace normalised");
}
#[tokio::test]
async fn workstate_conversation_turn_text_preview_truncated() {
let a = agent(10, "alpha");
let f = conv_fixture(std::slice::from_ref(&a));
let from = aid(20);
f.queue.set(
a.id,
vec![snapshot(1, 0, InputSource::agent(from), "Main", "task")],
);
let conv = conv_of(1);
let long_text = format!("begin{}fin", " w ".repeat(120));
f.logs.set(
conv,
LogOutcome::Turns(vec![turn(1, conv, TurnRole::Prompt, &long_text)]),
);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let turn_preview = &out.conversations[0].recent_turns[0];
assert_eq!(
turn_preview.text_preview.chars().count(),
220,
"turn capped"
);
assert!(!turn_preview.text_preview.contains(" "), "normalised");
assert_eq!(turn_preview.text_len, long_text.chars().count());
}

View File

@ -0,0 +1,351 @@
//! Tests for the Lot D agent-level controlled actions (`AttachLiveAgent`,
//! `StopLiveAgent`) over the live-session registries.
//!
//! Every port is faked in-memory so the use cases run without a real PTY or
//! agent-session backend: [`FakePty`] records `kill`s, [`FakeSession`] records
//! `shutdown`s.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use application::{
AttachLiveAgent, AttachLiveAgentInput, CloseTerminal, LiveSessionKind, LiveSessions,
StopLiveAgent, StopLiveAgentInput, StructuredSessions, TerminalSessions,
};
use domain::ports::{
AgentSession, AgentSessionError, ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort,
ReplyStream, SpawnSpec,
};
use domain::Project;
use domain::{
AgentId, NodeId, ProjectId, ProjectPath, PtySize, RemoteRef, SessionId, SessionKind,
TerminalSession,
};
use uuid::Uuid;
// ---------------------------------------------------------------------------
// ids / fixtures
// ---------------------------------------------------------------------------
fn aid(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n))
}
fn sid(n: u128) -> SessionId {
SessionId::from_uuid(Uuid::from_u128(n))
}
fn nid(n: u128) -> NodeId {
NodeId::from_uuid(Uuid::from_u128(n))
}
fn project() -> Project {
Project::new(
ProjectId::from_uuid(Uuid::from_u128(1)),
"demo",
ProjectPath::new("/tmp/idea-workstate-actions").unwrap(),
RemoteRef::local(),
1_700_000_000_000,
)
.unwrap()
}
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
/// A recording [`PtyPort`] whose `kill` records the killed session id.
#[derive(Default)]
struct FakePty {
kills: Mutex<Vec<SessionId>>,
}
impl FakePty {
fn kills(&self) -> Vec<SessionId> {
self.kills.lock().unwrap().clone()
}
}
#[async_trait]
impl PtyPort for FakePty {
async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
unreachable!("Lot D never spawns")
}
fn write(&self, _handle: &PtyHandle, _data: &[u8]) -> Result<(), PtyError> {
Ok(())
}
fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> {
Ok(())
}
fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
Ok(Box::new(std::iter::empty()))
}
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
Ok(Vec::new())
}
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
self.kills.lock().unwrap().push(handle.session_id);
Ok(ExitStatus { code: Some(0) })
}
}
/// A fake structured [`AgentSession`] recording whether `shutdown` was called.
struct FakeSession {
id: SessionId,
shutdown_called: Arc<AtomicBool>,
}
#[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> {
self.shutdown_called.store(true, Ordering::SeqCst);
Ok(())
}
}
// ---------------------------------------------------------------------------
// wiring helpers
// ---------------------------------------------------------------------------
struct Fixture {
attach: AttachLiveAgent,
stop: StopLiveAgent,
pty: Arc<TerminalSessions>,
structured: Arc<StructuredSessions>,
pty_port: Arc<FakePty>,
}
fn fixture() -> Fixture {
let pty = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let live = Arc::new(LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured)));
let pty_port = Arc::new(FakePty::default());
let close = Arc::new(CloseTerminal::new(
Arc::clone(&pty_port) as Arc<dyn PtyPort>,
Arc::clone(&pty),
));
Fixture {
attach: AttachLiveAgent::new(Arc::clone(&live)),
stop: StopLiveAgent::new(Arc::clone(&live), close),
pty,
structured,
pty_port,
}
}
fn insert_pty(
sessions: &TerminalSessions,
session_id: SessionId,
agent_id: AgentId,
node_id: NodeId,
) {
sessions.insert(
PtyHandle { session_id },
TerminalSession::starting(
session_id,
node_id,
ProjectPath::new("/tmp/idea-workstate-actions").unwrap(),
SessionKind::Agent { agent_id },
PtySize { rows: 24, cols: 80 },
),
);
}
fn insert_structured(
sessions: &StructuredSessions,
session_id: SessionId,
agent_id: AgentId,
node_id: NodeId,
) -> Arc<AtomicBool> {
let flag = Arc::new(AtomicBool::new(false));
sessions.insert(
Arc::new(FakeSession {
id: session_id,
shutdown_called: Arc::clone(&flag),
}),
agent_id,
node_id,
);
flag
}
// ---------------------------------------------------------------------------
// AttachLiveAgent
// ---------------------------------------------------------------------------
#[test]
fn attach_pty_rebinds_node_without_changing_session() {
let f = fixture();
let a = aid(10);
insert_pty(&f.pty, sid(1), a, nid(100));
let out = f
.attach
.execute(AttachLiveAgentInput {
project: project(),
agent_id: a,
node_id: nid(200),
})
.unwrap();
assert_eq!(out.agent_id, a);
assert_eq!(out.session_id, sid(1), "session id is preserved on rebind");
assert_eq!(out.node_id, nid(200), "view rebound to the new node");
assert_eq!(out.kind, LiveSessionKind::Pty);
// The registry reflects the new host node, same session.
assert_eq!(f.pty.node_for_agent(&a), Some(nid(200)));
assert_eq!(f.pty.session_for_agent(&a), Some(sid(1)));
}
#[test]
fn attach_structured_rebinds_node() {
let f = fixture();
let a = aid(10);
let _flag = insert_structured(&f.structured, sid(2), a, nid(100));
let out = f
.attach
.execute(AttachLiveAgentInput {
project: project(),
agent_id: a,
node_id: nid(300),
})
.unwrap();
assert_eq!(out.session_id, sid(2));
assert_eq!(out.node_id, nid(300));
assert_eq!(out.kind, LiveSessionKind::Structured);
assert_eq!(f.structured.node_for_agent(&a), Some(nid(300)));
}
#[test]
fn attach_unknown_agent_is_not_found() {
let f = fixture();
let err = f
.attach
.execute(AttachLiveAgentInput {
project: project(),
agent_id: aid(999),
node_id: nid(1),
})
.expect_err("absent agent ⇒ NOT_FOUND");
assert!(
matches!(err, application::AppError::NotFound(_)),
"got {err:?}"
);
}
#[test]
fn attach_same_node_is_idempotent() {
let f = fixture();
let a = aid(10);
insert_pty(&f.pty, sid(1), a, nid(100));
let first = f
.attach
.execute(AttachLiveAgentInput {
project: project(),
agent_id: a,
node_id: nid(100),
})
.unwrap();
let second = f
.attach
.execute(AttachLiveAgentInput {
project: project(),
agent_id: a,
node_id: nid(100),
})
.unwrap();
// Re-attaching to the same node is a no-op: same session, same node, no spawn.
assert_eq!(first.session_id, second.session_id);
assert_eq!(first.node_id, nid(100));
assert_eq!(second.node_id, nid(100));
assert_eq!(f.pty.len(), 1, "no extra session created");
}
// ---------------------------------------------------------------------------
// StopLiveAgent
// ---------------------------------------------------------------------------
#[tokio::test]
async fn stop_pty_kills_and_removes_session() {
let f = fixture();
let a = aid(10);
insert_pty(&f.pty, sid(1), a, nid(100));
let out = f
.stop
.execute(StopLiveAgentInput {
project: project(),
agent_id: a,
})
.await
.unwrap();
assert_eq!(out.agent_id, a);
assert_eq!(out.session_id, sid(1));
assert_eq!(out.kind, LiveSessionKind::Pty);
// Delegated to the close primitive: process killed and registry emptied.
assert_eq!(f.pty_port.kills(), vec![sid(1)]);
assert!(f.pty.is_empty(), "live session removed from the registry");
assert_eq!(f.pty.session_for_agent(&a), None);
}
#[tokio::test]
async fn stop_structured_shuts_down_and_removes_session() {
let f = fixture();
let a = aid(10);
let flag = insert_structured(&f.structured, sid(2), a, nid(100));
let out = f
.stop
.execute(StopLiveAgentInput {
project: project(),
agent_id: a,
})
.await
.unwrap();
assert_eq!(out.session_id, sid(2));
assert_eq!(out.kind, LiveSessionKind::Structured);
assert!(flag.load(Ordering::SeqCst), "session.shutdown() was called");
assert!(f.structured.is_empty(), "live session removed");
assert_eq!(f.structured.session_id_for_agent(&a), None);
// No PTY was touched.
assert!(f.pty_port.kills().is_empty());
}
#[tokio::test]
async fn stop_unknown_agent_is_not_found() {
let f = fixture();
let err = f
.stop
.execute(StopLiveAgentInput {
project: project(),
agent_id: aid(999),
})
.await
.expect_err("absent agent ⇒ NOT_FOUND");
assert!(
matches!(err, application::AppError::NotFound(_)),
"got {err:?}"
);
assert!(f.pty_port.kills().is_empty(), "nothing killed");
}

View File

@ -1,6 +1,6 @@
[package]
name = "domain"
version = "0.2.0"
version = "0.3.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true

View File

@ -277,24 +277,59 @@ impl ManifestEntry {
/// In-memory image of `.ideai/agents.json`.
///
/// Invariant enforced here: `md_path` values are unique across entries.
/// Invariants enforced here:
/// - `md_path` values are unique across entries;
/// - `orchestrator == Some(id)` ⇒ `id` is present in `entries` (referential).
///
/// **Ordering invariant**: `entries` are kept in **creation order**, so
/// `entries.first()` is the **oldest** agent. There is no per-entry timestamp; the
/// vector position *is* the chronology, which is why the default orchestrator (when
/// none is designated) is `entries.first()`.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentManifest {
/// Schema version of the manifest file.
pub version: u32,
/// Entries (one per project agent).
/// Entries (one per project agent), in **creation order** (oldest first).
#[serde(rename = "agents")]
pub entries: Vec<ManifestEntry>,
/// Explicitly designated orchestrator agent, if any.
///
/// We persist only the **deviation** from the default: `None` (the common case)
/// means « the oldest agent orchestrates » (see [`effective_orchestrator`]);
/// `Some(id)` is an explicit designation. Skipped on serialisation when `None`,
/// so pre-feature manifests round-trip unchanged (free backward compatibility).
///
/// [`effective_orchestrator`]: AgentManifest::effective_orchestrator
#[serde(default, skip_serializing_if = "Option::is_none")]
pub orchestrator: Option<AgentId>,
}
impl AgentManifest {
/// Builds a validated manifest.
/// Builds a validated manifest with **no** explicit orchestrator (the default:
/// the oldest agent orchestrates — see [`effective_orchestrator`]).
///
/// [`effective_orchestrator`]: AgentManifest::effective_orchestrator
///
/// # Errors
/// Returns [`DomainError::InconsistentManifest`] if two entries share the
/// same `md_path`.
pub fn new(version: u32, entries: Vec<ManifestEntry>) -> Result<Self, DomainError> {
Self::with_orchestrator(version, entries, None)
}
/// Builds a validated manifest carrying an explicit `orchestrator` designation.
///
/// # Errors
/// - [`DomainError::InconsistentManifest`] if two entries share the same
/// `md_path`;
/// - [`DomainError::InconsistentManifest`] if `orchestrator == Some(id)` while
/// `id` is **not** present in `entries` (referential integrity).
pub fn with_orchestrator(
version: u32,
entries: Vec<ManifestEntry>,
orchestrator: Option<AgentId>,
) -> Result<Self, DomainError> {
let mut seen = std::collections::HashSet::new();
for entry in &entries {
if !seen.insert(entry.md_path.as_str()) {
@ -303,6 +338,196 @@ impl AgentManifest {
});
}
}
Ok(Self { version, entries })
if let Some(id) = orchestrator {
if !entries.iter().any(|e| e.agent_id == id) {
return Err(DomainError::InconsistentManifest {
reason: format!("designated orchestrator `{id}` is not a known agent"),
});
}
}
Ok(Self {
version,
entries,
orchestrator,
})
}
/// The **effective** orchestrator agent: the explicit designation if any,
/// otherwise the **oldest** agent (`entries.first()`, by the creation-order
/// invariant). `None` only when the manifest has no entries at all.
#[must_use]
pub fn effective_orchestrator(&self) -> Option<AgentId> {
self.orchestrator
.or_else(|| self.entries.first().map(|e| e.agent_id))
}
/// Folds the [`effective_orchestrator`](Self::effective_orchestrator) into the
/// [`OrchestratorDesignation`] value object consumed by the FileGuard policy.
#[must_use]
pub fn orchestrator_designation(&self) -> crate::fileguard::OrchestratorDesignation {
match self.effective_orchestrator() {
Some(id) => crate::fileguard::OrchestratorDesignation::of(id),
None => crate::fileguard::OrchestratorDesignation::none(),
}
}
/// Designates `id` as the project's orchestrator (radio semantics: overwrites any
/// previous designation).
///
/// # Errors
/// [`DomainError::InconsistentManifest`] if `id` is not a known agent of this
/// manifest.
pub fn designate(&mut self, id: AgentId) -> Result<(), DomainError> {
if !self.entries.iter().any(|e| e.agent_id == id) {
return Err(DomainError::InconsistentManifest {
reason: format!("cannot designate unknown agent `{id}` as orchestrator"),
});
}
self.orchestrator = Some(id);
Ok(())
}
/// Reacts to the removal of an agent: if it was the **explicitly designated**
/// orchestrator, clears the designation so the manifest falls back to the default
/// (lazy succession — the next-oldest agent becomes effective). A no-op when the
/// removed agent was not the designated one.
///
/// Pure bookkeeping: it does **not** remove the entry itself (the caller owns
/// `entries`); it only keeps the `orchestrator` deviation consistent.
pub fn on_agent_deleted(&mut self, removed: AgentId) {
if self.orchestrator == Some(removed) {
self.orchestrator = None;
}
}
}
#[cfg(test)]
mod orchestrator_tests {
use super::*;
use crate::conversation::ConversationParty;
use crate::fileguard::{may_write_directly, GuardedResource, OrchestratorDesignation};
fn agent_id(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
/// Builds a manifest entry for `id`, with a `md_path` derived from `n` so entries
/// stay unique.
fn entry(n: u128) -> ManifestEntry {
ManifestEntry::new(
agent_id(n),
format!("agent-{n}"),
format!("agents/agent-{n}.md"),
ProfileId::from_uuid(uuid::Uuid::from_u128(1000 + n)),
None,
false,
None,
)
.unwrap()
}
#[test]
fn default_orchestrator_is_oldest_agent_when_none_designated() {
// entries are in creation order: first is the oldest → the effective default.
let m = AgentManifest::new(1, vec![entry(1), entry(2), entry(3)]).unwrap();
assert_eq!(m.orchestrator, None);
assert_eq!(m.effective_orchestrator(), Some(agent_id(1)));
assert_eq!(
m.orchestrator_designation(),
OrchestratorDesignation::of(agent_id(1))
);
}
#[test]
fn empty_manifest_has_no_effective_orchestrator() {
let m = AgentManifest::new(1, vec![]).unwrap();
assert_eq!(m.effective_orchestrator(), None);
assert_eq!(
m.orchestrator_designation(),
OrchestratorDesignation::none()
);
}
#[test]
fn explicit_designation_overrides_oldest() {
let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap();
m.designate(agent_id(2)).unwrap();
assert_eq!(m.orchestrator, Some(agent_id(2)));
assert_eq!(m.effective_orchestrator(), Some(agent_id(2)));
}
#[test]
fn designate_is_radio_and_rejects_unknown_agent() {
let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap();
m.designate(agent_id(1)).unwrap();
// Radio: a second designation overwrites the first (never accumulates).
m.designate(agent_id(2)).unwrap();
assert_eq!(m.orchestrator, Some(agent_id(2)));
// Referential: designating an agent absent from `entries` is rejected.
let err = m.designate(agent_id(99)).unwrap_err();
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
// …and the previous valid designation is untouched.
assert_eq!(m.orchestrator, Some(agent_id(2)));
}
#[test]
fn lazy_succession_falls_back_to_oldest_when_designated_is_deleted() {
let mut m = AgentManifest::new(1, vec![entry(1), entry(2), entry(3)]).unwrap();
m.designate(agent_id(3)).unwrap();
// The designated agent is removed → designation cleared, default takes over.
m.entries.retain(|e| e.agent_id != agent_id(3));
m.on_agent_deleted(agent_id(3));
assert_eq!(m.orchestrator, None);
// Falls back to the (now) oldest remaining agent.
assert_eq!(m.effective_orchestrator(), Some(agent_id(1)));
}
#[test]
fn on_agent_deleted_is_noop_for_non_designated_agent() {
let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap();
m.designate(agent_id(2)).unwrap();
m.on_agent_deleted(agent_id(1));
// Deleting a non-designated agent leaves the designation intact.
assert_eq!(m.orchestrator, Some(agent_id(2)));
}
#[test]
fn constructor_enforces_referential_integrity_of_orchestrator() {
// Some(id) with id present in entries → ok.
assert!(
AgentManifest::with_orchestrator(1, vec![entry(1), entry(2)], Some(agent_id(2)),)
.is_ok()
);
// Some(id) with id absent → rejected.
let err =
AgentManifest::with_orchestrator(1, vec![entry(1)], Some(agent_id(42))).unwrap_err();
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
// None is always valid (even with an empty manifest).
assert!(AgentManifest::with_orchestrator(1, vec![], None).is_ok());
}
#[test]
fn designated_agent_may_write_project_context_via_manifest_designation() {
let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap();
m.designate(agent_id(1)).unwrap();
let d = m.orchestrator_designation();
// The designated agent writes the project context directly…
assert!(may_write_directly(
ConversationParty::agent(agent_id(1)),
&GuardedResource::ProjectContext,
&d,
));
// …another agent is refused (single-writer preserved)…
assert!(!may_write_directly(
ConversationParty::agent(agent_id(2)),
&GuardedResource::ProjectContext,
&d,
));
// …and the human is always allowed.
assert!(may_write_directly(
ConversationParty::User,
&GuardedResource::ProjectContext,
&d,
));
}
}

View File

@ -206,6 +206,141 @@ impl Handoff {
}
}
/// Borne dure (en **caractères**) du `summary_md` d'un [`Handoff`] (lot LS5).
///
/// Plafond universel **indépendant du résumeur** : qu'il soit produit par
/// l'heuristique zéro-dépendance ou par un futur résumeur LLM, le résumé injecté dans
/// le contexte d'un agent (et persisté) ne dépasse jamais cette taille. `4096`
/// caractères tiennent largement un objectif + une fenêtre de tours déjà bornés
/// individuellement, tout en gardant le contexte de reprise petit (chemin chaud).
pub const HANDOFF_SUMMARY_MAX_CHARS: usize = 4096;
/// Préfixe d'une **ligne de tour** dans un `summary_md` rendu (cf. le résumeur
/// heuristique d'infra) : une ligne `- **<role>:** <texte>`. Sert ici à séparer le
/// **préambule d'objectif** des **lignes de tours** pour la troncature distillée.
const TURN_LINE_PREFIX: &str = "- **";
/// Borne le `summary_md` d'un [`Handoff`] à `max_chars` caractères, **best-effort et
/// idempotent** (lot LS5).
///
/// - Sous la borne (`summary_md.chars().count() <= max_chars`) ⇒ handoff **inchangé**
/// (coût nul, cas chaud nominal).
/// - Au-dessus ⇒ **troncature distillée** : on garde le **préambule d'objectif** (le
/// 1er bloc, avant la première ligne de tour) PUIS le **maximum de lignes de tours
/// les plus récentes** (fin de fenêtre) tenant sous `max_chars`, en **droppant les
/// plus anciennes d'abord**. Si la ligne conservée la plus récente dépasse à elle
/// seule le budget, sa **fin** est tronquée en gardant le préfixe `- **role:**`
/// intact (le résultat reste **toujours reparsable** comme un `summary_md`).
///
/// [`Handoff::up_to`] et [`Handoff::objective`] ne sont **jamais** modifiés. La fonction
/// ne **re-fold jamais**, ne **rejette jamais** (elle ne doit pas pouvoir bloquer un
/// append/checkpoint) et garantit `bound(bound(x)) == bound(x)`.
#[must_use]
pub fn bound_handoff_summary(handoff: Handoff, max_chars: usize) -> Handoff {
if handoff.summary_md.chars().count() <= max_chars {
return handoff;
}
let summary_md = distill_summary(&handoff.summary_md, max_chars);
Handoff {
summary_md,
up_to: handoff.up_to,
objective: handoff.objective,
}
}
/// Troncature distillée d'un `summary_md` au-dessus de la borne (cf.
/// [`bound_handoff_summary`]). Sépare le préambule d'objectif des lignes de tours, garde
/// le préambule puis le suffixe le plus récent de tours tenant sous `max_chars`.
fn distill_summary(summary: &str, max_chars: usize) -> String {
// Préambule d'objectif = lignes de tête jusqu'à la 1re ligne de tour ; lignes de
// tours = celles préfixées `- **` (les seules reparsées par le résumeur). Toute
// ligne non-tour APRÈS la 1re ligne de tour est du bruit inter-tours, droppée.
let mut objective_block: Vec<&str> = Vec::new();
let mut turn_lines: Vec<&str> = Vec::new();
let mut seen_turn = false;
for line in summary.lines() {
if line.starts_with(TURN_LINE_PREFIX) {
seen_turn = true;
turn_lines.push(line);
} else if !seen_turn {
objective_block.push(line);
}
}
while objective_block.last().is_some_and(|l| l.trim().is_empty()) {
objective_block.pop();
}
let objective_md = objective_block.join("\n");
let objective_len = objective_md.chars().count();
// Séparateur `\n\n` entre l'objectif et la fenêtre, seulement si les deux existent.
let separator_len = if objective_md.is_empty() { 0 } else { 2 };
let budget_for_turns = max_chars.saturating_sub(objective_len + separator_len);
// Garder les lignes de tours les plus RÉCENTES (fin) tenant sous le budget ; dropper
// les plus anciennes d'abord. On parcourt depuis la fin et on s'arrête au 1er dépassement.
let mut kept_rev: Vec<String> = Vec::new();
let mut used = 0usize;
for line in turn_lines.iter().rev() {
let join_cost = usize::from(!kept_rev.is_empty()); // le `\n` de jointure
let line_len = line.chars().count();
if used + join_cost + line_len <= budget_for_turns {
used += join_cost + line_len;
kept_rev.push((*line).to_owned());
} else {
// La ligne la plus récente ne tient pas même seule ⇒ hard-truncate sa fin
// en gardant le préfixe `- **role:**` intact (reparse toujours valide).
if kept_rev.is_empty() {
let truncated = truncate_turn_line_keeping_prefix(line, budget_for_turns);
if !truncated.is_empty() {
kept_rev.push(truncated);
}
}
break;
}
}
kept_rev.reverse();
let mut out = String::new();
if !objective_md.is_empty() {
out.push_str(&objective_md);
if !kept_rev.is_empty() {
out.push_str("\n\n");
}
}
if !kept_rev.is_empty() {
out.push_str(&kept_rev.join("\n"));
}
// Clamp final de sûreté : garantit `out.chars().count() <= max_chars` (donc
// l'idempotence via la garde d'entrée) même sur une entrée pathologique (objectif
// gigantesque, aucune ligne de tour). Char-boundary safe.
if out.chars().count() > max_chars {
out = out.chars().take(max_chars).collect();
}
out
}
/// Tronque la **fin** d'une ligne de tour à `budget` caractères en gardant intact le
/// préfixe `- **<role>:** ` pour que la ligne reste reparsable comme un tour.
fn truncate_turn_line_keeping_prefix(line: &str, budget: usize) -> String {
if line.chars().count() <= budget {
return line.to_owned();
}
// Préfixe = `- **Role:** ` (inclus l'espace) quand présent, sinon a minima `- **`.
let prefix: &str = match line.find(":** ") {
Some(pos) => &line[..pos + 4],
None => TURN_LINE_PREFIX,
};
let prefix_len = prefix.chars().count();
if budget <= prefix_len {
// Budget trop petit pour le moindre texte : on garde juste le préfixe reparsable.
return prefix.to_owned();
}
let tail: String = line[prefix.len()..]
.chars()
.take(budget - prefix_len)
.collect();
format!("{prefix}{tail}")
}
/// Le store du résumé de reprise (handoff) d'une conversation (port driven, lot P3).
///
/// Distinct du [`ConversationLog`] append-only : ici on garde **un seul** [`Handoff`]
@ -316,6 +451,208 @@ pub trait ProviderSessionStore: Send + Sync {
) -> Result<(), StoreError>;
}
// =====================================================================================
// Rotation & pagination du log (lot LS6)
// =====================================================================================
//
// Le `log.jsonl` actif est **borné** par rotation : au-delà d'un seuil (tours OU octets)
// la **tête froide** est déplacée dans des **segments d'archive** (`log.<k>.jsonl`), pour
// que le segment actif (et donc tout `read`/`last`/append) reste petit. La lecture
// **humaine** paginée traverse archives + actif.
//
// ## Invariant pivot (INV-LS6)
//
// La rotation ne déplace/élague **JAMAIS** un tour d'id ≥ `up_to` du handoff courant : le
// tour `up_to` et tous ses postérieurs restent dans le segment **actif**. Sans handoff (ou
// `up_to` = sentinelle nil de LS5) ⇒ **aucune** rotation. C'est ce qui garantit que
// [`ConversationLog::read`] avec `since = up_to` (fold incrémental) et la reprise restent
// corrects après rotation.
/// Seuil de rotation en **nombre de tours** du segment actif : au-delà, la tête froide
/// est archivée (lot LS6).
pub const ROTATE_AFTER_TURNS: usize = 500;
/// Seuil de rotation en **octets** du segment actif (1 MiB) : au-delà, la tête froide est
/// archivée (lot LS6). L'un OU l'autre seuil déclenche.
pub const ROTATE_AFTER_BYTES: u64 = 1024 * 1024;
/// Nombre maximum de **segments d'archive** conservés par conversation (lot LS6). Au-delà,
/// le segment d'archive **le plus ancien** est supprimé (backstop) ; jamais l'actif,
/// jamais un segment contenant un tour ≥ `up_to` (les archives n'en contiennent jamais).
pub const MAX_ARCHIVE_SEGMENTS: usize = 20;
/// Taille de page par défaut de la lecture humaine paginée (lot LS6), appliquée quand
/// l'appelant ne précise pas de limite (`limit == 0`).
pub const PAGE_DEFAULT_LIMIT: usize = 50;
/// Borne **dure** de la taille d'une page (lot LS6) : toute limite est clampée à au plus
/// cette valeur (anti-dump, la lecture humaine reste paginée).
pub const PAGE_MAX_LIMIT: usize = 200;
/// Seuils de rotation passés à [`rotation_plan`] (lot LS6). Regroupés pour garder la
/// fonction pure simple et testable avec des seuils ad hoc.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RotationThresholds {
/// Seuil en nombre de tours du segment actif (cf. [`ROTATE_AFTER_TURNS`]).
pub max_turns: usize,
/// Seuil en octets du segment actif (cf. [`ROTATE_AFTER_BYTES`]).
pub max_bytes: u64,
}
impl RotationThresholds {
/// Les seuils par défaut du projet ([`ROTATE_AFTER_TURNS`] / [`ROTATE_AFTER_BYTES`]).
#[must_use]
pub const fn defaults() -> Self {
Self {
max_turns: ROTATE_AFTER_TURNS,
max_bytes: ROTATE_AFTER_BYTES,
}
}
}
/// Statistiques **bon marché** du segment actif d'une conversation (lot LS6), servant de
/// **déclencheur** de rotation sans relire/élaguer quoi que ce soit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SegmentStats {
/// Nombre de tours (lignes valides) du segment actif.
pub active_turns: usize,
/// Taille en octets du segment actif.
pub active_bytes: u64,
}
/// Sens de pagination d'une page humaine (lot LS6).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PageDirection {
/// Vers les tours **plus récents** (postérieurs à l'ancre).
Forward,
/// Vers les tours **plus anciens** (antérieurs à l'ancre).
Backward,
}
/// Curseur d'une lecture paginée (lot LS6).
///
/// `anchor = None` ⇒ on part d'un **bout** du fil : `Forward` ⇒ le tout début (plus
/// anciens), `Backward` ⇒ la toute fin (plus récents). `anchor = Some(id)` ⇒ on pagine
/// **strictement** autour de ce tour, dans la `direction` donnée.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PageCursor {
/// Le tour pivot, ou `None` pour partir d'un bout du fil.
pub anchor: Option<TurnId>,
/// Le sens de progression.
pub direction: PageDirection,
}
/// Une tranche paginée de tours (lot LS6), **toujours en ordre chronologique croissant**.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TurnSlice {
/// Les tours de la page, du plus ancien au plus récent.
pub turns: Vec<ConversationTurn>,
/// `true` s'il existe d'autres tours **dans le sens de progression** au-delà de la page.
pub has_more: bool,
}
/// La décision **pure** de rotation calculée par [`rotation_plan`] (lot LS6).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RotationDecision {
/// Ne rien faire (sous les seuils, ou pas de plancher `up_to`).
Skip,
/// Archiver la tête froide, en **gardant** tout à partir de `keep_from` dans l'actif.
Archive {
/// Le plancher : premier tour conservé dans l'actif (= `up_to` du handoff). Tous
/// les tours **strictement antérieurs** sont archivés ; `keep_from` et ses
/// postérieurs restent actifs (INV-LS6).
keep_from: TurnId,
},
}
/// Décide **purement** s'il faut archiver la tête froide du segment actif (lot LS6).
///
/// - `floor = None` ⇒ [`RotationDecision::Skip`] : sans plancher `up_to` (pas de handoff),
/// on ne rote jamais (INV-LS6). De même si `floor` est la **sentinelle nil** de LS5
/// (`up_to` d'un handoff vide) : rien à garder en aval de façon fiable ⇒ `Skip`.
/// - Sous **les deux** seuils ⇒ `Skip`.
/// - Au-dessus de **l'un** des seuils ⇒ [`RotationDecision::Archive`] avec
/// `keep_from = floor` : `keep_from` n'est **jamais** antérieur à `up_to` (il **est**
/// `up_to`), donc l'invariant pivot est respecté par construction.
///
/// Pure et idempotente (mêmes entrées ⇒ même sortie ; aucune I/O).
#[must_use]
pub fn rotation_plan(
active_turns: usize,
active_bytes: u64,
floor: Option<TurnId>,
thresholds: RotationThresholds,
) -> RotationDecision {
// Pas de plancher, ou plancher = sentinelle nil (handoff vide LS5) ⇒ jamais de rotation.
let Some(keep_from) = floor else {
return RotationDecision::Skip;
};
if keep_from.as_uuid().is_nil() {
return RotationDecision::Skip;
}
let over_turns = active_turns > thresholds.max_turns;
let over_bytes = active_bytes > thresholds.max_bytes;
if over_turns || over_bytes {
RotationDecision::Archive { keep_from }
} else {
RotationDecision::Skip
}
}
/// Clampe une taille de page demandée dans `[1, PAGE_MAX_LIMIT]` (lot LS6), en appliquant
/// [`PAGE_DEFAULT_LIMIT`] quand l'appelant ne précise rien (`limit == 0`). Pure/testable.
#[must_use]
pub fn clamp_page_limit(limit: usize) -> usize {
let limit = if limit == 0 {
PAGE_DEFAULT_LIMIT
} else {
limit
};
limit.clamp(1, PAGE_MAX_LIMIT)
}
/// Le port d'**archivage & pagination** du log d'une conversation (port driven, lot LS6).
///
/// Complète [`ConversationLog`] (append/read/last, **inchangé**) sans le modifier : la
/// rotation est une opération **hors chemin chaud** (jamais déclenchée par un `append`) et
/// la pagination est une lecture **humaine** archive-aware. Implémenté par le **même**
/// adapter FS que [`ConversationLog`] (il détient déjà les fichiers).
///
/// `#[async_trait]` + erreur [`StoreError`] comme les ports voisins ; injecté en
/// `Arc<dyn ConversationArchive>` au composition root, gardé object-safe.
#[async_trait::async_trait]
pub trait ConversationArchive: Send + Sync {
/// Statistiques bon marché du segment actif (déclencheur de rotation).
///
/// # Errors
/// [`StoreError`] en cas d'échec de lecture.
async fn stats(&self, conversation: ConversationId) -> Result<SegmentStats, StoreError>;
/// Archive la tête froide en **gardant** tout à partir de `keep_from` dans l'actif
/// (INV-LS6). Idempotente ; ne perd jamais de tour ; n'archive jamais un tour
/// `≥ keep_from`. No-op si `keep_from` est absent de l'actif (déjà roté/inconnu).
///
/// # Errors
/// [`StoreError`] en cas d'échec d'I/O.
async fn rotate(
&self,
conversation: ConversationId,
keep_from: TurnId,
) -> Result<(), StoreError>;
/// Lit une **page humaine** (archive-aware), toujours en ordre chronologique croissant,
/// selon le `cursor` et la `limit` (clampée `[1, PAGE_MAX_LIMIT]`).
///
/// # Errors
/// [`StoreError`] en cas d'échec de lecture.
async fn page(
&self,
conversation: ConversationId,
cursor: PageCursor,
limit: usize,
) -> Result<TurnSlice, StoreError>;
}
#[cfg(test)]
mod tests {
use super::*;
@ -483,6 +820,176 @@ mod tests {
// Port contract — via the in-memory double
// ---------------------------------------------------------------------
// ---------------------------------------------------------------------
// bound_handoff_summary (lot LS5) — borne universelle du summary_md
// ---------------------------------------------------------------------
fn handoff(summary: &str) -> Handoff {
Handoff::new(summary, turn_id(1), Some("garder l'objectif".to_owned()))
}
#[test]
fn bound_under_limit_is_unchanged() {
let h = handoff("**Objectif :** x\n\n- **Prompt:** a\n- **Response:** b");
let bounded = bound_handoff_summary(h.clone(), HANDOFF_SUMMARY_MAX_CHARS);
assert_eq!(bounded, h, "sous la borne ⇒ inchangé (coût nul)");
}
#[test]
fn bound_preserves_objective_and_cursor_and_drops_oldest_turns() {
// 10 lignes de tours ; borne minuscule ⇒ on garde l'objectif + le suffixe récent.
let mut s = String::from("**Objectif :** garder ceci");
for i in 0..10 {
s.push_str(&format!("\n\n"));
s.push_str(&format!("- **Prompt:** tour-{i}"));
}
// Reconstruire au bon format (objectif + lignes jointes par \n).
let summary = {
let mut out = String::from("**Objectif :** garder ceci\n\n");
let lines: Vec<String> = (0..10).map(|i| format!("- **Prompt:** tour-{i}")).collect();
out.push_str(&lines.join("\n"));
out
};
let h = Handoff::new(summary, turn_id(7), Some("garder ceci".to_owned()));
let bounded = bound_handoff_summary(h.clone(), 60);
assert!(bounded.summary_md.chars().count() <= 60);
// Objectif + curseur jamais modifiés.
assert_eq!(bounded.objective, h.objective);
assert_eq!(bounded.up_to, h.up_to);
assert!(bounded.summary_md.contains("**Objectif :** garder ceci"));
// Les plus récents survivent, les plus anciens sont droppés.
assert!(
bounded.summary_md.contains("tour-9"),
"le plus récent reste"
);
assert!(
!bounded.summary_md.contains("tour-0"),
"le plus ancien droppé"
);
// Chaque ligne de tour conservée reste reparsable.
for line in bounded.summary_md.lines().filter(|l| l.starts_with("- **")) {
assert!(line.starts_with("- **"));
}
}
#[test]
fn bound_hard_truncates_a_single_oversize_line_keeping_prefix() {
let long = "x".repeat(500);
let summary = format!("- **Response:** {long}");
let h = Handoff::new(summary, turn_id(3), None);
let bounded = bound_handoff_summary(h, 60);
assert!(bounded.summary_md.chars().count() <= 60);
assert!(
bounded.summary_md.starts_with("- **Response:** "),
"préfixe de tour intact ⇒ reparse valide : {}",
bounded.summary_md
);
}
#[test]
fn bound_is_idempotent() {
let summary = {
let mut out = String::from("**Objectif :** garder ceci\n\n");
let lines: Vec<String> = (0..30)
.map(|i| {
format!(
"- **Prompt:** un tour relativement long numero {i} {}",
"y".repeat(50)
)
})
.collect();
out.push_str(&lines.join("\n"));
out
};
let h = Handoff::new(summary, turn_id(9), Some("garder ceci".to_owned()));
let once = bound_handoff_summary(h, 200);
let twice = bound_handoff_summary(once.clone(), 200);
assert_eq!(once, twice, "bound(bound(x)) == bound(x)");
assert!(once.summary_md.chars().count() <= 200);
}
// ---------------------------------------------------------------------
// rotation_plan + clamp_page_limit (lot LS6) — fonctions pures
// ---------------------------------------------------------------------
#[test]
fn rotation_plan_skips_without_floor() {
// Largement au-dessus des seuils mais pas de plancher ⇒ jamais de rotation.
let d = rotation_plan(10_000, 10 << 20, None, RotationThresholds::defaults());
assert_eq!(d, RotationDecision::Skip);
}
#[test]
fn rotation_plan_skips_with_nil_sentinel_floor() {
let nil = TurnId::from_uuid(uuid::Uuid::nil());
let d = rotation_plan(10_000, 10 << 20, Some(nil), RotationThresholds::defaults());
assert_eq!(
d,
RotationDecision::Skip,
"sentinelle nil ⇒ pas de rotation"
);
}
#[test]
fn rotation_plan_skips_under_both_thresholds() {
let floor = turn_id(7);
let d = rotation_plan(10, 1_000, Some(floor), RotationThresholds::defaults());
assert_eq!(d, RotationDecision::Skip);
}
#[test]
fn rotation_plan_archives_over_either_threshold_keeping_floor() {
let floor = turn_id(7);
// Au-dessus du seuil de tours seulement.
assert_eq!(
rotation_plan(
ROTATE_AFTER_TURNS + 1,
0,
Some(floor),
RotationThresholds::defaults()
),
RotationDecision::Archive { keep_from: floor }
);
// Au-dessus du seuil d'octets seulement.
assert_eq!(
rotation_plan(
0,
ROTATE_AFTER_BYTES + 1,
Some(floor),
RotationThresholds::defaults()
),
RotationDecision::Archive { keep_from: floor }
);
}
#[test]
fn rotation_plan_is_idempotent() {
let floor = turn_id(7);
let inputs = (ROTATE_AFTER_TURNS + 5, ROTATE_AFTER_BYTES + 5);
let once = rotation_plan(
inputs.0,
inputs.1,
Some(floor),
RotationThresholds::defaults(),
);
let twice = rotation_plan(
inputs.0,
inputs.1,
Some(floor),
RotationThresholds::defaults(),
);
assert_eq!(once, twice);
}
#[test]
fn clamp_page_limit_applies_default_and_bounds() {
assert_eq!(clamp_page_limit(0), PAGE_DEFAULT_LIMIT, "0 ⇒ défaut");
assert_eq!(clamp_page_limit(1), 1);
assert_eq!(clamp_page_limit(50), 50);
assert_eq!(clamp_page_limit(10_000), PAGE_MAX_LIMIT, "clampé au max");
}
#[tokio::test]
async fn append_then_read_all_preserves_insertion_order() {
let log = InMemoryConversationLog::default();

View File

@ -258,6 +258,19 @@ pub enum DomainEvent {
/// sinon `None` (l'utilisateur fournira l'heure).
resets_at_ms: Option<i64>,
},
/// The project's **orchestrator designation** changed (cadrage « orchestrateur
/// du projet », T1). Emitted when an agent is designated (radio selection) or the
/// designation is cleared back to the default (e.g. the designated agent was
/// deleted — lazy succession to the oldest agent). Relayed to the front so the UI
/// can move the radio / refresh the orchestrator badge. Model-agnostic: carries
/// only the neutral fact « who orchestrates now ».
OrchestratorChanged {
/// The project whose designation changed.
project_id: ProjectId,
/// The newly designated orchestrator agent, or `None` for the default
/// (the oldest agent orchestrates).
orchestrator: Option<AgentId>,
},
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
PtyOutput {
/// The session.
@ -358,6 +371,30 @@ mod tests {
);
}
#[test]
fn orchestrator_changed_constructs_and_compares() {
let project = ProjectId::from_uuid(uuid::Uuid::from_u128(100));
let ev = DomainEvent::OrchestratorChanged {
project_id: project,
orchestrator: Some(agent(1)),
};
assert_eq!(
ev,
DomainEvent::OrchestratorChanged {
project_id: project,
orchestrator: Some(agent(1)),
}
);
// Clearing to the default (None) is a distinct event.
assert_ne!(
ev,
DomainEvent::OrchestratorChanged {
project_id: project,
orchestrator: None,
}
);
}
#[test]
fn distinct_session_limit_variants_are_not_equal() {
// Les variantes ne se confondent pas entre elles malgré des champs proches.

View File

@ -159,30 +159,71 @@ pub trait FileGuard: Send + Sync {
) -> Result<WriteLease, GuardError>;
}
/// **Which agent (if any) the project designates as its orchestrator.**
///
/// We persist only the *deviation* from the default (cadrage T1, « orchestrateur du
/// projet ») : the human ([`ConversationParty::User`]) is a **permanent** orchestrator
/// and is never represented here.
/// - [`none`](Self::none) — no agent designated: the human is the sole orchestrator.
/// - [`of`](Self::of) — agent `id` is the explicitly designated orchestrator (radio
/// selection, single agent by construction — the illegal "two orchestrators" state
/// is unrepresentable).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OrchestratorDesignation(Option<AgentId>);
impl OrchestratorDesignation {
/// No agent designated: only the human ([`ConversationParty::User`]) orchestrates.
#[must_use]
pub const fn none() -> Self {
Self(None)
}
/// Designates `agent` as the project's orchestrator (radio selection).
#[must_use]
pub const fn of(agent: AgentId) -> Self {
Self(Some(agent))
}
/// The designated agent, if any. `None` ⇒ default (human-only orchestrator).
#[must_use]
pub const fn designated(&self) -> Option<AgentId> {
self.0
}
}
/// Whether `who` is allowed to **write** `res` directly (vs. having to propose).
///
/// Pure policy, shared by the port's documented contract and the adapter: only the
/// Pure policy, shared by the port's documented contract and the adapter: only an
/// orchestrator may write the global [`GuardedResource::ProjectContext`]; everyone
/// may write the per-agent context and memory. The orchestrator is modelled as
/// [`ConversationParty::User`] — IdeA's single logical operator identity (the
/// human-driven orchestrator), never a project [`AgentId`].
/// may write the per-agent context and memory. Orchestrator identity is resolved
/// against the project's [`OrchestratorDesignation`] (the human is always one; a
/// project agent only when explicitly designated).
#[must_use]
pub fn may_write_directly(who: ConversationParty, res: &GuardedResource) -> bool {
pub fn may_write_directly(
who: ConversationParty,
res: &GuardedResource,
d: &OrchestratorDesignation,
) -> bool {
if res.is_project_context() {
is_orchestrator(who)
is_orchestrator(who, d)
} else {
true
}
}
/// Whether `who` is the orchestrator identity for the single-writer rule.
/// Whether `who` is an orchestrator identity for the single-writer rule, given the
/// project's [`OrchestratorDesignation`] `d`.
///
/// The orchestrator is the human-driven operator ([`ConversationParty::User`]); a
/// project agent ([`ConversationParty::Agent`]) is never the orchestrator and must
/// propose changes to the global context.
/// The human-driven operator ([`ConversationParty::User`]) is **always** an
/// orchestrator. A project agent ([`ConversationParty::Agent`]) is an orchestrator
/// **only** when it is the designated one; otherwise it must propose changes to the
/// global context.
#[must_use]
pub const fn is_orchestrator(who: ConversationParty) -> bool {
matches!(who, ConversationParty::User)
pub fn is_orchestrator(who: ConversationParty, d: &OrchestratorDesignation) -> bool {
match who {
ConversationParty::User => true,
ConversationParty::Agent { agent_id } => d.designated() == Some(agent_id),
}
}
#[cfg(test)]
@ -195,13 +236,41 @@ mod tests {
#[test]
fn project_context_is_single_writer_to_orchestrator_only() {
// Single-writer rule preserved with the default (no designated agent):
// the human writes, every agent is refused.
let default = OrchestratorDesignation::none();
assert!(may_write_directly(
ConversationParty::User,
&GuardedResource::ProjectContext
&GuardedResource::ProjectContext,
&default,
));
assert!(!may_write_directly(
agent_party(1),
&GuardedResource::ProjectContext
&GuardedResource::ProjectContext,
&default,
));
}
#[test]
fn designated_agent_may_write_project_context() {
let id = AgentId::from_uuid(uuid::Uuid::from_u128(1));
let designation = OrchestratorDesignation::of(id);
// The designated agent now writes the global context directly…
assert!(may_write_directly(
ConversationParty::agent(id),
&GuardedResource::ProjectContext,
&designation,
));
// …while another agent stays refused, and the human stays allowed.
assert!(!may_write_directly(
agent_party(2),
&GuardedResource::ProjectContext,
&designation,
));
assert!(may_write_directly(
ConversationParty::User,
&GuardedResource::ProjectContext,
&designation,
));
}
@ -209,16 +278,34 @@ mod tests {
fn agent_context_and_memory_are_writable_by_anyone() {
let mem = GuardedResource::Memory(MemorySlug::new("note").unwrap());
let ctx = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(9)));
let default = OrchestratorDesignation::none();
for who in [ConversationParty::User, agent_party(1)] {
assert!(may_write_directly(who, &mem));
assert!(may_write_directly(who, &ctx));
assert!(may_write_directly(who, &mem, &default));
assert!(may_write_directly(who, &ctx, &default));
}
}
#[test]
fn is_orchestrator_only_for_user() {
assert!(is_orchestrator(ConversationParty::User));
assert!(!is_orchestrator(agent_party(1)));
fn is_orchestrator_for_user_and_designated_agent() {
let id = AgentId::from_uuid(uuid::Uuid::from_u128(1));
// Human is always an orchestrator, designation or not.
assert!(is_orchestrator(
ConversationParty::User,
&OrchestratorDesignation::none()
));
// An agent is an orchestrator only when it is the designated one.
assert!(!is_orchestrator(
ConversationParty::agent(id),
&OrchestratorDesignation::none()
));
assert!(is_orchestrator(
ConversationParty::agent(id),
&OrchestratorDesignation::of(id)
));
assert!(!is_orchestrator(
agent_party(2),
&OrchestratorDesignation::of(id)
));
}
#[test]

View File

@ -173,32 +173,22 @@ pub trait InputMediator: Send + Sync {
/// own the delivery write).
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
/// Like [`InputMediator::bind_handle`], but also arms **prompt-ready detection**
/// (cadrage §6, lot C5): `prompt_ready_pattern` is the agent profile's optional
/// literal marker (`AgentProfile::prompt_ready_pattern`). When `Some`, the mediator
/// watches the bound handle's output stream and calls [`InputMediator::mark_idle`]
/// the first time the marker appears (one of the two OR signals; the other is an
/// explicit `idea_reply`). When `None`, no pattern detection is armed — the agent
/// only returns `Idle` on the explicit signal or the per-turn timeout (fallback
/// «en cas de doute → reste Busy mais la file accepte»; never a false `Idle`).
///
/// It also records the target's [`SubmitConfig`] (ARCHITECTURE §20.3) so the
/// adapter can echo `submit_sequence`/`submit_delay_ms` on the
/// Like [`InputMediator::bind_handle`], but also records the target's
/// [`SubmitConfig`] (ARCHITECTURE §20.3) so the adapter can echo
/// `submit_sequence`/`submit_delay_ms` on the
/// [`crate::events::DomainEvent::DelegationReady`] published when a turn starts.
/// The bind is the natural carrier: both the prompt pattern and the submit config
/// are per-agent profile data the orchestrator resolves at the same time, so they
/// travel together (no extra ticket field, no second resolve).
/// The bind is the natural carrier: the submit config is per-agent profile data the
/// orchestrator resolves at bind time, so it travels with the handle (no extra
/// ticket field, no second resolve).
///
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern and
/// submit config (a mediator that does not observe the output stream). The infra
/// adapter overrides it to arm the watcher and stash the submit config.
fn bind_handle_with_prompt(
&self,
agent: AgentId,
handle: PtyHandle,
_prompt_ready_pattern: Option<String>,
_submit: SubmitConfig,
) {
/// Turn-end detection is **no longer** armed here: the dead PTY prompt-ready sniff
/// was replaced by the transcript [`crate::ports::TurnWatcher`] (armed at the
/// composition root, once per live session) which calls
/// [`InputMediator::turn_ended`].
///
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the submit config.
/// The infra adapter overrides it to stash the submit config.
fn bind_handle_with_submit(&self, agent: AgentId, handle: PtyHandle, _submit: SubmitConfig) {
self.bind_handle(agent, handle);
}
@ -212,12 +202,12 @@ pub trait InputMediator: Send + Sync {
}
/// Déclare qu'`agent` vient d'être **lancé à froid** : la livraison de son tout
/// premier tour doit être *gatée* sur le prompt-ready (la `DelegationReady` est
/// différée jusqu'à l'apparition du prompt du CLI), pour éviter d'écrire la tâche
/// premier tour doit être *gatée* sur la readiness MCP (la `DelegationReady` est
/// différée jusqu'à la connexion du pont MCP du CLI), pour éviter d'écrire la tâche
/// avant que le CLI ait fini de booter (premier tour perdu sinon).
///
/// À n'appeler **que** lorsqu'un prompt-ready watcher sera effectivement armé (le
/// profil porte un `prompt_ready_pattern` non vide). Sans watcher pour le libérer,
/// À n'appeler **que** lorsqu'un signal de readiness le libérera (le profil porte un
/// pont MCP ⇒ [`InputMediator::release_cold_start`]). Sans signal pour le libérer,
/// gater le premier tour le bloquerait indéfiniment ; dans ce cas l'orchestrateur ne
/// doit **pas** appeler `mark_starting` et l'`enqueue` livre la tâche immédiatement
/// (chemin chaud, fallback sûr, zéro régression).
@ -229,10 +219,10 @@ pub trait InputMediator: Send + Sync {
/// connecter (son CLI est up et a chargé les outils `idea_*`). Si un premier tour
/// a été différé par [`InputMediator::mark_starting`] (démarrage à froid), c'est le
/// moment de le livrer ⇒ draine le `DelegationReady` retenu. **Contrairement à
/// `prompt_ready`, aucun repli `mark_idle`** : c'est un signal de DÉMARRAGE, pas de
/// fin de tour. Idempotent : un second appel (ou après que `prompt_ready` ait déjà
/// drainé) ne trouve rien et est un no-op. En OR avec le prompt-ready : le premier
/// arrivé draine, l'autre est no-op.
/// `turn_ended`, aucun repli `mark_idle`** : c'est un signal de DÉMARRAGE, pas de
/// fin de tour. Idempotent : un second appel ne trouve rien et est un no-op. C'est
/// désormais le **seul** drain du tour différé (le signal MCP-initialize), le
/// watcher prompt-ready PTY ayant été supprimé.
///
/// Default: no-op (médiateur qui ne gate pas les démarrages à froid).
fn release_cold_start(&self, _agent: AgentId) {}
@ -254,9 +244,24 @@ pub trait InputMediator: Send + Sync {
/// is **not** an enqueue and correlates **no** ticket.
fn preempt(&self, agent: AgentId);
/// Marks `agent` free (prompt-ready or explicit signal) so its FIFO advances.
/// Marks `agent` free (explicit signal) so its FIFO advances.
fn mark_idle(&self, agent: AgentId);
/// **End-of-turn** signal: the agent's transcript just recorded a completed turn
/// (the [`crate::ports::TurnWatcher`] fired), and **no** `idea_reply` carried a
/// result. This is the no-reply backstop trigger: the adapter captures the active
/// ticket, marks the agent `Idle` (advancing the FIFO), then arms a short **grace**
/// window — if no `idea_reply` correlates the ticket by then, it completes the turn
/// "without reply" (waking a parked caller with a typed error instead of leaving it
/// blocked until the long timeout). A late `idea_reply` within the grace wins.
///
/// Replaces the former prompt-ready watcher branch verbatim; only the **trigger**
/// changed (transcript `turn_duration` instead of a PTY prompt sigil). Default:
/// [`InputMediator::mark_idle`] (a mediator with no mailbox/grace just advances).
fn turn_ended(&self, agent: AgentId) {
self.mark_idle(agent);
}
/// Records a **proof of liveness** (« battement ») for `agent` — called on every
/// non-terminal turn event (text delta, tool activity, [`crate::ports::ReplyEvent::Heartbeat`])
/// by the drain loop. Refreshes the per-agent `last_seen` timestamp so the stall

View File

@ -40,18 +40,20 @@ pub mod git;
pub mod ids;
pub mod input;
pub mod layout;
pub mod live_state;
pub mod mailbox;
pub mod markdown;
pub mod memory;
pub mod memory_harvest;
pub mod orchestrator;
pub mod permission;
pub mod ports;
pub mod profile;
pub mod project;
pub mod readiness;
pub mod remote;
pub mod sandbox;
pub mod session_limit;
pub mod remote;
pub mod skill;
pub mod template;
pub mod terminal;
@ -82,7 +84,10 @@ pub use profile::{
McpServerWiring, RateLimitPattern, SessionStrategy,
};
pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
pub use mailbox::{
AgentMailbox, AgentQueueSnapshot, MailboxError, PendingReply, QueuedTicketSnapshot, Ticket,
TicketId,
};
pub use conversation::{
Conversation, ConversationError, ConversationId, ConversationParty, ConversationRegistry,
@ -91,24 +96,34 @@ pub use conversation::{
pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource};
pub use live_state::{LiveEntry, LiveState, WorkStatus, FIELD_MAX_BYTES, FIELD_PREVIEW_MAX_CHARS};
pub use readiness::{ReadinessPolicy, ReadinessSignal};
pub use session_limit::{plan_resume, ResumePlan, RateLimitSource, SessionLimit};
pub use session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit};
pub use conversation_log::{
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
ProviderSessionStore, TurnId, TurnRole,
bound_handoff_summary, clamp_page_limit, rotation_plan, ConversationArchive, ConversationLog,
ConversationTurn, Handoff, HandoffStore, HandoffSummarizer, PageCursor, PageDirection,
ProviderSessionStore, RotationDecision, RotationThresholds, SegmentStats, TurnId, TurnRole,
TurnSlice, HANDOFF_SUMMARY_MAX_CHARS, MAX_ARCHIVE_SEGMENTS, PAGE_DEFAULT_LIMIT, PAGE_MAX_LIMIT,
ROTATE_AFTER_BYTES, ROTATE_AFTER_TURNS,
};
pub use fileguard::{
is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease,
WriteLease,
is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource,
OrchestratorDesignation, ReadLease, WriteLease,
};
pub use markdown::MarkdownDoc;
pub use memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType};
pub use memory_harvest::{
parse_memory_directives, MemoryHarvestDirective, MemoryHarvestReport, MAX_BLOCKS,
MAX_BLOCK_BYTES, MAX_DESCRIPTION_CHARS,
};
pub use remote::{RemoteKind, RemoteRef, SshAuth};
pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
@ -126,7 +141,7 @@ pub use permission::{
render_permission_summary, resolve as resolve_permissions, AgentPermissionOverride, Capability,
CommandMatcher, CommandRule, Effect, EffectivePermissions, Glob, PathScope, PermissionError,
PermissionProjection, PermissionProjector, PermissionRule, PermissionSet, Posture,
ProjectedFile, ProjectionContext, ProjectPermissions, ProjectorKey, PERMISSIONS_VERSION,
ProjectPermissions, ProjectedFile, ProjectionContext, ProjectorKey, PERMISSIONS_VERSION,
};
pub use sandbox::{
@ -143,8 +158,8 @@ pub use ports::{
EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderProfileStore,
EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem,
FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator,
MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore,
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle,
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec,
StoreError, TemplateStore,
LiveStateStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream,
PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore,
PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask,
Scheduler, SpawnSpec, StoreError, TemplateStore,
};

View File

@ -0,0 +1,391 @@
//! Agent **live-state** — the volatile, "what is each agent doing right now"
//! projection (programme live-state, lot LS1).
//!
//! Unlike the conversation log (an append-only journal) this is a **keyed,
//! last-writer-wins** snapshot: there is exactly **one** [`LiveEntry`] per
//! [`AgentId`], replaced in place on every update. There is deliberately **no
//! append API** — stacking duplicate rows per agent is an architectural
//! anti-pattern here (it would turn a live snapshot back into a journal). The
//! only mutations are [`LiveState::upsert`] (keyed replace-or-insert) and
//! [`LiveState::prune`] (TTL + cardinality bound).
//!
//! The type is **pure** (zero I/O). Persistence is the job of the
//! [`crate::ports::LiveStateStore`] port, implemented by infrastructure in a
//! later lot.
use serde::{Deserialize, Serialize};
use crate::error::DomainError;
use crate::ids::AgentId;
use crate::mailbox::TicketId;
/// Soft bound (in characters) applied to free-text fields (`intent`,
/// `progress`): values longer than this are **truncated**, not rejected. Aligned
/// with the workstate UI task preview (`TASK_PREVIEW_MAX_CHARS`, ≈160 chars) so
/// the live state and its rendering agree on excerpt length.
pub const FIELD_PREVIEW_MAX_CHARS: usize = 160;
/// Hard anti-dump threshold (in **bytes**) for any single free-text field. A
/// value above this is **rejected** with [`DomainError::Invariant`] rather than
/// silently truncated: the soft bound trims ordinary over-long lines, while this
/// guards against a caller dumping long content (file bodies, logs) into the
/// live state. Distinct semantics: soft = truncate, hard = reject.
pub const FIELD_MAX_BYTES: usize = 2 * 1024;
/// What an agent is doing right now, at a glance.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WorkStatus {
/// No active task — available.
Idle,
/// Actively working on its current `intent`.
Working,
/// Stuck / cannot proceed (e.g. needs a decision or input).
Blocked,
/// Suspended awaiting an external event (e.g. a delegation reply).
Waiting,
/// Current task finished.
Done,
}
impl WorkStatus {
/// Parses a status label (case-insensitive) into a [`WorkStatus`].
///
/// Accepts exactly the five canonical labels (`idle`, `working`, `blocked`,
/// `waiting`, `done`), ignoring surrounding whitespace and ASCII case. Returns
/// `None` for anything else, so the caller can raise a typed error rather than
/// guess a default.
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"idle" => Some(Self::Idle),
"working" => Some(Self::Working),
"blocked" => Some(Self::Blocked),
"waiting" => Some(Self::Waiting),
"done" => Some(Self::Done),
_ => None,
}
}
/// The canonical, human-readable label of this status (the inverse of
/// [`WorkStatus::parse`]): `idle`, `working`, `blocked`, `waiting` or `done`.
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Working => "working",
Self::Blocked => "blocked",
Self::Waiting => "waiting",
Self::Done => "done",
}
}
}
/// A single agent's live-state row. One per [`AgentId`] in a [`LiveState`].
///
/// Construct via [`LiveEntry::new`], which enforces the field invariants
/// (soft-truncation of `intent`/`progress`, hard rejection of oversize input).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveEntry {
/// The agent this row describes (the keyed identity for last-writer-wins).
pub agent_id: AgentId,
/// The ticket the agent is currently handling, if any.
pub ticket: Option<TicketId>,
/// Short human-readable description of the current intent (soft-bounded).
pub intent: String,
/// Coarse status at a glance.
pub status: WorkStatus,
/// Optional finer-grained progress note (soft-bounded).
pub progress: Option<String>,
/// The ticket of the most recent delegation this agent issued, if any.
pub last_delegation: Option<TicketId>,
/// Wall-clock update time in epoch milliseconds (drives `prune` ordering).
pub updated_at_ms: u64,
}
impl LiveEntry {
/// Builds a validated live-state entry.
///
/// `intent` and `progress` are bounded: each is **rejected** if it exceeds
/// the hard [`FIELD_MAX_BYTES`] anti-dump threshold, otherwise **truncated**
/// to [`FIELD_PREVIEW_MAX_CHARS`] characters (never mid-codepoint).
///
/// # Errors
/// [`DomainError::Invariant`] if `intent` or `progress` exceeds
/// [`FIELD_MAX_BYTES`] bytes.
pub fn new(
agent_id: AgentId,
ticket: Option<TicketId>,
intent: impl Into<String>,
status: WorkStatus,
progress: Option<String>,
last_delegation: Option<TicketId>,
updated_at_ms: u64,
) -> Result<Self, DomainError> {
let intent = bound_text("intent", intent.into())?;
let progress = progress.map(|p| bound_text("progress", p)).transpose()?;
Ok(Self {
agent_id,
ticket,
intent,
status,
progress,
last_delegation,
updated_at_ms,
})
}
}
/// Enforces the field bounds: reject above the hard byte threshold, otherwise
/// truncate to the soft character bound (char-boundary safe).
fn bound_text(field: &'static str, value: String) -> Result<String, DomainError> {
if value.len() > FIELD_MAX_BYTES {
return Err(DomainError::Invariant(format!(
"live-state field `{field}` exceeds the max size of {FIELD_MAX_BYTES} bytes ({} bytes)",
value.len()
)));
}
if value.chars().count() <= FIELD_PREVIEW_MAX_CHARS {
Ok(value)
} else {
Ok(value.chars().take(FIELD_PREVIEW_MAX_CHARS).collect())
}
}
/// The live-state snapshot: one [`LiveEntry`] per agent, keyed last-writer-wins.
///
/// There is **no append API by design** (anti-journal invariant): the only
/// mutations are [`LiveState::upsert`] and [`LiveState::prune`].
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveState {
/// The current rows, at most one per [`AgentId`].
pub entries: Vec<LiveEntry>,
}
impl LiveState {
/// Inserts or **replaces** the row for `entry.agent_id` (last-writer-wins).
///
/// Never produces a duplicate row for an agent: if an entry with the same
/// `agent_id` already exists it is overwritten in place; otherwise the entry
/// is appended as a new key. (This is *keyed* upsert, not an append of
/// duplicates — see the type-level anti-journal invariant.)
pub fn upsert(&mut self, entry: LiveEntry) {
if let Some(slot) = self
.entries
.iter_mut()
.find(|e| e.agent_id == entry.agent_id)
{
*slot = entry;
} else {
self.entries.push(entry);
}
}
/// Drops entries older than `ttl_ms` relative to `now_ms`, then bounds the
/// result to `max_n`, keeping the most recently updated rows.
///
/// Age is `now_ms.saturating_sub(updated_at_ms)`; a row is kept while its
/// age is `<= ttl_ms`. After the TTL sweep, if more than `max_n` rows remain
/// they are ordered by `updated_at_ms` (most recent first, stable on ties)
/// and truncated to `max_n`.
pub fn prune(&mut self, now_ms: u64, ttl_ms: u64, max_n: usize) {
self.entries
.retain(|e| now_ms.saturating_sub(e.updated_at_ms) <= ttl_ms);
if self.entries.len() > max_n {
self.entries
.sort_by(|a, b| b.updated_at_ms.cmp(&a.updated_at_ms));
self.entries.truncate(max_n);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn aid(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn tid(n: u128) -> TicketId {
TicketId::from_uuid(uuid::Uuid::from_u128(n))
}
#[test]
fn work_status_parse_is_case_insensitive_and_rejects_unknown() {
assert_eq!(WorkStatus::parse("idle"), Some(WorkStatus::Idle));
assert_eq!(WorkStatus::parse(" Working "), Some(WorkStatus::Working));
assert_eq!(WorkStatus::parse("BLOCKED"), Some(WorkStatus::Blocked));
assert_eq!(WorkStatus::parse("waiting"), Some(WorkStatus::Waiting));
assert_eq!(WorkStatus::parse("Done"), Some(WorkStatus::Done));
assert_eq!(WorkStatus::parse("busy"), None);
assert_eq!(WorkStatus::parse(""), None);
}
#[test]
fn work_status_label_round_trips_through_parse() {
for status in [
WorkStatus::Idle,
WorkStatus::Working,
WorkStatus::Blocked,
WorkStatus::Waiting,
WorkStatus::Done,
] {
assert_eq!(WorkStatus::parse(status.label()), Some(status));
}
}
#[test]
fn new_truncates_intent_and_progress_at_soft_bound() {
let long = "x".repeat(FIELD_PREVIEW_MAX_CHARS + 50);
let entry = LiveEntry::new(
aid(1),
None,
long.clone(),
WorkStatus::Working,
Some(long.clone()),
None,
10,
)
.expect("under the hard threshold ⇒ accepted, just truncated");
assert_eq!(entry.intent.chars().count(), FIELD_PREVIEW_MAX_CHARS);
assert_eq!(
entry.progress.as_deref().map(|p| p.chars().count()),
Some(FIELD_PREVIEW_MAX_CHARS)
);
}
#[test]
fn new_keeps_short_text_verbatim() {
let entry =
LiveEntry::new(aid(1), None, "ship it", WorkStatus::Done, None, None, 5).unwrap();
assert_eq!(entry.intent, "ship it");
assert!(entry.progress.is_none());
}
#[test]
fn new_rejects_intent_over_hard_byte_threshold() {
let huge = "x".repeat(FIELD_MAX_BYTES + 1);
let err = LiveEntry::new(aid(1), None, huge, WorkStatus::Working, None, None, 0)
.expect_err("oversize intent must be rejected, not truncated");
assert!(matches!(err, DomainError::Invariant(_)));
}
#[test]
fn new_rejects_progress_over_hard_byte_threshold() {
let huge = "y".repeat(FIELD_MAX_BYTES + 1);
let err = LiveEntry::new(aid(1), None, "ok", WorkStatus::Working, Some(huge), None, 0)
.expect_err("oversize progress must be rejected, not truncated");
assert!(matches!(err, DomainError::Invariant(_)));
}
#[test]
fn upsert_is_last_writer_wins_per_agent_no_duplicates() {
let mut state = LiveState::default();
state.upsert(
LiveEntry::new(aid(1), None, "first", WorkStatus::Working, None, None, 1).unwrap(),
);
state.upsert(
LiveEntry::new(
aid(1),
Some(tid(9)),
"second",
WorkStatus::Blocked,
None,
None,
2,
)
.unwrap(),
);
// A second upsert for the same agent replaces, never appends.
assert_eq!(state.entries.len(), 1, "same agent ⇒ exactly one row");
let row = &state.entries[0];
assert_eq!(row.intent, "second");
assert_eq!(row.status, WorkStatus::Blocked);
assert_eq!(row.ticket, Some(tid(9)));
}
#[test]
fn upsert_distinct_agents_coexist() {
let mut state = LiveState::default();
state
.upsert(LiveEntry::new(aid(1), None, "a", WorkStatus::Working, None, None, 1).unwrap());
state.upsert(LiveEntry::new(aid(2), None, "b", WorkStatus::Idle, None, None, 1).unwrap());
assert_eq!(state.entries.len(), 2, "distinct agents ⇒ distinct rows");
}
#[test]
fn prune_drops_entries_older_than_ttl() {
let mut state = LiveState::default();
state.upsert(
LiveEntry::new(aid(1), None, "stale", WorkStatus::Idle, None, None, 100).unwrap(),
);
state.upsert(
LiveEntry::new(aid(2), None, "fresh", WorkStatus::Working, None, None, 900).unwrap(),
);
// now=1000, ttl=200 ⇒ age(aid1)=900 > 200 dropped; age(aid2)=100 kept.
state.prune(1000, 200, 100);
assert_eq!(state.entries.len(), 1);
assert_eq!(state.entries[0].agent_id, aid(2));
}
#[test]
fn prune_bounds_to_max_n_keeping_most_recent() {
let mut state = LiveState::default();
for n in 1..=5u128 {
state.upsert(
LiveEntry::new(
aid(n),
None,
"x",
WorkStatus::Working,
None,
None,
n as u64 * 10,
)
.unwrap(),
);
}
// All within TTL; bound to 2 ⇒ keep the two highest updated_at_ms (40,50).
state.prune(60, 1_000, 2);
assert_eq!(state.entries.len(), 2);
let kept: Vec<u64> = state.entries.iter().map(|e| e.updated_at_ms).collect();
assert_eq!(kept, vec![50, 40], "most recent first, oldest dropped");
}
#[test]
fn serde_round_trip_is_camel_case() {
let mut state = LiveState::default();
state.upsert(
LiveEntry::new(
aid(7),
Some(tid(3)),
"review PR",
WorkStatus::Waiting,
Some("waiting on QA".to_owned()),
Some(tid(4)),
1234,
)
.unwrap(),
);
let json = serde_json::to_string(&state).unwrap();
// Field names are camelCase…
assert!(json.contains("\"agentId\""));
assert!(json.contains("\"lastDelegation\""));
assert!(json.contains("\"updatedAtMs\""));
// …and the enum variant is camelCase too.
assert!(json.contains("\"waiting\""));
let back: LiveState = serde_json::from_str(&json).unwrap();
assert_eq!(back, state, "round-trip is lossless");
}
}

View File

@ -33,6 +33,47 @@ use crate::conversation::ConversationId;
use crate::ids::AgentId;
use crate::input::InputSource;
/// A read-only, cloned view of one queued [`Ticket`] in a target agent's FIFO.
///
/// Carries **only the data** of a ticket (never the one-shot reply sender, which
/// stays inside the adapter), plus its `position` in the FIFO at snapshot time
/// (`0` = head). Pure value object used by the [`AgentQueueSnapshot`] read port so
/// the work-state read model can list pending delegations without touching the
/// mutating [`AgentMailbox`] surface (ISP).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueuedTicketSnapshot {
/// Stable id of the queued ticket.
pub id: TicketId,
/// The origin of the input (Human or a delegating Agent).
pub source: InputSource,
/// The conversation thread this task enters.
pub conversation: ConversationId,
/// Display name (or id) of the requester.
pub requester: String,
/// The full task/message text (preview/truncation is the caller's concern).
pub task: String,
/// Position in the FIFO at snapshot time (`0` = head, recomputed each call).
pub position: u32,
}
/// Read-only inspection port over the per-agent delegation FIFO.
///
/// Segregated from the mutating [`AgentMailbox`] (Interface Segregation): the
/// work-state read model depends on this port to *observe* the queue, never on
/// `enqueue`/`resolve`/`cancel`. Implementations return **cloned** snapshots in
/// FIFO order with recomputed positions; observing the queue must never mutate it.
///
/// Object-safe (`&self`) so the application layer holds it as
/// `Arc<dyn AgentQueueSnapshot>`; one concrete `InMemoryMailbox` can be shared as
/// both an [`AgentMailbox`] (mutation) and an [`AgentQueueSnapshot`] (read) view.
pub trait AgentQueueSnapshot: Send + Sync {
/// Returns a cloned, FIFO-ordered snapshot of `agent`'s queued tickets.
///
/// An agent with no queue yields an empty `Vec`. Positions are recomputed from
/// the current order (`0` = head). Pure read: the queue is left untouched.
fn queue_for(&self, agent: AgentId) -> Vec<QueuedTicketSnapshot>;
}
/// Identifies one queued [`Ticket`] within a target agent's mailbox.
///
/// Newtype around [`uuid::Uuid`]; minted by the adapter on `enqueue`. It is **never
@ -150,6 +191,28 @@ impl Ticket {
}
}
/// The outcome of a delegated turn, delivered to the awaiting [`PendingReply`].
///
/// A turn ends in **exactly one** of two ways (besides the channel closing, which is
/// [`MailboxError::Cancelled`]):
/// - [`TurnResolution::Replied`] — the target rendered a result via `idea_reply`
/// ([`AgentMailbox::resolve`] / [`AgentMailbox::resolve_ticket`]). The normal
/// success: the caller receives the answer inline.
/// - [`TurnResolution::ReturnedToPromptNoReply`] — the target **finished its turn and
/// returned to its prompt without calling `idea_reply`**. The rendezvous can produce
/// no payload, so instead of leaving the caller blocked until the long safety
/// timeout, the mailbox wakes it with this variant
/// ([`AgentMailbox::complete_without_reply`]) so the application layer can surface a
/// clear, retryable error promptly.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TurnResolution {
/// The target rendered a result via `idea_reply`. Carries the answer.
Replied(String),
/// The target returned to its prompt (turn ended) **without** an `idea_reply`.
/// No payload exists; the caller must surface a typed, retryable error.
ReturnedToPromptNoReply,
}
/// Errors raised by the [`AgentMailbox`] port.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MailboxError {
@ -165,28 +228,32 @@ pub enum MailboxError {
Cancelled,
}
/// A handle the caller awaits to receive a target agent's reply.
/// A handle the caller awaits to receive a target agent's turn outcome.
///
/// Returned by [`AgentMailbox::enqueue`]. Awaiting it yields the `String` result a
/// later [`AgentMailbox::resolve`] feeds into this ticket's slot, or
/// [`MailboxError::Cancelled`] if the reply channel closes first. The future is
/// **opaque** (the adapter builds it from its one-shot receiver): the domain stays
/// free of any concrete channel type, and the await point lives in the application
/// layer's `ask_agent`.
/// Returned by [`AgentMailbox::enqueue`]. Awaiting it yields a [`TurnResolution`] (a
/// [`TurnResolution::Replied`] result fed by a later [`AgentMailbox::resolve`], or a
/// [`TurnResolution::ReturnedToPromptNoReply`] fed by
/// [`AgentMailbox::complete_without_reply`] when the target returns to its prompt
/// without replying), or [`MailboxError::Cancelled`] if the reply channel closes
/// first. The future is **opaque** (the adapter builds it from its one-shot
/// receiver): the domain stays free of any concrete channel type, and the await point
/// lives in the application layer's `ask_agent`.
pub struct PendingReply {
inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>,
inner: Pin<Box<dyn Future<Output = Result<TurnResolution, MailboxError>> + Send>>,
}
impl PendingReply {
/// Wraps a reply future built by the adapter (e.g. over a one-shot receiver).
#[must_use]
pub fn new(inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>) -> Self {
pub fn new(
inner: Pin<Box<dyn Future<Output = Result<TurnResolution, MailboxError>> + Send>>,
) -> Self {
Self { inner }
}
}
impl Future for PendingReply {
type Output = Result<String, MailboxError>;
type Output = Result<TurnResolution, MailboxError>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
self.inner.as_mut().poll(cx)
@ -260,6 +327,28 @@ pub trait AgentMailbox: Send + Sync {
/// A no-op when the head is a different ticket (the timed-out one was already
/// resolved, or another caller's ticket is now in front) — idempotent and safe.
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId);
/// Completes the turn of the ticket `ticket_id` **without** a reply, waking its
/// awaiting [`PendingReply`] with [`TurnResolution::ReturnedToPromptNoReply`].
///
/// Called when the target **returned to its prompt** (its turn ended) without
/// rendering an `idea_reply`: the rendezvous can produce no payload, so rather than
/// leaving the caller blocked until its long safety timeout, the head ticket is
/// resolved with the "no reply" outcome so the caller errors out promptly.
///
/// Contract for implementations:
/// - **head-only & idempotent**: act only if `ticket_id` is currently at the head
/// (a no-op once it has been resolved/cancelled, or if another ticket is in
/// front) — exactly like [`AgentMailbox::cancel_head`];
/// - **preserve fire-and-forget**: if the awaiting receiver already went away
/// (a human submit that is not awaited, or a caller that already timed out),
/// skip without retiring the head — the existing cancel/timeout paths own it.
///
/// The default is a no-op: a mailbox that cannot wake a pending caller early simply
/// falls back to the caller's timeout net (no behavioural change for it).
fn complete_without_reply(&self, agent: AgentId, ticket_id: TicketId) {
let _ = (agent, ticket_id);
}
}
#[cfg(test)]
@ -310,4 +399,22 @@ mod tests {
let a = AgentId::from_uuid(uuid::Uuid::from_u128(1));
assert_ne!(MailboxError::NoPendingRequest(a), MailboxError::Cancelled);
}
#[test]
fn queued_ticket_snapshot_carries_ticket_data_and_position() {
let conv = ConversationId::from_uuid(uuid::Uuid::from_u128(3));
let from = AgentId::from_uuid(uuid::Uuid::from_u128(4));
let snap = QueuedTicketSnapshot {
id: TicketId::from_uuid(uuid::Uuid::from_u128(8)),
source: InputSource::agent(from),
conversation: conv,
requester: "Main".to_owned(),
task: "delegate".to_owned(),
position: 2,
};
assert_eq!(snap.source.as_agent(), Some(from));
assert_eq!(snap.conversation, conv);
assert_eq!(snap.position, 2);
assert_eq!(snap.task, "delegate");
}
}

View File

@ -0,0 +1,411 @@
//! Auto-memory harvest parser (Lot E1) — pure domain.
//!
//! When an agent ends a `Response` turn it may embed one or more fenced
//! ` ```idea-memory ` blocks asking IdeA to persist a memory note. This module
//! owns the **pure** parsing of that directive: it turns the raw turn text into a
//! [`MemoryHarvestReport`] of validated [`MemoryHarvestDirective`]s plus per-block
//! parse errors. It performs no I/O and never fails as a whole — invalid blocks
//! are skipped individually so the valid ones can still be persisted by the
//! application layer (best-effort harvest).
//!
//! ## Directive format
//!
//! ````markdown
//! ```idea-memory
//! slug: kebab-case-slug
//! title: Human title
//! type: project|reference|feedback|user
//! description: One-line recall hook
//! ---
//! Markdown body to persist.
//! ```
//! ````
//!
//! `slug`, `title`, `type`, `description` are mandatory frontmatter keys; the
//! `---` line separates them from a non-empty Markdown body (preserved verbatim
//! save for outer whitespace). Fences with any other info string are ignored.
use crate::markdown::MarkdownDoc;
use crate::memory::{MemorySlug, MemoryType};
/// The fence info string that marks a harvest directive block.
const FENCE_INFO: &str = "idea-memory";
/// Maximum number of directive blocks honoured per response. Extra blocks are
/// reported as errors and skipped (a single response should not dump dozens of
/// notes).
pub const MAX_BLOCKS: usize = 3;
/// Maximum raw size (bytes) of a single directive block's inner content.
pub const MAX_BLOCK_BYTES: usize = 8 * 1024;
/// Maximum length (characters) of a directive's `description` (the index hook).
pub const MAX_DESCRIPTION_CHARS: usize = 200;
/// A validated memory-harvest directive parsed from one ` ```idea-memory ` block.
///
/// Pure value object. Note that `title` is captured (it is a mandatory key) even
/// though the persisted [`crate::memory::Memory`] entity has no dedicated title
/// field today — the application maps the rest onto a `Memory` and the title is
/// kept here for callers/forward use.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoryHarvestDirective {
/// Validated kebab-case slug (the note identity / file stem).
pub slug: MemorySlug,
/// Human-readable title (mandatory in the directive).
pub title: String,
/// The note kind.
pub r#type: MemoryType,
/// One-line recall hook (bounded, non-empty).
pub description: String,
/// The Markdown body to persist (non-empty).
pub body: MarkdownDoc,
}
/// Outcome of parsing a turn's text for harvest directives.
///
/// `found` counts every ` ```idea-memory ` fence detected (valid or not), so the
/// application can report how many directives the agent emitted versus how many
/// were usable. `directives` are the valid ones (in document order); `errors`
/// carries one human-readable message per skipped block.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MemoryHarvestReport {
/// Number of `idea-memory` fences detected in the text.
pub found: usize,
/// Successfully parsed, validated directives (document order).
pub directives: Vec<MemoryHarvestDirective>,
/// One message per skipped (invalid / over-limit) block.
pub errors: Vec<String>,
}
/// Parses `text` for ` ```idea-memory ` directive blocks, best-effort.
///
/// Never fails: malformed blocks land in [`MemoryHarvestReport::errors`] and valid
/// ones in [`MemoryHarvestReport::directives`]. Fences with a different info string
/// are skipped entirely. At most [`MAX_BLOCKS`] blocks are honoured; further blocks
/// are reported as errors.
#[must_use]
pub fn parse_memory_directives(text: &str) -> MemoryHarvestReport {
let mut report = MemoryHarvestReport::default();
let lines: Vec<&str> = text.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let Some(info) = fence_info(line) else {
i += 1;
continue;
};
// A fenced block opens here. Find its closing fence (a line whose trimmed
// content is exactly ```), tracking the inner content lines.
let mut j = i + 1;
while j < lines.len() && lines[j].trim_end() != "```" {
j += 1;
}
let inner = if j <= lines.len() {
lines[i + 1..j.min(lines.len())].join("\n")
} else {
String::new()
};
// Advance past the closing fence (or to EOF for an unterminated block).
let next = if j < lines.len() { j + 1 } else { lines.len() };
if info == FENCE_INFO {
report.found += 1;
if report.found > MAX_BLOCKS {
report.errors.push(format!(
"skipped idea-memory block #{}: exceeds the max of {MAX_BLOCKS} blocks per response",
report.found
));
} else {
match parse_block(&inner) {
Ok(directive) => report.directives.push(directive),
Err(err) => report.errors.push(err),
}
}
}
i = next;
}
report
}
/// Returns the (trimmed) info string of a fence opener line, or `None` if `line`
/// is not a ```` ``` ```` fence opener.
fn fence_info(line: &str) -> Option<&str> {
let trimmed = line.trim();
let rest = trimmed.strip_prefix("```")?;
// A bare ``` (no info string) is a generic fence opener, not ours.
Some(rest.trim())
}
/// Parses one block's inner content into a validated directive, or an error
/// message describing why it was skipped.
fn parse_block(inner: &str) -> Result<MemoryHarvestDirective, String> {
if inner.len() > MAX_BLOCK_BYTES {
return Err(format!(
"block exceeds the max size of {MAX_BLOCK_BYTES} bytes ({} bytes)",
inner.len()
));
}
// Split frontmatter (until a `---` line) from the body.
let mut sep = None;
for (idx, line) in inner.lines().enumerate() {
if line.trim() == "---" {
sep = Some(idx);
break;
}
}
let Some(sep) = sep else {
return Err("missing '---' separator between frontmatter and body".to_owned());
};
let block_lines: Vec<&str> = inner.lines().collect();
let frontmatter = &block_lines[..sep];
let body_raw = block_lines[sep + 1..].join("\n");
// Collect the known frontmatter keys (unknown keys are ignored leniently).
let mut slug_raw = None;
let mut title = None;
let mut type_raw = None;
let mut description = None;
for line in frontmatter {
let Some((key, value)) = line.split_once(':') else {
continue;
};
let value = value.trim().to_owned();
match key.trim() {
"slug" => slug_raw = Some(value),
"title" => title = Some(value),
"type" => type_raw = Some(value),
"description" => description = Some(value),
_ => {}
}
}
let slug_raw = required(slug_raw, "slug")?;
let title = required(title, "title")?;
let type_raw = required(type_raw, "type")?;
let description = required(description, "description")?;
let slug = MemorySlug::new(&slug_raw).map_err(|_| format!("invalid slug: {slug_raw:?}"))?;
let r#type = parse_type(&type_raw)?;
if description.chars().count() > MAX_DESCRIPTION_CHARS {
return Err(format!(
"description exceeds {MAX_DESCRIPTION_CHARS} characters"
));
}
let body = body_raw.trim();
if body.is_empty() {
return Err("empty body".to_owned());
}
Ok(MemoryHarvestDirective {
slug,
title,
r#type,
description,
body: MarkdownDoc::new(body.to_owned()),
})
}
/// Requires a non-empty frontmatter value for `field`.
fn required(value: Option<String>, field: &str) -> Result<String, String> {
match value {
Some(v) if !v.trim().is_empty() => Ok(v.trim().to_owned()),
Some(_) => Err(format!("empty required field: {field}")),
None => Err(format!("missing required field: {field}")),
}
}
/// Maps the `type` value to a [`MemoryType`].
fn parse_type(raw: &str) -> Result<MemoryType, String> {
match raw {
"project" => Ok(MemoryType::Project),
"reference" => Ok(MemoryType::Reference),
"feedback" => Ok(MemoryType::Feedback),
"user" => Ok(MemoryType::User),
other => Err(format!("invalid type: {other:?}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn block(slug: &str, title: &str, ty: &str, desc: &str, body: &str) -> String {
format!(
"```idea-memory\nslug: {slug}\ntitle: {title}\ntype: {ty}\ndescription: {desc}\n---\n{body}\n```"
)
}
#[test]
fn parses_a_single_valid_block() {
let text = format!(
"Here is what I learned.\n\n{}\n\nDone.",
block(
"api-base-url",
"API base URL",
"project",
"Where the API lives",
"Use `https://api`."
)
);
let report = parse_memory_directives(&text);
assert_eq!(report.found, 1);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert_eq!(report.directives.len(), 1);
let d = &report.directives[0];
assert_eq!(d.slug.as_str(), "api-base-url");
assert_eq!(d.title, "API base URL");
assert_eq!(d.r#type, MemoryType::Project);
assert_eq!(d.description, "Where the API lives");
assert_eq!(d.body.as_str(), "Use `https://api`.");
}
#[test]
fn parses_multiple_valid_blocks_in_order() {
let text = format!(
"{}\n{}",
block("first-note", "First", "project", "one", "Body one"),
block("second-note", "Second", "reference", "two", "Body two")
);
let report = parse_memory_directives(&text);
assert_eq!(report.found, 2);
assert_eq!(report.directives.len(), 2);
assert_eq!(report.directives[0].slug.as_str(), "first-note");
assert_eq!(report.directives[1].slug.as_str(), "second-note");
}
#[test]
fn invalid_slug_is_skipped_with_error() {
let text = block("Not Kebab!", "Bad", "project", "hook", "Body");
let report = parse_memory_directives(&text);
assert_eq!(report.found, 1);
assert!(report.directives.is_empty());
assert_eq!(report.errors.len(), 1);
assert!(
report.errors[0].contains("invalid slug"),
"{:?}",
report.errors
);
}
#[test]
fn empty_body_is_skipped() {
let text = block("ok-slug", "Title", "project", "hook", " ");
let report = parse_memory_directives(&text);
assert_eq!(report.found, 1);
assert!(report.directives.is_empty());
assert!(report.errors[0].contains("empty body"));
}
#[test]
fn missing_required_field_is_skipped() {
// No `title:` key.
let text =
"```idea-memory\nslug: ok-slug\ntype: project\ndescription: hook\n---\nBody\n```";
let report = parse_memory_directives(text);
assert_eq!(report.found, 1);
assert!(report.directives.is_empty());
assert!(report.errors[0].contains("title"), "{:?}", report.errors);
}
#[test]
fn invalid_type_is_skipped() {
let text = block("ok-slug", "Title", "wisdom", "hook", "Body");
let report = parse_memory_directives(&text);
assert!(report.directives.is_empty());
assert!(report.errors[0].contains("invalid type"));
}
#[test]
fn missing_separator_is_skipped() {
let text = "```idea-memory\nslug: ok-slug\ntitle: T\ntype: project\ndescription: hook\nBody without separator\n```";
let report = parse_memory_directives(text);
assert_eq!(report.found, 1);
assert!(report.directives.is_empty());
assert!(
report.errors[0].contains("separator"),
"{:?}",
report.errors
);
}
#[test]
fn caps_at_max_blocks() {
let mut text = String::new();
for n in 0..(MAX_BLOCKS + 2) {
text.push_str(&block(&format!("note-{n}"), "T", "project", "hook", "Body"));
text.push('\n');
}
let report = parse_memory_directives(&text);
assert_eq!(report.found, MAX_BLOCKS + 2);
assert_eq!(
report.directives.len(),
MAX_BLOCKS,
"only the first MAX_BLOCKS honoured"
);
assert_eq!(report.errors.len(), 2, "the surplus blocks are reported");
assert!(report.errors[0].contains("exceeds the max"));
}
#[test]
fn oversize_block_is_skipped() {
let huge_body = "x".repeat(MAX_BLOCK_BYTES + 1);
let text = block("big-note", "T", "project", "hook", &huge_body);
let report = parse_memory_directives(&text);
assert_eq!(report.found, 1);
assert!(report.directives.is_empty());
assert!(report.errors[0].contains("max size"), "{:?}", report.errors);
}
#[test]
fn over_long_description_is_skipped() {
let long_desc = "d".repeat(MAX_DESCRIPTION_CHARS + 1);
let text = block("ok-slug", "T", "project", &long_desc, "Body");
let report = parse_memory_directives(&text);
assert!(report.directives.is_empty());
assert!(report.errors[0].contains("description exceeds"));
}
#[test]
fn non_idea_memory_fences_are_ignored() {
let text = "```rust\nslug: looks-like-one\ntitle: T\ntype: project\ndescription: hook\n---\nfn main() {}\n```\n```\nplain\n```";
let report = parse_memory_directives(text);
assert_eq!(report.found, 0);
assert!(report.directives.is_empty());
assert!(report.errors.is_empty());
}
#[test]
fn markdown_body_is_preserved() {
let body = "# Heading\n\n- bullet **bold**\n- second\n\n1. step\n\n`inline code`";
let text = block("md-note", "Markdown", "reference", "hook", body);
let report = parse_memory_directives(&text);
assert_eq!(report.directives.len(), 1, "{:?}", report.errors);
assert_eq!(report.directives[0].body.as_str(), body);
}
#[test]
fn empty_text_yields_empty_report() {
let report = parse_memory_directives("");
assert_eq!(report.found, 0);
assert!(report.directives.is_empty());
assert!(report.errors.is_empty());
}
#[test]
fn valid_and_invalid_blocks_coexist() {
let text = format!(
"{}\n{}",
block("good-one", "Good", "project", "hook", "Body"),
block("Bad Slug", "Bad", "project", "hook", "Body")
);
let report = parse_memory_directives(&text);
assert_eq!(report.found, 2);
assert_eq!(report.directives.len(), 1);
assert_eq!(report.directives[0].slug.as_str(), "good-one");
assert_eq!(report.errors.len(), 1);
}
}

View File

@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize};
use crate::conversation::ConversationParty;
use crate::ids::{AgentId, NodeId};
use crate::live_state::WorkStatus;
use crate::mailbox::TicketId;
use crate::skill::SkillScope;
@ -56,6 +57,15 @@ pub enum OrchestratorError {
/// The offending id value.
value: String,
},
/// The `status` field of `workstate.set` is present but not a recognised
/// [`WorkStatus`] label.
#[error("unknown work status `{status}` for action `{action}`")]
UnknownWorkStatus {
/// The action being validated.
action: String,
/// The offending status value.
status: String,
},
}
/// The raw, wire-level orchestrator request as deserialised from a request file.
@ -132,6 +142,23 @@ pub struct OrchestratorRequest {
/// the aggregated index). Ignored by the other actions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slug: Option<String>,
/// Live-state status label for `workstate.set` (`idle|working|blocked|waiting|done`,
/// case-insensitive). Required by that action, ignored otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
/// Short current-intent free-text for `workstate.set` (domain-bounded downstream).
/// Optional, ignored by the other actions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent: Option<String>,
/// Optional finer-grained progress note for `workstate.set` (domain-bounded
/// downstream). Ignored by the other actions. **Never** exposed on read.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub progress: Option<String>,
/// Optional `lastDelegation` ticket id for `workstate.set` (the most recent
/// delegation the agent issued). Blank ⇒ none; non-uuid ⇒ rejected. Ignored by
/// the other actions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_delegation: Option<String>,
}
/// A validated orchestrator command — the only thing the application layer acts on.
@ -248,6 +275,17 @@ pub enum OrchestratorCommand {
/// The reading party (handshake identity).
requester: ConversationParty,
},
/// Read a reusable skill's Markdown body **by name** (`idea_skill_read`,
/// feature « skills à la MCP »). Resolution is project-scope-first then global;
/// the body is returned inline. Read-only — no [`crate::fileguard::FileGuard`]
/// lease (skills are not mutated through this path).
ReadSkill {
/// Skill display name to resolve (case-insensitive).
name: String,
/// The party that issued the read (handshake identity). Carried for
/// symmetry/auditing with the other read tools; skill reads need no lease.
requester: ConversationParty,
},
/// Write a memory note under the [`crate::fileguard::FileGuard`] (cadrage C7).
/// Memory is project-shared; written directly under a write-lease.
WriteMemory {
@ -258,6 +296,33 @@ pub enum OrchestratorCommand {
/// The writing party (handshake identity).
requester: ConversationParty,
},
/// Read the project's agent **live-state** (`idea_workstate_read`, programme
/// live-state, lot LS4): the lean "who is doing what right now" snapshot, enriched
/// with each agent's display name. Read-only; `progress` is never exposed.
ReadWorkState {
/// The party that issued the read (handshake identity). Carried for symmetry
/// with the other read tools; the read is project-wide (no per-requester
/// filtering).
requester: ConversationParty,
},
/// Publish (insert or replace) the **current agent's** live-state row
/// (`idea_workstate_set`, lot LS4). The key is the handshake `requestedBy`
/// identity — an agent only ever writes its own row (no agent argument).
SetWorkState {
/// The agent whose row is written (the handshake identity, keyed
/// last-writer-wins). Never a model-managed value.
agent: AgentId,
/// Coarse status the agent declares.
status: WorkStatus,
/// Short current-intent free-text (domain-bounded on write); `None` ⇒ cleared.
intent: Option<String>,
/// Optional finer-grained progress note (domain-bounded on write).
progress: Option<String>,
/// The ticket the agent is currently handling, if any.
ticket: Option<TicketId>,
/// The ticket of the most recent delegation it issued, if any.
last_delegation: Option<TicketId>,
},
}
/// Where IdeA should place a launched agent session.
@ -345,11 +410,29 @@ impl OrchestratorRequest {
slug: self.optional_slug(),
requester: self.requester_party(),
}),
"skill.read" => Ok(OrchestratorCommand::ReadSkill {
name: self.require_name(action)?,
requester: self.requester_party(),
}),
"memory.write" => Ok(OrchestratorCommand::WriteMemory {
slug: self.require("slug", action, self.slug.as_deref())?,
content: self.require("content", action, self.content.as_deref())?,
requester: self.requester_party(),
}),
"workstate.read" => Ok(OrchestratorCommand::ReadWorkState {
requester: self.requester_party(),
}),
"workstate.set" => Ok(OrchestratorCommand::SetWorkState {
// The key is the handshake identity (`requestedBy`), exactly like
// `agent.reply`: an agent writes only its own row, never a peer's.
agent: self.require_from(action)?,
status: self.require_work_status(action)?,
intent: self.intent.clone(),
progress: self.progress.clone(),
ticket: self.optional_ticket(action)?,
last_delegation: self
.parse_optional_ticket(action, self.last_delegation.as_deref())?,
}),
"list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents),
"create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill {
name: self.require_name(action)?,
@ -439,17 +522,40 @@ impl OrchestratorRequest {
/// Absent/blank ⇒ `None` (head-of-queue fallback); present-but-non-uuid ⇒
/// [`OrchestratorError::InvalidAgentId`] (reused as a generic invalid-id error).
fn optional_ticket(&self, action: &str) -> Result<Option<TicketId>, OrchestratorError> {
match self.ticket.as_deref().map(str::trim) {
self.parse_optional_ticket(action, self.ticket.as_deref())
}
/// Parses an **optional** ticket-id field (`ticket`, `lastDelegation`) into a
/// [`TicketId`]. Absent/blank ⇒ `None`; present-but-non-uuid ⇒
/// [`OrchestratorError::InvalidAgentId`] (reused as a generic invalid-id error).
#[allow(clippy::unused_self)]
fn parse_optional_ticket(
&self,
action: &str,
raw: Option<&str>,
) -> Result<Option<TicketId>, OrchestratorError> {
match raw.map(str::trim) {
None | Some("") => Ok(None),
Some(raw) => uuid::Uuid::parse_str(raw)
Some(value) => uuid::Uuid::parse_str(value)
.map(|u| Some(TicketId::from_uuid(u)))
.map_err(|_| OrchestratorError::InvalidAgentId {
action: action.to_owned(),
value: raw.to_owned(),
value: value.to_owned(),
}),
}
}
/// Parses the **required** `status` of `workstate.set` into a [`WorkStatus`].
/// Missing/blank ⇒ [`OrchestratorError::MissingField`]; present-but-unknown ⇒
/// [`OrchestratorError::UnknownWorkStatus`].
fn require_work_status(&self, action: &str) -> Result<WorkStatus, OrchestratorError> {
let raw = self.require("status", action, self.status.as_deref())?;
WorkStatus::parse(&raw).ok_or_else(|| OrchestratorError::UnknownWorkStatus {
action: action.to_owned(),
status: raw,
})
}
/// The optional target agent name for the FileGuard context tools: `targetAgent`
/// (or legacy `name`), trimmed; absent/blank ⇒ `None` (the global project context).
fn optional_target_agent(&self) -> Option<String> {
@ -924,6 +1030,115 @@ mod tests {
);
}
#[test]
fn workstate_read_derives_requester_party() {
let uid = uuid::Uuid::from_u128(5);
let r = req(&format!(
r#"{{ "type":"workstate.read", "requestedBy":"{uid}" }}"#
));
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::ReadWorkState {
requester: ConversationParty::agent(AgentId::from_uuid(uid)),
}
);
// No requester ⇒ user party (still valid: read is project-wide).
assert_eq!(
req(r#"{ "type":"workstate.read" }"#).validate().unwrap(),
OrchestratorCommand::ReadWorkState {
requester: ConversationParty::User,
}
);
}
#[test]
fn workstate_set_keys_on_requester_and_parses_fields() {
let from = uuid::Uuid::from_u128(3);
let tkt = uuid::Uuid::from_u128(9);
let last = uuid::Uuid::from_u128(11);
let r = req(&format!(
r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"Working",
"intent":"ship LS4", "progress":"wiring", "ticket":"{tkt}",
"lastDelegation":"{last}" }}"#
));
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::SetWorkState {
agent: AgentId::from_uuid(from),
status: WorkStatus::Working,
intent: Some("ship LS4".to_owned()),
progress: Some("wiring".to_owned()),
ticket: Some(TicketId::from_uuid(tkt)),
last_delegation: Some(TicketId::from_uuid(last)),
}
);
}
#[test]
fn workstate_set_requires_status_and_requester() {
let from = uuid::Uuid::from_u128(3);
// Missing status ⇒ MissingField.
assert_eq!(
req(&format!(
r#"{{ "type":"workstate.set", "requestedBy":"{from}" }}"#
))
.validate(),
Err(OrchestratorError::MissingField {
action: "workstate.set".to_owned(),
field: "status".to_owned(),
})
);
// Unknown status ⇒ UnknownWorkStatus.
assert_eq!(
req(&format!(
r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"busy" }}"#
))
.validate(),
Err(OrchestratorError::UnknownWorkStatus {
action: "workstate.set".to_owned(),
status: "busy".to_owned(),
})
);
// Missing requester ⇒ MissingField on `requestedBy`.
assert_eq!(
req(r#"{ "type":"workstate.set", "status":"idle" }"#).validate(),
Err(OrchestratorError::MissingField {
action: "workstate.set".to_owned(),
field: "requestedBy".to_owned(),
})
);
// Non-uuid lastDelegation ⇒ InvalidAgentId (generic invalid-id error).
assert!(matches!(
req(&format!(
r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"idle", "lastDelegation":"nope" }}"#
))
.validate(),
Err(OrchestratorError::InvalidAgentId { .. })
));
// Non-uuid `ticket` ⇒ InvalidAgentId too (same parse_optional_ticket path).
assert!(matches!(
req(&format!(
r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"idle", "ticket":"nope" }}"#
))
.validate(),
Err(OrchestratorError::InvalidAgentId { .. })
));
// Non-uuid `requestedBy` ⇒ InvalidAgentId (the handshake key must be a real id).
assert!(matches!(
req(r#"{ "type":"workstate.set", "requestedBy":"not-a-uuid", "status":"idle" }"#)
.validate(),
Err(OrchestratorError::InvalidAgentId { .. })
));
// Blank `requestedBy` ⇒ MissingField (trimmed-empty is treated as absent).
assert_eq!(
req(r#"{ "type":"workstate.set", "requestedBy":" ", "status":"idle" }"#).validate(),
Err(OrchestratorError::MissingField {
action: "workstate.set".to_owned(),
field: "requestedBy".to_owned(),
})
);
}
#[test]
fn unknown_action_is_rejected() {
let r = req(r#"{ "action": "delete_everything", "name": "a" }"#);

View File

@ -894,8 +894,11 @@ pub trait PermissionProjector: Send + Sync {
///
/// `eff == None` ⇒ the **empty** projection ([`PermissionProjection::empty`]):
/// we keep the CLI's native prompting (the product invariant of [`resolve`]).
fn project(&self, eff: Option<&EffectivePermissions>, ctx: &ProjectionContext)
-> PermissionProjection;
fn project(
&self,
eff: Option<&EffectivePermissions>,
ctx: &ProjectionContext,
) -> PermissionProjection;
/// Run-dir-relative paths of the [`ProjectedFile::Replace`] files this
/// projector owns, so the swap path can clean them up when an agent moves
@ -1448,7 +1451,10 @@ mod tests {
let md = render_permission_summary(Some(&eff)).expect("a posed policy renders a summary");
// Files: OS-enforced / Landlock when supported.
assert!(md.contains("OS-enforced"), "files block must say OS-enforced");
assert!(
md.contains("OS-enforced"),
"files block must say OS-enforced"
);
assert!(md.contains("Landlock"), "files block must name Landlock");
// Commands: advisory, NOT OS-locked, and the why (ExecuteBash).
assert!(md.contains("advisory"), "commands must be called advisory");
@ -1462,7 +1468,10 @@ mod tests {
);
// The actual rules surface in their respective sections.
assert!(md.contains("`src/**`"), "the file scope is shown");
assert!(md.contains("`rm *` (prefix)"), "the command matcher is shown");
assert!(
md.contains("`rm *` (prefix)"),
"the command matcher is shown"
);
// Resolved posture is surfaced.
assert!(md.contains("**Default posture:** Ask"));
}

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