Files
LLeMbas/tests/test_agent_draft.py
T
Homer 3d51ba061e Tests that found things reading did not
The testing pass: 2140 tests to 2283, and four bugs that no amount of
reading had turned up. Three came from driving the JavaScript under a
Node DOM stub, which is the practice CLAUDE.md sets out and this is the
reason it does.

The terminal dropped every keystroke after a reconnect. `onclose` closed
over the module-level socket rather than its own, and close() queues its
event -- so the old socket's close arrived after a new one was assigned
and nulled the live one. Output kept coming, because onmessage is bound
to the object, while every send gates on the variable. It also announced
"Disconnected" about a shell that had just reconnected.

Two scripts were loaded twice on /messages, once by base.html and again
by the page. Each is an IIFE with its own state, so four keyboard
shortcuts toggled their panel twice and therefore did nothing, /help
opened two dialogs, and an @ mention attached its file twice. A sweep
refuses any template re-loading what base.html has.

The microphone had no guard while the permission prompt was up, so each
click opened another stream and only the last was ever stopped. And a
skill shared with you took its name out of your own library: create
checked uniqueness against what is *visible* rather than what is owned,
against a (owner_id, name) constraint, and told you to edit a row you
cannot edit.

--ink-faint failed the contrast minimum in both themes -- 3.85 and 3.19
against 4.5 -- so the smallest text on every screen was the hardest to
read. Measured in a headless browser rather than judged by eye.

And the suite runs on 3.11 and 3.12 now as well as 3.14. It had only ever
run on 3.14 while the image ships 3.12 and the packaging claimed 3.11:
the interpreter most people would run was the one nothing had tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 14:41:45 +02:00

363 lines
13 KiB
Python

