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
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>
139 lines
4.5 KiB
Python
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() == []
|