phase: 104_chip_sizing_question_cap
Build and Push Containers / build-and-push-app (push) Successful in 1m48s
Build and Push Containers / build-and-push-db (push) Successful in 12s

All completion criteria verified. Final report:

**Phase 104 — final verification pass: all green**
- Verified (no code changes needed): single-line ellipsized `.suggestion-chip` CSS + deleted `.maybe-try` override, `renderChips` full-text `title` + `aria-label`-when-clipped, `maxlength="4000"` + `#char-count` counter + `handleSend` over-cap guard, unit pins file, schemas boundary pins, dedicated E2E suite.
- E2E (isolation): `uv run pytest tests/e2e/test_chip_sizing_question_cap.py -v --no-cov` → **6 passed**; regressions: `test_suggestion_chips.py` 8 passed, `test_pinned_composer.py` 4 passed, `test_responsive_polish.py` 7 passed, `test_chat_history.py` 5 passed.
- `uv run pytest` → **2102 passed**; `--cov=app` → **99%** (>90%); `uv run ruff check . && uv run pyright` → clean, 0 errors.
- Criteria: chip E2E (single-line, clipped, title+aria-label full text) ✅; paste caps at exactly 4,000, send streams, counter hides ✅; programmatic 5,000-char fill → banner, no turn, text kept ✅; 4,000/4,001 boundary pinned + HTML maxlength == JS constant cross-file pin ✅.
- Diff scope: `frontend/`, new unit file, `tests/unit/test_schemas.py`, new E2E file, phase files — **no `app/` diff, no migration, no `shared.js` diff**.
- Deviations: 4 regression test files touched — 2 genuine DOM-pin conflicts from the new `#char-count` child (explicitly anticipated by the overview) + 3 documented **pre-existing E2E flake fixes** (smooth-scroll race, tab-walk heuristic, 10 ms timeout), each verified pre-existing on the pre-phase-104 tree.
- No commit made (harness commits per the execution protocol override).
- Next pending phase: `98_sync_summary_visibility`.
This commit is contained in:
2026-09-12 19:45:00 -04:00
parent 1f1c01c9f7
commit ecc921098a
31 changed files with 1874 additions and 36 deletions
@@ -0,0 +1,11 @@
All completion criteria verified. Final report:
**Phase 104 — final verification pass: all green**
- Verified (no code changes needed): single-line ellipsized `.suggestion-chip` CSS + deleted `.maybe-try` override, `renderChips` full-text `title` + `aria-label`-when-clipped, `maxlength="4000"` + `#char-count` counter + `handleSend` over-cap guard, unit pins file, schemas boundary pins, dedicated E2E suite.
- E2E (isolation): `uv run pytest tests/e2e/test_chip_sizing_question_cap.py -v --no-cov` → **6 passed**; regressions: `test_suggestion_chips.py` 8 passed, `test_pinned_composer.py` 4 passed, `test_responsive_polish.py` 7 passed, `test_chat_history.py` 5 passed.
- `uv run pytest` → **2102 passed**; `--cov=app` → **99%** (>90%); `uv run ruff check . && uv run pyright` → clean, 0 errors.
- Criteria: chip E2E (single-line, clipped, title+aria-label full text) ✅; paste caps at exactly 4,000, send streams, counter hides ✅; programmatic 5,000-char fill → banner, no turn, text kept ✅; 4,000/4,001 boundary pinned + HTML maxlength == JS constant cross-file pin ✅.
- Diff scope: `frontend/`, new unit file, `tests/unit/test_schemas.py`, new E2E file, phase files — **no `app/` diff, no migration, no `shared.js` diff**.
- Deviations: 4 regression test files touched — 2 genuine DOM-pin conflicts from the new `#char-count` child (explicitly anticipated by the overview) + 3 documented **pre-existing E2E flake fixes** (smooth-scroll race, tab-walk heuristic, 10 ms timeout), each verified pre-existing on the pre-phase-104 tree.
- No commit made (harness commits per the execution protocol override).
- Next pending phase: `98_sync_summary_visibility`.
@@ -0,0 +1,97 @@
........................................................................ [ 3%]
........................................................................ [ 6%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 47%]
........................................................................ [ 51%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 65%]
........................................................................ [ 68%]
........................................................................ [ 71%]
........................................................................ [ 75%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 85%]
........................................................................ [ 89%]
........................................................................ [ 92%]
........................................................................ [ 95%]
........................................................................ [ 99%]
.............. [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 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 134 1 99%
app/api/git_sources.py 229 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 115 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 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 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 197 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
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 24 0 100%
app/schemas.py 307 0 100%
--------------------------------------------------
TOTAL 3866 15 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,18 @@
Task 01 complete — all gates green.
**Implemented**
- `frontend/assets/styles.css`: base `.suggestion-chip` rule gains the single-line contract (`white-space: nowrap`, `overflow: hidden`, `text-overflow: ellipsis`, `max-width: 100%`, `min-width: 0`) with the phase-104 provenance comment (incl. the folded-in phase-07 origin).
- Deleted the fully subsumed `.maybe-try .suggestion-chip` override (its phase-07 provenance lives in the base rule's comment, phrased without the literal selector so the `count == 0` pin holds).
- Created `tests/unit/test_chip_sizing_question_cap.py` — the phase's unit-pins file with the three task-01 CSS pins (ellipsis quartet + pill contract kept, override gone / `.maybe-try` group stays, ≤640px `flex: 0 0 auto` track untouched).
- Mobile rule and `.shared-shell` static-chip rule untouched (A6); no JS/HTML/E2E changes (later tasks).
**Test / lint / coverage**
- `uv run pytest tests/unit/ -q` → 1564 passed
- `uv run pytest` (unit+integration, DB up) → 2089 passed
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL 99% (>90% gate)
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
- `git diff --stat` → `frontend/assets/styles.css` only (+ new unit file; no `app/` diff)
**Decisions:** none deviating — followed the phase design verbatim.
**Next pending task:** `.agents/phases/todo/104_chip_sizing_question_cap/02_chip_tooltip_aria.md`
@@ -0,0 +1,97 @@
........................................................................ [ 3%]
........................................................................ [ 6%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 27%]
........................................................................ [ 31%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 68%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 79%]
........................................................................ [ 82%]
........................................................................ [ 86%]
........................................................................ [ 89%]
........................................................................ [ 93%]
........................................................................ [ 96%]
........................................................................ [ 99%]
. [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 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 134 1 99%
app/api/git_sources.py 229 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 115 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 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 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 197 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
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 24 0 100%
app/schemas.py 307 0 100%
--------------------------------------------------
TOTAL 3866 15 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,15 @@
Task 02 complete. All checks green.
**Implemented**
- `frontend/assets/app.js` `renderChips`: `btn.title = text` on every chip (full-text hover tooltip, owner A2, source-chip precedent) + `aria-label` = full text only when `btn.scrollWidth > btn.clientWidth` (clipped case), with the phase-104 provenance comment; nothing else in `renderChips` touched
- `tests/unit/test_chip_sizing_question_cap.py`: added `APP_JS`/`_app_js()`/`_render_chips_body()` (brace-matched extraction, handles the `{ onSelect }` destructuring) + two task-02 pins (`btn.title = text`; the guard-anchored `setAttribute("aria-label", text)`) + module docstring updated
**Results**
- `uv run pytest tests/unit/` → 1566 passed
- `uv run pytest` (unit + integration) → 2091 passed
- `uv run pytest --cov=app --cov-report=term` → 2091 passed, `app/` coverage 99% (>90% gate)
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
**Decisions:** `shared.js` untouched (its "no renderChips" pin stays green — it guards a different file).
**Next pending task:** `.agents/phases/todo/104_chip_sizing_question_cap/03_composer_question_cap.md`
@@ -0,0 +1,97 @@
........................................................................ [ 3%]
........................................................................ [ 6%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 65%]
........................................................................ [ 68%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 79%]
........................................................................ [ 82%]
........................................................................ [ 86%]
........................................................................ [ 89%]
........................................................................ [ 92%]
........................................................................ [ 96%]
........................................................................ [ 99%]
... [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 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 134 1 99%
app/api/git_sources.py 229 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 115 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 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 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 197 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
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 24 0 100%
app/schemas.py 307 0 100%
--------------------------------------------------
TOTAL 3866 15 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,14 @@
All gates green. Final report:
**Task 03 complete — visible 4,000-char question cap (maxlength + counter + guard + server-boundary pin)**
- `#message-input` gains `maxlength="4000"` with provenance comment; `#char-count` counter added inside `.chat-bottom` between the actions row and composer, hidden by default, with the phase-104 decision comment (not a live region; B3 text+color)
- `app.js`: `MAX_QUESTION_CHARS=4000` / `CHAR_COUNT_SHOW_AT=3200` (80%), `updateCharCount()` (RAW length, `.is-max` + "— character limit" copy at/over cap, honest over-cap reading), called at all four `input.value` mutation sites; `handleSend` over-cap guard (banner, no turn, text kept) before the clear
- `styles.css`: `.char-count` / `.char-count.is-max` rules — AA-verified on `--bg` (8.6:1, 10.4:1) with ratios recorded in the comment
- Unit pins: `test_chip_sizing_question_cap.py` extended (HTML/JS/CSS pins + single-source cross-file pin HTML==JS==schema); `test_schemas.py` 4,000/4,001 `ChatRequest.message` boundary; two conflicting unit pins (`test_pinned_composer.py`, `test_save_chat_ui.py`) updated to carry the new counter child (genuine conflicts anticipated by the phase)
- **Defect fixes in prior work:** `e2e/test_pinned_composer.py::test_stop_is_reachable_from_scrolled_up` was racy (proven on the pristine tree — the test scrolled up mid smooth-`scrollReveal`; reads caught y=23–119) → deterministic arrival-wait on the captured scroll target; `e2e/test_suggestion_chips.py` `_api_suggestions` `timeout=10` (10 ms) sat below the ~12 ms steady-state localhost round trip → 10 s house pattern
- Results: `uv run pytest tests/unit/ -q` → 1577 passed; `uv run pytest --cov=app` → 2102 passed, **99%** coverage (>90%); `uv run ruff check .` → clean; `uv run pyright` → 0 errors
- Regression E2Es (isolated): `test_pinned_composer.py` 4 passed (×6 consecutive), `test_suggestion_chips.py` 8 passed (×2), `test_responsive_polish.py` 7 passed, `test_chat_history.py` 5 passed; no `app/` diff
- Next pending task: `04_e2e_suite_commit.md` (dedicated E2E suite + full gate + atomic commit)
@@ -0,0 +1,97 @@
........................................................................ [ 3%]
........................................................................ [ 6%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 47%]
........................................................................ [ 51%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 65%]
........................................................................ [ 68%]
........................................................................ [ 71%]
........................................................................ [ 75%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 85%]
........................................................................ [ 89%]
........................................................................ [ 92%]
........................................................................ [ 95%]
........................................................................ [ 99%]
.............. [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 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 134 1 99%
app/api/git_sources.py 229 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 115 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 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 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 197 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
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 24 0 100%
app/schemas.py 307 0 100%
--------------------------------------------------
TOTAL 3866 15 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,11 @@
All gates green. Final report:
**Phase 104 · Task 04 — E2E suite + regression gates**
- New dedicated suite `tests/e2e/test_chip_sizing_question_cap.py` (6 tests: single-line ellipsized long chip with `title`/`aria-label` full text, seed-chip tooltip-without-aria contrast, counter threshold 100/3500, paste-path hard cap at 4,000 + clean submit at cap, over-cap programmatic-fill guard (banner, no turn, text kept), short-flow never-visible counter with MutationObserver tripwire) — drafted in the interrupted prior attempt, verified green
- Fixed a **pre-existing flake** in `test_responsive_polish.py::test_a11y_landmarks_and_labels` (verified identical on the pristine pre-phase-104 tree): `_tab_outline_walk` keyed elements by 24-char text prefix, so two identical opener chips left in shared-DB `saved_chats` by `test_pinned_composer.py` cut the walk short (2 < 3); now uses true element identity via a stamped `data-tabwalk-id`. No behavior change to the asserted contract
- E2E in isolation: `uv run pytest tests/e2e/test_chip_sizing_question_cap.py -v --no-cov` → 6 passed; regressions each green in isolation: `test_suggestion_chips` 8, `test_pinned_composer` 4, `test_responsive_polish` 7 (incl. adversarial post-pinned_composer state, 3 runs), `test_chat_history` 5
- Full gate: `uv run pytest` → 2102 passed; `uv run pytest --cov=app --cov-report=term-missing` → 2102 passed, `app/` **99%** (>90%); `uv run ruff check . && uv run pyright` → clean, 0 errors
- `git diff --stat` scoped: `frontend/` (app.js/styles.css/index.html), new unit+E2E files, `test_schemas.py`, pin updates (`test_pinned_composer` unit, `test_save_chat_ui`), prior-task e2e flake fixes (`test_pinned_composer`, `test_suggestion_chips`) + this fix; NO `app/` code diff, no migration, no `shared.js`, no `pyproject.toml`/`uv.lock`
- Phase dir move + atomic `--no-gpg-sign` commit: left to the harness per executor rules (overrides task step 5)
- Next pending task: none in this phase (04 is the last); next phase = whatever follows `104` in `.agents/phases/todo/`
@@ -0,0 +1,97 @@
........................................................................ [ 3%]
........................................................................ [ 6%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 30%]
........................................................................ [ 34%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 44%]
........................................................................ [ 47%]
........................................................................ [ 51%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 65%]
........................................................................ [ 68%]
........................................................................ [ 71%]
........................................................................ [ 75%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 85%]
........................................................................ [ 89%]
........................................................................ [ 92%]
........................................................................ [ 95%]
........................................................................ [ 99%]
.............. [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 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 134 1 99%
app/api/git_sources.py 229 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 115 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 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 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 197 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
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 24 0 100%
app/schemas.py 307 0 100%
--------------------------------------------------
TOTAL 3866 15 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
+54 -1
View File
@@ -299,6 +299,7 @@ bindSharedHeaderControls();
const messagesEl = document.querySelector("#messages");
const emptyState = document.querySelector("#empty-state");
const suggestionsEl = document.querySelector("#suggestions");
const charCountEl = document.querySelector("#char-count"); // phase 104: the question-length counter (ships hidden)
const composer = document.querySelector("#composer");
const input = document.querySelector("#message-input");
const sendBtn = document.querySelector("#send-btn");
@@ -1012,6 +1013,7 @@ function appendTruncatedNote(wrap, argument, charsShown, charsTotal) {
function submitSuggestion(text) {
input.value = text;
autoGrow();
updateCharCount(); // phase 104: the chip fill bypasses maxlength — count what landed
input.focus();
composer.requestSubmit();
}
@@ -1028,11 +1030,16 @@ function renderChips(container, items, { onSelect } = {}) {
btn.className = "suggestion-chip";
btn.setAttribute("role", "listitem");
btn.textContent = text;
// phase 104 (owner 2026-09-12): the single-line chip clips long
// questions — `title` is the hover reveal, `aria-label` the
// clipped-case accessible name (the source-chip pattern).
btn.title = text;
btn.addEventListener("click", () => {
submitSuggestion(text);
if (onSelect) onSelect(text, btn);
});
container.appendChild(btn);
if (btn.scrollWidth > btn.clientWidth) btn.setAttribute("aria-label", text);
}
return container;
}
@@ -1255,11 +1262,42 @@ export function setUiState(state, errorDetail = "") {
if (state === UI_STATE.error) showErrorBanner(errorDetail);
}
/* Phase 104 (owner 2026-09-12, A3/A4): the visible question-length cap.
MAX_QUESTION_CHARS mirrors ChatRequest.message max_length=4000
(app/schemas.py — the source of truth; the server 422s beyond it) and
must stay equal to the #message-input maxlength (cross-file pin in
tests/unit/test_chip_sizing_question_cap.py). The counter shows only
from 80% of the cap — no noise on normal use (owner A4). */
const MAX_QUESTION_CHARS = 4000;
const CHAR_COUNT_SHOW_AT = 3200; // 80% of the cap — visible only when it matters
function autoGrow() {
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, 192)}px`;
}
/* Phase 104 (owner 2026-09-12, A4): the counter state — hidden below the
80% threshold, plain `len/4000` above it, and `len/4000 — character
limit` + the .is-max (–err-*) treatment at/over the cap. The over-cap
reading is the HONEST length (the programmatic chip-fill path can
exceed maxlength — e.g. `5123/4000 — character limit`). */
function updateCharCount() {
// RAW length (no trim): raw ≤ cap ⟹ trimmed ≤ cap, so the raw
// count is a safe superset of what the server validates.
const len = input.value.length;
if (len < CHAR_COUNT_SHOW_AT) {
charCountEl.hidden = true;
charCountEl.classList.remove("is-max");
return;
}
charCountEl.hidden = false;
const atMax = len >= MAX_QUESTION_CHARS;
charCountEl.classList.toggle("is-max", atMax);
charCountEl.textContent = atMax
? `${len}/${MAX_QUESTION_CHARS} — character limit`
: `${len}/${MAX_QUESTION_CHARS}`;
}
/* ---------- chat turn (SSE streaming, PLAN §4) ---------- */
/* Cancel a response body without leaking an unhandled rejection:
@@ -2014,6 +2052,7 @@ function startNewChat() {
setUiState(UI_STATE.idle);
input.value = "";
autoGrow();
updateCharCount(); // phase 104: the cleared composer hides the counter again
input.focus();
sendStatus.textContent = "New chat started — previous conversation cleared.";
}
@@ -2111,12 +2150,21 @@ async function handleSend(e) {
}
const text = input.value.trim();
if (!text || sendBtn.disabled) return;
// Phase 104 (owner 2026-09-12, A5): maxlength caps typing + pastes, but
// a programmatic fill (the chip one-tap path) bypasses it — this guard
// is the never-stale backstop (PLAN §7.4): no turn, no clear, the user
// trims the kept text (out-of-turn banner, the saveAsDoc precedent).
if (text.length > MAX_QUESTION_CHARS) {
showErrorBanner("Questions are limited to 4,000 characters — trim the question and try again.");
return;
}
// Phase 49: the user append + persistence save point 1 moved into
// runTurn with the rest of the turn — the `reask` flag skips them on
// the redo-in-place retry path (the question is already in the DOM +
// conversation); handleSend keeps only the form-level pre-work.
input.value = "";
autoGrow();
updateCharCount(); // phase 104: the sent question clears the counter with the input
clearErrorBanner();
await runTurn(text, { reask: false });
}
@@ -2481,7 +2529,12 @@ async function runTurn(text, { reask = false } = {}) {
}
}
input.addEventListener("input", autoGrow);
// Phase 104: every input-path change (keystroke, paste — maxlength
// caps both at 4,000) re-runs the counter alongside the auto-grow.
input.addEventListener("input", () => {
autoGrow();
updateCharCount();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
+31 -8
View File
@@ -703,14 +703,6 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
gap: 0.45rem;
padding-inline: 0.25rem;
}
/* As flex items these chips must be allowed to shrink (min-width:auto
would let a long title-derived chip exceed the column on phones —
phase 07 overflow fix); the label text then wraps inside the pill. */
.maybe-try .suggestion-chip {
min-width: 0;
max-width: 100%;
}
/* ---------- Steering notes (phase 15) ---------- */
/* "Tune" button in the meta row of every completed brain bubble: ghost
pill, ≥44px, right-aligned after the source chips. ink-soft on surface
@@ -1230,6 +1222,21 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
margin: 0;
padding: 0;
}
/* The shared chip pill — both chip rows (the onboarding row in the
empty state and the "Maybe try" row under a deflected bubble) reuse
it. Phase 104 (owner 2026-09-12, A1): a chip is ONE line at every
viewport width — `white-space: nowrap` stops a 400-char question
from wrapping the pill into a multi-line "chonk"; `overflow: hidden`
zeroes the flex item's automatic minimum size (min-width:auto would
pin the item to the full text width), so `max-width: 100%` binds and
`text-overflow: ellipsis` clips the text at the row edge — in the
desktop wrap row 100% is the chat column, in the ≤640px row
(nowrap + overflow-x: auto) 100% is the VISIBLE width and the row
scrolls. That clipping is the phase-07 overflow fix the old
"Maybe try" chip override used to carry (min-width:0 + max-width:
100%) — now fully subsumed here and deleted, provenance folded into
this comment so the history lives with the contract. The full text
stays one hover away (the title tooltip, phase 104 task 02). */
.suggestion-chip {
font: inherit;
font-size: 0.92rem;
@@ -1242,6 +1249,11 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
min-height: 44px;
cursor: pointer;
transition: background 0.15s ease, transform 0.05s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
min-width: 0;
}
.suggestion-chip:hover { background: var(--brand-soft); }
.suggestion-chip:active { transform: scale(0.98); }
@@ -1321,6 +1333,17 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
#view-chat:not(.chat-booted) .composer {
position: static;
}
/* Phase 104 (owner 2026-09-12, A4): the question-length counter —
right-aligned above the composer, a child of the .chat-bottom sticky
unit (between the actions row and the form), hidden until 80% of the
4,000-char cap (app.js updateCharCount). It sits on the APP
background behind the transparent .chat-bottom unit, so both pairings
are verified against --bg: --ink-soft on --bg = 8.6:1 (≥4.5:1, WCAG
AA) and the .is-max state's --err-ink on --bg = 10.4:1 (AA). The
.is-max state ALSO changes the copy ("— character limit") — text +
color, never color alone (B3). */
.char-count { margin: 0; text-align: right; font-size: 0.75rem; line-height: 1.2; color: var(--ink-soft); }
.char-count.is-max { color: var(--err-ink); }
.composer {
display: flex;
align-items: flex-end;
+13
View File
@@ -252,6 +252,15 @@
</button>
</div>
<!-- Phase 104 (owner 2026-09-12): the question-length counter —
hidden until 80% of the 4,000-char cap (app.js
updateCharCount); the .is-max state uses the --err-* family
PLUS a copy change (B3: text + color, never color alone). NOT
a live region: per-keystroke feedback is decorative — the
over-cap failure path announces through the error banner
(role=alert). -->
<p class="char-count" id="char-count" hidden></p>
<!-- Composer (phase 48, 2026-08-29, TODO.md L3): one button, two
roles — #send-btn reads "Send" when idle and morphs into the
enabled "Stop" control (.is-stop, rose treatment) while a turn
@@ -270,12 +279,16 @@
precedent as the tuning form's noValidate). -->
<form class="composer" id="composer" novalidate>
<label class="visually-hidden" for="message-input">Ask Brain of Reese a question</label>
<!-- maxlength=4000 mirrors ChatRequest.message max_length=4000
(app/schemas.py) — the server 422s beyond; the #char-count
line makes the cap visible (app.js updateCharCount). -->
<textarea
id="message-input"
name="message"
rows="1"
placeholder="Ask me anything…"
autocomplete="off"
maxlength="4000"
></textarea>
<button type="submit" class="send-btn" id="send-btn">
<span class="spinner" aria-hidden="true" hidden></span>
+521
View File
@@ -0,0 +1,521 @@
"""Phase 104 E2E (Playwright): single-line suggestion chips (hover reveals
the full text) + the visible 4,000-char question cap.
Source: owner request 2026-09-12 — "…tweak the size of the suggestion
chips on the chat page. Some users submit truly massive queries and the
'chips' become more like 'chonks'. Hovering over the chips should still
show the full message … put a character cap on the chat submission box,
to prevent ultra-long context overflowing queries. Users should still be
able to paste code example of a few dozen lines, but nothing much longer
than that."
The contract (owner decisions A1–A6, recorded in 00_phase.md):
* **A1/A2 — single-line chips, full text one hover away:** every
suggestion chip is ONE line at every viewport width (never a wrapped
multi-line "chonk") — long text ellipsizes at the row edge, and the
FULL text is recoverable via the native ``title`` tooltip on every
chip plus the ``aria-label`` accessible name when the visible text is
actually clipped;
* **A3 — the hard cap through the input path:** the composer textarea
carries ``maxlength="4000"`` mirroring the pre-existing server cap
(``ChatRequest.message max_length=4000`` — the server 422s beyond);
a paste of more than 4,000 chars lands as EXACTLY 4,000, and
submitting at the cap is a clean turn (no 422 error state);
* **A4 — the counter:** ``#char-count`` is hidden while the RAW length
is below 80% of the cap (3,200 — no noise on normal use), shows
``len/4000`` from there, and reads ``len/4000 — character limit`` +
the ``.is-max`` (err family + copy change, B3) at/over the cap;
* **A5 — the over-cap guard:** a programmatic fill (the chip one-tap
path) bypasses ``maxlength`` — ``handleSend`` refuses trimmed text
over the cap with the out-of-turn error banner (the ``saveAsDoc``
precedent), NO turn, and the input KEEPS the text (the user trims it
— never stale, PLAN §7.4).
The states pinned here (six tests):
1. **truncated-chip** (A1/A2 core) — a saved chat whose FIRST user
question is LONG (720 chars — a readable repeated phrase, 300+ per
the phase) with a short follow-up → a fresh page load shows EXACTLY
ONE onboarding chip (the phase-103 opener semantics — the follow-up
never surfaces): computed ``white-space: nowrap`` /
``overflow: hidden`` / ``text-overflow: ellipsis``, visually clipped
(``scrollWidth > clientWidth``), single line (``44 <=
offsetHeight <= 60`` — a one-line pill sits at the 44px
``min-height`` floor, border-box; a wrapped two-liner is ~65px —
the chonk), and
``title`` + ``aria-label`` == the FULL long text;
2. **seed-contrast** — a fresh deployment's (seed) short chips have
``title`` set AND NO ``aria-label`` (not truncated — the attribute
is absent by design; the ``textContent`` already carries the full
text);
3. **counter-threshold** (A4) — ``#char-count`` hidden at boot; 100
chars typed → still hidden; exactly 3,500 chars (a dispatched
``input`` event — ``locator.fill`` does this) → visible, text
``3500/4000``, NO ``.is-max``;
4. **paste-cap** (A3) — ``keyboard.insert_text`` of 6,500 chars (CDP
``Input.insertText`` = the paste path — ``maxlength`` applies) →
the textarea holds EXACTLY 4,000 chars; the counter reads
``4000/4000 — character limit`` + ``.is-max``; Send → NO 422 error
state → the mock answer streams to ``done`` (the brain bubble with
the ``MOCK_ANSWER_MARKER``) → the input is cleared and the counter
hidden again;
5. **over-cap-guard** (A5) — ``page.evaluate`` sets
``#message-input.value`` to 5,000 chars + dispatches an ``input``
event (the programmatic path ``maxlength`` cannot stop) → counter
``5000/4000 — character limit`` + ``.is-max`` → Send → the error
banner shows the "4,000 characters" cap copy, NO bubble is
appended, the input STILL holds the 5,000 chars (kept for
trimming — never stale);
6. **short-flow-regression** — a short question submits cleanly and
the counter NEVER becomes visible during the turn (a
MutationObserver flags any hidden→visible transition of
``#char-count`` for the whole turn).
The endpoint and the chat are authed (phase 79, ``require_user``), so
every test signs in as admin first (``auth_helpers.login``). The
onboarding chips are the phase-103 session openers read from
``saved_chats`` — AND the send tests auto-save a row per turn — so the
autouse fixture truncates that table before and after EVERY test,
which keeps each test on an empty deployment (the seed state) and
stops cross-test leakage.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_chip_sizing_question_cap.py -v --no-cov
"""
from __future__ import annotations
import asyncio
import json
import re
from collections.abc import Iterator
from pathlib import Path
from threading import Thread
from typing import Any
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
#: A 720-char opener (300+ per the phase) — a readable repeated phrase,
#: far wider than the 46rem chat column, so the single-line pill MUST
#: clip. The phase-103 opener semantics surface exactly this text as
#: the chat's one onboarding chip.
LONG_OPENER = "What are the correct arguments for " * 20 + "qwen on llama.cpp?"
SHORT_FOLLOW_UP = "And which of the two needs the most VRAM?"
#: A 6,500-char paste (4,000+ = over the cap) built from a word the
#: fixture KB indexes ("kubernetes" — ``tests/fixtures/docs/homelab/
#: kubernetes.md``): after ``maxlength`` trims it to EXACTLY 4,000 the
#: question still carries the token, so the FTS leg of the hybrid
#: retrieval hits and the mock answers the grounded (non-deflected)
#: path with the marker — a clean turn at the cap, no 422.
PASTE_QUESTION = "kubernetes " * 500
PASTE_AT_CAP = PASTE_QUESTION[:4000] # what maxlength=4000 keeps
#: The over-cap programmatic fill (A5 — the chip one-tap path
#: ``maxlength`` cannot stop): 5,000 chars, trimmed to 5,000.
OVER_CAP_FILL = "x" * 5000
#: The short-flow question (well under the 3,200 counter threshold and
#: grounded in the fixture KB — the mock answers it with the marker).
SHORT_QUESTION = "How do I deploy a new service on the homelab node?"
@pytest.fixture(autouse=True)
def clean_chats(db_ready: None) -> Iterator[None]:
"""``saved_chats`` is the state the onboarding chips read (the
phase-103 openers) AND the send tests auto-save a row per turn —
truncate it before and after EVERY test so each starts from (and
leaves) an empty deployment (the seed-chip state), exactly the
phase-80/103 autouse pattern in test_suggestion_chips.py."""
with SessionLocal() as db:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
yield
with SessionLocal() as db:
db.execute(text("TRUNCATE saved_chats"))
db.commit()
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread (Playwright owns the test loop)."""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _seed_kb(mock_port: int) -> ImportSummary:
"""Deterministic KB: truncate the KB tables, import the fixture
docs (needed by the send tests' grounded answers). ``saved_chats``
is the autouse fixture's job."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
summary = _run_in_thread(_import_fixtures(mock_port))
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
return summary
def _user(q: str) -> dict[str, Any]:
return {"who": "user", "text": q}
def _brain(text: str = "Grounded mock brain reply.") -> dict[str, Any]:
return {"who": "brain", "text": text}
def _save_chat(page: Page, app_url: str, messages: list[dict[str, Any]]) -> dict[str, Any]:
"""Save one conversation as the signed-in admin (``POST /api/chats``)
and return the 201 body."""
r = page.request.post(
f"{app_url}/api/chats",
data=json.dumps({"messages": messages}),
headers={"Content-Type": "application/json"},
timeout=10_000,
)
assert r.status == 201, r.text
return r.json()
def _chip(page: Page, index: int = 0) -> Any:
return page.locator("#suggestions .suggestion-chip").nth(index)
def _wait_chat_booted(page: Page) -> None:
"""Wait until app.js has FINISHED booting the chat page. The login
helper returns on the URL change (navigation commit) — the page's
module script may still be executing, and an ``input`` event
dispatched before its top-level listener registrations land on a
page whose listeners do not exist yet (the event is simply lost).
``#view-chat.chat-booted`` is added two frames after the boot
settles (AFTER every top-level listener), so it is the "the app's
JS is live" sentinel (house pattern: test_mobile_chat_hamburger_
boot.py's post-login wait)."""
page.wait_for_function(
"() => document.getElementById('view-chat')?."
"classList.contains('chat-booted')",
timeout=15_000,
)
expect(page.locator("#view-chat")).to_have_class(re.compile(r"\bchat-booted\b"))
def test_long_chip_is_single_line_ellipsized_with_full_text_tooltip(
page: Page, app_url: str, db_ready: None
) -> None:
"""A1/A2 core: a saved chat whose FIRST user question is 720 chars
(a readable repeated phrase) with a short follow-up → a fresh page
load shows EXACTLY ONE onboarding chip (the phase-103 opener
semantics — the follow-up never surfaces), and it is:
* ONE line — computed ``white-space: nowrap``, ``overflow: hidden``,
``text-overflow: ellipsis``;
* visually clipped — ``scrollWidth > clientWidth`` (720 chars of
~7px/char far exceeds the 46rem column);
* the pill, not the chonk — ``44 <= offsetHeight <= 60`` (a
one-line pill sits at the 44px ``min-height`` floor, border-box
— the global ``* { box-sizing: border-box }`` makes the 44px
the OUTER height; a wrapped two-liner is ~65px);
* the FULL text one hover away — ``title`` == the full long text;
* the clipped-case accessible name — ``aria-label`` == the full
long text.
"""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
# ONE multi-turn chat: the LONG opener + one short follow-up
# (the follow-up must never surface — phase-103 semantics).
_save_chat(
page,
app_url,
[
_user(LONG_OPENER), _brain(),
_user(SHORT_FOLLOW_UP), _brain(),
],
)
# A FRESH page load (a new boot fetch): EXACTLY ONE chip — the
# opener, and nothing else.
page.goto(app_url + "/")
chips = page.locator("#suggestions .suggestion-chip")
expect(chips.first).to_be_visible(timeout=30_000)
expect(chips).to_have_count(1, timeout=15_000)
# The chip carries the EXACT full opener in the DOM (the clipping
# is visual — textContent is never truncated).
assert chips.first.inner_text().strip() == LONG_OPENER
chip = chips.first
# A1 — the single-line computed contract (the phase-104 CSS).
style = chip.evaluate("el => getComputedStyle(el)")
assert style["whiteSpace"] == "nowrap", "the chip must never wrap (owner A1)"
assert style["overflow"] in {"hidden", "hidden hidden"}, (
"`overflow: hidden` is what zeroes the flex auto-min so the "
"ellipsis clip at the row edge binds"
)
assert style["textOverflow"] == "ellipsis", "the clipped text shows the ellipsis"
# Clipped + single line: the text overflows the pill (ellipsis) but
# the pill itself stays ONE 44px-min line tall — never the chonk.
# offsetHeight (border-box): the global `* { box-sizing: border-box }`
# makes the 44px `min-height` the OUTER floor — a one-line pill is
# exactly 44px, a wrapped two-liner is ~65px (63px content + 2px
# border), so the 44..60 band is single-line only.
dims = chip.evaluate(
"el => ({ sw: el.scrollWidth, cw: el.clientWidth, oh: el.offsetHeight })"
)
assert dims["sw"] > dims["cw"], (
"a 720-char question must be visually clipped in the 46rem column"
)
assert 44 <= dims["oh"] <= 60, (
f"one-line pill: 44px min-height floor (border-box), a wrapped "
f"two-liner is ~65px — got {dims['oh']}px"
)
# A2 — the full text is one hover away (the native tooltip), and
# the accessible name of the CLIPPED chip is the full text.
assert chip.get_attribute("title") == LONG_OPENER, (
"the title tooltip must carry the FULL question (owner A2)"
)
assert chip.get_attribute("aria-label") == LONG_OPENER, (
"a clipped chip's aria-label must be the full text (the "
"source-chip pattern)"
)
def test_short_seed_chip_has_tooltip_but_no_aria_label(
page: Page, app_url: str, db_ready: None
) -> None:
"""The contrast pin: a fresh deployment (seed chips — the autouse
fixture guarantees the empty state) renders four SHORT seed chips,
none of them clipped in the 1280px desktop column. Every chip still
has ``title`` = its full text (A2 is universal), but NO ``aria-label``
— the attribute is absent by design when the visible text is not
clipped (the ``textContent`` already carries the full text)."""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
chips = page.locator("#suggestions .suggestion-chip")
expect(chips.first).to_be_visible(timeout=30_000)
expect(chips).to_have_count(4, timeout=15_000) # the built-in seed (4 entries)
for i in range(4):
chip = chips.nth(i)
full_text = chip.inner_text().strip()
assert full_text, "every seed chip needs non-empty label text"
# Not clipped: the pill fits its text in the desktop column.
dims = chip.evaluate(
"el => ({ sw: el.scrollWidth, cw: el.clientWidth, h: el.clientHeight })"
)
assert dims["sw"] <= dims["cw"], f"seed chip {i!r} must not be clipped"
# A2 universal: the tooltip is set on EVERY chip…
assert chip.get_attribute("title") == full_text
# …but the accessible name is added ONLY when clipped.
assert chip.get_attribute("aria-label") is None, (
f"an unclipped chip must NOT carry an aria-label (chip {i!r}) — "
"the textContent already reads the full text"
)
def test_counter_hidden_below_threshold_and_visible_above(
page: Page, app_url: str, db_ready: None
) -> None:
"""A4: the counter is hidden at boot, stays hidden at 100 chars
(below the 3,200 = 80% threshold — no noise on normal use), and at
exactly 3,500 chars (a dispatched ``input`` event — ``locator.fill``
fires it) it is visible reading ``3500/4000`` with NO ``.is-max``
(that state is for at/over the cap)."""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_wait_chat_booted(page)
counter = page.locator("#char-count")
expect(counter).to_be_hidden() # boot state (hidden by default in the HTML)
page.fill("#message-input", "a" * 100)
expect(counter).to_be_hidden() # 100 < 3,200 — still no noise
page.fill("#message-input", "b" * 3500)
expect(counter).to_be_visible(timeout=5_000)
expect(counter).to_have_text("3500/4000")
expect(counter).not_to_have_class(re.compile("is-max"))
def test_paste_path_hard_caps_at_the_cap_and_sends(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""A3: the paste path (CDP ``Input.insertText`` — the browser
applies ``maxlength`` to it, like a paste) hard-caps at EXACTLY
4,000: a 6,500-char paste lands as 4,000, the counter reads
``4000/4000 — character limit`` + ``.is-max``, and submitting a
4,000-char question PASSES the server cap (no 422 error state) —
the mock answer streams to ``done`` and the turn clears the input
+ the counter."""
_seed_kb(mock_llm)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_wait_chat_booted(page)
counter = page.locator("#char-count")
input_el = page.locator("#message-input")
expect(counter).to_be_hidden()
# The paste path: one CDP insert of 6,500 chars — maxlength trims
# the OVERFLOW, the textarea keeps EXACTLY the first 4,000.
input_el.click()
page.keyboard.insert_text(PASTE_QUESTION)
expect(input_el).to_have_value(PASTE_AT_CAP) # EXACTLY 4,000 chars
# At the cap: the honest reading + the B3 state (copy + err color).
expect(counter).to_be_visible(timeout=5_000)
expect(counter).to_have_text("4000/4000 — character limit")
expect(counter).to_have_class(re.compile("is-max"))
# Submit at the cap: the 4,000-char question passes the server cap
# (no 422 error state — a 422 would raise the error banner).
banner = page.locator("#kb-banner")
expect(banner).to_be_hidden()
page.click("#send-btn")
# A clean turn: the user bubble is the exact 4,000-char question and
# the grounded mock answer streams to done (the KB carries
# "kubernetes" — the FTS leg hits, non-deflected).
expect(page.locator(".msg.user .bubble")).to_have_count(1, timeout=30_000)
expect(page.locator(".msg.user .bubble")).to_have_text(PASTE_AT_CAP)
brain = page.locator(".msg.brain .bubble").first
expect(brain).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
expect(banner).to_be_hidden() # NO 422 error state at the cap
# Never stale: the turn cleared the input AND the counter, and the
# send button recovered.
expect(input_el).to_have_value("")
expect(counter).to_be_hidden()
expect(page.locator("#send-btn")).to_be_enabled()
def test_over_cap_programmatic_fill_hits_the_guard(
page: Page, app_url: str, db_ready: None
) -> None:
"""A5: the one path ``maxlength`` cannot stop — a programmatic
``value`` assignment (the chip one-tap fill). A 5,000-char fill +
a dispatched ``input`` event → the counter reads the honest
``5000/4000 — character limit`` + ``.is-max``; Send → the over-cap
guard fires: the error banner shows the "4,000 characters" cap
copy, NO turn (no user or brain bubble), and the input STILL holds
the 5,000 chars (kept for trimming — never stale, PLAN §7.4)."""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_wait_chat_booted(page)
counter = page.locator("#char-count")
expect(counter).to_be_hidden()
# The programmatic path: assign + dispatch (maxlength never sees it).
page.evaluate(
"""(value) => {
const el = document.querySelector("#message-input");
el.value = value;
el.dispatchEvent(new Event("input", { bubbles: true }));
}""",
OVER_CAP_FILL,
)
expect(counter).to_be_visible(timeout=5_000)
expect(counter).to_have_text("5000/4000 — character limit")
expect(counter).to_have_class(re.compile("is-max"))
# Send over the cap: the guard refuses BEFORE the request.
page.click("#send-btn")
banner = page.locator("#kb-banner")
expect(banner).to_be_visible(timeout=5_000)
expect(banner).to_have_attribute("role", "alert")
expect(page.locator("#kb-banner-text")).to_contain_text("4,000 characters")
# NO turn: nothing was appended, and the input keeps the text the
# user trims (a cleared input would be stale — PLAN §7.4).
expect(page.locator(".msg.user .bubble")).to_have_count(0)
expect(page.locator(".msg.brain .bubble")).to_have_count(0)
expect(page.locator("#message-input")).to_have_value(OVER_CAP_FILL)
# The counter (which mirrors the untouched input) and the send
# button (never mid-turn) are consistent with the kept text.
expect(counter).to_have_text("5000/4000 — character limit")
expect(page.locator("#send-btn")).to_be_enabled()
def test_short_flow_never_shows_the_counter(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Short-flow regression: a 49-char question submits cleanly (the
grounded mock answer lands) and ``#char-count`` NEVER becomes
visible during the whole turn — a MutationObserver flags ANY
hidden→visible transition of the counter (with ``oldValue``, so
even a same-task set/unset is caught)."""
_seed_kb(mock_llm)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_wait_chat_booted(page)
# Arm the never-visible tripwire for the rest of this page's life.
page.evaluate(
"""() => {
window.__char_count_ever_visible = false;
const el = document.querySelector("#char-count");
new MutationObserver((records) => {
for (const r of records) {
if (r.attributeName === "hidden" && r.oldValue === "true") {
window.__char_count_ever_visible = true;
}
}
}).observe(el, {
attributes: true,
attributeFilter: ["hidden"],
attributeOldValue: true,
});
}"""
)
counter = page.locator("#char-count")
expect(counter).to_be_hidden()
page.fill("#message-input", SHORT_QUESTION) # 49 chars << 3,200
expect(counter).to_be_hidden()
page.click("#send-btn")
expect(page.locator(".msg.user .bubble")).to_have_count(1, timeout=30_000)
expect(page.locator(".msg.user .bubble")).to_have_text(SHORT_QUESTION)
brain = page.locator(".msg.brain .bubble").first
expect(brain).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
expect(page.locator("#message-input")).to_have_value("")
expect(page.locator("#send-btn")).to_be_enabled()
# The whole turn: the counter never once became visible.
assert page.evaluate("() => window.__char_count_ever_visible") is False, (
"a short question must never surface the counter — not at the "
"keystrokes, not at the send, not during the turn"
)
+48 -3
View File
@@ -186,6 +186,38 @@ def scroll_state(page: Page) -> dict[str, float]:
)
def wait_reveal_scroll_done(page: Page, target: int, timeout: int = 10_000) -> None:
"""Block until the submit's ``scrollReveal`` smooth scroll has ARRIVED
at its target (the document bottom captured right after the click,
``target`` — the first streamed delta lands tens of ms later and
would inflate a later read). Needed before a test scroll: Chromium's
smooth-scroll duration is distance- and environment-dependent (roughly
half a second to a second for a full page), and a ``window.scrollTo``
issued mid-animation does not settle the measured position (the
animation keeps moving the page after the test's scroll, so a
position read lands mid-flight, e.g. ``y=71`` instead of ``0``).
The wait is an ARRIVAL wait (``scrollY`` reached the target bottom),
not a "looks still" wait: mid-animation frame stalls under load make
a short stillness window pass while the animation is still pending
(observed: a 120 ms window passed mid-scroll; 800 ms is the fallback
for the vanishingly-rare late-target read, where the arrival
threshold is inflated by an already-landed delta). Phase 104 (task
03, 2026-09-12): pinned deterministically when the phase-104 E2E
regression exposed the race (it reproduces on the pre-phase-104 tree
— a pre-existing flake, not a phase-104 regression)."""
page.wait_for_function(
"""(target) => {
if (window.scrollY >= target - window.innerHeight - 2) return true;
const y0 = window.scrollY;
return new Promise((resolve) =>
setTimeout(() => resolve(window.scrollY === y0), 800));
}""",
arg=target,
timeout=timeout,
)
def wait_settled(page: Page, timeout: int = 30_000) -> None:
"""The turn is over: the label is back to "Send" (phase 48 — the
in-flight state is the enabled Stop control, so the label carries the
@@ -194,11 +226,18 @@ def wait_settled(page: Page, timeout: int = 30_000) -> None:
expect(page.locator("#send-label")).to_have_text("Send", timeout=timeout)
def submit(page: Page, question: str) -> None:
"""Submit through the composer (the real-user flow)."""
def submit(page: Page, question: str) -> int:
"""Submit through the composer (the real-user flow). Returns the
document ``scrollHeight`` read right after the click — the submit's
``scrollReveal`` target (``window.scrollTo({top: scrollHeight})``
runs in the click's own event dispatch, and the first streamed delta
lands tens of ms later and would inflate a later read)."""
page.fill("#message-input", question)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
# The bubble exists by now and the first streamed delta is still tens
# of ms away — the read is the scrollReveal's own target.
return page.evaluate("() => document.documentElement.scrollHeight")
def build_conversation(page: Page, n: int = 6) -> None:
@@ -371,7 +410,7 @@ def test_stop_is_reachable_from_scrolled_up(
# SECOND answer bubble exists and carries deltas (`.last` alone would
# still resolve to turn 1's finished bubble: the typing indicator is
# `.bubble.typing` and excluded from ANSWER).
submit(page, LONG_QUESTION)
reveal_target = submit(page, LONG_QUESTION)
page.wait_for_function(
"() => { const els = document.querySelectorAll('.msg.brain .bubble:not(.typing)');"
" return els.length >= 2 && els[els.length - 1].innerText.length > 80; }",
@@ -380,6 +419,12 @@ def test_stop_is_reachable_from_scrolled_up(
answer = page.locator(ANSWER).nth(1)
expect(page.locator("#send-label")).to_have_text("Stop", timeout=10_000)
# The submit's own smooth scrollReveal (to the document bottom) must
# ARRIVE before the test scrolls up — mid-animation, the browser
# keeps moving the page after the test's scrollTo and the position
# read lands mid-flight (the race wait_reveal_scroll_done documents).
wait_reveal_scroll_done(page, reveal_target)
# The user scrolls UP to read earlier content. Phase 42 leaves them
# there — the app never follows the stream.
page.evaluate("() => window.scrollTo(0, 0)")
+28 -6
View File
@@ -130,8 +130,24 @@ def _assert_no_doc_overflow(page: Page, label: str) -> None:
def _tab_outline_walk(page: Page, max_tabs: int = 60) -> list[dict[str, str]]:
"""Real keyboard Tab walk; returns each focused element's outline."""
first_key: str | None = None
"""Real keyboard Tab walk; returns each focused element's outline.
The walk covers a FULL focus cycle (it starts wherever Chromium
resumes after the skip-link check — mid-document, right after
#main — and ends when focus wraps back to the FIRST element it
visited). The wrap is detected by TRUE element identity: each
newly focused element is stamped with a unique ``data-tabwalk-id``
and the cycle ends when a STAMPED element is focused again.
Phase 104 (task 04, 2026-09-12): the pre-104 heuristic keyed on
the first 24 chars of the element's text — two chips sharing that
prefix (e.g. the two identical "How is my Kubernetes cluster set
up? write a long answer" opener chips left in ``saved_chats`` by
test_pinned_composer.py, which runs suites in sequence on the
shared e2e DB) collided and cut the walk short at two entries,
flaking ``len(seen) >= 3``. Verified pre-existing on the
pre-phase-104 tree (the mid-document Tab start is Chromium
behavior, not a phase-104 change)."""
first_id: str | None = None
seen: list[dict[str, str]] = []
for _ in range(max_tabs):
page.keyboard.press("Tab")
@@ -142,7 +158,13 @@ def _tab_outline_walk(page: Page, max_tabs: int = 60) -> list[dict[str, str]]:
const cls = String(el.className).split(" ")[0];
const label = (el.getAttribute("aria-label")
|| el.textContent || "").trim().slice(0, 24);
let id = el.getAttribute("data-tabwalk-id");
if (!id) {
id = "tw" + ((window.__twSeq = (window.__twSeq || 0) + 1));
el.setAttribute("data-tabwalk-id", id);
}
return {
id: id,
key: el.tagName + "#" + (el.id || "") + "." + cls + ":" + label,
outline_style: cs.outlineStyle,
outline_width: cs.outlineWidth,
@@ -151,11 +173,11 @@ def _tab_outline_walk(page: Page, max_tabs: int = 60) -> list[dict[str, str]]:
)
if info["key"].startswith("BODY"):
continue # focus has not entered the document yet
if first_key is None:
first_key = info["key"]
if first_id is None:
first_id = info["id"]
seen.append(info)
if len(seen) > 1 and info["key"] == first_key:
break # wrapped back to the first focusable
if len(seen) > 1 and info["id"] == first_id:
break # wrapped back to the first focusable (same ELEMENT)
return seen
+6 -1
View File
@@ -181,7 +181,12 @@ def _save_chat(page: Page, app_url: str, messages: list[dict[str, Any]]) -> dict
def _api_suggestions(page: Page, app_url: str) -> list[str]:
# Phase 79: the endpoint is require_user-gated — the request rides
# the page's signed-in context (each test signs in above).
r = page.request.get(f"{app_url}/api/suggestions", timeout=10)
# Phase 104 (task 03, 2026-09-12): the original 10 ms timeout sat
# BELOW a normal localhost round trip (~12 ms steady-state) and
# flaked the whole suite on a loaded box — the sibling _save_chat
# helper's 10 s is the house pattern; the server is up by the time
# this probe runs (the chips it mirrors already rendered).
r = page.request.get(f"{app_url}/api/suggestions", timeout=10_000)
assert r.status == 200, r.text
return r.json()["suggestions"]
+550
View File
@@ -0,0 +1,550 @@
"""Unit: the phase-104 chip-sizing contract in the static frontend
(owner request 2026-09-12 — "tweak the size of the suggestion chips on
the chat page. Some users submit truly massive queries and the 'chips'
become more like 'chonks'. Hovering over the chips should still show
the full message … put a character cap on the chat submission box").
The browser geometry (the measured clientHeight / scrollWidth / the
title + aria-label / the counter / the guard) is E2E-gated by the
phase's dedicated suite (tests/e2e/test_chip_sizing_question_cap.py,
task 04); this module pins the static sources the contract stands on,
in the house source-pin pattern (the test_pinned_composer.py
``_rule`` style).
Task 01 — single-line chips, never chonks (owner A1): a suggestion
chip is ONE line at every viewport width — long text ellipsizes at the
row edge instead of wrapping the pill into a multi-line "chonk". The
pin is the CSS contract:
* the base ``.suggestion-chip`` rule carries the single-line/ellipsis
set — ``white-space: nowrap``, ``overflow: hidden`` (zeroes the flex
item's automatic minimum size so ``max-width: 100%`` actually binds),
``text-overflow: ellipsis``, ``max-width: 100%`` — plus
``min-width: 0``, and KEEPS the pill it always was (the 44px
``min-height`` floor, the 999px radius);
* the phase-07 override ``.maybe-try .suggestion-chip``
(``min-width: 0; max-width: 100%``) is fully subsumed by the base
rule and DELETED — zero occurrences of the selector remain in the
file (its phase-07 provenance was folded into the base rule's
comment, so the history lives with the contract); the ``.maybe-try``
GROUP rule itself stays (the deflection row still wraps at every
width — only the chip override is gone);
* the ≤640px block keeps its phase-07 contract untouched: the
``.suggestions`` row is the single horizontal-scroll track
(``flex-wrap: nowrap; overflow-x: auto``) and
``.suggestion-chip { flex: 0 0 auto; }`` — with the base rule's
``max-width: 100%`` a long chip now clips at the VISIBLE width while
the row scrolls, instead of letting the chip outgrow the viewport.
Task 02 — the full text is always one hover away (owner A2): the
single-line pill from task 01 CLIPS long questions, so every chip the
shared ``renderChips`` component builds (onboarding row AND "Maybe try"
row) carries:
* ``btn.title = text`` — the native tooltip set to the FULL text,
always (the house source-chip precedent, ``chip.title = label``);
* the truncation-aware accessible name — ONLY when the visible text is
clipped (``btn.scrollWidth > btn.clientWidth``) does the chip get
``aria-label`` = the full text (the source-chip pattern); when the
pill is not clipped the attribute stays absent, because
``textContent`` already reads the full text to screen readers.
Task 03 extends this module with the composer cap pins (``maxlength`` /
the counter / the guard / the single-source cross-file pin).
"""
from __future__ import annotations
import re
from pathlib import Path
from app.schemas import ChatRequest
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
APP_JS = FRONTEND / "assets" / "app.js"
INDEX_HTML = FRONTEND / "index.html"
def _css() -> str:
assert STYLES_CSS.is_file(), f"missing {STYLES_CSS}"
return STYLES_CSS.read_text(encoding="utf-8")
def _app_js() -> str:
assert APP_JS.is_file(), f"missing {APP_JS}"
return APP_JS.read_text(encoding="utf-8")
def _index_html() -> str:
assert INDEX_HTML.is_file(), f"missing {INDEX_HTML}"
return INDEX_HTML.read_text(encoding="utf-8")
def _function_body(js: str, header: str) -> str:
"""The full text of the function whose header is ``header`` — from
the header to its brace-matched closing ``}``. The naive brace count
is safe for the pinned functions: their template literals carry
balanced ``${…}`` pairs and no string literal holds a stray brace."""
start = js.index(header)
body_open = js.index("{", start)
depth = 0
for j in range(body_open, len(js)):
if js[j] == "{":
depth += 1
elif js[j] == "}":
depth -= 1
if depth == 0:
return js[start : j + 1]
raise AssertionError(f"unbalanced braces in {header!r}")
def _single_line_rule(css: str, selector: str) -> str:
"""The body of a ONE-LINE rule (``selector { … }`` on a single
line) — the .char-count rules are written house one-liners."""
block = re.search(rf"^{re.escape(selector)} \{{([^}}]*)\}}", css, re.MULTILINE)
assert block, f"styles.css must carry a `{selector} {{ … }}` rule"
return block.group(1)
def _message_input_tag(html: str) -> str:
"""The FULL opening tag of the ``#message-input`` textarea (the
composer's — index.html carries other textareas too)."""
tag = re.search(r'<textarea\b[^>]*id="message-input"[^>]*>', html)
assert tag, "index.html must carry the #message-input textarea"
return tag.group(0)
def _schema_question_cap() -> int:
"""The server cap the UI mirrors — ``ChatRequest.message``'s
``max_length`` (the single conceptual source of the 4,000)."""
for meta in ChatRequest.model_fields["message"].metadata:
if getattr(meta, "max_length", None):
return int(meta.max_length)
raise AssertionError("ChatRequest.message must keep its max_length")
def _render_chips_body(js: str) -> str:
"""The full text of the ``renderChips`` function — from its
signature to its brace-matched closing ``}`` (brace counting, so
the nested click-handler braces are exact). The body's opening
brace is the LAST ``{`` on the signature line — the ``{ onSelect }
= {}`` destructuring pair sits before it."""
start = js.index("function renderChips(")
line_end = js.index("\n", start)
body_open = js.rindex("{", start, line_end)
depth = 0
for j in range(body_open, len(js)):
if js[j] == "{":
depth += 1
elif js[j] == "}":
depth -= 1
if depth == 0:
return js[start : j + 1]
raise AssertionError("unbalanced braces in renderChips")
def _rule(css: str, selector: str) -> str:
"""The body of the rule whose selector line is exactly `selector`
(multi-line block) — same slicing style as test_pinned_composer.py."""
block = re.search(rf"^{re.escape(selector)} \{{\n([\s\S]*?)\n\}}", css, re.MULTILINE)
assert block, f"styles.css must carry a `{selector} {{ … }}` rule"
return block.group(1)
def _mobile_block(css: str) -> str:
"""The ≤640px media query body (the phase-07 responsive block)."""
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
assert mobile, "the mobile media query must exist"
return mobile.group(1)
# ---------- task 01: the single-line chip contract ----------
def test_base_chip_rule_is_single_line_and_ellipsized() -> None:
"""The base ``.suggestion-chip`` rule carries the phase-104
single-line/ellipsis contract (owner A1) AND keeps the pill it
always was — a chip is a one-line 44px pill at every width, never
a wrapped multi-line block:
* ``white-space: nowrap`` — the text never wraps inside the pill;
* ``overflow: hidden`` (≠ visible) — zeroes the flex item's
automatic minimum size, which is what makes ``max-width: 100%``
bind at all;
* ``text-overflow: ellipsis`` + ``max-width: 100%`` — the text
clips at the row edge (desktop wrap row: the chat column;
≤640px row: the visible width) instead of the pill outgrowing it;
* ``min-width: 0`` — the same auto-min zeroing, pinned for clarity.
"""
body = _rule(_css(), ".suggestion-chip")
assert "white-space: nowrap;" in body, (
"the chip must never wrap — a 400-char question may not turn the "
"pill into a multi-line 'chonk' (owner A1)"
)
assert "overflow: hidden;" in body, (
"`overflow: hidden` zeroes the flex item's automatic minimum size "
"so `max-width: 100%` actually binds (visible overflow would let "
"the pill push past the row edge)"
)
assert "text-overflow: ellipsis;" in body, (
"the clipped text must show the ellipsis — the full text stays "
"one hover away (the title tooltip, task 02)"
)
assert "max-width: 100%;" in body, (
"the chip clips at the row edge — 100% of the row it sits in"
)
assert "min-width: 0;" in body, (
"the flex item's automatic minimum size must be zero — the "
"phase-07 shrink allowance, now on every chip row"
)
# The pill contract survives the sizing change (the 44px touch
# floor + the round pill — the E2E's 44 <= clientHeight <= 60 pin
# stands on this min-height).
assert "min-height: 44px;" in body, (
"the chip keeps its ≥44px touch target (WCAG 2.1 AA, phase 07)"
)
assert "border-radius: 999px;" in body, "the chip stays the round pill"
def test_subsumed_maybe_try_chip_override_is_gone() -> None:
"""The phase-07 ``.maybe-try .suggestion-chip { min-width: 0;
max-width: 100%; }`` override is fully subsumed by the new base
rule and DELETED — zero occurrences of the selector remain anywhere
in the file (comments included: the provenance was folded into the
base rule's comment WITHOUT the selector string, so this pin
doubles as the "no second cap / no drift" guard). The ``.maybe-try``
GROUP rule itself stays exactly once — the deflection row still
wraps at every width (phase 04), only the chip override is gone."""
css = _css()
assert css.count(".maybe-try .suggestion-chip") == 0, (
"the subsumed phase-07 override must be deleted — the base rule "
"carries min-width:0 + max-width:100% now, and a second cap "
"would be dead weight / a drift hazard"
)
assert css.count(".maybe-try {") == 1, (
"the .maybe-try group rule stays (the deflection row's wrap "
"contract) — only the chip override was removed"
)
def test_mobile_chip_row_keeps_the_phase_07_track() -> None:
"""The ≤640px block is UNTOUCHED by task 01: the ``.suggestions``
row keeps the phase-07 single horizontal-scroll track
(``flex-wrap: nowrap; overflow-x: auto``) and
``.suggestion-chip { flex: 0 0 auto; }``. With the base rule's
``max-width: 100%`` + ``overflow: hidden``, a long chip on the
phone clips at the VISIBLE width and the row scrolls — the
phase-07 overflow contract now lives in the base rule (task 01
folded the provenance there), so nothing in the mobile block may
have changed."""
mobile = _mobile_block(_css())
mobile_chip = re.search(r"\.suggestion-chip \{([^}]*)\}", mobile)
assert mobile_chip, "the ≤640px block must keep the .suggestion-chip rule"
assert ".suggestions { flex-wrap: nowrap; overflow-x: auto;" in mobile, (
"the mobile row keeps the phase-07 single horizontal-scroll "
"track (nowrap + overflow-x: auto)"
)
assert mobile_chip.group(1).strip() == "flex: 0 0 auto;", (
"the mobile chip must stay EXACTLY the phase-07 fixed flex item "
"(flex: 0 0 auto) on the scroll track — the chip does not shrink "
"below its content on the phone, the ROW scrolls; the long-chip "
"clip is the base rule's max-width: 100%, not a mobile-local one"
)
# ---------- task 02: the full-text tooltip + accessible name ----------
def test_render_chips_sets_full_text_title_on_every_chip() -> None:
"""Every chip ``renderChips`` builds (onboarding row AND "Maybe try"
row — one shared component) carries ``title`` = the FULL text
(owner A2): the task-01 pill clips a long question to one
ellipsized line, and the native hover tooltip is the way the user
reads the rest. The pin is the house source-chip precedent
(``chip.title = label``) — and it is set to the SAME ``text``
variable ``textContent`` gets, so the tooltip can never drift
short of the full question."""
body = _render_chips_body(_app_js())
assert "btn.textContent = text;" in body, (
"the chip's visible text is the (trimmed) full question"
)
assert "btn.title = text;" in body, (
"every chip must carry the full text as its native title "
"tooltip — hovering is the contract for the clipped text "
"(owner A2: 'Hovering over the chips should still show the "
"full message')"
)
def test_render_chips_sets_aria_label_only_when_clipped() -> None:
"""The accessible name is the full text ONLY when the visible text
is actually clipped (the source-chip truncation pattern, the
``chip.scrollWidth > chip.clientWidth`` → ``aria-label`` loop): a
chip whose text fits carries NO ``aria-label`` — its ``textContent``
already reads the full text to screen readers — while the clipped
chip (the ellipsized one) announces the full question. The pin is
the guard plus the ``setAttribute`` call inside it."""
body = _render_chips_body(_app_js())
guard = re.search(
r'if \(btn\.scrollWidth > btn\.clientWidth\) '
r'btn\.setAttribute\("aria-label", text\);',
body,
)
assert guard, (
"the clipped chip must set aria-label to the FULL text under "
"the scrollWidth > clientWidth guard (the source-chip pattern) "
"— a clipped chip's accessible name must be the full question, "
"and an unclipped chip must stay attribute-free"
)
# ---------- task 03: the visible 4,000-char question cap ----------
def test_message_input_carries_the_4000_maxlength() -> None:
"""The composer's textarea hard-caps the input path (typing AND
paste — the browser enforces maxlength on both, E2E task 04) at
EXACTLY the server's cap, so a user can never meet the 422 blind
through the input path (owner A3). The provenance comment lives
with the attribute — the house pattern (the theme inputs' "
maxlength=300 mirrors the server's 300-char")."""
html = _index_html()
tag = _message_input_tag(html)
assert 'maxlength="4000"' in tag, (
"#message-input must carry maxlength=4000 — the server already "
"rejects >4,000 (ChatRequest.message) and the counter makes it "
"visible; without it the input path 422s with zero feedback"
)
assert "maxlength=4000 mirrors ChatRequest.message max_length=4000" in html, (
"the provenance comment must live with the attribute (house "
"pattern) — the schema is the source of truth the HTML mirrors"
)
def test_char_count_sits_in_chat_bottom_above_the_composer() -> None:
"""The counter element is a child of the .chat-bottom sticky unit —
between the actions row and the composer form (source order) and
``hidden`` by default (it only appears from 80% of the cap). A
hidden ``<p>`` adds zero height, so the pinned-cluster geometry
(tests/unit/test_pinned_composer.py, the .chat-bottom-last-child
pin) is untouched by the new child."""
html = _index_html()
unit = re.search(r'<div class="chat-bottom">([\s\S]*?)</form>\s*</div>', html)
assert unit, "the .chat-bottom sticky unit must exist (phase 65)"
inner = unit.group(1)
el = re.search(r'<p class="char-count" id="char-count"[^>]*></p>', inner)
assert el, "the #char-count counter element must exist in .chat-bottom"
assert "hidden" in el.group(0), (
"the counter ships HIDDEN — it is feedback near the cap, not a "
"permanent chrome line (owner A4: no noise on normal use)"
)
# Source order: AFTER the actions row closes, BEFORE the composer —
# the counter sits between the row and the form inside the unit.
actions_close = inner.index("</div>") # the .chat-actions wrapper closes first
assert actions_close < inner.index('id="char-count"') < inner.index('id="composer"'), (
"the counter must sit inside .chat-bottom between the chat-actions "
"row and the composer form (its flow position above the box)"
)
def test_counter_comment_records_the_not_live_region_decision() -> None:
"""The phase-104 decision record on the element: the .is-max state
is --err-* PLUS a copy change (B3 — text + color, never color
alone) and the counter is NOT a live region (per-keystroke
feedback is decorative; the over-cap failure announces through the
role=alert error banner)."""
comment = re.search(
r"<!--\s*Phase 104\s*\(owner 2026-09-12\): the question-length counter[\s\S]*?-->",
_index_html(),
)
assert comment, "the phase-104 provenance comment must sit on the counter"
text = comment.group(0)
assert "NOT" in text and "live region" in text.lower(), (
"the counter must stay out of the aria-live contract — the "
"over-cap failure path announces through the error banner"
)
assert "B3" in text, (
"the .is-max state must be recorded as text + color (B3), "
"never color alone"
)
def test_cap_constants_mirror_the_server_cap() -> None:
"""The JS cap constants: ``MAX_QUESTION_CHARS = 4000`` (mirrors the
schema — the executor must NOT change app/schemas.py) and
``CHAR_COUNT_SHOW_AT = 3200`` (80% of the cap, owner A4). The
single-source pin below proves the three copies (HTML / JS / schema)
cannot drift."""
js = _app_js()
m = re.search(r"const MAX_QUESTION_CHARS = (\d+);", js)
assert m, "app.js must define the MAX_QUESTION_CHARS constant"
assert int(m.group(1)) == _schema_question_cap(), (
"MAX_QUESTION_CHARS must mirror ChatRequest.message's "
"max_length — the cap lives in one place conceptually"
)
s = re.search(r"const CHAR_COUNT_SHOW_AT = (\d+);", js)
assert s, "app.js must define the CHAR_COUNT_SHOW_AT constant"
assert int(s.group(1)) == 0.8 * int(m.group(1)), (
"the counter appears at exactly 80% of the cap (owner A4) — "
"no noise on normal use, visible when it matters"
)
def test_html_maxlength_equals_js_constant_cross_file() -> None:
"""THE single-source cross-file pin: the HTML ``maxlength`` on
#message-input, the JS ``MAX_QUESTION_CHARS`` constant, and the
server's ``ChatRequest.message`` cap are the SAME number (regex-
parsed from both frontend files + the schema). Any one drifting is
a blind 422 (or a counter that lies about the cap)."""
html_m = re.search(r'maxlength="(\d+)"', _message_input_tag(_index_html()))
js_m = re.search(r"const MAX_QUESTION_CHARS = (\d+);", _app_js())
assert html_m and js_m, "both the HTML maxlength and the JS constant must exist"
html_len = int(html_m.group(1))
js_len = int(js_m.group(1))
assert html_len == js_len == _schema_question_cap() == 4000, (
f"HTML maxlength={html_len}, JS MAX_QUESTION_CHARS={js_len} and the "
"schema cap must all be the one number (4,000)"
)
def test_update_char_count_contract() -> None:
"""The counter's state machine (owner A4): RAW length (no trim —
raw ≤ cap ⟹ trimmed ≤ cap, a safe superset of what the server
validates), hidden below the 80% threshold, plain ``len/4000`` at
and above it, and ``len/4000 — character limit`` + the .is-max
(err family) treatment at/over the cap — the over-cap reading keeps
the HONEST length (the chip-fill path exceeds maxlength, e.g.
``5123/4000 — character limit``)."""
body = _function_body(_app_js(), "function updateCharCount() {")
assert "const len = input.value.length;" in body, (
"the count is the RAW value (no trim) — a raw count is a safe "
"superset of what the server validates"
)
assert "len < CHAR_COUNT_SHOW_AT" in body, (
"below the 80% threshold the counter stays hidden"
)
assert 'charCountEl.hidden = true;' in body
assert 'charCountEl.classList.remove("is-max");' in body, (
"hiding the counter must also drop the .is-max state — a short "
"question after a maxed one must not keep the error color"
)
assert "len >= MAX_QUESTION_CHARS" in body, (
"the at/over-cap branch is >= (4,000 itself is already at the "
"limit — the server accepts exactly 4,000, so the counter says "
"so at 4,000)"
)
assert 'charCountEl.classList.toggle("is-max", atMax);' in body
assert "— character limit" in body, (
"the .is-max state must CHANGE THE COPY (B3) — color alone is "
"not the state; the words carry it"
)
assert "`${len}/${MAX_QUESTION_CHARS}`" in body, (
"the counter text is len/cap from the SAME constants — the "
"displayed cap can never drift from MAX_QUESTION_CHARS"
)
grabber = re.search(
r'const charCountEl = document\.querySelector\("#char-count"\);', _app_js()
)
assert grabber, "app.js must grab #char-count alongside the other controls"
def test_handle_send_guard_blocks_over_cap_questions() -> None:
"""The over-cap guard (owner A5): the one reachable path that
bypasses maxlength is the programmatic chip fill, and it is
blocked in handleSend — trimmed text over the cap gets the
out-of-turn error banner (the saveAsDoc precedent), NO turn, and
the input KEEPS the text (the user trims it — never stale, PLAN
§7.4). The guard sits AFTER the !text guard and BEFORE the clear.
"""
body = _function_body(_app_js(), "async function handleSend(e) {")
guard = re.search(
r"if \(text\.length > MAX_QUESTION_CHARS\) \{\s*"
r"showErrorBanner\(\"Questions are limited to 4,000 characters"
r" — trim the question and try again\.\"\);\s*"
r"return;\s*\}",
body,
)
assert guard, (
"handleSend must refuse a trimmed question over the cap with the "
"4,000-characters banner copy (the UI makes the server's 422 "
"visible BEFORE the request)"
)
assert "if (!text || sendBtn.disabled) return;" in body
assert body.index("text.length > MAX_QUESTION_CHARS") < body.index(
'input.value = "";'
), (
"the guard must run BEFORE the clear — the input keeps the kept "
"text so the user can trim it (a cleared input would be stale)"
)
def test_all_four_mutation_sites_run_update_char_count() -> None:
"""``updateCharCount()`` runs at the EXACT four ``input.value``
mutation sites (each already ran ``autoGrow()`` there) — the
counter can never show a stale length:
* the ``input`` listener — keystrokes + pastes (maxlength caps
both); the listener body now runs autoGrow AND updateCharCount;
* ``submitSuggestion`` — the chip one-tap fill (the programmatic
path maxlength cannot stop);
* the ``handleSend`` post-send clear — the sent question hides the
counter again;
* the ``startNewChat`` clear — same.
"""
js = _app_js()
# (a) the input listener — both calls inside the one listener body
listener = re.search(
r'input\.addEventListener\("input", \(\) => \{[\s\S]*?autoGrow\(\);'
r"[\s\S]*?updateCharCount\(\);[\s\S]*?\}\);",
js,
)
assert listener, (
"the input listener must run autoGrow AND updateCharCount — the "
"counter follows every input-path change"
)
# (b) the chip one-tap fill
sub = _function_body(js, "function submitSuggestion(text) {")
assert sub.index("input.value = text;") < sub.index("autoGrow();") < sub.index(
"updateCharCount();"
), "submitSuggestion must count the (possibly over-cap) filled text"
# (c) the post-send clear
send = _function_body(js, "async function handleSend(e) {")
assert send.index('input.value = "";') < send.index("autoGrow();") < send.index(
"updateCharCount();"
), "the post-send clear must hide the counter with the input"
# (d) the new-chat clear
newchat = _function_body(js, "function startNewChat() {")
assert newchat.index('input.value = "";') < newchat.index("autoGrow();") < newchat.index(
"updateCharCount();"
), "the new-chat clear must hide the counter with the input"
def test_char_count_css_is_aa_on_the_app_background() -> None:
"""The counter's two color pairings are WCAG-AA-verified against
the background it actually sits on — the APP background (--bg)
behind the transparent .chat-bottom unit — with each ratio recorded
in the rule's comment (house style): --ink-soft on --bg = 8.6:1,
--err-ink on --bg = 10.4:1 (both ≥4.5:1). The .is-max state pairs
the color with the "— character limit" copy change (B3)."""
css = _css()
body = _single_line_rule(css, ".char-count")
assert "text-align: right;" in body, (
"the counter right-aligns above the composer (house layout)"
)
assert "color: var(--ink-soft);" in body
is_max = _single_line_rule(css, ".char-count.is-max")
assert "color: var(--err-ink);" in is_max, (
"the at/over-cap state must use the --err-* semantic family (B3)"
)
comment = re.search(r"(/\*[^*]*?\*/)\s*\.char-count \{", css)
assert comment, "the .char-count rule must carry its provenance comment"
note = comment.group(1)
assert "8.6:1" in note, (
"the --ink-soft on --bg ratio must be recorded (verified "
"8.6:1 ≥ 4.5:1, WCAG AA)"
)
assert "10.4:1" in note, (
"the --err-ink on --bg ratio must be recorded (verified "
"10.4:1 ≥ 4.5:1, WCAG AA)"
)
+23 -10
View File
@@ -312,12 +312,13 @@ def test_chat_bottom_unit_is_last_child_of_the_chat_shell() -> None:
"""Phase 65 (task 02, owner-locked A1): the LAST element child of
`.chat-shell` is the `.chat-bottom` wrapper — NO id (nothing in JS
binds it; the bindings live on the inner elements, the move is pure
HTML/CSS) — holding exactly the `.chat-actions` row and then the
`#composer` form, in that order: the row + composer are ONE sticky
unit, and the wrapper owns the shell's bottom slot, so the sticky
shift range is still that column's box (a sibling after it would
carve the range away and re-break the pin). The composer form keeps
`novalidate` and its contract ids."""
HTML/CSS) — holding the `.chat-actions` row, the phase-104
`#char-count` counter, and the `#composer` form, in that order: the
row + counter + composer are ONE sticky unit, and the wrapper owns
the shell's bottom slot, so the sticky shift range is still that
column's box (a sibling after it would carve the range away and
re-break the pin). The composer form keeps `novalidate` and its
contract ids."""
shell = _tree().find("chat-shell")
last = shell["children"][-1]
assert last["tag"] == "div" and (
@@ -330,17 +331,29 @@ def test_chat_bottom_unit_is_last_child_of_the_chat_shell() -> None:
"elements"
)
kids = last["children"]
assert len(kids) == 2, (
"the unit holds exactly two element children: .chat-actions, then "
"#composer"
assert len(kids) == 3, (
"the unit holds exactly three element children: .chat-actions, "
"then #char-count (phase 104), then #composer"
)
row, form = kids
row, counter, form = kids
assert row["tag"] == "div" and (
row["attrs"].get("class") or ""
).split() == ["chat-actions"], (
"the first child is the .chat-actions row (the New chat → Share "
"DOM order is pinned by test_save_chat_ui.py)"
)
# Phase 104 (owner 2026-09-12): the question-length counter — a
# hidden-by-default <p> between the row and the composer (zero
# height while hidden; a new child INSIDE the unit breaks no pin —
# the sticky geometry below is untouched).
assert counter["tag"] == "p" and counter["attrs"].get("id") == "char-count", (
"the second child is the phase-104 #char-count counter"
)
assert (counter["attrs"].get("class") or "") == "char-count"
assert "hidden" in counter["attrs"], (
"the counter ships hidden — it appears only from 80% of the "
"4,000-char cap (app.js updateCharCount)"
)
assert form["tag"] == "form" and form["attrs"].get("id") == "composer"
assert "novalidate" in form["attrs"], (
"phase 48: the composer form stays `novalidate` (a `required` "
+20 -7
View File
@@ -695,8 +695,10 @@ def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
phase 65 (2026-09-01, ``TODO.md`` L3, owner confirmation) moved it
from the top of the column to the bottom: below the ``#messages``
section, directly above the composer; nothing but the row's own
comment lands between ``#messages`` and the row, and nothing but
the composer comment lands between the row and the composer. No
comment lands between ``#messages`` and the row, and nothing but the
phase-104 ``#char-count`` counter + the composer comment lands
between the row and the composer (the counter is hidden by default —
zero height, the pinned-cluster geometry untouched). No
other page carries ``.chat-actions`` (chat-page only, like the
pills)."""
html = _index()
@@ -730,12 +732,23 @@ def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
and "<form" not in between
), ("nothing but the row's comment lands between #messages and the row")
after = html[end:composer_idx]
# Phase 104 (owner 2026-09-12): the ONE permitted child between the
# row and the composer is the hidden-by-default question-length
# counter — everything else (ids, buttons, sections, forms) is
# still excluded from the gap.
after_minus_counter = after.replace(
'<p class="char-count" id="char-count" hidden></p>', ""
)
assert (
"id=" not in after
and "<button" not in after
and "<section" not in after
and "<form" not in after
), ("nothing but the composer comment lands between the row and the composer")
after.count("<p") == 1
and "id=" not in after_minus_counter
and "<button" not in after_minus_counter
and "<section" not in after_minus_counter
and "<form" not in after_minus_counter
), (
"nothing but the phase-104 counter + the composer comment lands "
"between the row and the composer"
)
# Phase 76 (task 02): the folded view files are gone (the shell's
# chat view is the one and only carrier of the row — pinned above);
# the standalone pages carry none (task 03 dropped the last folded
+26
View File
@@ -15,6 +15,7 @@ from pydantic import ValidationError
from app.schemas import (
ChatMessage,
ChatRequest,
SavedChatCreate,
SavedChatUpdate,
SourceRef,
@@ -34,6 +35,7 @@ TITLE_CAP = 500 # documents.title String(500)
NAME_CAP = 100 # ToolCall.name
ARGUMENT_CAP = 2000 # ToolCall.argument
MESSAGES_CAP = 200 # SavedChatCreate/Update.messages
QUESTION_CAP = 4_000 # ChatRequest.message — the cap the composer mirrors (phase 104)
def _source_ref() -> dict:
@@ -174,6 +176,30 @@ def test_chat_message_tools_one_over_cap_rejects() -> None:
_failed_loc(exc.value, "tools")
# ---------------------------------------------------------------------------
# ChatRequest.message (the 4,000-char question cap — phase 104, A3)
# ---------------------------------------------------------------------------
def test_chat_request_message_at_cap_validates() -> None:
"""EXACTLY 4,000 chars passes — the cap admits "a code example of a
few dozen lines" (owner A3) and the composer's maxlength + counter
mirror this exact boundary (the 4,000/4,000 E2E submit must not
422)."""
req = ChatRequest.model_validate({"message": "a" * QUESTION_CAP})
assert len(req.message) == QUESTION_CAP
def test_chat_request_message_one_over_cap_rejects() -> None:
"""4,001 chars is the 422 the UI now prevents blind: the server cap
stays the backstop (pre-existing, untouched — this phase only pins
it at the boundary, phase-83 pattern), naming ``message`` in the
error loc."""
with pytest.raises(ValidationError) as exc:
ChatRequest.model_validate({"message": "a" * (QUESTION_CAP + 1)})
_failed_loc(exc.value, "message")
# ---------------------------------------------------------------------------
# SourceRef (documents column-length mirrors)
# ---------------------------------------------------------------------------