fix(ai): creation dossier+fichier en mode agent (BUG-050)
CI / lint (push) Successful in 1m32s
CI / security (push) Successful in 1m7s
CI / test (push) Successful in 3m6s
CI / build (push) Successful in 55s
CI / e2e (push) Successful in 10m48s

This commit is contained in:
2026-09-16 21:10:38 -04:00
parent 0d4f43a8bf
commit 634d10cdd4
18 changed files with 168 additions and 22 deletions
+55
View File
@@ -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
+6
View File
@@ -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"})
+7
View File
@@ -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