diff --git a/.agents/phases/todo/161_reasoning_bubble/00_phase.md b/.agents/phases/complete/161_reasoning_bubble/00_phase.md similarity index 100% rename from .agents/phases/todo/161_reasoning_bubble/00_phase.md rename to .agents/phases/complete/161_reasoning_bubble/00_phase.md diff --git a/.agents/phases/todo/161_reasoning_bubble/01_live_container.md b/.agents/phases/complete/161_reasoning_bubble/01_live_container.md similarity index 100% rename from .agents/phases/todo/161_reasoning_bubble/01_live_container.md rename to .agents/phases/complete/161_reasoning_bubble/01_live_container.md diff --git a/.agents/phases/todo/161_reasoning_bubble/02_restore_and_shared.md b/.agents/phases/complete/161_reasoning_bubble/02_restore_and_shared.md similarity index 100% rename from .agents/phases/todo/161_reasoning_bubble/02_restore_and_shared.md rename to .agents/phases/complete/161_reasoning_bubble/02_restore_and_shared.md diff --git a/.agents/phases/todo/161_reasoning_bubble/03_container_css.md b/.agents/phases/complete/161_reasoning_bubble/03_container_css.md similarity index 100% rename from .agents/phases/todo/161_reasoning_bubble/03_container_css.md rename to .agents/phases/complete/161_reasoning_bubble/03_container_css.md diff --git a/.agents/phases/todo/161_reasoning_bubble/04_mock_and_e2e.md b/.agents/phases/complete/161_reasoning_bubble/04_mock_and_e2e.md similarity index 100% rename from .agents/phases/todo/161_reasoning_bubble/04_mock_and_e2e.md rename to .agents/phases/complete/161_reasoning_bubble/04_mock_and_e2e.md diff --git a/.agents/phases/todo/161_reasoning_bubble/05_regression_update_and_commit.md b/.agents/phases/complete/161_reasoning_bubble/05_regression_update_and_commit.md similarity index 100% rename from .agents/phases/todo/161_reasoning_bubble/05_regression_update_and_commit.md rename to .agents/phases/complete/161_reasoning_bubble/05_regression_update_and_commit.md diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__00_phase.a1.err b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__00_phase.a1.md b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__00_phase.a1.md new file mode 100644 index 0000000..9baed14 --- /dev/null +++ b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__00_phase.a1.md @@ -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/` diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__00_phase.a1.validate b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__00_phase.a1.validate new file mode 100644 index 0000000..f75e087 --- /dev/null +++ b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__00_phase.a1.validate @@ -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 diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__01_live_container.a1.err b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__01_live_container.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__01_live_container.a1.md b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__01_live_container.a1.md new file mode 100644 index 0000000..8c8d645 --- /dev/null +++ b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__01_live_container.a1.md @@ -0,0 +1 @@ +My diag was missing fixture env vars. Simpler: a temp diagnostic test using the real conftest fixtures: diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__01_live_container.a1.validate b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__01_live_container.a1.validate new file mode 100644 index 0000000..5b41f1e --- /dev/null +++ b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__01_live_container.a1.validate @@ -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) diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__01_live_container.a2.err b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__01_live_container.a2.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__01_live_container.a2.md b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__01_live_container.a2.md new file mode 100644 index 0000000..4611368 --- /dev/null +++ b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__01_live_container.a2.md @@ -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` diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__01_live_container.a2.validate b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__01_live_container.a2.validate new file mode 100644 index 0000000..8c705db --- /dev/null +++ b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__01_live_container.a2.validate @@ -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 diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__02_restore_and_shared.a1.err b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__02_restore_and_shared.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__02_restore_and_shared.a1.md b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__02_restore_and_shared.a1.md new file mode 100644 index 0000000..77083f1 --- /dev/null +++ b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__02_restore_and_shared.a1.md @@ -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` diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__02_restore_and_shared.a1.validate b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__02_restore_and_shared.a1.validate new file mode 100644 index 0000000..0c93b76 --- /dev/null +++ b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__02_restore_and_shared.a1.validate @@ -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 diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__03_container_css.a1.err b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__03_container_css.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__03_container_css.a1.md b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__03_container_css.a1.md new file mode 100644 index 0000000..c66fafc --- /dev/null +++ b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__03_container_css.a1.md @@ -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` diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__03_container_css.a1.validate b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__03_container_css.a1.validate new file mode 100644 index 0000000..f75e087 --- /dev/null +++ b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__03_container_css.a1.validate @@ -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 diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a1.err b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a1.md b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a1.md new file mode 100644 index 0000000..9e9de95 --- /dev/null +++ b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a1.md @@ -0,0 +1 @@ +Now I'll write the dedicated story E2E suite: diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a1.validate b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a1.validate new file mode 100644 index 0000000..10b0ab3 --- /dev/null +++ b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a1.validate @@ -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 ```` 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) diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a2.err b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a2.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a2.md b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a2.md new file mode 100644 index 0000000..9e9de95 --- /dev/null +++ b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a2.md @@ -0,0 +1 @@ +Now I'll write the dedicated story E2E suite: diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a2.validate b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a2.validate new file mode 100644 index 0000000..10b0ab3 --- /dev/null +++ b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a2.validate @@ -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 ```` 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) diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a3.err b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a3.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a3.md b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a3.md new file mode 100644 index 0000000..21170e0 --- /dev/null +++ b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a3.md @@ -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/` 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). diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a3.validate b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a3.validate new file mode 100644 index 0000000..f75e087 --- /dev/null +++ b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__04_mock_and_e2e.a3.validate @@ -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 diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__05_regression_update_and_commit.a1.err b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__05_regression_update_and_commit.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__05_regression_update_and_commit.a1.md b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__05_regression_update_and_commit.a1.md new file mode 100644 index 0000000..515baa3 --- /dev/null +++ b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__05_regression_update_and_commit.a1.md @@ -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 diff --git a/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__05_regression_update_and_commit.a1.validate b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__05_regression_update_and_commit.a1.validate new file mode 100644 index 0000000..f75e087 --- /dev/null +++ b/.agents/reports/161_reasoning_bubble/161_reasoning_bubble__05_regression_update_and_commit.a1.validate @@ -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 diff --git a/README.md b/README.md index 9a17997..dfcab8c 100644 --- a/README.md +++ b/README.md @@ -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 …") 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`. diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 7fa3f64..1d62dc5 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -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 …" / "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 …" / "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
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 …" / "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 …"; 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 …" — 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); diff --git a/frontend/assets/shared.js b/frontend/assets/shared.js index 46fde2e..5377a61 100644 --- a/frontend/assets/shared.js +++ b/frontend/assets/shared.js @@ -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 diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index d9b9158..59cb41d 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -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 …" / + "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
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 diff --git a/tests/e2e/mock_llm.py b/tests/e2e/mock_llm.py index ce50d8e..d76acad 100644 --- a/tests/e2e/mock_llm.py +++ b/tests/e2e/mock_llm.py @@ -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 ```` 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 ```` 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 +#: ```` 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", , )`` — + ``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", , )`` — 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 + ```` 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 "" 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 ```` 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 diff --git a/tests/e2e/test_reasoning_bubble.py b/tests/e2e/test_reasoning_bubble.py new file mode 100644 index 0000000..f8961b1 --- /dev/null +++ b/tests/e2e/test_reasoning_bubble.py @@ -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 ```` 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 …`` 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 → ````). + +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/`` 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 .``). +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 ```` + ```` + + ```` 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) diff --git a/tests/e2e/test_thinking_display.py b/tests/e2e/test_thinking_display.py index 2363cc4..c970b71 100644 --- a/tests/e2e/test_thinking_display.py +++ b/tests/e2e/test_thinking_display.py @@ -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
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" diff --git a/tests/e2e/test_thinking_timeline.py b/tests/e2e/test_thinking_timeline.py index da9ba53..53dbd6f 100644 --- a/tests/e2e/test_thinking_timeline.py +++ b/tests/e2e/test_thinking_timeline.py @@ -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 → ````). 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 `` 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 `` 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/`` 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 `` 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 `` 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
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
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
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
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 is a real focusable toggle). + # ONE — the native 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) diff --git a/tests/e2e/test_tool_call_compact.py b/tests/e2e/test_tool_call_compact.py index 528eb27..6f09472 100644 --- a/tests/e2e/test_tool_call_compact.py +++ b/tests/e2e/test_tool_call_compact.py @@ -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 ```` 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. diff --git a/tests/unit/test_frontend_edit_message.py b/tests/unit/test_frontend_edit_message.py index 6beac46..8ae1f3c 100644 --- a/tests/unit/test_frontend_edit_message.py +++ b/tests/unit/test_frontend_edit_message.py @@ -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 …"; 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 …" — 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 diff --git a/tests/unit/test_frontend_feedback.py b/tests/unit/test_frontend_feedback.py index a173807..e477cab 100644 --- a/tests/unit/test_frontend_feedback.py +++ b/tests/unit/test_frontend_feedback.py @@ -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)] diff --git a/tests/unit/test_frontend_tool_states.py b/tests/unit/test_frontend_tool_states.py index 0b1379c..7a565f8 100644 --- a/tests/unit/test_frontend_tool_states.py +++ b/tests/unit/test_frontend_tool_states.py @@ -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 path. 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 diff --git a/tests/unit/test_reasoning_bubble.py b/tests/unit/test_reasoning_bubble.py new file mode 100644 index 0000000..cfb553f --- /dev/null +++ b/tests/unit/test_reasoning_bubble.py @@ -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 …" / "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 `` { … }`` 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 …" (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 …' — 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 diff --git a/tests/unit/test_thinking_timeline.py b/tests/unit/test_thinking_timeline.py index 02b648e..e520852 100644 --- a/tests/unit/test_thinking_timeline.py +++ b/tests/unit/test_thinking_timeline.py @@ -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" diff --git a/tests/unit/test_tool_call_compact.py b/tests/unit/test_tool_call_compact.py index 270c1be..2a54525 100644 --- a/tests/unit/test_tool_call_compact.py +++ b/tests/unit/test_tool_call_compact.py @@ -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