diff --git a/CLAUDE.md b/CLAUDE.md index f16ed0e..ee67ac3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused lembas secret-key # generate LEMBAS_SECRET_KEY lembas create-admin # create or promote an admin -pytest # 1500 tests, ~92s +pytest # 1513 tests, ~92s # PLAN.md tracks what is and is not built ruff check . # lint (line length 100) python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons; @@ -276,6 +276,31 @@ sees that flag and re-renders the bubble from the database, so the row has to be authoritative first. The other order silently showed the previous turn's stored metrics. +**A thinking block reports its own round.** `Message.reasoning_ms` was the +reply's *first* burst, written once, so on a forty-round reply only the first +block could honestly claim a duration and the rest said "Thought" and nothing. +`Generation.thinking_ms` accumulates per round — the interval between a round's +first and last reasoning delta, not a sum of per-delta gaps, which would count +the network's latency as the model's thinking — and `close_step` stamps it +cumulatively, so `steps.py` diffs it exactly as it diffs the three lengths. The +last round closes no mark (it is the round that stopped calling tools), so its +duration is what the reply spent beyond the last mark, which is why `_persist` +stores `thinking_ms` into `reasoning_ms` in preference: it is the same +measurement done properly. + +The live block's numbers come from a `think` frame, and `round_thinking_ms` is +written by the *producer*. Computing it in the follower from a start time would +keep the clock running after the model had stopped thinking and moved on to a +tool — a timer rather than a measurement. The animated ellipsis is a `content` +keyframe in CSS: no timer to start, stop or clean up when the block is swapped +away, and it stops existing when the element does. + +**`include_open` and `live` are two questions, and conflating them put a caret +on every finished reply.** One says whether to emit the trailing step, the other +whether it is still being written. `for_message` wants the first without the +second. The test that existed asserted the caret was on the *right* step and +passed; it never asked whether a finished reply should have one. + **A reply is a sequence of steps, and the marks are what make it one.** The three stores a reply writes into -- `content`, `reasoning`, `tool_events` -- are each append-only and each correct, and none of them records *interleaving*. So a @@ -971,6 +996,21 @@ came from an ancestor — the same family as the trigger bound where the event d not go, and the reason that test asserts the resolved property rather than the attributes. +**And htmx events bubble, which is the same lesson a second time.** The form +also declares `hx-on::after-request` so it can clear itself after sending — and +`htmx:afterRequest` bubbles, so every request made by anything inside the form +ran that handler. Six things do: the two scope switches, "ask me about these +again", the agent mode select, the effort select and the jobs chip. Changing the +mode or the effort while typing therefore called `this.reset()` on the composer +and dragged the view to the bottom, and had done for as long as those controls +existed; the chip only made it periodic, and therefore visible. The guard is +`event.target === this`. A form's own handler answers its own request. + +Both bugs had correct markup whose meaning came from an ancestor. That is why +both tests assert the *resolved* behaviour — one walks the form and refuses a +descendant that fetches without a target, the other drives the handler under a +DOM stub and fires the event from a descendant. + **Background jobs have a chip in the composer row and a panel behind it.** A job runs detached for as long as it takes and the only way to see one used to be asking the model to call `job_list` — something that outlives the reply that diff --git a/PLAN.md b/PLAN.md index f6b4b75..06509d0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -7,7 +7,7 @@ that would be expensive to revisit. Kept current as work lands; the detail of **Status:** usable daily. Streaming chat, attachments, reasoning, tool calling with web search, custom HTTP tools and MCP servers, agent chats that work on a machine over SSH, a knowledge library, notes, memory and skills, speech in and -out, users and groups, model administration, installable as an app. 1500 tests, +out, users and groups, model administration, installable as an app. 1513 tests, `ruff` clean. --- diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index 283ad94..35f11de 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -38,6 +38,7 @@ from lembas.services import interaction, settings_store, sse from lembas.services import metrics as metrics_service from lembas.services import prompts as prompts_service from lembas.services import steps as steps_service +from lembas.services import tokens as tokens_service from lembas.services import tools as tools_service from lembas.services.agent import policy as agent_policy from lembas.services.agent import terminal as terminal_service @@ -855,6 +856,24 @@ def _step_html(message_id: str, step) -> str: ) +def _think_label(generation, thinking_tail: str) -> str: + """How long this round has been thinking, and roughly how much. + + This round's, not the reply's, so the live block means the same thing as the + closed blocks above it and does not change meaning the moment it settles. + The reply's total is already under the bubble, in the metrics chips. + + The producer owns the number. Computing it here from a start time would + keep the clock running after the model had stopped thinking and moved on to + a tool, which is a timer rather than a measurement. + """ + return steps_service.thinking_label( + ms=generation.round_thinking_ms, + tokens=tokens_service.estimate(thinking_tail), + live=True, + ) + + def _ask_html(chat_id: str, pending) -> str: """The card asking the reader something, or nothing at all. @@ -946,6 +965,7 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]: # guarded, or a frame could wipe the answer. thinking_tail, text_tail = steps_service.tail(generation) yield sse.event("reasoning", escape_text(thinking_tail)) + yield sse.event("think", escape_text(_think_label(generation, thinking_tail))) yield sse.event("render", render_markdown(text_tail) if text_tail else "") if generation.canvas.get("tabs"): # Guarded on truthiness, which puts this in the diff --git a/src/lembas/services/generation.py b/src/lembas/services/generation.py index 91a8851..731e627 100644 --- a/src/lembas/services/generation.py +++ b/src/lembas/services/generation.py @@ -103,6 +103,17 @@ class Generation: content: list[str] = field(default_factory=list) reasoning: list[str] = field(default_factory=list) reasoning_ms: int = 0 + # Milliseconds spent thinking, summed over rounds. Distinct from + # `reasoning_ms`, which is the reply's *first* burst and is written once -- + # right for "Thought for 8 seconds" on a single-round answer, and unable to + # say anything about round seven of forty. Stamped on each mark by + # `close_step` and diffed by services/steps.py into a per-block figure. + thinking_ms: int = 0 + # How long the round *currently* running has been thinking. Read by the live + # block's label, and written by the producer rather than computed from a + # start time by the follower: a model that has stopped thinking and moved on + # to a tool should show a settled number, not a clock that keeps running. + round_thinking_ms: int = 0 # One entry per tool call made while producing this reply, in order. Shown # live as the model works and kept on the message afterwards. @@ -234,6 +245,8 @@ class Generation: "thinking_to": len(self.thinking), "text_to": len(self.text), "tools_to": len(self.tool_events), + # Cumulative, like the three above it, and diffed the same way. + "thinking_ms": self.thinking_ms, } ) @@ -554,6 +567,13 @@ async def _run(generation: Generation) -> None: # Text the model produced in *this* round, needed separately from # generation.content when echoing the assistant turn back. round_text: list[str] = [] + # When this round's thinking started and when it was last seen, so + # the interval can be added to `generation.thinking_ms` at the + # round's end. Per round, because the thinking block is per round: + # `reasoning_ms` is the whole reply's first burst, written once, and + # cannot say how long round seven thought for. `None` until the + # round thinks at all -- plenty of rounds do not. + round_thinking: tuple[float, float] | None = None async for chunk in stream_chat(endpoint, payload): counts = chunk_usage(chunk) @@ -572,6 +592,7 @@ async def _run(generation: Generation) -> None: if thought: if reasoning_started is None: reasoning_started = time.monotonic() + round_thinking = _thought_at(generation, round_thinking) generation.reasoning.append(thought) generation.touch() @@ -586,6 +607,7 @@ async def _run(generation: Generation) -> None: if kind == REASONING: if reasoning_started is None: reasoning_started = time.monotonic() + round_thinking = _thought_at(generation, round_thinking) generation.reasoning.append(piece) else: if reasoning_started is not None and not generation.reasoning_ms: @@ -624,6 +646,8 @@ async def _run(generation: Generation) -> None: # output, so the mark belongs at the round's end. # See metrics._since_counted. _mark_counted(generation) + # Before `close_step` below, which stamps the total this adds to. + round_thinking = _close_thinking(generation, round_thinking) calls = accumulator.calls if generation.stopped or not calls: @@ -1218,6 +1242,35 @@ def _too_big(generation: Generation) -> bool: return generation.prompt_estimate > generation.context_limit * CONTEXT_HEADROOM +def _thought_at(generation: Generation, span: tuple[float, float] | None) -> tuple[float, float]: + """Widen this round's thinking interval to now. + + First call in a round opens it; every later one moves its end. The interval + rather than a running sum, because reasoning arrives in a burst of small + deltas and adding a gap per delta would count the network's latency as the + model's thinking. + """ + now = time.monotonic() + span = (now, now) if span is None else (span[0], now) + generation.round_thinking_ms = int((span[1] - span[0]) * 1000) + return span + + +def _close_thinking( + generation: Generation, span: tuple[float, float] | None +) -> tuple[float, float] | None: + """Add this round's thinking to the reply's total. Returns None to reopen. + + Called where the round ends, so `close_step` can stamp a cumulative figure + that `services/steps.py` diffs into a per-block duration -- the same shape + as the three lengths it already stamps. + """ + if span is not None: + generation.thinking_ms += int((span[1] - span[0]) * 1000) + generation.round_thinking_ms = 0 + return None + + def _mark_counted(generation: Generation) -> None: """Record that everything written so far is covered by a reported count. @@ -1848,7 +1901,11 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None: message.content = generation.text message.reasoning = generation.thinking - message.reasoning_ms = generation.reasoning_ms + # `thinking_ms` in preference: it is the same measurement done + # properly, summed over every round rather than stopping at the + # first burst, and it is what the trailing block's duration is + # derived from. Falls back for a reply that produced no marks. + message.reasoning_ms = generation.thinking_ms or generation.reasoning_ms message.tool_calls_json = generation.tool_events # Written together with the three stores it indexes, by the one # writer, so a row can never carry marks that describe a different diff --git a/src/lembas/services/steps.py b/src/lembas/services/steps.py index 6a53054..216dd1f 100644 --- a/src/lembas/services/steps.py +++ b/src/lembas/services/steps.py @@ -24,12 +24,18 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Any +from lembas.services import tokens as tokens_service from lembas.services.markdown import open_fence, render_markdown +from lembas.services.reasoning import format_duration KIND_THINKING = "thinking" KIND_TEXT = "text" KIND_TOOLS = "tools" +# Below this a token count is printed exactly; above it, as `1.4k`. The point is +# where the digits stop meaning anything to a reader. +TOKENS_EXACT_BELOW = 200 + @dataclass(frozen=True) class Step: @@ -49,6 +55,56 @@ class Step: text: str = "" html: str = "" events: tuple[dict, ...] = field(default_factory=tuple) + # How long this block thought for and roughly how much it produced. Only on + # a thinking step, and only where there is something to say -- a block whose + # duration was never recorded (every row written before the marks carried + # one) shows the word alone rather than "Thought for 0 seconds". + label: str = "" + + +def format_tokens(count: int) -> str: + """A token count as a person reads it: `86`, or `0.2k` once it is worth it. + + The threshold is where the exact number stops carrying information: nobody + acts on the difference between 214 and 219 tokens of thinking, and four + digits beside a spinner is noise. One helper, so the live label and the + stored one cannot drift into two conventions. + """ + if count <= 0: + return "" + if count <= TOKENS_EXACT_BELOW: + return str(count) + return f"{count / 1000:.1f}k" + + +def thinking_label(*, ms: int, tokens: int, live: bool) -> str: + """What a thinking block says about itself. + + The two states are one function because they are one sentence with a + different tense, and because the running one becomes the finished one in + place -- a reader watching the numbers climb should see them settle, not be + replaced by something formatted differently. + + The word itself is not here: the live block animates its own ellipsis in CSS + and the template owns that. This is only what follows it. + """ + parts = [] + if ms > 0: + parts.append(format_duration(ms) if not live else _short_duration(ms)) + if counted := format_tokens(tokens): + parts.append(counted) + return " · ".join(parts) + + +def _short_duration(ms: int) -> str: + """`6s`, `1m 04s`. Terser than `format_duration` because it sits beside an + animating word and changes every second; "less than a second" flickering + into "1 second" reads as a glitch rather than as a measurement.""" + seconds = max(0, ms) // 1000 + if seconds < 60: + return f"{seconds}s" + minutes, remainder = divmod(seconds, 60) + return f"{minutes}m {remainder:02d}s" def for_message(message: Any) -> list[Step]: @@ -63,6 +119,7 @@ def for_message(message: Any) -> list[Step]: thinking=(message.reasoning or "") if not message.error else "", events=list(message.tool_calls_json or []), marks=list(getattr(message, "steps_json", None) or []), + whole_ms=int(getattr(message, "reasoning_ms", 0) or 0), # A stored reply has a trailing step and it is not being written. Those # are two facts and they used to be one flag: `include_open` both # emitted the tail and marked it live, so every finished bubble ending @@ -118,6 +175,7 @@ def _build( since: int = 0, include_open: bool = True, live: bool = True, + whole_ms: int = 0, ) -> list[Step]: """The shared walk. @@ -141,7 +199,26 @@ def _build( # nothing: for that one the two orders are the same list, because there # are no tool blocks to sit between the prose. if thinking: - steps.append(Step(index=0, kind=KIND_THINKING, text=thinking)) + # No marks means no per-round timing was ever recorded, so the + # duration falls back to whatever the row knows about the reply as a + # whole -- which for a single-round answer is exactly right, and is + # what every row written before the marks existed carries. + steps.append( + Step( + index=0, + kind=KIND_THINKING, + text=thinking, + # Nothing baked in while the reply runs: the live block's + # numbers come from the `think` frame, which knows the + # clock. A label rendered here would be one that never + # moved again. + label="" + if live + else thinking_label( + ms=whole_ms, tokens=tokens_service.estimate(thinking), live=False + ), + ) + ) if events: steps.append(Step(index=0, kind=KIND_TOOLS, events=tuple(events))) if text: @@ -153,6 +230,7 @@ def _build( thought_from = 0 text_from = 0 tools_from = 0 + thought_ms_from = 0 carry = "" for index, mark in enumerate(marks): @@ -175,7 +253,18 @@ def _build( if index >= since: # Thinking, then prose, then tools -- the order a model emits them. if thought: - steps.append(Step(index=index, kind=KIND_THINKING, text=thought)) + steps.append( + Step( + index=index, + kind=KIND_THINKING, + text=thought, + label=thinking_label( + ms=_at(mark, "thinking_ms") - thought_ms_from, + tokens=tokens_service.estimate(thought), + live=False, + ), + ) + ) if said: steps.append(Step(index=index, kind=KIND_TEXT, html=render_markdown(source))) if ran: @@ -183,6 +272,9 @@ def _build( carry = f"{marker}{info}" if marker else "" thought_from, text_from, tools_from = thought_to, text_to, tools_to + # Cumulative on the mark, so a block's own duration is the difference -- + # the same rule the three lengths above follow. + thought_ms_from = max(_at(mark, "thinking_ms"), thought_ms_from) if not include_open: return steps @@ -191,7 +283,24 @@ def _build( # live path and the stored one -- one rule instead of two that could drift. index = len(marks) if trailing_thought := thinking[thought_from:]: - steps.append(Step(index=index, kind=KIND_THINKING, text=trailing_thought)) + # The last round closes no mark -- it is the round that stopped calling + # tools -- so its duration is whatever the reply spent thinking beyond + # the last one that did. Zero while the reply is running, where the live + # block carries its own label instead. + steps.append( + Step( + index=index, + kind=KIND_THINKING, + text=trailing_thought, + label="" + if live + else thinking_label( + ms=max(0, whole_ms - thought_ms_from), + tokens=tokens_service.estimate(trailing_thought), + live=False, + ), + ) + ) if trailing_text := text[text_from:]: source = f"{carry}\n{trailing_text}" if carry else trailing_text steps.append( diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css index 1d4606b..f934ee8 100644 --- a/src/lembas/web/static/css/chat.css +++ b/src/lembas/web/static/css/chat.css @@ -181,6 +181,36 @@ .reasoning__summary::-webkit-details-marker { display: none; } .reasoning__summary:hover { color: var(--ink); background: var(--surface-hover); } +/* "Thinking" with an ellipsis that types itself: `.` `..` `...`, on a step + timer so it lands on whole dots rather than sliding. Animating `content` is + the only way to do this without a JavaScript timer to start, stop and clean + up when the block is swapped away -- and this one simply stops existing when + the element does. + + `min-width` on the pseudo-element so the label does not jog left and right as + the dots come and go; `ch` because it is exactly three dot-widths. */ +.reasoning__working::after { + content: "..."; + display: inline-block; + min-width: 1.6ch; + text-align: left; + animation: thinking-dots 1.5s steps(1) infinite; +} +@keyframes thinking-dots { + 0% { content: "."; } + 33% { content: ".."; } + 66% { content: "..."; } +} + +/* The seconds and the token count. Tabular figures so a climbing number does + not shift the text beside it on every tick. Empty until the first frame + arrives, and `:empty` keeps its separator from showing before that. */ +.reasoning__stats { + color: var(--ink-faint); + font-variant-numeric: tabular-nums; +} +.reasoning__stats:not(:empty)::before { content: " · "; } + /* --- Compacted turns ------------------------------------------------------- Summarised messages, kept and readable but out of the way. Collapsed by default: the point of compacting was that they stopped mattering. diff --git a/src/lembas/web/templates/chat/_message.html b/src/lembas/web/templates/chat/_message.html index da4b602..858b6c6 100644 --- a/src/lembas/web/templates/chat/_message.html +++ b/src/lembas/web/templates/chat/_message.html @@ -112,10 +112,20 @@ 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. #} + {# The word animates its own ellipsis in CSS -- `.` `..` `...` -- so there + is no timer to start or clean up, and it stops when the element does. + The numbers beside it come from the `think` frame. + + That span is a SIBLING of the body, inside a `
` that is never + itself swapped. Two swap targets in one static container is fine; a + swap target inside another is what blanked every agent chat. #}
{{ icon("sparkle", "icon--sm reasoning__icon") }} - Thinking… + + Thinking + + {{ icon("chevron-down", "icon--sm reasoning__chevron") }}
diff --git a/src/lembas/web/templates/chat/_step.html b/src/lembas/web/templates/chat/_step.html index b4ba45f..de9b4a4 100644 --- a/src/lembas/web/templates/chat/_step.html +++ b/src/lembas/web/templates/chat/_step.html @@ -19,12 +19,14 @@
{{ icon("sparkle", "icon--sm reasoning__icon") }} + {# Each block reports its own round: `step.label` is built from that + block's slice of the thinking and the interval between its mark and the + one before it. It used to read the reply's total, which meant only the + first block could honestly claim it and the other eleven said "Thought" + and nothing else. #} - {% if step.index == 0 and message.reasoning_ms %} - {# The duration is for the whole reply, so only the first block may - claim it. Repeating it on each would be four blocks each saying - they took ninety seconds. #} - Thought for {{ message.reasoning_ms | duration }} + {% if step.label %} + Thought for {{ step.label }} {% else %} Thought {% endif %} diff --git a/tests/test_chat.py b/tests/test_chat.py index cf536a0..9b1e6fc 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -1044,3 +1044,24 @@ def test_the_composer_form_answers_only_its_own_request(): assert "event.target === this" in handler.group(1), ( "a descendant's request will run this handler without the guard" ) + + +def test_the_think_frame_lands_beside_the_reasoning_body_not_around_it(): + """The live block has two swap targets inside one static `
` -- the + label and the body. Two siblings is fine; one inside the other is what + blanked every agent chat, because the outer swap tears out the inner + element while the frames aimed at it are still arriving.""" + from pathlib import Path + + import lembas + + source = ( + Path(lembas.__file__).parent / "web/templates/chat/_message.html" + ).read_text() + + label = source.index('sse-swap="think"') + body = source.index('sse-swap="reasoning"') + between = source[min(label, body) : max(label, body)] + # Neither element may open a tag that the other closes: siblings, not nested. + assert "" in between or "" in between + assert between.count(" 0, "the reply spent time thinking" + assert generation.steps, "and closed a step" + assert generation.steps[0]["thinking_ms"] > 0, "stamped on the mark" + # Cumulative on the mark, so a block's own duration is the difference -- + # the same rule the three lengths beside it follow. + assert generation.steps[0]["thinking_ms"] <= generation.thinking_ms + + db.expire_all() + stored = db.get(Message, message_id) + assert stored.reasoning_ms == generation.thinking_ms, ( + "the row keeps the summed figure, not the first burst" + ) + + +async def test_the_round_clock_settles_when_thinking_stops(db, user_id, monkeypatch): + """`round_thinking_ms` is written by the producer, not computed from a start + time by the follower -- a model that has stopped thinking and moved on to a + tool should show a settled number rather than a clock that keeps running.""" + settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH) + chat_id, message_id = _chat_with_tools(db, user_id) + monkeypatch.setattr("lembas.services.search.run", _empty_search) + monkeypatch.setattr( + generation_service, + "stream_chat", + _stub_stream([[_text_chunk("No thinking at all.")]], []), + ) + monkeypatch.setattr("lembas.services.chat.generate_title", _never_called_title) + + generation = generation_service.Generation(chat_id=chat_id, message_id=message_id) + await generation_service._run(generation) + + assert generation.round_thinking_ms == 0 + assert generation.thinking_ms == 0 diff --git a/tests/test_steps.py b/tests/test_steps.py index 19c2d9d..f82f6d8 100644 --- a/tests/test_steps.py +++ b/tests/test_steps.py @@ -346,3 +346,64 @@ def _live_steps(generation): events=list(generation.tool_events), marks=list(generation.steps), ) + + +# --- What a thinking block says about itself ---------------------------------- +def test_a_token_count_stays_exact_until_the_digits_stop_meaning_anything(): + assert steps.format_tokens(0) == "" + assert steps.format_tokens(86) == "86" + assert steps.format_tokens(200) == "200" + assert steps.format_tokens(201) == "0.2k" + assert steps.format_tokens(1500) == "1.5k" + + +def test_the_live_label_is_terser_than_the_finished_one(): + """It sits beside an animating word and changes every second. "less than a + second" flickering into "1 second" reads as a glitch, not a measurement.""" + assert steps.thinking_label(ms=6000, tokens=400, live=True) == "6s · 0.4k" + assert steps.thinking_label(ms=6000, tokens=400, live=False) == "6 seconds · 0.4k" + assert steps.thinking_label(ms=64000, tokens=0, live=True) == "1m 04s" + + +def test_a_block_with_nothing_to_report_says_nothing(): + """Rather than "Thought for 0 seconds", which is worse than the bare word.""" + assert steps.thinking_label(ms=0, tokens=0, live=False) == "" + assert steps.thinking_label(ms=0, tokens=0, live=True) == "" + + +def test_each_block_reports_its_own_round_not_the_reply(): + """The whole point of the per-round timing. It used to read the reply's + total, so only the first block could honestly claim it and every other one + said "Thought" and nothing else.""" + built = steps.for_message( + _message( + reasoning="a" * 400 + "b" * 800, + events=[{"name": "x"}], + marks=[ + {"round": 1, "thinking_to": 400, "text_to": 0, "tools_to": 1, "thinking_ms": 4000} + ], + ms=9000, + ) + ) + + labels = [s.label for s in built if s.kind == "thinking"] + assert labels[0].startswith("4 seconds") + # The last round closes no mark -- it is the one that stopped calling + # tools -- so its duration is whatever the reply spent beyond the last mark. + assert labels[1].startswith("5 seconds") + + +def test_a_row_with_no_per_round_timing_falls_back_to_the_reply(): + """Every reply written before the marks carried a duration. One block, and + the reply's own figure is exactly right for it.""" + built = steps.for_message(_message(reasoning="a" * 400, ms=3000)) + + assert built[0].label.startswith("3 seconds") + + +def test_a_running_block_carries_no_label_of_its_own(): + """The live one is fed by the `think` frame, which knows the clock. Baking a + stale number into the markup would be a number that never moved.""" + generation = _generation(reasoning=["still thinking"]) + + assert [s.label for s in _live_steps(generation)] == [""]