merge(codex/cli): cycle #157 #171 — dedup cartes tool/MCP + intention pré-MCP live

Backend: agent_message d'intention avant MCP reclassé en Announcement (non avalé comme faux Final); doublon ToolActivity supprimé à la source. Frontend: dédup métier des cartes progress par entité+stage; idea_ask_agent(s) rendus comme Délégation une fois. QA verte ciblée (codex 41, chat_bridge 25, CustomAgentChatView 26, vitest 1133, tsc 0). #167 reste closed. Réserve: E2E AppImage live non rejouée. Rouge préexistant hors scope: mcp_server.rs:777 (38 vs 37 tools).
This commit is contained in:
2026-08-10 18:38:13 +02:00
21 changed files with 1253 additions and 165 deletions

3
.gitignore vendored
View File

@ -27,6 +27,9 @@ frontend/coverage/
# ─── Tauri ──────────────────────────────────────────────────────────────────
# Bundles live under target/ (already ignored). Generated icons are committed.
.tauri/
# AppImage artefacts: sort normalement sous target/ (ignoré ci-dessus), mais une
# copie parasite peut atterrir à la racine lors de tests manuels — ne jamais committer.
*.AppImage
# ─── Claude Code ────────────────────────────────────────────────────────────
# Personal, machine-local overrides (shared settings.json, if any, stays tracked).

View File

@ -5,7 +5,7 @@
"probableAppModule": null,
"projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023",
"projectRoot": "/home/anthony/Documents/Projects/IdeA",
"updatedAt": "2026-08-06T09:47:45.004Z"
"updatedAt": "2026-08-10T07:14:28.007Z"
},
"ownerAgentId": null,
"ownerAgentMapping": {},

View File

@ -80,3 +80,4 @@
- [plugin-asset-serving-and-owned-storage-contracts](plugin-asset-serving-and-owned-storage-contracts.md) — memory note plugin-asset-serving-and-owned-storage-contracts
- [ticket156-reply-progress-foundation](ticket156-reply-progress-foundation.md) — memory note ticket156-reply-progress-foundation
- [cycle-151-167-155-git-topology](cycle-151-167-155-git-topology.md) — memory note cycle-151-167-155-git-topology
- [opencode-streaming-progress-mcp-surfacing-157](opencode-streaming-progress-mcp-surfacing-157.md) — memory note opencode-streaming-progress-mcp-surfacing-157

View File

@ -0,0 +1,35 @@
---
name: opencode-streaming-progress-mcp-surfacing-157
description: memory note opencode-streaming-progress-mcp-surfacing-157
metadata:
type: project
---
# OpenCode (CLI custom) — streaming progress & bulle MCP
Décision/fix du ticket #157 (itération 2, 2026-08-09). Profil OpenCode/GLM sur la CLI custom.
## Cause racine (vérifiée en source)
`opencode run --format json` (v1.17.18, `packages/opencode/src/cli/cmd/run.ts`) émet chaque event JSONL **en temps réel** sur stdout. Schéma ligne : `{type, timestamp, sessionID, ...data}`. Types : `step_start`, `step_finish`, `text` (part textuelle finalisée sous `.part.text`, pas un delta token), `tool_use` (**seulement à** `part.state.status=completed|error`, pas de start ; nom outil sous `.part.tool`), `reasoning` (thinking), `error`.
Le parser IdeA `crates/infrastructure/src/session/opencode.rs::parse_jsonl_record` reconnaît déjà ces types — il n'était pas cassé. Les 2 vraies causes :
1. `OpenCodeSession` ne surchargeait pas `send_with_tap` (défaut `domain/ports.rs:1380` ignore le tap) → `send` bufferisait tout le stdout jusqu'à l'EOF → silence total pendant le tour, burst à la fin.
2. La bulle `kind:Mcp`/`source:IdeaLocal` n'était émise que par `openai_compat.rs::apply_tool_calls`. OpenCode taggait les `tool_use` en `kind:Tool` générique.
## Fix (backend-only, commit 836137a3, branche `fix/ticket157-opencode-progress-mcp-surfacing`)
- Override `send_with_tap` sur `OpenCodeSession` (pattern codex.rs/claude.rs) : stdout ligne par ligne, projection live au tap des non-terminaux, Final assemblé à la fin (jamais au tap).
- Tagging MCP : `tool_use` préfixé `idea_` → `ReplyProgress{source:IdeaLocal, kind:Mcp, stage:Completed, tool_name}` (remplace la Progress Tool provider-native).
- Protection replay : session continuée = premier messageID tamponné puis supprimé si un nouveau arrive.
## Règles durables pour les prochains travaux
- **Tout adapter `AgentSession` qui peut streamer DOIT surcharger `send_with_tap`** (le défaut ignore le tap). Sans override, la CLI custom est muette jusqu'au Final. codex/claude/openai_compat l'ont ; OpenCode maintenant aussi.
- **Bulle MCP dédiée** : n'existe que quand IdeA sait que l'outil est un outil MCP IdeA. Pour les profils provider-orchestrés (OpenCode/Codex/Claude), les outils tournent DANS le provider ; IdeA ne peut les tagger Mcp que heuristiquement (préfixe `idea_`). Pour openai_compat (IdeA orchestre), c'est natif (`IdeaLocal/Mcp`).
- **opencode JSON est granulaire et honnête** : pas de deltas de tokens ni de tool-start. Ne pas simuler de faux stream. step_start/step_finish + text(finalisé) + tool_use(completed) = le maximum exposé.
- **Dette** : events `reasoning` (thinking) d'OpenCode ignorés par le parser (`Ignored`). Ticket dédié à créer si on veut afficher le thinking.
- **Réserve replay** : session continuée à messageID unique sans replay observable → tap muet jusqu'à l'EOF (`hold_until_new_message`) pour éviter un historique non rétractable. Cas frais = streaming immédiat.
## Validation
`cargo test -p infrastructure session::opencode` = 20 passed (3 nouveaux tests streaming/mcp/replay). QA VERT, pas de régression (échecs larges mcp_server/orchestrator pré-existants sur base `a3d94d4d`). Vérif live user en attente (AppImage rebuild `target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage`). Branche non mergée dans develop en attente du live vert.

View File

@ -1,8 +1,8 @@
---
issueRef: "#155"
version: 12
version: 13
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1786039145667
updatedAt: 1786346556578
---
## 2026-08-06 — Reopened from user validation on AppImage
- User reports that clipboard paste in the custom CLI chat still does not work in practice.

View File

@ -2,7 +2,7 @@
id: "620bc08f-835c-483c-a9f1-59690ebf8ab5"
number: 155
title: "Custom chat: paste image/fichier depuis le clipboard dans le composer"
status: "qa"
status: "inProgress"
priority: "medium"
sprint: null
links: [{"target":"#154","kind":"dependsOn"}]
@ -11,7 +11,7 @@ attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1785964740363
updatedAt: 1786039145667
version: 12
updatedAt: 1786346556578
version: 13
---
Demande utilisateur requalifiée le 2026-08-06: la custom CLI ne permet toujours pas de coller un fichier dans la barre de chat, comme dans une TUI native Claude Code ou Codex. Le scope ne doit pas rester limité au seul collage d'image: il faut couvrir au minimum les fichiers/images exposés par le clipboard et les convertir en attachments utilisables dans le composer custom, via le pipeline d'attachments existant.

View File

