merge(chat): le transcript s'affiche à nouveau après redémarrage automatique d'un agent — #295 (QA verte reconfirmée par Git: diff --check, fmt --all --check, app-tauri chat_bridge 34/34 + dto_chat 24/24, backend 91+7 ignored, web-server websocket_chat_attach 3/3, npm test 77/77, npm run build; branche fix/295-custom-chat-transcript-after-restart commits 1b1476d + 8482fc5; restant: rebuild AppImage + validation live post-restart depuis l'app installée; réserves non bloquantes: 7 tests backend ignored permissions socket, warnings Vite connus; scripts/ hors périmètre; aucun push)
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@ -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
|
||||
}
|
||||
90
.ideai/tickets/293/carnet.md
Normal file
90
.ideai/tickets/293/carnet.md
Normal file
@ -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`.
|
||||
17
.ideai/tickets/293/issue.md
Normal file
17
.ideai/tickets/293/issue.md
Normal file
@ -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.
|
||||
112
.ideai/tickets/294/carnet.md
Normal file
112
.ideai/tickets/294/carnet.md
Normal file
@ -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`.
|
||||
17
.ideai/tickets/294/issue.md
Normal file
17
.ideai/tickets/294/issue.md
Normal file
@ -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.
|
||||
156
.ideai/tickets/295/carnet.md
Normal file
156
.ideai/tickets/295/carnet.md
Normal file
@ -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.
|
||||
17
.ideai/tickets/295/issue.md
Normal file
17
.ideai/tickets/295/issue.md
Normal file
@ -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.
|
||||
@ -1,3 +1,3 @@
|
||||
{
|
||||
"nextNumber": 293
|
||||
"nextNumber": 296
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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<SessionId, ReplyChunk>,
|
||||
successors: Mutex<HashMap<SessionId, SessionId>>,
|
||||
owners: Mutex<HashMap<SessionId, (ProjectId, AgentId)>>,
|
||||
}
|
||||
|
||||
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<ReplyChunk>) -> 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<ReplyChunk> {
|
||||
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
|
||||
|
||||
@ -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<String>,
|
||||
agent_id: Option<String>,
|
||||
on_reply: Channel<ReplyChunk>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ReattachChatDto, ErrorDto> {
|
||||
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() {
|
||||
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 {sid}"
|
||||
"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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -1284,7 +1284,8 @@ impl From<OpenTerminalOutput> 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<u8>,
|
||||
@ -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<String>,
|
||||
/// The chunks already streamed for this conversation, in order. The frontend
|
||||
/// replays them to rebuild the visible turns, then receives subsequent chunks
|
||||
/// over the freshly-registered channel.
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -1827,7 +1827,30 @@ async fn ws_attach_chat(
|
||||
tx: &mpsc::Sender<ServerFrame>,
|
||||
owned: &Arc<Mutex<Vec<WsAttachment>>>,
|
||||
) -> Result<(), ErrorDto> {
|
||||
let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?;
|
||||
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
|
||||
@ -1836,6 +1859,8 @@ async fn ws_attach_chat(
|
||||
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<dyn AgentSession>,
|
||||
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();
|
||||
|
||||
@ -275,11 +275,14 @@ export class TauriAgentGateway implements AgentGateway {
|
||||
async reattachAgentChat(
|
||||
sessionId: string,
|
||||
onChunk: (chunk: ReplyChunk) => void,
|
||||
options: { projectId?: string; agentId?: string } = {},
|
||||
): Promise<ReattachAgentChatResult> {
|
||||
const channel = new Channel<ReplyChunk>();
|
||||
channel.onmessage = onChunk;
|
||||
return invoke<ReattachAgentChatResult>("reattach_agent_chat", {
|
||||
sessionId,
|
||||
...(options.projectId ? { projectId: options.projectId } : {}),
|
||||
...(options.agentId ? { agentId: options.agentId } : {}),
|
||||
onReply: channel,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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), {
|
||||
|
||||
@ -152,5 +152,7 @@ export interface ChatAttachedPayload {
|
||||
};
|
||||
cellKind?: "chat" | "pty";
|
||||
assignedConversationId?: string;
|
||||
conversationId?: string;
|
||||
engineSessionId?: string;
|
||||
scrollback?: ReplyChunk[];
|
||||
}
|
||||
|
||||
@ -379,8 +379,9 @@ export class HttpAgentGateway implements AgentGateway {
|
||||
async reattachAgentChat(
|
||||
sessionId: string,
|
||||
onChunk: (chunk: ReplyChunk) => void,
|
||||
options: { projectId?: string; agentId?: string } = {},
|
||||
): Promise<ReattachAgentChatResult> {
|
||||
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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@ -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(
|
||||
<DIProvider gateways={{ agent, system } as unknown as Gateways}>
|
||||
<AnnouncementsProvider projectId="project-1">
|
||||
<CustomAgentChatView
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
profile={profile}
|
||||
cwd="/repo"
|
||||
nodeId="node-1"
|
||||
sessionId="old-session"
|
||||
conversationId="old-conversation"
|
||||
onSessionId={onSessionId}
|
||||
onConversationId={onConversationId}
|
||||
/>
|
||||
</AnnouncementsProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<DIProvider
|
||||
gateways={{ agent, system: new MockSystemGateway() } as unknown as Gateways}
|
||||
>
|
||||
<CustomAgentChatView
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
profile={profile}
|
||||
cwd="/repo"
|
||||
nodeId="node-1"
|
||||
sessionId="stale-session"
|
||||
conversationId="conversation-1"
|
||||
onSessionId={onSessionId}
|
||||
onConversationId={vi.fn()}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
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),
|
||||
);
|
||||
|
||||
@ -1015,6 +1015,7 @@ export function CustomAgentChatView({
|
||||
const mountedRef = useRef(false);
|
||||
const openOrAttachCountRef = useRef(0);
|
||||
const selfEmittedSessionIdRef = useRef<string | null | undefined>(undefined);
|
||||
const reconciledRestartIdsRef = useRef(new Set<string>());
|
||||
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<ReturnType<NonNullable<typeof agent.reattachAgentChat>>>;
|
||||
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…"}
|
||||
</span>
|
||||
</div>
|
||||
) : displayTurns.length === 0 && hasLinkedConversation ? (
|
||||
<p className="m-auto text-xs text-muted">
|
||||
Conversation liée — récupération du transcript en cours.
|
||||
</p>
|
||||
) : displayTurns.length === 0 ? (
|
||||
<p className="m-auto text-xs text-muted">
|
||||
Envoyez un message pour démarrer la conversation structurée.
|
||||
|
||||
@ -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<ReattachAgentChatResult>;
|
||||
/** Detaches the current custom chat view transport while keeping the session alive. */
|
||||
detachAgentChat?(sessionId: string): Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user