phase: 161_reasoning_bubble
All gates verified. Final check — AGENTS.md rules against the project (commands, gates, protocols) — the quick-reference commands all exist and the rules (PLAN §2/§7/§9 references, phase protocol, No CDN, Debugpy, git gates) accurately describe the project; the reasoning bubble is a pure UI change with no new commands or env vars, so no AGENTS.md change is needed.
**Verification complete — full results:**
- **Verified** (implemented by prior task passes, all in working tree): `createReasoningWrap`/`ensureReasoningWrap` live container (D1), `settleReasoning` fold at the 3 settle sites + not at delta (D2), 320px `.reasoning-body` window + pinned-tail follow (D3), zero persistence change (D4), collapsed restore + shared-page parity with D6 step counts, `styles.css` family treatment + reduced-motion still, mock `REASONING_MIDROUND_TRIGGER` flow, 6-test story suite, phase-158 pin updates
- **Full suite:** `uv run pytest` → **3738 passed, 0 failed** · `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99% (>90%)** · `uv run ruff check .` → all checks passed · `uv run pyright` → **0 errors, 0 warnings**
- **E2E in isolation** (DB up; port-8123 collision with an unrelated `undercooked` service → `E2E_APP_PORT=8125`/`E2E_MOCK_PORT=8902`, no live change made): `test_reasoning_bubble.py` **6/6** · `test_thinking_timeline.py` ✓ · `test_thinking_display.py` ✓ · `test_tool_call_compact.py` ✓ · `test_big_read_progress.py` ✓ · `test_turn_progress_loader.py` ✓ · `test_share_chat.py` ✓ · `test_chat_persistence.py` ✓ · `test_smoke.py` ✓ (all exit 0, zero failures)
- **Completion criteria:** all 9 checked against the code + test outcomes — container open/live/nested ✓, stays open past mid-round delta ✓, folds at done ✓, restore + shared collapsed with "3 steps" ✓, plain turn = no container ✓, regression suites green ✓, suite/coverage/lint green ✓, diff scope limited to `frontend/assets/{app.js,shared.js,styles.css}` + `tests/` + `README.md` + phase files (no `app/` — D4) ✓
- **Documentation:** README.md "Thinking" section names the Reasoning bubble accurately (320px window, open-until-settle incl. mid-round deltas, "N steps", byte-identical no-container, collapsed on restore/shared) — verified consistent, no changes needed; AGENTS.md — verified consistent, no changes needed; all `.env.example` vars documented
- **Decisions:** commit + phase-dir move left to the harness (per pass rules — no git index/history commands executed); `undercooked` dev server on port 8123 untouched, E2E used the env port overrides
- **Next pending phase:** none — `161_reasoning_bubble` is the last in `todo/`
This commit is contained in:
@@ -25,7 +25,15 @@ own ``details.thinking`` / ``details.tool-calls-disclosure`` (D1), all
|
||||
above the answer bubble in arrival order (D2 — Option B,
|
||||
owner-chosen), open while the run's frames stream and folded at rest
|
||||
(D5), and persists the run order in the record's ``segments`` array
|
||||
(D4).
|
||||
(D4). Phase 161 (the documented supersession, owner TODO direction
|
||||
2026-10-05): the per-run blocks are NESTED inside ONE
|
||||
``details.reasoning`` container (``.reasoning-body``) that sits
|
||||
before the bubble — open during the turn, folded at done / on
|
||||
restore / on the shared page; the per-run open/fold behavior itself
|
||||
is unchanged. The sequence pin is shape-unchanged, read from the
|
||||
container's ``.reasoning-body`` children (the container is the one
|
||||
new parent — the pre-bubble ``.msg-body`` children are exactly
|
||||
``["details.reasoning"]``).
|
||||
|
||||
KB fixture — mirrored from ``test_agent_document_tools.py`` (the
|
||||
deterministic two-document pair): ``Deployments`` registered FIRST so
|
||||
@@ -37,37 +45,46 @@ prompt → ``<tools>``).
|
||||
|
||||
Test → phase mapping (Playwright Mapping Rule):
|
||||
1. ``test_timeline_order_after_completed_turn`` — D1/D2: after ``done``
|
||||
the brain wrap's ``.msg-body`` children before the bubble are
|
||||
EXACTLY the 7-element arrival-order sequence (4 thinking blocks +
|
||||
3 one-line disclosures, alternating); the four ``.thinking-text``s
|
||||
carry the DISTINCT ``Step N`` markers (distinctness = separate
|
||||
blocks, not one lump); each disclosure summary is ``Tool call
|
||||
(1)``; the answer bubble carries the ``Read <source/path>`` echo.
|
||||
2. ``test_runs_fold_at_rest`` — D5: after ``done`` NO ``.thinking`` /
|
||||
``.tool-calls-disclosure`` has the ``open`` attribute (the settle
|
||||
folded the runs); the summaries are still visible.
|
||||
the pre-bubble ``.msg-body`` children are EXACTLY the reasoning
|
||||
container (the one new parent — phase 161), and its
|
||||
``.reasoning-body`` children are EXACTLY the 7-element
|
||||
arrival-order sequence (4 thinking blocks + 3 one-line
|
||||
disclosures, alternating); the four ``.thinking-text``s carry the
|
||||
DISTINCT ``Step N`` markers (distinctness = separate blocks, not
|
||||
one lump); each disclosure summary is ``Tool call (1)``; the
|
||||
answer bubble carries the ``Read <source/path>`` echo.
|
||||
2. ``test_runs_fold_at_rest`` — D5 (phase 161 D2): after ``done``
|
||||
the container AND every ``.thinking`` /
|
||||
``.tool-calls-disclosure`` inside it has NO ``open`` attribute
|
||||
(the settle folded the runs + the container); the at-rest visible
|
||||
line is the container's ``Reasoning`` summary (``7 steps``);
|
||||
opening it reveals the folded run summaries.
|
||||
3. ``test_live_runs_open_while_streaming`` — D5 (the live half):
|
||||
DURING the turn (the mock's pacing window — the 0.1 s tool frames +
|
||||
the thinking stream, the answer bubble still EMPTY) the active run's
|
||||
block is ``open`` (the live feedback the owner keeps).
|
||||
4. ``test_restored_timeline_and_record`` — the restore path + D4: a
|
||||
same-context RELOAD re-renders the same 7-element sequence (all
|
||||
collapsed), and the localStorage ``bor.chat.v1`` record (phase 14)
|
||||
carries a ``segments`` array of EXACTLY 7 entries in the
|
||||
same-context RELOAD re-renders the same 7-element sequence in ONE
|
||||
collapsed reasoning container (``7 steps``, phase 161 D5/D6), and
|
||||
the localStorage ``bor.chat.v1`` record (phase 14) carries a
|
||||
``segments`` array of EXACTLY 7 entries in the
|
||||
``thinking, tool, ×3`` pattern (the ``Step N`` text on the thinking
|
||||
entries, the ``name``/``argument`` on the tool entries); the legacy
|
||||
``thinking`` (full concatenation) + ``tools`` (3 records) keys are
|
||||
STILL present (the old-client graceful degradation).
|
||||
5. ``test_shared_page_timeline`` — the shared-page parity (phase 51
|
||||
save + share flow): a FRESH context on ``/shared/<token>`` renders
|
||||
the same 7-element sequence, all collapsed (``addSegments``), the
|
||||
``Step N`` markers visible on summary click (expand ONE), the answer
|
||||
bubble carries the ``Read <source/path>`` echo.
|
||||
the same 7-element sequence in ONE collapsed reasoning container
|
||||
(``addSegments``), the ``Step N`` markers visible on container +
|
||||
summary click (expand ONE each), the answer bubble carries the
|
||||
``Read <source/path>`` echo.
|
||||
6. ``test_no_thinking_turn_single_disclosure`` — the run-grouping
|
||||
contract for no-thinking turns (the phase-117 behavior preserved): a
|
||||
plain ``use your tools`` turn (NO thinking trigger) renders EXACTLY
|
||||
ONE ``.tool-calls-disclosure`` (3 ``.tool-call`` lines, summary
|
||||
``Tool calls (3)``) and ZERO ``.thinking``.
|
||||
contract for no-thinking turns (the phase-117 behavior preserved,
|
||||
now wrapped — phase 161): a plain ``use your tools`` turn (NO
|
||||
thinking trigger) renders ONE folded reasoning container (``1
|
||||
steps``) wrapping EXACTLY ONE ``.tool-calls-disclosure`` inside its
|
||||
``.reasoning-body`` (3 ``.tool-call`` lines, summary ``Tool calls
|
||||
(3)``) and ZERO ``.thinking``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -169,9 +186,11 @@ ANSWER_QUOTE = "date: 2024-06-15" + RECORD_FILE_CONTENT[:63]
|
||||
#: lump).
|
||||
STEP_MARKERS = ("Step 1", "Step 2", "Step 3", "Step 4")
|
||||
|
||||
#: The 7-element pre-bubble segment sequence the timeline renders, in
|
||||
#: arrival order (D1: alternating thinking/tool runs — one block per
|
||||
#: run) — tests 1/4 (chat page) and 5 (shared page) pin it.
|
||||
#: The 7-element sequence the timeline renders in the reasoning
|
||||
#: container's ``.reasoning-body`` children, in arrival order (D1:
|
||||
#: alternating thinking/tool runs — one block per run; phase 161: the
|
||||
#: sequence is shape-unchanged — the container is the one new parent)
|
||||
#: — tests 1/4 (chat page) and 5 (shared page) pin it.
|
||||
TIMELINE_SEQ = [
|
||||
"details.thinking",
|
||||
"details.tool-calls-disclosure",
|
||||
@@ -269,10 +288,12 @@ def _reset_db(seed: Callable[[Session], None] | None = None) -> None:
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: The last ``.msg.brain`` wrap's ``.msg-body`` children BEFORE the
|
||||
#: bubble, as ``tag.className`` strings — the pre-bubble segment
|
||||
#: sequence (the timeline's order pin; the ``.msg-meta`` row lands
|
||||
#: AFTER the bubble, so it never enters the sequence).
|
||||
BODY_SEQUENCE_JS = """
|
||||
#: bubble, as ``tag.className`` strings. Phase 161 (D1): EXACTLY ONE
|
||||
#: — the ``details.reasoning`` container (the one new anchor before
|
||||
#: the bubble; the per-run blocks live inside it, not as direct
|
||||
#: children; the ``.msg-meta`` row lands AFTER the bubble, so it
|
||||
#: never enters the sequence).
|
||||
PRE_BUBBLE_JS = """
|
||||
() => {
|
||||
const wrap = document.querySelector('#messages .msg.brain');
|
||||
if (!wrap) return null;
|
||||
@@ -286,6 +307,24 @@ BODY_SEQUENCE_JS = """
|
||||
}
|
||||
"""
|
||||
|
||||
#: The last ``.msg.brain`` wrap's reasoning container's
|
||||
#: ``.reasoning-body`` children, as ``tag.className`` strings — the
|
||||
#: timeline's order pin (phase 161: the 7-element arrival-order
|
||||
#: sequence nested in the container — shape unchanged from phase 158).
|
||||
BODY_SEQUENCE_JS = """
|
||||
() => {
|
||||
const wrap = document.querySelector('#messages .msg.brain');
|
||||
if (!wrap) return null;
|
||||
const body = wrap.querySelector('details.reasoning .reasoning-body');
|
||||
if (!body) return null;
|
||||
const out = [];
|
||||
for (const child of body.children) {
|
||||
out.push(child.tagName.toLowerCase() + '.' + child.className);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
"""
|
||||
|
||||
#: D5's live-open predicate — the active run's block is ``open`` while
|
||||
#: the answer bubble is STILL EMPTY (the turn is in flight; the mock's
|
||||
#: pacing window — the 0.1 s tool frames + the thinking stream — keeps
|
||||
@@ -435,13 +474,19 @@ def test_timeline_order_after_completed_turn(
|
||||
_submit(page, TIMELINE_QUESTION)
|
||||
_wait_settled(page)
|
||||
|
||||
# D1: the pre-bubble segment sequence is EXACTLY the 7-element
|
||||
# arrival-order timeline — thinking, tool, ×3 (one block per
|
||||
# contiguous run; the plain ``use your tools`` flow's 3 calls land
|
||||
# in 3 SEPARATE one-line disclosures, one per thinking run).
|
||||
# Phase 161 (D1): the container is the ONE pre-bubble anchor —
|
||||
# the per-run blocks are no longer direct ``.msg-body`` children.
|
||||
assert page.evaluate(PRE_BUBBLE_JS) == ["details.reasoning"], (
|
||||
"the pre-bubble children must be exactly the reasoning "
|
||||
"container (phase 161 D1)"
|
||||
)
|
||||
# D1: the container's ``.reasoning-body`` sequence is EXACTLY the
|
||||
# 7-element arrival-order timeline — thinking, tool, ×3 (one block
|
||||
# per contiguous run; the plain ``use your tools`` flow's 3 calls
|
||||
# land in 3 SEPARATE one-line disclosures, one per thinking run).
|
||||
seq = page.evaluate(BODY_SEQUENCE_JS)
|
||||
assert seq == TIMELINE_SEQ, (
|
||||
f"the pre-bubble segment sequence is not the timeline: {seq}"
|
||||
f"the in-container segment sequence is not the timeline: {seq}"
|
||||
)
|
||||
|
||||
# D1/D3: the four thinking blocks carry the DISTINCT ``Step N``
|
||||
@@ -482,8 +527,22 @@ def test_runs_fold_at_rest(
|
||||
_submit(page, TIMELINE_QUESTION)
|
||||
_wait_settled(page)
|
||||
|
||||
# D5: at rest NOTHING is open — the ``done`` settle (closeAllRuns)
|
||||
# folded every run (a closed <details> has no ``open`` attribute).
|
||||
# Phase 161 (D2): the container folds at done — the at-rest
|
||||
# visible line is its ``Reasoning`` summary (the D6 settled status
|
||||
# is the run count — 7 runs → "7 steps").
|
||||
container = page.locator("#messages .msg.brain details.reasoning")
|
||||
expect(container).to_have_count(1)
|
||||
expect(container).not_to_have_attribute("open")
|
||||
expect(
|
||||
page.locator("#messages .msg.brain .reasoning-summary").first
|
||||
).to_be_visible()
|
||||
expect(
|
||||
page.locator("#messages .msg.brain .reasoning-status")
|
||||
).to_have_text("7 steps")
|
||||
|
||||
# D5: at rest NOTHING inside is open — the ``done`` settle
|
||||
# (closeAllRuns) folded every run (a closed <details> has no
|
||||
# ``open`` attribute).
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.thinking[open]")
|
||||
).to_have_count(0)
|
||||
@@ -493,8 +552,11 @@ def test_runs_fold_at_rest(
|
||||
)
|
||||
).to_have_count(0)
|
||||
|
||||
# The collapsed summaries are still visible (the timeline stays
|
||||
# readable at rest — 4 "Thinking" + 3 "Tool call (1)" lines).
|
||||
# The inner runs stay in the DOM inside the fold — opening the
|
||||
# container (the native toggle) reveals the 4 "Thinking" + 3
|
||||
# "Tool call (1)" lines (the timeline stays readable at rest).
|
||||
page.locator("#messages .msg.brain .reasoning-summary").click()
|
||||
expect(container).to_have_attribute("open", "")
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.thinking > summary").first
|
||||
).to_be_visible()
|
||||
@@ -566,11 +628,28 @@ def test_restored_timeline_and_record(
|
||||
page.reload()
|
||||
expect(page.locator("#empty-state")).to_be_hidden(timeout=30_000)
|
||||
|
||||
# The restored message renders the SAME 7-element timeline, all
|
||||
# COLLAPSED (D5 — the restore renders collapsed).
|
||||
# Phase 161 (D5/D6): the restore renders ONE collapsed container
|
||||
# around the timeline (the D6 at-rest step count). The pre-bubble
|
||||
# children are exactly the container (the live pin, D1).
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.reasoning")
|
||||
).to_have_count(1)
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.reasoning")
|
||||
).not_to_have_attribute("open")
|
||||
expect(
|
||||
page.locator("#messages .msg.brain .reasoning-status")
|
||||
).to_have_text("7 steps")
|
||||
assert page.evaluate(PRE_BUBBLE_JS) == ["details.reasoning"], (
|
||||
"the restored pre-bubble children must be exactly the "
|
||||
"container (phase 161 D1)"
|
||||
)
|
||||
# The restored message renders the SAME 7-element timeline
|
||||
# (in-container), all COLLAPSED (D5 — the restore renders
|
||||
# collapsed).
|
||||
seq = page.evaluate(BODY_SEQUENCE_JS)
|
||||
assert seq == TIMELINE_SEQ, (
|
||||
f"the restored pre-bubble sequence is not the timeline: {seq}"
|
||||
f"the restored in-container sequence is not the timeline: {seq}"
|
||||
)
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.thinking[open]")
|
||||
@@ -680,12 +759,24 @@ def test_shared_page_timeline(
|
||||
# The title = the auto-title (the shared chat's h1).
|
||||
expect(anon.locator("#shared-title")).to_have_text(_auto_title(TIMELINE_QUESTION))
|
||||
|
||||
# The SAME 7-element timeline renders (the ``addSegments``
|
||||
# parity), all COLLAPSED (D5 — the shared page is a finished
|
||||
# conversation; a closed <details> has no ``open`` attribute).
|
||||
# Phase 161 (D5/D6): the shared page renders ONE collapsed
|
||||
# container (the D6 at-rest step count) around the timeline.
|
||||
expect(
|
||||
anon.locator("#messages .msg.brain details.reasoning")
|
||||
).to_have_count(1)
|
||||
expect(
|
||||
anon.locator("#messages .msg.brain details.reasoning")
|
||||
).not_to_have_attribute("open")
|
||||
expect(
|
||||
anon.locator("#messages .msg.brain .reasoning-status")
|
||||
).to_have_text("7 steps")
|
||||
# The SAME 7-element timeline renders inside it (the
|
||||
# ``addSegments`` parity), all COLLAPSED (D5 — the shared
|
||||
# page is a finished conversation; a closed <details> has no
|
||||
# ``open`` attribute).
|
||||
seq = anon.evaluate(BODY_SEQUENCE_JS)
|
||||
assert seq == TIMELINE_SEQ, (
|
||||
f"the shared pre-bubble sequence is not the timeline: {seq}"
|
||||
f"the shared in-container sequence is not the timeline: {seq}"
|
||||
)
|
||||
expect(
|
||||
anon.locator("#messages .msg.brain details.thinking[open]")
|
||||
@@ -697,7 +788,10 @@ def test_shared_page_timeline(
|
||||
).to_have_count(0)
|
||||
|
||||
# The ``Step N`` markers are visible on a summary click (expand
|
||||
# ONE — the native <summary> is a real focusable toggle).
|
||||
# ONE — the native <summary> is a real focusable toggle; phase
|
||||
# 161: the container's own summary first — the inner runs'
|
||||
# summaries sit inside its closed fold).
|
||||
anon.locator("#messages .msg.brain .reasoning-summary").click()
|
||||
anon.locator("#messages .msg.brain details.thinking > summary").first.click()
|
||||
expect(
|
||||
anon.locator("#messages .msg.brain details.thinking").first
|
||||
@@ -736,22 +830,51 @@ def test_no_thinking_turn_single_disclosure(
|
||||
_submit(page, PLAIN_TOOLS_QUESTION)
|
||||
_wait_settled(page)
|
||||
|
||||
# D1 (the grouping contract): no thinking frames → ONE tool run →
|
||||
# EXACTLY ONE disclosure (the phase-117 behavior for no-thinking
|
||||
# turns is preserved), with the 3 ``.tool-call`` lines and the
|
||||
# plural summary.
|
||||
# Phase 161 (D1/D2): the tool frames create the container — it
|
||||
# exists and folds at done; the D6 settled status is the single
|
||||
# tool run ("1 steps").
|
||||
expect(
|
||||
page.locator("#messages .msg.brain .tool-calls-disclosure")
|
||||
page.locator("#messages .msg.brain details.reasoning")
|
||||
).to_have_count(1)
|
||||
expect(page.locator("#messages .msg.brain .tool-call")).to_have_count(3)
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.reasoning")
|
||||
).not_to_have_attribute("open")
|
||||
expect(
|
||||
page.locator("#messages .msg.brain .reasoning-status")
|
||||
).to_have_text("1 steps")
|
||||
# D1 (the grouping contract): no thinking frames → ONE tool run →
|
||||
# EXACTLY ONE disclosure INSIDE THE CONTAINER (the phase-117
|
||||
# behavior for no-thinking turns is preserved, now wrapped), with
|
||||
# the 3 ``.tool-call`` lines and the plural summary.
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning .reasoning-body "
|
||||
".tool-calls-disclosure"
|
||||
)
|
||||
).to_have_count(1)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning .reasoning-body "
|
||||
".tool-call"
|
||||
)
|
||||
).to_have_count(3)
|
||||
expect(
|
||||
page.locator("#messages .msg.brain .tool-calls-summary")
|
||||
).to_have_text("Tool calls (3)")
|
||||
# And ZERO thinking blocks (no thinking ran — no empty block).
|
||||
expect(page.locator("#messages .msg.brain details.thinking")).to_have_count(0)
|
||||
# And ZERO thinking blocks inside (no thinking ran — no empty
|
||||
# block).
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning .reasoning-body "
|
||||
"details.thinking"
|
||||
)
|
||||
).to_have_count(0)
|
||||
|
||||
# Folded at rest (the first delta / done settle) + the answer.
|
||||
expect(
|
||||
page.locator("#messages .msg.brain .tool-calls-disclosure[open]")
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning .reasoning-body "
|
||||
".tool-calls-disclosure[open]"
|
||||
)
|
||||
).to_have_count(0)
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(ANSWER_PREFIX)
|
||||
|
||||
Reference in New Issue
Block a user