feat(persistence): couche conversationnelle — cadrage §18/§19 + briques P1→P4
Resync ARCHITECTURE.md (état livré + cadrage persistance/handoff) et premières briques de la couche de persistance conversationnelle (log canonique par paire + handoff incrémental), indépendante du provider — prépare reprise fiable et handoff cross-profile Claude↔Codex. ARCHITECTURE.md - §14.3.2/§17 : M5 marqué livré, verrou « ouvert » périmé, §17 réconcilié (vue = terminal de sortie, pas d'UI chat) ; §18 état livré 2026-06-12 ; §19 cadrage persistance/handoff (log par paire + handoff, 10 lots P1→P10) Domaine (conversation_log.rs, pur) - P1 : ConversationTurn / TurnId / TurnRole + port ConversationLog - P3 : Handoff + port HandoffStore - P4 : port HandoffSummarizer (async, seam OCP pour adapter LLM futur) Infrastructure (conversation_log/) - P2 : FsConversationLog — JSONL append-only par paire, sync_all (durabilité crash), skip ligne corrompue, fichier absent ⇒ vide - P3 : FsHandoffStore — handoff.md front-matter, write atomique tmp+rename - P4 : HeuristicHandoffSummarizer — incrémental, zéro modèle/I/O, fenêtre WINDOW Tests : domaine 12 + infra 24 (conversation_log) verts, suites complètes sans régression. Cycle dev/test : le binôme a débusqué et corrigé un bug de durabilité (append sans flush) au passage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
112
ARCHITECTURE.md
112
ARCHITECTURE.md
@ -774,13 +774,15 @@ Exemple `skill.create` :
|
|||||||
|
|
||||||
#### 14.3.2 Orchestration v5 — bind transport S-MCP + fix registre session
|
#### 14.3.2 Orchestration v5 — bind transport S-MCP + fix registre session
|
||||||
|
|
||||||
|
> **✅ LIVRÉ / FIGÉ 2026-06-12 (commit `eca2ba9`, sur la base de `cf89b3b` M5a-e).** L'ensemble R0→A0→M5a-e est **code-complet, tests verts** ; seule la validation end-to-end réelle en AppImage (CLI Claude/Codex live) reste à faire — ce n'est pas un sujet d'architecture. **Le « verrou M5 ouvert » mentionné dans les anciens passages est PÉRIMÉ** : le transport est réellement vivant (bind loopback + handshake + `.mcp.json` réel). La cartographie nette des ports/adapters livrés est consolidée en **§18**.
|
||||||
|
|
||||||
> Cadrage complet : `.ideai/briefs/orchestration-v5-transport-bind-cadrage.md`. Cette sous-section fige le **dernier kilomètre** (transport réellement vivant) et le **fix de robustesse** prérequis. Elle ne réécrit ni le domaine ni l'application : elle **remplit** le placeholder de conf MCP, **pilote `serve`** par connexion, et **durcit** un invariant existant.
|
> Cadrage complet : `.ideai/briefs/orchestration-v5-transport-bind-cadrage.md`. Cette sous-section fige le **dernier kilomètre** (transport réellement vivant) et le **fix de robustesse** prérequis. Elle ne réécrit ni le domaine ni l'application : elle **remplit** le placeholder de conf MCP, **pilote `serve`** par connexion, et **durcit** un invariant existant.
|
||||||
|
|
||||||
**Décision V5-1 — Transport S-MCP = `stdio-spawn` (loopback), socket = TODO.** Une CLI MCP (Claude/Codex) attend une déclaration `{command,args}` et **spawn elle-même** ce process à l'`initialize`. IdeA fournit donc une **sous-commande `mcp-server` du binaire app-tauri existant** (route dans `main.rs` avant init Tauri, **un seul exécutable livré** AppImage/setup.exe) : un **pont** ultraléger `StdioTransport(stdin,stdout)` ↔ **endpoint loopback du projet** (Unix domain socket / Windows named pipe, **sans port réseau** ⇒ AppImage/Windows/SSH-safe). Le `McpServer` (qui tient l'`OrchestratorService`/`Project`) **reste dans le process Tauri** ; `McpServerHandle` **accepte** sur l'endpoint et **spawn une tâche `McpServer::serve(conn)` par pair**. Le **point dur** « comment le process serveur retrouve le bon projet » est résolu par **injection d'identité aux `args`** (`--endpoint`/`--project`/`--requester`), fixée au `LaunchAgent` (projet connu à ce moment). Le socket direct est **rejeté en défaut** (ports/permissions/cross-OS, support CLI inégal) mais reste un **ajout sans toucher `McpServer`** derrière le trait `Transport`.
|
**Décision V5-1 — Transport S-MCP = `stdio-spawn` (loopback), socket = TODO.** Une CLI MCP (Claude/Codex) attend une déclaration `{command,args}` et **spawn elle-même** ce process à l'`initialize`. IdeA fournit donc une **sous-commande `mcp-server` du binaire app-tauri existant** (route dans `main.rs` avant init Tauri, **un seul exécutable livré** AppImage/setup.exe) : un **pont** ultraléger `StdioTransport(stdin,stdout)` ↔ **endpoint loopback du projet** (Unix domain socket / Windows named pipe, **sans port réseau** ⇒ AppImage/Windows/SSH-safe). Le `McpServer` (qui tient l'`OrchestratorService`/`Project`) **reste dans le process Tauri** ; `McpServerHandle` **accepte** sur l'endpoint et **spawn une tâche `McpServer::serve(conn)` par pair**. Le **point dur** « comment le process serveur retrouve le bon projet » est résolu par **injection d'identité aux `args`** (`--endpoint`/`--project`/`--requester`), fixée au `LaunchAgent` (projet connu à ce moment). Le socket direct est **rejeté en défaut** (ports/permissions/cross-OS, support CLI inégal) mais reste un **ajout sans toucher `McpServer`** derrière le trait `Transport`.
|
||||||
|
|
||||||
**Décision V5-2 — Cohérence conf↔serveur, source d'endpoint unique.** `apply_mcp_config` (M1) écrit la **déclaration réelle** (fin du placeholder `mcp_server_declaration`) : `command = current_exe()`, `args = ["mcp-server","--endpoint",mcp_endpoint(project),"--project",id,"--requester",agent]`. Le **chemin d'endpoint** vient d'une **fonction unique** `mcp_endpoint(project_id)` partagée par celui qui **écrit** la conf (M1/M5d) et celui qui **écoute** (`ensure_mcp_server`/M5a) ⇒ **zéro chaîne dupliquée**, invariant de cohérence testable. `McpConfigStrategy` inchangé (`ConfigFile` écrit le fichier non-clobbering ; `Flag`/`Env` portent le chemin de conf). L'identité du pair (`--requester`) lève le `requester_id = "mcp"` figé ⇒ observabilité UI exacte (qui délègue à qui).
|
**Décision V5-2 — Cohérence conf↔serveur, source d'endpoint unique.** `apply_mcp_config` (M1) écrit la **déclaration réelle** (fin du placeholder `mcp_server_declaration`) : `command = current_exe()`, `args = ["mcp-server","--endpoint",mcp_endpoint(project),"--project",id,"--requester",agent]`. Le **chemin d'endpoint** vient d'une **fonction unique** `mcp_endpoint(project_id)` partagée par celui qui **écrit** la conf (M1/M5d) et celui qui **écoute** (`ensure_mcp_server`/M5a) ⇒ **zéro chaîne dupliquée**, invariant de cohérence testable. `McpConfigStrategy` inchangé (`ConfigFile` écrit le fichier non-clobbering ; `Flag`/`Env` portent le chemin de conf). L'identité du pair (`--requester`) lève le `requester_id = "mcp"` figé ⇒ observabilité UI exacte (qui délègue à qui).
|
||||||
|
|
||||||
**Décision V5-3 — Fix registre session = lot PRIORITAIRE et indépendant du transport.** Invariant correct = **« 1 session vivante par agent »** (décision produit verrouillée : un agent est un **singleton**, la cellule est une **vue** §17.6 — *pas* d'identité par cellule à inventer). `session_for_agent` est déterministe **à condition** d'enforcer l'invariant sur **les deux** registres. **Trois fuites** à boucher : (A) le garde de `LaunchAgent` ne lève **jamais** `AgentAlreadyRunning` (rebind/idempotent silencieux qui masque un second lancement) ⇒ distinguer **réattache de vue** (rebind) de **lancement neuf** (refus typé) ; (B) `list_live_agents` est **aveugle aux sessions structurées** (lit seulement `terminal_sessions`) ⇒ lire l'agrégateur `LiveSessions` (PTY+chat) ; (C) les `layouts.json` **à doublons** (N feuilles, même agent) ⇒ **réconciliation à l'ouverture** (garder une hôte, dé-flagger les autres), ce qui supprime le symptôme « une cellule reset au retour d'onglet ».
|
**Décision V5-3 — Fix registre session = lot PRIORITAIRE et indépendant du transport. ✅ RÉSOLU 2026-06-12.** L'ancienne ambiguïté de `session_for_agent` (mémoire `session-registry-agent-ambiguity`) est **PÉRIMÉE** : l'invariant « 1 agent = 1 session vivante » est désormais gardé par les registres `TerminalSessions`/`StructuredSessions` agrégés en `LiveSessions`, avec `session_for_agent` (non ambigu) **+** `sessions_for_agent` (pluriel) et un garde reattach `Rebind`/`Refuse`/`Idempotent` dans `LaunchAgent`. Invariant correct = **« 1 session vivante par agent »** (décision produit verrouillée : un agent est un **singleton**, la cellule est une **vue** §17.6 — *pas* d'identité par cellule à inventer). `session_for_agent` est déterministe **à condition** d'enforcer l'invariant sur **les deux** registres. **Trois fuites** à boucher : (A) le garde de `LaunchAgent` ne lève **jamais** `AgentAlreadyRunning` (rebind/idempotent silencieux qui masque un second lancement) ⇒ distinguer **réattache de vue** (rebind) de **lancement neuf** (refus typé) ; (B) `list_live_agents` est **aveugle aux sessions structurées** (lit seulement `terminal_sessions`) ⇒ lire l'agrégateur `LiveSessions` (PTY+chat) ; (C) les `layouts.json` **à doublons** (N feuilles, même agent) ⇒ **réconciliation à l'ouverture** (garder une hôte, dé-flagger les autres), ce qui supprime le symptôme « une cellule reset au retour d'onglet ».
|
||||||
|
|
||||||
**Décision V5-4 — Robustesse `ask` : sérialisation FIFO par agent.** Au-dessus de l'existant (cible morte ⇒ lancement structuré ; PTY brut ⇒ `Invalid` ; timeout 300 s ⇒ cible vivante + erreur typée), le seul manque est la **concurrence** : deux `ask` simultanés sur la même cible appelleraient `send_blocking` en parallèle sur **une** `AgentSession` ⇒ tours entrelacés (cf. bug accents = writes non sérialisés). `OrchestratorService::ask_agent` **sérialise les tours par `agent_id`** (verrou par agent) : file FIFO naturelle, timeout **par tour**, plafond d'attente borné. Règle **applicative** (vit dans le service/registre, pas dans l'adapter MCP).
|
**Décision V5-4 — Robustesse `ask` : sérialisation FIFO par agent.** Au-dessus de l'existant (cible morte ⇒ lancement structuré ; PTY brut ⇒ `Invalid` ; timeout 300 s ⇒ cible vivante + erreur typée), le seul manque est la **concurrence** : deux `ask` simultanés sur la même cible appelleraient `send_blocking` en parallèle sur **une** `AgentSession` ⇒ tours entrelacés (cf. bug accents = writes non sérialisés). `OrchestratorService::ask_agent` **sérialise les tours par `agent_id`** (verrou par agent) : file FIFO naturelle, timeout **par tour**, plafond d'attente borné. Règle **applicative** (vit dans le service/registre, pas dans l'adapter MCP).
|
||||||
|
|
||||||
@ -1551,6 +1553,8 @@ dispatch(AskAgent { target, task, correlation, visibility }):
|
|||||||
|
|
||||||
## 17. Exécution structurée des agents IA — port `AgentSession` (PIVOT 2026-06-09, voie principale)
|
## 17. Exécution structurée des agents IA — port `AgentSession` (PIVOT 2026-06-09, voie principale)
|
||||||
|
|
||||||
|
> **⚠️ RÉCONCILIÉ 2026-06-12 — PIVOT « Option 1 » (chef d'orchestre, acté).** Le port `AgentSession` et les deux adapters structurés (Claude/Codex) **restent la voie principale** d'exécution. **MAIS** : la **vue** d'un agent est désormais un **terminal natif PTY = vue de SORTIE**. Il n'y a **PLUS d'UI chat** : **`AgentChatView` a été supprimée** (`frontend/src/features/chat/` retiré), et toute la sous-section **§17.6 décrivant `AgentChatView`/`ChatBridge`/`cellKind:"chat"` est SUPERSEDED**. L'entrée utilisateur est **médiée par IdeA** (`MediatedInput`/`useAgentBusy` côté front ; modules domaine `input`/`mailbox`/`conversation`/`fileguard`). L'observabilité des délégations vit dans le **modèle terminal/debug**, **pas** dans un fil de chat séparé. Cartographie des modules livrés : **§18**.
|
||||||
|
>
|
||||||
> **Pivot verrouillé par le chef d'orchestre (acté, non rediscuté).** IdeA ne lit plus le terminal d'un agent IA et ne lui demande plus de se rapporter. Pour un agent **IA**, IdeA le **pilote via son mode programmatique/structuré** (ex. `claude -p --output-format stream-json` ou l'Agent SDK ; `codex exec` à sortie structurée) et **lit la réponse comme du JSON déterministe** (un message `result` final bien défini). La **plomberie devient 100 % fiable** ; seul reste irréductible le *contenu* de la réponse (propre à tout LLM). Cette section **remplace §16** comme voie principale et **réconcilie** avec §15 (chantiers A « hot-swap profil » et B « reprise session », tous deux LIVRÉS).
|
> **Pivot verrouillé par le chef d'orchestre (acté, non rediscuté).** IdeA ne lit plus le terminal d'un agent IA et ne lui demande plus de se rapporter. Pour un agent **IA**, IdeA le **pilote via son mode programmatique/structuré** (ex. `claude -p --output-format stream-json` ou l'Agent SDK ; `codex exec` à sortie structurée) et **lit la réponse comme du JSON déterministe** (un message `result` final bien défini). La **plomberie devient 100 % fiable** ; seul reste irréductible le *contenu* de la réponse (propre à tout LLM). Cette section **remplace §16** comme voie principale et **réconcilie** avec §15 (chantiers A « hot-swap profil » et B « reprise session », tous deux LIVRÉS).
|
||||||
>
|
>
|
||||||
> Cette section **complète** §6 (use cases agent), §7 (layout), §9 (profils déclaratifs), §14.1 (run dir isolé), §14.3 (registre visible/arrière-plan) et §15 (agent = entité reprenable). Elle **ajoute un port domaine** (`AgentSession` + sa factory), **deux adapters infra** (Claude/Codex), **un type de cellule** (cellule IA vs terminal brut), **un registre de sessions structurées**, et le câblage frontend (UI chat). Elle **ne casse pas** les terminaux non-IA (PTY + xterm inchangés) ni A/B.
|
> Cette section **complète** §6 (use cases agent), §7 (layout), §9 (profils déclaratifs), §14.1 (run dir isolé), §14.3 (registre visible/arrière-plan) et §15 (agent = entité reprenable). Elle **ajoute un port domaine** (`AgentSession` + sa factory), **deux adapters infra** (Claude/Codex), **un type de cellule** (cellule IA vs terminal brut), **un registre de sessions structurées**, et le câblage frontend (UI chat). Elle **ne casse pas** les terminaux non-IA (PTY + xterm inchangés) ni A/B.
|
||||||
@ -1812,6 +1816,8 @@ OrchestratorService::dispatch(AskAgent { target, task, … }):
|
|||||||
|
|
||||||
### 17.6 Deux types de cellules — modèle de layout & frontend
|
### 17.6 Deux types de cellules — modèle de layout & frontend
|
||||||
|
|
||||||
|
> **⚠️ SUPERSEDED 2026-06-12 (pivot Option 1).** Le `cellKind:"chat"` et le composant `AgentChatView`/`ChatBridge` décrits ci-dessous **ne sont plus la cible** : `AgentChatView` a été **supprimée**, la vue d'un agent (structuré ou non) est un **terminal natif PTY** (vue de SORTIE). L'entrée passe par l'**entrée médiée** (`MediatedInput` + `useAgentBusy`, §18). La partie « la session structurée vit dans le registre backend et ne meurt pas au changement d'onglet » **reste vraie** (invariant 1-session/agent, §18). Conservé ci-dessous pour l'historique.
|
||||||
|
|
||||||
**Décision : la distinction « cellule IA (chat) » vs « cellule terminal brut » est DÉRIVÉE, pas un nouveau champ de layout.** Le modèle `LeafCell` (§7) reste **inchangé** (`session?`, `agent?`, `conversation_id?`, `agent_was_running`). Le **type de rendu** d'une cellule se déduit à l'attache :
|
**Décision : la distinction « cellule IA (chat) » vs « cellule terminal brut » est DÉRIVÉE, pas un nouveau champ de layout.** Le modèle `LeafCell` (§7) reste **inchangé** (`session?`, `agent?`, `conversation_id?`, `agent_was_running`). Le **type de rendu** d'une cellule se déduit à l'attache :
|
||||||
- cellule **sans agent** ⇒ terminal brut (PTY + xterm), inchangé ;
|
- cellule **sans agent** ⇒ terminal brut (PTY + xterm), inchangé ;
|
||||||
- cellule **avec agent** ⇒ on lit le `structured_adapter` du profil de l'agent : `Some` ⇒ **cellule chat** ; `None` ⇒ **cellule terminal brut** (un agent TUI legacy).
|
- cellule **avec agent** ⇒ on lit le `structured_adapter` du profil de l'agent : `Some` ⇒ **cellule chat** ; `None` ⇒ **cellule terminal brut** (un agent TUI legacy).
|
||||||
@ -1893,4 +1899,108 @@ Nouvelles commandes (jumelles des commandes PTY existantes ; réutilisent `resol
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 18. État livré 2026-06-12 — cartographie des ports/adapters réels (conversation · mailbox · entrée médiée · FileGuard · transport MCP)
|
||||||
|
|
||||||
|
> Section **descriptive** (pas un cadrage à faire) : elle fige la **réalité committée** (`eca2ba9`, base `cf89b3b`) pour que les lots suivants planifient depuis le code, pas depuis l'ancien texte. Tests verts ; validation e2e AppImage hors sujet archi. Les §15/16/17 antérieures restent la **genèse** ; en cas de divergence, **§18 fait foi** sur ces cinq modules.
|
||||||
|
|
||||||
|
### 18.1 Conversation par paire (domaine `conversation` + infra `conversation`)
|
||||||
|
- **Port domaine** `domain::conversation::ConversationRegistry` (`crates/domain/src/conversation.rs`) : `resolve(a,b)` lazy get-or-create **par paire non ordonnée** (`resolve(a,b)==resolve(b,a)`), `bind_session`, `suspend(id, resumable_id)`, `get`. Value objects : `ConversationId` (UUID), `ConversationParty` (`User | Agent{agent_id}` — au plus **un** `User`, jamais `x↔x`), `ConversationSession` (`Dormant | Live{handle_ref:SessionRef}`), `Conversation{id,left,right,session,resumable_id}`. Pur (zéro I/O).
|
||||||
|
- **`WaitForGraph`** (même fichier) : graphe wait-for **pur** pour la **prévention de cycle** inter-agents (`would_cycle(from,to)` sans mutation ; refuse self-wait + cycles transitifs).
|
||||||
|
- **Adapter infra** `InMemoryConversationRegistry` (`crates/infrastructure/src/conversation/mod.rs`) : `HashMap<ConversationId,Conversation>` + index `pair_key` normalisé, `Mutex` **synchrone jamais tenu à travers un `.await`**.
|
||||||
|
|
||||||
|
### 18.2 Mailbox FIFO inter-agents (domaine `mailbox` + infra `mailbox`)
|
||||||
|
- **Port domaine** `domain::mailbox::AgentMailbox` (`crates/domain/src/mailbox.rs`) : **une FIFO par agent cible**. `enqueue(agent,ticket) -> PendingReply` (future opaque que l'appelant `await`), `resolve(agent,result)` (corrélation **positionnelle** = tête de file), `resolve_ticket(agent,ticket_id,result)` (corrélation **par id** quand l'agent a plusieurs fils — défaut = repli sur la tête), `cancel_head(agent,ticket_id)` (retire la tête sur timeout). `Ticket{id,source:InputSource,conversation:ConversationId,requester,task}` (constructeurs `new`/`from_human`/`from_agent`). `MailboxError::{NoPendingRequest, Cancelled}`.
|
||||||
|
- **Adapter infra** `InMemoryMailbox` (`crates/infrastructure/src/mailbox/mod.rs`) : `VecDeque` par agent + `tokio::sync::oneshot` par ticket ; `Mutex` synchrone, await **hors** du lock.
|
||||||
|
|
||||||
|
### 18.3 Entrée médiée (domaine `input` + infra `input`)
|
||||||
|
- **Port domaine** `domain::input::InputMediator` (`crates/domain/src/input.rs`) : **point de convergence unique** de **toute** entrée d'un agent (humain **et** délégation) sur **une FIFO/agent**. `enqueue` (Envoyer, écrit aussi le tour dans le flux), `bind_handle`/`bind_handle_with_prompt` (arme la détection prompt-ready via `AgentProfile::prompt_ready_pattern`), `delivers_turn`, `preempt` (Interrompre ≠ Envoyer, ne corrèle aucun ticket), `mark_idle`, `busy_state`. Value objects : `InputSource` (`Human | Agent{agent_id}` — **source de vérité** du requester), `AgentBusyState` (`Idle | Busy{ticket,since_ms}`).
|
||||||
|
- **Adapter infra** `MediatedInbox` (`crates/infrastructure/src/input/mod.rs`) : **compose** `InMemoryMailbox` (moteur de corrélation) + bookkeeping busy + `preempt` ; **ne crée pas** de 2ᵉ file. Publie `AgentBusyChanged` sur l'`EventBus`.
|
||||||
|
- **Frontend** `MediatedInput.tsx` + `useAgentBusy.ts` (`frontend/src/features/terminals/`) : la zone de saisie **médiée** (Envoyer/Interrompre) au-dessus du terminal de sortie — **pas** un fil de chat.
|
||||||
|
|
||||||
|
### 18.4 FileGuard (domaine `fileguard` + infra `fileguard`)
|
||||||
|
- **Port domaine** `domain::fileguard::FileGuard` (`crates/domain/src/fileguard.rs`, `#[async_trait]`) : lock **lecteurs/écrivain par ressource** sur l'ensemble **borné** `GuardedResource::{AgentContext(id), ProjectContext, Memory(slug)}`. `acquire_read`/`acquire_write` rendent des leases RAII (`ReadLease`/`WriteLease`, libèrent au drop). **`ProjectContext` = single-writer orchestrateur** (`GuardError::Forbidden` sinon ; politique pure `may_write_directly`/`is_orchestrator`, l'orchestrateur = `ConversationParty::User`). `GuardError::{Busy, Forbidden}`.
|
||||||
|
- **Portée coopérative** (cadrage §9.5) : corrige les collisions **dans le chemin IdeA** (MCP + UI) ; un agent gardant un shell brut peut contourner — l'étanchéité réelle est un sujet **sandbox OS (Landlock)**, hors périmètre.
|
||||||
|
- **Adapter infra** `RwFileGuard` (`crates/infrastructure/src/fileguard/mod.rs`) : un `tokio::sync::RwLock` par ressource (lazy + `Arc`), registre derrière `Mutex` synchrone, garde `'static` boxée dans les leases.
|
||||||
|
|
||||||
|
### 18.5 Transport MCP natif M5 (infra `orchestrator/mcp` + app-tauri)
|
||||||
|
- **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).
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
> **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).
|
||||||
|
|
||||||
|
### 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 :
|
||||||
|
1. **Reprise non garantie / non portable** : si le `resumable_id` est perdu (provider qui ne reprend pas, run nettoyé) l'agent **ne sait plus sur quoi il travaillait**.
|
||||||
|
2. **Handoff cross-profile impossible** : un swap **Claude→Codex** (chantier §15.1) **kill+relance** ; le `resumable_id` Claude **n'a aucun sens** pour Codex ⇒ le nouveau profil **repart de zéro**.
|
||||||
|
|
||||||
|
**Objectif** : au redémarrage **et** au swap de profil, le nouvel agent **reprend fidèlement le travail utile** (fidélité **opérationnelle** > illusion de continuité terminale). Pour cela, IdeA tient une **mémoire de conversation propre, indépendante du provider**, en deux couches : un **log canonique** (source durable, par paire) + un **résumé/handoff cumulatif incrémental** (couche compacte de reprise, maintenue **aux checkpoints**, pas seulement au moment du swap).
|
||||||
|
|
||||||
|
### 19.1 Décisions tranchées (frontières & invariants)
|
||||||
|
- **D19-1 — Deux couches, pas une.** (a) **Log canonique** = append-only, fidèle, par **conversation** (paire) : source de vérité durable. (b) **Handoff cumulatif** = vue compacte dérivée, **réécrite incrémentalement** à chaque checkpoint (≠ recalcul intégral) : c'est ce qu'on **injecte** au (re)lancement.
|
||||||
|
- **D19-2 — Trois mémoires disjointes.** Cette couche est **distincte** de (i) la **mémoire durable** `.ideai/memory/` (savoir stable, low-noise, §14.5) et (ii) la **mémoire vivante / live-state** (busy, sessions en cours). Le **log de conversation** est **volumineux & bruité** par nature : il ne **pollue jamais** `memory/`. Frontière nette : `memory/` = *ce que le projet sait* ; `conversations/` = *ce qui a été dit dans un fil* ; live-state = *ce qui tourne maintenant*.
|
||||||
|
- **D19-3 — Provider-agnostique.** Le log et le handoff sont en **format IdeA** (Claude/Codex génériques) ; les `resumable_id` par provider sont **rangés à côté** (un par provider), jamais l'unique support de reprise. Un swap réutilise **le handoff**, pas le `resumable_id` de l'ancien provider.
|
||||||
|
- **D19-4 — Stockage sous `.ideai/`, hors git.** Arborescence cible :
|
||||||
|
```
|
||||||
|
.ideai/conversations/<conversationId>/
|
||||||
|
log.jsonl # log canonique append-only (un ReplyTurn/ligne)
|
||||||
|
handoff.md # résumé cumulatif incrémental (réinjecté au relancement)
|
||||||
|
providers.json # { "claude": "<resumableId>", "codex": "<id>", ... }
|
||||||
|
```
|
||||||
|
**Gitignoré** (`.ideai/conversations/` ajouté au `.gitignore` géré par IdeA) : c'est de l'**état d'exécution**, pas une source versionnable ; cohérent avec « zéro dépendance git ». (À l'inverse de `.ideai/memory/` qui, lui, **peut** être versionné — savoir projet.)
|
||||||
|
- **D19-5 — Checkpoint = fin de tour.** Le point d'écriture canonique est la **fin d'un tour** d'agent (un `result`/`final` structuré, OU prompt-ready pour un PTY) — exactement le signal qui fait déjà passer `AgentBusyState`→`Idle` (§18.3). On **réutilise ce signal**, on n'en invente pas.
|
||||||
|
- **D19-6 — Résumé incrémental = port, pas un LLM imposé.** Comprimer le log en `handoff.md` est une **stratégie** derrière un port (`HandoffSummarizer`). Adapter **défaut zéro-dépendance** = troncature/heuristique structurée (derniers N tours + objectif courant), **sans appel modèle** ; un adapter LLM optionnel viendra plus tard (profil déclaratif façon CLI, comme l'embedder §memory-system-design). On **ne bloque pas** la persistance sur la qualité du résumé.
|
||||||
|
|
||||||
|
### 19.2 Ports (frontière domaine, purs) — `crates/domain/src/conversation_log.rs` (nouveau)
|
||||||
|
- `ConversationTurn` (value object) : `{ id: TurnId, conversation: ConversationId, at_ms: u64, source: InputSource, role: TurnRole, text: String }` où `TurnRole = Prompt | Response | ToolActivity`. Pur, sérialisable.
|
||||||
|
- **`ConversationLog`** (port driven) :
|
||||||
|
- `append(conversation, turn)` — ajoute un tour au log canonique.
|
||||||
|
- `read(conversation, since: Option<TurnId>) -> Vec<ConversationTurn>` — relecture (reprise, recalcul handoff).
|
||||||
|
- `last(conversation, n) -> Vec<ConversationTurn>` — les N derniers (résumé incrémental).
|
||||||
|
- **`HandoffStore`** (port driven) :
|
||||||
|
- `load(conversation) -> Option<Handoff>` / `save(conversation, handoff)` où `Handoff = { summary_md: String, up_to: TurnId, objective: Option<String> }`.
|
||||||
|
- **`HandoffSummarizer`** (port driving-policy, pur ou délégant) :
|
||||||
|
- `fold(prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff` — **incrémental** : part du handoff précédent + seulement les tours neufs. (Défaut heuristique ; adapter LLM optionnel.)
|
||||||
|
- **`ProviderSessionStore`** (port driven) : `get/set(conversation, provider_id) -> Option<resumable_id>` — range les `resumable_id` **par provider** (remplace le `resumable_id` unique porté par `Conversation` comme support exclusif ; `Conversation.resumable_id` peut rester en cache du provider courant).
|
||||||
|
|
||||||
|
### 19.3 Adapters infra — `crates/infrastructure/src/conversation_log/` (nouveau)
|
||||||
|
- `FsConversationLog` : `log.jsonl` append-only (un `ConversationTurn` JSON/ligne), lecture en stream. I/O `tokio::fs`, écriture sérialisée par conversation (réutiliser la discipline FileGuard si le fichier devient une `GuardedResource` — cf. 19.6).
|
||||||
|
- `FsHandoffStore` : `handoff.md` (+ entête front-matter `up_to`/`objective`) read/write atomique (write tmp+rename).
|
||||||
|
- `FsProviderSessionStore` : `providers.json` map provider→id.
|
||||||
|
- `HeuristicHandoffSummarizer` : `fold` = derniers N tours + objectif courant, **sans modèle** (défaut). (`LlmHandoffSummarizer` = lot ultérieur, hors ce chantier.)
|
||||||
|
|
||||||
|
### 19.4 Câblage application
|
||||||
|
- **Au checkpoint (fin de tour)** : le chemin qui fait déjà `mark_idle`/publie le `result` (orchestrator/`MediatedInbox`/`launch_structured`) appelle `ConversationLog::append`, puis — **debouncé**/aux checkpoints — `HandoffSummarizer::fold` + `HandoffStore::save`. **Une seule** dépendance ajoutée à l'orchestrateur (les trois ports via `Arc<dyn …>`), zéro logique dupliquée.
|
||||||
|
- **À la reprise** (`ListResumableAgents`/`LaunchAgent`, §15.2) : si un `providers.json[provider_courant]` existe ⇒ `--resume`. **En plus** (et **toujours**, même sans resumable) : injecter `handoff.md` dans le contexte du run (au même endroit que le convention file / le seed permissions, run dir isolé) ⇒ l'agent **sait sur quoi il travaillait** indépendamment du provider.
|
||||||
|
- **Au swap de profil** (§15.1, Claude→Codex) : kill+relance **réutilise `handoff.md`** comme amorce du nouveau provider ; on **n'injecte pas** l'ancien `resumable_id`. La fidélité vient du handoff, pas de la session CLI.
|
||||||
|
|
||||||
|
### 19.5 Conformité hexagonale & SOLID
|
||||||
|
- Domaine **pur** (`conversation_log.rs` : value objects + 4 ports, zéro `tokio`/`fs`). Adapters infra isolés. L'orchestrateur dépend de **traits**, jamais de fichiers. `HandoffSummarizer` = **OCP** (heuristique ↔ LLM interchangeables). Frontière franche avec `memory/` (D19-2) et live-state.
|
||||||
|
|
||||||
|
### 19.6 Découpage en LOTS testables (cycle §3) — ordonné
|
||||||
|
| Lot | Côté | Objectif | Ports/types | Fichiers cibles (approx.) | Critères de test | Dépend de |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **P1** | domaine | Value objects + port `ConversationLog` | `ConversationTurn`, `TurnId`, `TurnRole`, `ConversationLog` | `crates/domain/src/conversation_log.rs`, `lib.rs` | append→read ordonné ; `since`/`last(n)` corrects ; sérialisation round-trip ; **pur** (compile sans tokio) | — |
|
||||||
|
| **P2** | infra | `FsConversationLog` (jsonl append-only) | impl `ConversationLog` | `crates/infrastructure/src/conversation_log/mod.rs`, `lib.rs` | append persiste 1 ligne/tour ; relecture après « redémarrage » (réouverture fichier) ; conversations disjointes ⇒ fichiers disjoints ; ligne corrompue ⇒ skip, jamais panic | P1 |
|
||||||
|
| **P3** | domaine+infra | `HandoffStore` + `FsHandoffStore` | `Handoff`, `HandoffStore` | `conversation_log.rs`, `infrastructure/src/conversation_log/handoff.rs` | save→load round-trip ; `up_to` conservé ; write atomique (tmp+rename) ; absent ⇒ `None` | P1 |
|
||||||
|
| **P4** | domaine+infra | `HandoffSummarizer` heuristique **incrémental** | `HandoffSummarizer`, `HeuristicHandoffSummarizer` | `conversation_log.rs`, `infrastructure/src/conversation_log/summarizer.rs` | `fold(None, turns)` = base ; `fold(prev, neufs)` n'inclut que l'incrément ; borne N respectée ; **zéro I/O / zéro modèle** | P1, P3 |
|
||||||
|
| **P5** | domaine+infra | `ProviderSessionStore` + `FsProviderSessionStore` | `ProviderSessionStore` | `conversation_log.rs`, `infrastructure/src/conversation_log/providers.rs` | get/set par provider ; providers multiples coexistent ; absent ⇒ `None` ; round-trip disque | P1 |
|
||||||
|
| **P6** | application | Câblage **checkpoint** : append + fold+save aux fins de tour | (réutilise P1–P4) | `crates/application/src/agent/lifecycle.rs`, `orchestrator/service.rs`, `input/` | un tour terminé ⇒ 1 append + handoff réécrit ; debounce (pas N writes/delta) ; profil sans persistance ⇒ no-op (zéro régression) | P2, P3, P4 |
|
||||||
|
| **P7** | application | Câblage **reprise** : injecter `handoff.md` au (re)lancement + `--resume` si `providers.json` présent | (réutilise P3, P5) ; `ListResumableAgents`, `LaunchAgent` | `application/src/agent/{resume,lifecycle}.rs` | resumable présent ⇒ `--resume` + handoff injecté ; resumable absent ⇒ handoff seul injecté ; aucun handoff ⇒ chemin actuel inchangé | P3, P5, P6 |
|
||||||
|
| **P8** | application | Câblage **swap cross-profile** : réutiliser `handoff.md`, ignorer l'ancien `resumable_id` | (réutilise P3, P5) ; `ChangeAgentProfile` (§15.1) | `application/src/agent/lifecycle.rs` | swap Claude→Codex ⇒ handoff injecté au nouveau profil, ancien resumable **non** passé ; nouveau provider écrit son **propre** `providers.json[codex]` | P7 |
|
||||||
|
| **P9** *(opt.)* | infra/app | Router le log/handoff sous FileGuard si concurrence d'écriture réelle | `GuardedResource` étendu (ou wrapper) | `domain/src/fileguard.rs`, `infrastructure/src/conversation_log/` | écritures concurrentes même conversation sérialisées ; pas de corruption ; conversations différentes parallèles | P2, P3 |
|
||||||
|
| **P10** *(opt., ultérieur)* | infra | `LlmHandoffSummarizer` (profil déclaratif) | impl `HandoffSummarizer` | `infrastructure/src/conversation_log/summarizer_llm.rs` | substituable à P4 sans toucher l'app (OCP) ; défaut reste l'heuristique | P4 |
|
||||||
|
|
||||||
|
**Ordre** : **P1→P2→P3→P4→P5** (briques, parallélisables après P1) **→ P6 → P7 → P8**, puis **P9/P10** optionnels. P6 est le **pivot** (relie le checkpoint existant aux briques) ; P7/P8 délivrent la valeur produit (reprise + handoff cross-profile). P9/P10 durcissent/enrichissent sans bloquer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
*Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.*
|
*Document maintenu par l'Agent Architecture — base du jalon « cadrage architecture » avant tout code applicatif.*
|
||||||
|
|||||||
559
crates/domain/src/conversation_log.rs
Normal file
559
crates/domain/src/conversation_log.rs
Normal file
@ -0,0 +1,559 @@
|
|||||||
|
//! Log canonique de conversation (cadrage « persistance conversationnelle », lot P1).
|
||||||
|
//!
|
||||||
|
//! Aujourd'hui la continuité d'un fil repose sur le `resumable_id` CLI du provider
|
||||||
|
//! ([`crate::conversation::Conversation::resumable_id`]) : fragile (perdu si le run
|
||||||
|
//! est nettoyé) et **non portable** d'un provider à l'autre (un swap Claude→Codex
|
||||||
|
//! repart de zéro). Pour y remédier, IdeA tient sa **propre mémoire de conversation**,
|
||||||
|
//! indépendante du provider, dont la **source de vérité durable** est ce **log
|
||||||
|
//! canonique append-only, par conversation (paire)** (ARCHITECTURE §19, décision
|
||||||
|
//! D19-1a).
|
||||||
|
//!
|
||||||
|
//! Ce module est **pur** (règle de dépendance du domaine) : ni `tokio`, ni `std::fs`,
|
||||||
|
//! ni I/O. Il possède le value object [`ConversationTurn`] (un tour de conversation),
|
||||||
|
//! ses newtypes/énumérés ([`TurnId`], [`TurnRole`]) et le port driven
|
||||||
|
//! [`ConversationLog`]. L'écriture/lecture réelle du `log.jsonl` est une affaire
|
||||||
|
//! d'infrastructure (l'adapter `FsConversationLog`, lot P2).
|
||||||
|
//!
|
||||||
|
//! ## Frontière (D19-2)
|
||||||
|
//!
|
||||||
|
//! Ce log est **distinct** de la mémoire durable `.ideai/memory/` (savoir projet
|
||||||
|
//! stable, peu bruité) et de la live-state (busy, sessions en cours). Le log est
|
||||||
|
//! **volumineux et bruité** par nature : il ne **pollue jamais** `memory/`.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::conversation::ConversationId;
|
||||||
|
use crate::input::InputSource;
|
||||||
|
use crate::ports::StoreError;
|
||||||
|
|
||||||
|
/// Identifie un [`ConversationTurn`] dans le log d'une conversation.
|
||||||
|
///
|
||||||
|
/// Newtype autour d'[`uuid::Uuid`], calqué sur [`crate::conversation::ConversationId`]
|
||||||
|
/// / [`crate::mailbox::TicketId`]. Sa monotonie (un id frappé à l'`append`) sert de
|
||||||
|
/// **curseur** à la relecture incrémentale ([`ConversationLog::read`] avec `since`).
|
||||||
|
#[derive(
|
||||||
|
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
|
||||||
|
)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct TurnId(pub uuid::Uuid);
|
||||||
|
|
||||||
|
impl TurnId {
|
||||||
|
/// Enrobe un [`uuid::Uuid`] existant.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn from_uuid(id: uuid::Uuid) -> Self {
|
||||||
|
Self(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frappe un nouvel identifiant de tour aléatoire.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new_random() -> Self {
|
||||||
|
Self(uuid::Uuid::new_v4())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renvoie l'[`uuid::Uuid`] interne.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_uuid(&self) -> uuid::Uuid {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for TurnId {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nature d'un tour dans le fil (qui parle / ce qui se passe).
|
||||||
|
///
|
||||||
|
/// Sépare l'invite émise vers l'agent ([`TurnRole::Prompt`]), sa réponse
|
||||||
|
/// ([`TurnRole::Response`]) et l'activité outillée intermédiaire
|
||||||
|
/// ([`TurnRole::ToolActivity`]) — assez pour rejouer/résumer fidèlement le travail
|
||||||
|
/// utile sans dépendre du format d'un provider donné (D19-3).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum TurnRole {
|
||||||
|
/// Une invite adressée à l'agent (humaine ou déléguée par un autre agent).
|
||||||
|
Prompt,
|
||||||
|
/// Une réponse rendue par l'agent (le `result`/`final` d'un tour).
|
||||||
|
Response,
|
||||||
|
/// Une activité outillée intermédiaire (appel d'outil, trace) au sein d'un tour.
|
||||||
|
ToolActivity,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Un tour de conversation : qui a parlé, quand, dans quel fil, et quoi.
|
||||||
|
///
|
||||||
|
/// Value object pur (aucun comportement, aucune I/O), sérialisable : c'est l'unité
|
||||||
|
/// **append-only** persistée (une ligne JSON par tour dans `log.jsonl`, lot P2). La
|
||||||
|
/// [`InputSource`] est la *source de vérité* de l'origine (Humain ou Agent), réutilisée
|
||||||
|
/// telle quelle depuis le domaine de l'entrée médiée.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ConversationTurn {
|
||||||
|
/// Identifiant stable de ce tour (frappé à l'`append`) ; sert aussi de curseur.
|
||||||
|
pub id: TurnId,
|
||||||
|
/// La conversation (paire) à laquelle ce tour appartient.
|
||||||
|
pub conversation: ConversationId,
|
||||||
|
/// Horodatage (epoch millisecondes) du tour.
|
||||||
|
pub at_ms: u64,
|
||||||
|
/// L'origine de l'entrée (Humain ou Agent délégant).
|
||||||
|
pub source: InputSource,
|
||||||
|
/// La nature du tour (invite, réponse, activité outillée).
|
||||||
|
pub role: TurnRole,
|
||||||
|
/// Le contenu textuel du tour.
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConversationTurn {
|
||||||
|
/// Construit un tour à partir de ses composants.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
id: TurnId,
|
||||||
|
conversation: ConversationId,
|
||||||
|
at_ms: u64,
|
||||||
|
source: InputSource,
|
||||||
|
role: TurnRole,
|
||||||
|
text: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
conversation,
|
||||||
|
at_ms,
|
||||||
|
source,
|
||||||
|
role,
|
||||||
|
text: text.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Le log canonique append-only des conversations, par paire (port driven, lot P1).
|
||||||
|
///
|
||||||
|
/// **Source de vérité durable** (D19-1a) : on **ajoute** un tour à chaque checkpoint
|
||||||
|
/// (fin de tour, D19-5) et on **relit** à la reprise ou pour recalculer le handoff. Le
|
||||||
|
/// log est gardé **par conversation** : deux fils distincts n'interfèrent jamais.
|
||||||
|
///
|
||||||
|
/// `#[async_trait]` et erreur [`StoreError`] comme les autres ports driven de
|
||||||
|
/// persistance ([`crate::ports::TemplateStore`]…) : injecté en
|
||||||
|
/// `Arc<dyn ConversationLog>` au composition root, donc gardé object-safe (les ports
|
||||||
|
/// async dyn-compatibles passent par `async_trait` qui box le futur — cf. note
|
||||||
|
/// d'en-tête de [`crate::ports`]).
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait ConversationLog: Send + Sync {
|
||||||
|
/// Ajoute `turn` à la fin du log canonique de `conversation`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`StoreError`] en cas d'échec d'écriture ou de sérialisation.
|
||||||
|
async fn append(
|
||||||
|
&self,
|
||||||
|
conversation: ConversationId,
|
||||||
|
turn: ConversationTurn,
|
||||||
|
) -> Result<(), StoreError>;
|
||||||
|
|
||||||
|
/// Relit les tours de `conversation`, dans l'ordre d'ajout.
|
||||||
|
///
|
||||||
|
/// `since` est un curseur **exclusif** : quand il vaut `Some(id)`, seuls les tours
|
||||||
|
/// **postérieurs** au tour `id` sont renvoyés (relecture incrémentale pour le
|
||||||
|
/// recalcul de handoff) ; `None` relit tout le fil depuis le début.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`StoreError`] en cas d'échec de lecture ou de désérialisation.
|
||||||
|
async fn read(
|
||||||
|
&self,
|
||||||
|
conversation: ConversationId,
|
||||||
|
since: Option<TurnId>,
|
||||||
|
) -> Result<Vec<ConversationTurn>, StoreError>;
|
||||||
|
|
||||||
|
/// Renvoie les `n` derniers tours de `conversation`, dans l'ordre d'ajout.
|
||||||
|
///
|
||||||
|
/// Renvoie moins de `n` éléments si le fil est plus court (et un `Vec` vide pour
|
||||||
|
/// `n == 0` ou un fil vide). Sert au résumé incrémental (les N derniers tours).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`StoreError`] en cas d'échec de lecture ou de désérialisation.
|
||||||
|
async fn last(
|
||||||
|
&self,
|
||||||
|
conversation: ConversationId,
|
||||||
|
n: usize,
|
||||||
|
) -> Result<Vec<ConversationTurn>, StoreError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Résumé cumulatif de reprise d'une conversation (handoff, lot P3).
|
||||||
|
///
|
||||||
|
/// Value object pur (aucune I/O, aucun comportement) : c'est le **point de reprise**
|
||||||
|
/// portable d'une conversation (ARCHITECTURE §19.2/§19.3). À la reprise d'un fil — ou
|
||||||
|
/// au swap d'un provider à l'autre — IdeA injecte ce `summary_md` plutôt que de
|
||||||
|
/// dépendre du `resumable_id` CLI d'un provider donné. Le champ [`Handoff::up_to`]
|
||||||
|
/// est le **curseur** ([`TurnId`]) jusqu'auquel le résumé a été calculé : un recalcul
|
||||||
|
/// incrémental relit le log à partir de ce curseur ([`ConversationLog::read`] avec
|
||||||
|
/// `since`) pour étendre le résumé aux tours postérieurs.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Handoff {
|
||||||
|
/// Le résumé cumulatif, en Markdown : ce qu'un agent doit savoir pour reprendre.
|
||||||
|
pub summary_md: String,
|
||||||
|
/// Le curseur : dernier [`TurnId`] couvert par ce résumé (borne du recalcul).
|
||||||
|
pub up_to: TurnId,
|
||||||
|
/// L'objectif courant de la conversation, le cas échéant (fil sans but explicite).
|
||||||
|
pub objective: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Handoff {
|
||||||
|
/// Construit un handoff à partir de ses composants.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
summary_md: impl Into<String>,
|
||||||
|
up_to: TurnId,
|
||||||
|
objective: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
summary_md: summary_md.into(),
|
||||||
|
up_to,
|
||||||
|
objective,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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`]
|
||||||
|
/// par conversation (le dernier point de reprise), écrasé à chaque recalcul. La
|
||||||
|
/// persistance réelle (`handoff.md`) est une affaire d'infrastructure (l'adapter
|
||||||
|
/// `FsHandoffStore`, lot P3).
|
||||||
|
///
|
||||||
|
/// `#[async_trait]` et erreur [`StoreError`] comme le port voisin [`ConversationLog`] :
|
||||||
|
/// injecté en `Arc<dyn HandoffStore>` au composition root, gardé object-safe.
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait HandoffStore: Send + Sync {
|
||||||
|
/// Charge le handoff de `conversation`, ou `None` si aucun n'a encore été écrit.
|
||||||
|
///
|
||||||
|
/// L'absence d'un handoff n'est **jamais** une erreur (`Ok(None)`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`StoreError`] en cas d'échec de lecture ou de désérialisation.
|
||||||
|
async fn load(&self, conversation: ConversationId) -> Result<Option<Handoff>, StoreError>;
|
||||||
|
|
||||||
|
/// Écrit (en écrasant) le handoff de `conversation`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`StoreError`] en cas d'échec d'écriture ou de sérialisation.
|
||||||
|
async fn save(
|
||||||
|
&self,
|
||||||
|
conversation: ConversationId,
|
||||||
|
handoff: Handoff,
|
||||||
|
) -> Result<(), StoreError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Le résumeur incrémental de handoff (port driving, lot P4).
|
||||||
|
///
|
||||||
|
/// **Replie** (au sens d'un *fold*) un [`Handoff`] : on part du résumé précédent (s'il
|
||||||
|
/// existe) et on n'intègre que **l'incrément** de tours nouveaux ([`new_turns`]), sans
|
||||||
|
/// jamais relire tout le fil — c'est le seul appelant (l'application) qui relit le log à
|
||||||
|
/// partir de [`Handoff::up_to`] ([`ConversationLog::read`] avec `since`) pour fournir cet
|
||||||
|
/// incrément.
|
||||||
|
///
|
||||||
|
/// ## Pourquoi `async` (OCP, décision tranchée par Main)
|
||||||
|
///
|
||||||
|
/// Le port est **async** même si l'implémentation heuristique zéro-dépendance
|
||||||
|
/// ([`HeuristicHandoffSummarizer`] côté infra) n'`await` rien : on fige **maintenant** le
|
||||||
|
/// seam pour qu'un futur `LlmHandoffSummarizer` (P10, qui fera de l'I/O réseau async) se
|
||||||
|
/// **substitue sans modifier ni le trait ni l'application** (open/closed). `#[async_trait]`
|
||||||
|
/// pour rester object-safe (injecté en `Arc<dyn HandoffSummarizer>` au composition root).
|
||||||
|
///
|
||||||
|
/// ## Pas de `Result` (best-effort, D19-6)
|
||||||
|
///
|
||||||
|
/// Le repli est **best-effort** : il ne doit **jamais** bloquer la persistance du log
|
||||||
|
/// canonique (la source de vérité durable). Une heuristique ne peut pas échouer ; un futur
|
||||||
|
/// adapter LLM qui échouerait renverra simplement le `prev` (ou un repli heuristique) plutôt
|
||||||
|
/// que de propager une erreur. Le port ne porte donc **aucune** erreur.
|
||||||
|
///
|
||||||
|
/// [`new_turns`]: HandoffSummarizer::fold
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait HandoffSummarizer: Send + Sync {
|
||||||
|
/// Replie `prev` (s'il existe) avec **uniquement** `new_turns` et renvoie le handoff
|
||||||
|
/// étendu.
|
||||||
|
///
|
||||||
|
/// `prev` est le dernier point de reprise connu (ou `None` pour un premier calcul) ;
|
||||||
|
/// `new_turns` est l'incrément des tours postérieurs à `prev.up_to`, dans l'ordre
|
||||||
|
/// d'ajout. Le résultat couvre jusqu'au dernier tour vu (cf. [`Handoff::up_to`]).
|
||||||
|
/// Best-effort : ne renvoie jamais d'erreur (cf. note du trait).
|
||||||
|
async fn fold(&self, prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Deterministic constructors (calqués sur conversation.rs / mailbox.rs)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
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 turn(conv: ConversationId, id: TurnId, role: TurnRole, text: &str) -> ConversationTurn {
|
||||||
|
ConversationTurn::new(id, conv, 1_000, InputSource::Human, role, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn texts(turns: &[ConversationTurn]) -> Vec<String> {
|
||||||
|
turns.iter().map(|t| t.text.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// In-memory double of the port (the normal way to lock a pure port's
|
||||||
|
// contract before the Fs adapter lands in P2). Vec-backed, per conversation.
|
||||||
|
//
|
||||||
|
// The trait is async but the double's state is purely in-memory: the sync
|
||||||
|
// Mutex is locked briefly and **never** held across an `.await` point.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct InMemoryConversationLog {
|
||||||
|
threads: Mutex<HashMap<ConversationId, Vec<ConversationTurn>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ConversationLog for InMemoryConversationLog {
|
||||||
|
async fn append(
|
||||||
|
&self,
|
||||||
|
conversation: ConversationId,
|
||||||
|
turn: ConversationTurn,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
self.threads
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entry(conversation)
|
||||||
|
.or_default()
|
||||||
|
.push(turn);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read(
|
||||||
|
&self,
|
||||||
|
conversation: ConversationId,
|
||||||
|
since: Option<TurnId>,
|
||||||
|
) -> Result<Vec<ConversationTurn>, StoreError> {
|
||||||
|
let guard = self.threads.lock().unwrap();
|
||||||
|
let Some(thread) = guard.get(&conversation) else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let out = match since {
|
||||||
|
None => thread.clone(),
|
||||||
|
Some(cursor) => {
|
||||||
|
// Strictly-after semantics: everything past the cursor's position.
|
||||||
|
match thread.iter().position(|t| t.id == cursor) {
|
||||||
|
Some(idx) => thread[idx + 1..].to_vec(),
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn last(
|
||||||
|
&self,
|
||||||
|
conversation: ConversationId,
|
||||||
|
n: usize,
|
||||||
|
) -> Result<Vec<ConversationTurn>, StoreError> {
|
||||||
|
let guard = self.threads.lock().unwrap();
|
||||||
|
let Some(thread) = guard.get(&conversation) else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let start = thread.len().saturating_sub(n);
|
||||||
|
Ok(thread[start..].to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Serde round-trips (verrouille camelCase / transparent)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn turn_id_serializes_transparently() {
|
||||||
|
let u = uuid::Uuid::from_u128(7);
|
||||||
|
let id = TurnId::from_uuid(u);
|
||||||
|
let json = serde_json::to_string(&id).unwrap();
|
||||||
|
// `#[serde(transparent)]` => bare quoted uuid, no wrapper object.
|
||||||
|
assert_eq!(json, format!("\"{u}\""));
|
||||||
|
let back: TurnId = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn turn_role_serializes_camel_case() {
|
||||||
|
assert_eq!(serde_json::to_string(&TurnRole::Prompt).unwrap(), "\"prompt\"");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&TurnRole::Response).unwrap(),
|
||||||
|
"\"response\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&TurnRole::ToolActivity).unwrap(),
|
||||||
|
"\"toolActivity\""
|
||||||
|
);
|
||||||
|
for role in [TurnRole::Prompt, TurnRole::Response, TurnRole::ToolActivity] {
|
||||||
|
let json = serde_json::to_string(&role).unwrap();
|
||||||
|
let back: TurnRole = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back, role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conversation_turn_round_trips_in_camel_case() {
|
||||||
|
let t = ConversationTurn::new(
|
||||||
|
turn_id(3),
|
||||||
|
conv_id(9),
|
||||||
|
1_700_000_000_123,
|
||||||
|
InputSource::Human,
|
||||||
|
TurnRole::Prompt,
|
||||||
|
"hello",
|
||||||
|
);
|
||||||
|
let json = serde_json::to_string(&t).unwrap();
|
||||||
|
// camelCase field renaming on the struct.
|
||||||
|
assert!(json.contains("\"atMs\":1700000000123"), "got: {json}");
|
||||||
|
assert!(!json.contains("at_ms"), "snake_case leaked: {json}");
|
||||||
|
let back: ConversationTurn = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conversation_turn_round_trips_agent_source() {
|
||||||
|
let from = crate::ids::AgentId::from_uuid(uuid::Uuid::from_u128(11));
|
||||||
|
let t = ConversationTurn::new(
|
||||||
|
turn_id(4),
|
||||||
|
conv_id(9),
|
||||||
|
42,
|
||||||
|
InputSource::agent(from),
|
||||||
|
TurnRole::ToolActivity,
|
||||||
|
"ran tool",
|
||||||
|
);
|
||||||
|
let json = serde_json::to_string(&t).unwrap();
|
||||||
|
let back: ConversationTurn = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back, t);
|
||||||
|
assert_eq!(back.source.as_agent(), Some(from));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Port contract — via the in-memory double
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn append_then_read_all_preserves_insertion_order() {
|
||||||
|
let log = InMemoryConversationLog::default();
|
||||||
|
let c = conv_id(1);
|
||||||
|
log.append(c, turn(c, turn_id(1), TurnRole::Prompt, "a"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
log.append(c, turn(c, turn_id(2), TurnRole::Response, "b"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
log.append(c, turn(c, turn_id(3), TurnRole::Prompt, "c"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let all = log.read(c, None).await.unwrap();
|
||||||
|
assert_eq!(texts(&all), vec!["a", "b", "c"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_with_cursor_is_strictly_exclusive() {
|
||||||
|
let log = InMemoryConversationLog::default();
|
||||||
|
let c = conv_id(1);
|
||||||
|
for (id, txt) in [(1, "a"), (2, "b"), (3, "c")] {
|
||||||
|
log.append(c, turn(c, turn_id(id), TurnRole::Prompt, txt))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
// since = id of "a" => only the strictly-posterior tours, "a" excluded.
|
||||||
|
let after_a = log.read(c, Some(turn_id(1))).await.unwrap();
|
||||||
|
assert_eq!(texts(&after_a), vec!["b", "c"]);
|
||||||
|
|
||||||
|
let after_b = log.read(c, Some(turn_id(2))).await.unwrap();
|
||||||
|
assert_eq!(texts(&after_b), vec!["c"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_cursor_on_last_id_yields_empty() {
|
||||||
|
let log = InMemoryConversationLog::default();
|
||||||
|
let c = conv_id(1);
|
||||||
|
log.append(c, turn(c, turn_id(1), TurnRole::Prompt, "a"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
log.append(c, turn(c, turn_id(2), TurnRole::Response, "b"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let after_last = log.read(c, Some(turn_id(2))).await.unwrap();
|
||||||
|
assert!(after_last.is_empty(), "cursor on last id => nothing after");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn last_zero_is_empty() {
|
||||||
|
let log = InMemoryConversationLog::default();
|
||||||
|
let c = conv_id(1);
|
||||||
|
log.append(c, turn(c, turn_id(1), TurnRole::Prompt, "a"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(log.last(c, 0).await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn last_on_empty_thread_is_empty() {
|
||||||
|
let log = InMemoryConversationLog::default();
|
||||||
|
// Never appended to this conversation.
|
||||||
|
assert!(log.last(conv_id(99), 5).await.unwrap().is_empty());
|
||||||
|
assert!(log.read(conv_id(99), None).await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn last_n_greater_than_len_returns_everything() {
|
||||||
|
let log = InMemoryConversationLog::default();
|
||||||
|
let c = conv_id(1);
|
||||||
|
for (id, txt) in [(1, "a"), (2, "b")] {
|
||||||
|
log.append(c, turn(c, turn_id(id), TurnRole::Prompt, txt))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let last = log.last(c, 10).await.unwrap();
|
||||||
|
assert_eq!(texts(&last), vec!["a", "b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn last_n_less_than_len_returns_the_n_last_in_order() {
|
||||||
|
let log = InMemoryConversationLog::default();
|
||||||
|
let c = conv_id(1);
|
||||||
|
for (id, txt) in [(1, "a"), (2, "b"), (3, "c"), (4, "d")] {
|
||||||
|
log.append(c, turn(c, turn_id(id), TurnRole::Prompt, txt))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let last2 = log.last(c, 2).await.unwrap();
|
||||||
|
assert_eq!(texts(&last2), vec!["c", "d"], "the 2 last, in insertion order");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn conversations_are_disjoint_threads() {
|
||||||
|
let log = InMemoryConversationLog::default();
|
||||||
|
let c1 = conv_id(1);
|
||||||
|
let c2 = conv_id(2);
|
||||||
|
log.append(c1, turn(c1, turn_id(1), TurnRole::Prompt, "c1-a"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
log.append(c2, turn(c2, turn_id(2), TurnRole::Prompt, "c2-a"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
log.append(c1, turn(c1, turn_id(3), TurnRole::Response, "c1-b"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(texts(&log.read(c1, None).await.unwrap()), vec!["c1-a", "c1-b"]);
|
||||||
|
assert_eq!(texts(&log.read(c2, None).await.unwrap()), vec!["c2-a"]);
|
||||||
|
// A cursor from one thread never leaks tours from another.
|
||||||
|
assert_eq!(texts(&log.last(c2, 10).await.unwrap()), vec!["c2-a"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,6 +32,7 @@
|
|||||||
|
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
|
pub mod conversation_log;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod fileguard;
|
pub mod fileguard;
|
||||||
@ -85,6 +86,10 @@ pub use conversation::{
|
|||||||
|
|
||||||
pub use input::{AgentBusyState, InputMediator, InputSource};
|
pub use input::{AgentBusyState, InputMediator, InputSource};
|
||||||
|
|
||||||
|
pub use conversation_log::{
|
||||||
|
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer, TurnId, TurnRole,
|
||||||
|
};
|
||||||
|
|
||||||
pub use fileguard::{
|
pub use fileguard::{
|
||||||
is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease,
|
is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease,
|
||||||
WriteLease,
|
WriteLease,
|
||||||
|
|||||||
217
crates/infrastructure/src/conversation_log/handoff.rs
Normal file
217
crates/infrastructure/src/conversation_log/handoff.rs
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
//! [`FsHandoffStore`] — l'adapter `tokio::fs` du port [`HandoffStore`]
|
||||||
|
//! (cadrage « persistance conversationnelle », lot P3).
|
||||||
|
//!
|
||||||
|
//! Le **point de reprise** d'une conversation (ARCHITECTURE §19.2/§19.3) est un
|
||||||
|
//! [`Handoff`] : un résumé cumulatif Markdown borné par un curseur [`TurnId`]. On en
|
||||||
|
//! garde **un seul** par conversation (le dernier), dans un fichier lisible à l'œil :
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! <project_root>/.ideai/conversations/
|
||||||
|
//! └── <conversationId>/
|
||||||
|
//! ├── log.jsonl # le log append-only (lot P2)
|
||||||
|
//! └── handoff.md # le dernier point de reprise (ce module)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Format `handoff.md`
|
||||||
|
//!
|
||||||
|
//! Un **front-matter** YAML délimité par `---`, suivi du corps `summary_md` tel quel :
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! ---
|
||||||
|
//! upTo: 00000000-0000-0000-0000-00000000002a
|
||||||
|
//! objective: livrer le lot P3
|
||||||
|
//! ---
|
||||||
|
//! # Résumé
|
||||||
|
//! …le summary_md, octet pour octet…
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Le front-matter porte le curseur `upTo` (toujours) et `objective` (seulement s'il
|
||||||
|
//! est `Some`). Le corps après le second `---\n` est le `summary_md` **exact** : le
|
||||||
|
//! round-trip (`save` puis `load`) redonne le même [`Handoff`]. `objective` étant
|
||||||
|
//! sérialisé sur une seule ligne, un objectif est interdit de retour-chariot ici ; en
|
||||||
|
//! pratique c'est une phrase courte (un titre de but), jamais du multi-ligne.
|
||||||
|
//!
|
||||||
|
//! ## Robustesse
|
||||||
|
//!
|
||||||
|
//! - **Écriture atomique** : on écrit dans `handoff.md.tmp` puis on `rename` — jamais
|
||||||
|
//! de fichier à moitié écrit visible.
|
||||||
|
//! - **Fichier absent** ⇒ `Ok(None)` (jamais une erreur) : une conversation sans
|
||||||
|
//! reprise est l'état normal au premier tour.
|
||||||
|
//! - **Fichier présent mais illisible** (front-matter absent/incomplet, `upTo`
|
||||||
|
//! manquant ou non-UUID) ⇒ [`StoreError::Serialization`]. Contrairement au log
|
||||||
|
//! JSONL (où une ligne corrompue est sautée), un handoff corrompu est une vraie
|
||||||
|
//! erreur : il n'y a qu'un enregistrement, on ne peut pas « sauter ».
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use domain::conversation::ConversationId;
|
||||||
|
use domain::conversation_log::{Handoff, HandoffStore, TurnId};
|
||||||
|
use domain::ports::StoreError;
|
||||||
|
use domain::project::ProjectPath;
|
||||||
|
|
||||||
|
use super::{CONVERSATIONS_DIR, IDEAI_DIR};
|
||||||
|
|
||||||
|
/// Nom du fichier de handoff, par conversation.
|
||||||
|
const HANDOFF_FILE: &str = "handoff.md";
|
||||||
|
|
||||||
|
/// Nom du fichier temporaire d'écriture atomique (renommé sur `handoff.md`).
|
||||||
|
const HANDOFF_TMP_FILE: &str = "handoff.md.tmp";
|
||||||
|
|
||||||
|
/// Délimiteur de front-matter (en tête et fin de l'entête).
|
||||||
|
const FRONT_MATTER_FENCE: &str = "---";
|
||||||
|
|
||||||
|
/// Adapter `tokio::fs` du store de handoff, un `handoff.md` par conversation.
|
||||||
|
///
|
||||||
|
/// Même convention de construction que [`super::FsConversationLog`] : le **project
|
||||||
|
/// root** est fourni au constructeur, la base `<root>/.ideai/conversations` en dérive,
|
||||||
|
/// et chaque conversation a son sous-dossier `<conversationId>/`.
|
||||||
|
pub struct FsHandoffStore {
|
||||||
|
/// Racine `<project_root>/.ideai/conversations`.
|
||||||
|
base: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsHandoffStore {
|
||||||
|
/// Construit l'adapter à partir du **project root**.
|
||||||
|
///
|
||||||
|
/// La base `<root>/.ideai/conversations` en est dérivée ; le dossier de
|
||||||
|
/// conversation est créé paresseusement au premier `save`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(root: &ProjectPath) -> Self {
|
||||||
|
let base = PathBuf::from(root.as_str())
|
||||||
|
.join(IDEAI_DIR)
|
||||||
|
.join(CONVERSATIONS_DIR);
|
||||||
|
Self { base }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<base>/<conversationId>` — le dossier d'une conversation.
|
||||||
|
fn conversation_dir(&self, conversation: ConversationId) -> PathBuf {
|
||||||
|
self.base.join(conversation.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<base>/<conversationId>/handoff.md` — le fichier de handoff d'une conversation.
|
||||||
|
fn handoff_path(&self, conversation: ConversationId) -> PathBuf {
|
||||||
|
self.conversation_dir(conversation).join(HANDOFF_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<base>/<conversationId>/handoff.md.tmp` — le fichier temporaire d'écriture.
|
||||||
|
fn handoff_tmp_path(&self, conversation: ConversationId) -> PathBuf {
|
||||||
|
self.conversation_dir(conversation).join(HANDOFF_TMP_FILE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sérialise un [`Handoff`] au format `handoff.md` (front-matter + corps exact).
|
||||||
|
fn serialize(handoff: &Handoff) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str(FRONT_MATTER_FENCE);
|
||||||
|
out.push('\n');
|
||||||
|
out.push_str(&format!("upTo: {}\n", handoff.up_to));
|
||||||
|
if let Some(objective) = &handoff.objective {
|
||||||
|
out.push_str(&format!("objective: {objective}\n"));
|
||||||
|
}
|
||||||
|
out.push_str(FRONT_MATTER_FENCE);
|
||||||
|
out.push('\n');
|
||||||
|
// Corps : le summary_md tel quel, octet pour octet.
|
||||||
|
out.push_str(&handoff.summary_md);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse le contenu d'un `handoff.md` en [`Handoff`].
|
||||||
|
///
|
||||||
|
/// Format attendu : `---\n<clé: valeur>*\n---\n<summary_md>`. Le corps après le second
|
||||||
|
/// `---\n` est rendu **exact**. Toute déviation (front-matter absent/incomplet, `upTo`
|
||||||
|
/// manquant ou non-UUID) ⇒ `Err(StoreError::Serialization)`.
|
||||||
|
fn deserialize(content: &str) -> Result<Handoff, StoreError> {
|
||||||
|
let bad = |msg: &str| StoreError::Serialization(format!("handoff.md: {msg}"));
|
||||||
|
|
||||||
|
// Entête : `---\n` exactement en tête.
|
||||||
|
let after_open = content
|
||||||
|
.strip_prefix(FRONT_MATTER_FENCE)
|
||||||
|
.and_then(|rest| rest.strip_prefix('\n'))
|
||||||
|
.ok_or_else(|| bad("front-matter ouvrant `---` absent"))?;
|
||||||
|
|
||||||
|
// Fin de l'entête : la première ligne `---\n` (ou `---` en toute fin).
|
||||||
|
// On sépare les lignes du front-matter du corps.
|
||||||
|
let mut up_to: Option<TurnId> = None;
|
||||||
|
let mut objective: Option<String> = None;
|
||||||
|
|
||||||
|
// Trouver le `---` de fermeture, ligne par ligne.
|
||||||
|
let mut rest = after_open;
|
||||||
|
loop {
|
||||||
|
// Découpe la prochaine ligne (jusqu'au `\n` inclus si présent).
|
||||||
|
let (line, tail) = match rest.find('\n') {
|
||||||
|
Some(idx) => (&rest[..idx], &rest[idx + 1..]),
|
||||||
|
None => (rest, ""),
|
||||||
|
};
|
||||||
|
|
||||||
|
if line == FRONT_MATTER_FENCE {
|
||||||
|
// Fermeture trouvée : `tail` est le corps exact.
|
||||||
|
let up_to = up_to.ok_or_else(|| bad("clé `upTo` absente du front-matter"))?;
|
||||||
|
return Ok(Handoff {
|
||||||
|
summary_md: tail.to_string(),
|
||||||
|
up_to,
|
||||||
|
objective,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Une ligne de clé: valeur dans le front-matter.
|
||||||
|
let (key, value) = line
|
||||||
|
.split_once(':')
|
||||||
|
.ok_or_else(|| bad("ligne de front-matter sans `:`"))?;
|
||||||
|
let value = value.trim();
|
||||||
|
match key.trim() {
|
||||||
|
"upTo" => {
|
||||||
|
let uuid = uuid::Uuid::parse_str(value)
|
||||||
|
.map_err(|_| bad("`upTo` n'est pas un UUID valide"))?;
|
||||||
|
up_to = Some(TurnId::from_uuid(uuid));
|
||||||
|
}
|
||||||
|
"objective" => objective = Some(value.to_string()),
|
||||||
|
// Clé inconnue : tolérée (extensible), ignorée.
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tail.is_empty() {
|
||||||
|
// Plus de lignes et toujours pas de `---` fermant.
|
||||||
|
return Err(bad("front-matter fermant `---` absent"));
|
||||||
|
}
|
||||||
|
rest = tail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl HandoffStore for FsHandoffStore {
|
||||||
|
async fn load(&self, conversation: ConversationId) -> Result<Option<Handoff>, StoreError> {
|
||||||
|
let content = match tokio::fs::read_to_string(self.handoff_path(conversation)).await {
|
||||||
|
Ok(content) => content,
|
||||||
|
// Absent ⇒ pas de reprise (jamais une erreur).
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(e) => return Err(StoreError::Io(e.to_string())),
|
||||||
|
};
|
||||||
|
deserialize(&content).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(
|
||||||
|
&self,
|
||||||
|
conversation: ConversationId,
|
||||||
|
handoff: Handoff,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
let body = serialize(&handoff);
|
||||||
|
|
||||||
|
let dir = self.conversation_dir(conversation);
|
||||||
|
tokio::fs::create_dir_all(&dir)
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
// Écriture atomique : écrire le tmp puis `rename` sur la cible. Un lecteur ne
|
||||||
|
// voit jamais de fichier à moitié écrit (le rename est atomique sur le FS).
|
||||||
|
let tmp = self.handoff_tmp_path(conversation);
|
||||||
|
tokio::fs::write(&tmp, body.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||||
|
tokio::fs::rename(&tmp, self.handoff_path(conversation))
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
209
crates/infrastructure/src/conversation_log/mod.rs
Normal file
209
crates/infrastructure/src/conversation_log/mod.rs
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
//! [`FsConversationLog`] — l'adapter `tokio::fs` du port [`ConversationLog`]
|
||||||
|
//! (cadrage « persistance conversationnelle », lot P2).
|
||||||
|
//!
|
||||||
|
//! La **source de vérité durable** d'une conversation (ARCHITECTURE §19, D19-1a)
|
||||||
|
//! est un log **append-only**, **un fichier JSONL par conversation (paire)** sous
|
||||||
|
//! le project root :
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! <project_root>/.ideai/conversations/
|
||||||
|
//! └── <conversationId>/
|
||||||
|
//! └── log.jsonl # un ConversationTurn JSON par ligne, dans l'ordre d'ajout
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Chaque ligne est un [`ConversationTurn`] sérialisé en JSON (`serde_json`), suivi
|
||||||
|
//! d'un `\n`. Deux conversations sont **disjointes** : chacune a son propre dossier.
|
||||||
|
//!
|
||||||
|
//! ## Robustesse (survivre à un crash)
|
||||||
|
//!
|
||||||
|
//! Le log doit survivre à une **ligne tronquée** par un crash en plein milieu d'une
|
||||||
|
//! écriture : à la relecture, une ligne **illisible/corrompue est silencieusement
|
||||||
|
//! ignorée** (jamais de panic, jamais d'erreur dure). Un fichier **absent** est une
|
||||||
|
//! conversation vide (un `Vec` vide, pas une erreur). Seules les vraies erreurs d'I/O
|
||||||
|
//! (hors « absent », hors « ligne corrompue ») remontent en [`StoreError::Io`].
|
||||||
|
//!
|
||||||
|
//! ## Concurrence
|
||||||
|
//!
|
||||||
|
//! L'écriture est **sérialisée par conversation** par un `Mutex` async dédié à
|
||||||
|
//! chaque fichier (registre `paths → Arc<tokio::sync::Mutex<()>>`), tenu le temps
|
||||||
|
//! de l'`append`. Deux `append` sur des conversations **différentes** n'entrent
|
||||||
|
//! jamais en contention sur la donnée (verrous distincts) ; ils ne se croisent que
|
||||||
|
//! brièvement sur le registre. (P9 ajoutera un `FileGuard` plus large si un besoin
|
||||||
|
//! réel d'arbitrage lecture/écriture émerge — ici on ne sur-conçoit pas.)
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
use domain::conversation::ConversationId;
|
||||||
|
use domain::conversation_log::{ConversationLog, ConversationTurn, TurnId};
|
||||||
|
use domain::ports::StoreError;
|
||||||
|
use domain::project::ProjectPath;
|
||||||
|
|
||||||
|
mod handoff;
|
||||||
|
mod summarizer;
|
||||||
|
|
||||||
|
pub use handoff::FsHandoffStore;
|
||||||
|
pub use summarizer::{HeuristicHandoffSummarizer, WINDOW};
|
||||||
|
|
||||||
|
/// Dossier `.ideai/` à la racine d'un project root.
|
||||||
|
pub(crate) const IDEAI_DIR: &str = ".ideai";
|
||||||
|
|
||||||
|
/// Sous-dossier des logs de conversation dans `.ideai/`.
|
||||||
|
pub(crate) const CONVERSATIONS_DIR: &str = "conversations";
|
||||||
|
|
||||||
|
/// Nom du fichier log JSONL, par conversation.
|
||||||
|
const LOG_FILE: &str = "log.jsonl";
|
||||||
|
|
||||||
|
/// Adapter `tokio::fs` du log canonique append-only, un `log.jsonl` par conversation.
|
||||||
|
///
|
||||||
|
/// Le **project root** est fourni au constructeur (comme [`crate::FsProjectStore`] et
|
||||||
|
/// l'orchestrateur fichier reçoivent leur racine) : une instance sert toutes les
|
||||||
|
/// conversations d'un même projet. Tous les chemins en dérivent.
|
||||||
|
pub struct FsConversationLog {
|
||||||
|
/// Racine `<project_root>/.ideai/conversations`.
|
||||||
|
base: PathBuf,
|
||||||
|
/// Verrous d'écriture, un par fichier de conversation (sérialise les `append`).
|
||||||
|
write_locks: Mutex<HashMap<ConversationId, Arc<tokio::sync::Mutex<()>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsConversationLog {
|
||||||
|
/// Construit l'adapter à partir du **project root**.
|
||||||
|
///
|
||||||
|
/// La base `<root>/.ideai/conversations` en est dérivée ; les dossiers de
|
||||||
|
/// conversation sont créés paresseusement au premier `append`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(root: &ProjectPath) -> Self {
|
||||||
|
let base = PathBuf::from(root.as_str())
|
||||||
|
.join(IDEAI_DIR)
|
||||||
|
.join(CONVERSATIONS_DIR);
|
||||||
|
Self {
|
||||||
|
base,
|
||||||
|
write_locks: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<base>/<conversationId>` — le dossier d'une conversation.
|
||||||
|
fn conversation_dir(&self, conversation: ConversationId) -> PathBuf {
|
||||||
|
self.base.join(conversation.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<base>/<conversationId>/log.jsonl` — le fichier log d'une conversation.
|
||||||
|
fn log_path(&self, conversation: ConversationId) -> PathBuf {
|
||||||
|
self.conversation_dir(conversation).join(LOG_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renvoie (en le créant au besoin) le verrou d'écriture de `conversation`.
|
||||||
|
fn write_lock(&self, conversation: ConversationId) -> Arc<tokio::sync::Mutex<()>> {
|
||||||
|
self.write_locks
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.entry(conversation)
|
||||||
|
.or_default()
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lit et parse tout le fil de `conversation`, dans l'ordre d'ajout.
|
||||||
|
///
|
||||||
|
/// Fichier absent ⇒ `Vec` vide. Une ligne illisible (UTF-8 invalide ou JSON
|
||||||
|
/// corrompu) est **silencieusement ignorée** : le log survit à une ligne tronquée
|
||||||
|
/// par un crash. Seule une vraie erreur d'I/O remonte.
|
||||||
|
async fn read_all(
|
||||||
|
&self,
|
||||||
|
conversation: ConversationId,
|
||||||
|
) -> Result<Vec<ConversationTurn>, StoreError> {
|
||||||
|
let bytes = match tokio::fs::read(self.log_path(conversation)).await {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||||
|
Err(e) => return Err(StoreError::Io(e.to_string())),
|
||||||
|
};
|
||||||
|
// UTF-8 partiel (ex. fichier tronqué) : on décode en lossy plutôt que d'échouer ;
|
||||||
|
// une ligne devenue invalide ne parsera simplement pas en JSON et sera ignorée.
|
||||||
|
let text = String::from_utf8_lossy(&bytes);
|
||||||
|
let turns = text
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.trim().is_empty())
|
||||||
|
// Ligne corrompue/illisible ⇒ skip silencieux (jamais d'erreur dure).
|
||||||
|
.filter_map(|line| serde_json::from_str::<ConversationTurn>(line).ok())
|
||||||
|
.collect();
|
||||||
|
Ok(turns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ConversationLog for FsConversationLog {
|
||||||
|
async fn append(
|
||||||
|
&self,
|
||||||
|
conversation: ConversationId,
|
||||||
|
turn: ConversationTurn,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
// Sérialiser **avant** d'ouvrir le fichier : une erreur de sérialisation ne doit
|
||||||
|
// pas laisser le fichier ouvert ni écrire de ligne partielle.
|
||||||
|
let mut line =
|
||||||
|
serde_json::to_string(&turn).map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||||
|
line.push('\n');
|
||||||
|
|
||||||
|
// Écriture sérialisée par conversation : le verrou est tenu le temps de
|
||||||
|
// create_dir_all + open(append) + write, donc deux `append` concurrents sur la
|
||||||
|
// même conversation s'ordonnent (pas d'entrelacement de lignes).
|
||||||
|
let lock = self.write_lock(conversation);
|
||||||
|
let _guard = lock.lock().await;
|
||||||
|
|
||||||
|
let dir = self.conversation_dir(conversation);
|
||||||
|
tokio::fs::create_dir_all(&dir)
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut file = tokio::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(self.log_path(conversation))
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||||
|
file.write_all(line.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||||
|
// Le `File` async de tokio met l'écriture en file vers une tâche blocante ;
|
||||||
|
// droppé sans flush, l'écriture en vol du dernier `append` peut être jetée.
|
||||||
|
// `sync_all` force le drainage **et** la durabilité crash promise par l'en-tête
|
||||||
|
// du module (survivre à un crash en plein milieu d'une écriture).
|
||||||
|
file.sync_all()
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read(
|
||||||
|
&self,
|
||||||
|
conversation: ConversationId,
|
||||||
|
since: Option<TurnId>,
|
||||||
|
) -> Result<Vec<ConversationTurn>, StoreError> {
|
||||||
|
let all = self.read_all(conversation).await?;
|
||||||
|
let out = match since {
|
||||||
|
None => all,
|
||||||
|
// Curseur **exclusif** : tout ce qui suit strictement le tour `cursor`.
|
||||||
|
// Curseur introuvable ⇒ rien (cohérent avec le double in-memory du port).
|
||||||
|
Some(cursor) => match all.iter().position(|t| t.id == cursor) {
|
||||||
|
Some(idx) => all[idx + 1..].to_vec(),
|
||||||
|
None => Vec::new(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn last(
|
||||||
|
&self,
|
||||||
|
conversation: ConversationId,
|
||||||
|
n: usize,
|
||||||
|
) -> Result<Vec<ConversationTurn>, StoreError> {
|
||||||
|
if n == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let all = self.read_all(conversation).await?;
|
||||||
|
let start = all.len().saturating_sub(n);
|
||||||
|
Ok(all[start..].to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
166
crates/infrastructure/src/conversation_log/summarizer.rs
Normal file
166
crates/infrastructure/src/conversation_log/summarizer.rs
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
//! [`HeuristicHandoffSummarizer`] — l'adapter zéro-dépendance du port
|
||||||
|
//! [`HandoffSummarizer`] (cadrage « persistance conversationnelle », lot P4).
|
||||||
|
//!
|
||||||
|
//! Replie un [`Handoff`] de façon **incrémentale**, **déterministe**, **sans I/O** et
|
||||||
|
//! **sans modèle** (ARCHITECTURE §19.2/§19.3, ligne P4 du §19.6). C'est le repli
|
||||||
|
//! universel zéro-dépendance ; un `LlmHandoffSummarizer` plus riche viendra le substituer
|
||||||
|
//! en P10 (OCP — le port async est figé pour ça, cf. [`HandoffSummarizer`]).
|
||||||
|
//!
|
||||||
|
//! ## Stratégie heuristique
|
||||||
|
//!
|
||||||
|
//! Le résumé garde deux choses, en ne lisant que **l'incrément** (`new_turns`) — jamais
|
||||||
|
//! tout le fil depuis zéro :
|
||||||
|
//!
|
||||||
|
//! 1. **Un objectif courant** ([`Handoff::objective`]) : repris **tel quel** de `prev`.
|
||||||
|
//! Si `prev` n'en a pas (ou est `None`), on en **extrait** un depuis le **premier
|
||||||
|
//! tour `Prompt`** de l'incrément (sa première ligne non vide, tronquée) — règle simple
|
||||||
|
//! et déterministe : le premier prompt d'un fil énonce typiquement la tâche. Une fois
|
||||||
|
//! fixé, l'objectif ne change plus (on ne le réécrit pas à chaque tour).
|
||||||
|
//!
|
||||||
|
//! 2. **Une fenêtre des [`WINDOW`] derniers tours** rendue en Markdown. Comme on ne
|
||||||
|
//! dispose pas de tout le fil dans `fold`, on **reconstitue** cette fenêtre à partir des
|
||||||
|
//! tours déjà rendus dans `prev.summary_md` (reparsés) **concaténés** à `new_turns`,
|
||||||
|
//! puis on **tronque** aux [`WINDOW`] derniers. La borne est donc toujours respectée,
|
||||||
|
//! même après de nombreux replis successifs.
|
||||||
|
//!
|
||||||
|
//! Le `summary_md` résultant = (ligne d'objectif si présent) + les [`WINDOW`] derniers
|
||||||
|
//! tours formatés. Format d'un tour stable et reparsable (cf. [`render_turn`]).
|
||||||
|
//!
|
||||||
|
//! ## Curseur (`up_to`)
|
||||||
|
//!
|
||||||
|
//! - `new_turns` non vide ⇒ l'id du **dernier** tour de l'incrément.
|
||||||
|
//! - `new_turns` vide ⇒ on renvoie `prev` **inchangé** (rien de neuf à intégrer).
|
||||||
|
//! - `prev = None` et `new_turns` vide ⇒ handoff vide, curseur = **nil UUID**
|
||||||
|
//! ([`TurnId::from_uuid(Uuid::nil())`]) : sentinelle sûre « aucun tour couvert ».
|
||||||
|
//!
|
||||||
|
//! ## Déterminisme
|
||||||
|
//!
|
||||||
|
//! Mêmes entrées ⇒ même sortie (aucune horloge, aucun aléa, aucune I/O), donc trivialement
|
||||||
|
//! testable.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use domain::conversation_log::{ConversationTurn, Handoff, HandoffSummarizer, TurnId, TurnRole};
|
||||||
|
|
||||||
|
/// Nombre maximum de tours conservés dans la fenêtre Markdown du résumé.
|
||||||
|
///
|
||||||
|
/// Borne **publique** (et donc lisible par l'agent Test pour vérifier la troncature sans
|
||||||
|
/// la deviner) : au-delà de `WINDOW` tours, seuls les `WINDOW` derniers sont rendus.
|
||||||
|
pub const WINDOW: usize = 20;
|
||||||
|
|
||||||
|
/// Longueur maximale (en caractères) de l'objectif extrait d'un premier prompt.
|
||||||
|
const OBJECTIVE_MAX_CHARS: usize = 200;
|
||||||
|
|
||||||
|
/// Préfixe de la ligne d'objectif dans le `summary_md` (sert au rendu **et** au reparse).
|
||||||
|
const OBJECTIVE_PREFIX: &str = "**Objectif :** ";
|
||||||
|
|
||||||
|
/// Adapter heuristique zéro-dépendance du résumeur de handoff.
|
||||||
|
///
|
||||||
|
/// Sans état (aucun champ) : une seule instance sert toutes les conversations. Construit
|
||||||
|
/// via [`HeuristicHandoffSummarizer::new`] ou [`Default`].
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct HeuristicHandoffSummarizer;
|
||||||
|
|
||||||
|
impl HeuristicHandoffSummarizer {
|
||||||
|
/// Construit le résumeur heuristique (sans état).
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rend un tour en une ligne Markdown stable et **reparsable** (cf. [`parse_rendered`]).
|
||||||
|
///
|
||||||
|
/// Format : `- **<role>:** <texte sur une ligne>`. Le texte est aplati (sauts de ligne →
|
||||||
|
/// espaces) pour garder une ligne par tour, ce qui rend la fenêtre reconstituable depuis
|
||||||
|
/// `prev.summary_md`.
|
||||||
|
fn render_turn(turn: &ConversationTurn) -> String {
|
||||||
|
let label = match turn.role {
|
||||||
|
TurnRole::Prompt => "Prompt",
|
||||||
|
TurnRole::Response => "Response",
|
||||||
|
TurnRole::ToolActivity => "Tool",
|
||||||
|
};
|
||||||
|
let flat = flatten(&turn.text);
|
||||||
|
format!("- **{label}:** {flat}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aplati un texte multi-lignes en une seule ligne (sauts de ligne → espace, trim).
|
||||||
|
fn flatten(text: &str) -> String {
|
||||||
|
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reparse les lignes de tours déjà rendues dans un `summary_md` précédent.
|
||||||
|
///
|
||||||
|
/// Ne récupère que les lignes-tours (préfixe `- **`), en ignorant la ligne d'objectif et
|
||||||
|
/// les blancs. On garde la **ligne brute** (déjà au bon format) : pas besoin de
|
||||||
|
/// reconstruire un `ConversationTurn` complet, on ne manipule que du Markdown.
|
||||||
|
fn parse_rendered(summary_md: &str) -> Vec<String> {
|
||||||
|
summary_md
|
||||||
|
.lines()
|
||||||
|
.filter(|l| l.starts_with("- **"))
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extrait un objectif candidat du premier tour `Prompt` de l'incrément, le cas échéant.
|
||||||
|
///
|
||||||
|
/// Première ligne non vide du premier prompt, aplatie et tronquée à [`OBJECTIVE_MAX_CHARS`].
|
||||||
|
fn extract_objective(new_turns: &[ConversationTurn]) -> Option<String> {
|
||||||
|
let first_prompt = new_turns.iter().find(|t| t.role == TurnRole::Prompt)?;
|
||||||
|
let flat = flatten(&first_prompt.text);
|
||||||
|
if flat.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let truncated: String = flat.chars().take(OBJECTIVE_MAX_CHARS).collect();
|
||||||
|
Some(truncated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assemble le `summary_md` final = ligne d'objectif (si présent) + fenêtre de tours.
|
||||||
|
fn render_summary(objective: Option<&str>, window: &[String]) -> String {
|
||||||
|
let mut blocks: Vec<String> = Vec::new();
|
||||||
|
if let Some(obj) = objective {
|
||||||
|
blocks.push(format!("{OBJECTIVE_PREFIX}{obj}"));
|
||||||
|
}
|
||||||
|
if !window.is_empty() {
|
||||||
|
blocks.push(window.join("\n"));
|
||||||
|
}
|
||||||
|
blocks.join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl HandoffSummarizer for HeuristicHandoffSummarizer {
|
||||||
|
async fn fold(&self, prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff {
|
||||||
|
// Rien de neuf : on rend `prev` inchangé (ou un handoff vide cohérent si None).
|
||||||
|
if new_turns.is_empty() {
|
||||||
|
return prev.unwrap_or_else(|| {
|
||||||
|
Handoff::new(String::new(), TurnId::from_uuid(uuid::Uuid::nil()), None)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Objectif : repris de `prev` s'il existe, sinon extrait du premier prompt de
|
||||||
|
// l'incrément (règle simple et déterministe ; figé une fois fixé).
|
||||||
|
let objective = prev
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|h| h.objective.clone())
|
||||||
|
.or_else(|| extract_objective(new_turns));
|
||||||
|
|
||||||
|
// Fenêtre : (tours déjà rendus dans prev) ++ (incrément rendu), tronquée aux
|
||||||
|
// WINDOW derniers. On ne relit jamais tout le log : seul l'incrément est nouveau.
|
||||||
|
let mut window: Vec<String> = prev
|
||||||
|
.as_ref()
|
||||||
|
.map(|h| parse_rendered(&h.summary_md))
|
||||||
|
.unwrap_or_default();
|
||||||
|
window.extend(new_turns.iter().map(render_turn));
|
||||||
|
let start = window.len().saturating_sub(WINDOW);
|
||||||
|
let window = &window[start..];
|
||||||
|
|
||||||
|
// Curseur : l'id du dernier tour de l'incrément (new_turns non vide ici).
|
||||||
|
let up_to = new_turns
|
||||||
|
.last()
|
||||||
|
.map(|t| t.id)
|
||||||
|
.unwrap_or_else(|| TurnId::from_uuid(uuid::Uuid::nil()));
|
||||||
|
|
||||||
|
let summary_md = render_summary(objective.as_deref(), window);
|
||||||
|
Handoff::new(summary_md, up_to, objective)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
pub mod clock;
|
pub mod clock;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
|
pub mod conversation_log;
|
||||||
pub mod eventbus;
|
pub mod eventbus;
|
||||||
pub mod fileguard;
|
pub mod fileguard;
|
||||||
pub mod fs;
|
pub mod fs;
|
||||||
@ -32,6 +33,7 @@ pub mod store;
|
|||||||
|
|
||||||
pub use clock::SystemClock;
|
pub use clock::SystemClock;
|
||||||
pub use conversation::InMemoryConversationRegistry;
|
pub use conversation::InMemoryConversationRegistry;
|
||||||
|
pub use conversation_log::{FsConversationLog, FsHandoffStore, HeuristicHandoffSummarizer, WINDOW};
|
||||||
pub use eventbus::TokioBroadcastEventBus;
|
pub use eventbus::TokioBroadcastEventBus;
|
||||||
pub use fileguard::RwFileGuard;
|
pub use fileguard::RwFileGuard;
|
||||||
pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
|
pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
|
||||||
|
|||||||
763
crates/infrastructure/tests/conversation_log.rs
Normal file
763
crates/infrastructure/tests/conversation_log.rs
Normal file
@ -0,0 +1,763 @@
|
|||||||
|
//! L2 integration tests for [`FsConversationLog`] against a **real** temp directory
|
||||||
|
//! (cadrage « persistance conversationnelle », lot P2, critères §19.6).
|
||||||
|
//!
|
||||||
|
//! These lock the *durable* behaviour the in-memory double of P1 cannot prove:
|
||||||
|
//! - one JSONL line per appended turn, each line valid JSON;
|
||||||
|
//! - persistence survives a "restart" (a fresh instance on the same root relits all);
|
||||||
|
//! - two conversations land in two disjoint `log.jsonl` files (no leak);
|
||||||
|
//! - a corrupted/truncated line is silently skipped (no panic, no hard error);
|
||||||
|
//! - missing file/conversation => empty (never an error);
|
||||||
|
//! - the cursor/`last` contract (exclusive `since`, `last(0)`, `n>len`, `n<len`);
|
||||||
|
//! - two concurrent appends on the same conversation => 2 lines, no corruption.
|
||||||
|
//!
|
||||||
|
//! Convention: a hand-rolled [`TempDir`] over the OS temp dir (calqué sur
|
||||||
|
//! `project_store.rs`) — it yields an **absolute** path, as `ProjectPath::new`
|
||||||
|
//! requires, and is cleaned up on drop. No extra dependency is pulled in.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use domain::conversation::ConversationId;
|
||||||
|
use domain::conversation_log::{
|
||||||
|
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer, TurnId, TurnRole,
|
||||||
|
};
|
||||||
|
use domain::input::InputSource;
|
||||||
|
use domain::ports::StoreError;
|
||||||
|
use domain::project::ProjectPath;
|
||||||
|
use infrastructure::{FsConversationLog, FsHandoffStore, HeuristicHandoffSummarizer, WINDOW};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test scaffolding
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A unique scratch directory under the OS temp dir, cleaned up on drop. Its path
|
||||||
|
/// is absolute, so `ProjectPath::new` accepts it directly as a project root.
|
||||||
|
struct TempDir(PathBuf);
|
||||||
|
impl TempDir {
|
||||||
|
fn new() -> Self {
|
||||||
|
let p = std::env::temp_dir().join(format!("idea-l2-convlog-{}", Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&p).unwrap();
|
||||||
|
Self(p)
|
||||||
|
}
|
||||||
|
/// The project root as a [`ProjectPath`] (absolute, as the composition root passes).
|
||||||
|
fn project_path(&self) -> ProjectPath {
|
||||||
|
ProjectPath::new(self.0.to_string_lossy().into_owned()).unwrap()
|
||||||
|
}
|
||||||
|
/// `<root>/.ideai/conversations/<conversationId>/log.jsonl` — the raw log file.
|
||||||
|
fn log_path(&self, conversation: ConversationId) -> PathBuf {
|
||||||
|
self.conversation_dir(conversation).join("log.jsonl")
|
||||||
|
}
|
||||||
|
/// `<root>/.ideai/conversations/<conversationId>/` — a conversation's dir.
|
||||||
|
fn conversation_dir(&self, conversation: ConversationId) -> PathBuf {
|
||||||
|
self.0
|
||||||
|
.join(".ideai")
|
||||||
|
.join("conversations")
|
||||||
|
.join(conversation.to_string())
|
||||||
|
}
|
||||||
|
/// `<root>/.ideai/conversations/<conversationId>/handoff.md` — the handoff file (P3).
|
||||||
|
fn handoff_path(&self, conversation: ConversationId) -> PathBuf {
|
||||||
|
self.conversation_dir(conversation).join("handoff.md")
|
||||||
|
}
|
||||||
|
/// `<root>/.ideai/conversations/<conversationId>/handoff.md.tmp` — the atomic-write tmp.
|
||||||
|
fn handoff_tmp_path(&self, conversation: ConversationId) -> PathBuf {
|
||||||
|
self.conversation_dir(conversation).join("handoff.md.tmp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic constructors (calqués sur les tests P1 de conversation_log.rs).
|
||||||
|
fn conv_id(n: u128) -> ConversationId {
|
||||||
|
ConversationId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
fn turn_id(n: u128) -> TurnId {
|
||||||
|
TurnId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
fn turn(conv: ConversationId, id: TurnId, role: TurnRole, text: &str) -> ConversationTurn {
|
||||||
|
ConversationTurn::new(id, conv, 1_000, InputSource::Human, role, text)
|
||||||
|
}
|
||||||
|
fn texts(turns: &[ConversationTurn]) -> Vec<String> {
|
||||||
|
turns.iter().map(|t| t.text.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// append persistence — one JSONL line per turn, each line valid JSON
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn append_writes_one_valid_json_line_per_turn() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let log = FsConversationLog::new(&tmp.project_path());
|
||||||
|
let c = conv_id(1);
|
||||||
|
|
||||||
|
log.append(c, turn(c, turn_id(1), TurnRole::Prompt, "a"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
log.append(c, turn(c, turn_id(2), TurnRole::Response, "b"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
log.append(c, turn(c, turn_id(3), TurnRole::Prompt, "c"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Inspect the raw file: nb of non-empty lines == nb of appends, each valid JSON.
|
||||||
|
let raw = std::fs::read_to_string(tmp.log_path(c)).unwrap();
|
||||||
|
let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||||
|
assert_eq!(lines.len(), 3, "one line per append, got: {raw:?}");
|
||||||
|
for line in &lines {
|
||||||
|
let parsed: ConversationTurn =
|
||||||
|
serde_json::from_str(line).unwrap_or_else(|e| panic!("invalid JSON line {line:?}: {e}"));
|
||||||
|
// camelCase shape leaks through to the file (sanity on the persisted format).
|
||||||
|
assert!(line.contains("\"atMs\""), "expected camelCase atMs in {line:?}");
|
||||||
|
let _ = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// survives a "restart" — a fresh instance on the same root relits everything
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn persists_across_a_fresh_instance_restart() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let c = conv_id(7);
|
||||||
|
|
||||||
|
// First instance appends three turns, then is dropped.
|
||||||
|
{
|
||||||
|
let log = FsConversationLog::new(&tmp.project_path());
|
||||||
|
for (id, role, txt) in [
|
||||||
|
(1, TurnRole::Prompt, "a"),
|
||||||
|
(2, TurnRole::Response, "b"),
|
||||||
|
(3, TurnRole::Prompt, "c"),
|
||||||
|
] {
|
||||||
|
log.append(c, turn(c, turn_id(id), role, txt)).await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A brand-new instance on the same root reads it all back (no in-memory cache).
|
||||||
|
let reborn = FsConversationLog::new(&tmp.project_path());
|
||||||
|
let all = reborn.read(c, None).await.unwrap();
|
||||||
|
assert_eq!(texts(&all), vec!["a", "b", "c"]);
|
||||||
|
assert_eq!(texts(&reborn.last(c, 2).await.unwrap()), vec!["b", "c"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// disjoint conversations => disjoint files, no leak
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn conversations_land_in_disjoint_files() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let log = FsConversationLog::new(&tmp.project_path());
|
||||||
|
let c1 = conv_id(1);
|
||||||
|
let c2 = conv_id(2);
|
||||||
|
|
||||||
|
log.append(c1, turn(c1, turn_id(1), TurnRole::Prompt, "c1-a"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
log.append(c2, turn(c2, turn_id(2), TurnRole::Prompt, "c2-a"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
log.append(c1, turn(c1, turn_id(3), TurnRole::Response, "c1-b"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Two distinct files exist on disk.
|
||||||
|
let p1 = tmp.log_path(c1);
|
||||||
|
let p2 = tmp.log_path(c2);
|
||||||
|
assert!(p1.exists(), "c1 log file must exist");
|
||||||
|
assert!(p2.exists(), "c2 log file must exist");
|
||||||
|
assert_ne!(p1, p2, "the two conversations must use distinct files");
|
||||||
|
|
||||||
|
// No cross-leak through the API, and the c2 file holds only c2's single line.
|
||||||
|
assert_eq!(texts(&log.read(c1, None).await.unwrap()), vec!["c1-a", "c1-b"]);
|
||||||
|
assert_eq!(texts(&log.read(c2, None).await.unwrap()), vec!["c2-a"]);
|
||||||
|
let c2_raw = std::fs::read_to_string(&p2).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
c2_raw.lines().filter(|l| !l.trim().is_empty()).count(),
|
||||||
|
1,
|
||||||
|
"c2 file must not contain c1's turns"
|
||||||
|
);
|
||||||
|
assert!(!c2_raw.contains("c1-"), "no c1 content leaked into c2's file");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// robustness — a corrupted/truncated line is silently skipped
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn corrupted_line_is_skipped_without_panic() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let c = conv_id(5);
|
||||||
|
|
||||||
|
// Append two real turns first (creates the dir + file).
|
||||||
|
{
|
||||||
|
let log = FsConversationLog::new(&tmp.project_path());
|
||||||
|
log.append(c, turn(c, turn_id(1), TurnRole::Prompt, "good-1"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
log.append(c, turn(c, turn_id(2), TurnRole::Response, "good-2"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand-write a garbage (truncated) line directly into the JSONL, as a crash mid-write would.
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
let mut f = std::fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.open(tmp.log_path(c))
|
||||||
|
.unwrap();
|
||||||
|
writeln!(f, "{{tronqué").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh instance must skip the junk line and return only the two valid turns.
|
||||||
|
let log = FsConversationLog::new(&tmp.project_path());
|
||||||
|
let all = log.read(c, None).await.unwrap();
|
||||||
|
assert_eq!(texts(&all), vec!["good-1", "good-2"], "garbage line skipped");
|
||||||
|
// `last` must be just as tolerant.
|
||||||
|
assert_eq!(texts(&log.last(c, 5).await.unwrap()), vec!["good-1", "good-2"]);
|
||||||
|
// And the cursor still works across the corrupted tail.
|
||||||
|
assert_eq!(texts(&log.read(c, Some(turn_id(1))).await.unwrap()), vec!["good-2"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn good_line_appended_after_corruption_is_still_read() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let c = conv_id(6);
|
||||||
|
let log = FsConversationLog::new(&tmp.project_path());
|
||||||
|
|
||||||
|
log.append(c, turn(c, turn_id(1), TurnRole::Prompt, "before"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Inject a junk line between two good appends.
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
let mut f = std::fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.open(tmp.log_path(c))
|
||||||
|
.unwrap();
|
||||||
|
writeln!(f, "not json at all }}}}").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.append(c, turn(c, turn_id(2), TurnRole::Response, "after"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let all = log.read(c, None).await.unwrap();
|
||||||
|
assert_eq!(texts(&all), vec!["before", "after"], "junk in the middle is skipped, both reals survive");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// missing file / conversation => empty (never an error)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_conversation_reads_empty() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let log = FsConversationLog::new(&tmp.project_path());
|
||||||
|
|
||||||
|
// Nothing was ever appended: no .ideai dir at all.
|
||||||
|
assert!(log.read(conv_id(99), None).await.unwrap().is_empty());
|
||||||
|
assert!(log.read(conv_id(99), Some(turn_id(1))).await.unwrap().is_empty());
|
||||||
|
assert!(log.last(conv_id(99), 5).await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// cursor / last contract (mirrors the P1 port contract, now over the Fs adapter)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_cursor_is_strictly_exclusive() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let log = FsConversationLog::new(&tmp.project_path());
|
||||||
|
let c = conv_id(1);
|
||||||
|
for (id, txt) in [(1, "a"), (2, "b"), (3, "c")] {
|
||||||
|
log.append(c, turn(c, turn_id(id), TurnRole::Prompt, txt))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(texts(&log.read(c, Some(turn_id(1))).await.unwrap()), vec!["b", "c"]);
|
||||||
|
assert_eq!(texts(&log.read(c, Some(turn_id(2))).await.unwrap()), vec!["c"]);
|
||||||
|
// Cursor on the last id => nothing after.
|
||||||
|
assert!(log.read(c, Some(turn_id(3))).await.unwrap().is_empty());
|
||||||
|
// Unknown cursor => empty (cohérent avec le double in-memory de P1).
|
||||||
|
assert!(log.read(c, Some(turn_id(999))).await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn last_contract_zero_and_bounds() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let log = FsConversationLog::new(&tmp.project_path());
|
||||||
|
let c = conv_id(1);
|
||||||
|
for (id, txt) in [(1, "a"), (2, "b"), (3, "c"), (4, "d")] {
|
||||||
|
log.append(c, turn(c, turn_id(id), TurnRole::Prompt, txt))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// last(0) => empty.
|
||||||
|
assert!(log.last(c, 0).await.unwrap().is_empty());
|
||||||
|
// last(n < len) => the n last, in insertion order.
|
||||||
|
assert_eq!(texts(&log.last(c, 2).await.unwrap()), vec!["c", "d"]);
|
||||||
|
// last(n > len) => everything.
|
||||||
|
assert_eq!(texts(&log.last(c, 10).await.unwrap()), vec!["a", "b", "c", "d"]);
|
||||||
|
// last(n == len) => everything.
|
||||||
|
assert_eq!(texts(&log.last(c, 4).await.unwrap()), vec!["a", "b", "c", "d"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// concurrency — two concurrent appends => 2 intact lines, no corruption
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_appends_on_same_conversation_keep_both_lines_intact() {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let log = Arc::new(FsConversationLog::new(&tmp.project_path()));
|
||||||
|
let c = conv_id(42);
|
||||||
|
|
||||||
|
let l1 = Arc::clone(&log);
|
||||||
|
let l2 = Arc::clone(&log);
|
||||||
|
let h1 = tokio::spawn(async move {
|
||||||
|
l1.append(c, turn(c, turn_id(1), TurnRole::Prompt, "first"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
let h2 = tokio::spawn(async move {
|
||||||
|
l2.append(c, turn(c, turn_id(2), TurnRole::Response, "second"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
h1.await.unwrap();
|
||||||
|
h2.await.unwrap();
|
||||||
|
|
||||||
|
// Exactly two non-empty lines, each a fully-parseable turn (no interleaving).
|
||||||
|
let raw = std::fs::read_to_string(tmp.log_path(c)).unwrap();
|
||||||
|
let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||||
|
assert_eq!(lines.len(), 2, "two concurrent appends => two lines, got: {raw:?}");
|
||||||
|
for line in &lines {
|
||||||
|
serde_json::from_str::<ConversationTurn>(line)
|
||||||
|
.unwrap_or_else(|e| panic!("interleaved/corrupted line {line:?}: {e}"));
|
||||||
|
}
|
||||||
|
// Both turns are present (order between the two is unspecified under a race).
|
||||||
|
let mut got = texts(&log.read(c, None).await.unwrap());
|
||||||
|
got.sort();
|
||||||
|
assert_eq!(got, vec!["first".to_string(), "second".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// P3 — FsHandoffStore (handoff.md): le point de reprise, un par conversation.
|
||||||
|
//
|
||||||
|
// Choix d'emplacement : ces cas vivent dans CE fichier (et non un frère) car ils
|
||||||
|
// réutilisent à l'identique le scaffolding L2 de P2 — `TempDir` maison (chemin
|
||||||
|
// absolu pour `ProjectPath::new`), constructeurs déterministes `conv_id`/`turn_id`,
|
||||||
|
// et la même convention de dossier `<root>/.ideai/conversations/<id>/`. Tout regrouper
|
||||||
|
// garde la cartographie « persistance conversationnelle » au même endroit.
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// Écrit un `handoff.md` brut (corruption volontaire), créant le dossier au besoin.
|
||||||
|
fn write_raw_handoff(tmp: &TempDir, c: ConversationId, content: &str) {
|
||||||
|
std::fs::create_dir_all(tmp.conversation_dir(c)).unwrap();
|
||||||
|
std::fs::write(tmp.handoff_path(c), content).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// round-trip exact — summary_md multi-ligne + objective: Some(...)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handoff_round_trip_multiline_summary_with_objective() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let store = FsHandoffStore::new(&tmp.project_path());
|
||||||
|
let c = conv_id(1);
|
||||||
|
|
||||||
|
// summary_md délibérément multi-ligne, avec un `---` interne et des espaces de fin
|
||||||
|
// pour éprouver la fidélité octet-pour-octet du corps.
|
||||||
|
let summary = "# Résumé de reprise\n\n- point un\n- point deux\n\n---\n\nbloc final avec trailing \n";
|
||||||
|
let handoff = Handoff::new(summary, turn_id(42), Some("livrer le lot P3".to_string()));
|
||||||
|
|
||||||
|
store.save(c, handoff.clone()).await.unwrap();
|
||||||
|
let loaded = store.load(c).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(loaded, Some(handoff.clone()), "round-trip doit redonner le Handoff exact");
|
||||||
|
let loaded = loaded.unwrap();
|
||||||
|
assert_eq!(loaded.summary_md, summary, "summary_md conservé octet pour octet");
|
||||||
|
assert_eq!(loaded.up_to, turn_id(42), "up_to conservé à l'identique");
|
||||||
|
assert_eq!(loaded.objective.as_deref(), Some("livrer le lot P3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// round-trip exact — objective: None
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handoff_round_trip_without_objective() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let store = FsHandoffStore::new(&tmp.project_path());
|
||||||
|
let c = conv_id(2);
|
||||||
|
|
||||||
|
let summary = "résumé simple\nsur deux lignes\n";
|
||||||
|
let handoff = Handoff::new(summary, turn_id(7), None);
|
||||||
|
|
||||||
|
store.save(c, handoff.clone()).await.unwrap();
|
||||||
|
let loaded = store.load(c).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(loaded, Some(handoff), "round-trip exact sans objective");
|
||||||
|
let loaded = store.load(c).await.unwrap().unwrap();
|
||||||
|
assert_eq!(loaded.objective, None, "objective reste None");
|
||||||
|
assert_eq!(loaded.summary_md, summary);
|
||||||
|
assert_eq!(loaded.up_to, turn_id(7));
|
||||||
|
|
||||||
|
// Sanity sur le format brut : pas de ligne `objective:` quand None.
|
||||||
|
let raw = std::fs::read_to_string(tmp.handoff_path(c)).unwrap();
|
||||||
|
assert!(!raw.contains("objective:"), "aucune clé objective ne doit être écrite, got: {raw:?}");
|
||||||
|
assert!(raw.contains("upTo: "), "upTo toujours présent, got: {raw:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// absent ⇒ Ok(None)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handoff_absent_loads_none() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let store = FsHandoffStore::new(&tmp.project_path());
|
||||||
|
|
||||||
|
// Aucune conversation jamais écrite : pas de dossier .ideai du tout.
|
||||||
|
assert_eq!(store.load(conv_id(123)).await.unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// écriture atomique — pas de .tmp résiduel, fichier final complet
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handoff_save_is_atomic_no_tmp_left_behind() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let store = FsHandoffStore::new(&tmp.project_path());
|
||||||
|
let c = conv_id(3);
|
||||||
|
|
||||||
|
let handoff = Handoff::new("corps complet\n", turn_id(9), Some("but".to_string()));
|
||||||
|
store.save(c, handoff.clone()).await.unwrap();
|
||||||
|
|
||||||
|
// Aucun handoff.md.tmp résiduel après save.
|
||||||
|
assert!(
|
||||||
|
!tmp.handoff_tmp_path(c).exists(),
|
||||||
|
"le fichier temporaire handoff.md.tmp ne doit pas subsister après save"
|
||||||
|
);
|
||||||
|
// Le fichier final existe et est complet/relisible.
|
||||||
|
assert!(tmp.handoff_path(c).exists(), "handoff.md final doit exister");
|
||||||
|
assert_eq!(store.load(c).await.unwrap(), Some(handoff), "contenu final lisible et complet");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// overwrite — deux save successifs => load = le dernier (pas d'accumulation)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handoff_overwrite_keeps_only_the_last() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let store = FsHandoffStore::new(&tmp.project_path());
|
||||||
|
let c = conv_id(4);
|
||||||
|
|
||||||
|
let first = Handoff::new("première version\n", turn_id(1), Some("obj-1".to_string()));
|
||||||
|
let second = Handoff::new("deuxième version\nplus longue\n", turn_id(2), None);
|
||||||
|
|
||||||
|
store.save(c, first).await.unwrap();
|
||||||
|
store.save(c, second.clone()).await.unwrap();
|
||||||
|
|
||||||
|
// load renvoie le dernier, aucune trace du premier.
|
||||||
|
assert_eq!(store.load(c).await.unwrap(), Some(second), "load doit renvoyer le dernier save");
|
||||||
|
let raw = std::fs::read_to_string(tmp.handoff_path(c)).unwrap();
|
||||||
|
assert!(!raw.contains("première version"), "le contenu du premier save ne doit pas subsister");
|
||||||
|
assert!(!raw.contains("obj-1"), "l'objective du premier save ne doit pas subsister");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// isolation par conversationId — deux convs => deux handoff distincts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handoff_is_isolated_per_conversation() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let store = FsHandoffStore::new(&tmp.project_path());
|
||||||
|
let c1 = conv_id(10);
|
||||||
|
let c2 = conv_id(20);
|
||||||
|
|
||||||
|
let h1 = Handoff::new("résumé c1\n", turn_id(11), Some("obj-c1".to_string()));
|
||||||
|
let h2 = Handoff::new("résumé c2\n", turn_id(22), None);
|
||||||
|
|
||||||
|
store.save(c1, h1.clone()).await.unwrap();
|
||||||
|
store.save(c2, h2.clone()).await.unwrap();
|
||||||
|
|
||||||
|
// Chacune relit le sien, sans fuite.
|
||||||
|
assert_eq!(store.load(c1).await.unwrap(), Some(h1));
|
||||||
|
assert_eq!(store.load(c2).await.unwrap(), Some(h2));
|
||||||
|
|
||||||
|
// Deux fichiers distincts sur disque.
|
||||||
|
let p1 = tmp.handoff_path(c1);
|
||||||
|
let p2 = tmp.handoff_path(c2);
|
||||||
|
assert_ne!(p1, p2, "deux conversations => deux fichiers handoff distincts");
|
||||||
|
assert!(p1.exists() && p2.exists());
|
||||||
|
let raw2 = std::fs::read_to_string(&p2).unwrap();
|
||||||
|
assert!(!raw2.contains("résumé c1"), "aucune fuite de c1 dans le handoff de c2");
|
||||||
|
assert!(!raw2.contains("obj-c1"), "aucune fuite de l'objective de c1 dans c2");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// corruption ⇒ Err(Serialization), aucun panic — les 5 cas fournis
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handoff_corruption_yields_serialization_error_no_panic() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let store = FsHandoffStore::new(&tmp.project_path());
|
||||||
|
|
||||||
|
// (1) pas de `---` ouvrant.
|
||||||
|
// (2) `---` fermant absent.
|
||||||
|
// (3) clé `upTo` absente.
|
||||||
|
// (4) `upTo` non-UUID.
|
||||||
|
// (5) ligne de front-matter sans `:`.
|
||||||
|
let cases: [(u128, &str, &str); 5] = [
|
||||||
|
(101, "pas de fence ouvrant\nupTo: 00000000-0000-0000-0000-000000000001\n---\ncorps\n", "fence ouvrant absent"),
|
||||||
|
(102, "---\nupTo: 00000000-0000-0000-0000-000000000001\ncorps sans fence fermant\n", "fence fermant absent"),
|
||||||
|
(103, "---\nobjective: but\n---\ncorps\n", "upTo absent"),
|
||||||
|
(104, "---\nupTo: pas-un-uuid\n---\ncorps\n", "upTo non-UUID"),
|
||||||
|
(105, "---\nligne sans deux-points\n---\ncorps\n", "ligne front-matter sans `:`"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (n, content, label) in cases {
|
||||||
|
let c = conv_id(n);
|
||||||
|
write_raw_handoff(&tmp, c, content);
|
||||||
|
|
||||||
|
let result = store.load(c).await;
|
||||||
|
match result {
|
||||||
|
Err(StoreError::Serialization(msg)) => {
|
||||||
|
assert!(
|
||||||
|
msg.starts_with("handoff.md:"),
|
||||||
|
"cas «{label}» : message préfixé `handoff.md:` attendu, got: {msg:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("cas «{label}» : Err(Serialization) attendu, got: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// P4 — HeuristicHandoffSummarizer (port HandoffSummarizer, §19.6)
|
||||||
|
//
|
||||||
|
// Tests du repli incrémental, déterministe, sans I/O. Réutilise les helpers
|
||||||
|
// `conv_id` / `turn_id` / `turn` ci-dessus. Placés ici (cible infra) car l'impl
|
||||||
|
// `HeuristicHandoffSummarizer` est infra-side ; même module de test que P2/P3.
|
||||||
|
//
|
||||||
|
// Format reparsable verrouillé (cf. `summarizer.rs`) :
|
||||||
|
// - ligne d'objectif : `**Objectif :** <obj>`
|
||||||
|
// - une ligne par tour : `- **<Prompt|Response|Tool>:** <texte aplati>`
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// Compte les lignes-tours (`- **…`) dans un `summary_md` rendu.
|
||||||
|
fn turn_lines(summary_md: &str) -> Vec<&str> {
|
||||||
|
summary_md.lines().filter(|l| l.starts_with("- **")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `fold(None, turns)` = calcul de base : tours rendus, curseur = dernier id,
|
||||||
|
/// objectif extrait du 1er Prompt.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fold_none_renders_base_window_objective_and_cursor() {
|
||||||
|
let s = HeuristicHandoffSummarizer::new();
|
||||||
|
let c = conv_id(1);
|
||||||
|
let turns = vec![
|
||||||
|
turn(c, turn_id(1), TurnRole::Prompt, "Implémente la feature X"),
|
||||||
|
turn(c, turn_id(2), TurnRole::ToolActivity, "ran grep"),
|
||||||
|
turn(c, turn_id(3), TurnRole::Response, "fait"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let h = s.fold(None, &turns).await;
|
||||||
|
|
||||||
|
// Objectif extrait du 1er Prompt.
|
||||||
|
assert_eq!(h.objective.as_deref(), Some("Implémente la feature X"));
|
||||||
|
assert!(
|
||||||
|
h.summary_md.contains("**Objectif :** Implémente la feature X"),
|
||||||
|
"ligne d'objectif attendue, got:\n{}",
|
||||||
|
h.summary_md
|
||||||
|
);
|
||||||
|
// Curseur = dernier id de l'incrément.
|
||||||
|
assert_eq!(h.up_to, turn_id(3));
|
||||||
|
// Les 3 tours rendus, un par ligne, avec le bon label.
|
||||||
|
let lines = turn_lines(&h.summary_md);
|
||||||
|
assert_eq!(lines.len(), 3, "3 lignes-tours attendues, got:\n{}", h.summary_md);
|
||||||
|
assert_eq!(lines[0], "- **Prompt:** Implémente la feature X");
|
||||||
|
assert_eq!(lines[1], "- **Tool:** ran grep");
|
||||||
|
assert_eq!(lines[2], "- **Response:** fait");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Incrément seulement : `h2 = fold(Some(h1), [t6])` étend h1 (t1..t5) sans
|
||||||
|
/// re-fournir t1..t5 ; curseur avance à t6.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fold_is_incremental_does_not_re_pass_old_turns() {
|
||||||
|
let s = HeuristicHandoffSummarizer::new();
|
||||||
|
let c = conv_id(1);
|
||||||
|
let first = vec![
|
||||||
|
turn(c, turn_id(1), TurnRole::Prompt, "obj un"),
|
||||||
|
turn(c, turn_id(2), TurnRole::Response, "r2"),
|
||||||
|
turn(c, turn_id(3), TurnRole::Response, "r3"),
|
||||||
|
turn(c, turn_id(4), TurnRole::Response, "r4"),
|
||||||
|
turn(c, turn_id(5), TurnRole::Response, "r5"),
|
||||||
|
];
|
||||||
|
let h1 = s.fold(None, &first).await;
|
||||||
|
|
||||||
|
// Le 2e appel ne re-fournit QUE t6.
|
||||||
|
let h2 = s
|
||||||
|
.fold(Some(h1.clone()), &[turn(c, turn_id(6), TurnRole::Response, "r6")])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let lines = turn_lines(&h2.summary_md);
|
||||||
|
assert_eq!(lines.len(), 6, "t1..t6 reconstitués depuis prev + incrément");
|
||||||
|
assert_eq!(lines[0], "- **Prompt:** obj un");
|
||||||
|
assert_eq!(lines[5], "- **Response:** r6");
|
||||||
|
assert_eq!(h2.up_to, turn_id(6), "curseur = dernier de l'incrément");
|
||||||
|
// Aucune duplication de t5 (la borne de prev) — exactement une occurrence.
|
||||||
|
assert_eq!(h2.summary_md.matches("- **Response:** r5").count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borne WINDOW : folder > WINDOW tours ⇒ exactement WINDOW lignes, et ce sont
|
||||||
|
/// les DERNIERS (les plus anciens évincés).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fold_truncates_to_window_keeping_the_last() {
|
||||||
|
let s = HeuristicHandoffSummarizer::new();
|
||||||
|
let c = conv_id(1);
|
||||||
|
let total = WINDOW + 5; // 25 si WINDOW=20.
|
||||||
|
let mut turns = Vec::new();
|
||||||
|
// 1er tour = Prompt (pour avoir un objectif) ; le reste = Response numérotés.
|
||||||
|
turns.push(turn(c, turn_id(1), TurnRole::Prompt, "objectif global"));
|
||||||
|
for n in 2..=(total as u128) {
|
||||||
|
turns.push(turn(c, turn_id(n), TurnRole::Response, &format!("r{n}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let h = s.fold(None, &turns).await;
|
||||||
|
|
||||||
|
let lines = turn_lines(&h.summary_md);
|
||||||
|
assert_eq!(lines.len(), WINDOW, "exactement WINDOW lignes-tours");
|
||||||
|
// Les plus anciens (Prompt + premiers Response) sont évincés.
|
||||||
|
assert!(
|
||||||
|
!h.summary_md.contains("- **Prompt:** objectif global"),
|
||||||
|
"le 1er tour doit être évincé de la fenêtre"
|
||||||
|
);
|
||||||
|
assert!(!h.summary_md.contains("- **Response:** r2 "), "r2 évincé");
|
||||||
|
// Le tout dernier reste, en dernière ligne.
|
||||||
|
let last_n = total as u128;
|
||||||
|
assert_eq!(lines[WINDOW - 1], format!("- **Response:** r{last_n}"));
|
||||||
|
// La 1re ligne conservée = total - WINDOW + 1 (numérotation des tours).
|
||||||
|
let first_kept = last_n - WINDOW as u128 + 1;
|
||||||
|
assert_eq!(lines[0], format!("- **Response:** r{first_kept}"));
|
||||||
|
// L'objectif extrait du 1er Prompt survit même si ce Prompt sort de la fenêtre.
|
||||||
|
assert_eq!(h.objective.as_deref(), Some("objectif global"));
|
||||||
|
assert_eq!(h.up_to, turn_id(last_n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Objectif figé : un `prev` avec objectif le conserve même si l'incrément a un
|
||||||
|
/// autre 1er Prompt.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fold_keeps_existing_objective_over_new_prompt() {
|
||||||
|
let s = HeuristicHandoffSummarizer::new();
|
||||||
|
let c = conv_id(1);
|
||||||
|
let prev = Handoff::new(
|
||||||
|
"**Objectif :** but initial".to_string(),
|
||||||
|
turn_id(1),
|
||||||
|
Some("but initial".to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let h = s
|
||||||
|
.fold(Some(prev), &[turn(c, turn_id(2), TurnRole::Prompt, "un autre but")])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(h.objective.as_deref(), Some("but initial"), "objectif figé");
|
||||||
|
assert!(
|
||||||
|
h.summary_md.contains("**Objectif :** but initial")
|
||||||
|
&& !h.summary_md.contains("**Objectif :** un autre but"),
|
||||||
|
"l'objectif ne doit pas être réécrit, got:\n{}",
|
||||||
|
h.summary_md
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `fold(None, [])` ⇒ handoff vide cohérent : summary vide, curseur nil, objectif None.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fold_none_empty_yields_empty_handoff() {
|
||||||
|
let s = HeuristicHandoffSummarizer::new();
|
||||||
|
let h = s.fold(None, &[]).await;
|
||||||
|
assert_eq!(h.summary_md, "");
|
||||||
|
assert_eq!(h.up_to, TurnId::from_uuid(Uuid::nil()));
|
||||||
|
assert_eq!(h.objective, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `fold(Some(h), [])` ⇒ `h` strictement inchangé.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fold_some_empty_returns_prev_unchanged() {
|
||||||
|
let s = HeuristicHandoffSummarizer::new();
|
||||||
|
let prev = Handoff::new(
|
||||||
|
"**Objectif :** but\n\n- **Prompt:** but".to_string(),
|
||||||
|
turn_id(7),
|
||||||
|
Some("but".to_string()),
|
||||||
|
);
|
||||||
|
let h = s.fold(Some(prev.clone()), &[]).await;
|
||||||
|
assert_eq!(h, prev, "rien de neuf ⇒ prev rendu tel quel");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Déterminisme : deux `fold` identiques ⇒ sorties strictement égales.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fold_is_deterministic() {
|
||||||
|
let s = HeuristicHandoffSummarizer::new();
|
||||||
|
let c = conv_id(1);
|
||||||
|
let turns = vec![
|
||||||
|
turn(c, turn_id(1), TurnRole::Prompt, "tâche"),
|
||||||
|
turn(c, turn_id(2), TurnRole::Response, "ok"),
|
||||||
|
];
|
||||||
|
let a = s.fold(None, &turns).await;
|
||||||
|
let b = s.fold(None, &turns).await;
|
||||||
|
assert_eq!(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Robustesse format : un tour dont le texte contient des sauts de ligne et la
|
||||||
|
/// séquence `- **` reste UNE seule ligne (collapse) et ne casse pas le reparse de
|
||||||
|
/// la fenêtre au repli suivant.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fold_flattens_multiline_and_marker_like_text_into_one_line() {
|
||||||
|
let s = HeuristicHandoffSummarizer::new();
|
||||||
|
let c = conv_id(1);
|
||||||
|
// Texte piégeux : retours à la ligne + une séquence ressemblant à un marqueur.
|
||||||
|
let nasty = "ligne une\n- **Prompt:** faux marqueur\nligne trois";
|
||||||
|
let turns = vec![
|
||||||
|
turn(c, turn_id(1), TurnRole::Prompt, "vrai objectif"),
|
||||||
|
turn(c, turn_id(2), TurnRole::Response, nasty),
|
||||||
|
];
|
||||||
|
let h1 = s.fold(None, &turns).await;
|
||||||
|
|
||||||
|
// Le tour piégeux est aplati en une seule ligne (whitespace collapsé).
|
||||||
|
let lines = turn_lines(&h1.summary_md);
|
||||||
|
assert_eq!(lines.len(), 2, "2 lignes-tours seulement, pas de ligne fantôme");
|
||||||
|
assert_eq!(
|
||||||
|
lines[1],
|
||||||
|
"- **Response:** ligne une - **Prompt:** faux marqueur ligne trois",
|
||||||
|
"texte multi-lignes + marqueur aplati en une ligne"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sonde de cohérence de fenêtre : un repli incrémental reparse proprement.
|
||||||
|
// NOTE de fragilité (rapportée à Main) : la ligne aplatie contient toujours la
|
||||||
|
// sous-chaîne `- **Prompt:**`, MAIS comme elle ne COMMENCE pas par `- **`
|
||||||
|
// (préfixée par `- **Response:** ligne une `), le reparse par `starts_with("- **")`
|
||||||
|
// la compte comme UNE seule ligne — la fenêtre reste cohérente.
|
||||||
|
let h2 = s
|
||||||
|
.fold(Some(h1.clone()), &[turn(c, turn_id(3), TurnRole::Response, "suite")])
|
||||||
|
.await;
|
||||||
|
let lines2 = turn_lines(&h2.summary_md);
|
||||||
|
assert_eq!(lines2.len(), 3, "fenêtre cohérente après repli (pas de split parasite)");
|
||||||
|
assert_eq!(lines2[2], "- **Response:** suite");
|
||||||
|
assert_eq!(h2.up_to, turn_id(3));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user