"""The terminal and the canvas before a chat exists.
Chats are created lazily and that rule is kept: a draft is not a chat and never
becomes one. What it does is hold an id, three facts and a tab strip, so the
panels can work on the screen where you are still deciding which machine to work
on -- and hand all of it over when the first prompt creates the real chat.
The security-shaped tests here are the two refusals. `canvas._load_file`
authorises with `attachment.chat_id != chat.id`, and a draft upload is stored
with `chat_id=None`; a draft whose chat carried no id would make that comparison
`None != None`, which is False.
"""
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 draft as draft_service
from lembas.services.agent import terminal as terminal_service
# Stands up something real -- see the `slow` marker in pyproject.toml.
pytestmark = pytest.mark.slow
@pytest.fixture(autouse=True)
def _clean():
draft_service.clear()
yield
draft_service.clear()
@pytest.fixture
def target(db, registered):
settings_store.update(
db, {"enabled": True, "terminal_enabled": True}, key=settings_store.AGENTS
)
user = db.scalar(select(User))
profile = SshProfile(
owner_id=user.id,
name="Box",
host="127.0.0.1",
port=1,
username="nobody",
host_key="ssh-ed25519 AAAA",
host_fingerprint="SHA256:x",
default_dir="/srv/project",
)
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
db.add_all([profile, connection])
db.commit()
db.add(Model(connection_id=connection.id, model_id="m", capabilities_json={"tools": True}))
db.commit()
return user, profile
# --- The id --------------------------------------------------------------------
def test_the_same_target_is_the_same_draft():
"""Derived rather than invented, so coming back to the new-chat screen finds
the shell already running there instead of quietly opening a second."""
one = draft_service.remember("u1", "p1", "/srv/x")
two = draft_service.remember("u1", "p1", "/srv/x")
assert one.id == two.id
assert draft_service.is_draft(one.id)
def test_a_different_directory_or_owner_is_a_different_draft():
base = draft_service.remember("u1", "p1", "/srv/x").id
assert draft_service.remember("u1", "p1", "/srv/y").id != base
assert draft_service.remember("u1", "p2", "/srv/x").id != base
# Two people pointed at the same directory of the same connection would
# otherwise share a *shell*, and one chat one shell is scoped to a person.
assert draft_service.remember("u2", "p1", "/srv/x").id != base
def test_a_draft_id_cannot_be_confused_with_a_chat_id():
"""`Chat.id` is 32 hex characters. Nothing here can collide with one."""
assert not draft_service.is_draft("a" * 32)
assert not draft_service.is_draft("")
def test_somebody_elses_draft_is_not_readable():
"""The id is a hash of the owner, so it cannot be guessed -- but that is not
an authorisation, and the next caller might build the id differently."""
made = draft_service.remember("u1", "p1", "/srv/x")
assert draft_service.get(made.id, "u1") is not None
assert draft_service.get(made.id, "u2") is None
# --- The transient chat --------------------------------------------------------
def test_the_transient_chat_carries_what_the_panels_read():
"""`agent_ready`, `_executor`, `_load_agent` and `agent_session.resolve` read
exactly these four, and none of them queries or writes the row -- which is
why none of them had to learn what a draft is."""
made = draft_service.remember("u1", "p1", "/srv/x")
chat = draft_service.as_chat(made)
assert chat.user_id == "u1"
assert chat.kind == KIND_AGENT
assert chat.ssh_profile_id == "p1"
assert chat.project_dir == "/srv/x"
assert chat.canvas_json == {}
def test_the_transient_chat_has_an_id_and_is_never_saved(db):
"""The id matters: `_load_file` compares it against an attachment's, and a
draft upload has `chat_id=None`. `None != None` is False."""
chat = draft_service.as_chat(draft_service.remember("u1", "p1", "/srv/x"))
assert chat.id, "a column default is applied at flush, and this is never flushed"
assert db.get(Chat, chat.id) is None, "and it must not have reached the database"
# --- The refusals --------------------------------------------------------------
def test_the_two_sources_that_need_a_row_are_refused_by_name():
"""Stated rather than left to fall out of an id comparison. `scratch` would
write a row keyed on a chat that does not exist; `file` is the hole."""
assert draft_service.refuses("scratch")
assert draft_service.refuses("file")
assert not draft_service.refuses("agent")
assert not draft_service.refuses("note")
def test_opening_a_refused_source_says_so_rather_than_failing(client, db, target):
user, profile = target
made = draft_service.remember(user.id, profile.id, "/srv/project")
response = client.post(
f"/api/chats/{made.id}/canvas/tabs", data={"key": f"scratch:{made.id}"}
)
assert response.status_code == 200
assert "once this chat exists" in response.text
assert db.query(Chat).count() == 0, "and no row was created on the way"
# --- Adoption ------------------------------------------------------------------
def test_sending_the_first_prompt_adopts_the_shell(client, db, target, monkeypatch):
"""The shell opened while deciding becomes the chat's shell, scrollback and
all. A re-key, not a reconnect: the browser navigates after `start_chat` and
attaches to the session now living under the real id."""
user, profile = target
made = draft_service.remember(user.id, profile.id, "/srv/project")
session = _fake_session(made.id, user.id, profile.id, "/srv/project")
terminal_service._SESSIONS[made.id] = session
try:
client.post(
"/api/chats/start",
data={
"content": "Do the thing.",
"kind": "agent",
"ssh_profile_id": profile.id,
"project_dir": "/srv/project",
"model_id": "m",
"draft_id": made.id,
},
)
chat = db.scalar(select(Chat))
assert terminal_service._SESSIONS.get(chat.id) is session
assert made.id not in terminal_service._SESSIONS
# Both, or `close_for_profile` and the reaper -- which pop by the field
# rather than by the key -- would leave a dead session findable.
assert session.chat_id == chat.id
finally:
terminal_service._SESSIONS.clear()
def test_a_shell_on_a_different_target_is_not_transplanted(client, db, target):
"""`_new_chat` settles the directory last: an empty one falls back to the
connection's own. A shell opened elsewhere belongs to the draft it was
opened under, and is reaped on idle rather than moved."""
user, profile = target
made = draft_service.remember(user.id, profile.id, "/somewhere/else")
session = _fake_session(made.id, user.id, profile.id, "/somewhere/else")
terminal_service._SESSIONS[made.id] = session
try:
client.post(
"/api/chats/start",
data={
"content": "Do the thing.",
"kind": "agent",
"ssh_profile_id": profile.id,
"project_dir": "/srv/project",
"model_id": "m",
"draft_id": made.id,
},
)
chat = db.scalar(select(Chat))
assert chat.id not in terminal_service._SESSIONS
assert session.chat_id == made.id
finally:
terminal_service._SESSIONS.clear()
def test_open_tabs_are_carried_onto_the_chat(client, db, target):
user, profile = target
made = draft_service.remember(user.id, profile.id, "/srv/project")
made.canvas_json = {
"tabs": [
{"key": "agent:/srv/project/main.py", "title": "main.py", "source": "agent"},
# Dropped rather than carried across to fail on first click.
{"key": "scratch:whatever", "title": "Scratch", "source": "scratch"},
],
"active": "agent:/srv/project/main.py",
}
client.post(
"/api/chats/start",
data={
"content": "Do the thing.",
"kind": "agent",
"ssh_profile_id": profile.id,
"project_dir": "/srv/project",
"model_id": "m",
"draft_id": made.id,
},
)
chat = db.scalar(select(Chat))
assert [t["key"] for t in chat.canvas_json["tabs"]] == ["agent:/srv/project/main.py"]
assert draft_service.get(made.id, user.id) is None, "and the draft is done with"
def test_a_draft_belonging_to_somebody_else_adopts_nothing(client, db, target):
user, profile = target
theirs = draft_service.remember("someone-else", profile.id, "/srv/project")
theirs.canvas_json = {"tabs": [{"key": "agent:/etc/shadow", "source": "agent"}]}
client.post(
"/api/chats/start",
data={
"content": "Do the thing.",
"kind": "agent",
"ssh_profile_id": profile.id,
"project_dir": "/srv/project",
"model_id": "m",
"draft_id": theirs.id,
},
)
chat = db.scalar(select(Chat))
assert not (chat.canvas_json or {}).get("tabs")
# --- The screen ----------------------------------------------------------------
def test_the_new_chat_screen_offers_both_panels(client: TestClient, target):
"""The decision this reverses: they used to require a chat, which meant they
were absent on the one screen where you are choosing a machine."""
html = client.get("/chat").text
assert "data-terminal" in html
assert "data-canvas" in html
assert "js/draft.js" in html
def test_the_draft_route_answers_with_a_stable_id(client: TestClient, target):
_user, profile = target
first = client.get(f"/api/agents/{profile.id}/draft?dir=/srv/project").json()
again = client.get(f"/api/agents/{profile.id}/draft?dir=/srv/project").json()
assert first["id"] == again["id"]
assert draft_service.is_draft(first["id"])
assert first["dir"] == "/srv/project"
def test_the_draft_route_refuses_somebody_elses_connection(client: TestClient, db, target):
_user, profile = target
stranger = User(email="s@x.test", name="S", password_hash="x")
db.add(stranger)
db.commit()
profile.owner_id = stranger.id
db.commit()
assert client.get(f"/api/agents/{profile.id}/draft").status_code == 404
class _FakeSession:
def __init__(self, chat_id, owner_id, profile_id, project_dir):
self.chat_id = chat_id
self.owner_id = owner_id
self.profile_id = profile_id
self.project_dir = project_dir
self.closed = False
def _fake_session(chat_id, owner_id, profile_id, project_dir):
return _FakeSession(chat_id, owner_id, profile_id, project_dir)
# --- Against a real machine ----------------------------------------------------
asyncssh = pytest.importorskip("asyncssh")
async def test_a_draft_canvas_opens_and_saves_a_project_file(db, registered, tmp_path):
"""The point of the whole thing: look around the machine, and edit something
on it, before committing to a conversation about it.
Against a real sshd and through `canvas_service` directly rather than the
TestClient -- the client drives the app on another thread, and the server
here is bound to this test's loop. What is being claimed is that a
*transient* chat drives the same SFTP path a real one does, and that is what
this exercises.
"""
from lembas.services import canvas as canvas_service
from lembas.services.agent import ssh as ssh_service
from tests.test_canvas_ssh import _Server
project = tmp_path / "project"
project.mkdir()
(project / "main.py").write_text("print('before')\n")
server = await asyncssh.create_server(
_Server,
"127.0.0.1",
0,
server_host_keys=[asyncssh.generate_private_key("ssh-ed25519")],
# SFTP only: the canvas never goes through a shell -- a path is a path.
sftp_factory=True,
)
port = next(iter(server.sockets)).getsockname()[1]
line, fingerprint = await ssh_service.capture_host_key("127.0.0.1", port)
try:
settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS)
user = db.scalar(select(User))
profile = SshProfile(
owner_id=user.id,
name="Box",
host="127.0.0.1",
port=port,
username="tester",
host_key=line,
host_fingerprint=fingerprint,
default_dir=str(project),
)
db.add(profile)
db.commit()
made = draft_service.remember(user.id, profile.id, str(project))
chat = draft_service.as_chat(made)
key = f"agent:{project}/main.py"
doc = await canvas_service.load(db, user, chat, key)
assert "before" in doc.text
await canvas_service.save(db, user, chat, key, "print('after')\n", doc.revision)
assert (project / "main.py").read_text() == "print('after')\n"
# And none of it reached the database.
assert db.query(Chat).count() == 0
finally:
server.close()
await server.wait_closed()