00ce04addf
Six things, all found by using the thing rather than by reading it. The scope menu only appeared once a chat existed, on the reasoning that there was no row to post to. True, and the wrong conclusion: the harness puts a tool's guidance in front of the model the moment the tool is offered, so the menu could not be reached until after the model had been told how to keep notes and handed the tools to do it -- and switching it off then does not un-send that turn. It is on the new-chat screen now and writes nothing: `_scope_context` builds a stand-in Chat, which is `draft.as_chat`'s trick again, and the switches ride along with the first message. Checked means on and a browser submits only the ticked boxes, so every gate also renders a hidden input naming it and `start_chat` subtracts one list from the other; inverting the control would read backwards under a menu that says everything is on unless you say otherwise. Only the off ones are written, because absent means on and one representation of it is what keeps "why is this off?" to a single answer. Nothing is validated against the offered set, since scope_json narrows after every gate -- naming a gate that was never offered switches off something that was not on. Then the scheduling instructions, audited against a 4B model on this machine rather than against my own reading of them. Ten realistic requests, ten compiled, twice over -- so the prompt is sound. What was not sound was `describe`, which built a phrase by joining fragments and read "Every the 1st at 09:00" for the commonest monthly schedule there is, and "Every of January" for a month with no day. That string is the whole of what somebody sees before approving a schedule and the whole of what the model is told about its own chat, so a phrase nobody can parse is a review step nobody performs. It reads as English now, collapses Monday-to-Friday to "every weekday" and seven days to "every day", and every case in the test is a rule that model actually produced. The one mistake it made was naming Wednesday for "every other tuesday", so the weekday numbering is spelled out rather than left as "0-6, Monday is 0": getting that wrong is the error here that still looks like a working schedule. Roughly one call in six also came back empty -- a local runner swapping models under the request will do that -- so an unusable reply is asked for once more before giving up. Not on an LLMError: an endpoint that refused will refuse again, and the reader is better served by the form than by waiting twice for the same answer. Canvas asked for a typed path, which was the last control in the application expecting somebody to remember an absolute path on another machine -- the same complaint the folder page's directory field answered with a picker. /browse takes pick=file and the same fragment makes files buttons, because a second copy of that listing is a second place for the path arithmetic to be got subtly differently. The button carries data-canvas-open rather than an hx-post since the path is not known until the dialog closes, and ui.js posts it through htmx.ajax so the response lands in the panel exactly as every other canvas action's does. The key is `agent:<path>`, so a file opened by hand and one opened by the model are one tab rather than two spellings of it. The tabs already existed and already closed; they now square off at the bottom and the active one takes the body's background, so which is selected is structural rather than a tint nobody can see in a theme they did not choose. Highlighting was already there for every language named and is checked for fifteen of them. Three smaller ones. Tabs kept their scroll position, so switching from a long panel to a short one left the browser clamping to that panel's bottom: the end of it above a screen of nothing, which reads as a page that failed to load. Nothing in CSS can reset a scroll position. The sidebar's footer and the composer sit either side of one vertical edge and were both content-sized, so their top borders met it at different heights and read as one line that had been broken -- `--footer-height` is a calc of the pieces the footer is built from, applied as a min-height to both, which is exactly what `--header-height` already does at the top of the shell. And "Add a workflow" sat flush against the list it adds to, stated as an adjacency because `.btn-row` is right to carry no margin everywhere else it appears. Both pieces of JavaScript were driven under a DOM stub before committing, which is how the tab listener's delegation and the canvas button's six behaviours were checked at all -- `node --check` parses a file that does nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
443 lines
16 KiB
Python
443 lines
16 KiB
Python
"""What one chat may use, and the rule that it can only ever be less.
|
|
|
|
The security-shaped test here is `test_a_chat_cannot_widen_what_it_was_not_given`.
|
|
The scope is applied inside `resolve_tools` *after* the model's capabilities,
|
|
the reader's permissions and the instance configuration, so a crafted POST
|
|
turning something on reaches a tool those gates have already removed. Asserting
|
|
that against the UI path alone would prove nothing, so it is asserted against a
|
|
directly-written column.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import select
|
|
|
|
from lembas.db.models import Chat, Connection, Model, User
|
|
from lembas.services import settings_store
|
|
from lembas.services import tools as tools_service
|
|
from lembas.services.library import skills as skills_service
|
|
|
|
|
|
@pytest.fixture
|
|
def chat(db, user_id):
|
|
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
|
|
db.add(connection)
|
|
db.commit()
|
|
db.add(Model(connection_id=connection.id, model_id="m", capabilities_json={"tools": True}))
|
|
db.commit()
|
|
row = Chat(user_id=user_id, model_id="m", connection_id=connection.id)
|
|
db.add(row)
|
|
db.commit()
|
|
return row
|
|
|
|
|
|
def _names(db, chat, user) -> set[str]:
|
|
return set(tools_service.resolve_tools(db, chat, user).by_name)
|
|
|
|
|
|
# --- The route ------------------------------------------------------------------
|
|
def test_switching_a_family_off_writes_it_to_the_row(client: TestClient, db, chat, registered):
|
|
response = client.post(
|
|
f"/api/chats/{chat.id}/scope", data={"kind": "family", "name": "web_search"}
|
|
)
|
|
assert response.status_code == 204
|
|
|
|
db.expire_all()
|
|
assert db.get(Chat, chat.id).scope_json["families"]["web_search"] is False
|
|
|
|
|
|
def test_switching_it_back_on_removes_the_key(client: TestClient, db, chat, registered):
|
|
"""On is stored by *removing* the key, so absent stays the single
|
|
representation of on and the column cannot grow a row per family per chat."""
|
|
client.post(f"/api/chats/{chat.id}/scope", data={"kind": "family", "name": "notes"})
|
|
client.post(
|
|
f"/api/chats/{chat.id}/scope",
|
|
data={"kind": "family", "name": "notes", "on": "true"},
|
|
)
|
|
|
|
db.expire_all()
|
|
assert "families" not in db.get(Chat, chat.id).scope_json
|
|
|
|
|
|
def test_the_route_refuses_a_kind_it_does_not_know(client: TestClient, chat, registered):
|
|
response = client.post(
|
|
f"/api/chats/{chat.id}/scope", data={"kind": "everything", "name": "x"}
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_the_route_refuses_the_wrong_verb(client: TestClient, chat, registered):
|
|
"""The half of `tests/test_agent_mode.py`'s lesson that actually caught the
|
|
bug: a control wired to a method a route does not serve fails silently."""
|
|
assert client.get(f"/api/chats/{chat.id}/scope").status_code == 405
|
|
|
|
|
|
def test_somebody_elses_chat_is_not_reachable(client: TestClient, db, chat, registered):
|
|
from lembas.security.passwords import hash_password
|
|
|
|
other = User(name="Sam", email="s@shire.test", password_hash=hash_password("secret123"))
|
|
db.add(other)
|
|
db.commit()
|
|
chat.user_id = other.id
|
|
db.commit()
|
|
|
|
response = client.post(
|
|
f"/api/chats/{chat.id}/scope", data={"kind": "family", "name": "notes"}
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
|
|
# --- What it does to the offer ------------------------------------------------------
|
|
def test_a_family_switched_off_is_not_offered(db, chat, user_id):
|
|
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
|
|
user = db.get(User, user_id)
|
|
assert "web_search" in _names(db, chat, user)
|
|
|
|
chat.scope_json = {"families": {"web_search": False}}
|
|
db.commit()
|
|
|
|
assert "web_search" not in _names(db, chat, user)
|
|
|
|
|
|
def test_switching_a_gate_off_takes_every_tool_in_it(db, chat, user_id):
|
|
"""A gate is one switch, not five. `notes` covers search, get, create, edit
|
|
and delete -- which is the same reasoning the per-model capability
|
|
checkboxes carry."""
|
|
user = db.get(User, user_id)
|
|
chat.scope_json = {"families": {"notes": False}}
|
|
db.commit()
|
|
|
|
offered = _names(db, chat, user)
|
|
assert not [name for name in offered if name.startswith("notes_")]
|
|
|
|
|
|
def test_a_chat_cannot_widen_what_it_was_not_given(db, chat, user_id):
|
|
"""The one that matters. Scope is applied AFTER the gates and never instead
|
|
of them, so writing `True` into the column reaches a tool the model's
|
|
capabilities had already removed."""
|
|
user = db.get(User, user_id)
|
|
model = db.scalar(tools_service.select(Model))
|
|
model.capabilities_json = {"tools": True, "tool_notes": False}
|
|
chat.scope_json = {"families": {"notes": True}}
|
|
db.commit()
|
|
|
|
assert "notes_search" not in _names(db, chat, user)
|
|
|
|
|
|
def test_an_unknown_family_in_the_column_changes_nothing(db, chat, user_id):
|
|
user = db.get(User, user_id)
|
|
before = _names(db, chat, user)
|
|
chat.scope_json = {"families": {"not-a-family": False}}
|
|
db.commit()
|
|
|
|
assert _names(db, chat, user) == before
|
|
|
|
|
|
# --- Skills -------------------------------------------------------------------------
|
|
@pytest.fixture
|
|
def skill(db, user_id):
|
|
return skills_service.create(
|
|
db,
|
|
owner=db.get(User, user_id),
|
|
name="weekly-report",
|
|
description="When asked for the weekly report.",
|
|
body="Do the thing.",
|
|
)
|
|
|
|
|
|
def test_a_skill_switched_off_leaves_the_index(db, chat, user_id, skill):
|
|
user = db.get(User, user_id)
|
|
assert "weekly-report" in skills_service.index_block(db, user)
|
|
assert "weekly-report" not in skills_service.index_block(
|
|
db, user, exclude=["weekly-report"]
|
|
)
|
|
|
|
|
|
def test_a_skill_switched_off_cannot_be_fetched_anyway(db, chat, user_id, skill):
|
|
"""Without this the narrowing is advisory: a model can name a skill it was
|
|
never shown -- from an earlier turn, from a note -- and the runner would
|
|
happily fetch it. Same rule as "what may be run is what was offered"."""
|
|
import asyncio
|
|
|
|
user = db.get(User, user_id)
|
|
chat.scope_json = {"skills": {"weekly-report": False}}
|
|
db.commit()
|
|
|
|
context = tools_service.context_for(db, user, chat)
|
|
outcome = asyncio.run(
|
|
tools_service.run_tool(context, "skill_get", '{"name": "weekly-report"}')
|
|
)
|
|
assert outcome.event["status"] == "error"
|
|
|
|
|
|
def test_the_last_skill_switched_off_withdraws_skill_get(db, chat, user_id, skill):
|
|
user = db.get(User, user_id)
|
|
assert "skill_get" in _names(db, chat, user)
|
|
|
|
chat.scope_json = {"skills": {"weekly-report": False}}
|
|
db.commit()
|
|
|
|
offered = _names(db, chat, user)
|
|
assert "skill_get" not in offered
|
|
assert "skill_create" in offered, "writing the first one is still possible"
|
|
|
|
|
|
# --- The zero-skills asymmetry --------------------------------------------------------
|
|
def test_with_no_skills_nothing_tells_the_model_to_read_one(db, chat, user_id):
|
|
"""The complaint this fixes. `tool.skills` was gated on the family alone, so
|
|
a person with no skills got "read the full instructions with skill_get"
|
|
above a list that was not there -- and got skill_get in the tools array, so
|
|
the model spent a round finding out."""
|
|
from lembas.services import harness
|
|
|
|
user = db.get(User, user_id)
|
|
offered = tools_service.resolve_tools(db, chat, user).schemas
|
|
text = harness.compose(db, user, offered, chat)
|
|
|
|
assert "skill_get" not in _names(db, chat, user)
|
|
assert "skill_get" not in text
|
|
assert "Skills available" not in text
|
|
# The half that is most useful with none: you can save the first one.
|
|
assert "save it with skill_create" in text
|
|
|
|
|
|
def test_with_a_skill_the_reading_guidance_comes_back(db, chat, user_id, skill):
|
|
from lembas.services import harness
|
|
|
|
user = db.get(User, user_id)
|
|
offered = tools_service.resolve_tools(db, chat, user).schemas
|
|
text = harness.compose(db, user, offered, chat)
|
|
|
|
assert "skill_get" in text
|
|
assert "weekly-report" in text
|
|
assert "save it with skill_create" in text
|
|
|
|
|
|
# --- The tool list --------------------------------------------------------------------
|
|
def test_the_model_is_told_what_it_actually_has(db, chat, user_id):
|
|
"""`tool_names` was resolved and documented with no fragment reading it. A
|
|
model that has to discover its own list by calling something and being told
|
|
it does not exist spends a round finding out -- and with one round, that is
|
|
the whole reply."""
|
|
from lembas.services import harness
|
|
|
|
user = db.get(User, user_id)
|
|
offered = tools_service.resolve_tools(db, chat, user).schemas
|
|
text = harness.compose(db, user, offered, chat)
|
|
|
|
assert "The tools you have on this request are:" in text
|
|
for name in tools_service.resolve_tools(db, chat, user).by_name:
|
|
assert name in text
|
|
|
|
|
|
def test_a_family_switched_off_disappears_from_the_list_too(db, chat, user_id):
|
|
from lembas.services import harness
|
|
|
|
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
|
|
user = db.get(User, user_id)
|
|
chat.scope_json = {"families": {"web_search": False}}
|
|
db.commit()
|
|
|
|
offered = tools_service.resolve_tools(db, chat, user).schemas
|
|
text = harness.compose(db, user, offered, chat)
|
|
|
|
assert "web_search" not in text
|
|
|
|
|
|
def test_no_tools_means_no_list(db, chat, user_id):
|
|
from lembas.services import harness
|
|
|
|
text = harness.compose(db, db.get(User, user_id), [])
|
|
assert "The tools you have on this request" not in text
|
|
|
|
|
|
# --- The control that writes ------------------------------------------------------------
|
|
def test_the_verb_is_on_every_checkbox(client: TestClient, db, chat, registered):
|
|
"""The element carrying `name` has to be the element carrying the request.
|
|
Two selects lost an entire release to getting this wrong -- their verb was
|
|
on a form the event never reached, and the tests passed throughout because
|
|
they asserted the markup rather than the property.
|
|
|
|
`conftest.control_named` is the helper for this and wants exactly one match;
|
|
there is one checkbox per family here, so the same check is made over all of
|
|
them, which is the stronger claim anyway.
|
|
"""
|
|
from html.parser import HTMLParser
|
|
|
|
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
|
|
html = client.get(f"/chat/{chat.id}").text
|
|
|
|
found: list[dict[str, str]] = []
|
|
|
|
class Finder(HTMLParser):
|
|
def handle_starttag(self, tag, attrs):
|
|
got = {key: (value or "") for key, value in attrs}
|
|
if got.get("name") == "on":
|
|
found.append(got)
|
|
|
|
Finder().feed(html)
|
|
|
|
assert found, "the scope menu rendered no switches"
|
|
for box in found:
|
|
assert box.get("hx-post") == f"/api/chats/{chat.id}/scope"
|
|
assert "kind" in box.get("hx-vals", ""), "and says which thing it is"
|
|
|
|
|
|
def test_the_menu_is_called_toggle(client: TestClient, db, chat, registered):
|
|
"""It was "What this chat can use", which described the contents rather than
|
|
naming the control. The label is on the button and on the menu, and both are
|
|
read aloud, so both have to say it."""
|
|
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
|
|
html = client.get(f"/chat/{chat.id}").text
|
|
|
|
assert 'aria-label="Toggle"' in html
|
|
assert "What this chat can use" not in html
|
|
|
|
|
|
def test_the_menu_no_longer_offers_to_type_an_at_sign(client: TestClient, db, chat, registered):
|
|
"""A menu you open in order to insert one character is a longer way round
|
|
than the character. Typing `@` is untouched and is asserted elsewhere."""
|
|
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
|
|
html = client.get(f"/chat/{chat.id}").text
|
|
|
|
assert "data-mention-open" not in html
|
|
assert "Mention a file or a document" not in html
|
|
|
|
|
|
def test_with_nothing_to_narrow_there_is_no_button_at_all(
|
|
client: TestClient, db, chat, registered
|
|
):
|
|
"""The guard used to be `has_scope or can upload`, because the mention row
|
|
was something to show when there was no scope. With that gone the same guard
|
|
would open an empty menu, which is worse than no button.
|
|
|
|
A model with no `tools` capability is offered nothing, so there is nothing
|
|
to switch off -- the honest way to reach an empty scope.
|
|
"""
|
|
model = db.query(Model).filter_by(model_id="m").one()
|
|
model.capabilities_json = {}
|
|
db.commit()
|
|
|
|
html = client.get(f"/chat/{chat.id}").text
|
|
|
|
assert "picker__menu--scope" not in html
|
|
|
|
|
|
# --- Before the chat exists -------------------------------------------------------
|
|
def test_the_menu_is_there_before_the_first_message(
|
|
client: TestClient, db, chat, registered
|
|
):
|
|
"""The bug this section exists for.
|
|
|
|
The harness puts a tool's guidance in front of the model the moment the tool
|
|
is offered — so a menu that only appeared once a chat existed was one you
|
|
could not reach until after the model had been told how to keep notes and
|
|
been handed the tools to do it. Switching it off then does not un-send that
|
|
turn.
|
|
"""
|
|
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
|
|
html = client.get("/chat").text
|
|
|
|
assert 'aria-label="Toggle"' in html
|
|
assert 'name="scope_all"' in html
|
|
assert 'name="scope_on"' in html
|
|
# And it posts nothing on its own: there is no row to post to yet.
|
|
assert "/scope" not in html
|
|
|
|
|
|
def test_the_prospective_switches_ride_with_the_first_message(
|
|
client: TestClient, db, chat, registered
|
|
):
|
|
"""A browser submits only the ticked boxes, so "which were unticked" needs
|
|
the hidden mirror. This asserts the pair exists per gate rather than that
|
|
the markup looks a certain way."""
|
|
from html.parser import HTMLParser
|
|
|
|
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
|
|
html = client.get("/chat").text
|
|
|
|
named: dict[str, list[str]] = {"scope_all": [], "scope_on": []}
|
|
|
|
class Finder(HTMLParser):
|
|
def handle_starttag(self, tag, attrs):
|
|
got = {key: (value or "") for key, value in attrs}
|
|
if got.get("name") in named:
|
|
named[got["name"]].append(got.get("value", ""))
|
|
|
|
Finder().feed(html)
|
|
|
|
assert named["scope_all"], "the prospective menu rendered no gates"
|
|
# Every gate offered has both halves, or one of them can never be turned off.
|
|
assert set(named["scope_all"]) == set(named["scope_on"])
|
|
|
|
|
|
def test_unticking_before_sending_writes_it_to_the_new_chat(
|
|
client: TestClient, db, chat, registered
|
|
):
|
|
"""End to end: what the menu was set to is what the row is created with, so
|
|
the very first request is already narrowed."""
|
|
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
|
|
|
|
client.post(
|
|
"/api/chats/start",
|
|
data={
|
|
"content": "hello",
|
|
"model_id": "m",
|
|
"scope_all": ["notes", "memory", "web_search"],
|
|
# `notes` left out: unticked.
|
|
"scope_on": ["memory", "web_search"],
|
|
},
|
|
)
|
|
|
|
fresh = db.scalars(
|
|
select(Chat).where(Chat.user_id == chat.user_id).order_by(Chat.created_at.desc())
|
|
).first()
|
|
assert tools_service.scoped_off(fresh) == frozenset({"notes"})
|
|
assert "notes_search" not in _names(db, fresh, db.get(User, chat.user_id))
|
|
|
|
|
|
def test_leaving_everything_ticked_writes_nothing(client: TestClient, db, chat, registered):
|
|
"""Absent means on, and there is one representation of it. A row full of
|
|
`True`s would be a second one, and "why is this off?" would have two
|
|
answers."""
|
|
client.post(
|
|
"/api/chats/start",
|
|
data={
|
|
"content": "hello",
|
|
"model_id": "m",
|
|
"scope_all": ["notes", "memory"],
|
|
"scope_on": ["notes", "memory"],
|
|
},
|
|
)
|
|
|
|
fresh = db.scalars(
|
|
select(Chat).where(Chat.user_id == chat.user_id).order_by(Chat.created_at.desc())
|
|
).first()
|
|
assert fresh.scope_json == {}
|
|
|
|
|
|
def test_starting_a_chat_cannot_widen_through_the_menu(
|
|
client: TestClient, db, chat, registered
|
|
):
|
|
"""The security-shaped half, from the new-chat side. `scope_json` narrows
|
|
inside `resolve_tools` *after* every gate, so naming a gate that was never
|
|
offered switches off something that was not on — which is nothing. A
|
|
crafted POST cannot turn anything on, because there is no representation
|
|
for "on" to send."""
|
|
client.post(
|
|
"/api/chats/start",
|
|
data={
|
|
"content": "hello",
|
|
"model_id": "m",
|
|
"scope_all": ["agent", "made_up"],
|
|
"scope_on": ["agent", "made_up"],
|
|
},
|
|
)
|
|
|
|
fresh = db.scalars(
|
|
select(Chat).where(Chat.user_id == chat.user_id).order_by(Chat.created_at.desc())
|
|
).first()
|
|
assert "shell_run" not in _names(db, fresh, db.get(User, chat.user_id))
|