Files
LLeMbas/tests/test_agent_mode.py
T
Jaroslav Beneš a7e59a00f8 The composer decides what a chat is, and the topbar stops trying
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. htmx
shows nothing when a request fails, so the control looked like it worked: the
select stayed where you put it and the server ignored you. It has never worked.

Two more of the same kind. A mode could not be chosen at all until the chat
existed, so reaching Plan meant sending something in Manual first and letting
the model answer under the wrong rules. And the project directory box was real
and submitted, but unlabelled and squeezed to a few characters by the select
beside it, so it read as broken -- which is how it was reported.

So the kind, the connection, the directory and the mode move out of the strip
above the text and into one toolbar row beneath it, where attach and send
already are. The directory becomes a button that opens a browser over SFTP,
because a path is something you would rather find than spell. `scan_dir` is new
beside `list_dir`: a picker has to tell a directory from a file before it can
draw the row, and `list_dir` backs a tool whose contract is a list of names and
must not change under a model mid-conversation.

Browsing is a person clicking, not a model calling, so it does not pass through
policy.py -- the same argument the terminal panel rests on. It does mean Manual
mode has a second exception now.

Also: .chip was two components with one name, and the attachment card won, so
the Chat/Agent pills silently wore its padding. --radius-md was used twice and
declared nowhere, so both fell back to 0. .btn.is-active has been set by
syncToggles since the terminal landed and styled by nothing. Enter-to-send
ignored isComposing, so committing an IME candidate sent the message. The
terminal had five colours of a sixteen-colour palette, with fallbacks from a
palette that no longer exists.

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

228 lines
7.9 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
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_rendered_form_patches(client: TestClient, db, registered):
"""And that the template actually carries it, since that is where it broke.
Pinned to the mode form by its id rather than to any `hx-patch` on the
page: the model picker and the system-prompt box patch the same URL, so a
looser assertion would have passed throughout the entire life of the bug.
"""
chat = _agent_chat(db)
body = client.get(f"/chat/{chat.id}").text
assert 'id="agent-mode-form"' in body
form = body[body.index('id="agent-mode-form"') :][:200]
assert f'hx-patch="/api/chats/{chat.id}"' in form
assert "hx-post" not in form
# And that the select outside it is actually submitted by it.
assert 'form="agent-mode-form"' in body
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