diff --git a/CHANGELOG.md b/CHANGELOG.md index e0f4bdb..147e4a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.23.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.24.0**. --- @@ -14,6 +14,49 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.24.0] — 2026-09-24 + +### Ajouté + +- **BUG-077 — assistant IA : bouton « Stop ».** Pendant qu'une réponse se diffuse, le + bouton d'envoi du composeur devient un bouton **Stop** (icône carrée, couleur + d'alerte) : un clic interrompt immédiatement l'exécution de l'agent (abort de la + requête SSE, annulation de la tâche côté serveur à la déconnexion) et la réponse + partielle est conservée avec un marqueur « ⏹ Exécution arrêtée. ». Le bouton reste + actif (il n'est plus désactivé) tant que l'agent travaille, y compris pendant la + reprise d'une confirmation. + +### Modifié + +- **BUG-075 — assistant IA : approbation globale des actions.** Une même réponse du + modèle peut demander **plusieurs** mutations (créer un dossier et les fichiers + qu'il contient…). Elles sont désormais **regroupées en une seule confirmation** (au + lieu d'une action après l'autre) : la carte liste chaque action avec son libellé et + son aperçu de diff, et un unique bouton « **Tout approuver (N)** » envoie + `confirm_all` — les actions en attente sont appliquées d'un bloc, puis la suite de + l'exécution est autorisée sans nouvelle carte. Les appels en lecture du même lot + s'exécutent immédiatement (conversation valide). Côté backend, `pending.actions` + remplace le report `deferred` des mutations d'un lot (`backend/agent/loop.py`) et + `confirm_all` arme `ToolContext.confirmed` pour le reste du run + (`backend/bookslm_routes.py`). + +### Corrigé + +- **BUG-074 — assistant IA : bloc d'étapes sans titre et compteur figé à 1.** Le + résumé replié affiche désormais un **titre** (le libellé de la première action, + « N étapes — Fichier créé : notes/a.md ▶ »), le compteur ne compte plus les + **réflexions** (seules les actions) et la reprise d'une confirmation continue de + diffuser dans **le même message** au lieu de créer un nouveau message « 1 étape » + par approbation : le bloc d'étapes s'accumule sur tout l'échange. +- **BUG-076 — assistant IA : arborescence et document ouvert non rafraîchis.** Après + une action mutatrice de l'agent (création/suppression/renommage de fichier ou + dossier, écriture), l'arborescence est rafraîchie immédiatement (rafraîchissement + débouncé sur les événements `tool` mutateurs, sans attendre le watcher) et le + document affiché est rechargé depuis le disque ; les outils de création de documents + (xlsx/docx/csv/pdf) notifient désormais aussi la visionneuse. + +--- + ## [2.23.0] — 2026-09-24 ### Ajouté diff --git a/README.fr.md b/README.fr.md index b28933a..97fc8e8 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.23.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.24.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -975,8 +975,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.23.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.24.0). --- -*Projet : ObsiGate | Version : 2.23.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.24.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index d118ae9..c4c3b94 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.23.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.24.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1150,8 +1150,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.23.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.24.0). --- -*Project: ObsiGate | Version: 2.23.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.24.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index e9763f6..ad22619 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.23.0 +2.24.0 diff --git a/backend/agent/loop.py b/backend/agent/loop.py index 088ec65..9970d80 100644 --- a/backend/agent/loop.py +++ b/backend/agent/loop.py @@ -28,6 +28,7 @@ from backend.tools.api import ( ToolError, ToolScope, call_tool, + get_tool, get_tool_schemas, ) from backend.tools.labels import thought_step_label, tool_step_label @@ -121,12 +122,15 @@ def _assistant_tool_message(content: str | None, tool_calls: list[Any]) -> dict[ def _deferred_tool_message(call: Any, reason: str | None = None) -> dict[str, Any]: """Answer a tool call that was not reached because the run stopped early. - A single LLM response may carry several tool calls. When one of them is - mutating and pauses the run for confirmation, the assistant message already - lists *all* of them, so every ``tool_call_id`` must get a tool result before - the next LLM call (the OpenAI tool protocol rejects dangling ids). The calls - that were not reached get a synthetic ``deferred`` result; the model - re-issues them once the confirmed call has been applied (BUG-050). + A single LLM response may carry several tool calls; when the run stops + before reaching some of them (tool-call quota), the assistant message still + lists *all* of them, so every ``tool_call_id`` must get a tool result + before the next LLM call (the OpenAI tool protocol rejects dangling ids). + The calls that were not reached get a synthetic ``deferred`` result. + + Note: mutating calls that pause the run for confirmation are no longer + deferred — they are batched and applied together on resume (BUG-075); this + helper remains for budget stops (BUG-050/BUG-052). """ return { "role": "tool", @@ -135,13 +139,29 @@ def _deferred_tool_message(call: Any, reason: str | None = None) -> dict[str, An "content": json.dumps({ "status": "deferred", "reason": reason or ( - "Not executed: the run paused to confirm an earlier tool call. " + "Not executed: the run stopped before reaching this tool call. " "Re-issue this call if it is still needed." ), }, ensure_ascii=False), } +def _action_descriptor(call: Any) -> dict[str, Any]: + """Describe one paused mutating tool call for the confirmation payload. + + A single LLM response may request several mutations (create a folder and + the files inside it…). They are batched into one confirmation so the user + approves the whole plan in one click (BUG-075). ``step`` reuses the + Notion-style label, so the confirmation card reads like the steps block. + """ + return { + "id": call.id, + "tool": call.name, + "arguments": call.arguments, + "step": tool_step_label(call.name, call.arguments), + } + + def _fallback_summary(executed: list[ToolCallRecord]) -> str: """Deterministic non-empty answer built from the gathered tool results. @@ -212,53 +232,66 @@ def _execute_confirmed( executed: list[ToolCallRecord], on_tool_call: Callable[[ToolCallRecord], None] | None, ) -> None: - """Apply a previously-paused mutating tool call and feed its result back. + """Apply previously-paused mutating tool calls and feed their results back. The pending payload is the ``error`` object emitted by a ``confirmation`` - event. The assistant tool-call message is expected to already be in + event, optionally carrying an ``actions`` list with every mutating call of + the LLM turn (BUG-075). Each action is applied with a one-shot confirmation + and its ``tool_call_id`` answered, keeping the conversation valid for the + resumed turn. The assistant tool-call message is expected to already be in ``convo`` (it is part of the snapshot returned with the confirmation). """ from backend.ai_chat import ToolCall - error = confirm_pending.get("error", confirm_pending) - name = error.get("tool") - arguments = error.get("arguments") or {} - call_id = error.get("id") or "call_pending" + error = confirm_pending.get("error", confirm_pending) or {} + actions = confirm_pending.get("actions") + if not isinstance(actions, list) or not actions: + # Legacy single-action payload (no ``actions`` list). + actions = [{ + "id": error.get("id") or "call_pending", + "tool": error.get("tool"), + "arguments": error.get("arguments") or {}, + }] - if not name: - raise ToolError("Malformed confirmation payload", code="invalid_confirmation") + for action in actions: + name = action.get("tool") + arguments = action.get("arguments") or {} + call_id = action.get("id") or "call_pending" - # Make sure the assistant tool-call message is present in the snapshot. - if not any( - m.get("role") == "assistant" and any( - tc.get("id") == call_id for tc in (m.get("tool_calls") or []) + if not name: + raise ToolError("Malformed confirmation payload", code="invalid_confirmation") + + # Make sure the assistant tool-call message is present in the snapshot. + if not any( + m.get("role") == "assistant" and any( + tc.get("id") == call_id for tc in (m.get("tool_calls") or []) + ) + for m in convo + ): + convo.append(_assistant_tool_message(None, [ToolCall(id=call_id, name=name, arguments=arguments)])) + + try: + result = call_tool(name, ctx, arguments, confirm=True) + payload = result.data + ok = True + except ToolError as e: + payload = e.to_dict() + ok = False + + record = ToolCallRecord( + name=name, arguments=arguments, ok=ok, result=payload, + step=tool_step_label(name, arguments), ) - for m in convo - ): - convo.append(_assistant_tool_message(None, [ToolCall(id=call_id, name=name, arguments=arguments)])) + executed.append(record) + if on_tool_call is not None: + on_tool_call(record) - try: - result = call_tool(name, ctx, arguments, confirm=True) - payload = result.data - ok = True - except ToolError as e: - payload = e.to_dict() - ok = False - - record = ToolCallRecord( - name=name, arguments=arguments, ok=ok, result=payload, - step=tool_step_label(name, arguments), - ) - executed.append(record) - if on_tool_call is not None: - on_tool_call(record) - - convo.append({ - "role": "tool", - "tool_call_id": call_id, - "name": name, - "content": json.dumps(_truncate(payload), ensure_ascii=False, default=str), - }) + convo.append({ + "role": "tool", + "tool_call_id": call_id, + "name": name, + "content": json.dumps(_truncate(payload), ensure_ascii=False, default=str), + }) async def run_agent( @@ -315,6 +348,36 @@ async def run_agent( convo = [dict(m) for m in (resume_messages if resume_messages is not None else messages)] executed: list[ToolCallRecord] = [] + def _run_call(call: Any) -> None: + """Execute one tool call, record it and answer its ``tool_call_id``. + + ``ToolConfirmationRequired`` propagates to the caller so the loop can + pause and batch the mutating calls of the turn (BUG-075). + """ + try: + result = call_tool(call.name, ctx, call.arguments) + payload: Any = result.data + ok = True + except ToolConfirmationRequired: + raise + except ToolError as e: + payload = e.to_dict() + ok = False + record = ToolCallRecord( + name=call.name, arguments=call.arguments, ok=ok, result=payload, + step=tool_step_label(call.name, call.arguments), + ) + executed.append(record) + steps.append(record.step) + if on_tool_call is not None: + on_tool_call(record) + convo.append({ + "role": "tool", + "tool_call_id": call.id, + "name": call.name, + "content": json.dumps(_truncate(payload), ensure_ascii=False, default=str), + }) + if confirm_pending: if quota is not None and len(executed) >= quota: return AgentResult( @@ -357,19 +420,25 @@ async def run_agent( llm, convo, executed, steps, iteration, STOP_QUOTA_EXCEEDED ) try: - result = call_tool(call.name, ctx, call.arguments) - payload = result.data - ok = True + _run_call(call) except ToolConfirmationRequired as e: logger.info(f"Agent paused: confirmation required for '{call.name}'") pending = e.to_dict() # Include the tool-call id so the client can echo it back. pending["error"]["id"] = call.id - # BUG-050: the assistant message lists every tool call of this - # batch, so answer the ones we did not reach to keep the - # conversation valid for the resumed turn. - for skipped in response.tool_calls[index + 1:]: - convo.append(_deferred_tool_message(skipped)) + # BUG-075: batch every mutating call of this LLM turn so the + # user approves the whole plan at once (one resume applies them + # all) instead of approving one action after another. Read-only + # calls of the batch run immediately and answer their + # ``tool_call_id`` so the resumed turn stays valid. + actions = [_action_descriptor(call)] + for after in response.tool_calls[index + 1:]: + spec = get_tool(after.name) + if spec is not None and spec.requires_confirmation: + actions.append(_action_descriptor(after)) + else: + _run_call(after) + pending["actions"] = actions return AgentResult( content=response.content or "", messages=convo, @@ -379,25 +448,6 @@ async def run_agent( stopped=STOP_CONFIRMATION_REQUIRED, pending=pending, ) - except ToolError as e: - payload = e.to_dict() - ok = False - - record = ToolCallRecord( - name=call.name, arguments=call.arguments, ok=ok, result=payload, - step=tool_step_label(call.name, call.arguments), - ) - executed.append(record) - steps.append(record.step) - if on_tool_call is not None: - on_tool_call(record) - - convo.append({ - "role": "tool", - "tool_call_id": call.id, - "name": call.name, - "content": json.dumps(_truncate(payload), ensure_ascii=False, default=str), - }) logger.warning(f"Agent reached max iterations ({max_iterations})") return await _finalize_answer( diff --git a/backend/bookslm_routes.py b/backend/bookslm_routes.py index 7b780fc..003642b 100644 --- a/backend/bookslm_routes.py +++ b/backend/bookslm_routes.py @@ -110,6 +110,12 @@ class BooksLMChatRequest(BaseModel): description="Conversation snapshot returned alongside a ``confirmation`` event, " "echoed back to resume the agent run.", ) + confirm_all: bool = Field( + default=False, + description="Global approval (BUG-075): apply every pending action of the batch " + "and auto-approve the remaining mutating calls of the same run, " + "so the run does not pause on each action.", + ) app_context: dict[str, Any] | None = Field( default=None, description="Live client UI state for the General assistant: open_documents, " @@ -506,9 +512,11 @@ async def api_bookslm_agent( Same context as ``/chat`` but the model may call tools (read/search the vault) through the shared tool layer. Emits one ``tool`` event per executed tool call, then a final ``message`` event. Mutating tools pause the run with - a ``confirmation`` event (two-step propose/apply) carrying the pending call - and the conversation snapshot; the client resumes by echoing them back in - ``confirm`` / ``confirm_messages``. + a ``confirmation`` event (two-step propose/apply) carrying the pending + ``actions`` (every mutating call of the turn) and the conversation snapshot; + the client resumes by echoing them back in ``confirm`` / ``confirm_messages``, + optionally with ``confirm_all`` to apply the whole batch and auto-approve the + rest of the run (BUG-075). """ _validate_vision_support(req) system_prompt = _resolve_system_prompt(req, current_user, agent=True) @@ -523,6 +531,10 @@ async def api_bookslm_agent( messages.append({"role": "user", "content": _build_user_content(req, vault_path)}) ctx = ToolContext(user=current_user, mode=ToolMode.IN_APP) + if req.confirm_all: + # BUG-075: a single global approval authorizes the whole plan, so the + # run no longer pauses on every subsequent mutating call. + ctx.confirmed = True async def _llm(msgs, tool_schemas): return await chat_completion( diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 65a1c34..9cb8eda 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.23.0" +version = "2.24.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 810f21a..5d2fe36 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.23.0" +version = "2.24.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 83f1dbb..5d99883 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.23.0", + "version": "2.24.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 1a54339..397c87b 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -14,7 +14,7 @@ - **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian - **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop) -- **Dernière mise à jour** : 2026-09-17 +- **Dernière mise à jour** : 2026-09-24 --- @@ -183,6 +183,10 @@ Avant de corriger quoi que ce soit, un agent IA doit : | *BUG-072* | Visionneuse d'images : le plein écran et le panneau « Métadonnées » ne sont pas conservés lors de la navigation ←/→, et le panneau s'affiche sous la pellicule au lieu d'une barre latérale | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/viewer.js`, `frontend/style.css` | Ouvrir une image, activer le plein écran (ou Métadonnées), puis naviguer avec les flèches précédent/suivant | État persistant `_imageViewerState { lightbox, meta }` + drapeau `_imageViewerNavPending` posé par `go()`/pellicule : `renderFile` ne réinitialise que hors navigation image→image. Panneau reconstruit dans `.image-viewer-body` (sidebar droite, `border-left`, `width:280px; max-width:40%`) ; la règle lightbox ne masque plus que la pellicule. Boutons `image-btn-lightbox`/`image-btn-metadata` (+ `aria-pressed`), `Escape` resynchronisé. Tests : `tests/frontend/image-viewer.test.mjs` (+2), E2E `tests/e2e/image-viewer.spec.js` (+1). | Navigation → `openFile` → `renderImageViewer` recréait le conteneur : les états `lightbox`/`metaPanel` étaient perdus. Le panneau était rendu en bas (colonne) au lieu d'une sidebar droite | | *BUG-073* | Mobile : la barre de navigation fixe du bas masque le bas de tous les documents et pages affichés (les dernières lignes restent définitivement sous la barre) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/style.css` | Mobile (≤768px) : ouvrir un document long, défiler jusqu'au bas → la fin du contenu passe sous la barre `#mobile-toolbar` et n'est jamais atteignable | Clearance mobile retargetée de `.main-layout` (sélecteur mort, absent de `index.html`) vers `.main-body` (`calc(64px + env(safe-area-inset-bottom, 0))`) ; règle sœur morte `.editor-modal.active ~ .main-layout` supprimée ; reset `body.reading-mode .main-body { padding-bottom: 0 }` (barre masquée en mode lecture) ; `body.np-active .content-area` ramené à `76px` (dégagement dock seul, géométrie totale inchangée). Tests : `tests/frontend/mobile-toolbar.test.mjs` (7, au CI), E2E `tests/e2e/mobile-toolbar.spec.js` (2, `chromium-mobile`, skip desktop) | La règle de clearance du bloc mobile cible `.main-layout`, classe absente de `index.html` (vrai conteneur : `.main-body`) → sélecteur mort, aucun dégagement réservé | | | | | | | | | | | | | +| *BUG-074* | [🟡 IMPORTANT] Assistant IA : le bloc d'étapes affiche « 1 step » sans titre alors que l'agent réalise plusieurs actions (compteur toujours à 1) | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/js/bookslm.js`, `backend/agent/loop.py` | Mode agent : demander une création multi-fichiers/dossiers → chaque message ne montre qu'« 1 étape ▶ » sans détail | `backend/agent/loop.py` + `frontend/js/bookslm.js` : compteur = actions (hors réflexions) + titre = 1re action dans le `` ; reprise de confirmation diffusée dans le **même** message (fusion des étapes). Tests : `tests/frontend/ai.test.mjs` (+3) | Le résumé `` ne porte aucun titre ; chaque reprise de confirmation crée un **nouveau** message assistant qui ne contient qu'une action ; les réflexions gonflent le compteur | +| *BUG-075* | [🟡 IMPORTANT] Assistant IA : chaque action mutatrice demande son propre « Appliquer » — aucun résumé des actions en attente ni approbation globale | 🟢 corrigé | P1 | ⚙️ backend + 📱 frontend | IA | `backend/agent/loop.py`, `backend/bookslm_routes.py`, `frontend/js/bookslm.js` | Mode agent : demander une structure de répertoires multi-fichiers → valider une action après l'autre | `backend/agent/loop.py` (`pending.actions`, lot exécuté au resume) ; `backend/bookslm_routes.py` (`confirm_all` → `ctx.confirmed`) ; `frontend/js/bookslm.js` (carte multi-actions + « Tout approuver (N) »). Tests : `tests/test_agent_loop.py`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs` | La pause de confirmation ne capture que le **premier** appel mutateur du lot (les suivants sont `deferred`) ; carte unique sans liste ; nouveau `confirm_all` à ajouter pour autoriser la suite de l'exécution en une approbation | +| *BUG-076* | [🟡 IMPORTANT] Assistant IA : après une action de l'agent, l'arborescence et le document ouvert ne sont pas rafraîchis dynamiquement | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : créer/supprimer un fichier ou dossier, modifier le document ouvert → l'UI ne bouge pas | `frontend/js/bookslm.js` : `MUTATING_TOOLS`/`FILE_WRITE_TOOLS`, refresh d'arborescence débouncé sur event `tool`, `_notifyFileWritten` étendu (xlsx/docx/csv/pdf). Tests : `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs` | Aucun refresh explicite sur les événements `tool` mutateurs (repose uniquement sur le watcher SSE) ; `_notifyFileWritten` ignore les créations de documents (xlsx/docx/csv/pdf) | +| *BUG-077* | [🟡 IMPORTANT] Assistant IA : aucun bouton « Stop » pour arrêter l'exécution de l'agent à tout moment | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : lancer une longue tâche → le bouton Envoyer est désactivé, impossible d'arrêter (seule la fermeture du panneau abort) | `frontend/js/bookslm.js` + `frontend/style.css` : bouton d'envoi → Stop (`_syncSendButton`/`_stopGeneration`/`_markStopped`), i18n `ai.stop`/`ai.stopped`. Tests : `tests/frontend/ai.test.mjs` (+2) | `_abortCtrl` n'est déclenché que par `close()` ; aucun signal d'arrêt côté client pendant le stream | ### TODOs techniques (améliorations / nouvelles tâches) @@ -263,6 +267,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-23 | BUG-072 | Correction | `frontend/js/viewer.js`, `frontend/style.css`, `tests/frontend/image-viewer.test.mjs`, `tests/e2e/image-viewer.spec.js`, `scripts/run-e2e-local.ps1` (nouveau), `package.json`, `AGENTS.md`, `README.md`, `README.fr.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-072** : dans la visionneuse d'images (#108-D), le plein écran (lightbox) et le panneau « Métadonnées » étaient perdus dès qu'on changeait d'image avec ←/→ (ou la pellicule), car `openFile` → `renderFile` recrée entièrement `renderImageViewer`. (1) **Persistance** : état module `_imageViewerState { lightbox, meta }` restauré à chaque rendu ; un drapeau `_imageViewerNavPending` posé par `go()` et le clic de vignette indique à `renderFile` que le rendu suivant est une navigation image→image (pas de réinitialisation) — toute autre ouverture repart à zéro. (2) **Panneau latéral** : `.image-meta-panel` déplacé dans un nouveau `.image-viewer-body` en flex row, à droite de `.image-stage` (`border-left`, `width:280px; max-width:40%`, défilement vertical) au lieu d'une bande sous la pellicule ; la règle lightbox ne masque plus que la pellicule. Boutons stables `image-btn-lightbox`/`image-btn-metadata` + `aria-pressed`, `Escape` resynchronise l'état. Tests statiques `image-viewer.test.mjs` (+2) et E2E Playwright (+1). **Diagnostic E2E** : `npm run test:e2e` bloquait car `bash` résout vers WSL (HS, Ubuntu `Stopped`, `HCS_E_CONNECTION_TIMEOUT`) et git-bash est bloqué par App Control → lanceur PowerShell ajouté. Vérifié : `image-viewer.spec.js` 4/4, **suite `chromium-desktop` complète 103 passed / 6 skipped (10,3 min)** via `scripts/run-e2e-local.ps1`, `image-viewer.test.mjs` 12/12, unit 10/10, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-23 | BUG-073 | Correction | `frontend/style.css`, `tests/frontend/mobile-toolbar.test.mjs` (nouveau), `tests/e2e/mobile-toolbar.spec.js` (nouveau), `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-073** : en mobile (≤768px), la barre fixe `#mobile-toolbar` (64px + safe-area) recouvrait le bas de tous les documents/pages — fin de contenu inaccessible. (1) **Cause** : la règle de clearance du bloc `@media (max-width: 768px)` ciblait `.main-layout`, classe absente de `index.html` (vrai conteneur : `.main-body`) → sélecteur mort, zéro dégagement ; règle sœur morte `.editor-modal.active ~ .main-layout { padding-bottom: 0 }` supprimée (overlay plein écran / nécessaire en édition inline). (2) **Correctif** : `.main-body { padding-bottom: calc(64px + env(safe-area-inset-bottom, 0)) }`, reset `body.reading-mode .main-body { padding-bottom: 0 }` (barre masquée en mode lecture), `body.np-active .content-area` ramené de `calc(64px+safe+76px)` à `76px` (dégagement dock seul — géométrie totale identique, pas de double comptage avec `.main-body`). Tests : `mobile-toolbar.test.mjs` (7 statiques, ajouté au CI), E2E `mobile-toolbar.spec.js` (géométrie + scroll fin de `ANALYSE_REVIEW.md`, skip hors viewport ≤768). Vérifié : `mobile-toolbar` 7/7, JSDOM 14 suites 0 échec, **E2E `chromium-mobile` BUG-073 2/2 + régressions mobile-editor/config-mobile 6/6**, **suite `chromium-desktop` complète 106 passed / 9 skipped (11,4 min)**, pytest 1302 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, unit 11/11. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-24 | #114 | Feature | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs`, `tests/e2e/config-mobile.spec.js`, `docs/features/settings-mobile-114.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **#114 — Configuration, refonte mobile-responsive (≤768px)**. (1) **Drawer sommaire** : `#config-nav` en panneau `position: fixed` (`min(320px, 88vw)`, z-index 40) sous backdrop `#config-modal.config-toc-open::before` (z-index 35) ; bouton `#config-toc-close` ; backdrop/Échap ferment le drawer d'abord puis la modale ; `_setConfigNav` bascule la classe + conserve le `display` inline de reset. (2) **Modale plein écran** : `100dvh` + `padding: 0`, `border-radius: 0`. (3) **Tactile** : boutons/liens ≥44px, inputs/selects `16px` + `min-height: 44px` (anti-zoom iOS, scopé `#config-modal`), rangée `.config-actions-row` sticky column + safe-area, formulaires 1 colonne, MFA 1 colonne + code full-width, wrap webhook/token/share/diag/avatar/webauthn. (4) **Dettes HTML/i18n** : `.config-actions-row` replacée dans `#cfg-backend-settings` (`` orphelin supprimé), id dupliqué `cfg-partages-publics` retiré du `