@ -1,9 +1,80 @@
---
issueRef: "#157"
version: 12
updatedBy: {"kind":"agent","agent_id":"1ff94b51-3e17-4a39-8543-7715d1ff0f80"}
updatedAt: 1786019394609
version: 17
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1786378849914
---
# Journal d'itération (le plus récent en haut)
## Itération 3 — 2026-08-10 — Régression CLI custom: doublons tool/MCP + progress potentiellement manquants avant le premier MCP
**Remontée user (capture live AppImage, 2026-08-10)** : sur la CLI custom, l'usage de `idea_ask_agent` affiche les événements tool/MCP en double (deux cartes `Delegation idea_ask_agent` et deux cartes `Provider tool: idea_ask_agent`) alors que l'attendu produit est de ne conserver qu'une première carte `start` et une première carte `done`. En parallèle, l'utilisateur suspecte que tous les messages de progression/intention ne sont pas surfacés, notamment possiblement avant le premier appel MCP (ex. "Je vais d'abord regarder la liste de tools MCP...").
### Arbitrage Main + Architect
- **#157 reste le ticket principal du rendu CLI custom** pour la hiérarchie visuelle et le dédoublonnage des cartes tool/MCP/delegation.
- **#171 porte la pipeline d'observabilité live** (`ReplyEvent::Progress -> UI`) pour les progress/messages d'intention qui ne doivent pas dépendre du `Final`.
- **#167 reste fermé** : le bug historique traité sur l'ordre `events after Final` n'est pas rouvert à ce stade ; si DevBackend démontre un nouveau défaut d'émission/ordering backend, il faudra alors créer un delta dédié ou rouvrir en second temps.
### Hypothèse de travail pour ce cycle
- Le backend expose déjà une part significative des progress live via la fondation #156 et le correctif OpenCode de l'itération 2.
- Le symptôme utilisateur courant est **probablement** un split :
1. problème frontend de réconciliation/rendu (dédoublonnage des cartes `start/done`), et/ou
2. défaut de projection live UI de certains `ReplyChunk::Progress` en amont du premier appel MCP.
### Cycle relancé
- Architect consulté pour arbitrage ports/contrats/tickets.
- Décision Git préparatoire récupérée : branche de cycle existante `fix/171-conversation-observability-pipeline` à privilégier, sauf preuve backend contraire.
- DevBackend mandaté pour vérifier si les progress pré-MCP existent bien dans le flux backend actuel et si les doublons tool/MCP naissent avant ou après transport UI.
- DevFrontend mandaté pour inspecter la logique de rendu/réconciliation des cartes progress/tool/MCP/delegation dans la custom CLI, corriger si nécessaire et préparer les tests.
- QA passera après implémentation réelle avec preuve de commandes/tests.
### Attendus de validation pour clôture
- Pour un appel `idea_ask_agent`, la CLI custom n'affiche qu'une carte `start` et une carte `done` pertinentes, sans doublons parasites.
- Les messages de progression/intention disponibles dans le flux sont visibles **avant** le premier MCP quand ils existent réellement côté provider/IdeA.
- Aucun faux streaming n'est simulé pour les profils qui n'exposent pas ce niveau de granularité.
---
## Itération 2 — 2026-08-09 — Régression profil OpenCode/GLM (pas de progress, pas de bulle MCP)
**Remontée user** : sur la CLI custom avec un profil OpenCode/GLM, aucun message de progress pendant le traitement de l'agent, et aucune bulle d'appel de tool MCP quand l'agent invoque un outil.
### Cause racine (vérifiée en source opencode v1.17.18)
J'ai lu `packages/opencode/src/cli/cmd/run.ts` (sst/opencode). `opencode run --format json` émet chaque événement JSONL **en temps réel** sur stdout (`{type, timestamp, sessionID, ...data}`). Types émis : `step_start`, `step_finish`, `text` (part textuelle finalisée, pas un delta token), `tool_use` (**seulement à** `part.state.status=completed|error`, pas de start), `reasoning`, `error`. Le parser IdeA `opencode.rs::parse_jsonl_record` reconnaît déjà ces types correctement — **le parser n'était pas cassé**.
Les deux vraies causes :
1. `OpenCodeSession` ne surchargeait **pas** `send_with_tap` (défaut `domain/ports.rs:1380` ignore le tap). Son `send` bufferisait TOUT le stdout jusqu'à l'EOF puis parsait en bloc → le tap live (`commands.rs:2301`) recevait zéro event pendant le tour (silence total), tout arrivait en burst à la fin.
2. La bulle `kind:Mcp`/`source:IdeaLocal` n'était émise que par `openai_compat.rs::apply_tool_calls` (où IdeA orchestre les outils). Pour OpenCode, les outils tournent **dans** opencode ; les `tool_use` étaient taggés `kind:Tool` générique.
### Correctif livré (backend-only)
Branche `fix/ticket157-opencode-progress-mcp-surfacing`, commit `836137a3` (base `develop` `a3d94d4d`). Fichier unique : `crates/infrastructure/src/session/opencode.rs` (+408/−69).
- **`send_with_tap` override** (pattern codex.rs/claude.rs) : lecture stdout ligne par ligne, projection live au tap des events non-terminaux (Progress/TextDelta/ToolActivity/Heartbeat), Final assemblé à la fin (jamais envoyé au tap). `send()` sans tap reste batch (inchangé).
- **Tagging MCP** : `tool_use` préfixé `idea_` → `ReplyProgress{source:IdeaLocal, kind:Mcp, stage:Completed, tool_name}` (remplace la Progress Tool provider-native, pas de double card).
- **Protection replay** : sur session continuée (`--session`), le premier messageID est tamponné puis supprimé si un nouveau messageID arrive (évite de projeter l'historique rejoué).
### Tests & QA
- 3 nouveaux tests : `parse_jsonl_turn_tags_idea_tools_as_mcp_progress`, `send_with_tap_emits_progress_live_before_final_and_tags_idea_tool_as_mcp`, `send_with_tap_does_not_replay_previous_message_into_live_stream`.
- `cargo test -p infrastructure session::opencode` → **20 passed**. `cargo check -p infrastructure -p application` → OK. `cargo fmt --check` → OK.
- QA verdict **VERT** : ciblé conforme au contrat, pas de régression. Les échecs larges (`mcp_server tools_list...` 38 vs 37 ; `orchestrator_service` 39 hangs/spawn) reproduisent **à l'identique sur la base `a3d94d4d`** → pré-existants, hors périmètre du fix.
### Restes / points pour les prochaines itérations
- **Réserve replay (tradeoff UX)** : sur une session continuée avec un **seul** messageID et aucun replay observable, le tap peut rester muet jusqu'à l'EOF (`hold_until_new_message`) pour éviter d'afficher un historique non rétractable. Cas frais = streaming immédiat. À retravailler si la reprise de session devient prioritaire.
- **`reasoning` non mappé** (dette) : opencode émet des events `reasoning` (thinking) que le parser ignore (`Ignored`). Hors périmètre de ce fix ; ticket dédié à créer si on veut afficher le thinking OpenCode.
- **Granularité honnête** : opencode JSON ne donne ni deltas de tokens ni tool-start — seulement step_start/step_finish + text(finalisé) + tool_use(completed). Le fix reste honnête (pas de faux stream simulé), conforme aux critères d'acceptation sur profils pauvres.
- **Vérif live en attente** : build AppImage lancé (artefact `target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage`). Le user doit remplacer l'AppImage installée + relancer IdeA pour valider en live (⚠️ relance = tue l'orchestrateur courant). Branche non mergée dans `develop` en attente de vérif live verte (Git ferait le fast-forward ensuite).
- **Frontend** : aucun changement requis (`CustomAgentChatView`/`ProgressBubble` gèrent déjà `Progress{Tool|Mcp}` + délégations). Si le rendu live révèle un souci visuel (spinner qui ne s'arrête pas — cf. description initiale du ticket), ce sera un delta frontend ciblé.
---
# Objectif
Améliorer la CLI custom / chat agent pour afficher **un maximum de progress intermédiaires réellement disponibles** au lieu d'un silence jusqu'au `Final`, avec une UX différenciée pour :

View File

@ -2,16 +2,16 @@
id: "d49aaae5-0d03-43fe-a51c-2ab5e921045c"
number: 157
title: "CLI custom: surface all available agent progress, MCP activity, and inter-agent delegation flow"
status: "closed"
status: "inProgress"
priority: "high"
sprint: null
links: [{"target":"#156","kind":"dependsOn"}]
agentRefs: [{"agentId":"8f7da528-58df-4315-97e9-0562230ecc19","role":"assigned"}]
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"1ff94b51-3e17-4a39-8543-7715d1ff0f80"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1785965383914
updatedAt: 1786019394609
version: 12
updatedAt: 1786378849914
version: 17
---
Suivi UX sur la custom CLI après livraison du flux Progress/MCP/inter-agent: l'affichage des messages de Progress est jugé correct, mais l'animation du rond/spinner affichée à côté du libellé Progress doit disparaître une fois l'événement terminé. Attendu: à la transition vers l'état terminal (done), la ligne Progress conserve éventuellement son contenu statique utile, mais n'affiche plus d'animation de chargement.
Régression/remontée utilisateur (profil OpenCode/GLM) sur la CLI custom : aucun message de progress ne s'affiche pendant le traitement de l'agent, et aucune bulle/carte d'appel de tool MCP n'apparaît quand l'agent invoque un outil MCP. Périmètre initial (surfacer progress + activité MCP + délégations inter-agent) à valider à nouveau sur le profil OpenCode.

View File

@ -0,0 +1,34 @@
---
issueRef: "#171"
version: 3
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1786378881038
---
# Journal d'itération (le plus récent en haut)
## Itération 1 — 2026-08-10 — Ouverture du cycle live progress depuis régression AppImage CLI custom
**Signal utilisateur (capture live du 2026-08-10)** : sur la custom CLI, tous les messages d'intention/progression de l'agent ne semblent pas remonter pendant le tour. Suspicion forte d'un trou avant le premier appel MCP (`idea_ask_agent`), alors que l'agent a vraisemblablement déjà émis des progress du type "Je vais d'abord regarder la liste de tools MCP...". En parallèle, des cartes tool/MCP apparaissent en double ; ce point de rendu est traité en articulation avec #157.
### Rôle de #171 dans le cycle
Ce ticket porte **la pipeline d'observabilité conversationnelle live** entre le backend (`ReplyEvent::Progress`, `ReplyChunk::Progress`, événements de tool/delegation observables localement) et la surface UI/CLI. Il ne porte pas la cosmétique visuelle elle-même, mais la garantie que l'UI reçoive effectivement les événements non terminaux en temps réel, sans attendre `Final`.
### Arbitrage de cycle
- **#157** : rendu/dédoublonnage/hierarchie visuelle de la custom CLI pour les cartes tool/MCP/delegation.
- **#171** : vérifier et, si nécessaire, corriger la chaîne backend -> transport -> store UI pour les progress/messages d'intention qui devraient apparaître avant le premier appel MCP.
- **#167** : reste fermé tant qu'aucune preuve ne montre un nouveau défaut backend d'ordering/post-Final distinct du bug déjà corrigé.
### Mandats actifs
- **DevBackend** : vérifier si les progress pré-MCP existent déjà dans le flux backend live pour le profil et la surface testés, et identifier tout buffer/relais/filtrage qui empêcherait leur projection temps réel.
- **DevFrontend** : vérifier si les `ReplyChunk::Progress` précoces arrivent mais sont ignorés, écrasés, réordonnés ou non rendus dans la custom CLI/store courant.
- **QA** : à venir après implémentation, avec scénario réel ciblant l'apparition d'un progress avant le premier MCP et l'absence de dépendance au `Final` pour l'affichage.
### Critères d'acceptation de #171 pour ce cycle
- Un progress/message d'intention disponible dans le flux source apparaît dans l'UI/CLI **avant** le premier événement MCP, sans attendre la fin de tour.
- Aucun `ReplyChunk::Progress` légitime n'est perdu entre émission backend, transport et affichage.
- La limite provider est traitée honnêtement : si un provider n'émet rien avant le premier MCP, l'UI n'invente pas de faux message.
- Les preuves QA permettent de distinguer clairement un manque d'émission backend d'un simple problème de rendu frontend.

View File

@ -0,0 +1,17 @@
---
id: "dcd6a2c9-c819-43b0-acf2-5d7dd9ada284"
number: 171
title: "[OBS] Pipeline d'observabilité conversationnelle — ReplyEvent progress -> UI en temps réel"
status: "inProgress"
priority: "high"
sprint: null
links: [{"target":"#58","kind":"relatesTo"},{"target":"#93","kind":"relatesTo"}]
agentRefs: [{"agentId":"fe887179-933f-47d4-960f-c3b06827f86c","role":"assigned"},{"agentId":"8f7da528-58df-4315-97e9-0562230ecc19","role":"assigned"}]
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1786371966186
updatedAt: 1786378881038
version: 3
---
Dérivé de la régression CLI/UI signalée le 2026-08-10: les messages process ne s'affichent pas pendant le tour et les usages d'outils MCP (ex. idea_ask_agent) n'apparaissent qu'après terminaison du tool / Final cible. Ticket transverse pour établir un pipeline live des événements conversationnels (progress/tool activity/process) du backend vers l'UI/CLI, sans dépendre du Final.

View File

@ -1,6 +1,6 @@
---
issueRef: "#58"
version: 2
updatedBy: {"kind":"user"}
updatedAt: 1785271075234
version: 3
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1786371966082
---

View File

@ -2,16 +2,17 @@
id: "495699aa-d5d1-409f-8830-cdfaba7a4be3"
number: 58
title: "[B/F] Rendu live des tâches de fond — subscriber UI + canal IPC attachable au task output"
status: "closed"
status: "open"
priority: "low"
sprint: null
links: [{"target":"#2","kind":"dependsOn"}]
agentRefs: []
attachments: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1784064521992
updatedAt: 1785271075234
version: 2
updatedAt: 1786371966082
version: 3
---
Sorti du ticket #2 lors de sa requalification (2026-07-14).

View File

@ -1,8 +1,8 @@
---
issueRef: "#93"
version: 3
updatedBy: {"kind":"user"}
updatedAt: 1784934179780
version: 4
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1786371966161
---
# Contexte

View File

@ -2,16 +2,17 @@
id: "40f56f7d-b0ec-4409-bf6b-c010e3003b76"
number: 93
title: "Agent Context : réponse finale non capturée, fragment d'appel d'outil brut renvoyé via idea_ask_agent"
status: "closed"
status: "open"
priority: "medium"
sprint: null
links: []
agentRefs: []
attachments: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1784817692650
updatedAt: 1784934179780
version: 3
updatedAt: 1786371966161
version: 4
---
Lors d'un test de la conversation inter-agent (idea_ask_agent ciblant Context), l'appel renvoie systématiquement un fragment JSON brut au lieu d'une réponse finale exploitable :

View File

@ -1,3 +1,3 @@
{
"nextNumber": 171
"nextNumber": 172
}

View File

@ -775,7 +775,7 @@
"issueRef": "#58",
"path": "58",
"title": "[B/F] Rendu live des tâches de fond — subscriber UI + canal IPC attachable au task output",
"status": "closed",
"status": "open",
"priority": "low",
"sprint": null,
"assignedAgentIds": [],
@ -783,7 +783,7 @@
"kind": "agent",
"agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641"
},
"updatedAt": 1785271075234
"updatedAt": 1786371966082
},
{
"issueRef": "#60",
@ -1216,7 +1216,7 @@
"issueRef": "#93",
"path": "93",
"title": "Agent Context : réponse finale non capturée, fragment d'appel d'outil brut renvoyé via idea_ask_agent",
"status": "closed",
"status": "open",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [],
@ -1224,7 +1224,7 @@
"kind": "agent",
"agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641"
},
"updatedAt": 1784934179780
"updatedAt": 1786371966161
},
{
"issueRef": "#94",
@ -2025,7 +2025,7 @@
"issueRef": "#155",
"path": "155",
"title": "Custom chat: paste image/fichier depuis le clipboard dans le composer",
"status": "qa",
"status": "inProgress",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [
@ -2036,7 +2036,7 @@
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1786039145667
"updatedAt": 1786346556578
},
{
"issueRef": "#156",
@ -2058,7 +2058,7 @@
"issueRef": "#157",
"path": "157",
"title": "CLI custom: surface all available agent progress, MCP activity, and inter-agent delegation flow",
"status": "closed",
"status": "inProgress",
"priority": "high",
"sprint": null,
"assignedAgentIds": [
@ -2068,7 +2068,7 @@
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1786019394609
"updatedAt": 1786378849914
},
{
"issueRef": "#158",
@ -2277,6 +2277,23 @@
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1786039145598
},
{
"issueRef": "#171",
"path": "171",
"title": "[OBS] Pipeline d'observabilité conversationnelle — ReplyEvent progress -> UI en temps réel",
"status": "inProgress",
"priority": "high",
"sprint": null,
"assignedAgentIds": [
"fe887179-933f-47d4-960f-c3b06827f86c",
"8f7da528-58df-4315-97e9-0562230ecc19"
],
"createdBy": {
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1786378881038
}
]
}

View File

@ -45,15 +45,22 @@ pub struct ParsedLine {
///
/// Le flux est du **JSONL**. Types réels :
///
/// - `{"type":"thread.started","thread_id":"<id>"}` ⇒ capte le `thread_id`
/// - `{"type":"thread.started","thread_id":"<id>"}` /
/// `{"type":"codex.thread.started","thread_id":"<id>"}` ⇒ capte le `thread_id`
/// (= id de conversation pour la reprise), **aucun** événement émis.
/// - `{"type":"turn.started"}` ⇒ [`ReplyEvent::Heartbeat`] (vivacité non terminale).
/// - `{"type":"conversation.item.added","item":{…}}` /
/// `{"type":"item.started","item":{…}}` ⇒ progression non terminale `Started`
/// pour les items outil/MCP dès que Codex annonce leur existence.
/// - `{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"…"}}`
/// ⇒ si `item.type=="agent_message"` ⇒ [`ReplyEvent::Final`] (`content` = `item.text`,
/// c'est la réponse) ; si `item.type=="error"` ⇒ [`ReplyEvent::Error`] avec message
/// extrait de `item.text`, `item.message`, `item.error.message`, `item.error`, puis
/// des champs top-level `stderr`/`message`/`error.message`/`error` ; sinon
/// (`reasoning`/`command`/autre) ⇒ [`ReplyEvent::ToolActivity`] (`label` = `item.type`).
/// (`reasoning`/`command`/autre) ⇒ progression canonique `Completed` (`label` =
/// `item.type`).
/// - `{"type":"conversation.item.done","item":{…}}` est traité comme `item.completed`
/// (Codex CLI ≥ 0.147).
/// - `{"type":"turn.completed","usage":{…}}` ⇒ [`ReplyEvent::Heartbeat`] (le `Final`
/// vient de l'`agent_message`, pas de `turn.completed`).
///
@ -74,7 +81,7 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
let mut events = Vec::new();
match value.get("type").and_then(Value::as_str) {
Some("thread.started") => {
Some("thread.started" | "codex.thread.started") => {
// Handshake : on ne capte que le thread_id (= id de conversation).
conversation_id = value
.get("thread_id")
@ -106,15 +113,37 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
});
events.push(ReplyEvent::Heartbeat);
}
Some("item.completed") => {
Some("item.started" | "conversation.item.added" | "response.output_item.added") => {
if let Some(item) = value.get("item") {
match item.get("type").and_then(Value::as_str) {
Some("agent_message") => {
let content = item
.get("text")
append_item_progress(
&mut events,
item,
ReplyProgressStage::Started,
value
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
.unwrap_or("item.started"),
);
}
}
Some("item.updated" | "conversation.item.updated") => {
if let Some(item) = value.get("item") {
append_item_progress(
&mut events,
item,
ReplyProgressStage::Delta,
value
.get("type")
.and_then(Value::as_str)
.unwrap_or("item.updated"),
);
}
}
Some("item.completed" | "conversation.item.done" | "response.output_item.done") => {
if let Some(item) = value.get("item") {
match item_type(item).as_deref() {
Some("agent_message") => {
let content = item_text(item).unwrap_or_default();
events.push(ReplyEvent::Final { content });
}
Some("error") => {
@ -122,19 +151,18 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
message: codex_error_message(&value, item),
});
}
// reasoning / command / tout autre item ⇒ activité (label = type).
// reasoning / command / tout autre item ⇒ progression canonique.
Some(kind) => {
let label = item_label(item).unwrap_or_else(|| kind.to_owned());
events.push(ReplyEvent::Progress {
progress: provider_progress(
ReplyProgressKind::Tool,
progress: item_progress(
&label,
ReplyProgressStage::Completed,
kind,
"item.completed",
)
.with_tool_name(kind.to_owned()),
});
events.push(ReplyEvent::ToolActivity {
label: kind.to_owned(),
value
.get("type")
.and_then(Value::as_str)
.unwrap_or("item.completed"),
),
});
}
None => {}
@ -161,6 +189,80 @@ fn provider_progress(
.with_provider_event("codex", native_event)
}
fn item_progress(
label: &str,
stage: ReplyProgressStage,
native_event: impl Into<String>,
) -> ReplyProgress {
if label.starts_with("idea_") {
ReplyProgress::new(
ReplyProgressSource::IdeaLocal,
ReplyProgressKind::Mcp,
stage,
label.to_owned(),
)
.with_provider_event("codex", native_event)
.with_tool_name(label.to_owned())
} else {
provider_progress(
ReplyProgressKind::Tool,
stage,
label.to_owned(),
native_event,
)
.with_tool_name(label.to_owned())
}
}
fn append_item_progress(
events: &mut Vec<ReplyEvent>,
item: &Value,
stage: ReplyProgressStage,
native_event: &str,
) {
let Some(kind) = item_type(item) else {
return;
};
if matches!(kind.as_str(), "agent_message" | "error") {
return;
}
let label = item_label(item).unwrap_or(kind);
events.push(ReplyEvent::Progress {
progress: item_progress(&label, stage, native_event),
});
}
fn item_type(item: &Value) -> Option<String> {
non_blank_string(item.get("type"))
.or_else(|| non_blank_string(item.get("item_type")))
.or_else(|| non_blank_string(item.get("kind")))
}
fn item_label(item: &Value) -> Option<String> {
non_blank_string(item.get("name"))
.or_else(|| non_blank_string(item.get("tool_name")))
.or_else(|| non_blank_string(item.get("tool")))
.or_else(|| non_blank_string(item.get("action")))
.or_else(|| nested_non_blank(item, &["call", "name"]))
.or_else(|| nested_non_blank(item, &["function", "name"]))
.or_else(|| nested_non_blank(item, &["function_call", "name"]))
.or_else(|| item_type(item))
}
fn item_text(item: &Value) -> Option<String> {
non_blank_string(item.get("text"))
.or_else(|| non_blank_string(item.get("message")))
.or_else(|| nested_non_blank(item, &["content", "text"]))
}
fn nested_non_blank(root: &Value, path: &[&str]) -> Option<String> {
let mut cursor = root;
for key in path {
cursor = cursor.get(*key)?;
}
non_blank_string(Some(cursor))
}
const CODEX_ERROR_FALLBACK: &str = "Codex a renvoyé une erreur.";
fn non_blank_string(value: Option<&Value>) -> Option<String> {
@ -449,11 +551,25 @@ impl CodexExecSession {
let (line_tap, parser_thread) = tap.map_or((None, None), |event_tap| {
let (line_tx, line_rx) = std::sync::mpsc::channel::<String>();
let parser = std::thread::spawn(move || {
let mut pending_final: Option<String> = None;
for line in line_rx {
if let Ok(parsed) = parse_event(&line) {
for event in parsed.events {
if !matches!(event, ReplyEvent::Final { .. }) {
let _ = event_tap.send(event);
match event {
ReplyEvent::Final { content } => {
if let Some(text) = pending_final.replace(content) {
let _ = event_tap.send(ReplyEvent::Announcement { text });
}
}
other => {
if event_supersedes_pending_final(&other) {
if let Some(text) = pending_final.take() {
let _ =
event_tap.send(ReplyEvent::Announcement { text });
}
}
let _ = event_tap.send(other);
}
}
}
}
@ -492,11 +608,22 @@ impl CodexExecSession {
events.push(ReplyEvent::Announcement {
text: last_final.take().expect("présent"),
});
} else {
let _ = last_final.take();
}
}
last_final = Some(content);
}
other => {
if last_final.is_some() && event_supersedes_pending_final(&other) {
if !has_tap {
events.push(ReplyEvent::Announcement {
text: last_final.take().expect("présent"),
});
} else {
let _ = last_final.take();
}
}
if !has_tap {
events.push(other);
}
@ -516,3 +643,17 @@ impl CodexExecSession {
Ok(Box::new(events.into_iter()))
}
}
fn event_supersedes_pending_final(event: &ReplyEvent) -> bool {
!matches!(
event,
ReplyEvent::Heartbeat
| ReplyEvent::RateLimited { .. }
| ReplyEvent::Progress {
progress: ReplyProgress {
kind: ReplyProgressKind::Turn,
..
}
}
)
}

View File

@ -50,7 +50,8 @@ mod tests {
use domain::ids::ProfileId;
use domain::ports::{
AgentSession, AgentSessionError, AgentSessionFactory, ContextInjectionPlan,
PreparedContext, ReplyEvent, SessionPlan,
PreparedContext, ReplyEvent, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage,
SessionPlan,
};
use domain::profile::{AgentProfile, ContextInjection, HttpChatConfig, StructuredAdapter};
use domain::project::ProjectPath;
@ -941,27 +942,122 @@ mod tests {
// ---- Codex parse_event (format RÉEL vérifié 2026-06-09) -------------
/// Un item non-`agent_message` (reasoning/command/…) ⇒ ToolActivity (label = type).
/// Un item non-`agent_message` (reasoning/command/…) ⇒ Progress canonique
/// (label = type), sans ToolActivity de compatibilité qui créerait une double
/// carte UI.
#[test]
fn codex_non_agent_message_item_is_tool_activity() {
fn codex_non_agent_message_item_is_progress_only() {
let r = codex::parse_event(
r#"{"type":"item.completed","item":{"id":"i0","type":"reasoning","text":"…"}}"#,
)
.unwrap();
assert_eq!(
without_progress(r.events),
vec![ReplyEvent::ToolActivity {
label: "reasoning".into()
}]
assert!(
matches!(
r.events.as_slice(),
[ReplyEvent::Progress { progress }]
if progress.source == ReplyProgressSource::ProviderNative
&& progress.kind == ReplyProgressKind::Tool
&& progress.stage == ReplyProgressStage::Completed
&& progress.label == "reasoning"
&& progress.tool_name.as_deref() == Some("reasoning")
),
"reasoning item must be represented by one canonical progress event: {:?}",
r.events
);
let c =
codex::parse_event(r#"{"type":"item.completed","item":{"id":"i1","type":"command"}}"#)
.unwrap();
assert!(
matches!(
c.events.as_slice(),
[ReplyEvent::Progress { progress }]
if progress.source == ReplyProgressSource::ProviderNative
&& progress.kind == ReplyProgressKind::Tool
&& progress.stage == ReplyProgressStage::Completed
&& progress.label == "command"
&& progress.tool_name.as_deref() == Some("command")
),
"command item must be represented by one canonical progress event: {:?}",
c.events
);
}
#[test]
fn codex_conversation_item_added_emits_live_started_progress() {
let r = codex::parse_event(
r#"{"type":"conversation.item.added","item":{"id":"call_1","type":"function_call","name":"read_file","arguments":"{}"}}"#,
)
.unwrap();
assert!(
matches!(
r.events.first(),
Some(ReplyEvent::Progress { progress })
if progress.source == ReplyProgressSource::ProviderNative
&& progress.kind == ReplyProgressKind::Tool
&& progress.stage == ReplyProgressStage::Started
&& progress.label == "read_file"
&& progress.tool_name.as_deref() == Some("read_file")
&& progress.native_event.as_deref() == Some("conversation.item.added")
),
"Codex item.added must surface tool start immediately: {:?}",
r.events
);
assert_eq!(
without_progress(c.events),
vec![ReplyEvent::ToolActivity {
label: "command".into()
}]
r.events.len(),
1,
"tool start must not emit a duplicate ToolActivity compatibility event"
);
}
#[test]
fn codex_idea_tool_is_tagged_as_local_mcp_progress() {
let r = codex::parse_event(
r#"{"type":"conversation.item.added","item":{"id":"call_1","type":"function_call","name":"idea_ask_agent","arguments":"{}"}}"#,
)
.unwrap();
assert!(
matches!(
r.events.first(),
Some(ReplyEvent::Progress { progress })
if progress.source == ReplyProgressSource::IdeaLocal
&& progress.kind == ReplyProgressKind::Mcp
&& progress.stage == ReplyProgressStage::Started
&& progress.label == "idea_ask_agent"
&& progress.tool_name.as_deref() == Some("idea_ask_agent")
&& progress.native_event.as_deref() == Some("conversation.item.added")
),
"Codex MCP tool start must be tagged as IdeaLocal/Mcp: {:?}",
r.events
);
}
#[test]
fn codex_conversation_item_done_completes_tool_with_name() {
let r = codex::parse_event(
r#"{"type":"conversation.item.done","item":{"id":"call_1","type":"function_call","name":"idea_ask_agent","status":"completed"}}"#,
)
.unwrap();
assert!(
matches!(
r.events.first(),
Some(ReplyEvent::Progress { progress })
if progress.source == ReplyProgressSource::IdeaLocal
&& progress.kind == ReplyProgressKind::Mcp
&& progress.stage == ReplyProgressStage::Completed
&& progress.label == "idea_ask_agent"
&& progress.tool_name.as_deref() == Some("idea_ask_agent")
&& progress.native_event.as_deref() == Some("conversation.item.done")
),
"Codex MCP tool completion must keep the real tool name: {:?}",
r.events
);
assert_eq!(
r.events.len(),
1,
"MCP tool completion must not emit a duplicate ToolActivity compatibility event"
);
}
@ -1163,6 +1259,45 @@ mod tests {
);
}
#[tokio::test]
async fn codex_agent_message_before_tool_becomes_announcement_before_tool_progress() {
let fake = FakeCli::printing(&[
r#"{"type":"thread.started","thread_id":"c"}"#,
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"je délègue"}}"#,
r#"{"type":"conversation.item.added","item":{"id":"tool0","type":"function_call","name":"idea_ask_agent","arguments":"{}"}}"#,
r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"résultat"}}"#,
]);
let s = CodexExecSession::new(
SessionId::new_random(),
fake.command(),
"/",
None,
Vec::new(),
Vec::new(),
None,
None,
);
let events: Vec<_> = s.send("x").await.expect("send").collect();
assert!(
matches!(
events.as_slice(),
[
ReplyEvent::Announcement { text },
ReplyEvent::Progress { progress },
ReplyEvent::Final { content }
] if text == "je délègue"
&& progress.source == ReplyProgressSource::IdeaLocal
&& progress.kind == ReplyProgressKind::Mcp
&& progress.stage == ReplyProgressStage::Started
&& progress.tool_name.as_deref() == Some("idea_ask_agent")
&& content == "résultat"
),
"l'annonce d'intention doit précéder le premier MCP dans le flux: {:?}",
events
);
}
#[tokio::test]
async fn codex_single_agent_message_is_final_without_announcement() {
let fake = FakeCli::printing(&[
@ -1220,6 +1355,8 @@ mod tests {
let fake = FakeCli::printing(&[
r#"{"type":"thread.started","thread_id":"c"}"#,
r#"{"type":"turn.started"}"#,
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"je délègue"}}"#,
r#"{"type":"conversation.item.added","item":{"id":"tool0","type":"function_call","name":"idea_ask_agent","arguments":"{}"}}"#,
r#"{"type":"item.completed","item":{"id":"tool0","type":"command","text":"cargo test"}}"#,
r#"{"type":"turn.completed"}"#,
r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"résultat"}}"#,
@ -1243,11 +1380,51 @@ mod tests {
matches!(live.first(), Some(ReplyEvent::Progress { progress }) if progress.native_event.as_deref() == Some("turn.started")),
"le démarrage du tour provider doit être tapé live en premier"
);
let announcement_idx = live
.iter()
.position(
|event| matches!(event, ReplyEvent::Announcement { text } if text == "je délègue"),
)
.expect("l'annonce d'intention doit être tapée live");
let mcp_idx = live
.iter()
.position(|event| {
matches!(
event,
ReplyEvent::Progress { progress }
if progress.source == ReplyProgressSource::IdeaLocal
&& progress.kind == ReplyProgressKind::Mcp
&& progress.stage == ReplyProgressStage::Started
&& progress.tool_name.as_deref() == Some("idea_ask_agent")
)
})
.expect("le début MCP doit être tapé live");
assert!(
live.iter().any(
|event| matches!(event, ReplyEvent::ToolActivity { label } if label == "command")
),
"l'activité outil provider doit être tapée live"
announcement_idx < mcp_idx,
"l'annonce d'intention doit précéder le premier MCP dans le tap live: {:?}",
live
);
assert!(
live.iter().any(|event| matches!(
event,
ReplyEvent::Progress { progress }
if progress.source == ReplyProgressSource::IdeaLocal
&& progress.kind == ReplyProgressKind::Mcp
&& progress.stage == ReplyProgressStage::Started
&& progress.tool_name.as_deref() == Some("idea_ask_agent")
)),
"le début MCP Codex doit être tapé live avant le Final"
);
assert!(
live.iter().any(|event| matches!(
event,
ReplyEvent::Progress { progress }
if progress.source == ReplyProgressSource::ProviderNative
&& progress.kind == ReplyProgressKind::Tool
&& progress.stage == ReplyProgressStage::Completed
&& progress.tool_name.as_deref() == Some("command")
)),
"l'activité outil provider doit être tapée live comme progression canonique"
);
assert!(
live.iter().any(|event| matches!(event, ReplyEvent::Progress { progress } if progress.native_event.as_deref() == Some("turn.completed"))),

View File

@ -184,10 +184,25 @@ fn parse_jsonl_turn_scoped(
expected_session_id: Option<&str>,
) -> Result<Vec<ReplyEvent>, AgentSessionError> {
let records = current_turn_records(lines, expected_session_id)?;
events_from_records(records)
}
fn events_from_records(records: Vec<ParsedRecord>) -> Result<Vec<ReplyEvent>, AgentSessionError> {
let mut events = Vec::new();
let mut final_text = String::new();
let mut error_seen = false;
for record in records {
append_record_events(record, &mut events, &mut final_text, &mut error_seen);
}
append_final_event(events, final_text, error_seen)
}
fn append_record_events(
record: ParsedRecord,
events: &mut Vec<ReplyEvent>,
final_text: &mut String,
error_seen: &mut bool,
) {
match record.event {
ParsedEvent::StepStart => {
events.push(ReplyEvent::Progress {
@ -213,13 +228,7 @@ fn parse_jsonl_turn_scoped(
}
ParsedEvent::ToolActivity(label) => {
events.push(ReplyEvent::Progress {
progress: provider_progress(
ReplyProgressKind::Tool,
ReplyProgressStage::Started,
label.clone(),
"tool_use",
)
.with_tool_name(label.clone()),
progress: tool_progress(&label),
});
events.push(ReplyEvent::ToolActivity { label });
}
@ -230,12 +239,18 @@ fn parse_jsonl_turn_scoped(
}
}
ParsedEvent::Error(message) => {
error_seen = true;
*error_seen = true;
events.push(ReplyEvent::Error { message });
}
ParsedEvent::Ignored => {}
}
}
fn append_final_event(
mut events: Vec<ReplyEvent>,
final_text: String,
error_seen: bool,
) -> Result<Vec<ReplyEvent>, AgentSessionError> {
if final_text.trim().is_empty() {
if error_seen {
return Ok(events);
@ -260,6 +275,106 @@ fn provider_progress(
.with_provider_event("opencode", native_event)
}
fn tool_progress(label: &str) -> ReplyProgress {
if label.starts_with("idea_") {
ReplyProgress::new(
ReplyProgressSource::IdeaLocal,
ReplyProgressKind::Mcp,
ReplyProgressStage::Completed,
label.to_owned(),
)
.with_tool_name(label.to_owned())
} else {
provider_progress(
ReplyProgressKind::Tool,
ReplyProgressStage::Started,
label.to_owned(),
"tool_use",
)
.with_tool_name(label.to_owned())
}
}
fn send_tap(tap: &std::sync::mpsc::Sender<ReplyEvent>, event: ReplyEvent) {
if !matches!(event, ReplyEvent::Final { .. }) {
let _ = tap.send(event);
}
}
#[derive(Default)]
struct LiveTurnProjector {
expected_session_id: Option<String>,
active_message_id: Option<String>,
seen_message_id: bool,
hold_until_new_message: bool,
pending: Vec<ParsedRecord>,
ready: Vec<ReplyEvent>,
final_text: String,
error_seen: bool,
}
impl LiveTurnProjector {
fn new(expected_session_id: Option<String>) -> Self {
Self {
hold_until_new_message: expected_session_id.is_some(),
expected_session_id,
..Self::default()
}
}
fn ingest(&mut self, record: ParsedRecord) -> Vec<ReplyEvent> {
if let (Some(expected), Some(actual)) = (
self.expected_session_id.as_deref(),
record.session_id.as_deref(),
) {
if actual != expected {
return Vec::new();
}
}
if matches!(record.event, ParsedEvent::Ignored) {
return Vec::new();
}
if let Some(message_id) = record.message_id.clone() {
if self.active_message_id.as_deref() != Some(message_id.as_str()) {
if self.seen_message_id {
self.pending.clear();
self.hold_until_new_message = false;
}
self.active_message_id = Some(message_id);
self.seen_message_id = true;
}
}
if self.hold_until_new_message && self.seen_message_id {
self.pending.push(record);
return Vec::new();
}
self.emit_record(record)
}
fn finish(mut self) -> Vec<ReplyEvent> {
let pending = std::mem::take(&mut self.pending);
let mut out = Vec::new();
for record in pending {
out.extend(self.emit_record(record));
}
out
}
fn emit_record(&mut self, record: ParsedRecord) -> Vec<ReplyEvent> {
let before = self.ready.len();
append_record_events(
record,
&mut self.ready,
&mut self.final_text,
&mut self.error_seen,
);
self.ready.split_off(before)
}
}
fn current_turn_records(
lines: &[String],
expected_session_id: Option<&str>,
@ -309,6 +424,16 @@ fn has_structured_terminal_event(events: &[ReplyEvent]) -> bool {
.any(|event| matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. }))
}
fn stream_events(events: Vec<ReplyEvent>, has_tap: bool) -> Vec<ReplyEvent> {
if !has_tap {
return events;
}
events
.into_iter()
.filter(|event| matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. }))
.collect()
}
/// Découpe une commande utilisateur en argv sans shell implicite.
///
/// Supporte les guillemets simples/doubles et les antislashs. Les expansions shell,
@ -440,6 +565,45 @@ impl AgentSession for OpenCodeSession {
}
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
self.send_inner(prompt, None).await
}
async fn send_with_tap(
&self,
prompt: &str,
tap: std::sync::mpsc::Sender<ReplyEvent>,
) -> Result<ReplyStream, AgentSessionError> {
self.send_inner(prompt, Some(tap)).await
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
async fn cancel_current_turn(&self) -> Result<(), AgentSessionError> {
let child = self
.current_child
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
if let Some(child) = child {
child
.lock()
.await
.kill()
.await
.map_err(|e| AgentSessionError::Io(format!("annulation OpenCode: {e}")))?;
}
Ok(())
}
}
impl OpenCodeSession {
async fn send_inner(
&self,
prompt: &str,
tap: Option<std::sync::mpsc::Sender<ReplyEvent>>,
) -> Result<ReplyStream, AgentSessionError> {
let mut cmd = Command::new(&self.command);
cmd.args(self.build_args(prompt))
.stdin(Stdio::null())
@ -482,12 +646,16 @@ impl AgentSession for OpenCodeSession {
let expected_session = self.conversation_id();
let mut lines = BufReader::new(stdout).lines();
let mut collected = Vec::new();
let mut live = tap
.as_ref()
.map(|_| LiveTurnProjector::new(expected_session.clone()));
while let Some(line) = lines
.next_line()
.await
.map_err(|e| AgentSessionError::Io(e.to_string()))?
{
if let Some(engine_id) = extract_session_id(&line)? {
let record = parse_jsonl_record(&line)?;
if let Some(engine_id) = record.session_id.clone() {
if expected_session
.as_deref()
.is_none_or(|expected| expected == engine_id)
@ -495,8 +663,18 @@ impl AgentSession for OpenCodeSession {
self.capture_session_id(engine_id);
}
}
if let (Some(tap), Some(live)) = (&tap, live.as_mut()) {
for event in live.ingest(record) {
send_tap(tap, event);
}
}
collected.push(line);
}
if let (Some(tap), Some(live)) = (&tap, live) {
for event in live.finish() {
send_tap(tap, event);
}
}
let mut stderr_bytes = Vec::new();
stderr_pipe
@ -517,10 +695,11 @@ impl AgentSession for OpenCodeSession {
let stderr = String::from_utf8_lossy(&stderr_bytes);
let parsed_session = expected_session.or_else(|| self.conversation_id());
let parsed = parse_jsonl_turn_scoped(&collected, parsed_session.as_deref());
let has_tap = tap.is_some();
if !status.success() {
if let Ok(events) = parsed {
if has_structured_terminal_event(&events) {
return Ok(Box::new(events.into_iter()));
return Ok(Box::new(stream_events(events, has_tap).into_iter()));
}
}
if stderr.contains("server unavailable") && stderr.contains("key=idea") {
@ -541,28 +720,7 @@ impl AgentSession for OpenCodeSession {
}
let events = parsed?;
Ok(Box::new(events.into_iter()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
async fn cancel_current_turn(&self) -> Result<(), AgentSessionError> {
let child = self
.current_child
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
if let Some(child) = child {
child
.lock()
.await
.kill()
.await
.map_err(|e| AgentSessionError::Io(format!("annulation OpenCode: {e}")))?;
}
Ok(())
Ok(Box::new(stream_events(events, has_tap).into_iter()))
}
}
@ -574,6 +732,8 @@ mod tests {
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::time::Duration;
use super::*;
@ -714,6 +874,36 @@ mod tests {
assert_eq!(event, ParsedEvent::ToolActivity("bash".to_owned()));
}
#[test]
fn parse_jsonl_turn_tags_idea_tools_as_mcp_progress() {
let events = parse_jsonl_turn(&[
r#"{"type":"tool_use","part":{"tool":"idea_ask_agent"}}"#.to_owned(),
r#"{"type":"text","part":{"text":"ok"}}"#.to_owned(),
])
.unwrap();
assert!(
events.iter().any(|event| matches!(
event,
ReplyEvent::Progress { progress }
if progress.source == ReplyProgressSource::IdeaLocal
&& progress.kind == ReplyProgressKind::Mcp
&& progress.stage == ReplyProgressStage::Completed
&& progress.tool_name.as_deref() == Some("idea_ask_agent")
)),
"les tools idea_* doivent être surfacés comme MCP IdeA local: {events:?}"
);
assert!(
!events.iter().any(|event| matches!(
event,
ReplyEvent::Progress { progress }
if progress.source == ReplyProgressSource::ProviderNative
&& progress.tool_name.as_deref() == Some("idea_ask_agent")
)),
"le tool MCP ne doit pas produire une double carte provider-native: {events:?}"
);
}
#[test]
fn parse_jsonl_turn_keeps_latest_message_when_opencode_replays_history() {
let events = parse_jsonl_turn(&[
@ -790,6 +980,14 @@ mod tests {
}
}
#[cfg(unix)]
fn write_executable_script(path: &Path, body: impl AsRef<str>) {
fs::write(path, body.as_ref()).unwrap();
let mut permissions = fs::metadata(path).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions).unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn send_prefers_parseable_stdout_over_nonzero_exit_status() {
@ -838,6 +1036,147 @@ exit 1
);
}
#[cfg(unix)]
#[tokio::test]
async fn send_with_tap_emits_progress_live_before_final_and_tags_idea_tool_as_mcp() {
let tmp = TempDir::new("tap-live");
let script = tmp.path().join("opencode-fixture.sh");
write_executable_script(
&script,
r#"#!/bin/sh
printf '%s\n' '{"type":"step_start","sessionID":"fresh-engine","part":{"messageID":"m1","type":"step-start"}}'
sleep 1
printf '%s\n' '{"type":"tool_use","sessionID":"fresh-engine","part":{"messageID":"m1","type":"tool","tool":"idea_ask_agent"}}'
printf '%s\n' '{"type":"text","sessionID":"fresh-engine","part":{"messageID":"m1","type":"text","text":"réponse live"}}'
printf '%s\n' '{"type":"step_finish","sessionID":"fresh-engine","part":{"messageID":"m1","type":"step-finish"}}'
"#,
);
let session = Arc::new(
OpenCodeSession::new(
SessionId::new_random(),
script.to_string_lossy(),
Vec::new(),
tmp.path().to_string_lossy(),
None,
Vec::new(),
None,
None,
)
.unwrap(),
);
let (tx, rx) = std::sync::mpsc::channel();
let worker = {
let session = Arc::clone(&session);
tokio::spawn(async move {
session
.send_with_tap("prompt", tx)
.await
.map(|stream| stream.collect::<Vec<_>>())
})
};
let (first_live, rx) = tokio::task::spawn_blocking(move || {
let first_live = rx
.recv_timeout(Duration::from_millis(500))
.expect("un événement de progress doit atteindre le tap avant le Final");
(first_live, rx)
})
.await
.unwrap();
assert!(
matches!(
first_live,
ReplyEvent::Progress { ref progress }
if progress.native_event.as_deref() == Some("step_start")
),
"le premier événement live doit être le step_start provider: {first_live:?}"
);
let returned = worker.await.unwrap().unwrap();
let live: Vec<_> = std::iter::once(first_live).chain(rx.into_iter()).collect();
assert!(
live.iter()
.all(|event| !matches!(event, ReplyEvent::Final { .. })),
"le tap ne doit pas recevoir le Final autoritaire: {live:?}"
);
assert!(
live.iter().any(|event| matches!(
event,
ReplyEvent::Progress { progress }
if progress.source == ReplyProgressSource::IdeaLocal
&& progress.kind == ReplyProgressKind::Mcp
&& progress.stage == ReplyProgressStage::Completed
&& progress.tool_name.as_deref() == Some("idea_ask_agent")
)),
"le tool_use idea_* doit produire une progression MCP locale: {live:?}"
);
assert_eq!(
returned,
vec![ReplyEvent::Final {
content: "réponse live".to_owned()
}]
);
}
#[cfg(unix)]
#[tokio::test]
async fn send_with_tap_does_not_replay_previous_message_into_live_stream() {
let tmp = TempDir::new("tap-replay");
let script = tmp.path().join("opencode-fixture.sh");
write_executable_script(
&script,
r#"#!/bin/sh
printf '%s\n' '{"type":"step_start","sessionID":"seeded-engine","part":{"messageID":"old","type":"step-start"}}'
printf '%s\n' '{"type":"text","sessionID":"seeded-engine","part":{"messageID":"old","type":"text","text":"ancienne réponse"}}'
printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messageID":"old","type":"step-finish"}}'
printf '%s\n' '{"type":"step_start","sessionID":"seeded-engine","part":{"messageID":"new","type":"step-start"}}'
printf '%s\n' '{"type":"text","sessionID":"seeded-engine","part":{"messageID":"new","type":"text","text":"réponse courante"}}'
printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messageID":"new","type":"step-finish"}}'
"#,
);
let session = OpenCodeSession::new(
SessionId::new_random(),
script.to_string_lossy(),
Vec::new(),
tmp.path().to_string_lossy(),
Some("seeded-engine".to_owned()),
Vec::new(),
None,
None,
)
.unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let returned = session
.send_with_tap("prompt", tx)
.await
.unwrap()
.collect::<Vec<_>>();
let live: Vec<_> = rx.into_iter().collect();
assert!(
!live.iter().any(|event| matches!(
event,
ReplyEvent::TextDelta { text } if text.contains("ancienne")
)),
"le texte rejoué ne doit pas polluer le tap live: {live:?}"
);
assert!(
live.iter().any(|event| matches!(
event,
ReplyEvent::TextDelta { text } if text == "réponse courante"
)),
"le message courant doit rester visible dans le tap live: {live:?}"
);
assert_eq!(
returned,
vec![ReplyEvent::Final {
content: "réponse courante".to_owned()
}]
);
}
#[test]
fn extract_session_id_accepts_opencode_jsonl_session_id_variants() {
assert_eq!(

View File

@ -335,6 +335,209 @@ describe("CustomAgentChatView", () => {
await screen.findByText("done");
});
it("renders available pre-MCP progress before the first MCP card", async () => {
let emitChunk: ((chunk: unknown) => void) | null = null;
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(
(_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) =>
new Promise<void>((resolve) => {
emitChunk = (chunk: unknown) => {
onChunk(chunk);
if ((chunk as { kind?: string }).kind === "final") resolve();
};
}),
),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const { container } = render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "delegate after intent" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(emitChunk).not.toBeNull());
act(() => {
emitChunk?.({
kind: "progress",
progress: {
source: "providerNative",
kind: "message",
stage: "delta",
label: "Intention",
text: "Je vais d'abord regarder les outils MCP disponibles.",
provider: "codex",
nativeEvent: "agent.message.delta",
},
});
});
expect(
screen.getByText("Je vais d'abord regarder les outils MCP disponibles."),
).toBeTruthy();
expect(screen.queryByText("idea_ask_agent")).toBeNull();
act(() => {
emitChunk?.({
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "started",
label: "idea_ask_agent",
toolName: "idea_ask_agent",
},
});
});
const intent = screen.getByText(
"Je vais d'abord regarder les outils MCP disponibles.",
);
const delegation = screen.getByText("idea_ask_agent");
expect(
Boolean(intent.compareDocumentPosition(delegation) & Node.DOCUMENT_POSITION_FOLLOWING),
).toBe(true);
act(() => {
emitChunk?.({ kind: "final", content: "done" });
});
await screen.findByText("done");
expect(container.textContent).toContain("Je vais d'abord regarder");
});
it("deduplicates delegated tool progress from tap and authoritative stream", async () => {
let emitChunk: ((chunk: unknown) => void) | null = null;
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(
(_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) =>
new Promise<void>((resolve) => {
emitChunk = (chunk: unknown) => {
onChunk(chunk);
if ((chunk as { kind?: string }).kind === "final") resolve();
};
}),
),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "delegate once" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(emitChunk).not.toBeNull());
const progress = (source: "providerNative" | "ideaLocal", stage: "started" | "completed") => ({
kind: "progress" as const,
progress: {
source,
kind: source === "ideaLocal" ? ("mcp" as const) : ("tool" as const),
stage,
label: "idea_ask_agent",
text: stage === "started" ? "target=QA task=validate" : "reply delivered",
provider: source === "providerNative" ? "opencode" : undefined,
nativeEvent:
source === "providerNative"
? stage === "started"
? "tool_use"
: "tool_done"
: stage === "started"
? "conversation.item.added"
: "conversation.item.done",
toolName: "idea_ask_agent",
},
});
act(() => {
emitChunk?.(progress("providerNative", "started"));
emitChunk?.(progress("ideaLocal", "started"));
emitChunk?.(progress("providerNative", "completed"));
emitChunk?.(progress("ideaLocal", "completed"));
emitChunk?.(progress("providerNative", "started"));
emitChunk?.(progress("ideaLocal", "started"));
emitChunk?.(progress("providerNative", "completed"));
emitChunk?.(progress("ideaLocal", "completed"));
emitChunk?.({ kind: "final", content: "done" });
});
await screen.findByText("done");
expect(screen.getAllByText("Délégation")).toHaveLength(2);
expect(screen.getByText("target=QA task=validate")).toBeTruthy();
expect(screen.getByText("reply delivered")).toBeTruthy();
expect(screen.queryByText("Tool")).toBeNull();
await waitFor(() =>
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(),
);
});
it("stops animating running progress rows once the turn reaches a final chunk", async () => {
let emitChunk: ((chunk: unknown) => void) | null = null;
const agent = {

View File

@ -47,7 +47,7 @@ type ChatTurn =
| { role: "user"; text: string; attachments?: string[] }
| { role: "agent"; text: string; pending?: boolean }
| { role: "tool"; label: string }
| { role: "progress"; progress: ReplyProgress }
| { role: "progress"; progress: ReplyProgress; settled?: boolean }
| { role: "final"; text: string }
| { role: "error"; text: string }
| { role: "unknown"; text: string };
@ -186,27 +186,72 @@ function progressKey(progress: ReplyProgress): string {
return [
progress.source,
progress.kind,
progress.stage,
progress.toolName ?? "",
progress.label,
progress.text ?? "",
].join("\u0000");
}
function normalizedProgressToolName(progress: ReplyProgress): string | null {
const name = progress.toolName ?? progress.label;
const normalized = name.trim().toLowerCase();
return normalized || null;
}
function isDelegationProgress(progress: ReplyProgress): boolean {
const name = normalizedProgressToolName(progress);
return name === "idea_ask_agent" || name === "idea_ask_agents";
}
function isToolLikeProgress(progress: ReplyProgress): boolean {
return progress.kind === "tool" || progress.kind === "mcp" || isDelegationProgress(progress);
}
function progressEntityKey(progress: ReplyProgress): string | null {
const name = normalizedProgressToolName(progress);
if (!name) return null;
if (isDelegationProgress(progress)) return `delegation:${name}`;
if (progress.kind === "tool" || progress.kind === "mcp") return `tool:${name}`;
return null;
}
function progressStageBucket(progress: ReplyProgress): string {
if (!isToolLikeProgress(progress)) return progress.stage;
if (progress.stage === "started" || progress.stage === "delta") return "started";
if (progress.stage === "completed") return "completed";
return "info";
}
function progressDisplayKey(progress: ReplyProgress): string {
const entity = progressEntityKey(progress);
if (entity) return `${entity}\u0000${progressStageBucket(progress)}`;
return progressKey(progress);
}
function appendProgress(turns: ChatTurn[], progress: ReplyProgress): ChatTurn[] {
const last = turns[turns.length - 1];
const key = progressDisplayKey(progress);
if (
last?.role === "progress" &&
progressKey(last.progress) === progressKey(progress)
turns.some(
(turn) => turn.role === "progress" && progressDisplayKey(turn.progress) === key,
)
) {
return [
...turns.slice(0, -1),
{ role: "progress", progress },
];
return turns;
}
return [...turns, { role: "progress", progress }];
}
function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] {
const completedEntities = new Set(
turns
.filter(
(turn): turn is Extract<ChatTurn, { role: "progress" }> =>
turn.role === "progress" &&
progressStageBucket(turn.progress) === "completed" &&
Boolean(progressEntityKey(turn.progress)),
)
.map((turn) => progressEntityKey(turn.progress)),
);
return turns.map((turn) => {
if (
turn.role !== "progress" ||
@ -214,6 +259,10 @@ function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] {
) {
return turn;
}
const entity = progressEntityKey(turn.progress);
if (entity && completedEntities.has(entity)) {
return { ...turn, settled: true };
}
return {
...turn,
progress: {
@ -1263,7 +1312,7 @@ function ChatBubble({ turn }: { turn: ChatTurn }) {
);
}
if (turn.role === "progress") {
return <ProgressBubble progress={turn.progress} />;
return <ProgressBubble progress={turn.progress} settled={turn.settled} />;
}
if (turn.role === "final") {
return (
@ -1316,11 +1365,6 @@ function ChatBubble({ turn }: { turn: ChatTurn }) {
);
}
function isDelegationProgress(progress: ReplyProgress): boolean {
const name = (progress.toolName ?? progress.label).toLowerCase();
return name === "idea_ask_agent" || name === "idea_ask_agents";
}
function progressTitle(progress: ReplyProgress): string {
if (isDelegationProgress(progress)) return "Délégation";
if (progress.kind === "mcp") return "MCP";
@ -1365,19 +1409,23 @@ function truncateProgressText(text: string): string {
return text.length > 140 ? `${text.slice(0, 137)}...` : text;
}
function ProgressBubble({ progress }: { progress: ReplyProgress }) {
function ProgressBubble({
progress,
settled = false,
}: {
progress: ReplyProgress;
settled?: boolean;
}) {
const title = progressTitle(progress);
const stage = progressStageLabel(progress.stage);
const source = progressSourceLabel(progress);
const detail = progress.text ? truncateProgressText(progress.text) : null;
const running = progress.stage === "started" || progress.stage === "delta";
const running = !settled && (progress.stage === "started" || progress.stage === "delta");
return (
<div
className={cn(
"flex min-w-0",
progress.kind === "tool" || progress.kind === "mcp" || isDelegationProgress(progress)
? "justify-start pl-7"
: "justify-center",
isToolLikeProgress(progress) ? "justify-start pl-7" : "justify-center",
)}
>
<div