Files
LLeMbas/tests/test_agent_mode.py
T
Jaroslav Beneš 52770d7ab1 Two selects that never wrote anything, and a queue
The approval card in Auto mode and the missing /effort were one bug. Both
selects hung their hx-patch on an empty sibling form reached by form="…",
and htmx binds a trigger to the annotated element: change fires on the
select and bubbles to its ancestors, which a sibling is not. The live rows
read agent_mode=manual and params_json={} while the browser showed Auto and
Effort: high. policy.py was never involved.

The verb moves onto the control; the empty form stays as value scoping,
which is the half of the CLAUDE.md note that was right. conftest gains
control_named so a test asserts the element carrying the name carries the
verb, rather than asserting the markup that was there throughout.

The composer's highlight was a third instance of the same carelessness in
CSS: .tok-mention is written for the transcript and scoped to nothing, so
the mirror painted its token in accent-coloured monospace over the
textarea's own text. Scoped under .msg; the mirror restates transparency
and font rather than inheriting them, and bleeds by box-shadow.

/effort is now offered before the first prompt and _new_chat reads it.
/index re-walks the project directory on demand, file_write drops the
listing it just invalidated, and the index ladder falls through to SFTP on
a host that refuses exec instead of returning nothing.

A second message during a reply is queued rather than starting a second
concurrent generation: a real Message row with queued set, so it survives a
restart and can be withdrawn. _drain hands one on at the end of a reply,
_inject takes one in at a tool-round boundary so an agent can be steered
mid-task. Stop leaves the queue undelivered. The terminal's Auto toggle
becomes off/copy/send, and send posts straight to the chat without touching
the composer.

@ now offers notes, skills, this chat's attachments and a URL to fetch; a
knowledge base attaches as a reference rather than a copy. copy_document
carries provenance, which was the one attach path that dropped it.

Also fixes an unrelated live bug: the round loop compared against the
global MAX_ROUNDS of 3 while sizing itself from the agent budget of 40, so
agent replies stopped after three rounds and reported forty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:49:34 +02:00

236 lines
8.2 KiB
Python