`, `#plugins-settings-container` supprimé, doublons `.config-btn-add` + règle morte `.mfa-recovery-input` purgés, `#mt-explorer` → `data-i18n="settings.explorer"` ; i18n : clés mortes `settings.{backend,backend_hint,restart_badge,save,plugins}` supprimées, `settings.explorer` + `config.toc_close` ajoutées, `settings.tabs` FR = « Onglets ». Vérifié : `config-mobile.test.mjs` 27/27 (au CI), unit 11/11, validate-imports 40 modules, pytest 1302 passed / 6 skipped, ruff/mypy 0, E2E `chromium-mobile` 5/5. | ✅ livré (en attente vérif utilisateur) | +| 2026-09-24 | BUG-074 → BUG-077 | Correction | `backend/agent/loop.py`, `backend/bookslm_routes.py`, `frontend/js/bookslm.js`, `frontend/style.css`, `frontend/index.html`, `frontend/locales/{fr,en}.json`, `frontend/sw.js`, `tests/test_agent_loop.py`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Lot assistant IA (mode agent)** : (BUG-075) confirmation par lot — `pending.actions` regroupe toutes les mutations d'un tour LLM, un unique bouton « Tout approuver (N) » envoie `confirm_all` (`ToolContext.confirmed`) et n'interrompt plus à chaque action ; les lectures du lot s'exécutent aussitôt. (BUG-074) résumé du bloc d'étapes avec titre de la 1re action + compteur limité aux actions, reprise diffusée dans le même message (fini le « 1 étape » fragmenté). (BUG-076) refresh de l'arborescence débouncé sur les events `tool` mutateurs + `_notifyFileWritten` étendu aux documents (xlsx/docx/csv/pdf). (BUG-077) le bouton d'envoi devient « Stop » pendant le stream (abort SSE, tâche serveur annulée à la déconnexion, marqueur « Exécution arrêtée. »). Guide/i18n FR/EN + `ai.stop`/`ai.stopped`/`ai.confirm_actions`/`ai.action_apply_all` ; `SW_VERSION` v25. Vérifié : pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, unit 11/11, ai 100/100, editor-inline 44/44, mobile-editor 35/35, ai-sidebar 6/6, forge 32/32, pane-manager 9/9, sw 8/8. | 🟢 corrigé (en attente vérif utilisateur) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c09886a..a205134 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.23.0 | **Dernière mise à jour :** 2026-09-24 +> **Version :** 2.24.0 | **Dernière mise à jour :** 2026-09-24 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** diff --git a/docs/features/ai-tools-mcp.md b/docs/features/ai-tools-mcp.md index e45d29a..1b81ef2 100644 --- a/docs/features/ai-tools-mcp.md +++ b/docs/features/ai-tools-mcp.md @@ -20,6 +20,7 @@ - [x] **B3.** Fallback : retry sans `tools` si le provider rejette les tools (400/404/422) → chat simple ; protocole texte `obsigate-action` conservé côté frontend **pour le chat classique uniquement** (BUG-053 : en mode agent, le prompt impose les outils natifs et interdit les blocs `obsigate-action`) - [x] **B4.** SSE réellement streaming — `ai_chat.stream_completion` (`_openai_stream` + `_gemini_stream`) alimente `/api/ai/bookslm/chat` token par token ; le middleware GZip laisse passer les endpoints SSE BooksLM. - [x] **B5.** Confirmations UI : toggle « mode agent » (front → `/agent`), événements `tool`/`confirmation`, carte Apply + aperçu diff (LCS) pour les mutations, reprise `confirm`/`confirm_messages` côté backend. *S'active dès que la phase D enregistre des outils `write`.* + - **Complément (BUG-074 → BUG-077)** : la pause de confirmation **regroupe toutes les mutations** d'un même tour LLM (`pending.actions`, chacune avec son libellé `step` et son diff) et la carte n'offre plus qu'un seul bouton « **Tout approuver (N)** » ; la reprise envoie `confirm_all` et le backend arme `ToolContext.confirmed` pour le reste du run (plus d'approbation action par action). Le bloc d'étapes affiche un **titre** (1re action) et ne compte que les **actions** (hors réflexions) ; la reprise diffuse dans le **même message** (« N étapes » cumulées). L'arborescence et le document affiché sont **rafraîchis** dès une action mutatrice (refresh débouncé + `obsigate:file-written`, documents xlsx/docx/csv/pdf inclus). Le bouton d'envoi devient « **Stop** » pendant le stream (abort SSE, tâche serveur annulée à la déconnexion, marqueur « Exécution arrêtée. »). - [x] **B6.** Outils de navigation in-app : `open_file`, `reveal_in_tree` (événement `obsigate:open-file`) — livré via les liens cliquables de l'assistant (#80, [ai-assistant-ux.md](./ai-assistant-ux.md)) - [x] **B7.** Tests : agent loop LLM mocké (`tests/test_agent_loop.py`), providers (`tests/test_ai_chat.py`), endpoint (`tests/test_bookslm.py`) diff --git a/frontend/index.html b/frontend/index.html index c8544fa..be3ab6a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4644,6 +4644,13 @@ chercher) ; les actions de modification demandent une confirmation avec aperçu des changements. +
  • + Les actions s'affichent dans le fil : l'assistant regroupe les + modifications en une seule approbation (« Tout approuver ») et + met à jour l'arborescence et le document ouvert dès qu'elles + sont appliquées. Le bouton d'envoi devient « Stop » pour + interrompre l'exécution à tout moment. +
  • Le bord gauche du panneau est redimensionnable ; la largeur est mémorisée. diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index 712cb1e..9f08e25 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -52,6 +52,22 @@ const PANEL_MIN_WIDTH = 320; const PANEL_MAX_WIDTH = 1000; const PANEL_WIDTH_KEY = 'obsigate-bookslm-width'; +// BUG-076 — Tools that mutate the vault: their execution must refresh the +// sidebar tree immediately (the watcher SSE is delayed and directory-only +// changes may not produce an index event). +const MUTATING_TOOLS = new Set([ + 'create_file', 'create_directory', 'edit_file', 'append_to_file', + 'rename_file', 'rename_directory', 'move_path', 'replace_in_files', + 'delete_file', 'delete_directory', 'restore_backup', + 'create_xlsx', 'create_docx', 'create_csv', 'create_pdf', +]); +// Subset carrying a concrete `vault` + `path`: the displayed document is +// reloaded from disk so an open viewer/editor reflects the agent's write. +const FILE_WRITE_TOOLS = new Set([ + 'edit_file', 'append_to_file', 'create_file', 'restore_backup', + 'create_xlsx', 'create_docx', 'create_csv', 'create_pdf', +]); + /** * BUG-059 — Is this pointer press a scrollbar drag (and only that)? * @@ -895,15 +911,14 @@ class BooksLM { } /** - * #93 — A write tool just modified a vault file. Notify the UI so the - * displayed document is reloaded from disk: otherwise the read view keeps the - * stale content, and an open editor buffer autosaves the old text back over - * the assistant's change (see utils.reloadExternalWrite). + * #93 / BUG-076 — A write tool just modified a vault file. Notify the UI so + * the displayed document is reloaded from disk: otherwise the read view keeps + * the stale content, and an open editor buffer autosaves the old text back + * over the assistant's change (see utils.reloadExternalWrite). */ _notifyFileWritten(data) { if (!data || data.ok === false) return; - const WRITE_TOOLS = ['edit_file', 'append_to_file', 'create_file', 'restore_backup']; - if (WRITE_TOOLS.indexOf(data.name) === -1) return; + if (!FILE_WRITE_TOOLS.has(data.name)) return; const args = data.arguments || {}; if (!args.vault || !args.path) return; window.dispatchEvent( @@ -913,6 +928,24 @@ class BooksLM { ); } + /** + * BUG-076 — Refresh the sidebar tree right after an agent mutation, without + * waiting for the (debounced) watcher SSE. Debounced so a batch of tool + * events (e.g. a folder plus its files) triggers a single refresh. + */ + _scheduleTreeRefresh() { + if (this._treeRefreshTimer) clearTimeout(this._treeRefreshTimer); + this._treeRefreshTimer = setTimeout(async () => { + this._treeRefreshTimer = null; + try { + const m = await import('./sidebar.js'); + if (m && typeof m.refreshSidebarTreePreservingState === 'function') { + await m.refreshSidebarTreePreservingState(); + } + } catch { /* tree refresh is best-effort */ } + }, 250); + } + // ── Rendering ─────────────────────────────────────────────────────── _render() { @@ -1041,7 +1074,11 @@ class BooksLM { } }); - panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage()); + panel.querySelector('.bookslm-btn-send').addEventListener('click', () => { + // BUG-077: the same button stops the run while a response streams. + if (this._isLoading) this._stopGeneration(); + else this._sendMessage(); + }); // Resizable panel (drag the left edge). const resizeHandle = panel.querySelector('.bookslm-resize-handle'); @@ -2665,6 +2702,17 @@ class BooksLM { return t('ai.tool_call', { name: call.name }); } + /** + * BUG-074 — number of *action* steps of a message (reasoning notes are not + * actions). Falls back to the total when the block holds only thoughts so a + * “0 étape” label can never appear. + */ + _actionStepCount(toolCalls) { + const list = toolCalls || []; + const actions = list.filter((c) => !(c.step && c.step.key === 'thought')); + return actions.length || list.length; + } + /** Chevron used by every collapsible block (▶ closed / ▼ open, via CSS). */ _chevron() { const c = document.createElement('span'); @@ -2698,13 +2746,32 @@ class BooksLM { dots.classList.add('bookslm-steps-dots'); summary.appendChild(dots); } - const countKey = toolCalls.length > 1 ? 'ai.steps_count_plural' : 'ai.steps_count'; + // BUG-074: the counter reflects *actions*, not reasoning notes, and the + // collapsed header also previews the first action so an “N étapes ▶” line + // is no longer an opaque title. + const actionCalls = toolCalls.filter((c) => !(c.step && c.step.key === 'thought')); + const count = this._actionStepCount(toolCalls); + const countKey = count > 1 ? 'ai.steps_count_plural' : 'ai.steps_count'; const label = document.createElement('span'); label.className = 'bookslm-steps-label'; - label.textContent = t(countKey, { count: toolCalls.length }); + label.textContent = t(countKey, { count }); summary.appendChild(label); + const first = actionCalls[0]; + let preview = ''; + if (first) { + preview = this._stepText(first); + const title = document.createElement('span'); + title.className = 'bookslm-steps-title'; + title.textContent = `— ${preview}`; + summary.appendChild(title); + } summary.appendChild(this._chevron()); - if (running) summary.setAttribute('aria-label', `${label.textContent} — ${t('ai.steps_running')}`); + if (running) { + summary.setAttribute( + 'aria-label', + `${label.textContent}${preview ? ` — ${preview}` : ''} — ${t('ai.steps_running')}`, + ); + } wrap.appendChild(summary); const body = document.createElement('div'); @@ -2804,8 +2871,11 @@ class BooksLM { const conf = msg.confirmation; const pending = conf.pending || {}; const error = pending.error || pending; - const tool = error.tool || 'action'; - const args = error.arguments || {}; + // BUG-075: the run batches every mutating call of the LLM turn into + // `pending.actions`; fall back to the single legacy `error` shape. + const actions = (Array.isArray(pending.actions) && pending.actions.length) + ? pending.actions + : [{ id: error.id, tool: error.tool, arguments: error.arguments || {}, step: null }]; const card = document.createElement('div'); card.className = 'bookslm-action bookslm-confirm'; @@ -2814,23 +2884,48 @@ class BooksLM { meta.className = 'bookslm-action-meta'; meta.innerHTML = '🔒'; const textEl = document.createElement('span'); - textEl.textContent = t('ai.tool_call', { name: tool }) + (args.path ? ` — ${args.path}` : ''); + if (actions.length > 1) { + textEl.textContent = t('ai.confirm_actions', { count: actions.length }); + } else { + const tool = actions[0].tool || 'action'; + const args = actions[0].arguments || {}; + textEl.textContent = t('ai.tool_call', { name: tool }) + (args.path ? ` — ${args.path}` : ''); + } meta.appendChild(textEl); card.appendChild(meta); - const diffHost = document.createElement('div'); - diffHost.className = 'bookslm-confirm-diff'; - if (conf._diffHtml) { - diffHost.innerHTML = conf._diffHtml; - } else if (!conf._diffLoading) { - conf._diffLoading = true; - this._fillConfirmationDiff(args, conf).then(() => this._renderMessages()); + // One row per action with a diff preview when it writes file content. + const hasContent = (a) => { + const args = a.arguments || {}; + return typeof args.content === 'string' && args.vault && args.path; + }; + if (actions.length > 1) { + const list = document.createElement('div'); + list.className = 'bookslm-confirm-actions'; + for (const action of actions) { + const args = action.arguments || {}; + const row = document.createElement('details'); + row.className = 'bookslm-confirm-action'; + const summary = document.createElement('summary'); + const text = document.createElement('span'); + text.textContent = this._stepText({ step: action.step, name: action.tool }) + + (args.path ? ` — ${args.path}` : ''); + summary.appendChild(text); + if (hasContent(action)) summary.appendChild(this._chevron()); + row.appendChild(summary); + if (hasContent(action)) row.appendChild(this._diffHost(action, args)); + list.appendChild(row); + } + card.appendChild(list); + } else if (hasContent(actions[0])) { + card.appendChild(this._diffHost(conf, actions[0].arguments || {})); } - card.appendChild(diffHost); const apply = document.createElement('button'); apply.className = 'bookslm-action-apply'; - apply.textContent = t('ai.action_apply'); + apply.textContent = actions.length > 1 + ? t('ai.action_apply_all', { count: actions.length }) + : t('ai.action_apply'); apply.addEventListener('click', async () => { apply.disabled = true; apply.textContent = t('ai.action_applying'); @@ -2839,7 +2934,9 @@ class BooksLM { apply.textContent = t('ai.action_applied'); } catch (e) { apply.disabled = false; - apply.textContent = t('ai.action_apply'); + apply.textContent = actions.length > 1 + ? t('ai.action_apply_all', { count: actions.length }) + : t('ai.action_apply'); showToast(t('ai.action_failed', { error: e.message }), 'error'); } }); @@ -2847,6 +2944,19 @@ class BooksLM { return card; } + /** Diff host for one confirmation action (lazy LCS diff, cached on the host). */ + _diffHost(host, args) { + const diffHost = document.createElement('div'); + diffHost.className = 'bookslm-confirm-diff'; + if (host._diffHtml) { + diffHost.innerHTML = host._diffHtml; + } else if (!host._diffLoading) { + host._diffLoading = true; + this._fillConfirmationDiff(args, host).then(() => this._renderMessages()); + } + return diffHost; + } + async _fillConfirmationDiff(args, conf) { const proposed = args.content; if (typeof proposed !== 'string' || !args.vault || !args.path) return; @@ -2935,22 +3045,20 @@ class BooksLM { if (!conf || conf._applying) return; conf._applying = true; + // BUG-075: one click applies the whole pending batch AND authorizes the + // remaining actions of the same run (no second confirmation card). const payload = { ...(msg.payload || {}), confirm: conf.pending, confirm_messages: conf.messages, + confirm_all: true, }; this._isLoading = true; this._abortCtrl = new AbortController(); - const sendBtn = this._panel && this._panel.querySelector('.bookslm-btn-send'); - if (sendBtn) sendBtn.disabled = true; + this._syncSendButton(); + this._setActivity('working', t('ai.activity_streaming')); - // BUG-046: carry the original request payload over to the continuation. - // When an applied tool failed and the model re-proposed a confirmation, - // the continuation used to have `payload: null`: the second "Appliquer" - // then resumed without `message` → 422 → "[object Object]" toast. - const continuation = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload: msg.payload ? { ...msg.payload } : null }; try { let resp = await this._postChat(payload); if (resp.status === 401 && AuthManager._authEnabled) { @@ -2960,21 +3068,65 @@ class BooksLM { if (!resp.ok) { throw await this._responseError(resp); } - // The confirmation is resolved: drop the card and show the continuation. + // The confirmation is resolved: drop the card and keep streaming into the + // SAME message, so the steps block accumulates the whole exchange + // instead of fragmenting into a new “1 étape” message per approval + // (BUG-074). BUG-046 payload carry-over is inherent: `msg.payload` stays. msg.confirmation = null; - this._messages.push(continuation); this._renderMessages({ anchor: true }); - await this._streamResponse(resp, continuation, payload); + await this._streamResponse(resp, msg, payload); + } catch (e) { + if (e.name === 'AbortError') { + this._markStopped(msg); + } else { + throw e; + } } finally { conf._applying = false; this._isLoading = false; this._abortCtrl = null; - if (sendBtn) sendBtn.disabled = false; + this._syncSendButton(); this._renderMessages(); this._saveHistory(); } } + /** BUG-077 — abort the in-flight agent/chat request. */ + _stopGeneration() { + if (this._abortCtrl) { + try { + this._abortCtrl.abort(); + } catch { /* already aborted */ } + } + } + + /** Append a visible « stopped by the user » marker to an assistant message. */ + _markStopped(msg) { + const marker = `⏹ ${t('ai.stopped')}`; + const content = String(msg.content || ''); + if (!content.includes(marker)) { + msg.content = content ? `${content}\n\n${marker}` : marker; + } + this._setActivity('idle'); + } + + /** + * BUG-077 — the composer's round button doubles as a Stop control while a + * response streams: a new send is already blocked by `_isLoading`, so the + * button switches to a stop icon instead of being disabled. + */ + _syncSendButton() { + const btn = this._panel && this._panel.querySelector('.bookslm-btn-send'); + if (!btn) return; + const loading = !!this._isLoading; + btn.classList.toggle('is-stopping', loading); + btn.title = loading ? t('ai.stop') : t('bookslm.send'); + btn.setAttribute('aria-label', btn.title); + const icon = btn.querySelector('i'); + if (icon) icon.setAttribute('data-lucide', loading ? 'square' : 'arrow-up'); + if (typeof safeCreateIcons === 'function') safeCreateIcons(); + } + // ── Messaging ─────────────────────────────────────────────────────── async _sendMessage() { @@ -3053,10 +3205,8 @@ class BooksLM { this._isLoading = true; this._renderMessages({ anchor: true, instant: true }); - const sendBtn = this._panel.querySelector('.bookslm-btn-send'); - if (sendBtn) sendBtn.disabled = true; - this._abortCtrl = new AbortController(); + this._syncSendButton(); this._setActivity('working', t('ai.activity_sending')); try { @@ -3085,17 +3235,17 @@ class BooksLM { } this._setActivity('done', t('ai.activity_done')); } catch (e) { - if (e.name !== 'AbortError') { + if (e.name === 'AbortError') { + this._markStopped(assistantMsg); + } else { assistantMsg.content = `⚠ Error: ${e.message}`; console.warn('AI assistant chat error:', e); this._setActivity('error', t('ai.activity_error')); - } else { - this._setActivity('idle'); } } this._isLoading = false; - if (sendBtn) sendBtn.disabled = false; + this._syncSendButton(); this._abortCtrl = null; this._renderMessages(); this._saveHistory(); @@ -3155,6 +3305,10 @@ class BooksLM { }); // #93 — a vault write must be reflected in the displayed document. this._notifyFileWritten(data); + // BUG-076 — reflect tree changes (create/delete/rename…) right away. + if (data.ok !== false && MUTATING_TOOLS.has(data.name)) { + this._scheduleTreeRefresh(); + } this._setActivity('working', t('ai.activity_tool', { name: data.name })); return; } diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 2d79792..c5d65a0 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -80,12 +80,16 @@ "admin.users_title": "User management", "ai.action_applied": "Applied ✓", "ai.action_apply": "Apply", + "ai.action_apply_all": "Approve all ({count})", "ai.action_applying": "Applying…", "ai.action_create_dir": "Create folder {path}", "ai.action_create_file": "Create file {path}", "ai.action_created_dir": "Folder created: {path}", "ai.action_created_file": "File created: {path}", "ai.action_failed": "Action failed: {error}", + "ai.confirm_actions": "{count} actions to approve", + "ai.stop": "Stop the assistant", + "ai.stopped": "Run stopped.", "ai.agent_mode_off": "Agent mode off (read/search + actions)", "ai.agent_mode_on": "Agent mode on (read/search tools + actions)", "ai.casual": "Casual tone", @@ -1352,6 +1356,7 @@ "help.assistant_links": "Cited files and paths are links: a bare filename copies the name to the clipboard, a folder is revealed in the tree, and a file path opens it in the viewer.", "help.assistant_sessions": "The header history icon lists past sessions (reopen or delete); “+” starts a new conversation.", "help.assistant_agent": "The \"agent mode\" button enables tools (read, list, search); modifying actions require confirmation with a change preview.", + "help.assistant_agent_run": "Actions appear in the thread: the assistant groups modifications into a single approval (\"Approve all\") and refreshes the file tree and the open document as soon as they are applied. The send button becomes \"Stop\" to interrupt the run at any time.", "help.assistant_resize": "The left edge of the panel is resizable; the width is remembered.", "help.assistant_at": "Type @ to attach a file or directory to the context, or to attach an image from a directory.", "help.assistant_slash": "Type / to run a skill (research, summary, correction, plan…) or an admin command (/help, /providers, /model, /keys).", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index c5777fd..090ca4a 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -80,12 +80,16 @@ "admin.users_title": "Gestion utilisateurs", "ai.action_applied": "Appliqué ✓", "ai.action_apply": "Appliquer", + "ai.action_apply_all": "Tout approuver ({count})", "ai.action_applying": "Application…", "ai.action_create_dir": "Créer le dossier {path}", "ai.action_create_file": "Créer le fichier {path}", "ai.action_created_dir": "Dossier créé : {path}", "ai.action_created_file": "Fichier créé : {path}", "ai.action_failed": "Échec de l'action : {error}", + "ai.confirm_actions": "{count} actions à approuver", + "ai.stop": "Arrêter l'assistant", + "ai.stopped": "Exécution arrêtée.", "ai.agent_mode_off": "Mode agent désactivé (lecture/recherche + actions)", "ai.agent_mode_on": "Mode agent activé (outils de lecture/recherche + actions)", "ai.casual": "Ton décontracté", @@ -1352,6 +1356,7 @@ "help.assistant_links": "Les fichiers et chemins cités sont des liens : un simple nom de fichier copie le nom dans le presse-papiers, un dossier est révélé dans l'arborescence, et un chemin de fichier l'ouvre dans le viewer.", "help.assistant_sessions": "L'icône historique de l'en-tête liste les sessions passées (recharger ou supprimer) ; « + » démarre une nouvelle conversation.", "help.assistant_agent": "Le bouton « mode agent » active les outils (lire, lister, chercher) ; les actions de modification demandent une confirmation avec aperçu des changements.", + "help.assistant_agent_run": "Les actions s'affichent dans le fil : l'assistant regroupe les modifications en une seule approbation (« Tout approuver ») et met à jour l'arborescence et le document ouvert dès qu'elles sont appliquées. Le bouton d'envoi devient « Stop » pour interrompre l'exécution à tout moment.", "help.assistant_resize": "Le bord gauche du panneau est redimensionnable ; la largeur est mémorisée.", "help.assistant_at": "Tapez @ pour joindre un fichier ou un répertoire au contexte, ou pour attacher une image d'un répertoire.", "help.assistant_slash": "Tapez / pour lancer un skill (recherche, résumé, correction, plan…) ou une commande admin (/help, /providers, /model, /keys).", diff --git a/frontend/style.css b/frontend/style.css index fce67d9..8f01b43 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -11196,6 +11196,9 @@ body.desktop-mode .editor-container { justify-content: center; flex-shrink: 0; } .bookslm-input-area button:hover { opacity: 0.9; } .bookslm-input-area button:disabled { opacity: 0.5; cursor: not-allowed; } +/* BUG-077 — the send button doubles as a Stop control while streaming. */ +.bookslm-input-area button.bookslm-btn-send.is-stopping { background: var(--danger); } +.bookslm-input-area button.bookslm-btn-send.is-stopping:hover { opacity: 1; filter: brightness(1.08); } .bookslm-input-hint { padding: 0 16px 10px; font-size: 10px; color: var(--text-secondary); text-align: right; border-top: none; } /* Action cards proposed by the General assistant (file/dir creation). */ @@ -11224,6 +11227,9 @@ body.desktop-mode .editor-container { .bookslm-tool-trace > summary:hover { color: var(--text-primary); } .bookslm-tool-trace[open] > summary { margin-bottom: 4px; } .bookslm-steps-dots { color: var(--accent); } +/* BUG-074 — preview of the first action next to the step counter. */ +.bookslm-steps-title { color: var(--text-secondary); max-width: 45vw; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } .bookslm-steps-body { display: flex; flex-direction: column; gap: 3px; } .bookslm-chevron { font-size: 8px; line-height: 1; opacity: 0.7; } .bookslm-chevron::before { content: '▶'; } @@ -11259,6 +11265,15 @@ details[open] > summary .bookslm-chevron::before { content: '▼'; } /* Mutation confirmation card (two-step propose/apply). */ .bookslm-confirm { flex-wrap: wrap; } .bookslm-confirm-diff { flex-basis: 100%; width: 100%; margin-top: 4px; } +/* BUG-075 — one row per action when the run batches several mutations. */ +.bookslm-confirm-actions { flex-basis: 100%; width: 100%; display: flex; + flex-direction: column; gap: 4px; margin-top: 4px; } +.bookslm-confirm-action > summary { display: inline-flex; align-items: center; gap: 5px; + font-size: 12px; color: var(--text-secondary); cursor: pointer; list-style: none; + word-break: break-word; } +.bookslm-confirm-action > summary::-webkit-details-marker { display: none; } +.bookslm-confirm-action > summary:hover { color: var(--text-primary); } +.bookslm-confirm-action[open] > summary { color: var(--text-primary); } .bookslm-diff-title { font-size: 11px; color: var(--text-secondary); margin-bottom: 4px; } .bookslm-diff { background: rgba(0,0,0,0.25); border-radius: 6px; padding: 8px; overflow-x: auto; font-size: 12px; line-height: 1.4; max-height: 240px; margin: 0; } diff --git a/frontend/sw.js b/frontend/sw.js index cd2b865..8cd01bf 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -11,7 +11,7 @@ * cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is * a separate store. Bumping SW_VERSION invalidates it on every release. */ -const SW_VERSION = 'v24'; +const SW_VERSION = 'v25'; const CODE_CACHE = `obsigate-code-${SW_VERSION}`; const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`; const API_CACHE = `obsigate-api-${SW_VERSION}`; diff --git a/package.json b/package.json index 64b6aa4..7b5203d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.23.0", + "version": "2.24.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs index 6e5089f..49ad0d1 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -104,6 +104,7 @@ async function main() { const bookslmMod = await import(pathToFileURL(path.join(JS_DIR, "bookslm.js")).href); const { BooksLM, MODE } = bookslmMod; const { collectOpenDocuments, documentsSignature } = bookslmMod; + const { t } = await import(pathToFileURL(path.join(JS_DIR, "i18n.js")).href); const { state } = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href); // ── 1. Rewrite modal resolves with the typed instruction ── @@ -213,8 +214,14 @@ async function main() { const { readFileSync } = await import("node:fs"); const src = readFileSync(path.join(JS_DIR, "bookslm.js"), "utf-8"); const apply = src.slice(src.indexOf("async _applyConfirmation")); - assert.match(apply, /payload:\s*msg\.payload\s*\?\s*\{[^\n]*\.\.\.msg\.payload[^\n]*\}\s*:\s*null/, - "continuation must inherit the original request payload (no null payload)"); + // BUG-075: the whole batch is approved at once and the continuation keeps + // streaming into the SAME message (its `payload` stays available, so the + // BUG-046 null-payload regression cannot reappear). + assert.match(apply, /confirm_all:\s*true/, "global approval flag sent"); + assert.match(apply, /await this\._streamResponse\(resp, msg, payload\)/, + "resume streams into the original message (payload preserved)"); + assert.doesNotMatch(apply, /this\._messages\.push\(continuation\)/, + "no fragmented continuation message per approval"); assert.match(apply, /await this\._responseError\(resp\)/, "resume path must format non-OK responses via _responseError"); }); @@ -550,6 +557,50 @@ async function main() { assert.ok(summary.getAttribute("aria-label"), "the state is announced to screen readers"); }); + await test("the steps header previews the first action and counts actions only (BUG-074)", () => { + const b = new BooksLM(); + // Two reasoning notes + one action → the header counts 1 action and + // previews it; the notes must not inflate the counter. + const calls = [ + { name: "", ok: true, step: { key: "thought", params: { value: "je réfléchis" } } }, + { name: "create_file", ok: true, step: { key: "file_create", params: { value: "a.md" } } }, + { name: "", ok: true, step: { key: "thought", params: { value: "encore" } } }, + ]; + const trace = b._renderToolActivity(calls, {}, false); + const summary = trace.querySelector("summary"); + assert.equal( + summary.querySelector(".bookslm-steps-label").textContent, + t("ai.steps_count", { count: 1 }), + "only actions are counted", + ); + const title = summary.querySelector(".bookslm-steps-title"); + assert.ok(title, "the first action is previewed in the collapsed header"); + assert.ok(title.textContent.startsWith("— "), "preview is prefixed with a dash"); + assert.ok(title.textContent.includes(b._stepText(calls[1])), + "preview shows the first action's label"); + // Several actions → plural label. + const two = b._renderToolActivity([ + { name: "create_file", ok: true, step: { key: "file_create", params: { value: "a.md" } } }, + { name: "create_directory", ok: true, step: { key: "dir_create", params: { value: "d" } } }, + ], {}, false); + assert.equal( + two.querySelector(".bookslm-steps-label").textContent, + t("ai.steps_count_plural", { count: 2 }), + "plural for several actions", + ); + }); + + await test("_actionStepCount ignores reasoning notes (BUG-074)", () => { + const b = new BooksLM(); + assert.equal(b._actionStepCount([ + { step: { key: "thought" } }, + { step: { key: "file_create" } }, + ]), 1, "only the action is counted"); + assert.equal(b._actionStepCount([{ step: { key: "thought" } }]), 1, + "a thoughts-only block still shows 1, never 0"); + assert.equal(b._actionStepCount([]), 0); + }); + await test("reasoning notes become « Réflexion » sub-sections, open state kept", () => { const b = new BooksLM(); const msg = {}; @@ -1206,14 +1257,119 @@ async function main() { assert.equal(msg.confirmation, null, "confirmation resolved"); assert.ok(posted, "resumed via /agent"); assert.equal(posted.confirm.error.tool, "edit_file"); + assert.equal(posted.confirm_all, true, "BUG-075: global approval flag sent"); assert.deepEqual(posted.confirm_messages, [{ role: "system", content: "s" }]); + // BUG-074: the continuation stays in the same message (cumulative steps). + assert.equal(b._messages.length, 1, "no fragmented continuation message"); const cont = b._messages[b._messages.length - 1]; + assert.equal(cont, msg, "streams into the original message"); assert.equal(cont.content, "Fait."); assert.equal(cont.toolCalls.length, 1, "tool trace captured"); b._panel.remove(); localStorage.clear(); }); + await test("a batch confirmation lists every action and offers one approval (BUG-075)", () => { + const b = new BooksLM(); + b._panel = b._render(); + document.body.appendChild(b._panel); + b._fillConfirmationDiff = async () => {}; + const msg = { + role: "assistant", + content: "", + confirmation: { + pending: { + error: { tool: "create_file", arguments: { vault: "V", path: "a.md" }, id: "1" }, + actions: [ + { id: "1", tool: "create_directory", step: { key: "dir_create", params: { value: "proj" } }, arguments: { vault: "V", path: "proj" } }, + { id: "2", tool: "create_file", step: { key: "file_create", params: { value: "proj/a.md" } }, arguments: { vault: "V", path: "proj/a.md", content: "x" } }, + ], + }, + messages: [], + }, + }; + const card = b._renderConfirmationCard(msg); + const rows = card.querySelectorAll(".bookslm-confirm-action"); + assert.equal(rows.length, 2, "one row per pending action"); + assert.ok( + rows[0].querySelector("summary").textContent.includes( + b._stepText({ step: { key: "dir_create", params: { value: "proj" } }, name: "create_directory" }), + ), + "each row shows the action's human label", + ); + const apply = card.querySelector(".bookslm-action-apply"); + assert.equal(apply.textContent, t("ai.action_apply_all", { count: 2 }), + "a single global approval button"); + b._panel.remove(); + }); + + await test("single-action confirmation keeps the legacy Apply label (BUG-075)", () => { + const b = new BooksLM(); + b._panel = b._render(); + document.body.appendChild(b._panel); + b._fillConfirmationDiff = async () => {}; + const msg = { + role: "assistant", + content: "", + confirmation: { + pending: { error: { tool: "edit_file", arguments: { vault: "V", path: "a.md", content: "x" }, id: "1" } }, + messages: [], + }, + }; + const card = b._renderConfirmationCard(msg); + assert.equal(card.querySelectorAll(".bookslm-confirm-action").length, 0, + "no batch list for a single action"); + assert.equal(card.querySelector(".bookslm-action-apply").textContent, t("ai.action_apply")); + b._panel.remove(); + }); + + await test("the send button doubles as a Stop control while running (BUG-077)", () => { + const b = new BooksLM(); + b._panel = b._render(); + document.body.appendChild(b._panel); + const btn = b._panel.querySelector(".bookslm-btn-send"); + b._syncSendButton(); + assert.equal(btn.querySelector("i").getAttribute("data-lucide"), "arrow-up"); + assert.ok(!btn.classList.contains("is-stopping"), "idle: normal send button"); + + let aborted = false; + b._isLoading = true; + b._abortCtrl = { abort: () => { aborted = true; } }; + b._syncSendButton(); + assert.ok(btn.classList.contains("is-stopping"), "stop state applied while running"); + assert.equal(btn.querySelector("i").getAttribute("data-lucide"), "square"); + btn.click(); + assert.ok(aborted, "clicking while running aborts the request"); + b._isLoading = false; + b._panel.remove(); + }); + + await test("_markStopped appends a visible stop marker (BUG-077)", () => { + const b = new BooksLM(); + const msg = { content: "Début de réponse" }; + b._markStopped(msg); + assert.ok(msg.content.includes("⏹"), "marker present"); + assert.ok(msg.content.startsWith("Début de réponse"), "partial answer kept"); + assert.ok(!msg.content.includes("⏹\n"), "marker separated from the answer"); + const empty = { content: "" }; + b._markStopped(empty); + assert.ok(empty.content.includes("⏹")); + assert.ok(!empty.content.startsWith("\n"), "no leading blank line for an empty answer"); + }); + + await test("document writes notify the viewer; path-less tools do not (BUG-076)", () => { + const b = new BooksLM(); + const seen = []; + const handler = (e) => seen.push(e.detail); + window.addEventListener("obsigate:file-written", handler); + b._notifyFileWritten({ name: "create_xlsx", ok: true, arguments: { vault: "V", path: "a.xlsx" } }); + b._notifyFileWritten({ name: "edit_file", ok: false, arguments: { vault: "V", path: "b.md" } }); + b._notifyFileWritten({ name: "replace_in_files", ok: true, arguments: { vault: "V" } }); + window.removeEventListener("obsigate:file-written", handler); + assert.equal(seen.length, 1, "only the successful path-bearing write notifies"); + assert.equal(seen[0].path, "a.xlsx"); + }); + // ── 29. Resizable panel bounds and persistence (#81) ── await test("panel width is clamped and persisted", async () => { localStorage.clear(); diff --git a/tests/frontend/editor-inline.test.mjs b/tests/frontend/editor-inline.test.mjs index 501d38f..13ea3c1 100644 --- a/tests/frontend/editor-inline.test.mjs +++ b/tests/frontend/editor-inline.test.mjs @@ -333,8 +333,13 @@ test("bookslm.js reports the edited document and the assistant writes", () => { assert.match(bookslmSrc, /this\._notifyFileWritten\(data\);/); const notify = bookslmSrc.match(/_notifyFileWritten\(data\) \{([\s\S]*?)\n \}/); assert.ok(notify, "_notifyFileWritten not found"); - for (const tool of ["edit_file", "append_to_file", "create_file", "restore_backup"]) { - assert.ok(notify[1].includes(`'${tool}'`), `${tool} missing from the write tools`); + // BUG-076: the write-tool list is a module-level set (documents included). + assert.match(notify[1], /FILE_WRITE_TOOLS\.has\(data\.name\)/); + const writeSet = bookslmSrc.match(/const FILE_WRITE_TOOLS = new Set\(\[([\s\S]*?)\]\);/); + assert.ok(writeSet, "FILE_WRITE_TOOLS not found"); + for (const tool of ["edit_file", "append_to_file", "create_file", "restore_backup", + "create_xlsx", "create_docx", "create_csv", "create_pdf"]) { + assert.ok(writeSet[1].includes(`'${tool}'`), `${tool} missing from the write tools`); } assert.match(notify[1], /new CustomEvent\('obsigate:file-written'/); }); diff --git a/tests/test_agent_loop.py b/tests/test_agent_loop.py index ca4ab9a..db00d8b 100644 --- a/tests/test_agent_loop.py +++ b/tests/test_agent_loop.py @@ -281,12 +281,13 @@ class TestConfirmationResume: assert assistant_tool_msgs[0]["tool_calls"][0]["id"] == "call_9" @pytest.mark.asyncio - async def test_confirmation_with_parallel_calls_keeps_conversation_valid(self, monkeypatch): - """BUG-050: pausing on one tool call of a batch must answer the others. + async def test_confirmation_batches_all_writes_and_keeps_conversation_valid(self, monkeypatch): + """BUG-075 (was BUG-050): one pause batches every mutating call. The assistant message lists every tool call of the response, so the provider rejects the resumed turn when a ``tool_call_id`` has no tool - result (the "create a folder and a file inside" scenario). + result. Mutating calls of the batch are now all pending (one approval + applies them together); no dangling id remains. """ _register(monkeypatch, "_write", lambda ctx, params: {"done": params}, risk=ToolRisk.WRITE) @@ -297,14 +298,15 @@ class TestConfirmationResume: paused = await run_agent([{"role": "user", "content": "write both"}], ctx=_ctx(), llm=llm1) assert paused.stopped == STOP_CONFIRMATION_REQUIRED assert paused.pending["error"]["id"] == "1" - - # The call that was not reached is answered right away; the pending one - # gets its result on resume, when the user applies it. + # Both mutating calls are batched into the single confirmation. + actions = paused.pending["actions"] + assert [a["id"] for a in actions] == ["1", "2"] + assert actions[0]["step"]["key"] == "generic" + # Nothing is executed nor deferred while waiting for the approval. answered = {m["tool_call_id"] for m in paused.messages if m.get("role") == "tool"} - assert "2" in answered - assert "1" not in answered + assert answered == set() - # Resume: the pending call is applied, the next turn stays valid. + # Resume: both pending calls are applied, the next turn stays valid. llm2 = ScriptedLLM([LLMResponse(content="ok")]) resumed = await run_agent( [{"role": "user", "content": "write both"}], @@ -315,8 +317,8 @@ class TestConfirmationResume: ) assert resumed.stopped == STOP_DONE assert resumed.content == "ok" - assert len(resumed.tool_calls) == 1 - assert resumed.tool_calls[0].ok is True + assert [r.name for r in resumed.tool_calls] == ["_write", "_write"] + assert all(r.ok for r in resumed.tool_calls) # Before the resumed LLM call, every announced tool_call_id is answered. resumed_messages = llm2.calls[0]["messages"] assistant = next( @@ -326,12 +328,31 @@ class TestConfirmationResume: announced = {tc["id"] for tc in assistant["tool_calls"]} answered = {m["tool_call_id"] for m in resumed_messages if m.get("role") == "tool"} assert announced <= answered - # The skipped call is flagged "deferred" so the model can re-issue it. + # No deferred result: the batched calls were approved, not skipped. deferred = [ m for m in resumed_messages if m.get("role") == "tool" and json.loads(m["content"]).get("status") == "deferred" ] - assert [m["tool_call_id"] for m in deferred] == ["2"] + assert deferred == [] + + @pytest.mark.asyncio + async def test_confirmation_runs_read_calls_of_the_batch_immediately(self, monkeypatch): + """Only mutating calls are batched; read-only calls of the turn run now.""" + _register(monkeypatch, "_write", lambda ctx, params: {"done": params}, risk=ToolRisk.WRITE) + _register(monkeypatch, "_read", lambda ctx, params: {"value": 1}) + + llm1 = ScriptedLLM([LLMResponse(tool_calls=[ + ToolCall(id="1", name="_write", arguments={"x": 1}), + ToolCall(id="2", name="_read", arguments={}), + ToolCall(id="3", name="_write", arguments={"x": 3}), + ])]) + paused = await run_agent([{"role": "user", "content": "write and read"}], ctx=_ctx(), llm=llm1) + assert paused.stopped == STOP_CONFIRMATION_REQUIRED + # The read ran while the two writes are pending. + assert [r.name for r in paused.tool_calls] == ["_read"] + assert [a["id"] for a in paused.pending["actions"]] == ["1", "3"] + answered = {m["tool_call_id"] for m in paused.messages if m.get("role") == "tool"} + assert answered == {"2"} class TestAgentPermissions: diff --git a/tests/test_bookslm.py b/tests/test_bookslm.py index b4de409..f969096 100644 --- a/tests/test_bookslm.py +++ b/tests/test_bookslm.py @@ -1065,6 +1065,81 @@ class TestBooksLMAgentEndpoint: assert "C'est fait." in resp2.text assert "event: message" in resp2.text + def test_agent_confirmation_batches_actions_and_confirm_all(self, bookslm_client, monkeypatch): + """BUG-075: one confirmation lists every mutating call of the turn and + ``confirm_all`` authorizes the rest of the run without pausing again.""" + import re + + import backend.bookslm_routes as routes + from backend.ai_chat import LLMResponse, ToolCall + from backend.tools import registry + from backend.tools.api import ToolRisk + from backend.tools.registry import ToolSpec + from backend.tools.schemas import ListVaultsInput + + calls = [] + + def handler(ctx, params): + calls.append(params) + return {"ok": True} + + spec = ToolSpec( + name="_agent_write_batch", + description="write for tests", + input_model=ListVaultsInput, + handler=handler, + risk=ToolRisk.WRITE, + ) + monkeypatch.setitem(registry._REGISTRY, "_agent_write_batch", spec) + + responses = [LLMResponse(tool_calls=[ + ToolCall(id="c1", name="_agent_write_batch", arguments={}), + ToolCall(id="c2", name="_agent_write_batch", arguments={}), + ])] + + async def fake_chat_completion(messages, **kwargs): + return responses.pop(0) + + monkeypatch.setattr(routes, "chat_completion", fake_chat_completion) + monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek") + + token, _ = _login_bookslm(bookslm_client) + payload = {"vault": "TestVault", "directory": "", "message": "crée tout", "mode": "directory"} + resp = bookslm_client.post( + "/api/ai/bookslm/agent", + json=payload, + headers={"Authorization": f"Bearer {token}"}, + ) + assert "event: confirmation" in resp.text + match = re.search(r"event: confirmation\ndata: (.*)", resp.text) + assert match, resp.text + confirmation = json.loads(match.group(1)) + # Both mutating calls are pending, not one. + assert [a["tool"] for a in confirmation["pending"]["actions"]] == [ + "_agent_write_batch", "_agent_write_batch", + ] + assert calls == [], "nothing runs before the approval" + + # Resume with a global approval: the two pending calls run, and a third + # mutating call of the same run runs without a second confirmation. + responses.append(LLMResponse(tool_calls=[ToolCall(id="c3", name="_agent_write_batch", arguments={})])) + responses.append(LLMResponse(content="Terminé.")) + resume_payload = dict( + payload, + confirm=confirmation["pending"], + confirm_messages=confirmation["messages"], + confirm_all=True, + ) + resp2 = bookslm_client.post( + "/api/ai/bookslm/agent", + json=resume_payload, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp2.status_code == 200 + assert "event: confirmation" not in resp2.text, "confirm_all must not pause again" + assert len(calls) == 3, "the whole plan ran in one approval" + assert "Terminé." in resp2.text + def test_agent_message_reports_effective_model(self, bookslm_client, monkeypatch): """The SSE payload carries the model really used, not the raw request.