Files
LLeMbas/tests/test_agent_transcript.py
T
Jaroslav Beneš ffe4966aac An sse-swap element must never contain another
An agent reply rendered nothing from its first tool call onwards. An ordinary
chat was fine, and that difference is the whole diagnosis: `#steps-{id}` is
itself an `sse-swap` target, so its innerHTML is replaced every time a round
closes -- and I had put the live `reasoning` and `render` containers *inside*
it. Every round boundary tore out the two elements the next frames were aimed
at, in the same pass that aimed them. An ordinary chat closes no steps, so the
swap never happened and nothing was ever torn out.

The tail moves back out to `_message.html`, as siblings of the steps container.
That removes the trick where the `steps` frame re-emitted the tail empty in
order to clear it, and replaces it with something simpler: `reasoning` and
`render` are now sent on every pass including empty, which is what clears them
when a round closes. Safe here and not before -- they carry the open tail only,
so an empty one means the tail is empty, where the version that carried the
whole reply would have wiped the answer. `steps` is the frame that must never
blank now.

`tests/test_chat.py` walks every template and refuses any `sse-swap` element
inside another; checked against the bug before being kept.

Two things I had left undone and should not have. `.msg__steps` had no styling
at all, so the sequence ran together with nothing separating a paragraph from
the command it led to. And `.msg__body--live:not(:empty) + .msg__waiting .dots`
stopped matching when those two stopped being siblings, so the dots pulsed
beside a finished answer for ever; it is a `:has()` on the bubble now.

The version bump is not cosmetic either: the service worker keys its cache on
it, so without one every browser kept serving the previous release's CSS and JS
against the new markup.

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

235 lines
7.7 KiB
Python

"""An agent reply, rendered end to end.
The steps rewrite touched the one template every bubble goes through and the
frames that fill it, and an agent reply is the case with all of it at once:
reasoning, prose, tool events, a plan card, a project listing. `test_steps.py`
covers the builder as pure functions; this covers the page.
"""
from __future__ import annotations
import json as _json
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import KIND_AGENT, Chat, Connection, Message, Model, SshProfile, User
from lembas.services import generation as generation_service
from lembas.services import settings_store
def _text_chunk(text: str) -> dict:
return {"choices": [{"delta": {"content": text}}]}
def _tool_chunk(name: str, arguments: str) -> dict:
return {
"choices": [
{
"delta": {
"tool_calls": [
{
"index": 0,
"id": "call_1",
"function": {"name": name, "arguments": arguments},
}
]
}
}
]
}
def _stub_stream(rounds):
calls = {"n": 0}
async def stream(_endpoint, _payload):
index = min(calls["n"], len(rounds) - 1)
calls["n"] += 1
for chunk in rounds[index]:
yield chunk
return stream
@pytest.fixture
def agent_chat(db, registered):
"""An agent chat whose connection exists but has no reachable host.
Deliberately unreachable: what is under test is the *rendering* of a reply
that called a tool, and a tool that fails still produces an event, which is
the thing the transcript has to lay out.
"""
settings_store.update(
db,
{"enabled": True, "background_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()
chat = Chat(
user_id=user.id,
model_id="m",
connection_id=connection.id,
kind=KIND_AGENT,
ssh_profile_id=profile.id,
project_dir="/srv/project",
agent_mode="auto",
)
db.add(chat)
db.commit()
return chat
async def test_an_agent_reply_that_called_a_tool_renders(
client: TestClient, db, agent_chat, registered, monkeypatch
):
"""The regression this file exists for: the bubble is rendered from the
steps now, and an agent reply is where every kind of step appears at once."""
db.add(Message(chat_id=agent_chat.id, role="user", content="Do the thing.", complete=True))
reply = Message(chat_id=agent_chat.id, role="assistant", content="", complete=False)
db.add(reply)
db.commit()
reply_id = reply.id
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream(
[
[
_text_chunk("Listing the project first. "),
_tool_chunk("file_list", _json.dumps({"path": "."})),
],
[_text_chunk("Nothing I can reach.")],
]
),
)
monkeypatch.setattr("lembas.services.chat.generate_title", lambda *a, **k: "")
generation = generation_service.Generation(chat_id=agent_chat.id, message_id=reply_id)
await generation_service._run(generation)
db.expire_all()
stored = db.get(Message, reply_id)
assert stored.tool_calls_json, "the tool event was recorded"
assert stored.steps_json, "and so were the marks"
page = client.get(f"/chat/{agent_chat.id}")
assert page.status_code == 200
assert "Listing the project first" in page.text
assert "Nothing I can reach" in page.text
assert page.text.index("Listing the project first") < page.text.index("tool-activity")
def test_the_page_renders_before_any_reply_exists(client: TestClient, agent_chat, registered):
"""An agent chat with nothing in it. The composer takes a different branch
here -- the mode select, the jobs chip -- and it is the screen somebody sees
first."""
page = client.get(f"/chat/{agent_chat.id}")
assert page.status_code == 200
assert 'name="agent_mode"' in page.text
def test_starting_an_agent_chat_from_the_composer_renders(
client: TestClient, db, registered, agent_chat
):
"""`POST /api/chats/start` returns the thread it just created. This is the
exact moment the reader reported a blank screen."""
profile = db.scalar(select(SshProfile))
response = client.post(
"/api/chats/start",
data={
"content": "Do the thing.",
"kind": "agent",
"ssh_profile_id": profile.id,
"project_dir": "/srv/project",
"model_id": "m",
},
)
# 204 plus HX-Redirect is the contract: the row is created here and the
# browser then navigates to it. The blank screen is on the page it lands on.
assert response.status_code == 204
assert response.headers["HX-Redirect"].startswith("/chat/")
landed = client.get(response.headers["HX-Redirect"])
assert landed.status_code == 200
assert "Do the thing." in landed.text
def test_the_page_renders_while_the_reply_is_still_unfinished(
client: TestClient, db, agent_chat, registered
):
"""The streaming branch of the bubble, which is what somebody sees for the
whole of a long agent reply -- and what they saw blank.
An unfinished assistant message is the *only* thing that starts a
generation, so this is also the page that sets the reply going.
"""
db.add(Message(chat_id=agent_chat.id, role="user", content="Do the thing.", complete=True))
db.add(Message(chat_id=agent_chat.id, role="assistant", content="", complete=False))
db.commit()
page = client.get(f"/chat/{agent_chat.id}")
assert page.status_code == 200
assert "Do the thing." in page.text
assert 'sse-swap="steps"' in page.text, "the container the frames land in"
assert 'sse-swap="render"' in page.text
async def test_the_stream_delivers_the_steps_frame(
client: TestClient, db, agent_chat, registered, monkeypatch
):
"""The SSE path with the new frame set, which is the one place the steps are
rendered without a `Message` row to hang them on."""
db.add(Message(chat_id=agent_chat.id, role="user", content="Do it.", complete=True))
reply = Message(chat_id=agent_chat.id, role="assistant", content="", complete=False)
db.add(reply)
db.commit()
reply_id = reply.id
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream(
[
[
_text_chunk("Listing first. "),
_tool_chunk("file_list", _json.dumps({"path": "."})),
],
[_text_chunk("Done.")],
]
),
)
monkeypatch.setattr("lembas.services.chat.generate_title", lambda *a, **k: "")
with client.stream(
"GET", f"/api/chats/{agent_chat.id}/messages/{reply_id}/stream"
) as response:
assert response.status_code == 200
body = "".join(response.iter_lines())
assert "event: steps" in body, "the closed steps never reached the browser"
assert "event: done" in body
assert "Listing first" in body