"""Choosing the approval mode: after a chat exists, and before one does.
The regression test at the top is the one that was missing. The mode select in
the topbar posted with `hx-post` against a route that only answers `PATCH`, so
every change returned 405 and the mode never moved -- and htmx surfaces nothing
on a failed request, so the control looked like it had worked. A control wired
to a method the route does not serve fails exactly this quietly, which is why
the assertion below reads the row rather than the response.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import KIND_AGENT, Chat, Connection, Model, SshProfile, User
from lembas.services import settings_store
from lembas.services.agent import policy
from .conftest import control_named
def _agent_chat(db, *, mode: str = policy.MODE_MANUAL) -> Chat:
"""An agent chat pointed at a profile that is never actually connected to.
Nothing here opens a connection: changing the mode is a database write, and
a real sshd would only make the test slower and flakier.
A model is needed even though nothing generates: with none, `index.html`
renders the "no models available" screen *instead of* the thread and the
composer, so a test asserting on composer markup would pass against a page
that does not contain a composer at all. That is not hypothetical -- it is
how the first version of the assertion below came to be vacuous.
"""
settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS)
user = db.scalars(select(User)).first()
assert user is not None
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"))
db.commit()
profile = SshProfile(
owner_id=user.id,
name="Test box",
host="127.0.0.1",
port=22,
username="tester",
host_key="host key",
host_fingerprint="SHA256:x",
default_dir="/project",
)
db.add(profile)
db.commit()
chat = Chat(
user_id=user.id,
model_id="m",
connection_id=connection.id,
kind=KIND_AGENT,
ssh_profile_id=profile.id,
project_dir="/project",
agent_mode=mode,
)
db.add(chat)
db.commit()
return chat
def test_patching_the_mode_changes_it(client: TestClient, db, registered):
chat = _agent_chat(db)
response = client.patch(f"/api/chats/{chat.id}", data={"agent_mode": policy.MODE_PLAN})
assert response.status_code == 204, response.text
db.refresh(chat)
assert chat.agent_mode == policy.MODE_PLAN
def test_the_mode_form_uses_a_method_the_route_serves(client: TestClient, db, registered):
"""The bug itself, stated as a test rather than as a comment.
POST is not merely unhandled here, it is *silently* unhandled: htmx swallows
the 405 and the select keeps showing whatever was clicked. Asserting the
method is refused is what stops somebody reintroducing `hx-post` and finding
the mode unchangeable again with nothing in the logs.
"""
chat = _agent_chat(db)
refused = client.post(f"/api/chats/{chat.id}", data={"agent_mode": policy.MODE_AUTO})
assert refused.status_code == 405
db.refresh(chat)
assert chat.agent_mode == policy.MODE_MANUAL
def test_the_mode_select_carries_its_own_verb(client: TestClient, db, registered):
"""The request must hang off the element the event fires on.
This is the invariant, and the previous version of this test did not check
it. `hx-patch` lived on an empty sibling `<form>` that the select pointed at
with `form="…"`, which was enough to make the markup look right and enough
to make every assertion here pass -- while htmx bound the `change` listener
to the form, and `change` fires on the select and bubbles to its *ancestors*
only. The mode never once reached the database.
So: assert on the control, by name, whichever element that turns out to be.
"""
chat = _agent_chat(db)
body = client.get(f"/chat/{chat.id}").text
select = control_named(body, "agent_mode")
assert select["hx-patch"] == f"/api/chats/{chat.id}"
assert "hx-post" not in select
# And the empty form still scopes the values, so the PATCH carries this
# field alone rather than the whole composer -- `project_dir` in a PATCH is
# a 409.
assert select["form"] == "agent-mode-form"
def test_the_mode_is_offered_beside_the_composer_not_in_the_topbar(
client: TestClient, db, registered
):
"""Where it is matters: it belongs where the message is written.
Asserted on order rather than on a class name so it survives a restyle --
what is being pinned is that the control comes after the thread, not what
it looks like.
"""
chat = _agent_chat(db)
body = client.get(f"/chat/{chat.id}").text
assert body.index('id="thread"') < body.index('id="agent-mode-form"')
# --- Chosen before the first word --------------------------------------------
def _profile_for(db) -> SshProfile:
"""A usable connection, and the feature switched on, with no chat yet."""
settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS)
user = db.scalars(select(User)).first()
assert user is not None
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"))
profile = SshProfile(
owner_id=user.id,
name="Test box",
host="127.0.0.1",
port=22,
username="tester",
host_key="host key",
host_fingerprint="SHA256:x",
default_dir="/project",
)
db.add(profile)
db.commit()
return profile
def test_a_chat_can_start_in_plan_mode(client: TestClient, db, registered):
"""The whole point: reaching Plan without first sending something in Manual."""
profile = _profile_for(db)
client.post(
"/api/chats/start",
data={
"content": "have a look around",
"kind": "agent",
"ssh_profile_id": profile.id,
"project_dir": "/project",
"agent_mode": policy.MODE_PLAN,
},
)
chat = db.scalars(select(Chat)).one()
assert chat.kind == KIND_AGENT
assert chat.agent_mode == policy.MODE_PLAN
def test_a_chat_started_with_no_mode_is_manual(client: TestClient, db, registered):
"""The column default still governs, so nobody's habits change."""
profile = _profile_for(db)
client.post(
"/api/chats/start",
data={"content": "hello", "kind": "agent", "ssh_profile_id": profile.id},
)
assert db.scalars(select(Chat)).one().agent_mode == policy.MODE_MANUAL
def test_an_unrecognised_mode_at_creation_is_ignored(client: TestClient, db, registered):
profile = _profile_for(db)
client.post(
"/api/chats/start",
data={
"content": "hello",
"kind": "agent",
"ssh_profile_id": profile.id,
"agent_mode": "root",
},
)
assert db.scalars(select(Chat)).one().agent_mode == policy.MODE_MANUAL
def test_a_mode_on_a_plain_chat_is_ignored(client: TestClient, db, registered):
"""A plain chat has no approval loop, so a mode on one means nothing.
It must not silently become an agent chat either: the connection is what
decides that, and there is none here.
"""
_profile_for(db)
client.post("/api/chats/start", data={"content": "hello", "agent_mode": policy.MODE_AUTO})
chat = db.scalars(select(Chat)).one()
assert chat.kind != KIND_AGENT
assert chat.agent_mode == policy.MODE_MANUAL
@pytest.mark.parametrize("wanted", ["", "sudo", "PLAN", "edit;auto"])
def test_an_unrecognised_mode_is_ignored(client: TestClient, db, registered, wanted):
"""Ignored rather than refused: an unknown mode is a bug in the sender, and
failing the whole request would leave the reader with a chat they cannot
change. The safe outcome is that nothing moves."""
chat = _agent_chat(db, mode=policy.MODE_EDIT)
client.patch(f"/api/chats/{chat.id}", data={"agent_mode": wanted})
db.refresh(chat)
assert chat.agent_mode == policy.MODE_EDIT