Files
app/backend/tests/test_ai.py
T
stroblmeandClaude Fable 5.1 cc4091166e
Docs / docs (push) Successful in 42s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m42s
Playwright Tests / test-playwright (2, 2) (push) Failing after 10s
pre-commit / pre-commit (push) Failing after 2m33s
Test Backend / test-backend (push) Failing after 2m48s
Compose Smoke Test / test-compose (push) Failing after 9s
Playwright Tests / merge-reports (push) Failing after 2m17s
Add the assistant: a chat that drives the MCP tools in-process
A provider set under Settings (OpenAI-compatible; key encrypted in
flow-data/ai.json) turns on an Assistant panel in the sidebar. Each turn runs
the FastMCP tools in-process as the person chatting, streams the rounds over
SSE and keeps one transcript per user. The panel floats on any screen and
shares the editor's lane: a settings panel keeps the top half, the chat the
bottom. The MCP client is now built once, for the endpoint and the chat.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 23:28:23 +02:00

139 lines
4.5 KiB
Python

"""The assistant: who may set it up, and that it acts as the person chatting."""
import json
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from fluksio.ai import agent
from fluksio.core.config import settings
@pytest.fixture
def ai_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
monkeypatch.setattr(settings, "AI_CONFIG_FILE", tmp_path / "ai.json")
monkeypatch.setattr(settings, "CHATS_DIR", tmp_path / "chats")
return tmp_path
def _events(response: Any) -> list[dict[str, Any]]:
return [
json.loads(line[5:])
for line in response.iter_lines()
if line.startswith("data:")
]
def test_the_provider_is_a_superuser_setting_and_the_key_stays_sealed(
client: TestClient,
superuser_token_headers: dict[str, str],
normal_user_token_headers: dict[str, str],
ai_files: Path,
) -> None:
assert client.get(
"/api/v1/ai/status", headers=normal_user_token_headers
).json() == {
"available": False,
"model": None,
}
body = {"api_base": "http://llm.test/v1", "api_key": "sk-test-123456", "model": "m"}
refused = client.put(
"/api/v1/ai/config", json=body, headers=normal_user_token_headers
)
assert refused.status_code == 403
saved = client.put("/api/v1/ai/config", json=body, headers=superuser_token_headers)
assert saved.status_code == 200, saved.text
assert saved.json()["api_key_masked"] == "sk-…3456"
assert "sk-test" not in (ai_files / "ai.json").read_text()
assert client.get(
"/api/v1/ai/status", headers=normal_user_token_headers
).json() == {
"available": True,
"model": "m",
}
# A save that names no key keeps the one there is.
kept = client.put(
"/api/v1/ai/config",
json={"api_base": "http://llm.test/v1", "model": "m2"},
headers=superuser_token_headers,
)
assert kept.json()["api_key_set"] is True
assert kept.json()["model"] == "m2"
@pytest.mark.usefixtures("ai_files")
def test_a_turn_runs_the_tools_as_the_person_chatting(
client: TestClient,
superuser_token_headers: dict[str, str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
client.put(
"/api/v1/ai/config",
json={"api_base": "http://llm.test/v1", "api_key": "k", "model": "m"},
headers=superuser_token_headers,
)
seen: list[list[dict[str, Any]]] = []
answers: Iterator[dict[str, Any]] = iter(
[
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "list_flows", "arguments": "{}"},
}
],
},
{"role": "assistant", "content": "Counted them."},
]
)
async def fake_completion(**kwargs: Any) -> dict[str, Any]:
seen.append(kwargs["messages"])
assert any(t["function"]["name"] == "save_flow" for t in kwargs["tools"])
return {"choices": [{"message": next(answers)}]}
monkeypatch.setattr(agent.client, "chat_completion", fake_completion)
with client.stream(
"POST",
"/api/v1/ai/chat",
json={"message": "How many flows are there?"},
headers=superuser_token_headers,
) as response:
assert response.status_code == 200
events = _events(response)
assert [e["type"] for e in events] == [
"start",
"tool_call",
"tool_result",
"message",
"done",
]
# The tool went through the REST API with the caller's own token: an
# unauthenticated call would have come back as an error the model reads.
assert events[2] == {"type": "tool_result", "name": "list_flows", "ok": True}
answered = seen[-1][-1]
assert answered["role"] == "tool"
assert "count" in json.loads(answered["content"])
assert seen[-1][0]["role"] == "system"
transcript = client.get("/api/v1/ai/chat", headers=superuser_token_headers).json()
assert [m["role"] for m in transcript] == ["user", "assistant", "tool", "assistant"]
assert transcript[2] == {
"role": "tool",
"content": None,
"tool_calls": None,
"ok": True,
"error": None,
}
client.delete("/api/v1/ai/chat", headers=superuser_token_headers)
assert client.get("/api/v1/ai/chat", headers=superuser_token_headers).json() == []