feat(ai): phase B function calling in-app (agent loop + endpoint /agent)
CI / lint (push) Successful in 57s
CI / security (push) Successful in 39s
CI / test (push) Successful in 1m13s
CI / build (push) Successful in 36s
CI / e2e (push) Successful in 10m13s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 57s
CI / security (push) Successful in 39s
CI / test (push) Successful in 1m13s
CI / build (push) Successful in 36s
CI / e2e (push) Successful in 10m13s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
- backend/ai_chat.py: chat_completion provider-agnostique (OpenAI-compat tools/tool_calls + Gemini functionDeclarations/functionCall), retry sans tools si rejete - backend/agent/loop.py: run_agent multi-etapes (limite 10, truncation, confirmation two-step), LLM injectable - endpoint opt-in POST /api/ai/bookslm/agent (events SSE tool/message/confirmation), extraction _resolve_system_prompt - tests: test_ai_chat.py, test_agent_loop.py + 3 tests endpoint (728 passed au total) - ROADMAP B1/B2/B3/B7 livres ; B4/B5/B6 restants
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
# 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 pytest
|
||||
|
||||
from backend.agent.loop import (
|
||||
STOP_CONFIRMATION_REQUIRED,
|
||||
STOP_DONE,
|
||||
STOP_MAX_ITERATIONS,
|
||||
run_agent,
|
||||
)
|
||||
from backend.ai_chat import LLMResponse, ToolCall
|
||||
from backend.tools.api import ToolContext, ToolRisk
|
||||
from backend.tools.registry import ToolSpec
|
||||
from backend.tools.schemas import ListVaultsInput
|
||||
|
||||
|
||||
def _ctx(vaults=None) -> ToolContext:
|
||||
return ToolContext(
|
||||
user={"username": "tester", "role": "admin", "vaults": vaults or ["*"]},
|
||||
audit_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
def _register(monkeypatch, name, handler, risk=ToolRisk.READ):
|
||||
from backend.tools import registry
|
||||
|
||||
spec = ToolSpec(
|
||||
name=name,
|
||||
description=f"test tool {name}",
|
||||
input_model=ListVaultsInput,
|
||||
handler=handler,
|
||||
risk=risk,
|
||||
)
|
||||
monkeypatch.setitem(registry._REGISTRY, name, spec)
|
||||
return spec
|
||||
|
||||
|
||||
class ScriptedLLM:
|
||||
"""Async LLM returning pre-scripted responses and recording invocations."""
|
||||
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
self.calls = []
|
||||
|
||||
async def __call__(self, messages, tools):
|
||||
self.calls.append({"messages": [dict(m) for m in messages], "tools": tools})
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Plain completion
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestPlainCompletion:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_tool_calls_returns_content(self):
|
||||
llm = ScriptedLLM([LLMResponse(content="bonjour")])
|
||||
result = await run_agent([{"role": "user", "content": "hi"}], ctx=_ctx(), llm=llm)
|
||||
assert result.stopped == STOP_DONE
|
||||
assert result.content == "bonjour"
|
||||
assert result.iterations == 1
|
||||
assert result.tool_calls == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_empty_disables_tool_calling(self):
|
||||
llm = ScriptedLLM([LLMResponse(content="ok")])
|
||||
await run_agent([{"role": "user", "content": "hi"}], ctx=_ctx(), llm=llm, tools=[])
|
||||
assert llm.calls[0]["tools"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_tools_expose_in_app_tools(self):
|
||||
llm = ScriptedLLM([LLMResponse(content="ok")])
|
||||
await run_agent([{"role": "user", "content": "hi"}], ctx=_ctx(), llm=llm)
|
||||
names = {t["function"]["name"] for t in llm.calls[0]["tools"]}
|
||||
assert "list_vaults" in names
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Tool calling
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestToolCalling:
|
||||
@pytest.mark.asyncio
|
||||
async def test_executes_tool_then_answers(self, monkeypatch):
|
||||
_register(monkeypatch, "_echo", lambda ctx, params: {"echoed": True})
|
||||
llm = ScriptedLLM([
|
||||
LLMResponse(tool_calls=[ToolCall(id="1", name="_echo", arguments={})]),
|
||||
LLMResponse(content="final"),
|
||||
])
|
||||
result = await run_agent([{"role": "user", "content": "go"}], ctx=_ctx(), llm=llm)
|
||||
assert result.stopped == STOP_DONE
|
||||
assert result.content == "final"
|
||||
assert result.iterations == 2
|
||||
assert len(result.tool_calls) == 1
|
||||
assert result.tool_calls[0].ok is True
|
||||
assert result.tool_calls[0].result == {"echoed": True}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_result_is_fed_back(self, monkeypatch):
|
||||
_register(monkeypatch, "_echo", lambda ctx, params: {"value": 42})
|
||||
llm = ScriptedLLM([
|
||||
LLMResponse(tool_calls=[ToolCall(id="1", name="_echo", arguments={})]),
|
||||
LLMResponse(content="done"),
|
||||
])
|
||||
await run_agent([{"role": "user", "content": "go"}], ctx=_ctx(), llm=llm)
|
||||
# Second LLM call must contain a tool message with the result.
|
||||
second_messages = llm.calls[1]["messages"]
|
||||
tool_msgs = [m for m in second_messages if m.get("role") == "tool"]
|
||||
assert len(tool_msgs) == 1
|
||||
assert '"value": 42' in tool_msgs[0]["content"]
|
||||
assert tool_msgs[0]["tool_call_id"] == "1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_tool_recorded_as_error(self):
|
||||
llm = ScriptedLLM([
|
||||
LLMResponse(tool_calls=[ToolCall(id="1", name="does_not_exist", arguments={})]),
|
||||
LLMResponse(content="recovered"),
|
||||
])
|
||||
result = await run_agent([{"role": "user", "content": "go"}], ctx=_ctx(), llm=llm)
|
||||
assert result.stopped == STOP_DONE
|
||||
assert len(result.tool_calls) == 1
|
||||
assert result.tool_calls[0].ok is False
|
||||
assert result.tool_calls[0].result["error"]["code"] == "not_found"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_tool_call_callback(self, monkeypatch):
|
||||
_register(monkeypatch, "_echo", lambda ctx, params: "x")
|
||||
seen = []
|
||||
llm = ScriptedLLM([
|
||||
LLMResponse(tool_calls=[ToolCall(id="1", name="_echo", arguments={})]),
|
||||
LLMResponse(content="done"),
|
||||
])
|
||||
await run_agent([{"role": "user", "content": "go"}], ctx=_ctx(), llm=llm, on_tool_call=seen.append)
|
||||
assert len(seen) == 1
|
||||
assert seen[0].name == "_echo"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Confirmation & limits
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestConfirmationAndLimits:
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_tool_pauses_for_confirmation(self, monkeypatch):
|
||||
_register(monkeypatch, "_write", lambda ctx, params: {"done": True}, risk=ToolRisk.WRITE)
|
||||
llm = ScriptedLLM([LLMResponse(tool_calls=[ToolCall(id="1", name="_write", arguments={})])])
|
||||
result = await run_agent([{"role": "user", "content": "write"}], ctx=_ctx(), llm=llm)
|
||||
assert result.stopped == STOP_CONFIRMATION_REQUIRED
|
||||
assert result.pending is not None
|
||||
assert result.pending["error"]["tool"] == "_write"
|
||||
assert result.tool_calls == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_tool_runs_when_confirmed(self, monkeypatch):
|
||||
_register(monkeypatch, "_write", lambda ctx, params: {"done": True}, risk=ToolRisk.WRITE)
|
||||
llm = ScriptedLLM([
|
||||
LLMResponse(tool_calls=[ToolCall(id="1", name="_write", arguments={})]),
|
||||
LLMResponse(content="done"),
|
||||
])
|
||||
ctx = _ctx()
|
||||
ctx.confirmed = True
|
||||
result = await run_agent([{"role": "user", "content": "write"}], ctx=ctx, llm=llm)
|
||||
assert result.stopped == STOP_DONE
|
||||
assert result.tool_calls[0].ok is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_iterations_stops_loop(self, monkeypatch):
|
||||
_register(monkeypatch, "_echo", lambda ctx, params: "x")
|
||||
llm = ScriptedLLM([
|
||||
LLMResponse(tool_calls=[ToolCall(id="1", name="_echo", arguments={})]),
|
||||
LLMResponse(tool_calls=[ToolCall(id="2", name="_echo", arguments={})]),
|
||||
LLMResponse(content="never reached"),
|
||||
])
|
||||
result = await run_agent([{"role": "user", "content": "loop"}], ctx=_ctx(), llm=llm, max_iterations=2)
|
||||
assert result.stopped == STOP_MAX_ITERATIONS
|
||||
assert result.iterations == 2
|
||||
assert len(result.tool_calls) == 2
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Permissions (index-backed)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestAgentPermissions:
|
||||
@pytest.mark.asyncio
|
||||
async def test_permission_denied_recorded(self, client):
|
||||
llm = ScriptedLLM([
|
||||
LLMResponse(tool_calls=[ToolCall(id="1", name="list_directory", arguments={"vault": "TestVault"})]),
|
||||
LLMResponse(content="denied"),
|
||||
])
|
||||
ctx = ToolContext(user={"username": "limited", "vaults": ["OtherVault"]}, audit_enabled=False)
|
||||
result = await run_agent([{"role": "user", "content": "list"}], ctx=ctx, llm=llm)
|
||||
assert result.tool_calls[0].ok is False
|
||||
assert result.tool_calls[0].result["error"]["code"] == "vault_access_denied"
|
||||
@@ -0,0 +1,201 @@
|
||||
# tests/test_ai_chat.py — Unit tests for provider-agnostic tool calling (Phase B)
|
||||
"""Tests for backend.ai_chat: payload building, parsing, and tools fallback."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from backend import ai_chat
|
||||
from backend.ai_chat import ToolCall, _gemini_tools, _parse_arguments, chat_completion
|
||||
|
||||
OPENAI_CFG = {
|
||||
"name": "deepseek",
|
||||
"api_key": "sk-test-key",
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
"model": "deepseek-chat",
|
||||
"auth_header": "Bearer {api_key}",
|
||||
}
|
||||
|
||||
GEMINI_CFG = {
|
||||
"name": "gemini",
|
||||
"api_key": "gem-test",
|
||||
"base_url": "https://generativelanguage.googleapis.com/v1beta",
|
||||
"model": "gemini-2.0-flash",
|
||||
"auth_header": None,
|
||||
}
|
||||
|
||||
TOOLS = [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read a file",
|
||||
"parameters": {"type": "object", "properties": {"vault": {"type": "string"}}, "required": ["vault"]},
|
||||
},
|
||||
}]
|
||||
|
||||
|
||||
class _Capture:
|
||||
"""Fake ``_post_json`` capturing calls and returning scripted responses."""
|
||||
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
self.calls = []
|
||||
|
||||
async def __call__(self, url, headers, payload):
|
||||
self.calls.append({"url": url, "headers": headers, "payload": payload})
|
||||
result = self.responses.pop(0)
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
|
||||
|
||||
def _http_error(status: int) -> httpx.HTTPStatusError:
|
||||
request = httpx.Request("POST", "https://example.test")
|
||||
response = httpx.Response(status, request=request)
|
||||
return httpx.HTTPStatusError("error", request=request, response=response)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Argument parsing
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestParseArguments:
|
||||
def test_dict_passthrough(self):
|
||||
assert _parse_arguments({"a": 1}) == {"a": 1}
|
||||
|
||||
def test_json_string(self):
|
||||
assert _parse_arguments('{"a": 1}') == {"a": 1}
|
||||
|
||||
def test_invalid_string(self):
|
||||
assert _parse_arguments("not json") == {"_raw": "not json"}
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _parse_arguments("") == {}
|
||||
|
||||
def test_none(self):
|
||||
assert _parse_arguments(None) == {}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# OpenAI-compatible path
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestOpenAIChat:
|
||||
@pytest.mark.asyncio
|
||||
async def test_content_only(self, monkeypatch):
|
||||
monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: OPENAI_CFG)
|
||||
cap = _Capture([{"choices": [{"message": {"content": "bonjour"}}]}])
|
||||
monkeypatch.setattr(ai_chat, "_post_json", cap)
|
||||
|
||||
result = await chat_completion([{"role": "user", "content": "hi"}])
|
||||
assert result.content == "bonjour"
|
||||
assert result.tool_calls == []
|
||||
assert result.provider == "deepseek"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_calls_parsed(self, monkeypatch):
|
||||
monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: OPENAI_CFG)
|
||||
cap = _Capture([{
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": None,
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": '{"vault": "V", "path": "a.md"}'},
|
||||
}],
|
||||
},
|
||||
}],
|
||||
}])
|
||||
monkeypatch.setattr(ai_chat, "_post_json", cap)
|
||||
|
||||
result = await chat_completion([{"role": "user", "content": "hi"}])
|
||||
assert result.content is None
|
||||
assert result.has_tool_calls
|
||||
assert result.tool_calls[0] == ToolCall(id="call_1", name="read_file", arguments={"vault": "V", "path": "a.md"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_included_in_payload(self, monkeypatch):
|
||||
monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: OPENAI_CFG)
|
||||
cap = _Capture([{"choices": [{"message": {"content": "ok"}}]}])
|
||||
monkeypatch.setattr(ai_chat, "_post_json", cap)
|
||||
|
||||
await chat_completion([{"role": "user", "content": "hi"}], tools=TOOLS)
|
||||
payload = cap.calls[0]["payload"]
|
||||
assert payload["tools"] == TOOLS
|
||||
assert payload["tool_choice"] == "auto"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_when_tools_rejected(self, monkeypatch):
|
||||
monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: OPENAI_CFG)
|
||||
cap = _Capture([
|
||||
_http_error(400),
|
||||
{"choices": [{"message": {"content": "sans outils"}}]},
|
||||
])
|
||||
monkeypatch.setattr(ai_chat, "_post_json", cap)
|
||||
|
||||
result = await chat_completion([{"role": "user", "content": "hi"}], tools=TOOLS)
|
||||
assert result.content == "sans outils"
|
||||
assert "tools" in cap.calls[0]["payload"]
|
||||
assert "tools" not in cap.calls[1]["payload"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_error_without_tools_propagates(self, monkeypatch):
|
||||
monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: OPENAI_CFG)
|
||||
cap = _Capture([_http_error(500)])
|
||||
monkeypatch.setattr(ai_chat, "_post_json", cap)
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await chat_completion([{"role": "user", "content": "hi"}])
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Gemini path
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGeminiChat:
|
||||
@pytest.mark.asyncio
|
||||
async def test_content_and_system_instruction(self, monkeypatch):
|
||||
monkeypatch.setitem(ai_chat.PROVIDERS, "gemini", GEMINI_CFG)
|
||||
monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: GEMINI_CFG)
|
||||
cap = _Capture([{"candidates": [{"content": {"parts": [{"text": "salut"}]}}]}])
|
||||
monkeypatch.setattr(ai_chat, "_post_json", cap)
|
||||
|
||||
result = await chat_completion([
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": "hi"},
|
||||
])
|
||||
assert result.content == "salut"
|
||||
assert result.provider == "gemini"
|
||||
payload = cap.calls[0]["payload"]
|
||||
assert payload["system_instruction"]["parts"][0]["text"] == "sys"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_function_call_parsed(self, monkeypatch):
|
||||
monkeypatch.setitem(ai_chat.PROVIDERS, "gemini", GEMINI_CFG)
|
||||
monkeypatch.setattr(ai_chat, "_get_provider_config", lambda provider=None: GEMINI_CFG)
|
||||
cap = _Capture([{
|
||||
"candidates": [{"content": {"parts": [
|
||||
{"functionCall": {"name": "read_file", "args": {"vault": "V", "path": "a.md"}}},
|
||||
]}}],
|
||||
}])
|
||||
monkeypatch.setattr(ai_chat, "_post_json", cap)
|
||||
|
||||
result = await chat_completion([{"role": "user", "content": "hi"}], tools=TOOLS)
|
||||
assert result.tool_calls[0].name == "read_file"
|
||||
assert result.tool_calls[0].arguments == {"vault": "V", "path": "a.md"}
|
||||
# Tools converted to Gemini declarations
|
||||
decls = cap.calls[0]["payload"]["tools"][0]["functionDeclarations"]
|
||||
assert decls[0]["name"] == "read_file"
|
||||
|
||||
|
||||
class TestGeminiToolsConversion:
|
||||
def test_none(self):
|
||||
assert _gemini_tools(None) is None
|
||||
|
||||
def test_conversion(self):
|
||||
converted = _gemini_tools(TOOLS)
|
||||
assert converted[0]["functionDeclarations"][0]["name"] == "read_file"
|
||||
assert converted[0]["functionDeclarations"][0]["description"] == "Read a file"
|
||||
@@ -746,3 +746,80 @@ class TestBooksLMChatModes:
|
||||
)
|
||||
assert resp.status_code in (200, 500), f"unexpected {resp.status_code}: {resp.text[:200]}"
|
||||
assert resp.status_code != 422
|
||||
|
||||
|
||||
# ── Tool-calling agent endpoint ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestBooksLMAgentEndpoint:
|
||||
"""Tests for POST /api/ai/bookslm/agent (Phase B)."""
|
||||
|
||||
def test_agent_requires_auth(self, bookslm_client):
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/agent",
|
||||
json={"vault": "TestVault", "directory": "", "message": "Hello"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_agent_tool_call_flow(self, bookslm_client, monkeypatch):
|
||||
import backend.bookslm_routes as routes
|
||||
from backend.ai_chat import LLMResponse, ToolCall
|
||||
|
||||
responses = [
|
||||
LLMResponse(tool_calls=[ToolCall(id="1", name="list_directory", arguments={"vault": "TestVault"})]),
|
||||
LLMResponse(content="Il y a des fichiers."),
|
||||
]
|
||||
|
||||
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)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/agent",
|
||||
json={"vault": "TestVault", "directory": "", "message": "liste les fichiers", "mode": "directory"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
assert "event: tool" in body
|
||||
assert "list_directory" in body
|
||||
assert "event: message" in body
|
||||
assert "Il y a des fichiers." in body
|
||||
|
||||
def test_agent_pauses_for_confirmation(self, bookslm_client, monkeypatch):
|
||||
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
|
||||
|
||||
spec = ToolSpec(
|
||||
name="_agent_write",
|
||||
description="write for tests",
|
||||
input_model=ListVaultsInput,
|
||||
handler=lambda ctx, params: {"ok": True},
|
||||
risk=ToolRisk.WRITE,
|
||||
)
|
||||
monkeypatch.setitem(registry._REGISTRY, "_agent_write", spec)
|
||||
|
||||
responses = [LLMResponse(tool_calls=[ToolCall(id="1", name="_agent_write", 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)
|
||||
resp = bookslm_client.post(
|
||||
"/api/ai/bookslm/agent",
|
||||
json={"vault": "TestVault", "directory": "", "message": "crée un fichier", "mode": "directory"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "event: confirmation" in resp.text
|
||||
assert "_agent_write" in resp.text
|
||||
|
||||
Reference in New Issue
Block a user