30ddcba787
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) <noreply@anthropic.com>
410 lines
15 KiB
Python
410 lines
15 KiB
Python
"""A reply as the sequence of steps it was.
|
|
|
|
The interesting cases are all about what a bubble does when the marks and the
|
|
three stores disagree, because that is what an old row, a half-written persist
|
|
and a hand-edited column all look like. None of them may throw: a transcript
|
|
that renders in the wrong order is a nuisance, one that will not render is a
|
|
page nobody can open.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
from lembas.services import steps
|
|
from lembas.services.markdown import open_fence
|
|
|
|
|
|
def _message(*, content="", reasoning="", events=None, marks=None, error="", ms=0):
|
|
return SimpleNamespace(
|
|
id="m1",
|
|
content=content,
|
|
reasoning=reasoning,
|
|
reasoning_ms=ms,
|
|
error=error,
|
|
tool_calls_json=list(events or []),
|
|
steps_json=list(marks or []),
|
|
)
|
|
|
|
|
|
def _kinds(built):
|
|
return [(step.index, step.kind) for step in built]
|
|
|
|
|
|
# --- The compatibility layout --------------------------------------------------
|
|
def test_a_reply_with_no_marks_reads_exactly_as_it_always_did():
|
|
"""Every row written before the marks existed. Thinking, then every tool
|
|
block, then the whole answer -- which is what those bubbles have shown since
|
|
the beginning, and there is no version flag anywhere to say so."""
|
|
built = steps.for_message(
|
|
_message(content="the answer", reasoning="hmm", events=[{"name": "a"}, {"name": "b"}])
|
|
)
|
|
|
|
assert _kinds(built) == [(0, "thinking"), (0, "tools"), (0, "text")]
|
|
assert built[1].events == ({"name": "a"}, {"name": "b"})
|
|
|
|
|
|
def test_a_new_reply_that_called_nothing_is_the_same_list():
|
|
"""The one ambiguity in "no marks means the old layout", and it is harmless:
|
|
with no tool blocks to sit between the prose, the old order and the new one
|
|
are the same sequence."""
|
|
built = steps.for_message(_message(content="hello", reasoning="hmm"))
|
|
|
|
assert _kinds(built) == [(0, "thinking"), (0, "text")]
|
|
|
|
|
|
def test_a_failed_reply_does_not_show_its_thinking():
|
|
built = steps.for_message(_message(content="", reasoning="hmm", error="boom"))
|
|
|
|
assert built == []
|
|
|
|
|
|
# --- Interleaving --------------------------------------------------------------
|
|
def test_prose_either_side_of_a_tool_call_renders_either_side_of_it():
|
|
"""The whole point. This used to be one thinking block, then every tool
|
|
block, then all the prose at the bottom -- fine on a two-round answer and
|
|
unusable on a forty-round one."""
|
|
built = steps.for_message(
|
|
_message(
|
|
content="Looking now. All fourteen pass.",
|
|
reasoning="first thoughtsecond thought",
|
|
events=[{"name": "shell_run"}],
|
|
marks=[{"round": 0, "thinking_to": 13, "text_to": 12, "tools_to": 1}],
|
|
)
|
|
)
|
|
|
|
assert _kinds(built) == [
|
|
(0, "thinking"),
|
|
(0, "text"),
|
|
(0, "tools"),
|
|
(1, "thinking"),
|
|
(1, "text"),
|
|
]
|
|
assert built[0].text == "first thought"
|
|
assert "Looking now." in built[1].html
|
|
assert built[3].text == "second thought"
|
|
assert "fourteen pass" in built[4].html
|
|
|
|
|
|
def test_thinking_is_sliced_per_round_and_the_column_stays_whole():
|
|
"""A dozen thinking blocks in one bubble, each beside the command it led to,
|
|
and `Message.reasoning` still the single string everything else reads."""
|
|
message = _message(
|
|
reasoning="round oneround two",
|
|
content="",
|
|
events=[{"name": "a"}],
|
|
marks=[{"round": 0, "thinking_to": 9, "text_to": 0, "tools_to": 1}],
|
|
)
|
|
built = steps.for_message(message)
|
|
|
|
assert [s.text for s in built if s.kind == "thinking"] == ["round one", "round two"]
|
|
assert message.reasoning == "round oneround two", "the column is untouched"
|
|
|
|
|
|
def test_only_the_trailing_prose_is_marked_live():
|
|
"""`--live` draws the caret, and a caret after every paragraph that happened
|
|
to precede a tool call is not where the reply is being written.
|
|
|
|
Driven through the *live* path deliberately. This used to go through
|
|
`for_message` and pass, which is precisely how a stored reply came to blink
|
|
a cursor for ever: the test asserted the caret was in the right place
|
|
without ever asking whether it should be there at all.
|
|
"""
|
|
generation = _generation(
|
|
content=["before after"],
|
|
tool_events=[{"name": "a"}],
|
|
steps=[{"round": 0, "thinking_to": 0, "text_to": 6, "tools_to": 1}],
|
|
)
|
|
|
|
assert [s.open for s in _live_steps(generation) if s.kind == "text"] == [False, True]
|
|
|
|
|
|
def test_a_step_with_nothing_in_it_produces_nothing():
|
|
"""A round that only called a tool leaves no empty prose block behind it."""
|
|
built = steps.for_message(
|
|
_message(
|
|
content="",
|
|
events=[{"name": "a"}],
|
|
marks=[{"round": 0, "thinking_to": 0, "text_to": 0, "tools_to": 1}],
|
|
)
|
|
)
|
|
|
|
assert _kinds(built) == [(0, "tools")]
|
|
|
|
|
|
# --- Offsets that disagree with the stores -------------------------------------
|
|
def test_offsets_past_the_end_are_clamped_rather_than_raising():
|
|
built = steps.for_message(
|
|
_message(
|
|
content="short",
|
|
reasoning="tiny",
|
|
events=[{"name": "a"}],
|
|
marks=[{"round": 0, "thinking_to": 9999, "text_to": 9999, "tools_to": 9999}],
|
|
)
|
|
)
|
|
|
|
assert "short" in built[1].html
|
|
assert built[0].text == "tiny"
|
|
|
|
|
|
def test_offsets_that_go_backwards_lose_nothing():
|
|
"""A second mark earlier than the first would slice backwards and silently
|
|
drop text. It comes out empty instead, and the tail still arrives."""
|
|
built = steps.for_message(
|
|
_message(
|
|
content="one two three",
|
|
marks=[
|
|
{"round": 0, "thinking_to": 0, "text_to": 8, "tools_to": 0},
|
|
{"round": 1, "thinking_to": 0, "text_to": 2, "tools_to": 0},
|
|
],
|
|
)
|
|
)
|
|
|
|
assert "one two" in built[0].html
|
|
assert "three" in built[-1].html
|
|
|
|
|
|
def test_junk_in_the_column_does_not_stop_the_bubble_rendering():
|
|
built = steps.for_message(
|
|
_message(content="hello", marks=[{}, {"text_to": None}, {"text_to": "lots"}])
|
|
)
|
|
|
|
assert any("hello" in step.html for step in built)
|
|
|
|
|
|
def test_a_row_written_before_the_column_existed_reads_as_no_marks():
|
|
message = _message(content="hello")
|
|
message.steps_json = None
|
|
|
|
assert _kinds(steps.for_message(message)) == [(0, "text")]
|
|
|
|
|
|
# --- Code fences across a tool call --------------------------------------------
|
|
def test_a_fence_left_open_is_closed_and_reopened_around_the_tool_call():
|
|
"""Splitting the markdown at a round boundary can leave a fence open, and
|
|
markdown-it then runs it to the end of that segment and mispairs every later
|
|
fence in the reply. Each piece closes its own and the next reopens it."""
|
|
opened = "Here:\n```python\nx = 1\n"
|
|
built = steps.for_message(
|
|
_message(
|
|
content=opened + "and the rest\n",
|
|
events=[{"name": "a"}],
|
|
marks=[{"round": 0, "thinking_to": 0, "text_to": len(opened), "tools_to": 1}],
|
|
)
|
|
)
|
|
|
|
first, last = (s for s in built if s.kind == "text")
|
|
# `code-block`, not the literal source: the fence renderer highlights, so
|
|
# `x = 1` comes back as a run of spans.
|
|
assert "code-block" in first.html
|
|
assert "rest" in last.html
|
|
assert "code-block" in last.html, "the fence carries on rather than the prose becoming code"
|
|
|
|
|
|
def test_the_carry_never_touches_the_stored_text():
|
|
"""It is a rendering device. `build_messages`, titling and the copy button
|
|
all read `message.content`, and it has to be what the model wrote."""
|
|
text = "```python\nx = 1\nmore"
|
|
message = _message(
|
|
content=text,
|
|
marks=[{"round": 0, "thinking_to": 0, "text_to": 16, "tools_to": 0}],
|
|
)
|
|
steps.for_message(message)
|
|
|
|
assert message.content == text
|
|
|
|
|
|
def test_a_fence_closed_before_the_boundary_carries_nothing():
|
|
text = "```py\nx\n```\ndone. more"
|
|
built = steps.for_message(
|
|
_message(
|
|
content=text,
|
|
marks=[{"round": 0, "thinking_to": 0, "text_to": 18, "tools_to": 0}],
|
|
)
|
|
)
|
|
|
|
assert "<pre" not in built[-1].html
|
|
|
|
|
|
def test_open_fence_reads_the_common_shapes():
|
|
assert open_fence("nothing here") == ("", "")
|
|
assert open_fence("a\n```python\nx = 1") == ("```", "python")
|
|
assert open_fence("a\n```python\nx = 1\n```\nb") == ("", "")
|
|
assert open_fence("~~~js\nx") == ("~~~", "js")
|
|
# A fence marker inside an open fence is text, not a closer: it carries an
|
|
# info string, and a closer never does.
|
|
assert open_fence("```\n```python inside\n") == ("```", "")
|
|
|
|
|
|
# --- The live path -------------------------------------------------------------
|
|
def _generation(**fields):
|
|
from lembas.services import generation as generation_service
|
|
|
|
generation = generation_service.Generation(chat_id="c", message_id="m")
|
|
for key, value in fields.items():
|
|
setattr(generation, key, value)
|
|
return generation
|
|
|
|
|
|
def test_closed_from_returns_only_what_a_follower_has_not_seen():
|
|
"""`_follow` keeps what it has rendered. A closed step never changes again,
|
|
which is what stops a forty-round reply re-rendering its whole transcript
|
|
twelve times a second -- the cost the old `tools` frame actually paid."""
|
|
generation = _generation(
|
|
content=["one ", "two "],
|
|
tool_events=[{"name": "a"}, {"name": "b"}],
|
|
steps=[
|
|
{"round": 0, "thinking_to": 0, "text_to": 4, "tools_to": 1},
|
|
{"round": 1, "thinking_to": 0, "text_to": 8, "tools_to": 2},
|
|
],
|
|
)
|
|
|
|
assert _kinds(steps.closed_from(generation, since=0)) == [
|
|
(0, "text"),
|
|
(0, "tools"),
|
|
(1, "text"),
|
|
(1, "tools"),
|
|
]
|
|
assert _kinds(steps.closed_from(generation, since=1)) == [(1, "text"), (1, "tools")]
|
|
|
|
|
|
def test_closed_from_never_includes_the_step_still_being_written():
|
|
generation = _generation(
|
|
content=["done ", "still going"],
|
|
steps=[{"round": 0, "thinking_to": 0, "text_to": 5, "tools_to": 0}],
|
|
)
|
|
|
|
assert all("still going" not in step.html for step in steps.closed_from(generation, since=0))
|
|
|
|
|
|
def test_the_tail_is_what_is_past_the_last_mark():
|
|
generation = _generation(
|
|
content=["closed ", "open"],
|
|
reasoning=["thought ", "thinking"],
|
|
steps=[{"round": 0, "thinking_to": 8, "text_to": 7, "tools_to": 0}],
|
|
)
|
|
|
|
assert steps.tail(generation) == ("thinking", "open")
|
|
|
|
|
|
def test_the_tail_reopens_a_fence_from_the_closed_part():
|
|
"""Otherwise the code being written mid-reply stops looking like code the
|
|
moment a round closes underneath it."""
|
|
generation = _generation(
|
|
content=["```python\n", "x = 1"],
|
|
steps=[{"round": 0, "thinking_to": 0, "text_to": 10, "tools_to": 0}],
|
|
)
|
|
|
|
_, text = steps.tail(generation)
|
|
assert text.startswith("```python")
|
|
|
|
|
|
def test_a_reply_with_no_marks_has_everything_in_its_tail():
|
|
generation = _generation(content=["all of it"], reasoning=["thinking"])
|
|
|
|
assert steps.tail(generation) == ("thinking", "all of it")
|
|
|
|
|
|
# --- Finished means finished ---------------------------------------------------
|
|
def test_a_stored_reply_marks_nothing_as_still_being_written():
|
|
"""`msg__body--live` draws the blinking caret. `include_open` used to do two
|
|
jobs -- emit the trailing step, and mark it live -- so every finished reply
|
|
ending in prose blinked a cursor at the reader for ever."""
|
|
built = steps.for_message(
|
|
_message(
|
|
content="before after",
|
|
events=[{"name": "a"}],
|
|
marks=[{"round": 0, "thinking_to": 0, "text_to": 6, "tools_to": 1}],
|
|
)
|
|
)
|
|
|
|
assert [s.open for s in built] == [False] * len(built)
|
|
|
|
|
|
def test_a_stored_reply_with_no_marks_marks_nothing_either():
|
|
"""The compatibility branch had its own copy of the same flag, so every row
|
|
written before the marks existed blinked too -- which is most of them."""
|
|
built = steps.for_message(_message(content="An older answer."))
|
|
|
|
assert [s.open for s in built] == [False]
|
|
|
|
|
|
def test_a_running_reply_still_marks_its_tail():
|
|
"""The other half: the caret has to be somewhere while the reply is being
|
|
written, or there is no sign it is still going."""
|
|
generation = _generation(content=["still going"])
|
|
|
|
assert [s.open for s in _live_steps(generation)] == [True]
|
|
|
|
|
|
def _live_steps(generation):
|
|
from lembas.services.steps import _build
|
|
|
|
return _build(
|
|
text=generation.text,
|
|
thinking=generation.thinking,
|
|
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)] == [""]
|