diff --git a/.ideai/attachments/agent-chat/7293ea58-3ce4-4280-a6b7-ddaa2f78aaa1/16cf2489-b2c1-4038-8f2f-4d5011775430-clipboard-image-1788969852787.png b/.ideai/attachments/agent-chat/7293ea58-3ce4-4280-a6b7-ddaa2f78aaa1/16cf2489-b2c1-4038-8f2f-4d5011775430-clipboard-image-1788969852787.png new file mode 100644 index 00000000..fd6a1f9c Binary files /dev/null and b/.ideai/attachments/agent-chat/7293ea58-3ce4-4280-a6b7-ddaa2f78aaa1/16cf2489-b2c1-4038-8f2f-4d5011775430-clipboard-image-1788969852787.png differ diff --git a/.ideai/attachments/agent-chat/7293ea58-3ce4-4280-a6b7-ddaa2f78aaa1/16cf2489-b2c1-4038-8f2f-4d5011775430.json b/.ideai/attachments/agent-chat/7293ea58-3ce4-4280-a6b7-ddaa2f78aaa1/16cf2489-b2c1-4038-8f2f-4d5011775430.json new file mode 100644 index 00000000..6492b06d --- /dev/null +++ b/.ideai/attachments/agent-chat/7293ea58-3ce4-4280-a6b7-ddaa2f78aaa1/16cf2489-b2c1-4038-8f2f-4d5011775430.json @@ -0,0 +1,13 @@ +{ + "id": "16cf2489-b2c1-4038-8f2f-4d5011775430", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "agentId": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d", + "sessionId": "7293ea58-3ce4-4280-a6b7-ddaa2f78aaa1", + "filename": "clipboard-image-1788969852787.png", + "mime": "image/png", + "sizeBytes": 28204, + "sourceKind": "clipboard", + "storagePath": "agent-chat/7293ea58-3ce4-4280-a6b7-ddaa2f78aaa1/16cf2489-b2c1-4038-8f2f-4d5011775430-clipboard-image-1788969852787.png", + "readablePath": "/home/anthony/Documents/Projects/IdeA/.ideai/attachments/agent-chat/7293ea58-3ce4-4280-a6b7-ddaa2f78aaa1/16cf2489-b2c1-4038-8f2f-4d5011775430-clipboard-image-1788969852787.png", + "createdAt": 1788969926078 +} \ No newline at end of file diff --git a/.ideai/tickets/293/carnet.md b/.ideai/tickets/293/carnet.md new file mode 100644 index 00000000..36a7a305 --- /dev/null +++ b/.ideai/tickets/293/carnet.md @@ -0,0 +1,90 @@ +--- +issueRef: "#293" +version: 4 +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedAt: 1788962603796 +--- + +## QA - 2026-09-09 - Validation doc-only SDK + +Périmètre vérifié dans le submodule `sdk/IdeaSDK`, branche `feature/293-mcp-tools-exposure-docs`. + +Diff relu : modifications limitées à `docs/activation-context.md`, `docs/manifest.md` et `docs/project-plugin-assignments.md`. + +Commandes exécutées : + +```bash +git status --short --branch +``` + +Sortie : + +```text +## feature/293-mcp-tools-exposure-docs + M docs/activation-context.md + M docs/manifest.md + M docs/project-plugin-assignments.md +``` + +```bash +git diff --stat +``` + +Sortie : + +```text + docs/activation-context.md | 11 ++++++++ + docs/manifest.md | 58 ++++++++++++++++++++++++++++++++++++++ + docs/project-plugin-assignments.md | 13 +++++++++ + 3 files changed, 82 insertions(+) +``` + +```bash +npm run check +``` + +Sortie essentielle : + +```text +> @idea/plugin-sdk@0.1.0 check +> npm run build && npm run typecheck:examples && npm run package:hello-plugin + +> @idea/plugin-sdk@0.1.0 build +> tsc -p tsconfig.json + +> @idea/plugin-sdk@0.1.0 typecheck:examples +> tsc -p examples/hello-plugin/tsconfig.json --noEmit + +> @idea/plugin-sdk@0.1.0 package:hello-plugin +> npm run build && npm run build:hello-plugin && node scripts/package-hello-plugin.mjs + +> @idea/plugin-sdk@0.1.0 build +> tsc -p tsconfig.json + +> @idea/plugin-sdk@0.1.0 build:hello-plugin +> tsc -p examples/hello-plugin/tsconfig.build.json + +created /home/anthony/Documents/Projects/IdeA/sdk/IdeaSDK/examples/hello-plugin/build/hello-plugin-0.1.0.zip +``` + +Lecture QA : la documentation clarifie bien que les tools agent MCP viennent de `contributes.mcpServers` via serveurs MCP stdio et `tools/list`, pas d'une API `activate(ctx)` ; elle couvre `ctx.pluginRoot`, `autoStart: true`, le triplet `pluginId`/`serverId`/`toolName`, et la recommandation skill -> MCP tool pour les scripts packagés. + +Verdict QA : VERT. + +## Git - 2026-09-09 - Intégration locale + +Git a intégré la branche dédiée du submodule SDK : + +- Submodule `sdk/IdeaSDK`: `develop` pointe sur `68fd612` (`merge(sdk): intègre feature/293-mcp-tools-exposure-docs`). +- Commit de feature SDK: `42567a5` (`docs(sdk): contrat d'exposition des tools MCP par les plugins`). +- Repo parent IdeA: `develop` pointe sur `1935e98` (`chore(sdk): pointe le submodule sur develop`). + +Aucune action distante effectuée. État parent restant hors périmètre : fichiers tickets locaux et répertoire `scripts/` déjà visibles/non liés à la doc SDK. + +## Réponse contractuelle pour UnityPlugin #11 + +1. Contrat manifest/runtime : exposer un serveur MCP stdio dans `idea-plugin.json` sous `contributes.mcpServers` avec `id`, `displayName`, `command`, `transport: "stdio"`, idéalement `autoStart: true`; l'agent reçoit les tools via assignation `pluginId`/`serverId`/`toolName` après activation projet et relance agent. +2. Pas de support SDK actuel pour enregistrer des tools MCP directement depuis `activate(ctx)`; seuls les serveurs MCP déclarés/externes sont le contrat agent-callable. +3. `ctx.pluginRoot` est l'API officielle pour résoudre la racine du package installé depuis le runtime. `import.meta.url` peut servir à de la résolution module JS, mais n'est pas le contrat host/plugin. Pour `contributes.mcpServers`, `${pluginRoot}` et `${appDataDir}` sont substitués au démarrage du serveur. +4. Recommandation court terme UnityPlugin : packager un serveur MCP stdio Node/script dans le plugin, le déclarer via `contributes.mcpServers`, assigner les tools aux agents, et modifier les skills pour demander l'appel des tools plutôt que référencer `../../scripts/...` depuis le Markdown. +5. Documentation SDK enrichie dans `manifest.md`, `activation-context.md`, `project-plugin-assignments.md`. diff --git a/.ideai/tickets/293/issue.md b/.ideai/tickets/293/issue.md new file mode 100644 index 00000000..fded8984 --- /dev/null +++ b/.ideai/tickets/293/issue.md @@ -0,0 +1,17 @@ +--- +id: "79792d0c-92a2-499d-8dc3-7b0e65d38dcc" +number: 293 +title: "Clarifier et documenter l’exposition de tools MCP par les plugins IdeA" +status: "closed" +priority: "high" +sprint: null +links: [] +agentRefs: [] +attachments: [] +createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +createdAt: 1788962113957 +updatedAt: 1788962603796 +version: 4 +--- +Demande entrante depuis Main du projet UnityPlugin, ticket #11. Clarifier le contrat SDK/manifest/runtime pour exposer des tools MCP depuis un plugin IdeA, vérifier si le SDK supporte les tools fournis directement par le runtime ou seulement des MCP servers déclarés/externes, préciser la résolution du répertoire d’installation plugin, et enrichir IdeaSDK/docs si le point est absent ou ambigu. \ No newline at end of file diff --git a/.ideai/tickets/294/carnet.md b/.ideai/tickets/294/carnet.md new file mode 100644 index 00000000..cce0e736 --- /dev/null +++ b/.ideai/tickets/294/carnet.md @@ -0,0 +1,112 @@ +--- +issueRef: "#294" +version: 4 +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedAt: 1788963918771 +--- + +## QA - 2026-09-09 - Validation doc-only SDK + +Périmètre vérifié dans le submodule `sdk/IdeaSDK`, branche `feature/294-node-mcp-launch-docs`. + +Diff relu : modifications limitées à `docs/manifest.md`, `docs/services.md` et `docs/packaging-distribution.md`. + +Commandes exécutées : + +```bash +git status --short --branch +``` + +Sortie : + +```text +## feature/294-node-mcp-launch-docs + M docs/manifest.md + M docs/packaging-distribution.md + M docs/services.md +``` + +```bash +git diff --stat +``` + +Sortie : + +```text + docs/manifest.md | 56 ++++++++++++++++++++++++++++++++++++------ + docs/packaging-distribution.md | 19 ++++++++++++++ + docs/services.md | 9 ++++++- + 3 files changed, 76 insertions(+), 8 deletions(-) +``` + +```bash +rg -n "\\$\\{node\\}|\\$\\{runtimeNode\\}|\\$\\{hostNode\\}|runtime Node|plugin-scoped Node|command: \\\"node\\\"|allowAbsoluteCommand|shell|package-relative|runCommand\\(\\)|\\$\\{pluginRoot\\}|\\$\\{appDataDir\\}" docs/manifest.md docs/services.md docs/packaging-distribution.md +``` + +Sortie essentielle : + +```text +docs/packaging-distribution.md:49:starting the declared `command` directly. IdeA does not inject a shell and does +docs/packaging-distribution.md:50:not provide a plugin-scoped Node.js runtime. +docs/packaging-distribution.md:52:For a packaged MCP server, prefer a package-relative executable such as +docs/packaging-distribution.md:61:or declare an explicit absolute host dependency with `allowAbsoluteCommand: +docs/packaging-distribution.md:62:true`. Avoid relying on a bare `command: "node"` in distributed or multi-harness +docs/services.md:45:as read-only. Unlike manifest-declared MCP servers, `runCommand()` does not +docs/services.md:54: command: "node", +docs/services.md:63:[Manifest](manifest.md), where a bare `command: "node"` is package-relative +docs/manifest.md:313:and no `runtime` field. IdeA does not provide a plugin-scoped Node.js runtime +docs/manifest.md:321:`command` is not shell syntax. IdeA starts it directly and passes `args` +docs/manifest.md:351:Avoid `command: "node"` for a packaged MCP server. With +docs/manifest.md:352:`allowAbsoluteCommand: false`, it is treated as package-relative and resolves to +docs/manifest.md:353:`${pluginRoot}/node`. With `allowAbsoluteCommand: true`, it is passed as a host +``` + +```bash +npm run check +``` + +Sortie essentielle : + +```text +> @idea/plugin-sdk@0.1.0 check +> npm run build && npm run typecheck:examples && npm run package:hello-plugin + +> @idea/plugin-sdk@0.1.0 build +> tsc -p tsconfig.json + +> @idea/plugin-sdk@0.1.0 typecheck:examples +> tsc -p examples/hello-plugin/tsconfig.json --noEmit + +> @idea/plugin-sdk@0.1.0 package:hello-plugin +> npm run build && npm run build:hello-plugin && node scripts/package-hello-plugin.mjs + +> @idea/plugin-sdk@0.1.0 build +> tsc -p tsconfig.json + +> @idea/plugin-sdk@0.1.0 build:hello-plugin +> tsc -p examples/hello-plugin/tsconfig.build.json + +created /home/anthony/Documents/Projects/IdeA/sdk/IdeaSDK/examples/hello-plugin/build/hello-plugin-0.1.0.zip +``` + +Lecture QA : la documentation clarifie bien qu'il n'existe pas de substitution `${node}`, `${runtimeNode}`, `${hostNode}` ni de runtime Node plugin fourni par IdeA ; qu'un `command` MCP est lancé directement sans shell ; qu'un `command` relatif est résolu sous `${pluginRoot}` ; que `command: "node"` devient `${pluginRoot}/node` avec `allowAbsoluteCommand: false` ; qu'une dépendance host absolue requiert `allowAbsoluteCommand: true` ; et que les serveurs JS reproductibles doivent vendor/installer un runtime ou déclarer explicitement une dépendance absolue. + +Verdict QA : VERT. + +## Git - 2026-09-09 - Intégration locale + +Cycle Git terminé, sans push. + +- SDK submodule `sdk/IdeaSDK` : commit feature `b5a3556` (`docs(sdk): clarifie le lancement Node des serveurs MCP plugins`) puis merge local `fdb3a90` sur `develop`. +- Repo parent IdeA : commit `b1c173f` sur `develop` (`chore(sdk): bump du pointeur IdeaSDK pour #294`). +- Vérification reconfirmée par Git : `npm run check` vert. + +Réserves : le submodule SDK est en avance sur origin avec les travaux #293/#294, et le parent `develop` est en avance localement. Aucune action distante effectuée. Fichiers `.ideai/tickets/*` et `scripts/` hors périmètre laissés tels quels. + +## Réponse contractuelle pour UnityPlugin #12 + +1. Pour un serveur MCP stdio JavaScript packagé, le contrat actuel est de lancer un `command` manifest direct, sans shell. Pour ne pas dépendre du PATH shell utilisateur, il faut pointer `command` vers un exécutable reproductible : package-relative sous `${pluginRoot}` ou géré sous `${appDataDir}`. Un `.mjs` avec shebang `#!/usr/bin/env node` reste dépendant du Node visible par le process IdeA, pas du shell interactif. +2. Il n'existe pas de substitution officielle `${node}`, `${runtimeNode}`, `${hostNode}` ni de champ `runtime`. IdeA ne fournit pas actuellement de runtime Node plugin-scoped. +3. `allowAbsoluteCommand: false` rejette les commandes absolues littérales dans le manifest, mais n'interdit pas un `command` qui devient absolu après substitution `${pluginRoot}`/`${appDataDir}`. `command: "bash"` est relatif et devient `${pluginRoot}/bash`; `/bin/bash` exige `allowAbsoluteCommand: true`. Préférer `command: "scripts/wrapper"` pour un wrapper packagé exécutable. +4. Solution compatible aujourd'hui : packager un exécutable MCP autonome ou installer/vendor un runtime sous le package/appData et pointer dessus. À défaut, déclarer explicitement une dépendance host absolue (`/usr/bin/node` ou `/bin/bash`) avec `allowAbsoluteCommand: true`. Un wrapper de résolution Node peut améliorer le diagnostic, mais ne supprime pas la dépendance si aucun runtime n'est fourni. +5. Documentation SDK enrichie dans `docs/manifest.md`, `docs/services.md`, `docs/packaging-distribution.md`. diff --git a/.ideai/tickets/294/issue.md b/.ideai/tickets/294/issue.md new file mode 100644 index 00000000..63e0f180 --- /dev/null +++ b/.ideai/tickets/294/issue.md @@ -0,0 +1,17 @@ +--- +id: "5ec58e6e-29f1-4f22-95bd-c5e24da5bbc3" +number: 294 +title: "Clarifier le lancement de serveurs MCP JavaScript packagés par les plugins IdeA" +status: "closed" +priority: "high" +sprint: null +links: [] +agentRefs: [] +attachments: [] +createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +createdAt: 1788963442298 +updatedAt: 1788963918771 +version: 4 +--- +Demande entrante depuis Main UnityPlugin, ticket #12. Après fix #11, le plugin com.idea.unity-developer-tools recharge avec changed=true/restartRequired=false mais le serveur MCP déclaré command:"node" échoue ENOENT. Clarifier le contrat de lancement Node/runtime pour contributes.mcpServers, l'existence ou non d'une substitution ${node}/${runtimeNode}, l'effet de allowAbsoluteCommand, et documenter la solution compatible directory plugin/multi-harness. \ No newline at end of file diff --git a/.ideai/tickets/295/carnet.md b/.ideai/tickets/295/carnet.md new file mode 100644 index 00000000..231dc6c7 --- /dev/null +++ b/.ideai/tickets/295/carnet.md @@ -0,0 +1,156 @@ +--- +issueRef: "#295" +version: 2 +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedAt: 1788971880464 +--- + +## QA - 2026-09-09 - Validation post-restart chat transcript + +Branche vérifiée : `fix/295-custom-chat-transcript-after-restart`. + +Diff relu sur les zones déclarées : + +- `crates/app-tauri/src/chat.rs`, `crates/app-tauri/src/commands.rs` +- `crates/app-tauri/tests/chat_bridge.rs`, `crates/app-tauri/tests/dto_chat.rs` +- `crates/backend/src/dto.rs`, `crates/backend/src/stream.rs` +- `crates/web-server/src/lib.rs` +- `frontend/src/adapters/agent.ts`, `frontend/src/adapters/http/*`, `frontend/src/ports/index.ts` +- `frontend/src/features/agents/CustomAgentChatView.tsx`, `CustomAgentChatView.test.tsx` + +Lecture QA : le fix couvre bien le contrat attendu. Côté Tauri, `ChatBridge` conserve/rekey scrollback, sink et propriétaire stable, et `reattach_agent_chat` peut récupérer une session vivante par `projectId`/`agentId` en renvoyant `sessionId` effectif et `conversationId`. Côté backend partagé, le DTO expose l'id effectif et `ReplayOutputBridge::rekey` préserve le flux logique. Côté web-server, `chat.attach` accepte le fallback stable et enregistre le sink sous le nouveau `sessionId`. Côté frontend, la vue réconcilie `agentRestartCompleted`, retente un stale `NOT_FOUND` avec identité stable, applique le scrollback, met à jour les ids effectifs et évite l'écran vide quand une conversation/run/workstate lié existe. + +Commandes exécutées : + +```bash +git status --short --branch +``` + +Sortie essentielle : + +```text +## fix/295-custom-chat-transcript-after-restart + M crates/app-tauri/src/chat.rs + M crates/app-tauri/src/commands.rs + M crates/app-tauri/tests/chat_bridge.rs + M crates/app-tauri/tests/dto_chat.rs + M crates/backend/src/dto.rs + M crates/backend/src/stream.rs + M crates/web-server/src/lib.rs + M frontend/src/adapters/agent.ts + M frontend/src/adapters/http/agentGateway.test.ts + M frontend/src/adapters/http/frames.ts + M frontend/src/adapters/http/streamGateways.ts + M frontend/src/features/agents/CustomAgentChatView.test.tsx + M frontend/src/features/agents/CustomAgentChatView.tsx + M frontend/src/ports/index.ts +``` + +```bash +git diff --check +``` + +Sortie : aucune. + +```bash +cargo fmt --all --check +``` + +Sortie : aucune. + +```bash +cargo test -p app-tauri --test chat_bridge --test dto_chat +``` + +Sortie essentielle : + +```text +Running tests/chat_bridge.rs +running 34 tests +... +test result: ok. 34 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out + +Running tests/dto_chat.rs +running 24 tests +... +test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +``` + +```bash +cargo check -p app-tauri +``` + +Sortie essentielle : + +```text +Finished `dev` profile [unoptimized + debuginfo] target(s) in 11.14s +``` + +```bash +cargo test -p backend && cargo check -p backend +``` + +Sortie essentielle : + +```text +Running unittests src/lib.rs +running 98 tests +... +test result: ok. 91 passed; 0 failed; 7 ignored; 0 measured; 0 filtered out + +Doc-tests backend +running 0 tests +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out + +Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s +``` + +Note : les 7 tests backend ignorés sont déclarés `ignored` car ils requièrent une permission de bind socket local ; ce n'est pas un échec et ce n'est pas bloquant pour #295. + +```bash +cargo test -p web-server websocket_chat_attach && cargo check -p web-server +``` + +Sortie essentielle : + +```text +running 3 tests +test tests::websocket_chat_attach_emits_chat_attached_and_registers_sink ... ok +test tests::websocket_chat_attach_recovers_stale_session_from_live_project_agent ... ok +test tests::websocket_chat_attach_stale_session_without_fallback_stays_not_found ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 109 filtered out + +Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s +``` + +```bash +npm test -- CustomAgentChatView.test.tsx agentGateway.test.ts +``` + +Sortie essentielle : + +```text +Test Files 2 passed (2) +Tests 77 passed (77) +``` + +Note : Vite a émis deux warnings de dépréciation de configuration `esbuild`/`oxc`; non bloquant pour #295. + +```bash +npm run build +``` + +Sortie essentielle : + +```text +> idea-frontend@0.3.0 build +> tsc --noEmit && vite build + +✓ 515 modules transformed. +✓ built in 2.61s +``` + +Note : Vite signale un chunk minifié > 500 kB ; avertissement existant/non bloquant pour #295. + +Verdict QA : VERT. diff --git a/.ideai/tickets/295/issue.md b/.ideai/tickets/295/issue.md new file mode 100644 index 00000000..b68da93e --- /dev/null +++ b/.ideai/tickets/295/issue.md @@ -0,0 +1,17 @@ +--- +id: "bc0fe5e4-8398-4c47-aeca-2c2e93a0d1d3" +number: 295 +title: "Corriger l’affichage du transcript après redémarrage automatique d’un agent" +status: "qa" +priority: "high" +sprint: null +links: [] +agentRefs: [] +attachments: [] +createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +createdAt: 1788969948217 +updatedAt: 1788971880464 +version: 2 +--- +Bug utilisateur observé sur UnitySoundTool: après redémarrage automatique de l'agent Main, l'agent continue réellement à travailler et les workstates changent, mais aucun message ne s'affiche après le message indiquant que l'agent a bien redémarré. Après changement de projet puis retour, l'affichage se nettoie et ne montre plus que des bandeaux/progress d'agents sollicités par Main en haut de la cellule. Contexte: le redémarrage est survenu après appel à Main UnityPlugin pour recharger un plugin modifié. Capture jointe dans la conversation utilisateur. \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index c631140c..7dd3cefb 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 293 + "nextNumber": 296 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 3b79826f..efe3ab9c 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -4009,6 +4009,48 @@ "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, "updatedAt": 1788956518862 + }, + { + "issueRef": "#293", + "path": "293", + "title": "Clarifier et documenter l’exposition de tools MCP par les plugins IdeA", + "status": "closed", + "priority": "high", + "sprint": null, + "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" + }, + "updatedAt": 1788962603796 + }, + { + "issueRef": "#294", + "path": "294", + "title": "Clarifier le lancement de serveurs MCP JavaScript packagés par les plugins IdeA", + "status": "closed", + "priority": "high", + "sprint": null, + "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" + }, + "updatedAt": 1788963918771 + }, + { + "issueRef": "#295", + "path": "295", + "title": "Corriger l’affichage du transcript après redémarrage automatique d’un agent", + "status": "qa", + "priority": "high", + "sprint": null, + "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" + }, + "updatedAt": 1788971880464 } ] } \ No newline at end of file diff --git a/crates/app-tauri/src/chat.rs b/crates/app-tauri/src/chat.rs index 6170e86d..f4363feb 100644 --- a/crates/app-tauri/src/chat.rs +++ b/crates/app-tauri/src/chat.rs @@ -24,12 +24,13 @@ //! [`unregister_if`]: ChatBridge::unregister_if //! [`send_output`]: ChatBridge::send_output -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; use backend::stream::ReplayOutputBridge; use tauri::ipc::Channel; -use domain::ids::SessionId; +use domain::ids::{AgentId, ProjectId, SessionId}; use domain::ports::{ ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage, }; @@ -54,6 +55,8 @@ pub const MAX_CHAT_SCROLLBACK_BYTES: usize = 512 * 1024; /// the twin of [`crate::pty::PtyBridge`]. pub struct ChatBridge { inner: ReplayOutputBridge, + successors: Mutex>, + owners: Mutex>, } impl Default for ChatBridge { @@ -72,9 +75,78 @@ impl ChatBridge { MAX_CHAT_SCROLLBACK_BYTES, reply_chunk_bytes, ), + successors: Mutex::new(HashMap::new()), + owners: Mutex::new(HashMap::new()), } } + fn canonical_session(&self, session: &SessionId) -> SessionId { + let Ok(successors) = self.successors.lock() else { + return *session; + }; + let mut current = *session; + for _ in 0..successors.len() { + let Some(next) = successors.get(¤t).copied() else { + break; + }; + if next == current { + break; + } + current = next; + } + current + } + + /// Returns the latest transport session id for a logical chat that crossed + /// one or more structured-runtime restarts. + #[must_use] + pub fn successor(&self, session: &SessionId) -> SessionId { + self.canonical_session(session) + } + + /// Associates a transport session with the stable runtime owner used to + /// recover the current session after an out-of-band restart. + pub fn bind_owner(&self, session: SessionId, project_id: ProjectId, agent_id: AgentId) { + let session = self.canonical_session(&session); + if let Ok(mut owners) = self.owners.lock() { + owners.insert(session, (project_id, agent_id)); + } + } + + /// Returns the stable owner previously bound to this logical chat. + #[must_use] + pub fn owner(&self, session: &SessionId) -> Option<(ProjectId, AgentId)> { + let canonical = self.canonical_session(session); + self.owners + .lock() + .ok() + .and_then(|owners| owners.get(&canonical).copied()) + } + + /// Moves a logical chat to the replacement runtime session while preserving + /// its sink and retained transcript. Future writes using either the old or + /// new id resolve to the replacement id. + pub fn rebind_session(&self, previous: SessionId, replacement: SessionId) { + let previous = self.canonical_session(&previous); + if previous == replacement { + return; + } + if let Ok(mut successors) = self.successors.lock() { + for successor in successors.values_mut() { + if *successor == previous { + *successor = replacement; + } + } + successors.insert(previous, replacement); + } + if let Ok(mut owners) = self.owners.lock() { + if let Some(owner) = owners.remove(&previous) { + owners.entry(replacement).or_insert(owner); + } + } + self.inner.rekey(&previous, replacement); + } + /// Registers (or replaces) the reply channel for a session and returns the /// **generation** of this registration. Each call for a session bumps the /// generation, so the caller's pump can later tear down *only its own* @@ -83,6 +155,7 @@ impl ChatBridge { /// channel and generation change), mirroring how the PTY ring buffer survives /// a `reattach_terminal`. pub fn register(&self, session: SessionId, channel: Channel) -> u64 { + let session = self.canonical_session(&session); self.inner .register(session, Arc::new(TauriChannelSink::new(channel))) } @@ -95,7 +168,7 @@ impl ChatBridge { /// `PtyPort::scrollback`. #[must_use] pub fn scrollback(&self, session: &SessionId) -> Vec { - self.inner.scrollback(session) + self.inner.scrollback(&self.canonical_session(session)) } /// Clears only the retained conversation scrollback for a live session, @@ -105,21 +178,29 @@ impl ChatBridge { /// clears its mounted view from the command response, and future re-attaches /// no longer replay the old conversation. pub fn clear_scrollback(&self, session: &SessionId) -> usize { - self.inner.clear_scrollback(session) + self.inner + .clear_scrollback(&self.canonical_session(session)) } /// Removes a session's transport state **and** its retained scrollback /// unconditionally (chat cell explicitly closed). Twin of /// [`PtyBridge::unregister`](crate::pty::PtyBridge::unregister). pub fn unregister(&self, session: &SessionId) { - self.inner.unregister(session); + let canonical = self.canonical_session(session); + self.inner.unregister(&canonical); + if let Ok(mut successors) = self.successors.lock() { + successors.retain(|from, to| *from != canonical && *to != canonical); + } + if let Ok(mut owners) = self.owners.lock() { + owners.remove(&canonical); + } } /// Detaches the current output channel while preserving the live session's /// retained scrollback. This is the view-unmount path; it is intentionally /// distinct from [`unregister`](Self::unregister), which is the real close. pub fn detach(&self, session: &SessionId) { - self.inner.detach(session); + self.inner.detach(&self.canonical_session(session)); } /// Detaches a session's channel **only if** `gen` is still the current @@ -133,7 +214,7 @@ impl ChatBridge { /// /// [`unregister`]: ChatBridge::unregister pub fn detach_if(&self, session: &SessionId, gen: u64) { - self.inner.detach_if(session, gen); + self.inner.detach_if(&self.canonical_session(session), gen); } /// Records a chunk in the session's scrollback and, if a view is attached at @@ -145,7 +226,8 @@ impl ChatBridge { /// The pump keeps draining either way so the turn still completes and the /// scrollback stays whole. pub fn send_output(&self, session: &SessionId, chunk: ReplyChunk) -> bool { - self.inner.send_output(session, chunk) + self.inner + .send_output(&self.canonical_session(session), chunk) } /// Sends `chunk`, first completing any previous running progress entity when diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index c080bfb4..e88b9fc5 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -2619,6 +2619,9 @@ pub async fn agent_send( .map(|attachment| attachment.filename.clone()) .collect(); let session_meta = state.structured_sessions.meta_for_session(&sid); + if let Some((project_id, agent_id, _, _)) = &session_meta { + state.chat_bridge.bind_owner(sid, *project_id, *agent_id); + } let turn_guard = session_meta.as_ref().map(|(project_id, agent_id, _, _)| { backend::StructuredHumanTurnGuard::start_with_source( std::sync::Arc::clone(&state.turn_watch_input), @@ -2830,6 +2833,11 @@ pub async fn agent_send( ); break; }; + // The provider replacement owns a fresh SessionId, but this remains + // one logical human chat. Move the transport transcript/sink before + // opening the continuation stream; writes still made with `sid` are + // resolved by ChatBridge to the replacement id. + bridge.rebind_session(sid, replacement.id()); let resume_prompt = resume_prompt .unwrap_or_else(|| application::SELF_RESTART_CONTINUATION_PROMPT.to_owned()); match runtime.block_on(replacement.send(&resume_prompt)) { @@ -3413,23 +3421,63 @@ pub async fn set_front_attached( /// then receives subsequent chunks over `on_reply`. /// /// # Errors -/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live -/// structured session owns the id — the caller then falls back to a fresh launch). +/// `project_id` + `agent_id` are an optional stable fallback for callers whose +/// stored transport id predates a restart. The response always carries the +/// effective live `sessionId`, its current scrollback and its provider +/// `conversationId` when known. +/// +/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if neither +/// the requested/successor session nor the project-agent fallback is live). #[tauri::command] pub fn reattach_agent_chat( session_id: String, + project_id: Option, + agent_id: Option, on_reply: Channel, state: State<'_, AppState>, ) -> Result { - let sid = parse_session_id(&session_id)?; + let requested_sid = parse_session_id(&session_id)?; - // A missing live session means the conversation is gone (closed/never live) — - // surfaced as NOT_FOUND so the caller falls back to opening a fresh cell. + // A restart replaces the provider session id. Prefer the bridge successor + // recorded by the restart-aware pump, then allow the caller to recover by + // stable project/agent identity if it only retained a dead id. + let mut sid = state.chat_bridge.successor(&requested_sid); if state.structured_sessions.session(&sid).is_none() { - return Err(ErrorDto::from(AppError::NotFound(format!( - "structured session {sid}" - )))); + let explicit_owner = match (project_id.as_deref(), agent_id.as_deref()) { + (Some(project_id), Some(agent_id)) => { + Some((parse_project_id(project_id)?, parse_agent_id(agent_id)?)) + } + (None, None) => None, + _ => { + return Err(ErrorDto::from(AppError::Invalid( + "projectId and agentId must be provided together".to_owned(), + ))); + } + }; + sid = match explicit_owner.or_else(|| state.chat_bridge.owner(&requested_sid)) { + Some((project_id, agent_id)) => { + let replacement = state + .structured_sessions + .session_id_for_agent_in_project(project_id, &agent_id) + .ok_or_else(|| { + ErrorDto::from(AppError::NotFound(format!( + "live structured session for agent {agent_id}" + ))) + })?; + state.chat_bridge.rebind_session(requested_sid, replacement); + replacement + } + None => { + return Err(ErrorDto::from(AppError::NotFound(format!( + "structured session {requested_sid}" + )))); + } + }; } + let conversation_id = state + .structured_sessions + .meta_for_session(&sid) + .and_then(|(_, _, _, conversation_id)| conversation_id); // (1) Snapshot the conversation scrollback before swapping the channel. let scrollback = state.chat_bridge.scrollback(&sid); @@ -3440,7 +3488,8 @@ pub fn reattach_agent_chat( let _gen = state.chat_bridge.register(sid, on_reply); Ok(ReattachChatDto { - session_id, + session_id: sid.to_string(), + conversation_id, scrollback, }) } diff --git a/crates/app-tauri/tests/chat_bridge.rs b/crates/app-tauri/tests/chat_bridge.rs index 35ee19d9..7ef65da1 100644 --- a/crates/app-tauri/tests/chat_bridge.rs +++ b/crates/app-tauri/tests/chat_bridge.rs @@ -550,6 +550,84 @@ fn reattach_repaints_scrollback_and_preserves_it_without_resend() { ); } +#[test] +fn restart_rebind_preserves_transcript_and_routes_old_id_writes_to_replacement() { + let bridge = ChatBridge::new(); + let previous = sid(); + let replacement = sid(); + let sink = Arc::new(Mutex::new(Vec::new())); + let generation = bridge.register(previous, capturing_channel(Arc::clone(&sink))); + + assert!(bridge.send_output(&previous, delta("before restart"))); + bridge.rebind_session(previous, replacement); + + assert_eq!(bridge.successor(&previous), replacement); + assert_eq!( + bridge.scrollback(&replacement), + vec![delta("before restart")] + ); + assert!( + bridge.send_output(&previous, delta("after restart")), + "the in-flight old pump must reach the rekeyed live sink" + ); + assert_eq!( + bridge.scrollback(&replacement), + vec![delta("before restart"), delta("after restart")] + ); + assert_eq!(sink.lock().unwrap().len(), 2); + + bridge.detach_if(&previous, generation); + assert!( + !bridge.send_output(&replacement, delta("detached")), + "the old pump generation detaches the migrated sink" + ); + assert_eq!(bridge.scrollback(&previous).len(), 3); +} + +#[test] +fn restart_rebind_moves_stable_owner_to_the_replacement_session() { + let bridge = ChatBridge::new(); + let previous = sid(); + let replacement = sid(); + let project = domain::ProjectId::from_uuid(Uuid::from_u128(10)); + let agent = domain::AgentId::from_uuid(Uuid::from_u128(20)); + bridge.bind_owner(previous, project, agent); + + bridge.rebind_session(previous, replacement); + + assert_eq!(bridge.owner(&previous), Some((project, agent))); + assert_eq!(bridge.owner(&replacement), Some((project, agent))); +} + +#[test] +fn restart_rebind_keeps_a_newer_replacement_sink_and_merges_scrollback_in_order() { + let bridge = ChatBridge::new(); + let previous = sid(); + let replacement = sid(); + let old_sink = Arc::new(Mutex::new(Vec::new())); + let new_sink = Arc::new(Mutex::new(Vec::new())); + let old_generation = bridge.register(previous, capturing_channel(Arc::clone(&old_sink))); + assert!(bridge.send_output(&previous, delta("old"))); + bridge.register(replacement, capturing_channel(Arc::clone(&new_sink))); + assert!(bridge.send_output(&replacement, delta("new"))); + + bridge.rebind_session(previous, replacement); + + assert_eq!( + bridge.scrollback(&replacement), + vec![delta("old"), delta("new")] + ); + assert!(bridge.send_output(&previous, delta("continued"))); + assert_eq!(old_sink.lock().unwrap().len(), 1); + assert_eq!(new_sink.lock().unwrap().len(), 2); + + bridge.detach_if(&previous, old_generation); + assert!( + bridge.send_output(&replacement, delta("still attached")), + "the old pump must not detach a channel already attached to the replacement" + ); +} + #[test] fn detach_view_keeps_session_scrollback_and_later_reattach_replays_absent_chunks() { let bridge = ChatBridge::new(); diff --git a/crates/app-tauri/tests/dto_chat.rs b/crates/app-tauri/tests/dto_chat.rs index 23bd463a..d0314d7b 100644 --- a/crates/app-tauri/tests/dto_chat.rs +++ b/crates/app-tauri/tests/dto_chat.rs @@ -363,6 +363,7 @@ fn import_chat_attachments_request_response_use_camel_case() { fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() { let dto = ReattachChatDto { session_id: "sess-1".into(), + conversation_id: Some("conv-1".into()), scrollback: vec![ ReplyChunk::UserPrompt { text: "Hi".into(), @@ -380,6 +381,7 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() { v, json!({ "sessionId": "sess-1", + "conversationId": "conv-1", "scrollback": [ { "kind": "userPrompt", "text": "Hi" }, { "kind": "textDelta", "text": "Hi" }, @@ -394,10 +396,12 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() { fn reattach_chat_dto_empty_scrollback_is_empty_array() { let dto = ReattachChatDto { session_id: "s".into(), + conversation_id: None, scrollback: vec![], }; let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v["scrollback"], json!([])); + assert!(v.get("conversationId").is_none()); } // --------------------------------------------------------------------------- diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 5b942c74..9476a164 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -1284,7 +1284,8 @@ impl From for TerminalSessionDto { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ReattachResultDto { - /// The session that was re-attached (echoed back for the frontend). + /// The effective live session that was re-attached. This can differ from the + /// requested id after a structured-runtime restart. pub session_id: String, /// The most-recent retained output bytes (scrollback ring buffer). pub scrollback: Vec, @@ -3678,8 +3679,13 @@ pub enum ReplyChunk { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ReattachChatDto { - /// The session that was re-attached (echoed back for the frontend). + /// The effective live session that was re-attached. This can differ from the + /// requested id after a structured-runtime restart. pub session_id: String, + /// Effective provider conversation id of the live replacement session, when + /// the adapter exposes one. + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, /// The chunks already streamed for this conversation, in order. The frontend /// replays them to rebuild the visible turns, then receives subsequent chunks /// over the freshly-registered channel. diff --git a/crates/backend/src/stream.rs b/crates/backend/src/stream.rs index c348ea8f..a67cb0b8 100644 --- a/crates/backend/src/stream.rs +++ b/crates/backend/src/stream.rs @@ -170,6 +170,41 @@ where .unwrap_or_default() } + /// Moves the retained stream state from `from` to `to`. + /// + /// When `to` already exists, its live sink wins and the source scrollback is + /// prepended to the destination scrollback. This is used when a logical chat + /// survives a provider restart under a fresh transport session id. + pub fn rekey(&self, from: &K, to: K) { + if *from == to { + return; + } + if let Ok(mut map) = self.entries.lock() { + let Some(source) = map.remove(from) else { + return; + }; + match map.get_mut(&to) { + Some(destination) => { + // Invalidate both pre-rekey pump generations. In particular, + // the dying source pump must never detach a sink that was + // already attached to the replacement key. + let mut generation = destination.generation.wrapping_add(1); + if generation == source.generation { + generation = generation.wrapping_add(1); + } + destination.generation = generation; + let mut scrollback = source.scrollback; + scrollback.append(&mut destination.scrollback); + destination.scrollback = scrollback; + trim_scrollback(destination, self.max_chunks, self.max_bytes, self.measure); + } + None => { + map.insert(to, source); + } + } + } + } + pub fn unregister(&self, key: &K) { if let Ok(mut map) = self.entries.lock() { map.remove(key); diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index b1efa09a..1619be79 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -1827,15 +1827,40 @@ async fn ws_attach_chat( tx: &mpsc::Sender, owned: &Arc>>, ) -> Result<(), ErrorDto> { - let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; - let session = state - .app - .structured_sessions - .session(&sid) - .ok_or_else(|| ErrorDto { - code: "NOT_FOUND".to_owned(), - message: "structured chat session not found".to_owned(), - })?; + let requested_sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?; + let (sid, session) = + if let Some(session) = state.app.structured_sessions.session(&requested_sid) { + (requested_sid, session) + } else { + let fallback = frame + .payload + .get("projectId") + .and_then(Value::as_str) + .zip(frame.payload.get("agentId").and_then(Value::as_str)); + let (project_id, agent_id) = fallback.ok_or_else(|| ErrorDto { + code: "NOT_FOUND".to_owned(), + message: "structured chat session not found".to_owned(), + })?; + let project_id = parse_project_id(project_id)?; + let agent_id = parse_agent_id(agent_id)?; + let sid = state + .app + .structured_sessions + .session_id_for_agent_in_project(project_id, &agent_id) + .ok_or_else(|| ErrorDto { + code: "NOT_FOUND".to_owned(), + message: "structured chat session not found".to_owned(), + })?; + let session = state + .app + .structured_sessions + .session(&sid) + .ok_or_else(|| ErrorDto { + code: "NOT_FOUND".to_owned(), + message: "structured chat session not found".to_owned(), + })?; + (sid, session) + }; tx.send(ServerFrame::chat_attached( &frame.id, &application::StructuredSessionDescriptor { @@ -7721,7 +7746,11 @@ mod tests { ws_frame( "chat-attach", "chat.attach", - json!({ "sessionId": session_id.to_string() }), + json!({ + "sessionId": session_id.to_string(), + "projectId": "not-parsed-for-a-live-session", + "agentId": "not-parsed-for-a-live-session", + }), ), &state, &tx, @@ -7742,6 +7771,94 @@ mod tests { assert_eq!(state.ws_chat_bridge.active_sessions(), 1); } + #[tokio::test] + async fn websocket_chat_attach_recovers_stale_session_from_live_project_agent() { + let state = state(); + let stale_session_id = SessionId::new_random(); + let live_session_id = SessionId::new_random(); + let project_id = ProjectId::from_uuid(Uuid::new_v4()); + let agent_id = AgentId::from_uuid(Uuid::new_v4()); + let node_id = NodeId::from_uuid(Uuid::new_v4()); + let session = Arc::new(FakeChatSession::new( + live_session_id, + vec![ReplyEvent::Final { + content: "unused".to_owned(), + }], + )); + state.app.structured_sessions.insert_in_project( + project_id, + Arc::clone(&session) as Arc, + agent_id, + node_id, + ); + let (tx, mut rx) = mpsc::channel(8); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "chat-attach-fallback", + "chat.attach", + json!({ + "sessionId": stale_session_id.to_string(), + "projectId": project_id.to_string(), + "agentId": agent_id.to_string(), + }), + ), + &state, + &tx, + &owned, + ) + .await; + let attached = recv_server_frame(&mut rx).await; + + assert_eq!(attached.kind, "chat.attached"); + assert_eq!(attached.payload["sessionId"], live_session_id.to_string()); + assert_eq!( + attached.payload["session"]["sessionId"], + live_session_id.to_string() + ); + assert!(state.ws_chat_bridge.send_output( + &live_session_id, + json!({ "kind": "textDelta", "text": "after restart" }) + )); + let output = recv_server_frame(&mut rx).await; + assert_eq!(output.kind, "chat.output"); + assert_eq!(output.payload["sessionId"], live_session_id.to_string()); + assert_eq!(output.payload["chunk"]["text"], "after restart"); + assert!(matches!( + owned.lock().unwrap().as_slice(), + [WsAttachment::Chat { session, .. }] if *session == live_session_id + )); + } + + #[tokio::test] + async fn websocket_chat_attach_stale_session_without_fallback_stays_not_found() { + let state = state(); + let stale_session_id = SessionId::new_random(); + let (tx, mut rx) = mpsc::channel(4); + let owned = Arc::new(Mutex::new(Vec::new())); + + handle_client_frame( + ws_frame( + "chat-attach-missing", + "chat.attach", + json!({ "sessionId": stale_session_id.to_string() }), + ), + &state, + &tx, + &owned, + ) + .await; + let error = recv_server_frame(&mut rx).await; + + assert_eq!(error.kind, "error"); + assert_eq!(error.reply_to.as_deref(), Some("chat-attach-missing")); + assert_eq!(error.payload["sessionId"], stale_session_id.to_string()); + assert_eq!(error.payload["code"], "NOT_FOUND"); + assert_eq!(state.ws_chat_bridge.active_sessions(), 0); + assert!(owned.lock().unwrap().is_empty()); + } + #[tokio::test] async fn websocket_chat_send_acknowledges_first_chunk_and_streams_rest() { let state = state(); diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index 66c71e27..b11b4525 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -275,11 +275,14 @@ export class TauriAgentGateway implements AgentGateway { async reattachAgentChat( sessionId: string, onChunk: (chunk: ReplyChunk) => void, + options: { projectId?: string; agentId?: string } = {}, ): Promise { const channel = new Channel(); channel.onmessage = onChunk; return invoke("reattach_agent_chat", { sessionId, + ...(options.projectId ? { projectId: options.projectId } : {}), + ...(options.agentId ? { agentId: options.agentId } : {}), onReply: channel, }); } diff --git a/frontend/src/adapters/http/agentGateway.test.ts b/frontend/src/adapters/http/agentGateway.test.ts index 72a094d0..ef451447 100644 --- a/frontend/src/adapters/http/agentGateway.test.ts +++ b/frontend/src/adapters/http/agentGateway.test.ts @@ -313,21 +313,30 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => { freshStart: false, }); - const reattached = gw.reattachAgentChat("chat-1", () => {}); + const reattached = gw.reattachAgentChat("chat-1", () => {}, { + projectId: "proj-1", + agentId: "agent-1", + }); const attach = await replyToLast(socket, 1, (id) => ({ kind: "chat.attached", replyTo: id, payload: { sessionId: "chat-1", + conversationId: "conv-replacement", scrollback: [{ kind: "final", content: "Recovered reply" }], }, })); await expect(reattached).resolves.toEqual({ sessionId: "chat-1", + conversationId: "conv-replacement", scrollback: [{ kind: "final", content: "Recovered reply" }], }); expect(attach.kind).toBe("chat.attach"); - expect(attach.payload).toEqual({ sessionId: "chat-1" }); + expect(attach.payload).toEqual({ + sessionId: "chat-1", + projectId: "proj-1", + agentId: "agent-1", + }); const chunks: unknown[] = []; const sent = gw.sendAgentChat("chat-1", "Hello", (chunk) => chunks.push(chunk), { diff --git a/frontend/src/adapters/http/frames.ts b/frontend/src/adapters/http/frames.ts index 548e3c67..53bc5918 100644 --- a/frontend/src/adapters/http/frames.ts +++ b/frontend/src/adapters/http/frames.ts @@ -152,5 +152,7 @@ export interface ChatAttachedPayload { }; cellKind?: "chat" | "pty"; assignedConversationId?: string; + conversationId?: string; + engineSessionId?: string; scrollback?: ReplyChunk[]; } diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index 49d7571b..97dcb98b 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -379,8 +379,9 @@ export class HttpAgentGateway implements AgentGateway { async reattachAgentChat( sessionId: string, onChunk: (chunk: ReplyChunk) => void, + options: { projectId?: string; agentId?: string } = {}, ): Promise { - const ack = await this.ws.send("chat.attach", { sessionId }); + const ack = await this.ws.send("chat.attach", { sessionId, ...options }); if (ack.kind !== "chat.attached") { throw { code: "CHAT_ATTACH_FAILED", @@ -392,6 +393,14 @@ export class HttpAgentGateway implements AgentGateway { this.ws.setChatSink(attachedSessionId, onChunk); return { sessionId: attachedSessionId, + ...(payload.conversationId ?? payload.assignedConversationId ?? payload.engineSessionId + ? { + conversationId: + payload.conversationId ?? + payload.assignedConversationId ?? + payload.engineSessionId, + } + : {}), scrollback: payload.scrollback ?? [], }; } diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 02920ebc..630609db 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -231,6 +231,143 @@ describe("CustomAgentChatView", () => { expect(screen.getByText("runtime unavailable")).not.toBeNull(); }); + it("reconciles a restarted chat to its replacement session and keeps receiving chunks", async () => { + const system = new MockSystemGateway(); + const liveReceivers: Array<(chunk: ReplyChunk) => void> = []; + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi + .fn() + .mockImplementationOnce( + async (_sessionId: string, onChunk: (chunk: ReplyChunk) => void) => { + liveReceivers.push(onChunk); + return { + sessionId: "old-session", + conversationId: "old-conversation", + scrollback: [{ kind: "final", content: "Avant redémarrage" }], + }; + }, + ) + .mockImplementationOnce( + async (_sessionId: string, onChunk: (chunk: ReplyChunk) => void) => { + liveReceivers.push(onChunk); + return { + sessionId: "replacement-session", + conversationId: "replacement-conversation", + scrollback: [ + { kind: "final", content: "Avant redémarrage" }, + { kind: "userPrompt", text: "Continue" }, + { kind: "textDelta", text: "Reprise conservée" }, + ], + }; + }, + ), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + const onSessionId = vi.fn(); + const onConversationId = vi.fn(); + + render( + + + + + , + ); + + expect(await screen.findByText("Avant redémarrage")).toBeTruthy(); + act(() => { + system.emit({ + type: "agentRestartRequested", + agentId: "agent-1", + restartId: "restart-295", + reason: "reload plugin", + }); + system.emit({ + type: "agentRestartCompleted", + agentId: "agent-1", + restartId: "restart-295", + }); + }); + + await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledTimes(2)); + expect(agent.reattachAgentChat).toHaveBeenNthCalledWith( + 2, + "old-session", + expect.any(Function), + { projectId: "project-1", agentId: "agent-1" }, + ); + expect(await screen.findByText("Reprise conservée")).toBeTruthy(); + expect(onSessionId).toHaveBeenCalledWith("replacement-session"); + expect(onConversationId).toHaveBeenCalledWith("replacement-conversation"); + + act(() => { + liveReceivers[1]?.({ kind: "final", content: "Chunk après redémarrage" }); + }); + expect(await screen.findByText("Chunk après redémarrage")).toBeTruthy(); + expect(screen.queryByText(/Envoyez un message/)).toBeNull(); + }); + + it("retries a stale session attach with stable project and agent identity", async () => { + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi + .fn() + .mockRejectedValueOnce({ code: "NOT_FOUND", message: "stale session" }) + .mockResolvedValueOnce({ + sessionId: "replacement-session", + conversationId: "replacement-conversation", + scrollback: [{ kind: "final", content: "Transcript restauré" }], + }), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + const onSessionId = vi.fn(); + + render( + + + , + ); + + expect(await screen.findByText("Transcript restauré")).toBeTruthy(); + expect(agent.reattachAgentChat).toHaveBeenNthCalledWith( + 2, + "stale-session", + expect.any(Function), + { projectId: "project-1", agentId: "agent-1" }, + ); + expect(agent.launchAgentChat).not.toHaveBeenCalled(); + expect(onSessionId).toHaveBeenCalledWith("replacement-session"); + }); + it("shows the technical open failure on mobile with retry and native TUI fallback", async () => { const openNativeTui = vi.fn(); const agent = { @@ -1344,6 +1481,7 @@ describe("CustomAgentChatView", () => { reattachAgentChat: vi .fn() .mockRejectedValueOnce({ code: "NOT_FOUND", message: "structured session gone" }) + .mockRejectedValueOnce({ code: "NOT_FOUND", message: "structured session gone" }) .mockImplementationOnce(async (sessionId: string) => ({ sessionId, scrollback: [], @@ -4829,6 +4967,9 @@ describe("CustomAgentChatView", () => { .mockRejectedValueOnce( "not found: structured session 69b57638-0ac7-4a79-8252-e0436a3265f6", ) + .mockRejectedValueOnce( + "not found: structured session 69b57638-0ac7-4a79-8252-e0436a3265f6", + ) .mockImplementationOnce(async (sessionId: string) => ({ sessionId, scrollback: [], @@ -4869,6 +5010,12 @@ describe("CustomAgentChatView", () => { ); expect(agent.reattachAgentChat).toHaveBeenNthCalledWith( 2, + "69b57638-0ac7-4a79-8252-e0436a3265f6", + expect.any(Function), + { projectId: "project-1", agentId: "agent-1" }, + ); + expect(agent.reattachAgentChat).toHaveBeenNthCalledWith( + 3, "chat-session-2", expect.any(Function), ); diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index c02497fc..d677225a 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -1015,6 +1015,7 @@ export function CustomAgentChatView({ const mountedRef = useRef(false); const openOrAttachCountRef = useRef(0); const selfEmittedSessionIdRef = useRef(undefined); + const reconciledRestartIdsRef = useRef(new Set()); const onSessionIdRef = useRef(onSessionId); onSessionIdRef.current = onSessionId; const onConversationIdRef = useRef(onConversationId); @@ -1543,7 +1544,14 @@ export function CustomAgentChatView({ }, []); const reattachStructuredSession = useCallback( - async (sid: string, options: { applyScrollback?: boolean } = {}) => { + async ( + sid: string, + options: { + applyScrollback?: boolean; + stableIdentity?: boolean; + preserveRestartTurns?: boolean; + } = {}, + ) => { if (!agent.reattachAgentChat) throw new Error("Structured reattach unavailable"); console.debug("[ticket149] reattachStructuredSession:start", { timestamp: new Date().toISOString(), @@ -1552,7 +1560,9 @@ export function CustomAgentChatView({ }); let reattached: Awaited>>; try { - reattached = await agent.reattachAgentChat(sid, receive); + reattached = options.stableIdentity + ? await agent.reattachAgentChat(sid, receive, { projectId, agentId }) + : await agent.reattachAgentChat(sid, receive); } catch (e) { console.error("[ticket149] reattachStructuredSession:error", { timestamp: new Date().toISOString(), @@ -1569,20 +1579,62 @@ export function CustomAgentChatView({ }); if (!mountedRef.current) return reattached.sessionId; setCurrentSession(reattached.sessionId); + if (reattached.sessionId !== sid) { + publishSessionId(reattached.sessionId); + } + if (reattached.conversationId) { + onConversationIdRef.current(reattached.conversationId); + } if (options.applyScrollback) { scrollbackChunkCountRef.current = reattached.scrollback.length; - const replayed = reattached.scrollback.reduce( + let replayed = reattached.scrollback.reduce( (state, chunk) => foldChunk(state, chunk), emptyChatViewState(), ); + if (options.preserveRestartTurns) { + const restartTurns = turnsRef.current.filter((turn) => turn.role === "restart"); + if (restartTurns.length > 0) { + replayed = { ...replayed, turns: [...replayed.turns, ...restartTurns] }; + } + } applyChatViewState(replayed); setBusy(turnsIndicateBusy(replayed)); } return reattached.sessionId; }, - [agent, applyChatViewState, receive], + [agent, agentId, applyChatViewState, projectId, publishSessionId, receive], ); + useEffect(() => { + const completed = [...restartEvents] + .reverse() + .find((event) => event.type === "agentRestartCompleted"); + if (!completed || reconciledRestartIdsRef.current.has(completed.restartId)) return; + const sid = sessionRef.current ?? externalSessionId; + if (!sid) return; + + reconciledRestartIdsRef.current.add(completed.restartId); + let cancelled = false; + setOpening(true); + setError(null); + void reattachStructuredSession(sid, { + applyScrollback: true, + stableIdentity: true, + preserveRestartTurns: true, + }) + .catch((e) => { + if (!cancelled) { + setError({ message: describeOpenFailure(e), openFailure: true }); + } + }) + .finally(() => { + if (!cancelled) setOpening(false); + }); + return () => { + cancelled = true; + }; + }, [externalSessionId, reattachStructuredSession, restartEvents]); + const recoverStructuredSession = useCallback( async ( options: { @@ -1749,6 +1801,23 @@ export function CustomAgentChatView({ // the session is gone (closed/never live) and the caller falls back // to a fresh launch — any other error still surfaces to the user. if (!isNotFound(e)) throw e; + try { + const replacementSessionId = await reattachStructuredSession(externalSessionId, { + applyScrollback: true, + stableIdentity: true, + }); + if (cancelled) return; + publishSessionId(replacementSessionId); + console.debug("[ticket295] openOrAttach:stable-reattach:success", { + timestamp: new Date().toISOString(), + execution, + requestedSessionId: externalSessionId, + replacementSessionId, + }); + return; + } catch (stableError) { + if (!isNotFound(stableError)) throw stableError; + } if (!cancelled) { setCurrentSession(null); publishSessionId(null); @@ -1834,6 +1903,12 @@ export function CustomAgentChatView({ !opening, [supported, draft, attachments.length, effectiveBusy, opening], ); + const hasLinkedConversation = Boolean( + conversationId || + requesterDelegation.currentAgentBusy || + restart || + delegationActive, + ); async function openProfileDialog() { const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) => @@ -2412,6 +2487,10 @@ export function CustomAgentChatView({ {externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"} + ) : displayTurns.length === 0 && hasLinkedConversation ? ( +

+ Conversation liée — récupération du transcript en cours. +

) : displayTurns.length === 0 ? (

Envoyez un message pour démarrer la conversation structurée. diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index cddc4b47..492d333a 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -204,12 +204,20 @@ export interface AgentChatHandle { /** Result of re-attaching a custom chat view to a live structured session. */ export interface ReattachAgentChatResult { - /** Stable structured session id. */ + /** Effective live structured session id (may replace the requested id after restart). */ sessionId: string; + /** Effective provider conversation id, when known by the structured adapter. */ + conversationId?: string; /** Retained chunks already streamed for the session. */ scrollback: ReplyChunk[]; } +/** Stable owner identity used to reconcile a stale transport id after restart. */ +export interface ReattachAgentChatOptions { + projectId?: string; + agentId?: string; +} + /** Agents: create, list, read/update context, delete, launch (L6). */ export interface AgentGateway { /** Lists all agents belonging to the given project. */ @@ -324,6 +332,7 @@ export interface AgentGateway { reattachAgentChat?( sessionId: string, onChunk: (chunk: ReplyChunk) => void, + options?: ReattachAgentChatOptions, ): Promise; /** Detaches the current custom chat view transport while keeping the session alive. */ detachAgentChat?(sessionId: string): Promise;