From 634d10cdd468b3493fed6d67795c4ef7e976dc59 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 16 Sep 2026 21:10:13 -0400 Subject: [PATCH] fix(ai): creation dossier+fichier en mode agent (BUG-050) --- CHANGELOG.md | 19 +++++++++++- README.fr.md | 6 ++-- README.md | 6 ++-- VERSION | 2 +- backend/agent/loop.py | 31 +++++++++++++++++++- backend/bookslm.py | 7 +++-- backend/services/mutations.py | 19 ++++++++++-- backend/tools/service.py | 17 ++++++++--- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ISSUES_TODOLIST.md | 4 +++ docs/ROADMAP.md | 2 +- docs/features/ai-tools-mcp.md | 1 + package.json | 2 +- tests/test_agent_loop.py | 55 +++++++++++++++++++++++++++++++++++ tests/test_api_main.py | 6 ++++ tests/test_tools_mutations.py | 7 +++++ 18 files changed, 168 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 729f187..f5e6364 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.6.1**. +> [Unreleased](#unreleased). La dernière version livrée est **2.6.2**. --- @@ -14,6 +14,23 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.6.2] — 2026-09-16 + +### Corrigé + +- **BUG-050 — Assistant IA : échec de la création d'un sous-dossier contenant un fichier** : + lors d'une pause de confirmation, la boucle d'agent ne renvoyait le résultat que du seul + appel confirmé alors que le message assistant annonçait tous les appels d'outils du tour — + la conversation devenait invalide (identifiant `tool_call_id` sans réponse) et la reprise + échouait. Les appels non atteints reçoivent désormais un résultat `deferred` explicite + (`backend/agent/loop.py`). En complément, `create_directory` est idempotent côté outil IA + (succès si le dossier existe déjà, `backend/services/mutations.py`) et les consignes + (`create_file` crée les dossiers parents) invitent le modèle à un seul appel avec un chemin + imbriqué (`backend/bookslm.py`, `backend/tools/service.py`). Tests : `tests/test_agent_loop.py` + (+1), `tests/test_tools_mutations.py` (+1), `tests/test_api_main.py` (+1). + +--- + ## [2.6.1] — 2026-09-16 --- diff --git a/README.fr.md b/README.fr.md index 87fb83b..a30cbed 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.6.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.6.2-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/) @@ -916,8 +916,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.6.1). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.6.2). --- -*Projet : ObsiGate | Version : 2.6.1 | Dernière mise à jour : Juin 2026* +*Projet : ObsiGate | Version : 2.6.2 | Dernière mise à jour : Juin 2026* diff --git a/README.md b/README.md index 9ddc2c1..4ceec1b 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.6.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.6.2-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/) @@ -1085,8 +1085,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.6.1). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.6.2). --- -*Project: ObsiGate | Version: 2.6.1 | Last updated: May 2026* +*Project: ObsiGate | Version: 2.6.2 | Last updated: May 2026* diff --git a/VERSION b/VERSION index 6a6a3d8..097a15a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.6.1 +2.6.2 diff --git a/backend/agent/loop.py b/backend/agent/loop.py index 1dfd607..4ca94b6 100644 --- a/backend/agent/loop.py +++ b/backend/agent/loop.py @@ -109,6 +109,30 @@ def _assistant_tool_message(content: str | None, tool_calls: list[Any]) -> dict[ } +def _deferred_tool_message(call: Any) -> dict[str, Any]: + """Answer a tool call that was not reached because the run paused. + + 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). + """ + return { + "role": "tool", + "tool_call_id": call.id, + "name": call.name, + "content": json.dumps({ + "status": "deferred", + "reason": ( + "Not executed: the run paused to confirm an earlier tool call. " + "Re-issue this call if it is still needed." + ), + }, ensure_ascii=False), + } + + def _execute_confirmed( ctx: ToolContext, confirm_pending: dict[str, Any], @@ -248,7 +272,7 @@ async def run_agent( _emit_note(response.content or "") convo.append(_assistant_tool_message(response.content, response.tool_calls)) - for call in response.tool_calls: + for index, call in enumerate(response.tool_calls): if quota is not None and len(executed) >= quota: logger.warning(f"Agent reached the tool-call quota ({quota})") return AgentResult( @@ -268,6 +292,11 @@ async def run_agent( 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)) return AgentResult( content=response.content or "", messages=convo, diff --git a/backend/bookslm.py b/backend/bookslm.py index 86afc06..6045138 100644 --- a/backend/bookslm.py +++ b/backend/bookslm.py @@ -478,7 +478,9 @@ def build_system_prompt(context: dict[str, Any], scope: str = "directory", vault f"\n\nCes documents appartiennent au vault « {vault_name} ». Quand tu utilises un outil " "d'écriture (`append_to_file`, `edit_file`, `create_file`), passe TOUJOURS " f"exactement `\"vault\": \"{vault_name}\"` (jamais un nom inventé) et un `path` " - "relatif au vault, identique à celui affiché ci-dessus." + "relatif au vault, identique à celui affiché ci-dessus. Pour créer un fichier " + "dans un nouveau dossier, un seul `create_file` avec le chemin complet suffit " + "(les dossiers parents sont créés automatiquement)." ) return prompt @@ -490,7 +492,7 @@ Tes deux rôles : 1. **Aider sur l'application** : expliquer la navigation, la recherche (full-text, filtres `tag:`, `created:`, `path:`), l'éditeur (CodeMirror, autosave, raccourcis), les onglets et le split view, les sauvegardes et la restauration, le partage public, l'export (HTML/Markdown/ePub/PDF), Mermaid, Excalidraw, les plugins, les thèmes, le mode hors-ligne, le MFA, etc. 2. **Proposer des actions concrètes** : créer un fichier ou un dossier dans un vault. -Quand l'utilisateur demande explicitement de créer un fichier, inclus EXACTEMENT un bloc de ce type dans ta réponse (et rien d'autre à l'intérieur du bloc) : +Quand l'utilisateur demande explicitement de créer un fichier, inclus un bloc de ce type dans ta réponse (un bloc par fichier, et rien d'autre à l'intérieur du bloc) : ```obsigate-action {"action": "create_file", "vault": "", "path": "", "content": ""} @@ -506,6 +508,7 @@ Règles : - Ne propose une action que si l'utilisateur la demande explicitement. - Explique en une phrase ce que fait l'action avant le bloc. - Utilise un chemin relatif se terminant par `.md` pour un fichier. +- Pour créer un fichier dans un nouveau dossier, utilise **un seul** bloc `create_file` avec le chemin complet (ex. `"path": "Dossier/fichier.md"`) : les dossiers parents sont créés automatiquement, inutile d'émettre un `create_directory` séparé. - N'invente jamais un nom de vault : utilise l'un des vaults disponibles listés ci-dessous. - Réponds dans la langue de l'utilisateur, de façon concise et structurée (Markdown). """ diff --git a/backend/services/mutations.py b/backend/services/mutations.py index accf6f0..ec08eaa 100644 --- a/backend/services/mutations.py +++ b/backend/services/mutations.py @@ -131,18 +131,33 @@ def create_file( return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)} -def create_directory(vault_name: str, path: str) -> dict[str, Any]: +def create_directory(vault_name: str, path: str, *, exist_ok: bool = False) -> dict[str, Any]: """Create a directory (and its parents) in a vault. + Args: + vault_name: Name of the vault. + path: Vault-relative path of the new directory. + exist_ok: When True, an existing directory is a success (idempotent) + instead of raising ``already_exists``. Used by the AI tool layer so + a "create folder then create file" plan does not fail when the + folder is already there (``create_file`` creates parents anyway). + Raises: ServiceError: ``not_found`` (404), ``read_only`` (403) or - ``already_exists`` (409). + ``already_exists`` (409) when *exist_ok* is False. """ root = get_vault_root(vault_name) _ensure_writable(root) dir_path = resolve_safe_path(root, path) if dir_path.exists(): + if exist_ok and dir_path.is_dir(): + return { + "success": True, + "vault": vault_name, + "path": _rel(root, dir_path), + "existed": True, + } raise ServiceError( f"Directory already exists: {path}", code="already_exists", diff --git a/backend/tools/service.py b/backend/tools/service.py index c7a350e..fe99576 100644 --- a/backend/tools/service.py +++ b/backend/tools/service.py @@ -355,7 +355,12 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]: @tool( name="create_file", - description="Create a new text file in a vault with optional initial content.", + description=( + "Create a new text file in a vault with optional initial content. " + "Parent directories are created automatically, so a single call with a " + "nested path (e.g. 'Folder/note.md') is enough to create a file inside " + "a new folder." + ), input_model=CreateFileInput, risk=ToolRisk.WRITE, requires_vault=True, @@ -367,14 +372,18 @@ def create_file(ctx: ToolContext, params: CreateFileInput) -> dict[str, Any]: @tool( name="create_directory", - description="Create a new directory (and parents) in a vault.", + description=( + "Create a new directory (and parents) in a vault. Succeeds if it " + "already exists. Optional when creating a file: create_file already " + "creates parent directories." + ), input_model=CreateDirectoryInput, risk=ToolRisk.WRITE, requires_vault=True, ) def create_directory(ctx: ToolContext, params: CreateDirectoryInput) -> dict[str, Any]: - """Create a vault directory.""" - return _create_directory(params.vault, params.path) + """Create a vault directory (idempotent).""" + return _create_directory(params.vault, params.path, exist_ok=True) @tool( diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 891dbfc..20e030e 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.6.1" +version = "2.6.2" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index c7608cb..659f3bc 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.6.1" +version = "2.6.2" 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 f7f9db5..4f7ad44 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.6.1", + "version": "2.6.2", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 41a24b9..73ebced 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -157,6 +157,9 @@ Avant de corriger quoi que ce soit, un agent IA doit : | *BUG-045* | [🟡 IMPORTANT] Éditeur « Editer » : deux barres de défilement superposées sur les documents longs | 🟢 corrigé | P1 | 📱 frontend | Éditeur | `frontend/style.css` | Ouvrir un fichier long (ex. IT/Docker Guide.md), cliquer Editer, mesurer `#editor-body` et `.cm-scroller` | `.editor-body-cm` gardait `overflow:auto` et un `.cm-editor{height:100%}` sous la rangée barre d'outils IA → le corps (toolbar+éditeur) ET le scroller CodeMirror débordaient simultanément. L'override global legacy `.cm-scroller{min-height:100%;overflow-y:auto!important}` aggravait. Passé en flex column : corps `overflow:hidden`, toolbar `flex:0 0 auto`, `.cm-editor` `flex:1 1 auto; height:auto`, seul le scroller défile ; override legacy retiré. Test : `tests/frontend/editor-inline.test.mjs` (+1) ; vérifié Playwright sur l'instance de test (un seul conteneur scrollable). | | *BUG-046* | [🔴 BLOQUANT] Assistant IA : « Échec de l'action : [object Object] » à l'application d'un ajout de texte au document courant | 🟢 corrigé | P0 | 📱 frontend + ⚙️ backend | IA | `frontend/js/bookslm.js`, `backend/bookslm.py`, `backend/bookslm_routes.py` | Mode agent : demander d'ajouter du texte au document ouvert puis cliquer « Appliquer » | Trois causes : (1) continuation de confirmation avec `payload:null` → second « Appliquer » sans `message` → 422 ; (2) `new Error(detail)` sur un `detail` tableau d'objets FastAPI → « [object Object] » ; (3) prompt documents/directory sans nom de vault → le modèle inventait `"vault":"test"` → échec silencieux de l'outil. Nouveau `_responseError()` (aplatit tableau/objet), payload porté à la continuation, bloc « Ces documents appartiennent au vault « X » » + consigne outils d'écriture (`build_system_prompt(vault_name=...)`). `SW_VERSION` v20. Tests : `tests/test_bookslm.py::test_vault_name_guidance`, `tests/frontend/ai.test.mjs` (+2). | | *BUG-047* | [🔴 BLOQUANT] La version affichée par l'application ne suit pas les livraisons : 66 commits livrés depuis v2.2.1 et l'UI/API restent bloquées sur `2.2.1` (et les numéros codés en dur divergent : `package.json` 1.0.0, desktop Tauri 2.0.0, `Dockerfile` 2.2.1, README 1.7.0) | 🟢 corrigé | P0 | ⚙️ build + 📄 docs | IA | `VERSION` (nouveau), `scripts/bump_version.py` (nouveau), `.githooks/prepare-commit-msg` + `.githooks/post-commit` (nouveaux), `scripts/install-hooks.sh` (nouveau), `backend/version.py`, `Dockerfile`, `docker-compose.yml`, `build.sh`, `.gitea/workflows/ci.yml`, `desktop/build.rs`, `tests/test_version.py` (nouveau) | `git tag -l \| tail -1` puis `python scripts/bump_version.py --print-version` ; `curl -s http://localhost:2020/api/health \| jq .version` | Le numéro provenait du **dernier tag git** et aucun tag n'était créé aux livraisons (`bump_version.sh` jamais appelé) → version figée, plus quatre numéros codés en dur ailleurs. Corrigé : **`VERSION` (racine) = source unique de vérité**, incrémentée automatiquement à chaque commit par le hook versionné `prepare-commit-msg` (SemVer : `!:`/`BREAKING CHANGE` → MAJEUR, `feat` → MINEUR, sinon CORRECTIF), tag `vX.Y.Z` créé par `post-commit` et publié au push (`push.followTags`) ; `bump_version.py` resynchronise `package.json`, desktop Tauri, ROADMAP, READMEs et fait la rotation du CHANGELOG dans le même commit ; backend, image Docker (`COPY VERSION`) et desktop lisent ce fichier. Contournement ponctuel : `SKIP_VERSION_BUMP=1`. | Garde-fou : `tests/test_version.py::TestRepoVersionAlignment` échoue dès qu'un dérivé diverge de `VERSION`. Vérifié : pytest complet vert, ruff/mypy 0, `/api/health` → `2.3.0` sur l'instance de test. | +| *BUG-048* | [🟡 IMPORTANT] Assistant IA : les entrées « Contextes » et « Skills » du menu « + » n'ouvraient pas leur menu (`@` / `/`) | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/bookslm.js` | Menu « + » de l'assistant → cliquer « Contextes » ou « Skills » | `e.stopPropagation()` sur les entrées du panneau `.bookslm-ext-menu` (le clic remontait au gestionnaire du panneau qui annulait le rendu asynchrone) | Journal 2026-09-16. Tests : `tests/frontend/ai.test.mjs` (+3) | +| *BUG-049* | [🔵 MINEUR] Assistant IA : icône du bouton « + » invisible (largeur SVG nulle) | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/style.css` | Ouvrir l'assistant et observer le bouton « + » | Sélecteur porté à `.bookslm-input-area button.bookslm-btn-plus` (la règle générique `padding: 8px 16px` sur un bouton 32 px annulait la largeur de contenu) | Vérifié navigateur : SVG 0 px → 18 px. Journal 2026-09-16 | +| *BUG-050* | [🟡 IMPORTANT] Assistant IA : échec de la création d'un sous-dossier contenant un fichier (appels d'outils parallèles + confirmation) | 🟢 corrigé | P1 | ⚙️ backend + 🤖 ia | IA | `backend/agent/loop.py`, `backend/services/mutations.py`, `backend/tools/service.py`, `backend/bookslm.py` | Mode Agent : « crée le dossier X et un fichier Y dedans » puis Appliquer | `backend/agent/loop.py` : résultats « deferred » (`_deferred_tool_message`) pour les `tool_calls` non atteints lors d'une pause de confirmation ; `backend/services/mutations.py` : `create_directory(..., exist_ok=True)` ; `backend/tools/service.py` + `backend/bookslm.py` : consignes `create_file` (parents auto-créés, chemin imbriqué unique) | Cause : le message assistant listait plusieurs `tool_calls` mais la pause n'ajoutait le résultat que du seul appel confirmé → conversation invalide (tool_call_id sans réponse) au resume. Tests : `tests/test_agent_loop.py` (+1), `tests/test_tools_mutations.py` (+1), `tests/test_api_main.py` (+1) | | | | | | | | | | | | | ### TODOs techniques (améliorations / nouvelles tâches) @@ -206,6 +209,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-16 | #94, #95, #96, #97 | Feature | `backend/ai_history.py` (nouveau), `backend/bookslm_routes.py`, `frontend/js/bookslm.js`, `frontend/js/config.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `docs/features/ai-assistant-history.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **Assistant IA** : #95 historique **permanent** (backend `data/ai_history/{user}.json`, cap 200, endpoints CRUD `/api/ai/bookslm/history[…]` résumés/full, sync frontend debounced 600 ms + repli localStorage + migration des clés legacy `bookslm-sessions-*`/`bookslm-history-*`, événement `bookslm:history-updated`) ; #96 onglet sidebar `#sidebar-tab-ai` (`messages-square`) + panneau `#sidebar-panel-ai` (liste chronologique, ouverture via `openWithSession`) ; #97 bouton **« + »** remplaçant « Attach an image » + panneau modulaire `.bookslm-ext-menu` (registre `_extensions` : Fichiers, Image, Contextes, Skills, Deep Research = mode agent + prompt, Recherche web & Canva en « Bientôt ») ; #94 bouton d'envoi circulaire + icône Lucide `arrow-up`. Vérifié : pytest 1088 passed / 6 skipped, ruff 0, mypy 0 (71 fichiers), tests frontend IA 84/84, unit 9/9, validate-imports 38 modules. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-16 | BUG-048, #98 | Correction + feature | `frontend/js/bookslm.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `.gitea/workflows/ci.yml`, `tests/frontend/ai.test.mjs`, `tests/frontend/ai-sidebar.test.mjs` (nouveau), `docs/features/ai-assistant-history.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-048** : les entrées « Contextes » et « Skills » du menu « + » ouvraient bien leur menu (`@` / `/`), mais le clic remontait au gestionnaire du panneau qui annulait le rendu asynchrone → menu jamais affiché ; correction par `e.stopPropagation()` sur les entrées du panneau `.bookslm-ext-menu`. **#98** : la barre de filtrage de la sidebar agit désormais sur l'onglet « Historique IA » — `filterAIHistory()` (config.js) filtre par titre, aperçu, répertoire, contexte ou libellé de mode, insensible casse/accents (`_aiNorm`), cache sessions `_aiSessionsCache`, message « aucune correspondance » (`bookslm.history_no_match`) dans la liste et placeholder dédié (`sidebar.filter_ai`) ; `initSidebarFilter` (sidebar.js) route saisie/touche casse/bouton « × » vers `filterAIHistory` quand l'onglet IA est actif ; chaque entrée du panneau « + » porte l'icône Lucide `plus`. Vérifié : tests frontend IA 87/87 (+3), nouvelle suite `ai-sidebar` 6/6, unit 9/9, 9 suites JSDOM vertes, validate-imports 38 modules, pytest / ruff / mypy inchangés (aucune modification backend). | 🟢 corrigé (en attente vérif utilisateur) — CI Gitea verte (lint, test, security, build, e2e) pour v2.5.0 (run #1511) | | 2026-09-16 | BUG-049, #99, #100 | Correction + feature | `frontend/style.css`, `frontend/js/config.js`, `frontend/js/viewer.js`, `frontend/js/sidebar.js`, `frontend/js/bookslm.js`, `frontend/locales/{fr,en}.json`, `.gitea/workflows/ci.yml`, `tests/frontend/ai.test.mjs`, `tests/frontend/sidebar-filters.test.mjs` (nouveau), `docs/features/sidebar-filters.md` (nouvelle), `docs/features/ai-assistant-history.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-049** : icône du bouton « + » de l'assistant invisible — la règle générique `.bookslm-input-area button` (spécificité supérieure) imposait `padding: 8px 16px` sur un bouton `width: 32px` ⇒ largeur de contenu nulle ⇒ SVG `width: 0px` ; sélecteur porté à `.bookslm-input-area button.bookslm-btn-plus` (+ `:hover`), vérifié en navigateur (Playwright : SVG 0 px → 18 px). **#99** : la barre de filtrage de la sidebar agit désormais sur les vues **Récents** (`filterRecentFiles`, titre/chemin/vault/aperçu/tags) et **Sauvegardes** (`filterSavedSearches`, cumulable avec les pills type), insensible casse/accents (`_sidebarNorm`/`_savedNorm`), message d'absence de résultat (`sidebar.no_results`) et placeholders dédiés (`sidebar.filter_recent`, `sidebar.filter_saved`) ; `initSidebarFilter` refactoré en `routeFilter`/`routeClear` couvrant les 5 onglets. **#100** : « Deep Research » ajoute une **pastille** `.bookslm-chip-deep-research` (au lieu d'injecter la directive dans le composeur), active le mode Agent et injecte la directive au moment de l'envoi. Vérifié : tests frontend IA 88/88 (+1), `sidebar-filters` 8/8 (nouveau), `ai-sidebar` 6/6, unit 9/9, 9 suites JSDOM vertes, validate-imports 38 modules, vérification navigateur du bouton « + » ; backend inchangé (pytest / ruff / mypy valides). | 🟢 corrigé (en attente vérif utilisateur) | +| 2026-09-16 | BUG-050 | Correction | `backend/agent/loop.py`, `backend/services/mutations.py`, `backend/tools/service.py`, `backend/bookslm.py`, `tests/test_agent_loop.py`, `tests/test_tools_mutations.py`, `tests/test_api_main.py`, `docs/features/ai-tools-mcp.md`, `CHANGELOG.md` | **BUG-050** : création d'un sous-dossier contenant un fichier en mode Agent. (1) La boucle d'agent renvoyait la conversation sans réponse pour les `tool_calls` non atteints lorsqu'un appel mutateur déclenchait une confirmation → le provider rejetait le tour de reprise (« tool_call_id » orphelin) ; les appels restants reçoivent désormais un résultat `deferred` explicite (`_deferred_tool_message`) que le modèle réémet après confirmation. (2) `create_directory` est idempotent côté outil IA (`exist_ok=True`, succès si le dossier existe), le REST restant strict (409). (3) Consignes renforcées : `create_file` crée les dossiers parents, un seul appel avec chemin imbriqué suffit (`backend/bookslm.py`, descriptions d'outils). Vérifié : pytest 1091 passed / 6 skipped, ruff 0 (backend), mypy 0 (71 fichiers), tests frontend validate-imports 38 modules + unit 9/9. | 🟢 corrigé (en attente vérif utilisateur) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 63a3491..195abad 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.6.1 | **Dernière mise à jour :** 2026-09-16 +> **Version :** 2.6.2 | **Dernière mise à jour :** 2026-09-16 > **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 85a6fd7..80df9a2 100644 --- a/docs/features/ai-tools-mcp.md +++ b/docs/features/ai-tools-mcp.md @@ -83,4 +83,5 @@ - ✅ Transport MCP : **Streamable HTTP** (2026-09-11) - ✅ Confirmation MCP : **two-step `propose`/`apply`** (2026-09-11) - ✅ Périmètre des mutations externes : **toutes autorisées** (create/edit/rename/move/delete) — encadrées par confirmation + backup auto + audit + toggle par vault (2026-09-11) + - ✅ Confirmation d'un lot d'appels (BUG-050) : quand un tour contient plusieurs appels d'outils et qu'un seul est mutateur, les appels non atteints reçoivent un résultat `deferred` pour préserver la validité du protocole tool-calling ; ils sont réémis après confirmation (2026-09-16) - Détail et justification dans le [guide §8](../AI_ARCHITECTURE_GUIDE.md). diff --git a/package.json b/package.json index b13f008..933cb7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.6.1", + "version": "2.6.2", "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/test_agent_loop.py b/tests/test_agent_loop.py index 04d8e88..ece9a94 100644 --- a/tests/test_agent_loop.py +++ b/tests/test_agent_loop.py @@ -1,6 +1,8 @@ # tests/test_agent_loop.py — Unit tests for the in-app agent loop (Phase B) """Tests for backend.agent.loop.run_agent using a scripted (mocked) LLM.""" +import json + import pytest from backend.agent.loop import ( @@ -244,6 +246,59 @@ class TestConfirmationResume: assert len(assistant_tool_msgs) == 1 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. + + 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). + """ + _register(monkeypatch, "_write", lambda ctx, params: {"done": params}, risk=ToolRisk.WRITE) + + llm1 = ScriptedLLM([LLMResponse(tool_calls=[ + ToolCall(id="1", name="_write", arguments={"x": 1}), + ToolCall(id="2", name="_write", arguments={"x": 2}), + ])]) + 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. + answered = {m["tool_call_id"] for m in paused.messages if m.get("role") == "tool"} + assert "2" in answered + assert "1" not in answered + + # Resume: the pending call is applied, the next turn stays valid. + llm2 = ScriptedLLM([LLMResponse(content="ok")]) + resumed = await run_agent( + [{"role": "user", "content": "write both"}], + ctx=_ctx(), + llm=llm2, + resume_messages=paused.messages, + confirm_pending=paused.pending, + ) + assert resumed.stopped == STOP_DONE + assert resumed.content == "ok" + assert len(resumed.tool_calls) == 1 + assert resumed.tool_calls[0].ok is True + # Before the resumed LLM call, every announced tool_call_id is answered. + resumed_messages = llm2.calls[0]["messages"] + assistant = next( + m for m in resumed_messages + if m.get("role") == "assistant" and m.get("tool_calls") + ) + 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. + 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"] + class TestAgentPermissions: @pytest.mark.asyncio diff --git a/tests/test_api_main.py b/tests/test_api_main.py index a5cfa04..97cc211 100644 --- a/tests/test_api_main.py +++ b/tests/test_api_main.py @@ -271,6 +271,12 @@ class TestDirectoryCRUD: data = resp.json() assert data["success"] is True + def test_create_directory_existing_conflicts(self, client): + """The REST endpoint stays strict: an existing folder is a 409.""" + client.post("/api/directory/TestVault", json={"path": "Dup"}) + resp = client.post("/api/directory/TestVault", json={"path": "Dup"}) + assert resp.status_code == 409 + def test_rename_directory(self, client): # Create first client.post("/api/directory/TestVault", json={"path": "OldName"}) diff --git a/tests/test_tools_mutations.py b/tests/test_tools_mutations.py index 2d1ef3d..ad794e2 100644 --- a/tests/test_tools_mutations.py +++ b/tests/test_tools_mutations.py @@ -143,6 +143,13 @@ class TestCreate: assert result.ok assert (_vault_path() / "A" / "B").is_dir() + def test_create_directory_idempotent(self, client): + """BUG-050: re-creating an existing folder is a success for the AI layer.""" + call_tool("create_directory", _ctx(), {"vault": "TestVault", "path": "Idem/Dir"}, confirm=True) + second = call_tool("create_directory", _ctx(), {"vault": "TestVault", "path": "Idem/Dir"}, confirm=True) + assert second.ok + assert second.data.get("existed") is True + # ═══════════════════════════════════════════════════════════════════ # D2. Edit / append / rename / move