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>
419 lines
15 KiB
Python
419 lines
15 KiB
Python
"""Working a plain-language request into a schedule, and running unattended.
|
|
|
|
The compile is the one place a *model* decides when something happens. So the
|
|
tests here are mostly about it failing safely: prose, a fence, a cron string, an
|
|
endpoint that is down and a cleared fragment all have to end at the manual form
|
|
rather than at a schedule that never fires.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import select
|
|
|
|
from lembas.db.models import KIND_TASK, Chat, Schedule, User
|
|
from lembas.services import prompts as prompts_service
|
|
from lembas.services import settings_store
|
|
from lembas.services.llm.openai_client import Endpoint, LLMError
|
|
from lembas.services.schedule import compile as compile_service
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def scheduling_allowed(db, registered):
|
|
from lembas.db.models import Connection, Model
|
|
from lembas.services.crypto import encrypt
|
|
|
|
settings_store.update(db, {"enabled": True}, key=settings_store.SCHEDULES)
|
|
settings_store.update(db, {"default_permissions": {"schedule.use": True}})
|
|
connection = Connection(
|
|
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
|
|
)
|
|
db.add(connection)
|
|
db.commit()
|
|
db.add(Model(connection_id=connection.id, model_id="test-model"))
|
|
db.commit()
|
|
return None
|
|
|
|
|
|
def _user(db) -> User:
|
|
return db.scalars(select(User).order_by(User.created_at)).first()
|
|
|
|
|
|
def _endpoint() -> Endpoint:
|
|
return Endpoint(base_url="http://127.0.0.1:1/v1", api_key="", extra_headers={})
|
|
|
|
|
|
def _template(db) -> str:
|
|
return prompts_service.resolve(db, "task.schedule_compile")
|
|
|
|
|
|
async def _compile(db, reply: str, monkeypatch, request: str = "every monday at 3"):
|
|
async def answer(endpoint, payload):
|
|
return reply
|
|
|
|
monkeypatch.setattr(compile_service, "complete", answer)
|
|
return await compile_service.compile_request(
|
|
_endpoint(), "test-model", request, template=_template(db), user=_user(db)
|
|
)
|
|
|
|
|
|
# --- What it can read -------------------------------------------------------------
|
|
@pytest.mark.anyio
|
|
async def test_a_plain_json_answer_compiles(client: TestClient, db, registered, monkeypatch):
|
|
compiled = await _compile(
|
|
db,
|
|
json.dumps(
|
|
{
|
|
"title": "Build check",
|
|
"instruction": "Check the build and say what broke.",
|
|
"target": "report",
|
|
"schedule": {"at": {"weekdays": [0], "times": ["15:00"]}},
|
|
}
|
|
),
|
|
monkeypatch,
|
|
)
|
|
|
|
assert compiled.ok is True
|
|
assert compiled.title == "Build check"
|
|
assert compiled.target == "report"
|
|
assert compiled.rule["at"]["times"] == ["15:00"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_fenced_answer_compiles(client: TestClient, db, registered, monkeypatch):
|
|
"""Small models fence their JSON however they were trained to. Refusing it
|
|
costs a whole round trip to end up showing the manual form anyway — the
|
|
same reasoning `tools.parse_arguments` already follows."""
|
|
body = json.dumps(
|
|
{"title": "T", "instruction": "I", "schedule": {"at": {"times": ["09:00"]}}}
|
|
)
|
|
compiled = await _compile(
|
|
db, f"Here you go:\n```json\n{body}\n```\nHope that helps!", monkeypatch
|
|
)
|
|
|
|
assert compiled.ok is True
|
|
assert compiled.rule["at"]["times"] == ["09:00"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_timer_with_no_start_begins_now(
|
|
client: TestClient, db, registered, monkeypatch
|
|
):
|
|
""""Every six hours" is written `{"every": {"hours": 6}}` and nothing else,
|
|
which is the natural reading and cannot fire on its own — a timer measures
|
|
from a start, and `rule.py` has no clock to invent one. Filled in here
|
|
exactly as the manual form does, or the commonest request of all compiles to
|
|
a schedule that never runs."""
|
|
compiled = await _compile(
|
|
db,
|
|
json.dumps({"title": "T", "instruction": "I", "schedule": {"every": {"hours": 6}}}),
|
|
monkeypatch,
|
|
request="every six hours",
|
|
)
|
|
|
|
assert compiled.ok is True
|
|
assert compiled.rule["every"] == {"minutes": 360}
|
|
assert compiled.rule["start"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_thinking_is_stripped_before_parsing(
|
|
client: TestClient, db, registered, monkeypatch
|
|
):
|
|
"""A model that thinks inline puts its reasoning in `content`, which is the
|
|
field `complete` hands back verbatim — the trap auto-titling hit."""
|
|
body = json.dumps({"title": "T", "instruction": "I", "schedule": {"at": {"times": ["09:00"]}}})
|
|
compiled = await _compile(db, f"<think>Let me work this out…</think>{body}", monkeypatch)
|
|
|
|
assert compiled.ok is True
|
|
assert compiled.title == "T"
|
|
|
|
|
|
# --- How it fails ------------------------------------------------------------------
|
|
@pytest.mark.anyio
|
|
async def test_prose_falls_back_to_the_readers_own_words(
|
|
client: TestClient, db, registered, monkeypatch
|
|
):
|
|
"""Never a schedule nobody asked for. The reader's words survive so the form
|
|
is filled in rather than blank."""
|
|
compiled = await _compile(db, "Sure! I'd suggest running that weekly.", monkeypatch)
|
|
|
|
assert compiled.ok is False
|
|
assert compiled.instruction == "every monday at 3"
|
|
assert compiled.reason
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_rule_that_normalises_to_nothing_is_not_ok(
|
|
client: TestClient, db, registered, monkeypatch
|
|
):
|
|
"""The compile's output is model output that becomes a *timer*, and this is
|
|
the reason `rule.validate` had to be total."""
|
|
compiled = await _compile(
|
|
db, json.dumps({"title": "T", "instruction": "I", "schedule": "0 3 * * 1"}), monkeypatch
|
|
)
|
|
|
|
assert compiled.ok is False
|
|
assert compiled.rule == {}
|
|
assert "when" in compiled.reason
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_time_already_past_is_not_ok(client: TestClient, db, registered, monkeypatch):
|
|
compiled = await _compile(
|
|
db,
|
|
json.dumps(
|
|
{"title": "T", "instruction": "I", "schedule": {"start": "2020-01-01T09:00:00Z"}}
|
|
),
|
|
monkeypatch,
|
|
)
|
|
|
|
assert compiled.ok is False
|
|
assert "already passed" in compiled.reason
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_an_endpoint_that_is_down_is_not_an_error(
|
|
client: TestClient, db, registered, monkeypatch
|
|
):
|
|
async def refuse(endpoint, payload):
|
|
raise LLMError("connection refused")
|
|
|
|
monkeypatch.setattr(compile_service, "complete", refuse)
|
|
compiled = await compile_service.compile_request(
|
|
_endpoint(), "test-model", "daily at nine", template=_template(db), user=_user(db)
|
|
)
|
|
|
|
assert compiled.ok is False
|
|
assert compiled.instruction == "daily at nine"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_clearing_the_fragment_switches_off_the_compiling_not_the_feature(
|
|
client: TestClient, db, registered, monkeypatch
|
|
):
|
|
"""`task.compact` set the precedent that clearing a fragment kills a
|
|
feature. Here it must not: the manual form is what makes "an empty override
|
|
means off" safe, and no request is made at all."""
|
|
called = False
|
|
|
|
async def answer(endpoint, payload):
|
|
nonlocal called
|
|
called = True
|
|
return "{}"
|
|
|
|
monkeypatch.setattr(compile_service, "complete", answer)
|
|
compiled = await compile_service.compile_request(
|
|
_endpoint(), "test-model", "daily at nine", template="", user=_user(db)
|
|
)
|
|
|
|
assert called is False
|
|
assert compiled.ok is False
|
|
assert compiled.instruction == "daily at nine"
|
|
assert compiled.reason == ""
|
|
|
|
|
|
def test_the_prompt_carries_the_readers_zone(client: TestClient, db, registered):
|
|
"""The model works out "Monday at 3" and the ticker fires it. If they
|
|
disagree about the zone, nothing errors — it simply runs at the wrong time."""
|
|
user = _user(db)
|
|
user.settings_json = {**(user.settings_json or {}), "timezone": "Asia/Tokyo"}
|
|
db.commit()
|
|
|
|
prompt = compile_service.render_prompt(
|
|
_template(db), request="every monday at 3", user=user
|
|
)
|
|
assert "Asia/Tokyo" in prompt
|
|
assert "every monday at 3" in prompt
|
|
|
|
|
|
# --- The review step ----------------------------------------------------------------
|
|
def test_describing_shows_it_back_rather_than_creating_it(
|
|
client: TestClient, db, registered, monkeypatch
|
|
):
|
|
"""A timing a model chose and nobody looked at is exactly the standing
|
|
instruction this codebase refuses to create silently elsewhere."""
|
|
|
|
async def answer(endpoint, payload):
|
|
return json.dumps(
|
|
{
|
|
"title": "Build check",
|
|
"instruction": "Check the build.",
|
|
"schedule": {"at": {"weekdays": [0], "times": ["15:00"]}},
|
|
}
|
|
)
|
|
|
|
monkeypatch.setattr(compile_service, "complete", answer)
|
|
|
|
response = client.post("/api/schedules/describe", data={"request": "mondays at 3"})
|
|
|
|
assert response.status_code == 200
|
|
assert "Every Monday at 15:00" in response.text
|
|
assert "Build check" in response.text
|
|
# Shown, not saved.
|
|
assert db.scalars(select(Schedule)).all() == []
|
|
|
|
|
|
def test_describing_with_no_model_configured_still_answers(
|
|
client: TestClient, db, registered
|
|
):
|
|
from lembas.db.models import Connection
|
|
|
|
for connection in db.scalars(select(Connection)):
|
|
db.delete(connection)
|
|
db.commit()
|
|
|
|
response = client.post("/api/schedules/describe", data={"request": "mondays at 3"})
|
|
|
|
assert response.status_code == 200
|
|
assert "fill it in yourself" in response.text
|
|
|
|
|
|
# --- Unattended ---------------------------------------------------------------------
|
|
def test_ask_user_is_not_offered_in_a_task_chat(client: TestClient, db, registered):
|
|
"""Enforced in `resolve_tools`, not merely discouraged in the prompt.
|
|
|
|
A parked `ask_user` holds the reply for the whole `approval_timeout` with
|
|
nobody there to answer — a run that silently does nothing for fifteen
|
|
minutes and then gives up. A rule living only in a system message is one a
|
|
page the model just read can argue with.
|
|
"""
|
|
from lembas.db.models import Model
|
|
from lembas.services import tools as tools_service
|
|
|
|
model = db.scalars(select(Model)).one()
|
|
model.capabilities_json = {"tools": True, "tool_ask": True}
|
|
db.commit()
|
|
settings_store.update(db, {"default_permissions": {"tools.ask": True}})
|
|
|
|
ordinary = Chat(user_id=_user(db).id, model_id="test-model")
|
|
task = Chat(user_id=_user(db).id, model_id="test-model", kind=KIND_TASK)
|
|
db.add_all([ordinary, task])
|
|
db.commit()
|
|
|
|
offered = {t.name for t in tools_service.resolve_tools(db, ordinary, _user(db)).defs}
|
|
assert "ask_user" in offered
|
|
|
|
withdrawn = {t.name for t in tools_service.resolve_tools(db, task, _user(db)).defs}
|
|
assert "ask_user" not in withdrawn
|
|
|
|
|
|
def test_a_task_chat_is_told_what_it_is_for(client: TestClient, db, registered, monkeypatch):
|
|
"""A task chat accumulates every run, so by the tenth the instruction is far
|
|
out of sight up the transcript."""
|
|
from lembas.services import harness
|
|
from lembas.services import schedules as schedules_service
|
|
|
|
schedule = schedules_service.create(
|
|
db,
|
|
owner=_user(db),
|
|
title="Build check",
|
|
instruction="Check the build and say what broke.",
|
|
rule={"at": {"weekdays": [0], "times": ["15:00"]}},
|
|
)
|
|
chat = db.get(Chat, schedule.chat_id)
|
|
|
|
values = harness.context_variables(db, _user(db), [], chat)
|
|
assert values["schedule_instruction"] == "Check the build and say what broke."
|
|
assert values["schedule_summary"] == "Every Monday at 15:00"
|
|
|
|
block = harness.compose(db, _user(db), [], chat)
|
|
assert "nobody is necessarily reading it" in block
|
|
assert "Check the build and say what broke." in block
|
|
|
|
|
|
def test_an_ordinary_chat_is_told_none_of_it(client: TestClient, db, registered):
|
|
"""`core.unattended` and `context.schedule` are gated on the same variable,
|
|
so the warning cannot appear without the thing it warns about."""
|
|
from lembas.services import harness
|
|
|
|
chat = Chat(user_id=_user(db).id, model_id="test-model")
|
|
db.add(chat)
|
|
db.commit()
|
|
|
|
block = harness.compose(db, _user(db), [], chat)
|
|
assert "nobody is necessarily reading" not in block
|
|
assert "This scheduled task" not in block
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_an_unusable_reply_is_asked_again_once(
|
|
client: TestClient, db, registered, monkeypatch
|
|
):
|
|
"""Measured against a 4B model: the prompt is sound — ten realistic requests
|
|
compiled ten times over, twice — but roughly one call in six came back empty
|
|
or truncated, which a local runner swapping models under the request will
|
|
do. One retry costs a second on a screen somebody is already waiting at."""
|
|
replies = ["", json.dumps({"title": "T", "instruction": "I",
|
|
"schedule": {"at": {"times": ["09:00"]}}})]
|
|
calls = 0
|
|
|
|
async def answer(endpoint, payload):
|
|
nonlocal calls
|
|
calls += 1
|
|
return replies.pop(0)
|
|
|
|
monkeypatch.setattr(compile_service, "complete", answer)
|
|
compiled = await compile_service.compile_request(
|
|
_endpoint(), "test-model", "daily at nine", template=_template(db), user=_user(db)
|
|
)
|
|
|
|
assert calls == 2
|
|
assert compiled.ok is True
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_it_gives_up_after_the_second_try(
|
|
client: TestClient, db, registered, monkeypatch
|
|
):
|
|
calls = 0
|
|
|
|
async def answer(endpoint, payload):
|
|
nonlocal calls
|
|
calls += 1
|
|
return "I'd suggest weekly."
|
|
|
|
monkeypatch.setattr(compile_service, "complete", answer)
|
|
compiled = await compile_service.compile_request(
|
|
_endpoint(), "test-model", "daily at nine", template=_template(db), user=_user(db)
|
|
)
|
|
|
|
assert calls == 2
|
|
assert compiled.ok is False
|
|
assert compiled.instruction == "daily at nine"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_an_endpoint_that_refuses_is_not_asked_twice(
|
|
client: TestClient, db, registered, monkeypatch
|
|
):
|
|
"""It will refuse again, and the reader is better served by the form than by
|
|
waiting twice for the same answer."""
|
|
calls = 0
|
|
|
|
async def refuse(endpoint, payload):
|
|
nonlocal calls
|
|
calls += 1
|
|
raise LLMError("connection refused")
|
|
|
|
monkeypatch.setattr(compile_service, "complete", refuse)
|
|
await compile_service.compile_request(
|
|
_endpoint(), "test-model", "daily at nine", template=_template(db), user=_user(db)
|
|
)
|
|
|
|
assert calls == 1
|
|
|
|
|
|
def test_the_weekday_numbering_is_spelled_out(client: TestClient, db, registered):
|
|
"""The one mistake a small model actually made in the audit: "every other
|
|
tuesday" came back as Wednesday. Naming the wrong day is the error here that
|
|
still looks like a working schedule, so the mapping is written out rather
|
|
than left as "0-6, Monday is 0"."""
|
|
template = _template(db)
|
|
|
|
for day, number in (("Monday", 0), ("Wednesday", 2), ("Sunday", 6)):
|
|
assert f"{day}={number}" in template
|