The planning package had grown to where a new agent could not tell what was authoritative. Phase 0 execution prompts sat beside the specification; four completed milestone reports sat beside the current one; and upstream AI-DnD's own `plan/` build log and `docs/` project site still described a hosted, scripted, multi-user product with accounts — every screenshot in it showed a Scripts tab and a Sign up button, none of which has existed since M2. `planning/archive/` now holds the history and says so in its own README: `phase0/` for the research that chose AI-DnD, `milestone-reports/` for M1 and M2, `decisions/` for ADR 008, the Phase-0-before-build gate Phase 0 satisfied. `planning/reports/` holds only the current milestone's report, because that is the one M4 planning has to read; it moves to the archive when M4's replaces it. Deleted rather than archived: the Phase 0B execution prompts and the handoff/status/summary documents, the Phase 0A discovery and triage reports, upstream's `plan/` and `docs/` trees, and `frontend/README.md`, which was Vite's template boilerplate. All of it is in Git history, and the two recommendation reports carry every conclusion the deleted research reached. Archived documents are kept verbatim. Paths written inside them point at where those files were when the document was written, which is the point: an evidence record that has been quietly edited is no longer evidence. Active documentation is corrected where it pointed at the removed trees or described removed capability as present. `DEVELOPMENT.md`'s "things M1 did not touch" list had gone stale at M2 and claimed QuickJS scripting was still tested; its test count was 604 against an actual 638. `README.md` loses the upstream CI badge, which reported upstream's pipeline rather than this fork's, and a reference to `backend/app/worldstate/engine.py`, a file that does not exist. `planning/README.md` is rewritten as the documentation index. New: `planning/PROJECT-SOURCES.md` and `planning/project-sources.txt`, the manifest of what belongs in the ChatGPT project's Sources. Source comments referring to the deleted trees are reworded; no behaviour changes. 638 backend tests pass, frontend lints and builds, and a reference scan over all 48 tracked Markdown files reports no unresolved path in active documentation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NCbwH7yLGKsj1rhXXzKSCu
127 lines
4.1 KiB
Python
127 lines
4.1 KiB
Python
"""HTTP tests for the AI Chat scratchpad.
|
|
|
|
Most of this file used to be about the shared demo key: an access gate on a
|
|
"power user" email allowlist, and a pinning rule that stopped a public visitor
|
|
reaching paid models on a server-funded key. M2 removed the hosted deployment
|
|
those defended, so the rules they tested no longer exist to be tested. See
|
|
`planning/archive/milestone-reports/M2-*` for the accounting.
|
|
|
|
What remains is what the page still does: stream a reply from the configured
|
|
model, honour a system prompt and a per-request model override, and refuse a
|
|
conversation too large to send.
|
|
|
|
python -m pytest tests/test_chat.py -v
|
|
"""
|
|
import pytest
|
|
from fastapi import Depends
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app import auth, models
|
|
from app.database import Base, SessionLocal, engine, get_db
|
|
from app.main import app
|
|
from app.routers import chat
|
|
|
|
|
|
class FakeProvider:
|
|
"""Records what it was constructed with, then streams a fixed reply.
|
|
|
|
It stands in for the real egress point, so asserting on `last_endpoint` and
|
|
`last_model` is asserting on exactly what would have gone over the wire.
|
|
There is no `last_key` any more: the provider takes no API key, because
|
|
Ollama does not use one.
|
|
"""
|
|
last_usage = None
|
|
last_model = None
|
|
last_endpoint = None
|
|
last_messages = None
|
|
|
|
def __init__(self, endpoint_url, model, api_mode="chat", read_timeout=None):
|
|
FakeProvider.last_model = model
|
|
FakeProvider.last_endpoint = endpoint_url
|
|
|
|
async def chat(self, messages, *, temperature, max_tokens):
|
|
FakeProvider.last_messages = messages
|
|
yield ("reasoning", "hmm")
|
|
yield ("text", "Hello back.")
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(monkeypatch):
|
|
Base.metadata.create_all(bind=engine)
|
|
setup = SessionLocal()
|
|
user = models.User(is_guest=False)
|
|
setup.add(user)
|
|
setup.flush()
|
|
setup.add(models.Settings(user_id=user.id, model="test-model"))
|
|
setup.commit()
|
|
user_id = user.id
|
|
setup.close()
|
|
|
|
monkeypatch.setattr(chat, "OpenAICompatibleProvider", FakeProvider)
|
|
|
|
def _current_user(db=Depends(get_db)):
|
|
return db.get(models.User, user_id)
|
|
|
|
app.dependency_overrides[auth.get_current_user] = _current_user
|
|
c = TestClient(app)
|
|
try:
|
|
yield c
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
Base.metadata.drop_all(bind=engine)
|
|
|
|
|
|
def _send(client, **body):
|
|
payload = {"messages": [{"role": "user", "content": "hi"}]}
|
|
payload.update(body)
|
|
return client.post("/api/chat/stream", json=payload)
|
|
|
|
|
|
def test_the_page_streams_a_reply(client):
|
|
resp = _send(client)
|
|
assert resp.status_code == 200, resp.text
|
|
assert '"type": "reasoning"' in resp.text
|
|
assert "Hello back." in resp.text
|
|
assert '"type": "done"' in resp.text
|
|
assert FakeProvider.last_messages == [{"role": "user", "content": "hi"}]
|
|
|
|
|
|
def test_it_uses_the_configured_endpoint_and_model(client):
|
|
_send(client)
|
|
assert FakeProvider.last_model == "test-model"
|
|
# The default from `models.Settings`, and the only kind of address the
|
|
# endpoint policy allows without configuration.
|
|
assert FakeProvider.last_endpoint == "http://localhost:11434/v1"
|
|
|
|
|
|
def test_system_prompt_and_model_override_are_honoured(client):
|
|
resp = client.post("/api/chat/stream", json={
|
|
"messages": [
|
|
{"role": "system", "content": "Be terse."},
|
|
{"role": "user", "content": "hi"},
|
|
],
|
|
"model": "some-other-model",
|
|
})
|
|
assert resp.status_code == 200, resp.text
|
|
assert FakeProvider.last_model == "some-other-model"
|
|
assert FakeProvider.last_messages[0] == {"role": "system", "content": "Be terse."}
|
|
|
|
|
|
def test_a_request_with_no_model_anywhere_is_refused(client):
|
|
db = SessionLocal()
|
|
try:
|
|
settings = db.query(models.Settings).first()
|
|
settings.model = ""
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
assert _send(client).status_code == 400
|
|
|
|
|
|
def test_oversized_conversation_is_refused(client):
|
|
huge = "x" * 90_000
|
|
resp = client.post("/api/chat/stream", json={
|
|
"messages": [{"role": "user", "content": huge} for _ in range(5)],
|
|
})
|
|
assert resp.status_code == 413, resp.text
|