Two reports against 1.6.0 and 1.7.0, both correct. The crowd worked end to end and was, in practice, not there: the picker was behind the ⋯ menu of a chat that already existed, and the switch was a card on the Agents page, which made it read as an agent-chat feature. The picker is now a button in the composer toolbar on both screens that include it, and on the new-chat screen the choice rides along with the first message, so a chat can start as a crowd instead of having to be converted into one. The instance switch has its own page. The width bug was the suggestion cards, exactly as reported. `.suggestions` rendered 455px inside a 366px column, and the tree's standing rule applied on its own made it worse -- 428px to 455px. A grid item carries `min-width: auto`, which is a min-content floor, and a floor beats `width: 100%`; the floor is measured while the percentage is indefinite, so `min(100%, …)` alone sends the track to a card's max-content. Both halves now go on all four auto-fit grids, and a test refuses either alone. It survived four releases of narrow-width checking because the harness never rendered that screen: `TestClient(app)` runs no lifespan outside a `with` block, so the startup-seeded cards were missing from every shot ever taken of it. And its overflow check skipped anything inside a scroller -- right for a table in its own scroller, blind to the scroller itself, which `overflow-y: auto` makes scroll sideways too. Both fixed; it now names the box and the child to blame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
324 lines
11 KiB
Python
324 lines
11 KiB
Python
"""Choosing a crowd, and reading one.
|
|
|
|
Two screens and one rule each. The picker may only ever offer and accept models
|
|
*this person* can reach — a control checked in the template and not in the route is
|
|
advisory, and a crafted request walks past it. The transcript has to say which
|
|
speaker a bubble is and which pass it belongs to, because nine bubbles for one
|
|
question are otherwise indistinguishable from nine people talking at once.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from lembas.db.models import (
|
|
ROLE_ASSISTANT,
|
|
ROLE_USER,
|
|
Chat,
|
|
Connection,
|
|
Group,
|
|
Model,
|
|
User,
|
|
)
|
|
from lembas.services import chat as chat_service
|
|
from lembas.services import crowd as crowd_service
|
|
from lembas.services import settings_store
|
|
from lembas.services.crypto import encrypt
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def crowd_on(db, registered):
|
|
settings_store.update(db, {"enabled": True}, key=settings_store.CROWD)
|
|
connection = Connection(
|
|
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
|
|
)
|
|
db.add(connection)
|
|
db.commit()
|
|
for index, name in enumerate(("main-model", "second-model", "third-model")):
|
|
db.add(
|
|
Model(
|
|
connection_id=connection.id,
|
|
model_id=name,
|
|
display_name=name,
|
|
position=index,
|
|
capabilities_json={"tools": True},
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
|
|
def _user(db) -> User:
|
|
return db.scalars(select(User).order_by(User.created_at)).first()
|
|
|
|
|
|
def _chat(db) -> Chat:
|
|
connection = db.scalars(select(Connection)).first()
|
|
chat = Chat(
|
|
user_id=_user(db).id, title="t", model_id="main-model", connection_id=connection.id
|
|
)
|
|
db.add(chat)
|
|
db.commit()
|
|
return chat
|
|
|
|
|
|
def _members(db, chat) -> list[str]:
|
|
db.expire_all()
|
|
return [
|
|
row.model_id
|
|
for row in sorted(db.get(Chat, chat.id).crowd, key=lambda r: r.position)
|
|
]
|
|
|
|
|
|
# --- Reachable where somebody would look --------------------------------------
|
|
#
|
|
# The feature shipped in 1.6.0 switched on and unreachable: the only control was
|
|
# inside the Chat settings panel, behind the ⋯ menu, in a chat that already
|
|
# existed. The owner enabled it, went looking, and reported that there was nothing
|
|
# to find. A control nobody can find is a feature nobody has, so these assert the
|
|
# two places it has to be rather than the one place it was.
|
|
def test_the_composer_offers_the_crowd_in_a_chat(client, db):
|
|
"""Beside the tool switches, where the comparable decisions are."""
|
|
chat = _chat(db)
|
|
page = client.get(f"/chat/{chat.id}").text
|
|
assert 'name="crowd_model_ids"' in page
|
|
assert 'form="crowd-form"' in page
|
|
assert '<form id="crowd-form">' in page
|
|
|
|
|
|
def test_the_composer_offers_the_crowd_before_the_chat_exists(client, db):
|
|
"""On the new-chat screen there is no row to attach anybody to, so the choice
|
|
rides along with the first message — the mechanism the scope switches use."""
|
|
page = client.get("/chat").text
|
|
assert 'name="crowd_model_ids"' in page
|
|
# Riding along, so no sibling form and no PATCH: the composer's own POST
|
|
# carries it.
|
|
assert '<form id="crowd-form">' not in page
|
|
assert 'value="second-model"' in page
|
|
|
|
|
|
def test_starting_a_chat_with_a_crowd_keeps_it(client, db):
|
|
"""The end of that path: the first message creates the chat *and* its crowd."""
|
|
from sqlalchemy import select as sa_select
|
|
|
|
client.post(
|
|
"/api/chats/start",
|
|
data={
|
|
"content": "Who is right?",
|
|
"model_id": "main-model",
|
|
"crowd_model_ids": ["", "second-model", "third-model"],
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
db.expire_all()
|
|
chat = db.scalars(sa_select(Chat).order_by(Chat.created_at.desc())).first()
|
|
assert [row.model_id for row in sorted(chat.crowd, key=lambda r: r.position)] == [
|
|
"second-model",
|
|
"third-model",
|
|
]
|
|
|
|
|
|
def test_starting_a_chat_refuses_a_model_the_person_cannot_reach(client, db):
|
|
"""The same rule as the panel, in the same one function, so there is nowhere
|
|
for the two to disagree."""
|
|
from sqlalchemy import select as sa_select
|
|
|
|
group = Group(name="Wheel")
|
|
db.add(group)
|
|
restricted = db.scalar(select(Model).where(Model.model_id == "third-model"))
|
|
restricted.public = False
|
|
restricted.groups = [group]
|
|
user = _user(db)
|
|
user.role = "user"
|
|
db.commit()
|
|
|
|
client.post(
|
|
"/api/chats/start",
|
|
data={
|
|
"content": "Who is right?",
|
|
"model_id": "main-model",
|
|
"crowd_model_ids": ["second-model", "third-model"],
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
db.expire_all()
|
|
chat = db.scalars(sa_select(Chat).order_by(Chat.created_at.desc())).first()
|
|
assert [row.model_id for row in chat.crowd] == ["second-model"]
|
|
|
|
|
|
def test_the_composer_control_is_absent_while_the_feature_is_off(client, db):
|
|
settings_store.update(db, {"enabled": False}, key=settings_store.CROWD)
|
|
assert 'name="crowd_model_ids"' not in client.get("/chat").text
|
|
|
|
|
|
# --- Choosing -----------------------------------------------------------------
|
|
def test_the_panel_offers_the_other_models(client, db):
|
|
chat = _chat(db)
|
|
page = client.get(f"/chat/{chat.id}").text
|
|
assert 'name="crowd_model_ids"' in page
|
|
assert 'name="crowd_model_ids" value="second-model"' in page
|
|
# Never the chat's own model: it would answer twice in a row. Asserted with
|
|
# the field name attached, because the model *picker* on the same page quite
|
|
# correctly offers it.
|
|
assert 'name="crowd_model_ids" value="main-model"' not in page
|
|
|
|
|
|
def test_the_panel_is_absent_while_the_feature_is_off(client, db):
|
|
settings_store.update(db, {"enabled": False}, key=settings_store.CROWD)
|
|
chat = _chat(db)
|
|
assert 'name="crowd_model_ids"' not in client.get(f"/chat/{chat.id}").text
|
|
|
|
|
|
def test_ticking_a_model_adds_it_in_order(client, db):
|
|
chat = _chat(db)
|
|
client.patch(
|
|
f"/api/chats/{chat.id}",
|
|
data={"crowd_model_ids": ["second-model", "third-model"]},
|
|
)
|
|
assert _members(db, chat) == ["second-model", "third-model"]
|
|
|
|
|
|
def test_clearing_every_box_clears_the_crowd(client, db):
|
|
"""The single field always sent is what makes this possible: an absent
|
|
checkbox carries no signal of its own."""
|
|
chat = _chat(db)
|
|
client.patch(f"/api/chats/{chat.id}", data={"crowd_model_ids": ["second-model"]})
|
|
assert _members(db, chat) == ["second-model"]
|
|
|
|
client.patch(f"/api/chats/{chat.id}", data={"crowd_model_ids": [""]})
|
|
assert _members(db, chat) == []
|
|
|
|
|
|
def test_a_model_this_person_cannot_reach_is_refused(client, db):
|
|
"""Checked in the route, not only in the template. Otherwise the picker is
|
|
advisory."""
|
|
group = Group(name="Wheel")
|
|
db.add(group)
|
|
restricted = db.scalar(select(Model).where(Model.model_id == "third-model"))
|
|
restricted.public = False
|
|
restricted.groups = [group]
|
|
user = _user(db)
|
|
user.role = "user"
|
|
db.commit()
|
|
chat = _chat(db)
|
|
|
|
client.patch(
|
|
f"/api/chats/{chat.id}",
|
|
data={"crowd_model_ids": ["second-model", "third-model"]},
|
|
)
|
|
|
|
assert _members(db, chat) == ["second-model"]
|
|
|
|
|
|
def test_the_chats_own_model_cannot_be_added(client, db):
|
|
chat = _chat(db)
|
|
client.patch(f"/api/chats/{chat.id}", data={"crowd_model_ids": ["main-model"]})
|
|
assert _members(db, chat) == []
|
|
|
|
|
|
def test_the_same_model_twice_is_one_member(client, db):
|
|
chat = _chat(db)
|
|
client.patch(
|
|
f"/api/chats/{chat.id}", data={"crowd_model_ids": ["second-model", "second-model"]}
|
|
)
|
|
assert _members(db, chat) == ["second-model"]
|
|
|
|
|
|
def test_the_cap_trims_what_is_accepted(client, db):
|
|
settings_store.update(db, {"max_models": 1}, key=settings_store.CROWD)
|
|
chat = _chat(db)
|
|
client.patch(
|
|
f"/api/chats/{chat.id}", data={"crowd_model_ids": ["second-model", "third-model"]}
|
|
)
|
|
assert _members(db, chat) == ["second-model"]
|
|
|
|
|
|
def test_the_panel_says_what_a_turn_will_cost(client, db):
|
|
"""The thing somebody will not have thought about: a turn is
|
|
speakers x rounds x 2 - 1 replies, and each is a whole reply."""
|
|
chat = _chat(db)
|
|
client.patch(
|
|
f"/api/chats/{chat.id}", data={"crowd_model_ids": ["second-model", "third-model"]}
|
|
)
|
|
page = client.get(f"/chat/{chat.id}").text
|
|
assert "5 replies a turn" in page
|
|
|
|
|
|
def test_a_member_that_can_no_longer_be_reached_is_shown_struck_through(client, db):
|
|
"""Membership is text with no foreign key, so the row outlives the model. Saying
|
|
so beats both deleting it and pretending it still answers."""
|
|
chat = _chat(db)
|
|
client.patch(f"/api/chats/{chat.id}", data={"crowd_model_ids": ["third-model"]})
|
|
gone = db.scalar(select(Model).where(Model.model_id == "third-model"))
|
|
db.delete(gone)
|
|
db.commit()
|
|
|
|
page = client.get(f"/chat/{chat.id}").text
|
|
|
|
assert "Skipped" in page
|
|
assert "<s>third-model</s>" in page
|
|
|
|
|
|
# --- Reading ------------------------------------------------------------------
|
|
def _bubble(db, chat, *, phase, index=1, of=3, stopped="", round_=1) -> str:
|
|
from lembas.api import chats as chats_api
|
|
|
|
chat_service.create_message(db, chat, ROLE_USER, "What should we do?")
|
|
message = chat_service.create_message(
|
|
db, chat, ROLE_ASSISTANT, "Something.", model_id="second-model"
|
|
)
|
|
message.crowd_json = crowd_service.Turn(
|
|
turn="u1", round=round_, phase=phase, index=index, of=of,
|
|
started_at=crowd_service.now_stamp(), stopped=stopped,
|
|
).as_json()
|
|
db.commit()
|
|
return chats_api._render_bubble(db, chat, _user(db), message)
|
|
|
|
|
|
def test_a_bubble_on_the_way_out_says_which_speaker_it_is(db):
|
|
chat = _chat(db)
|
|
html = _bubble(db, chat, phase=crowd_service.PHASE_OUT, index=1, of=3)
|
|
assert "2 of 3" in html
|
|
|
|
|
|
def test_a_bubble_on_the_way_back_says_so_and_is_quieter(db):
|
|
chat = _chat(db)
|
|
html = _bubble(db, chat, phase=crowd_service.PHASE_BACK)
|
|
assert "on the way back" in html
|
|
assert "msg--crowd-back" in html
|
|
|
|
|
|
def test_the_closing_bubble_says_it_is_closing(db):
|
|
chat = _chat(db)
|
|
html = _bubble(db, chat, phase=crowd_service.PHASE_CLOSE, index=0)
|
|
assert "closing" in html
|
|
|
|
|
|
def test_a_later_round_is_numbered(db):
|
|
chat = _chat(db)
|
|
html = _bubble(db, chat, phase=crowd_service.PHASE_OUT, round_=2)
|
|
assert "round 2" in html
|
|
|
|
|
|
def test_why_a_round_ended_is_shown_where_it_ended(db):
|
|
"""Otherwise a crowd that ran out of rounds or time simply stops, which reads
|
|
as the feature failing rather than as a limit doing its job."""
|
|
chat = _chat(db)
|
|
assert "no rounds left" in _bubble(
|
|
db, chat, phase=crowd_service.PHASE_CLOSE, stopped=crowd_service.STOPPED_ROUNDS
|
|
)
|
|
|
|
|
|
def test_an_ordinary_bubble_carries_no_crowd_chip(db):
|
|
from lembas.api import chats as chats_api
|
|
|
|
chat = _chat(db)
|
|
chat_service.create_message(db, chat, ROLE_USER, "Hello")
|
|
message = chat_service.create_message(
|
|
db, chat, ROLE_ASSISTANT, "Hello back.", model_id="main-model"
|
|
)
|
|
html = chats_api._render_bubble(db, chat, _user(db), message)
|
|
assert "of 3" not in html
|
|
assert "msg--crowd-back" not in html
|
|
assert "closing" not in html
|