From fe43e95b79427e40228b3ff13d473f75f5f12b77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Tue, 4 Aug 2026 21:03:25 +0200 Subject: [PATCH] A thinking block that says how long and how much MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each block reports its own round now. `reasoning_ms` was the reply's first burst, written once, so on the fifteen-block reply GPT-OSS actually produces only the first could claim a duration and the other fourteen said "Thought" and nothing at all. `Generation.thinking_ms` accumulates per round and `close_step` stamps it cumulatively, so steps.py diffs it exactly as it already diffs the three lengths beside it. The interval between a round's first and last reasoning delta, deliberately, not a sum of gaps between deltas -- that would count the network's latency as the model's thinking. While it runs: "Thinking" with an ellipsis that types itself, and the seconds and tokens climbing beside it. The ellipsis is a `content` keyframe, so there is no timer to start, stop or clean up when the block is swapped away -- it stops existing when the element does. The numbers come from a `think` frame, and `round_thinking_ms` is written by the producer rather than computed by the follower from a start time: a model that has stopped thinking and moved on to a tool should show a settled number, not a clock that keeps running. Tokens read exactly up to 200 and as `0.4k` above it, from one helper shared by the live label and the stored one, so the two cannot drift into two conventions. The live duration is terser than the finished one -- `6s` against `6 seconds` -- because it sits beside an animating word and changes every second, where "less than a second" flickering into "1 second" reads as a glitch. Checked against the real endpoint: fourteen marks carrying 919ms through 14223ms, per-block labels from "less than a second · 111" to "4 seconds · 0.5k", and the live frames resetting each round rather than accumulating. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 42 ++++++- PLAN.md | 2 +- src/lembas/api/chats.py | 20 ++++ src/lembas/services/generation.py | 59 +++++++++- src/lembas/services/steps.py | 115 +++++++++++++++++++- src/lembas/web/static/css/chat.css | 30 +++++ src/lembas/web/templates/chat/_message.html | 12 +- src/lembas/web/templates/chat/_step.html | 12 +- tests/test_chat.py | 21 ++++ tests/test_generation_tools.py | 65 +++++++++++ tests/test_steps.py | 61 +++++++++++ 11 files changed, 427 insertions(+), 12 deletions(-) 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)] == [""]