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