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>
This commit is contained in:
Jaroslav Beneš
2026-08-04 19:25:10 +02:00
parent e9546dcd1f
commit ffe4966aac
8 changed files with 366 additions and 93 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "0.6.2" __version__ = "0.6.3"
+7 -19
View File
@@ -855,19 +855,6 @@ def _step_html(message_id: str, step) -> str:
) )
def _steps_tail_html(message_id: str) -> str:
"""The two containers the live thinking and prose are swapped into.
Sent as part of the `steps` frame rather than as a frame of its own, which
is how the tail clears when a round closes: what was being written is now a
step above, and re-emitting these empty is what stops it also showing below.
It means `reasoning` and `render` can keep their never-blank guard.
"""
return templates.get_template("chat/_steps_tail.html").render(
{"message": SimpleNamespace(id=message_id, reasoning_ms=0)}
)
def _ask_html(chat_id: str, pending) -> str: def _ask_html(chat_id: str, pending) -> str:
"""The card asking the reader something, or nothing at all. """The card asking the reader something, or nothing at all.
@@ -948,14 +935,15 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
for step in steps_service.closed_from(generation, since=marks_done): for step in steps_service.closed_from(generation, since=marks_done):
rendered.append(_step_html(message_id, step)) rendered.append(_step_html(message_id, step))
marks_done = len(generation.steps) marks_done = len(generation.steps)
yield sse.event( yield sse.event("steps", "".join(rendered))
"steps", "".join(rendered) + _steps_tail_html(message_id) # Sent every pass, empty included. That is what clears the tail
) # when a round closes and its contents become a step above --
# and it is safe precisely because these carry the open tail
# only. The version that carried the whole reply had to be
# guarded, or a frame could wipe the answer.
thinking_tail, text_tail = steps_service.tail(generation) thinking_tail, text_tail = steps_service.tail(generation)
if thinking_tail:
yield sse.event("reasoning", escape_text(thinking_tail)) yield sse.event("reasoning", escape_text(thinking_tail))
if text_tail: yield sse.event("render", render_markdown(text_tail) if text_tail else "")
yield sse.event("render", render_markdown(text_tail))
if generation.canvas.get("tabs"): if generation.canvas.get("tabs"):
# Guarded on truthiness, which puts this in the # Guarded on truthiness, which puts this in the
# reasoning/tools/render group and not the # reasoning/tools/render group and not the
+21 -2
View File
@@ -99,6 +99,20 @@
max-width: 100%; max-width: 100%;
} }
/* A reply is a sequence of steps: thinking, prose, a tool call, more prose. The
gap is what separates one from the next -- without it a paragraph and the
command it led to run together and the ordering the whole thing exists for is
not legible. Each child brings its own margins, so this only has to space
them consistently. */
.msg__steps {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.msg__steps:empty { display: none; }
/* The blocks inside carry the rhythm; their own bottom margins would double it. */
.msg__steps > .reasoning { margin-bottom: 0; }
.msg__error { margin: var(--sp-2) 0; align-items: flex-start; } .msg__error { margin: var(--sp-2) 0; align-items: flex-start; }
/* --- Streaming indicator -------------------------------------------------- */ /* --- Streaming indicator -------------------------------------------------- */
@@ -557,8 +571,13 @@
padding: var(--sp-2) 0; padding: var(--sp-2) 0;
} }
/* Once the answer has content the caret carries the "still going" signal, so /* Once the answer has content the caret carries the "still going" signal, so
the dots go, but Stop must stay reachable until the stream ends. */ the dots go, but Stop must stay reachable until the stream ends.
.msg__body--live:not(:empty) + .msg__waiting .dots { display: none; }
`:has()` on the bubble rather than a sibling selector: the live body and the
waiting row stopped being siblings when the reply became a sequence of steps,
and an adjacent-sibling rule that matches nothing fails by leaving the dots
pulsing beside a finished-looking answer forever. */
.msg__main:has(.msg__body--live:not(:empty)) .msg__waiting .dots { display: none; }
/* Send and Stop are one button. Which icon shows is decided here rather than /* Send and Stop are one button. Which icon shows is decided here rather than
in JavaScript, so the state is visible in the markup and the swap is free. */ in JavaScript, so the state is visible in the markup and the swap is free. */
+27 -9
View File
@@ -95,16 +95,34 @@
{% endif %} {% endif %}
{% if streaming %} {% if streaming %}
{# The reply as a sequence of steps, in the order they happened. The {# The reply as a sequence of steps, in the order they happened. Closed
closed ones are re-sent only when a round ends; the two live containers steps only, re-sent when a round ends. See services/steps.py. #}
come with them, inside `_steps.html`, which is how the tail blanks
itself. See services/steps.py. #}
<div class="msg__steps" id="steps-{{ message.id }}" data-steps <div class="msg__steps" id="steps-{{ message.id }}" data-steps
sse-swap="steps" hx-swap="innerHTML"> sse-swap="steps" hx-swap="innerHTML"></div>
{% with steps = [], live = true %}
{% include "chat/_steps.html" %} {# The step still being written. SIBLINGS of the container above, never
{% endwith %} inside it: that one is swapped whole every time a round closes, and an
</div> `sse-swap` element nested in another is torn out and rebuilt at each
boundary with the frames aimed at it arriving in the same pass. That is
what made an agent reply render nothing from its first tool call on.
Both frames are sent on every version bump *including empty*, which is
how the tail clears when a round closes and its contents move into a
step above. That is safe here and was not before: these carry the open
tail only, so an empty one means the tail is genuinely empty, whereas
the version that carried the whole reply would have wiped it. `steps`
is the one that must never blank now. #}
<details class="reasoning reasoning--live" id="reasoning-{{ message.id }}">
<summary class="reasoning__summary">
{{ icon("sparkle", "icon--sm reasoning__icon") }}
<span class="reasoning__label">Thinking…</span>
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
</summary>
<div class="reasoning__body" sse-swap="reasoning" hx-swap="innerHTML"></div>
</details>
<div class="msg__body msg__body--live" id="stream-{{ message.id }}"
sse-swap="render" hx-swap="innerHTML"></div>
{# Where a question from the model, or a command waiting to be allowed, {# Where a question from the model, or a command waiting to be allowed,
lands. Unlike the blocks above it this frame is sent on every version lands. Unlike the blocks above it this frame is sent on every version
+12 -10
View File
@@ -6,17 +6,19 @@
the moment the stream ends. That was the point of putting the marks on the row the moment the stream ends. That was the point of putting the marks on the row
rather than only on the running generation. rather than only on the running generation.
When `live`, the two containers for the step still being written come last, and Closed steps ONLY. The step still being written lives in `_message.html`, in
they are part of *this* fragment rather than of `_message.html`. That is what two containers that are **siblings of this one, never inside it**.
lets the tail clear itself: the `steps` frame is sent whenever a round closes
and re-emits them empty, so the prose and thinking that have just become a That is not tidiness, it is the whole reason this works. This container is
closed step above do not also linger below. `reasoning` and `render` keep their itself an `sse-swap` target: every time a round closes, its `innerHTML` is
"never send an empty one" guard, and `metrics`, `status` and `ask` remain the replaced. Anything inside it carrying an `sse-swap` of its own is therefore
only frames allowed to blank what is on screen. torn out and rebuilt at every round boundary — and the frames aimed at it in
the same pass have nowhere to land. Nesting them here is what made an agent
reply show nothing at all from its first tool call onwards, while an ordinary
chat was fine: an ordinary chat closes no steps, so the swap never happened.
One `sse-swap` element must never contain another. There is a test.
#} #}
{% for step in steps %} {% for step in steps %}
{% include "chat/_step.html" %} {% include "chat/_step.html" %}
{% endfor %} {% endfor %}
{% if live %}
{% include "chat/_steps_tail.html" %}
{% endif %}
@@ -1,29 +0,0 @@
{% from "_macros.html" import icon %}
{#
The step still being written: everything past the last mark.
These two are the only things that move at streaming speed. Everything above
them is closed and is re-sent only when a round ends, which is what keeps a
forty-round reply from re-rendering its whole transcript twelve times a second.
Both carry the complete block each time rather than a delta -- that is what
makes reattaching to a reply in progress work at all, since a follower arriving
late has no earlier fragments to append to.
The reasoning wrapper is static and only its body is swapped, so a reader who
opens it keeps it open for as long as this step lasts. When the round closes it
becomes a `think-…` block above and comes back collapsed; that is a real seam
and it is left visible rather than papered over with a mapping from an
ephemeral id to a permanent one.
#}
<details class="reasoning reasoning--live" id="reasoning-{{ message.id }}">
<summary class="reasoning__summary">
{{ icon("sparkle", "icon--sm reasoning__icon") }}
<span class="reasoning__label">Thinking…</span>
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
</summary>
<div class="reasoning__body" sse-swap="reasoning" hx-swap="innerHTML"></div>
</details>
<div class="msg__body msg__body--live" id="stream-{{ message.id }}"
sse-swap="render" hx-swap="innerHTML"></div>
+234
View File
@@ -0,0 +1,234 @@
"""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
+63 -22
View File
@@ -877,19 +877,26 @@ def _user_id(db):
return db.scalar(select(User.id)) return db.scalar(select(User.id))
def test_both_branches_of_the_bubble_render_the_same_partial(): def test_both_paths_render_a_step_through_the_same_partial():
"""The streaming shell and the finished bubble are built from one builder, """A live step and a stored one are the same markup, so a reply cannot
so a reply cannot rearrange itself the moment the stream ends -- which is rearrange itself the moment the stream ends -- which is what it used to do
what it used to do the other way round, three zones either side.""" the other way round, three zones either side.
They arrive by different routes and that is deliberate: the finished bubble
loops `_steps.html`, while the stream renders one `_step.html` at a time and
swaps the accumulated prefix in. Both bottom out in the same file, which is
the property worth pinning.
"""
from pathlib import Path from pathlib import Path
import lembas import lembas
source = ( root = Path(lembas.__file__).parent
Path(lembas.__file__).parent / "web/templates/chat/_message.html" steps_partial = (root / "web/templates/chat/_steps.html").read_text()
).read_text() follower = (root / "api/chats.py").read_text()
assert source.count('include "chat/_steps.html"') == 2 assert 'include "chat/_step.html"' in steps_partial
assert 'get_template("chat/_step.html")' in follower
def test_no_template_still_asks_for_a_tools_frame(): def test_no_template_still_asks_for_a_tools_frame():
@@ -905,26 +912,60 @@ def test_no_template_still_asks_for_a_tools_frame():
assert 'sse-swap="tools"' not in path.read_text(), path assert 'sse-swap="tools"' not in path.read_text(), path
def test_the_steps_frame_carries_the_live_containers(): def test_no_sse_swap_element_contains_another():
"""That is how the tail blanks itself. When a round closes, what was being """The regression that made an agent reply render nothing at all.
written becomes a step above; re-emitting these two empty is what stops it
also showing below -- and it lets `reasoning` and `render` keep their
never-send-an-empty-one guard, which is what makes reattaching work."""
from types import SimpleNamespace
from lembas.web.templating import templates `#steps-{id}` is itself an `sse-swap` target, so its whole `innerHTML` is
replaced every time a round closes. An `sse-swap` element nested inside is
therefore torn out and rebuilt at every round boundary -- with the frames
aimed at it arriving in the same pass, at something that is no longer the
element the listener was bound to. An ordinary chat never noticed, because
it closes no steps and the swap never happens; an agent chat lost its answer
from the first tool call onwards.
html = templates.get_template("chat/_steps.html").render( Asserted structurally rather than by rendering, so it holds for whichever
{"steps": [], "live": True, "message": SimpleNamespace(id="m1", reasoning_ms=0)} branch of the template a given reply takes.
) """
import re
from html.parser import HTMLParser
from pathlib import Path
assert 'sse-swap="reasoning"' in html import lembas
assert 'sse-swap="render"' in html
root = Path(lembas.__file__).parent / "web/templates"
class Nesting(HTMLParser):
def __init__(self):
super().__init__()
self.depth = 0
self.stack = []
self.found = []
def handle_starttag(self, tag, attrs):
got = dict(attrs)
swaps = "sse-swap" in got
if swaps and self.depth:
self.found.append(got.get("sse-swap"))
if tag not in ("br", "img", "input", "hr", "meta", "link", "use"):
self.stack.append(swaps)
self.depth += 1 if swaps else 0
def handle_endtag(self, tag):
if self.stack and self.stack.pop():
self.depth -= 1
for path in root.rglob("*.html"):
source = path.read_text()
stripped = re.sub(r"\{%.*?%\}|\{#.*?#\}", "", source, flags=re.S)
stripped = re.sub(r"\{\{.*?\}\}", "x", stripped, flags=re.S)
parser = Nesting()
parser.feed(stripped)
assert not parser.found, f"{path.name} nests sse-swap: {parser.found}"
def test_the_finished_bubble_carries_no_live_containers(): def test_the_finished_bubble_carries_no_live_containers():
"""The mirror of the above. A finished reply with an `sse-swap` in it is a """A finished reply with an `sse-swap` in it is a container waiting for a
container waiting for a stream that is over.""" stream that is over."""
from types import SimpleNamespace from types import SimpleNamespace
from lembas.web.templating import templates from lembas.web.templating import templates