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:
+63
-22
@@ -877,19 +877,26 @@ def _user_id(db):
|
||||
return db.scalar(select(User.id))
|
||||
|
||||
|
||||
def test_both_branches_of_the_bubble_render_the_same_partial():
|
||||
"""The streaming shell and the finished bubble are built from one builder,
|
||||
so a reply cannot rearrange itself the moment the stream ends -- which is
|
||||
what it used to do the other way round, three zones either side."""
|
||||
def test_both_paths_render_a_step_through_the_same_partial():
|
||||
"""A live step and a stored one are the same markup, so a reply cannot
|
||||
rearrange itself the moment the stream ends -- which is what it used to do
|
||||
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
|
||||
|
||||
import lembas
|
||||
|
||||
source = (
|
||||
Path(lembas.__file__).parent / "web/templates/chat/_message.html"
|
||||
).read_text()
|
||||
root = Path(lembas.__file__).parent
|
||||
steps_partial = (root / "web/templates/chat/_steps.html").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():
|
||||
@@ -905,26 +912,60 @@ def test_no_template_still_asks_for_a_tools_frame():
|
||||
assert 'sse-swap="tools"' not in path.read_text(), path
|
||||
|
||||
|
||||
def test_the_steps_frame_carries_the_live_containers():
|
||||
"""That is how the tail blanks itself. When a round closes, what was being
|
||||
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
|
||||
def test_no_sse_swap_element_contains_another():
|
||||
"""The regression that made an agent reply render nothing at all.
|
||||
|
||||
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(
|
||||
{"steps": [], "live": True, "message": SimpleNamespace(id="m1", reasoning_ms=0)}
|
||||
)
|
||||
Asserted structurally rather than by rendering, so it holds for whichever
|
||||
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
|
||||
assert 'sse-swap="render"' in html
|
||||
import lembas
|
||||
|
||||
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():
|
||||
"""The mirror of the above. A finished reply with an `sse-swap` in it is a
|
||||
container waiting for a stream that is over."""
|
||||
"""A finished reply with an `sse-swap` in it is a container waiting for a
|
||||
stream that is over."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from lembas.web.templating import templates
|
||||
|
||||
Reference in New Issue
Block a user