phase: 161_reasoning_bubble
Build and Push Containers / build-and-push-app (push) Successful in 1m55s
Build and Push Containers / build-and-push-db (push) Successful in 14s

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:
2026-10-06 06:36:09 -04:00
parent 8172956c15
commit 051f62deb7
48 changed files with 4194 additions and 166 deletions
@@ -0,0 +1,11 @@
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/`
@@ -0,0 +1,123 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
.................................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 277 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 470 0 100%
--------------------------------------------------
TOTAL 5776 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1 @@
My diag was missing fixture env vars. Simpler: a temp diagnostic test using the real conftest fixtures:
@@ -0,0 +1,144 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
.............................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 277 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 470 0 100%
--------------------------------------------------
TOTAL 5776 17 99%
coverage gate: app/ 99% (>90%) OK
E501 Line too long (102 > 100)
--> tests/e2e/test_zz_diag.py:16:101
|
14 | page.on(
15 | "console",
16 | lambda m: print(f"\nCONSOLE[{m.type}]: {m.text}") if m.type in ("error", "warning") else None,
| ^^
17 | )
18 | page.set_default_timeout(30_000)
|
E501 Line too long (125 > 100)
--> tests/e2e/test_zz_diag.py:30:101
|
28 | print("\nUSER BUBBLE: MISSING")
29 | time.sleep(5)
30 | print("\nmessages:", page.evaluate("() => (document.querySelector('#messages')||{}).innerHTML?.slice(0, 400) || 'none'"))
| ^^^^^^^^^^^^^^^^^^^^^^^^^
31 | print("\nbody tail:", page.evaluate("() => document.body.innerHTML.slice(-400)"))
|
Found 2 errors.
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation FAILED (see output above)
@@ -0,0 +1,16 @@
All clean. Final report:
**Implemented (task 01 — live reasoning container)**
- `createReasoningWrap()` (createElement only, innerHTML absent, D1/D6), `ensureReasoningWrap()` (get-or-create, `open=true`, before bubble, module-scope `reasoningWrapEl` + reset in `runTurn`), per-run inserts re-anchored into `.reasoning-body` (per-run bubble anchor gone), `settleReasoning()` at the 3 settle sites (done/stop/failed — not the delta branch), D6 status literals, D3 follow (`REASONING_NEAR_BOTTOM_PX=32` + pre/post capture in both frame branches)
- **Critical defect found via browser E2E diag and fixed**: `reasoningWrapEl?.querySelector(…)?.textContent = x` is a SyntaxError (multi-optional LHS with a call) that killed the whole module — replaced with a `setReasoningStatus()` helper; unit-pinned
- 15 new unit pins; updated 8 superseded pins (live anchors, `runTurn` byte-pin incl. backslash-escape fix) + 2 E2E regression suites (at-rest focus/visibility in the folded container)
**Gates (all green)**
- `uv run pytest` → 3719 passed · `--cov=app` → 99% · `ruff check .` clean · `pyright` 0 errors
- E2E (DB up, isolation): smoke 3/3, thinking_display 5/5, tool_call_compact 4/4, big_read_progress 4/4, turn_progress_loader 3/3, chat_persistence 4/4, share_chat 4/4; thinking_timeline 4/6 — the 2 reds are the documented task-05 pins (direct-children sequence → container nesting; at-rest visibility), left for task 05 per the phase's staging
**Docs**: README `## Thinking` section now names the Reasoning bubble (created first thinking/tool frame, open until settle incl. mid-round deltas, folds at done/stop/failed, "N steps", no-container byte-identical, collapsed-on-restore); AGENTS.md — no impact.
**Decisions**: `setReasoningStatus` helper (syntax-error LHS fix); at-rest E2E contracts updated where my task directly superseded them; diff scope = `frontend/assets/app.js`, `tests/`, `README.md` (no `app/`, no `shared.js`/`styles.css`).
**Next pending task**: `.agents/phases/todo/161_reasoning_bubble/02_restore_and_shared.md`
@@ -0,0 +1,123 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
............................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 277 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 470 0 100%
--------------------------------------------------
TOTAL 5776 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,20 @@
All gates green. Task 02 complete.
## Report
**Implemented (task 02 — collapsed restore + shared-page container):**
- `app.js` `restoreTimeline`: creates the shared `createReasoningWrap()` container **closed** (`open = false`, D5), inserted once before the bubble; per-run blocks now append into `.reasoning-body` (per-run bubble anchor gone); D6 at-rest step count computed incrementally (each thinking entry = one run; first entry of a contiguous tool group = one run; uniform `${runs} steps` literal, written after the loop); `renderStoredMessage` dispatch unchanged (no-`segments` records stay byte-identical), comment cites the container
- `shared.js`: new **local parity** `createReasoningWrap()` builder (createElement only, same classes/literal, innerHTML absent) + `addSegments` parity wiring (closed container, same insertion convention, same two-increment count, line bytes unchanged)
- Unit pins: extended `tests/unit/test_reasoning_bubble.py` (8 new tests — builder parity, closed/once/anchor, append-into-body, contiguous-run count, legacy dispatch untouched, innerHTML absence in both wrappers); updated the two broken phase-158 restore/shared pins in `tests/unit/test_thinking_timeline.py` to the container-nested contract
**Gates (all green):**
- `uv run pytest tests/unit/test_reasoning_bubble.py -v --no-cov` → 26 passed
- `uv run pytest` → 3730 passed (100%)
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL 99% (>90%)
- `uv run ruff check . && uv run pyright` → all checks passed / 0 errors
**Docs:** no doc changes — README.md already documents the full bubble contract incl. "reload and the shared page re-render the same timeline **collapsed inside the Reasoning bubble**, with the step-count summary" (task-01 README pass); AGENTS.md has no thinking-display section.
**Decisions:** step-count semantics follow the unit-pin spec + D6 ("consecutive thinking entries + one per contiguous tool group") — each valid thinking entry counts unconditionally (two adjacent thinking entries = two runs); the work-item-2 "previous run not thinking" phrasing is the live-frame rule and conflicts with the pinned restore semantics. E2E regression suites (`test_thinking_timeline.py` 4 pins, `test_tool_call_compact.py` 1 restored-fold pin) are red by the documented supersession — per the phase plan their updates are task 05's scope (E2E runs in isolation, not in the default gate; same precedent as task 01).
**Next pending task:** `.agents/phases/todo/161_reasoning_bubble/03_container_css.md`
@@ -0,0 +1,123 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
.......................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 277 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 470 0 100%
--------------------------------------------------
TOTAL 5776 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,16 @@
Task 03 complete. All changes are in the working tree (untouched index; the file moves are the harness's own working-tree moves from earlier tasks).
**Report**
- **Implemented** — the `details.reasoning` container CSS in `frontend/assets/styles.css` (new distinct comment block citing phase 161 + D3, placed adjacent to the `details.thinking` family):
- family treatment (`--surface` / `--line` / `--brand-soft` / `--radius-sm` / `overflow: hidden`)
- `summary.reasoning-summary`: WCAG 44px target, `--brand-ink` (8.7:1), webkit marker suppressed, CSS chevron `▸` rotating 90° on `[open]`, house 3px `:focus-visible` ring
- `.reasoning-status` (`--ink-soft` 6.9:1, B5 text+color)
- `.reasoning-body`: the ONE bounded 320px `overflow-y: auto` live window (D3)
- inner-run compacting: de-bordered nested runs, inner `.thinking-text` `max-height: none` (one scroll surface), nested disclosure margin rule
- reduced-motion still appended to the house chat/stream stills block
- **Tests** — extended `tests/unit/test_reasoning_bubble.py` with 8 CSS source pins (all selectors, tokens, comments, adjacency, still, legacy-rules-unchanged gate)
- **Results** — `uv run pytest tests/unit/test_reasoning_bubble.py -v --no-cov` → **34 passed**; `uv run ruff check .` → clean; `uv run pyright` → **0 errors, 0 warnings**; full suite `uv run pytest --cov=app --cov-report=term-missing` → **3738 passed, 0 failed, 99%** coverage (>90%)
- **Docs** — no doc impact: README's "Thinking" section already documents the bubble + 320px window accurately (written in earlier phase-161 tasks); AGENTS.md has no thinking/reasoning content
- **Decisions** — none; CSS matches the phase design spec exactly; legacy `details.thinking` / `.tool-calls-disclosure` rules outside the container untouched (pinned)
- **Next pending task** — `04_mock_and_e2e.md`
@@ -0,0 +1,123 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
.................................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 277 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 470 0 100%
--------------------------------------------------
TOTAL 5776 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1 @@
Now I'll write the dedicated story E2E suite:
@@ -0,0 +1,134 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
.................................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 277 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 470 0 100%
--------------------------------------------------
TOTAL 5776 17 99%
coverage gate: app/ 99% (>90%) OK
E501 Line too long (134 > 100)
--> tests/e2e/mock_llm.py:250:101
|
248 | no existing E2E question or fixture file contains the phrase
249 | (verified 2026-10-06: every other suite is unaffected) — the
250 | more-specific-phrase-wins convention (the ``SLOW_PRETOKEN_-\n TRIGGER`` / ``THINK_PARAS_TRIGGER`` / ``SCAFFOLD_ALWAYS_TRIGGER``
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
251 | precedent). The trigger needs the ``<tools>`` section, so
252 | deflected turns never hit it.
|
Found 1 error.
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation FAILED (see output above)
@@ -0,0 +1 @@
Now I'll write the dedicated story E2E suite:
@@ -0,0 +1,134 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
.................................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 277 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 470 0 100%
--------------------------------------------------
TOTAL 5776 17 99%
coverage gate: app/ 99% (>90%) OK
E501 Line too long (134 > 100)
--> tests/e2e/mock_llm.py:250:101
|
248 | no existing E2E question or fixture file contains the phrase
249 | (verified 2026-10-06: every other suite is unaffected) — the
250 | more-specific-phrase-wins convention (the ``SLOW_PRETOKEN_-\n TRIGGER`` / ``THINK_PARAS_TRIGGER`` / ``SCAFFOLD_ALWAYS_TRIGGER``
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
251 | precedent). The trigger needs the ``<tools>`` section, so
252 | deflected turns never hit it.
|
Found 1 error.
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation FAILED (see output above)
@@ -0,0 +1,14 @@
All gates green. Final report:
**Implemented (task 04):**
- `tests/e2e/mock_llm.py`: `REASONING_MIDROUND_TRIGGER` + stateless `_reasoning_midround_flow` (request 1: thinking → mid-round `delta` → `ls` tool call; request 2: thinking → closing `delta` → content finish) + `_midround_tool_stream` (three-part wire shape); dispatch checked before the phase-158 check; docstring entry. Dispatch-disjointness verified (only `think out loud` shared; `THINK_TOOLS_TRIGGER` neither substring; no existing E2E question carries the phrase — re-verified)
- `tests/e2e/test_reasoning_bubble.py` (new, 6 tests): live-open container w/ nested runs + live status + D3 computed-style pin (≤320px, overflow-y auto); stays-open past a mid-round delta (bubble non-empty + in-flight `Stop`); folds at `done` with `3 steps` + per-run timeline; reload restore collapsed w/ `3 steps`; fresh-context `/shared/<token>` parity; plain answer = zero container + plain tools = wrapped phase-117 disclosure, live-open, `1 steps`
- Completed the half-finished phase-161 pin update already in the working tree for `test_tool_call_compact.py::test_restored_turn_renders_folded` (restored disclosure now nested in the folded container — step-8 defect fix)
**Results:** `uv run pytest` — 3738 passed; `--cov=app` — 99% (>90%); `tests/e2e/test_reasoning_bubble.py -v --no-cov` — 6/6 in isolation; `ruff check .` — clean (fixed prior attempt's E501 in mock docstring); `pyright` — 0 errors. Adjacent E2E: `test_thinking_display.py` 5/5, `test_tool_call_compact.py` 4/4. E2E ran on `E2E_APP_PORT/E2E_MOCK_PORT` overrides (an unrelated `undercooked` service holds default port 8123 — non-invasive, no live change made).
**Docs:** README's reasoning-bubble section (already in working tree from the prior attempt) reviewed — accurate; no AGENTS.md impact.
**Decisions:** `expect.poll` absent from installed Playwright → used the repo's proven deadline-poll pattern; the phase-158 timeline suite's direct-children pins remain intentionally RED (documented, task 05's scope — untouched).
**Next pending task:** `05_regression_update_and_commit.md` (regression pin updates, full gate, docs pass, atomic commit, phase move).
@@ -0,0 +1,123 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
.................................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 277 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 470 0 100%
--------------------------------------------------
TOTAL 5776 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,21 @@
All green. Final report:
**Task 05 — complete (all gates green; commit + phase move left to the harness per its protocol — no git add/commit or directory moves executed by me)**
**Implemented**
- `tests/e2e/test_thinking_timeline.py` pin updates (the one documented owner-directed exception): pre-bubble `.msg-body` children pinned to exactly `["details.reasoning"]` (new `PRE_BUBBLE_JS`); the 7-element sequence pin now reads the container's `.reasoning-body` children (shape unchanged); at-rest pins updated — container folds at `done`, at-rest visible line is the "Reasoning" summary with the D6 "7 steps" status, opening it reveals the folded run summaries; restore/shared pins add the collapsed container + "7 steps"; the no-thinking test's single-disclosure selectors scoped inside `details.reasoning .reasoning-body` (+ "1 steps" status); module docstring updated
- Verified `tests/unit/test_thinking_timeline.py` anchor pins + the other modified unit/E2E files were already updated and green by tasks 01–04
- Doc pass: README's "Thinking" section already names the bubble accurately (earlier task); AGENTS.md needs no change (no new commands/env vars) — both verified against the project as-is
**Gates**
- `uv run pytest` → **3738 passed** (100% of the suite)
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90%)
- `uv run pytest tests/e2e/test_reasoning_bubble.py -v --no-cov` → **6 passed** (in isolation)
- E2E regressions, each in isolation: thinking_timeline 6 ✓, thinking_display 5 ✓, tool_call_compact 4 ✓, big_read_progress 4 ✓ (unchanged), turn_progress_loader 3 ✓, share_chat 4 ✓, chat_persistence 4 ✓, smoke 3 ✓
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings
- `git diff --stat` limited to `frontend/assets/{app.js,shared.js,styles.css}`, `tests/`, `README.md`, phase files — **no `app/` change (D4)**
**Notes**
- E2E run with `E2E_APP_PORT=8125`/`E2E_MOCK_PORT=8902` overrides: the owner's other project's dev server (`undercooked`) holds the default E2E port 8123; I did not kill it
- Commit + phase-dir move deferred to the harness (its atomic per-phase commit); all changes left in the working tree
- Next pending task: none — this was the last task of phase 161
@@ -0,0 +1,123 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
.................................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 277 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 196 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 266 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 135 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 764 4 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 470 0 100%
--------------------------------------------------
TOTAL 5776 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+27 -11
View File
@@ -438,16 +438,31 @@ whole git-source refresh in one click:
The self-hosted `turbo` model reasons before it answers. That reasoning is
streamed with the turn as `thinking` SSE events and shown in **
collapsible "Thinking" blocks** above the answer bubble — one block per
contiguous thinking run, in arrival order (phase 158): the turn renders
as a chronological timeline (Thinking → Tool call (1) → Thinking → … →
answer). Each block opens and fills in live while its run streams,
and the runs tuck themselves away when the first answer token lands,
when the turn ends, or when the turn is stopped/failed — and every
block and tool-call disclosure stays click-toggleable afterwards. A
thinking run that starts after the answer began (the model replied,
called a tool, then thinks again) gets its own block above the answer
instead of resuming on top of it.
collapsible "Thinking" blocks** — one block per
contiguous thinking run, in arrival order (phase 158) — all wrapped in
**one collapsible "Reasoning" bubble** above the answer bubble
(phase 161): the turn renders as a chronological timeline
(Thinking → Tool call (1) → Thinking → … → answer), condensed into a
single bounded live window (320 px, one scroll surface) that follows
the tail while you watch.
The bubble is created on the turn's first thinking (or tool) frame and
stays **open** from creation until the turn settles — including a
mid-round reply (the model answered, called a tool, then thinks again:
the reply streams into the answer while the bubble stays open, and the
new thinking run gets its own block inside it instead of resuming on
top of it). It folds when the turn ends, is stopped, or fails. While
open, its summary carries a live status ("Thinking…" /
"Calling <name>…") and reads "Reasoning — N steps" once the turn
settles; a turn with no thinking and no tools renders no bubble at all
(the answer layout is byte-identical to pre-phase).
The per-run blocks keep their own open/fold behavior: each block opens
and fills in live while its run streams, and the runs tuck themselves
away when the first answer token lands, when the turn ends, or when the
turn is stopped/failed — and every block and tool-call disclosure stays
click-toggleable afterwards (the bubble's summary is the at-rest entry
point).
The tool calls ride the same timeline: each contiguous run of tool
calls gets its own collapsed-when-idle "Tool call(s) (N)" disclosure
@@ -457,7 +472,8 @@ suffix targets the newest line of the active run.
The timeline is persisted with the saved turn (an optional `segments`
array — the per-run order; the concatenated `thinking` and the `tools`
record stay for history and older clients), so a reload and the shared
page re-render the same timeline, collapsed.
page re-render the same timeline **collapsed inside the Reasoning
bubble**, with the step-count summary.
To hide it, set `BOR_STREAM_THINKING=0`.
+217 -20
View File
@@ -413,6 +413,47 @@ function isThinkingNearBottom(textEl) {
);
}
/* Phase 161 (D3): the reasoning container's ONE bounded live window
* (.reasoning-body, task 03's 320px) follows the SAME pre-update
* capture / post-append apply contract as the phase-17 per-block
* window: the pinned state is measured BEFORE the new chunk lands
* (the 32px band — the same threshold as THINKING_NEAR_BOTTOM_PX),
* and the scroll is applied AFTER the append. Inside the container
* the inner per-block window is disabled (task 03's max-height: none),
* so this is the single live scroll surface — the user watches the
* live tail move without the timeline pushing the answer off-screen.
* Exported so the band is unit-pinned (same pattern as TURN_TIMEOUT_MS). */
export const REASONING_NEAR_BOTTOM_PX = 32;
/* The container window's pre-update pin capture (the phase-17
* pre-measure convention — BEFORE the append grows the content; the
* post-render reading measures the new chunk's height, not the
* user's position). */
function isReasoningPinned(el) {
return (
el.scrollTop + el.clientHeight >= el.scrollHeight - REASONING_NEAR_BOTTOM_PX
);
}
/* The container window's post-append apply: follow the live tail
* (a scrolled-up reader is never re-pinned — returning to the bottom
* re-arms the pin on the next frame). */
function scrollReasoningToBottom(el) {
el.scrollTop = el.scrollHeight;
}
/* Phase 161 (D6): the live status setter — the span a frame fills
* ("Thinking…" / "Calling <name>…" / "N steps" at the settle sites);
* a no-op without a container (the plain-answer turn never creates
* one). The setter goes through a LOCAL reference: a direct
* `reasoningWrapEl?.…()?.textContent = x` left-hand side is a syntax
* error (an LHS allows at most one trailing `?.` member — not a
* call), which would kill the whole module at parse time. */
function setReasoningStatus(text) {
const el = reasoningWrapEl?.querySelector(".reasoning-status");
if (el) el.textContent = text;
}
/* No reply autoscroll (owner direction 2026-08-27, TODO.md L5 —
* revising the phase 18 follow-the-bottom choice): the page never
* auto-scrolls while a turn streams. The only scroll call sites are
@@ -1362,6 +1403,21 @@ function removeTyping() {
}
/* ---------- the per-run timeline (phase 158, D1/D2/D5) ----------
* Phase 161 (D1, owner 2026-10-05): the ENTIRE per-run timeline
* streams into ONE details.reasoning container (createReasoningWrap /
* ensureReasoningWrap) per brain message — the container sits ABOVE
* the answer bubble, is OPEN from creation until the turn settles
* (done / stop / the failed-turn funnel — settleReasoning, D2), and
* is condensed in its ONE bounded .reasoning-body live window (320px
* task 03) with the pinned-tail follow (D3). The per-run blocks keep
* their structure and open/fold behavior (phase 158 D5) — only their
* PARENT changed: they append into the container's .reasoning-body
* (the per-run bubble anchor is superseded), arrival order preserved.
* The container's summary carries the D6 status literals: "Reasoning"
* (static) + a live span ("Thinking…" / "Calling <name>…" / "N steps").
* A turn with no thinking and no tools creates NO container
* (byte-identical DOM — D1/D4).
*
* The model's reasoning streams into collapsible <details> blocks ABOVE
* the answer bubble, and the tool calls stream into per-run
* disclosures — one CONTIGUOUS thinking run gets its own
@@ -1401,6 +1457,34 @@ function createThinkingBlock() {
return block;
}
/* Phase 161 (D1/D6, owner 2026-10-05): the ONE reasoning
* container's STATIC structure — details.reasoning wrapping
* summary.reasoning-summary (the "Reasoning" literal — D6's ONLY
* new static literal — + the EMPTY span.reasoning-status a frame
* fills: "Thinking…" / "Calling <name>…" / "N steps") and the
* bounded .reasoning-body live window (task 03: 320px + overflow-y).
* Built with createElement only (innerHTML ABSENT — the house rule;
* the "Reasoning" literal is a static text node, no data flows
* through it). The caller owns open/fold + insertion: the live path
* creates it OPEN (D1 — until the turn settles, D2); the
* restore/shared path renders it COLLAPSED around the per-run
* timeline (D5) with the "N steps" summary (D6). */
function createReasoningWrap() {
const cont = document.createElement("details");
cont.className = "reasoning";
const summary = document.createElement("summary");
summary.className = "reasoning-summary";
summary.textContent = "Reasoning"; // D6: the ONLY new static literal
const status = document.createElement("span");
status.className = "reasoning-status"; // empty until a frame sets it
summary.appendChild(status);
cont.appendChild(summary);
const body = document.createElement("div");
body.className = "reasoning-body";
cont.appendChild(body);
return cont;
}
/* The tool-calls disclosure's STATIC structure — the phase-117 shape
* (summary.tool-calls-summary first + the .tool-calls list, role=list
* + aria-label "Tool calls"), built with createElement only (innerHTML
@@ -1421,19 +1505,40 @@ function createToolDisclosure() {
return container;
}
/* The turn's reasoning container — get-or-create (phase 161, D1):
* on the turn's FIRST thinking/tool frame it creates the container
* (createReasoningWrap), opens it (D1: open from creation until the
* turn settles), inserts it BEFORE the bubble (the ONE new anchor —
* the per-run blocks move from the bubble anchor into the
* container's .reasoning-body), and stores it in reasoningWrapEl
* (module scope — the pagehide partial reads the live container,
* like segRuns). A turn with no thinking and no tools never calls
* this (byte-identical DOM — D1). The container is created by the
* FIRST run's ensureReasoningWrap call — no other call site. */
function ensureReasoningWrap(wrap) {
if (reasoningWrapEl) return reasoningWrapEl;
const body = wrap.querySelector(".msg-body");
const cont = createReasoningWrap();
cont.open = true; // D1: open while the turn is live (D2: folds at settle)
body.insertBefore(cont, body.querySelector(".bubble"));
reasoningWrapEl = cont;
return cont;
}
/* The active thinking run's block — or, when the previous run is a
* different kind (or there is none): close it, create a NEW
* details.thinking (OPEN — D5), insert it BEFORE the bubble (D2 — the
* anchor is the bubble, in arrival order), and open a fresh run. The
* details.thinking (OPEN — D5), append it into the phase-161
* reasoning container's .reasoning-body (D1 — the container sits
* before the bubble; a new run appends after the previous run's
* block, so arrival order is preserved), and open a fresh run. The
* phase-109 D15 re-open of one shared block is superseded: a
* post-delta thinking frame lands in a NEW block, not a re-open. */
function ensureThinkingRun(wrap) {
if (currentRun?.kind === "thinking") return currentRun.el;
closeCurrentRun();
const body = wrap.querySelector(".msg-body");
const block = createThinkingBlock();
block.open = true; // D5: open while the run's frames stream
body.insertBefore(block, body.querySelector(".bubble"));
ensureReasoningWrap(wrap).querySelector(".reasoning-body").appendChild(block);
const run = { kind: "thinking", text: "", records: [] };
segRuns.push(run);
currentRun = { kind: "thinking", el: block, run };
@@ -1442,17 +1547,18 @@ function ensureThinkingRun(wrap) {
/* The active tool run's disclosure — or, when the previous run is a
* different kind (or there is none): close it, create a NEW
* details.tool-calls-disclosure (OPEN — D5), insert it BEFORE the
* bubble (D2, in arrival order), and open a fresh run. One disclosure
* per CONTIGUOUS tool-call run (the phase-117 structure, now per run;
* the summary count is per run — "Tool call(s) (N)"). */
* details.tool-calls-disclosure (OPEN — D5), append it into the
* phase-161 reasoning container's .reasoning-body (D1 — the
* container sits before the bubble; arrival order preserved), and
* open a fresh run. One disclosure per CONTIGUOUS tool-call run
* (the phase-117 structure, now per run; the summary count is per
* run — "Tool call(s) (N)"). */
function ensureToolRun(wrap) {
if (currentRun?.kind === "tool") return currentRun.el;
closeCurrentRun();
const body = wrap.querySelector(".msg-body");
const disc = createToolDisclosure();
disc.open = true; // D5: open while the run's frames stream
body.insertBefore(disc, body.querySelector(".bubble"));
ensureReasoningWrap(wrap).querySelector(".reasoning-body").appendChild(disc);
const run = { kind: "tool", text: "", records: [] };
segRuns.push(run);
currentRun = { kind: "tool", el: disc, run };
@@ -1478,6 +1584,18 @@ function closeAllRuns(wrap) {
)?.forEach((el) => (el.open = false));
}
/* Phase 161 (D2): the container's FOLD — the turn's terminal
* settle (done / the stop/abort / the failed-turn funnel), called
* alongside closeAllRuns at the three settle sites. Idempotent;
* a no-op without a container (the plain-answer turn settles
* byte-identically). The mid-round delta does NOT fold the
* container (D2 — closeCurrentRun folds the ACTIVE RUN; the
* container stays open until the final response has been returned
* and the session has stopped — the TODO's core contract). */
function settleReasoning(wrap) {
wrap?.querySelector("details.reasoning")?.removeAttribute("open");
}
/* D4: the record's `segments` array, built at save time from the turn's
* runs — in order. A thinking run stores its accumulated text ONLY when
* it is non-empty (a zero-char run — e.g. a BOR_STREAM_THINKING-
@@ -1789,6 +1907,12 @@ let thinkingAcc = ""; // accumulated thinking text (persisted with the turn)
* through buildSegments at the save points (non-empty only). */
let segRuns = []; // this turn's runs, in arrival order ([] = nothing to store)
let currentRun = null; // the active run — { kind, el, run } (run = the segRuns entry); null when no run is open
/* Phase 161 (D1): this turn's reasoning container — created on the
* first thinking/tool frame (ensureReasoningWrap), open until the
* settle (settleReasoning). Module scope, like segRuns: the
* pagehide partial reads the live container; reset per turn in
* runTurn. */
let reasoningWrapEl = null;
let persistedOnLeave = false; // pagehide partial-persist at most once
/* Phase 120 verification (navigate-away regression): a pagehide has
* fired. A REAL departure tears the document down and the browser
@@ -2503,8 +2627,22 @@ export function clearStoredConversation() {
* the per-run "Tool call(s) (N)" count riding appendToolLine's
* summary update (the SAME container signature as the live path). All
* blocks are created CLOSED (D5 — the restore renders collapsed; the
* live path opens while its frames stream) and inserted BEFORE the
* bubble (D2, in arrival order). The tOffset/tLimit/tPath
* live path opens while its frames stream). Phase 161 (task 02,
* D5/D6): the per-run timeline renders inside ONE details.reasoning
* container — the SAME container as the live path (the shared
* createReasoningWrap builder, task 01), created COLLAPSED (the
* live open state is exclusively the live turn's; explicit
* `open = false` for the pin) and inserted ONCE before the bubble;
* every per-run block appends into the container's .reasoning-body
* (the per-run bubble anchor is superseded — arrival order
* preserved). The D6 at-rest summary is the step count, computed
* incrementally with the LIVE segRuns.length semantics — CONTIGUOUS
* runs, NOT entry count: each thinking entry is ONE run (a thinking
* entry after a tool group starts a new run; two adjacent thinking
* entries are TWO runs); the first entry of a new contiguous tool
* group is ONE run (a group of N entries is ONE run). The literal is
* uniform — `${runs} steps` (no singular/plural special-case). The
* tOffset/tLimit/tPath
* well-formedness guards (Number.isInteger(x) && x >= 1 / the string
* guard) and the phase-95 appendTruncatedNote call move VERBATIM from
* the legacy m.tools loop (the phase-139/146 restore precedent) — a
@@ -2522,7 +2660,18 @@ function restoreTimeline(wrap, segments) {
const body = wrap?.querySelector?.(".msg-body");
if (!body) return;
const bubble = body.querySelector(".bubble");
// Phase 161 (task 02, D5/D6): the container — the shared task-01
// builder, created CLOSED (D5 — the restore renders collapsed; the
// live open state is exclusively the live turn's; explicit for the
// pin) and inserted ONCE before the bubble (the ONE anchor — the
// per-run blocks nest into its .reasoning-body, arrival order
// preserved).
const cont = createReasoningWrap();
cont.open = false; // D5: the restore renders collapsed (explicit for the pin)
const runsEl = cont.querySelector(".reasoning-body");
body.insertBefore(cont, bubble); // the container sits ABOVE the answer bubble
let toolDisc = null; // the CURRENT run of consecutive tool entries (one disclosure per run, D1)
let runs = 0; // D6: the at-rest step count — CONTIGUOUS runs, not entry count
for (const entry of segments || []) {
// a malformed entry (or one with an unknown `kind`) is SKIPPED —
// never render from storage (textContent only, the house rule).
@@ -2530,6 +2679,7 @@ function restoreTimeline(wrap, segments) {
if (entry.kind === "thinking") {
if (typeof entry.text !== "string") continue; // malformed: skipped
if (toolDisc) toolDisc = null; // the run is sealed — its count rode the last append
runs += 1; // D6: each thinking entry is ONE run (a thinking entry after a tool group starts a run; two adjacent thinking entries are TWO runs)
const block = document.createElement("details");
block.className = "thinking";
// D5: created CLOSED — the restore renders collapsed (the
@@ -2541,14 +2691,15 @@ function restoreTimeline(wrap, segments) {
textEl.className = "thinking-text";
textEl.innerHTML = renderMarkdown(entry.text); // the existing restore convention — escape-first, XSS-safe
block.append(summary, textEl);
body.insertBefore(block, bubble); // D2: above the bubble, in arrival order
runsEl.appendChild(block); // Phase 161 D1/D5: inside the container, in arrival order
} else if (entry.kind === "tool") {
if (typeof entry.name !== "string") continue; // malformed: skipped
if (!toolDisc) {
toolDisc = createToolDisclosure(); // the phase-117 structure (innerHTML ABSENT)
// D5: created CLOSED — details defaults closed; explicit for the pin.
toolDisc.open = false;
body.insertBefore(toolDisc, bubble); // D2: above the bubble, in arrival order
runs += 1; // D6: the first entry of a NEW contiguous group is ONE run (a group of N entries is ONE run)
runsEl.appendChild(toolDisc); // Phase 161 D1/D5: inside the container, in arrival order
}
const arg =
typeof entry.argument === "string" && entry.argument
@@ -2574,6 +2725,10 @@ function restoreTimeline(wrap, segments) {
// SKIPPED — never render from storage (textContent only, the
// house rule); a guard comment names the skip.
}
// Phase 161 (task 02, D6): the at-rest step count — the UNIFORM
// literal (no singular/plural special-case — `${runs} steps` is
// pinned exactly).
cont.querySelector(".reasoning-status").textContent = `${runs} steps`;
if (toolDisc) toolDisc.open = false; // defensive — already closed by construction
}
@@ -2609,12 +2764,16 @@ function renderStoredMessage(m) {
// per-run timeline through restoreTimeline (collapsed) — the runs
// carry the SAME data as `m.thinking` + `m.tools`, in order, so
// the legacy blocks are SKIPPED (rendering both would double the
// runs). A record WITHOUT `segments` — every pre-phase record —
// restores BYTE-IDENTICALLY through the legacy path below; its
// folded-at-rest result is the same as the pre-phase code's
// (the pre-phase settle pair became closeAllRuns(wrap) — task 02 —
// the legacy thinking block is created closed and the legacy
// disclosure opens during the loop and folds below, phase 117 D3).
// runs). Phase 161 (task 02, D5/D6): the segments path wraps the
// per-run timeline in the COLLAPSED details.reasoning container
// (restoreTimeline — the shared createReasoningWrap builder; the
// "N steps" summary). A record WITHOUT `segments` — every
// pre-phase record — restores BYTE-IDENTICALLY through the legacy
// path below (no container — D4); its folded-at-rest result is the
// same as the pre-phase code's (the pre-phase settle pair became
// closeAllRuns(wrap) — phase 158 task 02 — the legacy thinking
// block is created closed and the legacy disclosure opens during
// the loop and folds below, phase 117 D3).
if (Array.isArray(m.segments) && m.segments.length) {
restoreTimeline(wrap, m.segments);
} else {
@@ -3578,6 +3737,11 @@ function finalizeFailedTurn(
// open run closed (phase 158, D5 — the stop-finalize pattern),
// mark it failed.
closeAllRuns(wrap);
// Phase 161 (D2): the failed-turn funnel is a settle — fold the
// container alongside the runs; the summary reads the live run
// count (D6 — segRuns, module scope).
settleReasoning(wrap);
setReasoningStatus(`${segRuns.length} steps`);
appendFailedNote(wrap, error);
rememberBrainTurn(
acc,
@@ -3667,6 +3831,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
thinkingAcc = "";
segRuns = []; // phase 158: a fresh turn starts with no runs (the pagehide partial reads the current turn's runs)
currentRun = null;
reasoningWrapEl = null; // phase 161: a fresh turn starts with no container (the pagehide partial reads the live container, like segRuns)
persistedOnLeave = false;
leftThePage = false;
leavePartialIndex = -1;
@@ -3769,6 +3934,11 @@ async function runTurn(text, { reask = false, image = null } = {}) {
if (!wrap) wrap = addMessage("brain", "");
removeTyping(); // the live block replaces the dots as feedback
const block = ensureThinkingRun(wrap);
// Phase 161 (D6): the container's live status — "Thinking…"
// while a thinking frame streams (a tool frame overwrites it
// with "Calling <name>…"; the settle sites overwrite it with
// "N steps").
setReasoningStatus("Thinking…");
currentRun.run.text += ev.text || ""; // the RUN's text — the per-run block renders it
const textEl = block.querySelector(".thinking-text");
// Pin state is measured BEFORE the re-render: a chunk taller
@@ -3782,6 +3952,13 @@ async function runTurn(text, { reask = false, image = null } = {}) {
// THIS block (per-run — each run's block pins its own tail;
// the pre-update capture is verbatim).
const pinned = block.open && isThinkingNearBottom(textEl);
// Phase 161 (D3): the container window's PRE-update pin
// capture — the phase-17 pre-measure convention, BEFORE the
// re-render grows the content (the inner per-block follow
// above stays — inside the container the .thinking-text is
// no longer scrollable, so it no-ops there; task 03).
const rbody = reasoningWrapEl?.querySelector(".reasoning-body");
const rPinned = !!rbody && isReasoningPinned(rbody);
textEl.innerHTML = renderMarkdown(currentRun.run.text); // escape-first, XSS-safe
if (pinned) {
// Follow the live tail only while the user was pinned to the
@@ -3790,6 +3967,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
// returning to the bottom re-arms the pin on the next chunk.
textEl.scrollTop = textEl.scrollHeight;
}
if (rPinned && rbody) scrollReasoningToBottom(rbody); // post-append apply
} else if (ev.type === "tool") {
// Phase 37 (PLAN §4 extension): an agent tool call. The UI
// state stays "thinking" — the button stays the enabled "Stop"
@@ -3872,6 +4050,14 @@ async function runTurn(text, { reask = false, image = null } = {}) {
// wiring.
const disc = ensureToolRun(wrap);
currentRun.run.records.push(toolRecord);
// Phase 161 (D6): "Calling <name>…" — the display name the
// tool line already shows (the phase-70 legacy-name mapping
// produced `name` before this point).
setReasoningStatus(`Calling ${name}…`);
// Phase 161 (D3): the container window's PRE-update pin
// capture — before the line append grows the content.
const tbody = reasoningWrapEl?.querySelector(".reasoning-body");
const tPinned = !!tbody && isReasoningPinned(tbody);
const line = appendToolLine(
disc,
name,
@@ -3880,6 +4066,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
frameLimit,
framePath
);
if (tPinned && tbody) scrollReasoningToBottom(tbody); // post-append apply
// Phase 87 (TODO.md L5): arm this line's elapsed clock — the
// baseline resets per line, so each line counts its OWN
// frameless silence. This live branch is the ONLY arm call site
@@ -3936,6 +4123,11 @@ async function runTurn(text, { reask = false, image = null } = {}) {
// late double-close is a no-op). Replaces the phase-117
// closeToolCalls fold + the D15 block close.
closeAllRuns(wrap);
// Phase 161 (D2): the turn settled — fold the container too
// (the final response has been returned); the summary reads
// the live run count (D6 — segRuns, module scope).
settleReasoning(wrap);
setReasoningStatus(`${segRuns.length} steps`);
if (!wrap) {
setUiState(UI_STATE.streaming);
wrap = addMessage("brain", "…");
@@ -4147,6 +4339,11 @@ async function runTurn(text, { reask = false, image = null } = {}) {
// (replaces the phase-117 closeToolCalls fold + the D15 block
// close, like `done`).
closeAllRuns(wrap);
// Phase 161 (D2): the session stopped — fold the container
// alongside the runs; the summary reads the live run count
// (D6 — segRuns, module scope).
settleReasoning(wrap);
setReasoningStatus(`${segRuns.length} steps`);
persistedOnLeave = true;
appendTuneButton(wrap); // admin-only; parity with the restore path
appendStoppedNote(wrap);
+56 -3
View File
@@ -247,6 +247,33 @@ function addToolLines(wrap, tools) {
body.insertBefore(disclosure, body.querySelector(".bubble"));
}
/* Phase 161 (task 02, D1/D6): the SHARED page's parity copy of
* app.js's createReasoningWrap — the SAME details.reasoning
* container (summary.reasoning-summary with the "Reasoning" literal
* — D6's ONLY new static literal — + the EMPTY span.reasoning-status
* the at-rest count fills, and the .reasoning-body window), built
* with createElement only (innerHTML ABSENT — the house rule; the
* "Reasoning" literal is a static text node, no data flows through
* it). The caller owns open/fold + insertion: the shared page
* renders it COLLAPSED around the per-run timeline (D5 — the
* pure-render convention) with the D6 "N steps" summary. A parity
* copy, not an import (the shared page has no import of app.js). */
function createReasoningWrap() {
const cont = document.createElement("details");
cont.className = "reasoning";
const summary = document.createElement("summary");
summary.className = "reasoning-summary";
summary.textContent = "Reasoning"; // D6: the ONLY new static literal (parity with app.js)
const status = document.createElement("span");
status.className = "reasoning-status"; // empty until the at-rest count fills it
summary.appendChild(status);
cont.appendChild(summary);
const body = document.createElement("div");
body.className = "reasoning-body";
cont.appendChild(body);
return cont;
}
/* Phase 158 (task 03, D4/D5): the SHARED page's per-run timeline —
* the parity copy of the chat page's restoreTimeline: the record's
* `segments` array (the per-run order, task 01's ChatMessage field)
@@ -255,7 +282,18 @@ function addToolLines(wrap, tools) {
* details.tool-calls-disclosure ("Tool call(s) (N)" per run), in
* arrival order, ABOVE the answer — a pure render, so everything is
* created CLOSED (D5; the same collapsed convention as
* addThinkingBlock / addToolLines). The structure is
* addThinkingBlock / addToolLines). Phase 161 (task 02, D5/D6): the
* per-run timeline renders inside ONE details.reasoning container —
* the local parity builder above, created CLOSED (the pure-render
* convention) and inserted ONCE before the bubble (the shared
* page's existing insertion convention); every per-run block
* appends into the container's .reasoning-body (the per-run bubble
* anchor is superseded — arrival order preserved). The D6 at-rest
* step count uses the SAME two-increment semantics as app.js's
* restoreTimeline (a parity copy, not an import — contiguous runs:
* each thinking entry is ONE run; the first entry of a new
* contiguous tool group is ONE run), with the same uniform
* `${runs} steps` literal. The structure is
* createElement/textContent; the line bytes mirror addToolLines
* EXACTLY (the four pinned label literals + the phase-70 legacy
* names + the phase-139 readRangeSuffix + the phase-95 truncation
@@ -270,7 +308,17 @@ function addSegments(wrap, segments) {
const body = wrap.querySelector(".msg-body");
if (!body) return;
const bubble = body.querySelector(".bubble");
// Phase 161 (task 02, D5/D6): the container — the local parity
// builder, created CLOSED (D5 — the pure-render convention;
// explicit for the pin) and inserted ONCE before the bubble (the
// shared page's existing insertion convention — the per-run blocks
// nest into its .reasoning-body, arrival order preserved).
const cont = createReasoningWrap();
cont.open = false; // D5: collapsed (the pure-render convention; explicit for the pin)
const runsEl = cont.querySelector(".reasoning-body");
body.insertBefore(cont, bubble); // the shared page's existing insertion convention
let toolDisc = null; // the CURRENT run of consecutive tool entries (one disclosure per run, D1)
let runs = 0; // D6: the at-rest step count — contiguous runs, not entry count
for (const entry of segments || []) {
// a malformed entry (or one with an unknown `kind`) is SKIPPED —
// never render from storage (textContent only, the house rule).
@@ -278,6 +326,7 @@ function addSegments(wrap, segments) {
if (entry.kind === "thinking") {
if (typeof entry.text !== "string") continue; // malformed: skipped
if (toolDisc) toolDisc = null; // the run is sealed — the count is final
runs += 1; // D6: each thinking entry is ONE run (parity with app.js — a thinking entry after a tool group starts a run; two adjacent thinking entries are TWO runs)
const block = document.createElement("details");
block.className = "thinking";
block.open = false; // D5: collapsed (the addThinkingBlock convention)
@@ -287,13 +336,14 @@ function addSegments(wrap, segments) {
textEl.className = "thinking-text";
textEl.innerHTML = renderMarkdown(entry.text); // escape-first, XSS-safe (parity with addThinkingBlock)
block.append(summary, textEl);
body.insertBefore(block, bubble); // D2: above the bubble, in arrival order
runsEl.appendChild(block); // Phase 161 D5: inside the container, in arrival order
} else if (entry.kind === "tool") {
if (typeof entry.name !== "string") continue; // malformed: skipped
if (!toolDisc) {
toolDisc = document.createElement("details");
toolDisc.className = "tool-calls-disclosure";
toolDisc.open = false; // D5: collapsed (parity with addToolLines — phase 117 D3)
runs += 1; // D6: the first entry of a NEW contiguous group is ONE run (parity with app.js — a group of N entries is ONE run)
const summary = document.createElement("summary");
summary.className = "tool-calls-summary";
toolDisc.appendChild(summary);
@@ -302,7 +352,7 @@ function addSegments(wrap, segments) {
container.setAttribute("role", "list");
container.setAttribute("aria-label", "Tool calls");
toolDisc.appendChild(container);
body.insertBefore(toolDisc, bubble); // D2: above the bubble, in arrival order
runsEl.appendChild(toolDisc); // Phase 161 D5: inside the container, in arrival order
}
const container = toolDisc.querySelector(".tool-calls");
const line = document.createElement("span");
@@ -364,6 +414,9 @@ function addSegments(wrap, segments) {
// an entry with an unknown `kind` (or a malformed shape) is
// SKIPPED — never render from storage (textContent only, the
// house rule); a guard comment names the skip.
// Phase 161 (task 02, D6): the at-rest step count — the UNIFORM
// literal (parity with app.js — no singular/plural special-case).
cont.querySelector(".reasoning-status").textContent = `${runs} steps`;
}
/* Phase 139 (task 01): the shared page's parity copy of app.js's
+95
View File
@@ -682,6 +682,99 @@ details.thinking .thinking-text {
details.thinking .thinking-text p,
details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* Phase 161 (owner TODO 2026-10-05, D3): the ONE reasoning bubble —
the ENTIRE per-run timeline (phase-158 thinking + tool-call blocks,
arrival order) nests INSIDE this details.reasoning container instead
of stacking above the answer, so the live turn condenses to ONE
bounded window (the .reasoning-body, 320px) and the runs no longer
push the answer off-screen. Family treatment of details.thinking
(phase 17): the SAME surface/line/brand-soft/radius tokens, so the
two disclosures read as one block family. The summary is a native
focusable toggle — >=44px target, the house 3px :focus-visible ring,
the webkit marker suppressed, a CSS chevron ▸ that rotates 90° when
open (the transition stills under prefers-reduced-motion — see the
reduced-motion block below). Contrast: --brand-ink on --surface
≈8.7:1, --ink-soft on --surface ≈6.9:1 (the status span) — both AA.
overflow: hidden clips the scrollable body to the corner radius. */
details.reasoning {
background: var(--surface);
border: 1px solid var(--line);
border-left: 3px solid var(--brand-soft);
border-radius: var(--radius-sm);
margin: 0 0 0.5rem;
overflow: hidden;
}
summary.reasoning-summary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
min-height: 44px; /* WCAG 2.1 AA touch target (the family pin) */
color: var(--brand-ink); /* 8.7:1 on --surface */
font-size: 0.9rem;
cursor: pointer;
list-style: none;
}
summary.reasoning-summary::-webkit-details-marker { display: none; }
/* CSS chevron: ▸ rotates 90° when open (the details.thinking
convention; the transition stills under prefers-reduced-motion —
see the reduced-motion block below). */
summary.reasoning-summary::before {
content: "▸";
display: inline-block;
transition: transform 0.15s ease;
}
details.reasoning[open] summary.reasoning-summary::before {
transform: rotate(90deg);
}
summary.reasoning-summary:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
}
/* D6: the live status suffix ("Thinking…" / "Calling <name>…" /
"N steps") rides the summary row AFTER the "Reasoning" literal —
the flex row lays it out after the chevron + literal; ink-soft on
surface ≈6.9:1 (AA); text + color, never color alone (B5). */
.reasoning-status {
color: var(--ink-soft); /* 6.9:1 on --surface */
font-size: 0.8rem;
line-height: 1.4;
margin-left: 0.25rem;
}
/* D3: the ONE bounded live window — 320px (the phase-17 window
convention), user-scrollable; app.js's pinned-tail follow
(REASONING_NEAR_BOTTOM_PX) keeps the live tail in view while the
user stays near the bottom — scrolling up pauses it, returning to
the bottom resumes it. The per-run blocks nested inside are
de-bordered below so THIS is the single scroll surface (no nested
boxes). */
.reasoning-body {
padding: 0 0.75rem 0.75rem;
max-height: 320px;
overflow-y: auto;
}
/* D3 inner-run compacting: the per-run blocks nest de-bordered inside
the container — the nested border weight is removed (a 2px line
indent keeps the family rhythm) and the inner 320px windows are
disabled (max-height: none) so the container's window is the single
scroll surface (the phase-17 per-block follow in app.js no-ops on
the now-unscrollable .thinking-text). */
details.reasoning details.thinking {
border: 0;
border-left: 2px solid var(--line);
border-radius: 0;
margin: 0.25rem 0;
background: transparent;
}
details.reasoning details.thinking .thinking-text {
max-height: none;
overflow-y: visible;
}
/* The nested tool-calls disclosure is already compact — the rule
documents the intent (one window, one rhythm) without a visual
change. */
details.reasoning .tool-calls-disclosure { margin: 0.25rem 0; }
/* Phase 117: the tool-call record folds into a single disclosure line,
reusing the Thinking block's native <details> convention. Open while
the turn is live (phase 87's elapsed suffix stays visible), folded at
@@ -1642,6 +1735,8 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
details.thinking summary::before { transition: none; }
/* Phase 117 tool-calls disclosure: the same chevron, the same still. */
.tool-calls-summary::before { transition: none; }
/* Phase 161 reasoning bubble: the family chevron, the family still. */
summary.reasoning-summary::before { transition: none; }
}
/* Phase 117 (owner request 2026-09-15, live-mockup-confirmed — the
+254
View File
@@ -206,6 +206,51 @@ Implements just enough of the aipi surface:
TRIGGER`` / ``SCAFFOLD_ALWAYS_TRIGGER`` precedent). Verified
2026-10-04: no existing E2E question or fixture file contains
the phrase, so every other suite is unaffected.
- user message containing ``think out loud, answer, then check
your work`` (``REASONING_MIDROUND_TRIGGER``, phase 161 task 04
— the reasoning-bubble story's dedicated suite
``tests/e2e/test_reasoning_bubble.py``) **and** the system
prompt carries the ``<tools>`` section -> the deterministic
MID-ROUND tool flow: the owner's 2026-10-05 TODO's stays-open
wire shape — a ``reasoning_content`` run, then a MID-ROUND
``content`` delta (the React-style partial answer, the content
stream BEFORE the tool call), then the tool call; the follow-up
request thinks, closes the answer, and FINISHES with a content
finish (no further tool call — the ``done`` convention).
Discriminated statelessly from the messages (streaming only —
the app's chat path is the only streaming consumer; a
non-streaming marker request never issues and falls through to
the plain ``think out loud`` scratchpad answer, like the other
marker flows): the ``tools`` parameter gates the first step (a
no-tools request with no tool results is not the flow), and the
final step is reached when a ``tool``-role result is in the
messages (regardless of the ``tools`` parameter):
* request 1 (``tools`` offered, no tool results yet): stream
``reasoning_content`` (``Step 1: I think I know the
answer — let me check the documents.``) FIRST — the 12-char
``_sse_stream`` convention — then the MID-ROUND ``content``
delta (``The reference server is ref-server-1.`` — the SAME
12-char / 0.02 s convention), and ONLY then
``tool_calls`` deltas: ``ls`` (synthetic id ``call_0``, no
arguments), ``finish_reason: "tool_calls"`` — the tool call
AFTER the delta (the exact mid-round ordering the TODO
describes);
* request 2 (a ``tool``-role result in the messages): stream
``reasoning_content`` (``Step 2: the listing confirms
it.``) FIRST, then the closing ``content`` delta (`` —
confirmed by the listing.`` — it concatenates onto the
mid-round delta, so the turn's full answer is byte-stable),
then ``finish_reason: "stop"`` + ``[DONE]`` — the flow ends
with a content finish (NO further tool call).
Checked BEFORE the phase-158 ``think out loud and use your
tools`` check: the phrases share only ``think out loud`` and
NEITHER is a substring of the other (verified 2026-10-06), and
no existing E2E question or fixture file contains the phrase
(verified 2026-10-06: every other suite is unaffected) — the
more-specific-phrase-wins convention (the ``SLOW_PRETOKEN_-
TRIGGER`` / ``THINK_PARAS_TRIGGER`` / ``SCAFFOLD_ALWAYS_TRIGGER``
precedent). The trigger needs the ``<tools>`` section, so
deflected turns never hit it.
- user message containing ``search your documents``
(``SEARCH_TRIGGER``, phase 68 search tool — renamed to the
harness-aligned ``grep`` in phase 70, same match/output contract)
@@ -1117,6 +1162,36 @@ THINKING_TRIGGER = "think out loud"
#: exactly like the other marker flows.
THINK_TOOLS_TRIGGER = "think out loud and use your tools"
#: Phase 161 (task 04 — the reasoning-bubble story): a user message
#: containing this substring (case-insensitive) — combined with the
#: ``<tools>`` section in the system prompt — drives the
#: deterministic MID-ROUND flow documented in the module docstring:
#: a ``reasoning_content`` run, then a MID-ROUND ``delta`` (the
#: React-style partial answer — the content stream BEFORE the tool
#: call, the exact mid-round ordering the owner's 2026-10-05 TODO
#: describes), then the tool call; the follow-up request (a
#: ``tool``-role result in the messages) thinks, emits the closing
#: ``delta``, and FINISHES with a content finish (NO further tool
#: call — the ``done`` convention). Verified 2026-10-06: no
#: existing E2E question or fixture file contains the phrase, and
#: the dispatch is disjoint from the other ``think out loud``
#: triggers — the phrase shares only ``think out loud`` with
#: ``THINKING_TRIGGER`` and ``THINK_TOOLS_TRIGGER`` ("think out
#: loud and use your tools"): NEITHER ``THINK_TOOLS_TRIGGER`` nor
#: the new phrase is a substring of the other (the existing
#: continues ``…and use your tools``, the new continues ``…answer,
#: then check…``). NOTE: the phrase CONTAINS ``THINKING_TRIGGER``
#: ("think out loud") — the more-specific-phrase-wins convention
#: (``SLOW_PRETOKEN_TRIGGER`` / ``THINK_PARAS_TRIGGER`` /
#: ``SCAFFOLD_ALWAYS_TRIGGER``): the streaming dispatch checks the
#: flow BEFORE the phase-158 check and before the generic
#: ``think out loud`` path (so the plain scratchpad never sees the
#: marker). A non-streaming marker request (never issued by the app
#: — the chat path is the only streaming consumer) falls through to
#: the plain ``think out loud`` scratchpad answer, exactly like the
#: other marker flows.
REASONING_MIDROUND_TRIGGER = "think out loud, answer, then check your work"
#: Regression pin (2026-08-29, owner report): a user message containing
#: this substring (case-insensitive) gets the phase-17 scratchpad WITH
#: REAL paragraph breaks ("\n\n"), streamed at ``THINK_PARAS_CHUNK``
@@ -2133,6 +2208,55 @@ def _thinking_tools_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
return ("list", "Step 1: list the documents to find the reference server config.")
def _reasoning_midround_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
"""Classify a phase-161 marker request into one step of the
MID-ROUND thinking + delta + tool flow (see the module
docstring).
The two-request flow — the owner's 2026-10-05 TODO's
stays-open wire shape (the mid-round ``delta`` BEFORE the tool
call is what the client's stays-open container contract needs).
Discriminated statelessly from the messages, like the other
marker flows:
* ``("midround", <step 1 thinking>, <mid-round delta>)`` —
``tools`` are offered and no tool results are in the
messages yet: the model thinks, then emits a MID-ROUND
``delta`` (the content stream BEFORE the tool call), then
calls ``ls`` (no arguments, id ``call_0``) — the tool call
AFTER the delta.
* ``("final", <step 2 thinking>, <closing delta>)`` — a
``tool``-role result is in the messages: the model thinks,
then emits the closing ``delta`` and FINISHES with a content
finish (``finish_reason: "stop"`` — the ``done`` convention;
NO further tool call). Reached regardless of the ``tools``
parameter (phase 45 keeps the tools offered until the round
cap).
* ``None`` — not the marker flow: the marker is absent, the
``<tools>`` section is not in the system prompt, or a
no-tools request with no tool results (e.g. ``agent_max_
rounds=0``) — the request behaves exactly as before.
"""
user = _user(body).lower()
if REASONING_MIDROUND_TRIGGER not in user:
return None
if "<tools>" not in _system(body):
return None
if _tool_results(body):
return (
"final",
"Step 2: the listing confirms it.",
" — confirmed by the listing.",
)
if not body.get("tools"):
return None
return (
"midround",
"Step 1: I think I know the answer — let me check the documents.",
"The reference server is ref-server-1.",
)
def _ls_teach_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
"""Classify a phase-72 LS-TEACH request (see the module docstring).
@@ -4226,6 +4350,96 @@ def _tool_call_stream(
yield "data: [DONE]\n\n"
def _midround_tool_stream(
thinking: str,
mid_round_delta: str,
name: str,
arguments: dict[str, Any],
call_id: str,
) -> Any:
"""SSE frames for one MID-ROUND tool completion (phase 161, task
04 — the stays-open contract's wire shape).
The three-part ordering the owner's 2026-10-05 TODO describes:
the ``reasoning_content`` run FIRST (the 12-char
``_tool_call_stream`` convention — the 0.02 s cadence, the
aipi wire convention: reasoning before content), then the
MID-ROUND ``content`` delta (the SAME 12-char / 0.02 s
convention — the React-style partial answer, the content stream
BEFORE the tool call), and ONLY then the ``_tool_call_stream``
tool-call frames (the deterministic 16-char argument split, the
0.1 s pacing) + ``finish_reason: "tool_calls"`` + ``[DONE]``.
The app accumulates exactly this (``app/rag/llm.py`` — the
content-before-tools flush: the mid-round content reaches the
client as a ``delta`` frame BEFORE the ``tool`` frame; a rare
stream with both content and a tool call keeps the content AND
runs the tool, ``app/rag/agent.py``), so the client sees
thinking → mid-round delta → tool call → next round — the D2
ordering the stays-open container contract needs.
"""
model = "turbo"
chunk_id = f"chatcmpl-{uuid.uuid4()}"
for piece in re.findall(r".{1,12}", thinking, re.S):
payload = {
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [
{"index": 0, "delta": {"reasoning_content": piece}, "finish_reason": None}
],
}
yield f"data: {json_dumps(payload)}\n\n"
time.sleep(0.02)
for piece in re.findall(r".{1,12}", mid_round_delta, re.S):
payload = {
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{"index": 0, "delta": {"content": piece}, "finish_reason": None}],
}
yield f"data: {json_dumps(payload)}\n\n"
time.sleep(0.02)
raw_args = json_dumps(arguments) if arguments else "{}"
frags = [raw_args[i : i + 16] for i in range(0, len(raw_args), 16)] or ["{}"]
for i, frag in enumerate(frags):
tc: dict[str, Any] = {"index": 0, "function": {"arguments": frag}}
delta: dict[str, Any] = {"tool_calls": [tc]}
if i == 0:
tc = {
"index": 0,
"id": call_id,
"type": "function",
"function": {"name": name, "arguments": frag},
}
delta = {"role": "assistant", "tool_calls": [tc]}
payload = {
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{"index": 0, "delta": delta, "finish_reason": None}],
}
yield f"data: {json_dumps(payload)}\n\n"
time.sleep(0.1)
yield (
"data: "
+ json_dumps(
{
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
}
)
+ "\n\n"
)
yield "data: [DONE]\n\n"
@app.post("/v1/chat/completions")
def chat_completions(body: dict[str, Any]) -> Any:
# Phase 123 (task 04): the OBSERVATIONAL capture (the ring buffer —
@@ -4839,6 +5053,46 @@ def chat_completions(body: dict[str, Any]) -> Any:
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Phase 161 (task 04 — the reasoning-bubble story): the
# deterministic MID-ROUND flow — a thinking run, then a
# MID-ROUND ``delta`` (the React-style partial answer — the
# content stream BEFORE the tool call, the exact ordering
# the owner's 2026-10-05 TODO describes), then the tool
# call; the follow-up request thinks + the closing
# ``delta`` + the content finish (see the module
# docstring). Checked BEFORE the phase-158 check: the
# phrases share only ``think out loud`` and NEITHER is a
# substring of the other (verified 2026-10-06), and no
# existing E2E question carries the phrase (verified
# 2026-10-06: every other suite is unaffected) — the
# more-specific-phrase-wins convention (the SLOW_PRETOKEN /
# THINK_PARAS / SCAFFOLD_ALWAYS precedent). The trigger
# needs the ``<tools>`` section, so deflected turns never
# hit it.
midround = _reasoning_midround_flow(body)
if midround is not None:
if midround[0] == "midround":
# The mid-round ordering: the thinking run, then the
# MID-ROUND content delta, then the tool call — the
# ``_midround_tool_stream`` three-part shape.
stream = _midround_tool_stream(
midround[1],
midround[2],
"ls",
{},
"call_0",
)
else: # "final" — think, the closing delta, the content finish
stream = _sse_stream(
midround[2],
0.0,
thinking=midround[1],
)
return StreamingResponse(
stream,
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Phase 158 (task 04 — the thinking/tool-call timeline
# story): the deterministic INTERLEAVED flow — a DISTINCT
# reasoning run ahead of every tool call and ahead of the
+881
View File
@@ -0,0 +1,881 @@
"""Phase 161 E2E (Playwright, mock-only): ONE live-open, condensed
reasoning bubble (owner TODO 2026-10-05 — "the reasoning process
should be one collapsible section that stays open during the
reasoning process … This new thinking/tool calling bubble should only
collapse once the final response has been returned and the session has
stopped").
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_reasoning_bubble.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the story's
gate is the deterministic marker flow in ``tests/e2e/mock_llm.py``
(user message contains ``think out loud, answer, then check your
work`` (``REASONING_MIDROUND_TRIGGER``) **and** the system prompt
carries the ``<tools>`` section of the HIGH prompt): the
MID-ROUND flow — a ``reasoning_content`` run (``Step 1``), then a
MID-ROUND ``content`` delta (the React-style partial answer — the
content stream BEFORE the tool call, the exact mid-round ordering the
TODO describes), then the ``ls`` tool call; the follow-up request
(thinking ``Step 2`` + the closing delta + the content finish — no
further tool call). The client renders the ENTIRE per-run timeline
inside ONE ``details.reasoning`` container (D1): open from creation
until the turn settles (D2 — it STAYS OPEN through a mid-round
delta; only ``done`` / stop / the failed-turn funnel fold it),
condensed in its bounded 320px ``.reasoning-body`` live window (D3),
the summary carrying the D6 status literals (``Thinking…`` /
``Calling <name>…`` live, ``N steps`` at rest). The restore path and
the shared page render the SAME container COLLAPSED (D5) with the
at-rest step count (D6). A turn with no thinking and no tools creates
NO container (D1/D4 — the byte-identical DOM).
KB fixture — mirrored from ``test_thinking_timeline.py`` (the
deterministic two-document pair): the marker question cosines
≈0.595 against the retrievable document (well past the E2E 0.30
threshold) plus FTS hits, so the turn is solidly grounded (HIGH
prompt → ``<tools>``).
Test → phase mapping (Playwright Mapping Rule):
1. ``test_container_open_during_the_turn`` — D1/D3: during the turn
(a predicate over the mock's 0.1 s pacing window — the house
deadline-poll pattern) — ONE ``details.reasoning`` exists, is ``open``, contains a per-run
block NESTED inside its ``.reasoning-body`` (the phase-158
direct-children layout is superseded), and its
``.reasoning-status`` carries a live suffix (``Thinking…`` or
``Calling …``). The D3 computed-style pin lives here too: the
``.reasoning-body``'s computed ``max-height`` ≤ 320px +
``overflow-y: auto`` (the bounded-window contract — the CSS
verified in the browser, not just in source).
2. ``test_container_stays_open_past_a_mid_round_delta`` — D2 (the
TODO's core): while the answer bubble is NON-EMPTY (the mid-round
delta rendered) AND the turn is not settled (the in-flight
``Stop`` control), the container is STILL ``open`` with a run
visible.
3. ``test_container_folds_at_done`` — D2/D5/D6: after ``done`` the
container has NO ``open`` attribute, its summary is visible, EVERY
inner run has no ``open`` attribute (the phase-158 fold-at-rest
contract preserved inside the fold), and the status is ``3 steps``
(the flow's contiguous-run count — ``thinking (Step 1)``,
``tool (ls)``, ``thinking (Step 2)`` — the D6 live-settle
literal); the bubble carries the mid-round + closing answer.
4. ``test_restored_container_collapsed_with_step_count`` — D5/D6: a
same-context RELOAD renders ONE ``details.reasoning`` (NO ``open``
attribute) around the same per-run timeline, with the status
``3 steps`` (the D6 at-rest count — the restore's incremental
count matches the live ``segRuns.length`` semantics).
5. ``test_shared_page_container_collapsed`` — D5 (the phase-51 save +
share flow): a FRESH context on ``/shared/<token>`` renders ONE
``details.reasoning`` (collapsed) with the same timeline + step
count (the ``shared.js`` parity — ``addSegments``).
6. ``test_no_reasoning_turn_has_no_container`` — D1/D4: a plain
answer question renders ZERO ``details.reasoning`` (the
byte-identical DOM); a plain ``use your tools`` question (NO
thinking trigger) DOES create the container (the tool frames
create it), wrapping the ONE phase-117 disclosure — open live
(a deadline-poll window over the 0.1 s tool-frame pacing),
folded at ``done`` — with the status
``1 steps`` (the D6 uniform literal — the no-thinking turn's
single tool run; the phase-117 single-disclosure behavior
preserved, now wrapped).
"""
from __future__ import annotations
import hashlib
import re
import time
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
import httpx
from playwright.sync_api import Browser, BrowserContext, Page, expect
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import Chunk, Document, GitSource
from e2e.auth_helpers import login
from tests.e2e.mock_llm import embed_text
# --------------------------------------------------------------------------
# Fixture documents (deterministic, token-controlled — mirrored from
# test_thinking_timeline.py so the gate is byte-stable)
# --------------------------------------------------------------------------
SEED_SOURCE = "Homelab"
SEED_PATH = "aws-route53.md"
SEED_SP = f"{SEED_SOURCE}/{SEED_PATH}"
READ_SOURCE = "Deployments"
READ_PATH = "example-record-file.json"
READ_SP = f"{READ_SOURCE}/{READ_PATH}"
#: The retrievable document: references the JSON file but never
#: includes it. The repeated record-file lines carry the marker
#: question's key tokens (aws, route53, hosted, zone, reeselink,
#: json, exact, shape) — verified ≈0.595 cosine against the mock's
#: embeddings (E2E threshold 0.30) plus FTS hits, so the turn is
#: solidly grounded.
ROUTE53_CONTENT = (
"# AWS Route 53 Notes\n\n"
"## Record file\n\n"
+ (
"The aws route53 hosted zone for reeselink keeps every record in "
"reseelink.json — the exact JSON shape of reeselink.json is "
"documented in example-record-file.json.\n"
)
* 10
+ "\n## Sync job\n\n"
"A cron job pushes reeselink.json to the aws route53 hosted zone "
"every fifteen minutes; the diff is applied through the route53 api.\n"
)
#: The referenced document: the exact JSON shape (indexed, catalogued,
#: readable — but NO chunks, so retrieval never puts it in context).
RECORD_FILE_CONTENT = (
'{ "version": 3, "comment": "ReeseLink hosted zone records — the exact '
'JSON shape of reeselink.json",\n'
' "hosted_zone_id": "Z0RESEELINK01",\n'
' "record_sets": [\n'
' { "name": "www.reeselink.example", "type": "A", "ttl": 300,\n'
' "resource_records": [ { "value": "10.0.0.20" } ] },\n'
' { "name": "api.reeselink.example", "type": "CNAME", "ttl": 300,\n'
' "resource_records": [ { "value": "www.reeselink.example" } ] }\n'
" ]\n"
"}\n"
)
#: The phase-161 marker question (``REASONING_MIDROUND_TRIGGER`` + the
#: KB topic — the ≈0.595 cosine keeps the turn grounded over the
#: mock-calibrated E2E threshold).
MIDROUND_QUESTION = (
"Think out loud, answer, then check your work: what is the exact "
"JSON shape of reeselink.json for my aws route53 hosted zone?"
)
#: The NO-thinking tool control — the plain ``use your tools`` marker
#: (``test_thinking_timeline.py``'s question, mirrored so the 3-call
#: flow is the phase-37/117 one).
PLAIN_TOOLS_QUESTION = (
"Use your tools: what is the exact JSON shape of reeselink.json "
"for my aws route53 hosted zone?"
)
#: The plain-answer control (NO trigger at all — the generic
#: ``compose_answer`` path: no thinking, no tools → NO container,
#: the byte-identical DOM — D1/D4).
PLAIN_ANSWER_QUESTION = (
"What is the exact JSON shape of reeselink.json for my aws "
"route53 hosted zone?"
)
#: The mock's mid-round delta + the closing delta — the turn's full
#: answer (byte-stable: the closing delta concatenates onto the
#: mid-round delta).
MIDROUND_DELTA = "The reference server is ref-server-1."
CLOSING_DELTA = " — confirmed by the listing."
MIDROUND_ANSWER = MIDROUND_DELTA + CLOSING_DELTA
#: The mock's two DISTINCT reasoning runs (the ``Step N`` markers —
#: their distinctness proves the runs are separate blocks, not one
#: lump).
STEP1_MARKER = "Step 1"
STEP2_MARKER = "Step 2"
#: The plain-tools flow's answer prefix (the phase-37 shape —
#: ``Read <source/path>.``).
ANSWER_PREFIX = f"Read {READ_SP}."
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
# --------------------------------------------------------------------------
# DB seeding (TRUNCATE-then-seed, mirrored from test_thinking_timeline.py)
# --------------------------------------------------------------------------
def _seed(db: Session) -> None:
"""The two-document pair (see the module docstring), plus the
phase-94 registry rows: both sources registered, ``Deployments``
FIRST (the operator's ``BOR_GIT_SOURCES`` is ignored — a
non-empty table wins). COMMIT between the inserts (not flush):
``added_at`` is ``server_default now()`` and the tie-break is the
random uuid ``id`` — one-transaction rows order nondeterministically."""
db.add(GitSource(url=READ_SOURCE, kind="local"))
db.commit()
db.add(GitSource(url=SEED_SOURCE, kind="local"))
md = Document(
source=SEED_SOURCE,
path=SEED_PATH,
full_path=f"/tmp/{SEED_PATH}",
title="AWS Route 53 Notes",
content=ROUTE53_CONTENT,
content_hash=hashlib.sha256(ROUTE53_CONTENT.encode()).hexdigest(),
indexed_at=datetime.now(UTC),
created_at=datetime(2024, 6, 15, tzinfo=UTC),
)
db.add(md)
db.flush()
# One chunk carrying the mock's own embedding → genuine token
# overlap between the marker question and this document.
db.add(
Chunk(
document_id=md.id,
position=0,
content=ROUTE53_CONTENT,
embedding=embed_text(ROUTE53_CONTENT),
)
)
db.add(
Document(
source=READ_SOURCE,
path=READ_PATH,
full_path=f"/tmp/{READ_PATH}",
title="Example Record File",
content=RECORD_FILE_CONTENT,
content_hash=hashlib.sha256(RECORD_FILE_CONTENT.encode()).hexdigest(),
indexed_at=datetime.now(UTC),
created_at=datetime(2024, 6, 15, tzinfo=UTC),
)
)
def _reset_db(seed: Callable[[Session], None] | None = None) -> None:
"""Truncate the KB (plus the prompt-shaping tables and the
phase-55 auto-save rows), then re-seed — the E2E isolation
pattern. ``steering_notes`` / ``kb_overview`` are truncated too,
so the HIGH prompt is exactly ``<relevance>`` + ``<documents>`` +
``<tools>`` regardless of leftovers — byte-stable prompts,
byte-stable answers."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, "
"steering_notes, kb_overview, saved_chats, git_sources"
)
)
db.commit()
if seed is not None:
seed(db)
db.commit()
# --------------------------------------------------------------------------
# Page helpers
# --------------------------------------------------------------------------
#: The D1/D3 live predicate — the LAST brain message's container
#: exists, is ``open``, NESTS a per-run block in its
#: ``.reasoning-body`` (the phase-158 direct-children layout is
#: superseded), and its status carries a LIVE suffix (``Thinking…``
#: or ``Calling …`` — the D6 live literals).
CONTAINER_LIVE_JS = """
() => {
const wraps = document.querySelectorAll('#messages .msg.brain');
const wrap = wraps.length ? wraps[wraps.length - 1] : null;
if (!wrap) return false;
const cont = wrap.querySelector('details.reasoning');
if (!cont || !cont.hasAttribute('open')) return false;
const status = cont.querySelector('.reasoning-status');
const st = status ? status.textContent : '';
const live = st === 'Thinking…' || st.indexOf('Calling ') === 0;
if (!live) return false;
return !!cont.querySelector(
'.reasoning-body details.thinking, ' +
'.reasoning-body details.tool-calls-disclosure');
}
"""
#: The D2 predicate (test 2's core) — the answer bubble is NON-EMPTY
#: (the mid-round delta has rendered) AND the turn is NOT settled
#: (the in-flight ``Stop`` control — the settled label is ``Send``)
#: AND the container is STILL ``open`` with a run nested in its
#: ``.reasoning-body`` (the TODO's "only collapse once the final
#: response has been returned and the session has stopped").
MIDROUND_STILL_OPEN_JS = """
() => {
const wraps = document.querySelectorAll('#messages .msg.brain');
const wrap = wraps.length ? wraps[wraps.length - 1] : null;
if (!wrap) return false;
const body = wrap.querySelector('.msg-body');
const bubble = body ? body.querySelector('.bubble') : null;
if (!bubble || bubble.textContent === '') return false;
const label = document.querySelector('#send-label');
if (!label || label.textContent !== 'Stop') return false;
const cont = wrap.querySelector('details.reasoning');
if (!cont || !cont.hasAttribute('open')) return false;
return !!cont.querySelector(
'.reasoning-body details.thinking, ' +
'.reasoning-body details.tool-calls-disclosure');
}
"""
#: The no-thinking live predicate (test 6's tool half) — the tool
#: frames created the container; it is ``open`` with a
#: ``.tool-calls-disclosure`` nested in its ``.reasoning-body``
#: (the phase-117 disclosure, now wrapped — D1 for tool-only turns).
TOOL_ONLY_LIVE_JS = """
() => {
const wraps = document.querySelectorAll('#messages .msg.brain');
const wrap = wraps.length ? wraps[wraps.length - 1] : null;
if (!wrap) return false;
const cont = wrap.querySelector('details.reasoning');
if (!cont || !cont.hasAttribute('open')) return false;
return !!cont.querySelector(
'.reasoning-body details.tool-calls-disclosure');
}
"""
#: The D3 computed-style pin — the ``.reasoning-body``'s computed
#: ``max-height`` (≤ 320px) + ``overflow-y`` (the bounded-window
#: contract, verified in the browser, not just in source).
REASONING_BODY_STYLE_JS = """
() => {
const wraps = document.querySelectorAll('#messages .msg.brain');
const wrap = wraps.length ? wraps[wraps.length - 1] : null;
if (!wrap) return null;
const el = wrap.querySelector('details.reasoning .reasoning-body');
if (!el) return null;
const cs = getComputedStyle(el);
return { maxH: cs.maxHeight, ovy: cs.overflowY };
}
"""
def _submit(page: Page, question: str) -> None:
page.fill("#message-input", question)
page.click("#send-btn")
# The user bubble lands synchronously with the submit handler.
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
def _wait_settled(page: Page) -> None:
"""The turn is complete: answer text in the bubble, button
recovered (phase 48 — the in-flight button is the enabled Stop
control, so the label assertion carries the settle wait)."""
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("", timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
# --------------------------------------------------------------------------
# Phase-51 save + share flow (the test_thinking_timeline.py pattern —
# the auto-saved row is shared from the chat page; the row is
# deleted in a finally)
# --------------------------------------------------------------------------
def _admin_cookies(page: Page) -> dict[str, str]:
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]:
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
assert r.status_code == 200
return r.json()["chats"]
def _auto_title(question: str) -> str:
"""The phase-50 auto-title convention: the first question,
whitespace-collapsed, capped at 120 chars."""
return " ".join(question.split())[:120]
def _find_row(rows: list[dict[str, Any]], title: str) -> dict[str, Any] | None:
return next((c for c in rows if c["title"] == title), None)
def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
def _wait_saved_row(
app_url: str,
cookies: dict[str, str],
title: str,
messages: int = 2,
) -> dict[str, Any]:
"""Wait for the auto-saved row (phase 55 — auto-saves are SILENT,
so there is no status line to wait on). The upsert is
fire-and-forget from the UI's point of view, so poll the admin
list until the row with the conversation's auto-title appears
with the expected message count."""
deadline = time.monotonic() + 15
last: dict[str, Any] | None = None
while time.monotonic() < deadline:
last = _find_row(_chats(app_url, cookies), title)
if last is not None and last["message_count"] >= messages:
return last
time.sleep(0.2)
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
def _grant_clipboard(page: Page, app_url: str) -> None:
"""Grant the async-clipboard permissions on the admin context
(headless Chromium requires the grant before ``writeText``
resolves — without it the inline-link fallback fires)."""
page.context.grant_permissions(
["clipboard-read", "clipboard-write"], origin=app_url
)
def _click_share_and_assert_status(page: Page, app_url: str) -> None:
"""Press the chat page's Share pill and pin the outcome on the
live region (the assertion branches on the API's availability —
the test_share_chat.py convention)."""
page.locator("#share-chat-btn").click()
if page.evaluate("() => !!navigator.clipboard"):
expect(page.locator("#send-status")).to_have_text(
"Share link copied.", timeout=15_000
)
else:
expect(page.locator("#send-status")).to_have_text(
"Share link ready — copy it from the field.", timeout=15_000
)
field = page.locator(".share-link-fallback")
expect(field).to_be_visible()
href = field.get_attribute("href")
assert href is not None
assert href.startswith(app_url)
assert SHARE_URL_RE.fullmatch(href.removeprefix(app_url))
# --------------------------------------------------------------------------
# 1. D1/D3: the live turn renders ONE open container with the runs
# NESTED inside its bounded .reasoning-body live window
# --------------------------------------------------------------------------
def test_container_open_during_the_turn(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
login(page, app_url, next="/")
page.fill("#message-input", MIDROUND_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(MIDROUND_QUESTION)
# D1/D3: during the turn — the mock's 0.1 s pacing window (the
# 0.1 s tool frames + the thinking/delta streams) — the
# container exists, is OPEN, NESTS a per-run block in its
# .reasoning-body (the phase-158 direct-children layout is
# superseded by the container nesting), and its status carries a
# LIVE suffix (Thinking… / Calling …). The house deadline-poll
# pattern (a 50 ms stride over the ~2 s turn) comfortably
# samples the open window.
seen_live = False
deadline = time.monotonic() + 20.0
while time.monotonic() < deadline:
if page.evaluate(CONTAINER_LIVE_JS):
seen_live = True
break
time.sleep(0.05)
assert seen_live, (
"no open reasoning container with a NESTED per-run block "
"and a live status suffix was observed during the turn "
"(the D1/D3 live window — mid-round turn)"
)
# The turn settles (the D2 fold — test 3's contract).
_wait_settled(page)
# The D3 computed-style pin (folded in per the phase overview):
# the .reasoning-body is the ONE bounded live window — the
# computed max-height is ≤ 320px and overflow-y is auto (the CSS
# verified in the browser, not just in source).
style = page.evaluate(REASONING_BODY_STYLE_JS)
assert style is not None, "the .reasoning-body must exist after the turn"
max_h = style["maxH"]
assert max_h != "none", f"the .reasoning-body must be bounded, got {max_h!r}"
if max_h.endswith("px"):
assert float(max_h[:-2]) <= 320, f"max-height must be ≤ 320px, got {max_h!r}"
assert style["ovy"] == "auto", (
f"the .reasoning-body must be overflow-y: auto, got {style['ovy']!r}"
)
# The turn's full answer (the mid-round delta + the closing
# delta — the byte-stable concatenation).
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MIDROUND_ANSWER)
# --------------------------------------------------------------------------
# 2. D2 (the TODO's core): the container STAYS OPEN through a
# mid-round delta — it folds only once the final response has
# been returned and the session has stopped
# --------------------------------------------------------------------------
def test_container_stays_open_past_a_mid_round_delta(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
login(page, app_url, next="/")
page.fill("#message-input", MIDROUND_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(MIDROUND_QUESTION)
# D2: while the answer bubble is NON-EMPTY (the mid-round delta
# has rendered — the React-style partial answer) AND the turn is
# NOT settled (the in-flight Stop control), the container is
# STILL ``open`` with a run nested in its .reasoning-body. The
# house deadline-poll pattern (a 50 ms stride over the ~1 s
# window — the tool call + the app's ls execution + the
# follow-up request) comfortably samples it.
seen_still_open = False
deadline = time.monotonic() + 20.0
while time.monotonic() < deadline:
if page.evaluate(MIDROUND_STILL_OPEN_JS):
seen_still_open = True
break
time.sleep(0.05)
assert seen_still_open, (
"the container was not observed OPEN while the answer bubble "
"was non-empty and the turn was in flight (the D2 window — "
"the mid-round delta must NOT fold the container)"
)
# The turn settles with the full answer (the fold is test 3's
# contract — the settle proves the window ended in a completed
# turn, not a failure).
_wait_settled(page)
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MIDROUND_ANSWER)
# --------------------------------------------------------------------------
# 3. D2/D5/D6: the container folds at done — every inner run folds
# with it, and the status reads the live run count
# --------------------------------------------------------------------------
def test_container_folds_at_done(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
login(page, app_url, next="/")
_submit(page, MIDROUND_QUESTION)
_wait_settled(page)
# D2/D5: at rest the container has NO ``open`` attribute (the
# done settle folded it) — and EVERY inner run has no ``open``
# attribute either (the phase-158 fold-at-rest contract
# preserved inside the fold).
expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0)
expect(
page.locator("#messages .msg.brain details.reasoning details.thinking[open]")
).to_have_count(0)
expect(
page.locator(
"#messages .msg.brain details.reasoning details.tool-calls-disclosure[open]"
)
).to_have_count(0)
# The collapsed summary is still visible (the bubble stays
# readable at rest).
expect(
page.locator("#messages .msg.brain details.reasoning > .reasoning-summary").first
).to_be_visible()
# D6: the at-rest status is the live run count — the flow's 3
# contiguous runs (thinking Step 1, tool ls, thinking Step 2).
expect(
page.locator("#messages .msg.brain details.reasoning .reasoning-status").first
).to_have_text("3 steps")
# The per-run timeline inside the fold: the two DISTINCT
# ``Step N`` thinking runs + the one tool line (the phase-37 ls
# echo — no-arg top-level listing), in arrival order.
texts = page.locator(
"#messages .msg.brain details.reasoning .thinking-text"
)
expect(texts).to_have_count(2)
expect(texts.nth(0)).to_contain_text(STEP1_MARKER)
expect(texts.nth(1)).to_contain_text(STEP2_MARKER)
expect(
page.locator(
"#messages .msg.brain details.reasoning .tool-calls-disclosure"
)
).to_have_count(1)
expect(
page.locator(
"#messages .msg.brain details.reasoning .tool-call"
)
).to_have_count(1)
expect(
page.locator(
"#messages .msg.brain details.reasoning .tool-calls-summary"
)
).to_have_text("Tool call (1)")
# The turn's full answer (mid-round delta + closing delta).
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MIDROUND_ANSWER)
# --------------------------------------------------------------------------
# 4. D5/D6: a RELOAD restores the SAME container COLLAPSED around the
# per-run timeline, with the at-rest step count
# --------------------------------------------------------------------------
def test_restored_container_collapsed_with_step_count(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
login(page, app_url, next="/")
_submit(page, MIDROUND_QUESTION)
_wait_settled(page)
# RELOAD (same context) — the phase-14 record restores through
# the restore path (restoreTimeline — the segments take
# precedence).
page.reload()
expect(page.locator("#empty-state")).to_be_hidden(timeout=30_000)
# D5: ONE ``details.reasoning`` per restored brain message, NO
# ``open`` attribute (the restore renders collapsed; the live
# open state is exclusively the live turn's).
expect(page.locator("#messages .msg.brain details.reasoning")).to_have_count(1)
expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0)
# The SAME per-run timeline, nested in the container (all
# collapsed — the D5 restore convention).
expect(
page.locator("#messages .msg.brain details.reasoning details.thinking")
).to_have_count(2)
expect(
page.locator(
"#messages .msg.brain details.reasoning details.thinking[open]"
)
).to_have_count(0)
expect(
page.locator(
"#messages .msg.brain details.reasoning details.tool-calls-disclosure"
)
).to_have_count(1)
texts = page.locator(
"#messages .msg.brain details.reasoning .thinking-text"
)
expect(texts.nth(0)).to_contain_text(STEP1_MARKER)
expect(texts.nth(1)).to_contain_text(STEP2_MARKER)
# D6: the at-rest step count — the restore's INCREMENTAL count
# (each thinking entry + one per contiguous tool group) matches
# the live segRuns.length semantics: 3 contiguous runs.
expect(
page.locator("#messages .msg.brain details.reasoning .reasoning-status").first
).to_have_text("3 steps")
# The restored answer (the restore is synchronous at boot).
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
MIDROUND_ANSWER, timeout=30_000
)
# --------------------------------------------------------------------------
# 5. D5: the shared page (phase-51 save + share flow) renders the
# SAME container COLLAPSED — the addSegments parity
# --------------------------------------------------------------------------
def test_shared_page_container_collapsed(
page: Page,
browser: Browser,
app_url: str,
mock_llm: int,
db_ready: None,
) -> None:
_reset_db(_seed)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
_submit(page, MIDROUND_QUESTION)
_wait_settled(page)
# The phase-51 flow: the conversation is AUTO-SAVED by the time
# the answer settles (phase 55) — the Share click shares the
# linked row.
cookies = _admin_cookies(page)
_grant_clipboard(page, app_url)
_wait_saved_row(app_url, cookies, _auto_title(MIDROUND_QUESTION))
share = page.locator("#share-chat-btn")
expect(share).to_be_visible()
_click_share_and_assert_status(page, app_url)
row = _find_row(_chats(app_url, cookies), _auto_title(MIDROUND_QUESTION))
assert row is not None and row.get("share_url"), (
"the shared row must carry a share_url"
)
share_url: str = row["share_url"]
assert SHARE_URL_RE.fullmatch(share_url), f"bad share_url shape: {share_url}"
created: str = row["id"]
anon_ctx: BrowserContext | None = None
try:
# A FRESH context: a separate session with no cookies — the
# guest's only credential is the token in the URL.
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + share_url)
# The title = the auto-title (the shared chat's h1).
expect(anon.locator("#shared-title")).to_have_text(
_auto_title(MIDROUND_QUESTION)
)
# D5 (the shared-page half): ONE ``details.reasoning``, NO
# ``open`` attribute (the pure-render convention — the
# shared page is a finished conversation), wrapping the SAME
# per-run timeline, all collapsed.
expect(anon.locator("#messages .msg.brain details.reasoning")).to_have_count(1)
expect(
anon.locator("#messages .msg.brain details.reasoning[open]")
).to_have_count(0)
expect(
anon.locator(
"#messages .msg.brain details.reasoning details.thinking[open]"
)
).to_have_count(0)
expect(
anon.locator(
"#messages .msg.brain details.reasoning "
"details.tool-calls-disclosure[open]"
)
).to_have_count(0)
expect(
anon.locator("#messages .msg.brain details.reasoning details.thinking")
).to_have_count(2)
expect(
anon.locator(
"#messages .msg.brain details.reasoning "
"details.tool-calls-disclosure"
)
).to_have_count(1)
anon_texts = anon.locator(
"#messages .msg.brain details.reasoning .thinking-text"
)
expect(anon_texts.nth(0)).to_contain_text(STEP1_MARKER)
expect(anon_texts.nth(1)).to_contain_text(STEP2_MARKER)
# D6: the SAME at-rest step count (the addSegments parity —
# the same incremental count semantics as the restore).
expect(
anon.locator(
"#messages .msg.brain details.reasoning .reasoning-status"
).first
).to_have_text("3 steps")
# The shared answer bubble carries the turn's full answer.
expect(anon.locator(".msg.brain .bubble").first).to_contain_text(MIDROUND_ANSWER)
finally:
if anon_ctx is not None:
anon_ctx.close()
if created:
_delete_chat(app_url, cookies, created)
# --------------------------------------------------------------------------
# 6. D1/D4: a plain answer turn creates NO container (the
# byte-identical DOM); a no-thinking tool turn DOES (the tool
# frames create it — the phase-117 disclosure, now wrapped)
# --------------------------------------------------------------------------
def test_no_reasoning_turn_has_no_container(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_reset_db(_seed)
login(page, app_url, next="/")
# (a) The plain answer question (NO trigger — the generic
# compose_answer path: no thinking, no tools): D1/D4 — the
# byte-identical DOM, ZERO ``details.reasoning`` (and no
# thinking blocks / disclosures either).
_submit(page, PLAIN_ANSWER_QUESTION)
_wait_settled(page)
expect(page.locator("#messages .msg.brain details.reasoning")).to_have_count(0)
expect(page.locator("#messages .msg.brain details.thinking")).to_have_count(0)
expect(
page.locator("#messages .msg.brain details.tool-calls-disclosure")
).to_have_count(0)
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("")
# (b) The plain ``use your tools`` marker (NO thinking trigger —
# the mock's tool frames carry no ``reasoning_content``): the
# tool frames CREATE the container (D1 for tool-only turns),
# wrapping the ONE phase-117 disclosure.
page.fill("#message-input", PLAIN_TOOLS_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(PLAIN_TOOLS_QUESTION)
# D1 (the tool-only live half): during the turn — the mock's
# 0.1 s pacing window (the 0.1 s tool frames; the 3 phase-37
# calls) — the container is ``open`` with the disclosure
# NESTED in its .reasoning-body. The house deadline-poll
# pattern (a 50 ms stride over the ~1 s window) comfortably
# samples it.
seen_tool_live = False
deadline = time.monotonic() + 20.0
while time.monotonic() < deadline:
if page.evaluate(TOOL_ONLY_LIVE_JS):
seen_tool_live = True
break
time.sleep(0.05)
assert seen_tool_live, (
"no open reasoning container with the NESTED tool disclosure "
"was observed while the turn streamed (the D1 tool-only "
"live window)"
)
# The turn settles: the container FOLDS at done (the D2 settle —
# the no-thinking turn's single tool run), and the status reads
# the D6 UNIFORM literal — 1 run → ``1 steps``.
_wait_settled(page)
expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0)
expect(page.locator("#messages .msg.brain details.reasoning")).to_have_count(1)
expect(
page.locator("#messages .msg.brain details.reasoning .reasoning-status").first
).to_have_text("1 steps")
# The phase-117 single-disclosure behavior, preserved and now
# wrapped: ONE disclosure with the 3 ``.tool-call`` lines, zero
# thinking blocks, folded at rest.
expect(
page.locator(
"#messages .msg.brain details.reasoning details.tool-calls-disclosure"
)
).to_have_count(1)
expect(
page.locator(
"#messages .msg.brain details.reasoning .tool-call"
)
).to_have_count(3)
expect(
page.locator(
"#messages .msg.brain details.reasoning .tool-calls-summary"
)
).to_have_text("Tool calls (3)")
expect(
page.locator("#messages .msg.brain details.reasoning details.thinking")
).to_have_count(0)
expect(
page.locator(
"#messages .msg.brain details.reasoning details.thinking[open]"
)
).to_have_count(0)
# The phase-37 answer echo (the read document reached the model).
expect(page.locator(".msg.brain .bubble").last).to_contain_text(ANSWER_PREFIX)
+17 -2
View File
@@ -164,7 +164,12 @@ def test_thinking_block_streams_open_then_collapses(
# ---------------------------------------------------------------------------
# 2. Toggle: after settle the block is closed; the summary re-opens it
# (a real keyboard-focusable control) and closes it again
# (a real keyboard-focusable control) and closes it again.
# Phase 161 (D2): at rest the run sits INSIDE the folded reasoning
# container — the container's summary is the at-rest focusable
# surface (native <details> nesting semantics: a closed details
# hides its content, so the inner summary is focusable only after
# the container opens).
# ---------------------------------------------------------------------------
@@ -177,8 +182,18 @@ def test_thinking_toggle_after_done(page: Page, app_url: str, seeded_kb: None) -
details = last.locator("details.thinking")
expect(details).to_have_count(1)
expect(details).not_to_have_attribute("open") # auto-collapsed at first token
# Phase 161 (D2): the run nests in the reasoning container, which
# folds at done (the at-rest focusable surface is the container's
# summary — open it to reach the inner run's summary).
container = last.locator("details.reasoning")
expect(container).to_have_count(1)
expect(container).not_to_have_attribute("open")
container.locator("summary.reasoning-summary").focus()
assert page.evaluate("() => document.activeElement.tagName") == "SUMMARY"
container.locator("summary.reasoning-summary").click()
expect(container).to_have_attribute("open", "")
# The summary is a real, keyboard-focusable control.
# The inner run's summary is a real, keyboard-focusable control.
details.locator("summary").focus()
assert page.evaluate("() => document.activeElement.tagName") == "SUMMARY"
+176 -53
View File
@@ -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)
+45 -8
View File
@@ -144,6 +144,10 @@ SUMMARY = "#messages .msg.brain .tool-calls-summary"
DISCLOSURE = "#messages .msg.brain .tool-calls-disclosure"
LINES = "#messages .msg.brain .tool-call"
#: Phase 161 (D1/D2): the ONE reasoning container — created on the
#: turn's first tool frame, OPEN live, folded at the settle (D2).
REASONING = "#messages .msg.brain details.reasoning"
# --------------------------------------------------------------------------
# DB seeding (TRUNCATE-then-seed, cf. test_agent_document_tools.py)
# --------------------------------------------------------------------------
@@ -261,7 +265,12 @@ def _submit_tools_turn(page: Page, app_url: str) -> None:
def _expand_disclosure(page: Page) -> None:
"""Tap the summary — the native ``<summary>`` is a real focusable
toggle (AA), so a plain click is the user contract."""
toggle (AA), so a plain click is the user contract. Phase 161
(D2): at rest the disclosure sits INSIDE the folded reasoning
container, so the user flow is two native toggles — open the
container first (its own summary — a direct child, so the inner
runs' summaries are not matched), then the disclosure's summary."""
page.locator(f"{REASONING} > summary").click()
page.locator(SUMMARY).click()
@@ -274,16 +283,27 @@ def test_tool_calls_fold_to_one_line_after_turn(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Pin 1 (the space fix): after the deterministic ``ls`` →
``ls(scoped)`` → ``read`` turn COMPLETES, the record is ONE visible
"Tool calls (3)" summary — the disclosure is CLOSED (no ``open``
``ls(scoped)`` → ``read`` turn COMPLETES, the record is ONE
visible summary line — the disclosure is CLOSED (no ``open``
attribute: the first answer delta folded it, D3), the three
``.tool-call`` lines are still in the DOM (the permanent record)
but hidden (the first line is not visible), and the answer bubble
is present."""
is present. Phase 161 (D2): the at-rest record is wrapped in the
FOLDED reasoning container — the at-rest visible line is the
container's "Reasoning" summary; the disclosure's "Tool calls (3)"
count is the in-container record, revealed by opening it."""
page.set_default_timeout(30_000)
_reset_db(_seed)
_submit_tools_turn(page, app_url)
# Phase 161 (D2): the container exists (the tool frames created
# it) and folds at done — the at-rest visible line is its summary.
container = page.locator(REASONING)
expect(container).to_have_count(1)
expect(container).not_to_have_attribute("open")
page.locator(f"{REASONING} > summary").click()
expect(container).to_have_attribute("open", "")
# One compact summary line, visible, with the live count.
summary = page.locator(SUMMARY)
expect(summary).to_be_visible()
@@ -396,10 +416,15 @@ def test_restored_turn_renders_folded(
record is written at the save points, so the reload sees the full
conversation), a same-context RELOAD re-renders the persisted
record through the restore path (``renderStoredMessage`` →
``appendToolLine`` → the after-loop fold): the disclosure is
``restoreTimeline`` → the after-loop fold): the disclosure is
present, CLOSED (no ``open`` attribute — no auto-expand on load),
the summary reads "Tool calls (3)", the three lines are in the DOM
and the first is hidden (folded on load)."""
and the first is hidden (folded on load). Phase 161 (D2/D5): the
restored record is wrapped in the FOLDED reasoning container —
the at-rest visible line is the container's "Reasoning" summary;
the disclosure's "Tool calls (3)" count is the in-container
record, revealed by opening the container (the same two-native-
toggle user flow as the live at-rest pins)."""
page.set_default_timeout(30_000)
_reset_db(_seed)
_submit_tools_turn(page, app_url)
@@ -413,10 +438,22 @@ def test_restored_turn_renders_folded(
page.locator(".msg.brain .bubble").last
).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
# Folded on load: one compact summary line, disclosure closed.
# Folded on load (phase 161, D2/D5): the record is wrapped in the
# FOLDED reasoning container — the at-rest visible line is the
# container's "Reasoning" summary.
container = page.locator(REASONING)
expect(container).to_have_count(1)
expect(container).not_to_have_attribute("open")
expect(container.locator("> summary")).to_be_visible()
expect(container.locator("> summary")).to_contain_text("Reasoning")
# The disclosure is CLOSED (no auto-expand on load) — and the
# in-container record is revealed by opening the container (the
# two-native-toggle user flow).
assert page.get_attribute(DISCLOSURE, "open") is None
page.locator(f"{REASONING} > summary").click()
expect(page.locator(SUMMARY)).to_be_visible()
expect(page.locator(SUMMARY)).to_contain_text("Tool calls (3)")
assert page.get_attribute(DISCLOSURE, "open") is None
# The three lines are in the DOM (the permanent record) but folded:
# the first line is not rendered — no auto-expand.
+46 -4
View File
@@ -32,8 +32,10 @@ And — the phase's explicit "NOT touched" contract — ``retryLastTurn``
is byte-unchanged, ``runTurn`` is pinned at exactly the state the
phase left it (its sanctioned additions — the task-01
``lastUserWrap`` assignment + the idle-settle ``markLastEditable()``
mark pass, and the task-03 aborted-settle mark pair — included;
no task may restructure it), and — the task-04 regression pins —
mark pass, the task-03 aborted-settle mark pair, the phase-160
done-settle pair, and the phase-161 live-container additions —
included; no task may restructure it), and — the task-04 regression
pins —
``handleSend`` and ``rememberBrainTurn`` are byte-unchanged (the
plain send's pre-work and the brain-record funnel the re-ask's
settle rides are touched by no phase-159 task). Task 03 (the
@@ -852,6 +854,7 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
thinkingAcc = "";
segRuns = []; // phase 158: a fresh turn starts with no runs (the pagehide partial reads the current turn's runs)
currentRun = null;
reasoningWrapEl = null; // phase 161: a fresh turn starts with no container (the pagehide partial reads the live container, like segRuns)
persistedOnLeave = false;
leftThePage = false;
leavePartialIndex = -1;
@@ -954,6 +957,11 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
if (!wrap) wrap = addMessage("brain", "");
removeTyping(); // the live block replaces the dots as feedback
const block = ensureThinkingRun(wrap);
// Phase 161 (D6): the container's live status — "Thinking…"
// while a thinking frame streams (a tool frame overwrites it
// with "Calling <name>…"; the settle sites overwrite it with
// "N steps").
setReasoningStatus("Thinking…");
currentRun.run.text += ev.text || ""; // the RUN's text — the per-run block renders it
const textEl = block.querySelector(".thinking-text");
// Pin state is measured BEFORE the re-render: a chunk taller
@@ -967,6 +975,13 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
// THIS block (per-run — each run's block pins its own tail;
// the pre-update capture is verbatim).
const pinned = block.open && isThinkingNearBottom(textEl);
// Phase 161 (D3): the container window's PRE-update pin
// capture — the phase-17 pre-measure convention, BEFORE the
// re-render grows the content (the inner per-block follow
// above stays — inside the container the .thinking-text is
// no longer scrollable, so it no-ops there; task 03).
const rbody = reasoningWrapEl?.querySelector(".reasoning-body");
const rPinned = !!rbody && isReasoningPinned(rbody);
textEl.innerHTML = renderMarkdown(currentRun.run.text); // escape-first, XSS-safe
if (pinned) {
// Follow the live tail only while the user was pinned to the
@@ -975,6 +990,7 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
// returning to the bottom re-arms the pin on the next chunk.
textEl.scrollTop = textEl.scrollHeight;
}
if (rPinned && rbody) scrollReasoningToBottom(rbody); // post-append apply
} else if (ev.type === "tool") {
// Phase 37 (PLAN §4 extension): an agent tool call. The UI
// state stays "thinking" — the button stays the enabled "Stop"
@@ -1057,6 +1073,14 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
// wiring.
const disc = ensureToolRun(wrap);
currentRun.run.records.push(toolRecord);
// Phase 161 (D6): "Calling <name>…" — the display name the
// tool line already shows (the phase-70 legacy-name mapping
// produced `name` before this point).
setReasoningStatus(`Calling ${name}…`);
// Phase 161 (D3): the container window's PRE-update pin
// capture — before the line append grows the content.
const tbody = reasoningWrapEl?.querySelector(".reasoning-body");
const tPinned = !!tbody && isReasoningPinned(tbody);
const line = appendToolLine(
disc,
name,
@@ -1065,6 +1089,7 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
frameLimit,
framePath
);
if (tPinned && tbody) scrollReasoningToBottom(tbody); // post-append apply
// Phase 87 (TODO.md L5): arm this line's elapsed clock — the
// baseline resets per line, so each line counts its OWN
// frameless silence. This live branch is the ONLY arm call site
@@ -1121,6 +1146,11 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
// late double-close is a no-op). Replaces the phase-117
// closeToolCalls fold + the D15 block close.
closeAllRuns(wrap);
// Phase 161 (D2): the turn settled — fold the container too
// (the final response has been returned); the summary reads
// the live run count (D6 — segRuns, module scope).
settleReasoning(wrap);
setReasoningStatus(`${segRuns.length} steps`);
if (!wrap) {
setUiState(UI_STATE.streaming);
wrap = addMessage("brain", "…");
@@ -1332,6 +1362,11 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
// (replaces the phase-117 closeToolCalls fold + the D15 block
// close, like `done`).
closeAllRuns(wrap);
// Phase 161 (D2): the session stopped — fold the container
// alongside the runs; the summary reads the live run count
// (D6 — segRuns, module scope).
settleReasoning(wrap);
setReasoningStatus(`${segRuns.length} steps`);
persistedOnLeave = true;
appendTuneButton(wrap); // admin-only; parity with the restore path
appendStoppedNote(wrap);
@@ -1427,12 +1462,19 @@ def test_run_turn_is_pinned_at_phase_159_state() -> None:
plus phase 160 (task 04)'s sanctioned additions (task 02 consumes
its ``reask`` path AS-IS — the phase's "NOT touched" contract;
the phase-160 additions are the done settle's additive
``budget_exhausted`` meta key + the flag-gated Continue button):
``budget_exhausted`` meta key + the flag-gated Continue button)
and phase 161 (task 01)'s sanctioned additions (the
``reasoningWrapEl`` turn-local reset — the pagehide partial reads
the live container, like segRuns; the D6 status writes + the D3
pre/post follow in the thinking/tool frame branches; the
``settleReasoning(wrap)`` fold at the done / stop / failed
settle sites — the container's live-path contract, D1/D2/D3/D6):
the full source must match the pin — the ``reask`` path, the
turn-local resets, the SSE contract, the save points, and the
sanctioned additions (the task-01 ``lastUserWrap`` assignment +
the idle-settle mark pass, the task-03 aborted-settle mark pair,
and the phase-160 done-settle pair) included."""
the phase-160 done-settle pair, and the phase-161 live-container
additions) included."""
assert _fn_source(_js(), "runTurn") == PINNED_RUN_TURN
+14 -7
View File
@@ -145,11 +145,13 @@ def test_thinking_event_is_a_first_class_turn_branch() -> None:
def test_thinking_run_helpers_are_idempotent() -> None:
"""Phase 158 (D1/D5 — the phase-109 D15 pin, replaced by owner
direction 2026-10-04): ``ensureThinkingRun`` returns the active
run's block or creates a NEW one OPEN above the .bubble (a frame
of a new kind closes the previous run first); ``closeCurrentRun``
is a no-op without a run and folds the active one (the D15
re-open of the shared block is gone — a post-delta thinking frame
opens a NEW block)."""
run's block or creates a NEW one OPEN (phase 161, D1: appended
into the reasoning container's ``.reasoning-body`` — the
per-run bubble anchor is superseded; a frame of a new kind
closes the previous run first); ``closeCurrentRun`` is a no-op
without a run and folds the active one (the D15 re-open of the
shared block is gone — a post-delta thinking frame opens a NEW
block)."""
js = _js()
fn = js.find("function ensureThinkingRun")
assert fn != -1, "ensureThinkingRun must exist (near addTyping/removeTyping)"
@@ -157,8 +159,13 @@ def test_thinking_run_helpers_are_idempotent() -> None:
assert "closeCurrentRun()" in body, ("a frame of a new kind closes the "
"previous run (D1)")
assert "block.open = true" in body, "D5: created open — the stream is the show"
assert "insertBefore" in body
assert 'querySelector(".bubble")' in body, "the block sits ABOVE the bubble"
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(block)" in body
), "phase 161 (D1): appended into the container's body (before the bubble)"
assert "insertBefore" not in body, (
"the per-run bubble anchor is GONE (phase 161 D1)"
)
fn2 = js.find("function closeCurrentRun")
assert fn2 != -1, "closeCurrentRun must exist"
body2 = js[fn2 : js.find("\n}\n", fn2)]
+31 -16
View File
@@ -140,8 +140,10 @@ def test_tool_lines_render_into_the_bubble_wrap() -> None:
"""Phase 158 (D1): the tool lines render into the run's
disclosure — ``createToolDisclosure`` builds the phase-117
structure (a ``.tool-calls`` list, role=list + accessible name
"Tool calls") and ``ensureToolRun`` inserts it BEFORE the bubble
(D2: above the answer, in arrival order); each line is a
"Tool calls") and ``ensureToolRun`` appends it into the
phase-161 reasoning container's ``.reasoning-body`` (D1: the
container sits ABOVE the answer bubble — the per-run bubble
anchor is superseded; arrival order preserved); each line is a
.tool-call listitem with the exact marks: 🔎 Listing documents /
📄 Reading <code>path</code>. The path goes through textContent
(storage can never inject HTML)."""
@@ -155,8 +157,12 @@ def test_tool_lines_render_into_the_bubble_wrap() -> None:
)
fn2 = js.find("function ensureToolRun")
run = js[fn2 : js.find("\n}\n", fn2)]
assert 'insertBefore(disc, body.querySelector(".bubble"))' in run, (
"the disclosure sits ABOVE the answer, in arrival order"
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(disc)" in run
), (
"phase 161 (D1): the disclosure nests in the container, which "
"sits above the answer (arrival order preserved)"
)
fn = js.find("function appendToolLine")
assert fn != -1, "appendToolLine must exist"
@@ -266,25 +272,34 @@ def test_tool_lines_re_render_on_restore() -> None:
def test_segments_keep_arrival_order_above_the_bubble() -> None:
"""Phase 158 (D2): both run builders insert their element
BEFORE the bubble — later insertions land after earlier ones, so
document order is ARRIVAL order (a tool frame preceding the
first thinking frame lands the scratchpad BELOW the tool lines —
the timeline order; the old "scratchpad on top" anchor chain is
gone)."""
"""Phase 158 (D2) + 161 (D1): both run builders append their
element into the reasoning container's ``.reasoning-body`` —
later appends land after earlier ones, so document order is
ARRIVAL order (a tool frame preceding the first thinking frame
lands the scratchpad BELOW the tool lines — the timeline order;
the old "scratchpad on top" anchor chain is gone). The
container itself sits before the bubble (the ONE anchor)."""
js = _js()
fn = js.find("function ensureThinkingRun")
t = js[fn : js.find("\n}\n", fn)]
fn2 = js.find("function ensureToolRun")
d = js[fn2 : js.find("\n}\n", fn2)]
assert 'insertBefore(block, body.querySelector(".bubble"))' in t, (
"the thinking run inserts before the bubble"
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(block)" in t
), "the thinking run appends into the container's body"
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(disc)" in d
), "the tool run appends into the container's body"
assert "insertBefore" not in t, (
"the per-run bubble anchor is GONE (phase 161 D1)"
)
assert 'insertBefore(disc, body.querySelector(".bubble"))' in d, (
"the tool run inserts before the bubble"
assert "insertBefore" not in d, (
"the per-run bubble anchor is GONE (phase 161 D1)"
)
assert "??" not in t, ("the old anchor chain is GONE — the bubble is "
"the only anchor")
assert "??" not in t, ("the old anchor chain is GONE — the container "
"append is the only anchor")
assert "block.open = true" in t
assert "disc.open = true" in d
+956
View File
@@ -0,0 +1,956 @@
"""Unit: the phase-161 live reasoning container contract (task 01).
The owner's report (2026-10-05, live chat): the per-run thinking /
tool-call blocks (phase 158) stacked above the answer take up a ton
of space and obscure the answer. The fix (phase 161, owner decisions
D1–D6): ONE ``details.reasoning`` container per brain message wraps
the ENTIRE per-run timeline — created on the turn's FIRST
thinking/tool frame, OPEN from creation until the turn settles
(done / stop / the failed-turn funnel), condensed in its ONE bounded
``.reasoning-body`` live window (320px, task 03) with the
pinned-tail follow (the phase-17 pre-measure convention); the
per-run blocks keep their structure and open/fold behavior — only
their PARENT changed (the per-run bubble anchor is superseded by the
container's ``.reasoning-body`` append, arrival order preserved). The
container's summary carries the D6 status literals: the static
"Reasoning" + a live span ("Thinking…" / "Calling <name>…" / "N
steps"). A turn with no thinking and no tools creates NO container
(byte-identical DOM — D1).
Task 01 pins the LIVE path: the static builder (createElement only,
innerHTML ABSENT), the get-or-create wrapper (open at creation,
before the bubble, module-scope declaration + reset in runTurn — the
pagehide partial reads the live container, like segRuns), the
re-anchored per-run inserts, the ``settleReasoning`` fold at the
THREE settle sites (and NOT at the mid-round delta — D2's core
contract), the D6 status literals, and the D3 pre/post follow split.
Task 02 pins the RESTORE + SHARED path: the collapsed container in
``restoreTimeline`` (the shared task-01 builder, created CLOSED,
inserted ONCE before the bubble; the per-run blocks append into the
container's ``.reasoning-body`` — the per-run bubble anchor is
GONE), the D6 at-rest step count (the incremental contiguous-run
semantics — each thinking entry is ONE run; a contiguous tool group
of N entries is ONE run; the uniform ``${runs} steps`` literal), the
legacy no-segments path (the ``renderStoredMessage`` dispatch
UNCHANGED — a record without ``segments`` never reaches
``restoreTimeline`` — no container, D4), and the ``shared.js``
``addSegments`` parity (the LOCAL parity builder — the shared page
has no import of app.js — created CLOSED, the SAME step-count
semantics, the SAME line bytes, innerHTML ABSENT from both
wrappers).
No ``app/`` logic in this phase (D4 — zero persistence change) — the
behavior lives in ``frontend/assets/app.js`` and is E2E-gated by the
story suite (``tests/e2e/test_reasoning_bubble.py``, task 04). Like
the other frontend-adjacent unit files (the house
read-the-assets-as-text pattern, cf. ``test_thinking_timeline.py``),
this module pins the JS markers the phase depends on.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
SHARED_JS = FRONTEND / "assets" / "shared.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
# The shared-page parity line bytes (the same four label literals
# addSegments mirrors from addToolLines — unchanged by task 02).
PINNED_LABELS_SHARED = (
'line.textContent = "📄 Reading "',
'line.textContent = "🔎 Searching for "',
'line.textContent = "🔎 Listing documents in "',
'line.textContent = "🔎 Listing documents"',
)
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _js_shared() -> str:
return SHARED_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _rule(css: str, selector: str) -> str:
"""The first ``<selector> { … }`` rule body — the house pin
pattern (the reasoning selectors are unique in the sheet)."""
m = re.search(re.escape(selector) + r" \{([^}]*)\}", css)
assert m, f"styles.css must style {selector}"
return m.group(1)
def _comment_before(css: str, selector: str) -> str:
"""The comment region immediately preceding the rule (the sheet
documents each rule's phase + decision right above it)."""
i = css.find(selector)
assert i != -1, f"styles.css must style {selector}"
return css[max(0, i - 1600) : i]
def _fn(src: str, name: str) -> str:
"""The flat function body (house extraction: first ``\\n}\\n`` after
the definition — the function must stay flat, no nested
declarations)."""
fn = src.find(f"function {name}")
assert fn != -1, f"{name} must exist"
return src[fn : src.find("\n}\n", fn)]
def _branch(js: str, open_type: str, close_type: str) -> str:
"""One turn-handler branch, between the named event types."""
start = js.find(f'ev.type === "{open_type}"')
end = js.find(f'ev.type === "{close_type}"')
assert -1 < start < end, f"the {open_type} branch must exist"
return js[start:end]
# ---------- the static builder (D1/D6) ----------
def test_create_reasoning_wrap_builds_the_container_with_create_element() -> None:
"""D1/D6: ``createReasoningWrap`` builds ``details.reasoning``
wrapping ``summary.reasoning-summary`` (the "Reasoning" literal —
D6's ONLY new static literal — + the EMPTY
``span.reasoning-status`` a frame fills) and the bounded
``.reasoning-body`` window (task 03), with createElement only
(innerHTML ABSENT — the house rule; the literal is a static text
node, no data flows through it). The caller owns open/fold +
insertion."""
body = _fn(_js(), "createReasoningWrap")
assert 'document.createElement("details")' in body
assert 'cont.className = "reasoning"' in body
assert 'document.createElement("summary")' in body
assert 'summary.className = "reasoning-summary"' in body
assert 'summary.textContent = "Reasoning"' in body, (
"D6: the ONLY new static literal"
)
assert 'document.createElement("span")' in body
assert 'status.className = "reasoning-status"' in body
assert "summary.appendChild(status)" in body, (
"the status span rides the summary (after the literal)"
)
assert 'document.createElement("div")' in body
assert 'body.className = "reasoning-body"' in body
assert "cont.appendChild(body)" in body
assert "return cont" in body
assert "innerHTML" not in body, (
"createElement only — innerHTML ABSENT (the house rule)"
)
def test_the_reasoning_status_span_starts_empty() -> None:
"""D6: the ``span.reasoning-status`` is EMPTY until a frame sets
it — the builder never writes a status into the span itself."""
body = _fn(_js(), "createReasoningWrap")
assert "status.textContent" not in body, (
"the span starts empty — a frame (or the settle) fills it"
)
# ---------- the get-or-create wrapper (D1) ----------
def test_ensure_reasoning_wrap_is_get_or_create_open_before_bubble() -> None:
"""D1: ``ensureReasoningWrap`` is get-or-create — an existing
container is returned as-is; otherwise it is created (the shared
builder), OPEN (``open = true`` — open from creation until the
turn settles, D2), inserted BEFORE the bubble (the ONE new
anchor), and stored in the module-scope ``reasoningWrapEl``. The
container is created by the FIRST run's ensure call — no other
call site."""
body = _fn(_js(), "ensureReasoningWrap")
assert "if (reasoningWrapEl) return reasoningWrapEl;" in body, (
"get-or-create: the existing container is returned as-is"
)
assert "createReasoningWrap()" in body, "the shared builder is reused"
assert "cont.open = true" in body, (
"D1: OPEN at creation (it folds at the settle — D2)"
)
assert 'wrap.querySelector(".msg-body")' in body
assert 'body.insertBefore(cont, body.querySelector(".bubble"))' in body, (
"the container sits ABOVE the answer bubble"
)
assert "reasoningWrapEl = cont" in body, (
"stored in the module scope (the pagehide partial reads it)"
)
assert "return cont" in body
def test_the_reasoning_wrap_is_module_scoped_and_reset_in_run_turn() -> None:
"""D1 (module-scope readability): ``reasoningWrapEl`` is declared
at MODULE SCOPE (like segRuns / currentRun — the pagehide partial
reads the live container while a turn is in flight) and is reset
per turn in ``runTurn`` alongside the other turn locals."""
js = _js()
assert "\nlet reasoningWrapEl = null;" in js, (
"module-scope declaration (column 0 — not a function local)"
)
run = _fn(js, "runTurn")
assert "reasoningWrapEl = null;" in run, (
"reset per turn in runTurn — like segRuns"
)
# reset with the other turn locals (the reset block, one place)
assert "segRuns = []" in run
assert "currentRun = null" in run
assert run.find("segRuns = []") < run.find("reasoningWrapEl = null;")
# ---------- the re-anchored per-run inserts (D1) ----------
def test_per_run_inserts_append_into_the_reasoning_body() -> None:
"""D1: the per-run blocks append into the container's
``.reasoning-body`` — ``ensureThinkingRun`` / ``ensureToolRun``
insert via ``ensureReasoningWrap(wrap).querySelector(
".reasoning-body").appendChild(…)`` (arrival order preserved: a
new run appends after the previous run's block). EVERYTHING ELSE
in both functions is unchanged (closeCurrentRun, ``open = true``
D5, the segRuns push, currentRun)."""
js = _js()
t = _fn(js, "ensureThinkingRun")
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(block)" in t
), "the thinking run appends into the container's body"
assert "closeCurrentRun()" in t
assert "block.open = true" in t, "D5: the per-run open/fold is unchanged"
assert "segRuns.push(run)" in t
assert "currentRun =" in t
d = _fn(js, "ensureToolRun")
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(disc)" in d
), "the tool run appends into the container's body"
assert "closeCurrentRun()" in d
assert "disc.open = true" in d, "D5: the per-run open/fold is unchanged"
assert "segRuns.push(run)" in d
def test_the_per_run_bubble_anchor_is_gone() -> None:
"""D1 (the documented anchor supersession): the per-run
``insertBefore(…, .bubble)`` anchor is GONE from
``ensureThinkingRun`` / ``ensureToolRun`` — the container is the
ONE new anchor (it sits before the bubble; the runs nest inside
it). The container itself still anchors on the bubble (one site).
"""
js = _js()
t = _fn(js, "ensureThinkingRun")
d = _fn(js, "ensureToolRun")
assert "insertBefore" not in t, (
"the per-run bubble anchor is gone from ensureThinkingRun"
)
assert "insertBefore" not in d, (
"the per-run bubble anchor is gone from ensureToolRun"
)
er = _fn(js, "ensureReasoningWrap")
assert 'body.insertBefore(cont, body.querySelector(".bubble"))' in er, (
"the ONE anchor: the container before the bubble"
)
# ---------- the settle fold (D2) ----------
def test_settle_reasoning_is_defined_and_folds_the_container() -> None:
"""D2: ``settleReasoning`` removes the container's ``open``
attribute — idempotent; a no-op without a container (the
plain-answer turn settles byte-identically). Wrap-scoped (the
caller's wrap), like closeAllRuns."""
body = _fn(_js(), "settleReasoning")
assert (
'wrap?.querySelector("details.reasoning")?.removeAttribute("open")'
in body
), "the container fold — the open attribute is removed"
assert "reasoningWrapEl" not in body, (
"wrap-scoped (the caller's wrap), like closeAllRuns"
)
def test_settle_reasoning_runs_at_the_three_settle_sites() -> None:
"""D2: the container folds at the THREE settle sites — the
``done`` branch, the stop/abort path, the failed-turn funnel —
alongside ``closeAllRuns(wrap)`` (immediately after it), with
the D6 "N steps" summary (the live run count — ``segRuns``,
module scope)."""
js = _js()
done = _branch(js, "done", "tool_result")
assert "closeAllRuns(wrap)" in done
assert "settleReasoning(wrap)" in done
assert done.find("closeAllRuns(wrap)") < done.find(
"settleReasoning(wrap)"
), "done: settleReasoning sits alongside (after) closeAllRuns"
assert "`${segRuns.length} steps`" in done
# the stop path: the catch's stop branch up to the error else
start = js.find('stoppedByUser || err?.name === "AbortError"')
end = js.find("\n } else {\n", start)
assert -1 < start < end, "the stop path must exist"
stop = js[start:end]
assert "closeAllRuns(wrap)" in stop
assert "settleReasoning(wrap)" in stop
assert stop.find("closeAllRuns(wrap)") < stop.find(
"settleReasoning(wrap)"
), "stop: settleReasoning sits alongside (after) closeAllRuns"
assert "`${segRuns.length} steps`" in stop
ff = _fn(js, "finalizeFailedTurn")
assert "closeAllRuns(wrap)" in ff
assert "settleReasoning(wrap)" in ff
assert ff.find("closeAllRuns(wrap)") < ff.find(
"settleReasoning(wrap)"
), "failed: settleReasoning sits alongside (after) closeAllRuns"
assert "`${segRuns.length} steps`" in ff
def test_settle_reasoning_does_not_fold_at_the_mid_round_delta() -> None:
"""D2 (the TODO's core contract): a mid-round ``delta`` FOLDS the
active run (``closeCurrentRun`` — phase 158 D5, unchanged) but
the container STAYS OPEN — ``settleReasoning`` is NOT called in
the delta branch (the container collapses only once the final
response has been returned and the session has stopped)."""
js = _js()
delta = _branch(js, "delta", "done")
assert "closeCurrentRun()" in delta, (
"the active run still folds at the first delta (phase 158 D5)"
)
assert "settleReasoning" not in delta, (
"the container STAYS OPEN through a mid-round delta (D2)"
)
# ---------- the D6 status literals ----------
def test_the_d6_status_literals_are_pinned() -> None:
"""D6: the live status literals — "Thinking…" (the thinking frame
branch), "Calling <name>…" (the tool frame branch — the display
name the tool line already shows, the phase-70 legacy-name
mapping already produced it) — and "N steps" (the settle sites,
pinned in the settle test) all write to ``.reasoning-status``
through the ``setReasoningStatus`` setter (a no-op without a
container — a plain-answer turn never writes one)."""
js = _js()
thinking = _branch(js, "thinking", "tool")
assert '"Thinking…"' in thinking, "the live 'Thinking…' suffix"
tool = _branch(js, "tool", "retry")
assert "`Calling ${name}…`" in tool, (
"'Calling <name>…' — the display name the tool line shows"
)
assert "`${segRuns.length} steps`" in js, "the settle 'N steps' count"
assert js.count('setReasoningStatus(`${segRuns.length} steps`)') == 3, (
"the 'N steps' template at the three settle sites"
)
def test_the_status_setter_is_the_no_op_guarded_chain() -> None:
"""D6: the status writes go through ``setReasoningStatus`` — a
direct ``reasoningWrapEl?.…()?.textContent = x`` left-hand side
is a syntax error (an LHS allows at most one trailing ``?.``
member — not a call), which kills the module at parse time, so
the setter reads the span into a LOCAL reference and writes it
only when the container exists (a no-op without a container)."""
js = _js()
setter = _fn(js, "setReasoningStatus")
assert 'reasoningWrapEl?.querySelector(".reasoning-status")' in setter
assert "el.textContent = text" in setter, (
"the span is filled by a frame (or the settle)"
)
assert 'reasoning-status")?.textContent' not in js, (
"the multi-optional LHS assignment is GONE (it is a syntax "
"error — the parse-time module killer)"
)
assert js.count("setReasoningStatus(") == 6, (
"one definition + exactly the five status sites "
"(thinking / tool / done / stop / failed)"
)
def test_the_status_lands_after_the_run_ensures_the_container() -> None:
"""D6: the status is set AFTER ``ensureThinkingRun`` /
``ensureToolRun`` (the container exists by then —
``ensureReasoningWrap`` runs inside the run ensure), so the first
frame's status lands on a live container."""
js = _js()
thinking = _branch(js, "thinking", "tool")
assert thinking.find("ensureThinkingRun(wrap)") >= 0
assert thinking.find("ensureThinkingRun(wrap)") < thinking.find(
'"Thinking…"'
), "the thinking status follows the run's ensure"
tool = _branch(js, "tool", "retry")
assert tool.find("ensureToolRun(wrap)") >= 0
assert tool.find("ensureToolRun(wrap)") < tool.find(
"`Calling ${name}…`"
), "the tool status follows the run's ensure"
# ---------- the D3 live follow ----------
def test_the_reasoning_follow_band_is_pinned_at_32px() -> None:
"""D3: ``REASONING_NEAR_BOTTOM_PX = 32`` — the same 32px band as
the phase-17 per-block window (exported — the unit-pin
convention, same pattern as TURN_TIMEOUT_MS). The pin check is
the phase-17 pre-measure convention (the distance-to-bottom
form, mirrored)."""
js = _js()
assert "export const REASONING_NEAR_BOTTOM_PX = 32;" in js, (
"the band is the phase-17 32px threshold, exported"
)
pin = _fn(js, "isReasoningPinned")
assert "el.scrollHeight - REASONING_NEAR_BOTTOM_PX" in pin
assert "el.scrollTop + el.clientHeight" in pin
def test_the_follow_is_the_phase_17_pre_update_post_append_split() -> None:
"""D3: the pre-update capture (BEFORE the chunk/line grows the
content — the phase-17 pre-measure convention) and the
post-append apply (``el.scrollTop = el.scrollHeight``) — called
from BOTH the thinking frame branch (pre: before the block
re-render; post: after it) and the tool frame branch (pre: before
``appendToolLine``; post: after it), on
``reasoningWrapEl?.querySelector(".reasoning-body")`` (a no-op
when absent — the plain-answer turn never creates one)."""
js = _js()
scroll = _fn(js, "scrollReasoningToBottom")
assert "el.scrollTop = el.scrollHeight" in scroll, "the post-append apply"
thinking = _branch(js, "thinking", "tool")
pre_t = thinking.find("isReasoningPinned(")
render_t = thinking.find("textEl.innerHTML = renderMarkdown")
post_t = thinking.find("scrollReasoningToBottom(")
assert 0 < pre_t < render_t < post_t, (
"thinking: the pre capture is BEFORE the re-render, the "
"post apply AFTER it"
)
assert 'reasoningWrapEl?.querySelector(".reasoning-body")' in thinking
tool = _branch(js, "tool", "retry")
pre = tool.find("isReasoningPinned(")
append = tool.find("appendToolLine(")
post = tool.find("scrollReasoningToBottom(")
assert 0 < pre < append < post, (
"tool: the pre capture is BEFORE the line append, the post "
"apply AFTER it"
)
assert 'reasoningWrapEl?.querySelector(".reasoning-body")' in tool
def test_the_existing_per_block_follow_is_untouched() -> None:
"""D3 (harmless): the EXISTING per-block
``isThinkingNearBottom(textEl)`` follow is unchanged — inside the
container the ``.thinking-text`` is no longer scrollable (task
03's ``max-height: none``), so it no-ops there; the legacy
no-container path keeps its behavior, and it costs nothing."""
js = _js()
thinking = _branch(js, "thinking", "tool")
assert "isThinkingNearBottom(textEl)" in thinking
assert "textEl.scrollTop = textEl.scrollHeight" in thinking
# ---------- task 02: the collapsed restore wrapper (app.js) ----------
def test_restore_creates_the_container_closed_once_before_the_bubble() -> None:
"""D5: ``restoreTimeline`` creates the container via the SHARED
task-01 builder, created CLOSED (``cont.open = false`` — the
restore renders collapsed; explicit for the pin), and inserted
ONCE before the bubble (a get-or-create shape — a single
``details.reasoning`` per message: exactly one builder call and
one container insertion). The ``.reasoning-body`` window is
referenced for the per-run appends."""
body = _fn(_js(), "restoreTimeline")
assert "createReasoningWrap()" in body, "the shared task-01 builder is reused"
assert body.count("createReasoningWrap()") == 1, (
"the container is created ONCE per message (a get-or-create shape)"
)
assert "cont.open = false" in body, (
"D5: the restore renders collapsed (explicit for the pin)"
)
assert "body.insertBefore(cont, bubble)" in body, (
"the container sits ABOVE the answer bubble (the ONE anchor)"
)
assert body.count("body.insertBefore(cont, bubble)") == 1, "inserted ONCE"
assert 'cont.querySelector(".reasoning-body")' in body, (
"the .reasoning-body window is the per-run append target"
)
def test_restore_per_run_blocks_append_into_the_reasoning_body() -> None:
"""D1 (the documented anchor supersession): the per-run blocks
append into the container's ``.reasoning-body`` — the per-run
``insertBefore(…, bubble)`` anchor is GONE from
``restoreTimeline`` (the container sits before the bubble; the
runs nest inside it, arrival order preserved). EVERYTHING ELSE
in the loop is unchanged (the per-run create, the closed state,
the tool-run seal, the well-formedness guards, the truncation
note)."""
body = _fn(_js(), "restoreTimeline")
assert "runsEl.appendChild(block)" in body, (
"the thinking block appends into the container's body"
)
assert "runsEl.appendChild(toolDisc)" in body, (
"the tool disclosure appends into the container's body"
)
assert "insertBefore(block" not in body, (
"the per-run bubble anchor is GONE from restoreTimeline (thinking)"
)
assert "insertBefore(toolDisc" not in body, (
"the per-run bubble anchor is GONE from restoreTimeline (tool)"
)
# the per-run create + the closed state are unchanged.
assert 'summary.textContent = "Thinking"' in body, (
"the per-run thinking block structure is unchanged"
)
assert "block.open = true" not in body, "D5: created COLLAPSED (unchanged)"
assert "toolDisc.open = false" in body
assert "if (toolDisc) toolDisc = null" in body, "the tool-run seal is unchanged"
def test_restore_computes_the_step_count_incrementally() -> None:
"""D6: the at-rest step count is computed INCREMENTALLY inside
the loop — matching the LIVE ``segRuns.length`` semantics
(contiguous runs, NOT entry count): a ``thinking`` entry after a
tool group starts a NEW run (count+1); two ADJACENT thinking
entries are TWO runs (each thinking entry counts, the increment
is NOT gated on the previous kind); a tool group of N entries is
ONE run (count+1 only at the group's first entry, i.e. when
``toolDisc`` was null before it created it). After the loop,
``.reasoning-status`` is set to ``"${runs} steps"`` (the D6
literal is UNIFORM — no singular/plural special-case)."""
body = _fn(_js(), "restoreTimeline")
assert "runs = 0" in body, "the step counter starts at zero"
# the thinking branch increments per thinking entry.
think_i = body.find('entry.kind === "thinking"')
tool_i = body.find('entry.kind === "tool"')
assert -1 < think_i < tool_i
think_branch = body[think_i:tool_i]
assert "runs += 1" in think_branch, (
"a thinking entry (after a tool group) starts a NEW run"
)
# the increment is NOT gated on a previous-kind check — two
# adjacent thinking entries are TWO runs (the increment is
# unconditional within the thinking branch).
inc_i = think_branch.find("runs += 1")
inc_line = think_branch.splitlines()[think_branch.count("\n", 0, inc_i)]
assert not inc_line.lstrip().startswith("if ("), (
"two adjacent thinking entries are TWO runs (no kind gate)"
)
# the tool branch increments only at a NEW contiguous group
# (count+1 when toolDisc was null before it created it) — a
# group of N entries is ONE run.
assert "if (!toolDisc)" in body, "the contiguous tool-group guard"
tool_branch = body[tool_i:]
assert "runs += 1" in tool_branch, "a tool group's first entry is ONE run"
# exactly ONE tool increment — at the group's first entry
# (inside the !toolDisc guard); the group's other entries do
# not increment.
assert tool_branch.count("runs += 1") == 1, (
"exactly ONE tool increment — at the group's first entry "
"(a group of N entries is ONE run)"
)
# the uniform D6 literal (no singular/plural special-case).
assert 'textContent = `${runs} steps`' in body, (
"the D6 literal is UNIFORM — `${runs} steps`"
)
assert "steps`" in body
assert "runs} step`" not in body, "no singular special-case"
def test_restore_sets_the_status_after_the_loop() -> None:
"""D6: the ``.reasoning-status`` is filled with the at-rest
count AFTER the loop (the count is complete) — written to the
container's ``.reasoning-status`` span via the ``cont``
reference (the container the loop appended into)."""
body = _fn(_js(), "restoreTimeline")
loop_end = body.rfind("for (const entry of segments")
# the status write is after the loop's last append and before the
# function's close.
status_i = body.find('.reasoning-status")')
assert status_i != -1
# the status write uses the runs counter and the uniform literal.
status_line_region = body[status_i : status_i + 80]
assert "runs} steps" in status_line_region
# it is positioned after the loop (the count is complete).
assert loop_end < status_i, "the status is set AFTER the loop"
def test_the_restore_dispatch_keeps_the_legacy_path_untouched() -> None:
"""D4: the ``renderStoredMessage`` dispatch is UNCHANGED — a
record WITHOUT ``segments`` never reaches ``restoreTimeline``
(the legacy path restores byte-identically — no container); a
record WITH segments takes the ``restoreTimeline`` path (which
now wraps in the collapsed container). The dispatch comment
cites the container."""
js = _js()
fn = js.find("function renderStoredMessage")
end = js.find("function restoreConversation")
assert -1 < fn < end
body = js[fn:end]
assert "Array.isArray(m.segments) && m.segments.length" in body, (
"the dispatch guard is unchanged"
)
assert "restoreTimeline(wrap, m.segments)" in body, (
"the segments path renders restoreTimeline"
)
# the legacy path (the else branch) creates NO container.
else_i = body.find("} else {")
legacy = body[else_i:]
assert "createReasoningWrap" not in legacy, (
"the legacy path (no segments) creates NO container (D4)"
)
# the dispatch comment cites the container (the segments path
# wraps in the collapsed container).
assert "Phase 161" in body, "the dispatch comment cites the container"
# ---------- task 02: the shared-page parity wrapper (shared.js) ----------
def test_shared_create_reasoning_wrap_is_the_local_parity_builder() -> None:
"""D1/D6 (the shared page has NO import of app.js):
``createReasoningWrap`` in shared.js is a LOCAL parity builder
mirroring the app.js one — the same
``reasoning`` / ``reasoning-summary`` / ``reasoning-status`` /
``reasoning-body`` classes, the ``Reasoning`` literal via
textContent, the EMPTY status span, createElement only
(innerHTML ABSENT — the house rule; the literal is a static text
node, no data flows through it)."""
body = _fn(_js_shared(), "createReasoningWrap")
assert 'document.createElement("details")' in body
assert 'cont.className = "reasoning"' in body
assert 'document.createElement("summary")' in body
assert 'summary.className = "reasoning-summary"' in body
assert 'summary.textContent = "Reasoning"' in body, (
"the Reasoning literal (D6 — parity with app.js)"
)
assert 'document.createElement("span")' in body
assert 'status.className = "reasoning-status"' in body
assert "summary.appendChild(status)" in body
assert 'document.createElement("div")' in body
assert 'body.className = "reasoning-body"' in body
assert "cont.appendChild(body)" in body
assert "return cont" in body
assert "status.textContent" not in body, (
"the span starts empty — the at-rest count fills it"
)
assert "innerHTML" not in body, (
"createElement only — innerHTML ABSENT (the house rule)"
)
def test_shared_add_segments_creates_the_container_closed_once_before_the_bubble() -> None:
"""D5: ``addSegments`` creates the container via the LOCAL parity
builder, created CLOSED (``cont.open = false`` — the
pure-render convention; explicit for the pin), and inserted ONCE
before the bubble (the shared page's EXISTING insertion
convention — mirror the ``addSegments``/``addToolLines``
anchor). A get-or-create shape: exactly one builder call and one
container insertion per message."""
body = _fn(_js_shared(), "addSegments")
assert "createReasoningWrap()" in body, "the local parity builder is reused"
assert body.count("createReasoningWrap()") == 1, (
"the container is created ONCE per message (a get-or-create shape)"
)
assert "cont.open = false" in body, (
"D5: created CLOSED — the pure-render convention (explicit for the pin)"
)
assert "body.insertBefore(cont, bubble)" in body, (
"inserted before the bubble (the shared page's existing convention)"
)
assert body.count("body.insertBefore(cont, bubble)") == 1, "inserted ONCE"
assert 'cont.querySelector(".reasoning-body")' in body, (
"the .reasoning-body window is the per-run append target"
)
def test_shared_add_segments_per_run_blocks_append_into_the_reasoning_body() -> None:
"""D1 (the documented anchor supersession): the per-run blocks
append into the container's ``.reasoning-body`` — the per-run
``insertBefore(…, bubble)`` anchor is GONE from ``addSegments``
(the container sits before the bubble; the runs nest inside it,
arrival order preserved). The LINE BYTES mirror ``addToolLines``
— UNCHANGED (the four label literals, the phase-95 truncation
note, the phase-139/146 suffixes)."""
body = _fn(_js_shared(), "addSegments")
assert "runsEl.appendChild(block)" in body, (
"the thinking block appends into the container's body"
)
assert "runsEl.appendChild(toolDisc)" in body, (
"the tool disclosure appends into the container's body"
)
assert "insertBefore(block" not in body, (
"the per-run bubble anchor is GONE from addSegments (thinking)"
)
assert "insertBefore(toolDisc" not in body, (
"the per-run bubble anchor is GONE from addSegments (tool)"
)
# the line bytes mirror addToolLines — the pinned label literals.
for label in PINNED_LABELS_SHARED:
assert label in body, f"pinned label literal changed: {label!r}"
assert "truncated-note" in body, "the phase-95 note is unchanged"
assert "readRangeSuffix(entry.offset, entry.limit)" in body, (
"the phase-139 suffix is unchanged"
)
def test_shared_add_segments_computes_the_step_count_parity() -> None:
"""D6 (the SAME incremental step count as ``restoreTimeline`` —
a parity copy, not an import): a ``thinking`` entry after a tool
group starts a NEW run (count+1); two ADJACENT thinking entries
are TWO runs (the increment is NOT kind-gated); a tool group of
N entries is ONE run (count+1 only at the group's first entry).
After the loop, ``.reasoning-status`` is set to the UNIFORM
``"${runs} steps"`` literal."""
body = _fn(_js_shared(), "addSegments")
assert "runs = 0" in body, "the step counter starts at zero"
think_i = body.find('entry.kind === "thinking"')
tool_i = body.find('entry.kind === "tool"')
assert -1 < think_i < tool_i
think_branch = body[think_i:tool_i]
assert "runs += 1" in think_branch, (
"a thinking entry (after a tool group) starts a NEW run"
)
inc_i = think_branch.find("runs += 1")
inc_line = think_branch.splitlines()[think_branch.count("\n", 0, inc_i)]
assert not inc_line.lstrip().startswith("if ("), (
"two adjacent thinking entries are TWO runs (no kind gate)"
)
tool_branch = body[tool_i:]
assert tool_branch.count("runs += 1") == 1, (
"exactly ONE tool increment — at the group's first entry "
"(a group of N entries is ONE run)"
)
assert "if (!toolDisc)" in tool_branch, "the contiguous tool-group guard"
assert 'textContent = `${runs} steps`' in body, (
"the D6 literal is UNIFORM — parity with app.js"
)
def test_the_shared_dispatch_keeps_the_legacy_path_untouched() -> None:
"""D4: the ``renderSharedMessage`` dispatch is UNCHANGED — a
record WITHOUT ``segments`` renders the legacy
``addThinkingBlock`` + ``addToolLines`` path (byte-identical —
no container); a record WITH segments renders ``addSegments``
(which wraps in the collapsed container)."""
body = _fn(_js_shared(), "renderSharedMessage")
assert "Array.isArray(m.segments) && m.segments.length" in body, (
"the dispatch guard is unchanged"
)
assert "addSegments(wrap, m.segments)" in body, (
"the segments path renders addSegments"
)
else_i = body.find("} else {")
legacy = body[else_i:]
assert "createReasoningWrap" not in legacy, (
"the legacy path (no segments) creates NO container (D4)"
)
assert "addThinkingBlock(wrap, m.thinking)" in body
assert "addToolLines(wrap, m.tools)" in body
def test_inner_html_is_absent_from_both_wrappers() -> None:
"""The house rule: ``innerHTML`` is ABSENT from BOTH the
restore wrapper's and the shared wrapper's container builders
(createElement/textContent only — the ``Reasoning`` literal is a
static text node; the ONLY HTML write in each timeline function
remains the escape-first renderMarkdown into
``.thinking-text``)."""
app_builder = _fn(_js(), "createReasoningWrap")
assert "innerHTML" not in app_builder, (
"innerHTML ABSENT from the app.js builder (the house rule)"
)
shared_builder = _fn(_js_shared(), "createReasoningWrap")
assert "innerHTML" not in shared_builder, (
"innerHTML ABSENT from the shared.js builder (the house rule)"
)
# the ONLY HTML write in each timeline function is the
# escape-first renderMarkdown into .thinking-text.
for fn_name, js in (("restoreTimeline", _js()), ("addSegments", _js_shared())):
body = _fn(js, fn_name)
writes = re.findall(r"\b(\w+)\.innerHTML\s*=\s*([^;\n]+)", body)
assert len(writes) == 1, (
f"{fn_name}: exactly ONE innerHTML write (the structure is "
f"createElement): {writes}"
)
assert writes[0][0] == "textEl", "the single write is into .thinking-text"
assert writes[0][1].strip() == "renderMarkdown(entry.text)", (
"the single write is the escape-first renderMarkdown"
)
# ---------- task 03: the container's CSS (styles.css) ----------
def test_the_reasoning_container_rule_is_the_family_treatment() -> None:
"""Task 03 item 1: ``details.reasoning`` carries the
``details.thinking`` (phase-17) family treatment — the SAME
surface / line / brand-soft / radius / margin tokens +
``overflow: hidden`` (the corner radius clips the scrollable
body). The comment block cites this phase + D3, and the block
sits ADJACENT to the ``details.thinking`` family — after the
``.thinking-text`` margin rule, before the phase-117
``.tool-calls-disclosure`` comment."""
css = _css()
body = _rule(css, "details.reasoning")
assert "background: var(--surface)" in body
assert "border: 1px solid var(--line)" in body
assert "border-left: 3px solid var(--brand-soft)" in body
assert "border-radius: var(--radius-sm)" in body
assert "margin: 0 0 0.5rem" in body
assert "overflow: hidden" in body
comment = _comment_before(css, "details.reasoning {")
assert "Phase 161" in comment, "the comment cites this phase"
assert "D3" in comment, "the comment cites the D3 window decision"
# adjacency: after the family's last rule, before phase 117.
margins_i = css.find("details.thinking .thinking-text ul")
reason_i = css.find("details.reasoning {")
p117_i = css.find("/* Phase 117: the tool-call record")
assert -1 < margins_i < reason_i < p117_i, (
"the family blocks stay adjacent"
)
def test_the_reasoning_summary_rule_is_the_family_convention() -> None:
"""Task 03 item 2: ``summary.reasoning-summary`` — the
``details.thinking`` summary convention: the flex row, the
WCAG ≥44px target (``min-height: 44px``), the family
``--brand-ink`` (8.7:1 on ``--surface`` — AA), the webkit
marker suppressed, and the house 3px ``:focus-visible`` ring.
"""
css = _css()
body = _rule(css, "summary.reasoning-summary")
assert "display: flex" in body
assert "align-items: center" in body
assert "gap: 0.5rem" in body
assert "padding: 0.5rem 0.75rem" in body
assert "min-height: 44px" in body, "the WCAG ≥44px target"
assert "color: var(--brand-ink)" in body, ("the family AA token")
assert "font-size: 0.9rem" in body
assert "cursor: pointer" in body
assert "list-style: none" in body
# the webkit marker suppression.
assert (
"summary.reasoning-summary::-webkit-details-marker {"
" display: none; }" in css
)
# the house 3px :focus-visible ring.
focus = _rule(css, "summary.reasoning-summary:focus-visible")
assert "outline: 3px solid var(--brand)" in focus
assert "outline-offset: 2px" in focus
def test_the_reasoning_chevron_rotates_when_open() -> None:
"""Task 03 item 2: the CSS chevron ``▸`` (``::before``,
``display: inline-block``) with the 0.15s ease transform
transition, rotating 90° when the container is ``[open]`` —
the ``details.thinking`` convention."""
css = _css()
chevron = _rule(css, "summary.reasoning-summary::before")
assert 'content: "▸"' in chevron
assert "display: inline-block" in chevron
assert "transition: transform 0.15s ease" in chevron
open_rule = _rule(css, "details.reasoning[open] summary.reasoning-summary::before")
assert "transform: rotate(90deg)" in open_rule
def test_the_reasoning_status_rule_uses_the_aa_token() -> None:
"""Task 03 item 3: ``.reasoning-status`` (the D6 live suffix
span, inside the summary row) — ``--ink-soft`` (6.9:1 on
``--surface`` — AA), ``font-size: 0.8rem``,
``line-height: 1.4``, ``margin-left: 0.25rem`` (text + color,
never color alone — B5)."""
body = _rule(_css(), ".reasoning-status")
assert "color: var(--ink-soft)" in body, ("the AA token")
assert "font-size: 0.8rem" in body
assert "line-height: 1.4" in body
assert "margin-left: 0.25rem" in body
def test_the_reasoning_body_is_the_one_bounded_live_window() -> None:
"""Task 03 item 4: ``.reasoning-body`` — the ONE bounded live
window (D3): ``max-height: 320px`` (the phase-17 window
convention) + ``overflow-y: auto`` (user-scrollable — the
pinned-tail follow in app.js) + the family padding. The comment
cites D3 + the pinned-tail follow."""
css = _css()
body = _rule(css, ".reasoning-body")
assert "max-height: 320px" in body, ("the phase-17 320px window")
assert "overflow-y: auto" in body, ("user-scrollable (D3)")
assert "padding: 0 0.75rem 0.75rem" in body
comment = _comment_before(css, ".reasoning-body {")
assert "D3" in comment, ("the comment cites the D3 window")
assert "pinned-tail" in comment, (
"the comment cites the pinned-tail follow"
)
def test_the_inner_runs_are_compacted_to_one_scroll_surface() -> None:
"""Task 03 item 5: the inner-run compacting (D3 — ONE scroll
surface, the nested border weight removed):
``details.reasoning details.thinking`` is de-bordered (a 2px
``--line`` indent keeps the family rhythm, transparent
background, tight margins); the inner ``.thinking-text``
320px window is DISABLED (``max-height: none`` +
``overflow-y: visible``) so the container's window is the single
scroll surface; the nested ``.tool-calls-disclosure`` keeps its
compact margin (the documented-intent rule)."""
css = _css()
run = _rule(css, "details.reasoning details.thinking")
assert "border: 0" in run, ("the nested border weight is removed")
assert "border-left: 2px solid var(--line)" in run
assert "border-radius: 0" in run
assert "margin: 0.25rem 0" in run
assert "background: transparent" in run
inner_text = _rule(css, "details.reasoning details.thinking .thinking-text")
assert "max-height: none" in inner_text, (
"the inner 320px window is disabled"
)
assert "overflow-y: visible" in inner_text
disc = _rule(css, "details.reasoning .tool-calls-disclosure")
assert "margin: 0.25rem 0" in disc
def test_the_reasoning_still_is_in_the_house_reduced_motion_block() -> None:
"""Task 03 item 6: the chevron rotation stills under
``prefers-reduced-motion`` — the still is in the SAME media
block as the phase-17 / phase-117 stills (the house chat/stream
block, the one that stills ``details.thinking``)."""
css = _css()
i = css.find("details.thinking summary::before { transition: none; }")
assert i != -1, ("the phase-17 still must exist (the reference)")
start = css.rfind("@media (prefers-reduced-motion: reduce) {", 0, i)
end = css.find("\n}", start)
assert -1 < start < end, ("the stills must be in a media block")
block = css[start:end]
assert (
"summary.reasoning-summary::before { transition: none; }" in block
), ("the reasoning chevron stills with the family stills")
def test_the_legacy_no_container_rules_are_untouched() -> None:
"""The completion gate: the ``details.thinking`` /
``.tool-calls-disclosure`` rules OUTSIDE the container are
unchanged — the legacy no-container path renders byte-identically
(the family block, the 320px ``.thinking-text`` window,
the phase-117 disclosure + its chevron)."""
css = _css()
fam = _rule(css, "details.thinking")
assert "background: var(--surface)" in fam
assert "border-left: 3px solid var(--brand-soft)" in fam
assert "overflow: hidden" in fam
text = _rule(css, "details.thinking .thinking-text")
assert "max-height: 320px" in text, ("the legacy window is unchanged")
assert "overflow-y: auto" in text
# the phase-117 disclosure rules are unchanged (one base rule —
# line-anchored: the nested details.reasoning selector is a
# different rule).
assert len(re.findall(r"(?m)^\.tool-calls-disclosure \{", css)) == 1
disc = _rule(css, ".tool-calls-disclosure")
assert "margin: 0.25rem 0" in disc
chevron = _rule(css, ".tool-calls-summary::before")
assert 'content: "▸"' in chevron
assert "margin-right: 0.5rem" in chevron
+91 -24
View File
@@ -78,12 +78,15 @@ def _branch(js: str, open_type: str, close_type: str) -> str:
# ---------- the run builders (D1/D2/D3/D5) ----------
def test_ensure_thinking_run_builds_a_new_open_block_before_the_bubble() -> None:
"""D1/D2/D3: ``ensureThinkingRun`` returns the active thinking
def test_ensure_thinking_run_builds_a_new_open_block_in_the_container() -> None:
"""D1/D3/D5: ``ensureThinkingRun`` returns the active thinking
run's block or — a NEW ``details.thinking`` (the phase-17 static
structure via ``createThinkingBlock``) created OPEN (D5),
inserted BEFORE the ``.bubble`` (D2 — the anchor is the bubble,
in arrival order). The plain ``Thinking`` literal (D3 — the
appended into the phase-161 reasoning container's
``.reasoning-body`` (D1 — the container sits before the bubble,
a new run appends after the previous run's block, so arrival
order is preserved; the per-run bubble anchor is superseded). The
plain ``Thinking`` literal (D3 — the
phase-17 literal, not a numbered variant) rides the static
template; the fresh run joins segRuns + currentRun."""
js = _js()
@@ -96,8 +99,15 @@ def test_ensure_thinking_run_builds_a_new_open_block_before_the_bubble() -> None
)
assert "createThinkingBlock()" in body, "the shared builder is reused"
assert "block.open = true" in body, "D5: created OPEN while its frames stream"
assert 'insertBefore(block, body.querySelector(".bubble"))' in body, (
"D2: the anchor is the BUBBLE — above it, in arrival order"
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(block)" in body
), (
"phase 161 (D1): appended into the container's body — the "
"container sits above the bubble, arrival order preserved"
)
assert "insertBefore" not in body, (
"the per-run bubble anchor is GONE (phase 161 D1)"
)
assert 'kind: "thinking"' in body
assert "segRuns.push(run)" in body
@@ -129,22 +139,33 @@ def test_create_thinking_block_is_the_phase_17_static_template_without_data() ->
)
def test_ensure_tool_run_builds_a_new_open_disclosure_before_the_bubble() -> None:
"""D1/D2/D5: ``ensureToolRun`` returns the active tool run's
def test_ensure_tool_run_builds_a_new_open_disclosure_in_the_container() -> None:
"""D1/D5: ``ensureToolRun`` returns the active tool run's
disclosure or — a NEW ``details.tool-calls-disclosure`` (the
phase-117 structure via ``createToolDisclosure`` —
summary.tool-calls-summary + the .tool-calls list, role=list +
aria-label "Tool calls") created OPEN (D5), inserted BEFORE the
bubble (D2, in arrival order). One disclosure per CONTIGUOUS
tool-call run; the per-run "Tool call(s) (N)" count rides
appendToolLine's summary update."""
aria-label "Tool calls") created OPEN (D5), appended into the
phase-161 reasoning container's ``.reasoning-body`` (D1 — the
container sits before the bubble, arrival order preserved; the
per-run bubble anchor is superseded). One disclosure per
CONTIGUOUS tool-call run; the per-run "Tool call(s) (N)" count
rides appendToolLine's summary update."""
js = _js()
body = _fn(js, "ensureToolRun")
assert 'currentRun?.kind === "tool"' in body
assert "closeCurrentRun()" in body
assert "createToolDisclosure()" in body
assert "disc.open = true" in body, "D5: created OPEN while its frames stream"
assert 'insertBefore(disc, body.querySelector(".bubble"))' in body
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(disc)" in body
), (
"phase 161 (D1): appended into the container's body — the "
"container sits above the bubble, arrival order preserved"
)
assert "insertBefore" not in body, (
"the per-run bubble anchor is GONE (phase 161 D1)"
)
assert 'kind: "tool"' in body
assert "segRuns.push(run)" in body
disc = _fn(js, "createToolDisclosure")
@@ -566,8 +587,11 @@ def test_restore_timeline_builds_the_collapsed_per_run_structure() -> None:
created closed), and CONSECUTIVE ``kind:"tool"`` entries group
into ONE ``details.tool-calls-disclosure`` (the shared
phase-117 builder — ``createToolDisclosure``, innerHTML ABSENT —
created closed), each inserted BEFORE the bubble (D2, arrival
order). The per-run count rides ``appendToolLine``'s summary
created closed). Phase 161 (task 02, D5/D6): the per-run blocks
nest inside ONE details.reasoning container — the shared
task-01 builder, created CLOSED and inserted ONCE before the
bubble (the per-run bubble anchor is superseded; arrival order
preserved). The per-run count rides ``appendToolLine``'s summary
update; a kind change seals the tool run (one disclosure per
run, D1)."""
body = _fn(_js(), "restoreTimeline")
@@ -588,8 +612,29 @@ def test_restore_timeline_builds_the_collapsed_per_run_structure() -> None:
assert "if (toolDisc) toolDisc = null" in body, (
"a thinking entry seals the tool run — ONE disclosure per run (D1)"
)
assert "body.insertBefore(block, bubble)" in body, "D2: above the bubble"
assert "body.insertBefore(toolDisc, bubble)" in body, "D2: above the bubble"
# Phase 161 (task 02, D5/D6): the per-run timeline nests inside
# ONE details.reasoning container — the shared task-01 builder,
# created CLOSED and inserted ONCE before the bubble (the
# per-run bubble anchor is superseded; arrival order preserved).
assert "createReasoningWrap()" in body, "the shared task-01 builder is reused"
assert "cont.open = false" in body, (
"D5: the restore renders collapsed (explicit for the pin)"
)
assert "body.insertBefore(cont, bubble)" in body, (
"the container sits above the bubble — the ONE anchor"
)
assert "runsEl.appendChild(block)" in body, (
"D1: the per-run block nests into the container's body"
)
assert "runsEl.appendChild(toolDisc)" in body, (
"D1: the per-run disclosure nests into the container's body"
)
assert "insertBefore(block" not in body, (
"the per-run bubble anchor is GONE (phase 161 D5)"
)
assert "insertBefore(toolDisc" not in body, (
"the per-run bubble anchor is GONE (phase 161 D5)"
)
assert "for (const entry of segments" in body, "iterate the runs in order"
@@ -718,10 +763,12 @@ def test_add_segments_builds_the_collapsed_parity_timeline() -> None:
LINE BYTES as ``addToolLines`` (the four pinned label literals
+ the phase-70 legacy names + the phase-139 readRangeSuffix +
the phase-95 truncation note), the per-run
``Tool call(s) (N)`` count, and insertion BEFORE the bubble
(D2, arrival order) — all created CLOSED (no ``open = true``
anywhere; the addThinkingBlock / addToolLines collapsed
convention)."""
``Tool call(s) (N)`` count, and nesting inside the phase-161
details.reasoning container (the local parity builder, created
CLOSED and inserted ONCE before the bubble — the per-run bubble
anchor is superseded, arrival order preserved) — all created
CLOSED (no ``open = true`` anywhere; the addThinkingBlock /
addToolLines collapsed convention)."""
body = _fn(_js_shared(), "addSegments")
assert "document.createElement(\"details\")" in body, "the structure is createElement"
assert 'block.className = "thinking"' in body
@@ -746,9 +793,29 @@ def test_add_segments_builds_the_collapsed_parity_timeline() -> None:
assert '"truncated-note"' in body, "the phase-95 note parity"
# the per-run count template.
assert "Tool call${n === 1 ? \"\" : \"s\"} (${n})" in body
# D2: above the bubble, in arrival order.
assert "body.insertBefore(block, bubble)" in body
assert "body.insertBefore(toolDisc, bubble)" in body
# Phase 161 (task 02, D5): the per-run timeline nests inside ONE
# details.reasoning container — the local parity builder, created
# CLOSED and inserted ONCE before the bubble (the per-run bubble
# anchor is superseded; arrival order preserved).
assert "createReasoningWrap()" in body, "the local parity builder is reused"
assert "cont.open = false" in body, (
"D5: collapsed (the pure-render convention)"
)
assert "body.insertBefore(cont, bubble)" in body, (
"inserted ONCE before the bubble"
)
assert "runsEl.appendChild(block)" in body, (
"D1: the per-run block nests into the container's body"
)
assert "runsEl.appendChild(toolDisc)" in body, (
"D1: the per-run disclosure nests into the container's body"
)
assert "insertBefore(block" not in body, (
"the per-run bubble anchor is GONE (phase 161 D5)"
)
assert "insertBefore(toolDisc" not in body, (
"the per-run bubble anchor is GONE (phase 161 D5)"
)
# the consecutive-tool grouping (one disclosure per run, D1).
assert "if (toolDisc) toolDisc = null" in body, (
"a thinking entry seals the tool run — ONE disclosure per run"
+37 -18
View File
@@ -63,14 +63,17 @@ def _fn(src: str, name: str) -> str:
def test_tool_run_builds_an_open_details_disclosure() -> None:
"""Phase 158 (D1/D2/D5): ``ensureToolRun`` creates a native
``details.tool-calls-disclosure`` via ``createToolDisclosure`` —
a ``summary.tool-calls-summary`` (first child) + the existing
``.tool-calls`` list (role=list + aria-label "Tool calls",
second child) — inserted before the bubble in arrival order (D2).
It starts OPEN while the run is live; every append re-renders the
plain-text per-run "Tool call(s) (N)" count on the summary
through textContent (no innerHTML anywhere)."""
"""Phase 158 (D1/D5) + 161 (D1): ``ensureToolRun`` creates a
native ``details.tool-calls-disclosure`` via
``createToolDisclosure`` — a ``summary.tool-calls-summary``
(first child) + the existing ``.tool-calls`` list (role=list +
aria-label "Tool calls", second child) — appended into the
phase-161 reasoning container's ``.reasoning-body`` in arrival
order (D1 — the container sits above the bubble; the per-run
bubble anchor is superseded). It starts OPEN while the run is
live; every append re-renders the plain-text per-run "Tool
call(s) (N)" count on the summary through textContent (no
innerHTML anywhere)."""
js = _js()
disc = _fn(js, "createToolDisclosure")
assert "document.createElement(\"details\")" in disc
@@ -92,8 +95,15 @@ def test_tool_run_builds_an_open_details_disclosure() -> None:
"calls and the phase-87 .tool-elapsed suffix stay visible during "
"the turn"
)
assert 'insertBefore(disc, body.querySelector(".bubble"))' in run, (
"D2: above the answer, in arrival order"
assert 'insertBefore(disc, body.querySelector(".bubble"))' not in run, (
"the per-run bubble anchor is GONE (phase 161 D1)"
)
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(disc)" in run
), (
"phase 161 (D1): appended into the container's body — the "
"container sits above the answer, arrival order preserved"
)
# D5: the count rides the summary — the template fragment itself
# (singular for one call, plural otherwise), textContent-built.
@@ -169,19 +179,28 @@ def test_restore_path_folds_the_disclosure() -> None:
), "the fold sits AFTER the tool-line restore loop"
def test_run_anchors_are_the_bubble_in_arrival_order() -> None:
"""Phase 158 (D2): the anchor is the BUBBLE for both run kinds —
the old ``.tool-calls-disclosure ?? .tool-calls ?? .bubble``
anchor chain is GONE (with per-run disclosures, "above the
tool lines" was the wrong order — arrival order is the
contract), and the runs still open while active (D5)."""
def test_run_anchors_are_the_container_in_arrival_order() -> None:
"""Phase 158 (D2) + 161 (D1): the anchor moved to the REASONING
CONTAINER for both run kinds (the phase-161 documented
supersession — the container sits before the bubble; the runs
append into its ``.reasoning-body``) — the old
``.tool-calls-disclosure ?? .tool-calls ?? .bubble`` anchor chain
is GONE (with per-run disclosures, "above the tool lines" was the
wrong order — arrival order is the contract), and the runs still
open while active (D5)."""
js = _js()
run = _fn(js, "ensureThinkingRun")
assert 'querySelector(".bubble")' in run
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(block)" in run
), "phase 161 (D1): the thinking run anchors on the container"
assert "block.open = true" in run
assert "??" not in run, "the old anchor chain is GONE"
d = _fn(js, "ensureToolRun")
assert 'querySelector(".bubble")' in d
assert (
'ensureReasoningWrap(wrap).querySelector(".reasoning-body")'
".appendChild(disc)" in d
), "phase 161 (D1): the tool run anchors on the container"
assert "disc.open = true" in d