diff --git a/.agents/phases/todo/159_edit_and_resend_message/00_phase.md b/.agents/phases/complete/159_edit_and_resend_message/00_phase.md similarity index 100% rename from .agents/phases/todo/159_edit_and_resend_message/00_phase.md rename to .agents/phases/complete/159_edit_and_resend_message/00_phase.md diff --git a/.agents/phases/todo/159_edit_and_resend_message/01_edit_affordance_on_user_bubbles.md b/.agents/phases/complete/159_edit_and_resend_message/01_edit_affordance_on_user_bubbles.md similarity index 100% rename from .agents/phases/todo/159_edit_and_resend_message/01_edit_affordance_on_user_bubbles.md rename to .agents/phases/complete/159_edit_and_resend_message/01_edit_affordance_on_user_bubbles.md diff --git a/.agents/phases/todo/159_edit_and_resend_message/02_resend_in_place.md b/.agents/phases/complete/159_edit_and_resend_message/02_resend_in_place.md similarity index 100% rename from .agents/phases/todo/159_edit_and_resend_message/02_resend_in_place.md rename to .agents/phases/complete/159_edit_and_resend_message/02_resend_in_place.md diff --git a/.agents/phases/todo/159_edit_and_resend_message/03_editable_error_turn.md b/.agents/phases/complete/159_edit_and_resend_message/03_editable_error_turn.md similarity index 100% rename from .agents/phases/todo/159_edit_and_resend_message/03_editable_error_turn.md rename to .agents/phases/complete/159_edit_and_resend_message/03_editable_error_turn.md diff --git a/.agents/phases/todo/159_edit_and_resend_message/04_edit_resend_tests.md b/.agents/phases/complete/159_edit_and_resend_message/04_edit_resend_tests.md similarity index 100% rename from .agents/phases/todo/159_edit_and_resend_message/04_edit_resend_tests.md rename to .agents/phases/complete/159_edit_and_resend_message/04_edit_resend_tests.md diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__00_phase.a1.err b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__00_phase.a1.md b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__00_phase.a1.md new file mode 100644 index 0000000..c683b73 --- /dev/null +++ b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__00_phase.a1.md @@ -0,0 +1,22 @@ +All completion criteria verified. Final report: + +**Phase 159 — Edit the last question and re-ask it: VERIFIED COMPLETE** (all 4 tasks already in `complete/`; this pass verified end-to-end) + +**Verified (implementation landed + tests green):** +- `app.js`: `EDIT_ICON`, `lastUserWrap`, `appendEditButton`/`markLastEditable` (single-target discipline, in-flight guard), `openEditor` (labelled textarea, `maxlength=4000`, Escape/Ctrl+⌘+Enter, empty guard), save path: replace → splice tail → DOM removal → `lastBrainWrap=null` → `saveConversation`+`persistConversation` → `runTurn(text,{reask:true})` — matches README exactly +- `styles.css`: `.edit-btn`/`.edit-form`/`.edit-error`/`.is-editing` + mobile 44px; unit pins 40, E2E 7, integration `test_chats_api.py` extended + +**Test/lint results (all green):** +- `uv run pytest` — 3,661 tests, exit 0 +- `uv run pytest --cov=app --cov-report=term-missing` — TOTAL **99%** (>90%) +- `uv run ruff check . && uv run pyright` — all checks passed; 0 errors +- `uv run pytest tests/e2e/test_edit_and_resend.py -v --no-cov` (DB up) — **7/7 passed** +- Regressions in isolation: `test_retry_answer.py` 4/4, `test_failed_turn_retry.py` 4/4, `test_chat_persistence.py` 4/4 + +**Completion criteria:** all 7 ✓ — Edit on last bubble + prefill + Escape/Cancel restore (E2E #3); replace+truncate+re-ask with no duplicated question (E2E #1); refresh survival (E2E #4); failed/“question too long” turn editable + trim re-asks (E2E #5); earlier bubbles un-editable + byte-identical Retry/Stop/persistence (E2E #2 + regressions + unit pins); suite/coverage/lint ✓; E2E + regressions isolated ✓. Commit + phase-dir move: harness's job (no git ops by me, per protocol). + +**Documentation:** README.md — full review; phase 159 section cross-checked against `app.js` source (cap, keys, truncate, image survival) — consistent, no changes needed; all 30 `.env.example` vars documented. AGENTS.md — full review; all rules/quick-ref commands valid — verified consistent, no changes needed. + +**Notable:** none — no defects found in prior work; no live-infrastructure changes required. + +**Next pending phase:** `160_tool_budget_telemetry` diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__00_phase.a1.validate b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__00_phase.a1.validate new file mode 100644 index 0000000..e6825da --- /dev/null +++ b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__00_phase.a1.validate @@ -0,0 +1,122 @@ +........................................................................ [ 1%] +........................................................................ [ 3%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +............................................................. [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 764 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 466 0 100% +-------------------------------------------------- +TOTAL 5742 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__01_edit_affordance_on_user_bubbles.a1.err b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__01_edit_affordance_on_user_bubbles.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__01_edit_affordance_on_user_bubbles.a1.md b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__01_edit_affordance_on_user_bubbles.a1.md new file mode 100644 index 0000000..42461fd --- /dev/null +++ b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__01_edit_affordance_on_user_bubbles.a1.md @@ -0,0 +1,22 @@ +**Task 159.01 complete — report** + +**Implemented** +- `EDIT_ICON` (aria-hidden pencil, RETRY_ICON stroke family) + `appendEditButton` (joins `.msg-actions` via `getActionsRow`, listitem, "Edit" label, not admin-gated) +- `lastUserWrap` (mirror of `lastBrainWrap`: runTurn fresh-send, restore user branch, cleared by `startNewChat`) + `lastUserRecordIndex()` newest-first scan + `markLastEditable()` (remove-then-append single target, in-flight guard, record/wrap bails) — paired at all six `markLastRetryable()` sites +- `openEditor` (openTuneForm family): labelled `textarea[maxlength=MAX_QUESTION_CHARS]` rows=3 prefilled via `.value`, composer placeholder, Save/Cancel, `role=alert` `.edit-error`; `.is-editing` hides the bubble; Escape/Cancel restore + refocus; Ctrl/Cmd+Enter submits; in-flight save no-op; empty-trim rejected in-form; save = replace-in-place + `saveConversation()` + `renderMarkdown` re-render (attachment survives); task-02 seam marked at the close +- CSS: `.edit-btn` pill family (+ hover + mobile 44px), `.edit-form` card family, `.edit-save/.edit-cancel`, `.edit-error` (–err-* tokens), `.msg.user .bubble.is-editing`; global `:focus-visible` verified unscoped +- Verified E2E-style (scratch Playwright, since deleted): Edit at rest, Cancel/Escape byte-identical, empty guard, Ctrl+Enter save, localStorage update, reload restore, button moves on new send + +**Gates** +- `uv run pytest --cov=app --cov-report=term-missing` → **3641 passed, 0 failed, TOTAL 99%** (>90%) +- `uv run ruff check . && uv run pyright` → clean (0 errors) +- `uv run pytest tests/e2e/test_scratch_edit_manual_check.py --no-cov` (scratch, deleted) → green + +**Docs** — README: new "Edit the newest question" paragraph in the Chat section. AGENTS.md: no impact (no new conventions). + +**Notable decisions** +- `markLastEditable()`'s in-flight guard no-ops at the four mid-turn paired sites (uiState still streaming/thinking there) — added the one sanctioned 7th call in `runTurn`'s `finally` after the idle settle, so the button lands at rest (documented in-code; task 03 verifies the error-path pairs end-to-end) +- Stale-click guard `wrap !== lastUserWrap` in `openEditor` (phase-49 discipline) — a superseded button is inert +- Updated two stale prior-phase tail-pins (phase 120/130 `finalizeFailedTurn` now ends `markLastRetryable()` + `markLastEditable()`); scoped two over-broad assertions (`bubble.innerHTML` count → typing-clock functions; 900-char finally window → brace-matched); renamed local `saveBtn`→`editSaveBtn` (house `tuneSaveBtn` precedent); per-file E501 ignore for the 32KB `runTurn` byte-pin file (pyproject, commented) + +**Next pending task:** `02_resend_in_place.md` (phase 159) diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__01_edit_affordance_on_user_bubbles.a1.validate b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__01_edit_affordance_on_user_bubbles.a1.validate new file mode 100644 index 0000000..f763d9c --- /dev/null +++ b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__01_edit_affordance_on_user_bubbles.a1.validate @@ -0,0 +1,122 @@ +........................................................................ [ 1%] +........................................................................ [ 3%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +......................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 764 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 466 0 100% +-------------------------------------------------- +TOTAL 5742 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__01_edit_affordance_on_user_bubbles.a2.err b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__01_edit_affordance_on_user_bubbles.a2.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__02_resend_in_place.a1.err b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__02_resend_in_place.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__02_resend_in_place.a1.md b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__02_resend_in_place.a1.md new file mode 100644 index 0000000..a5f886a --- /dev/null +++ b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__02_resend_in_place.a1.md @@ -0,0 +1,8 @@ +**Task 02 complete — report** + +- **Implemented (frontend-only, no `app/` change):** `openEditor`'s submit handler now does Save = replace + truncate + re-ask: `conversation.splice(editIdx + 1)` drops the whole tail after the in-place record replace; DOM removal walks `lastUserWrap.nextSibling` onward; `lastBrainWrap = null` + `leavePartialIndex` reset to -1 (gated on `> editIdx`); `saveConversation()` + fire-and-forget `persistConversation()` persist the truncated shape; `markLastRetryable()` re-targets (clears); then `closeEditor()`, the double in-flight check on one synchronous stretch (no `await`), and `return runTurn(text, { reask: true })` — no image (A7 text-only; the record/bubble keep the attachment). Guards in order: in-flight no-op, empty trim. Composer untouched. +- **Tests:** `tests/unit/test_frontend_edit_message.py` — replaced the task-01 seam pin with a full-flow-order pin + 7 new pins (splice, DOM walk, bookkeeping resets, save+persist-before-reask, retry re-target, exact `runTurn(text, { reask: true })` no-image, synchronous-stretch double-check, composer untouched); `test_frontend_hidden_tab.py` — the phase-73 `leavePartialIndex` pin updated for the sanctioned third gated -1 write; `tests/integration/test_chats_api.py` — 2 new pins (edited + truncated tail PUT round-trips byte-identically through body/raw JSONB/GET; the `image` key survives the in-place edit). +- **Results:** `uv run pytest --cov=app` → **3651 passed, TOTAL 99%**; `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors; `node --check app.js` → OK; regression E2E in isolation: `test_retry_answer.py` 4 passed, `test_failed_turn_retry.py` 4 passed, `test_chat_persistence.py` 4 passed. `retryLastTurn` / `runTurn` byte-pins unchanged (task 02 touches neither). +- **Docs:** README's phase-159 paragraph updated (save now drops the tail and re-asks — a fresh answer streams in the old answer's place, no duplication, attachment survives); no AGENTS.md impact. +- **Decisions:** the pre-re-ask in-flight re-check is explicit (the stretch is synchronous, so it's defensive documentation of the invariant, per the task's double-check wording); `editIdx` reuses task 01's `lastUserRecordIndex()` scan in the closure (identical value). +- **Next pending task:** `03_editable_error_turn.md`. diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__02_resend_in_place.a1.validate b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__02_resend_in_place.a1.validate new file mode 100644 index 0000000..a3e4ebc --- /dev/null +++ b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__02_resend_in_place.a1.validate @@ -0,0 +1,122 @@ +........................................................................ [ 1%] +........................................................................ [ 3%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 764 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 466 0 100% +-------------------------------------------------- +TOTAL 5742 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__03_editable_error_turn.a1.err b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__03_editable_error_turn.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__03_editable_error_turn.a1.md b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__03_editable_error_turn.a1.md new file mode 100644 index 0000000..4669a0b --- /dev/null +++ b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__03_editable_error_turn.a1.md @@ -0,0 +1,18 @@ +All gates verified. Final report: + +**Task 03 complete — the driver case: a failed or rejected question stays editable** + +**Implemented:** +- Verified all 5 pre-existing `markLastEditable()` pairings (finalizeFailedTurn funnel — the 422 + SSE error-frame path, zero-frame fallback, stop finalize, done settle) + both restore pairs; the 300s-guard `aborted` catch branch settled with **no** mark pair → added `markLastRetryable(); markLastEditable();` there (timed-out turn no longer strands the question; finally idle-settle re-run stays harmless via remove-then-append) +- Verified the guard pins: `markLastEditable` bails only on `thinking`/`streaming` (never `error`/`idle`); the save handler's double guard is byte-identical +- Confirmed the failed `{failed: true}` record is dropped (single-arg splice + DOM walk + `lastBrainWrap = null` before re-ask) and added the one-line truncation-ordering comment (new turn's `rememberBrainTurn` sees the post-splice array) +- Confirmed `?chat=` admin restore is an editable working copy (mirrors localStorage, links `currentChatId`) — **no read-only render path exists; the assumption holds, no gate added** (finding recorded) +- `showErrorBanner` untouched — its `opts.retryable && lastBrainWrap` condition is byte-pinned via the shared phase-120 pin + +**Tests:** extended `tests/unit/test_frontend_edit_message.py` — 7 new task-03 tests (guard states, save-guard mirror, banner byte-pin, failed-record-dropped, ordering-note pin, aborted-settle pair, restore paths) + updated the 8-call/7-paired counts and `PINNED_RUN_TURN` + +**Results:** `uv run pytest` → exit 0, 100% dots, zero failures · `--cov=app` → TOTAL 99% (>90% gate) · `uv run ruff check .` → All checks passed · `uv run pyright` → 0/0/0 · manual driver check (mock LLM + Playwright): error-frame turn → Edit+Retry, trim+Save streams a **grounded** answer; 422 >4000-char turn → same; refresh restores with working Edit — all PASS + +**Docs:** no doc impact (README's phase-159 paragraph already covers the "failed turn fixed by trimming in the editor" behavior) + +**Next pending task:** `.agents/phases/todo/159_edit_and_resend_message/04_edit_resend_tests.md` diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__03_editable_error_turn.a1.validate b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__03_editable_error_turn.a1.validate new file mode 100644 index 0000000..b390d57 --- /dev/null +++ b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__03_editable_error_turn.a1.validate @@ -0,0 +1,122 @@ +........................................................................ [ 1%] +........................................................................ [ 3%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +.......................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 764 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 466 0 100% +-------------------------------------------------- +TOTAL 5742 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__03_editable_error_turn.a2.err b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__03_editable_error_turn.a2.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__03_editable_error_turn.a2.md b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__03_editable_error_turn.a2.md new file mode 100644 index 0000000..d8a52f2 --- /dev/null +++ b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__03_editable_error_turn.a2.md @@ -0,0 +1,17 @@ +Docs are consistent — the README's phase-159 paragraph (L103–114) already covers the task-03 driver-case behavior, and AGENTS.md needs no phase-159 changes (the Edit button joins the existing `.msg-actions` row; no new conventions). Final report: + +**Task 03 complete — the driver case: a failed or rejected question stays editable** + +**Verified/implemented (this pass re-runs all gates after the process-rejection):** +- All 5 non-`done` settle pairings + both restore pairs verified (422 non-OK-HTTP + SSE `error`-frame path → `finalizeFailedTurn` funnel with `markLastEditable()` as the very next statement; zero-frame fallback; stop finalize; done settle) +- The 300s-guard `aborted` catch branch settled with no mark pair → added `markLastRetryable(); markLastEditable();` there (a timed-out turn no longer strands the question) +- Guard pins verified: `markLastEditable` bails only on `thinking`/`streaming` (never `error`/`idle`); the save handler's double guard is byte-identical +- Failed `{failed: true}` record is dropped by the single-arg splice + DOM walk; `lastBrainWrap = null` precedes the re-ask; added the one-line truncation-ordering comment (new turn's `rememberBrainTurn` sees the post-splice array) +- `?chat=` admin restore confirmed an editable working copy — no read-only path exists, assumption holds, no gate added; `showErrorBanner` byte-unchanged (shared phase-120 pin) +- Extended `tests/unit/test_frontend_edit_message.py` with 7 new task-03 tests; updated the 8-call/7-paired counts and `PINNED_RUN_TURN` + +**Results:** `uv run pytest` → exit 0, zero failures (all-dots progress) · `--cov=app` → TOTAL 99% (>90% gate) · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors/0 warnings · manual driver check (mock LLM + Playwright): error-frame + 422 turns → Edit + trim + Save streams a grounded answer; refresh restores with a working Edit — all PASS + +**Docs:** README/AGENTS.md consistency check re-run — both accurate, no changes needed + +**Next pending task:** `.agents/phases/todo/159_edit_and_resend_message/04_edit_resend_tests.md` diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__03_editable_error_turn.a2.validate b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__03_editable_error_turn.a2.validate new file mode 100644 index 0000000..b390d57 --- /dev/null +++ b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__03_editable_error_turn.a2.validate @@ -0,0 +1,122 @@ +........................................................................ [ 1%] +........................................................................ [ 3%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +.......................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 764 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 466 0 100% +-------------------------------------------------- +TOTAL 5742 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__04_edit_resend_tests.a1.err b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__04_edit_resend_tests.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__04_edit_resend_tests.a1.md b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__04_edit_resend_tests.a1.md new file mode 100644 index 0000000..62e5910 --- /dev/null +++ b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__04_edit_resend_tests.a1.md @@ -0,0 +1,19 @@ +**Report — phase 159, task 04 (edit-and-resend tests)** + +**Implemented** +- Unit: `tests/unit/test_frontend_edit_message.py` — added the task-04 regression pins `PINNED_HANDLE_SEND` + `PINNED_REMEMBER_BRAIN_TURN` (byte-unchanged, "NOT touched" contract) with two tests; docstring extended to tasks 01–04 (file now 40 tests; tasks 01–03 had already pinned the icon/button/markLastEditable/openEditor/save-flow/CSS clusters and the runTurn@phase-159 state) +- Integration: `tests/integration/test_chats_api.py` — added `test_shared_serve_carries_the_edited_truncated_shape` (case b: post-edit shape on a shared chat serves byte-identically through `SharedChatOut.messages`; case a already existed from task 02) +- E2E: new `tests/e2e/test_edit_and_resend.py` — 7 mock-only tests (re-ask in place + row poll, single-target discipline, Escape no-op with focus return + byte-identical localStorage, refresh survival, the `always fail` driver case with the real-endpoint-only "Question too long" docstring note, in-flight inertness, composer draft survival) on the phase-120 template (TRUNCATE-then-seed, `auth_helpers.login`, `homelab/kubernetes.md` seed) + +**Results (all green)** +- `uv run pytest` (unit + integration): 3661 passed +- `uv run pytest --cov=app --cov-report=term-missing`: TOTAL 99% (>90%) +- `uv run pytest tests/e2e/test_edit_and_resend.py -v --no-cov`: 7 passed in isolation +- Regressions in isolation: `test_retry_answer` 4, `test_failed_turn_retry` 4, `test_chat_persistence` 4, `test_stop_generation` 3, `test_smoke` 3 — all passed +- `uv run ruff check . && uv run pyright`: clean (0 errors, 0 warnings) + +**Docs** — no doc impact from this task: README's phase-159 Edit paragraph was already added by earlier tasks; AGENTS.md unchanged. + +**Decisions** — the saved-chat row is compared to localStorage key-for-key (`_shape_matches`), not raw-equal: the DB stores the schema-normalized shape (explicit `None` keys, the `_expect` convention) while localStorage keeps raw client records. + +**Next pending task** — none in phase 159 (04 was the last); phase final validation (regression gate + atomic commit + dir move) is the harness's. diff --git a/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__04_edit_resend_tests.a1.validate b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__04_edit_resend_tests.a1.validate new file mode 100644 index 0000000..e6825da --- /dev/null +++ b/.agents/reports/159_edit_and_resend_message/159_edit_and_resend_message__04_edit_resend_tests.a1.validate @@ -0,0 +1,122 @@ +........................................................................ [ 1%] +........................................................................ [ 3%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 9%] +........................................................................ [ 11%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 98%] +............................................................. [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 270 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 196 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 204 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 266 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 135 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 77 0 100% +app/rag/importer.py 764 4 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 192 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 58 0 100% +app/schemas.py 466 0 100% +-------------------------------------------------- +TOTAL 5742 17 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/README.md b/README.md index a47dd1c..b09f01d 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,20 @@ origin the browser rejects the clipboard, so the click instead offers the full path in an inline fallback field that selects itself on focus — click or Tab, then copy (phase 153). +**Edit** the newest question (phase 159): the latest user bubble carries +an **Edit** button that swaps the bubble for an inline textarea +prefilled with the question (the 4,000-character cap is enforced with +`maxlength`; **Ctrl/⌘+Enter** submits, **Esc** / **Cancel** restore the +original byte-identical, and a whitespace-only save is rejected inline). +Saving rewrites the question in the chat and the saved chat in place, +drops every turn that followed it, and re-asks the trimmed question — +a fresh answer streams in the old answer's place (the question is +never duplicated; an attachment on the question survives the edit and +the re-ask stays text-only). A turn that failed with "Question too +long — trim it and re-ask" (or a mistyped question) is fixed by +trimming it in the editor instead of retyping it. Earlier bubbles +never carry the button (last-question-only scope, owner-locked). + ### RAG / Knowledge base (`/sources.html`) The indexed document list. The *Path* column opens each document in the same diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 8b07a20..9480d0c 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -600,12 +600,33 @@ function appendFailedNote(wrap, detail) { * key off it. */ let lastBrainWrap = null; +/* Phase 159 (task 01, TODO.md L3): the rendered wrap of the CURRENT + * last USER record — the exact mirror of lastBrainWrap (one `who` + * over). Assigned exactly where a user bubble becomes the newest + * question (runTurn's fresh-send addMessage — the reask path keeps + * the existing bubble, so no assignment there — and the phase-14 + * restore's user branch, where the LAST restored user bubble wins), + * and cleared by startNewChat. Both markLastEditable's targeting and + * openEditor's stale-click guard key off it (the phase-49 + * single-target discipline). Task 02 reuses the variable for the tail + * truncation. */ +let lastUserWrap = null; + /* The Retry button's redo glyph (aria-hidden decoration — the "Retry" * text carries the accessible name), currentColor so the CSS themes the * stroke (ink-soft → ink on hover, the phase-08 palette). */ const RETRY_ICON = ''; +/* Phase 159 (task 01, TODO.md L3): the Edit button's pencil glyph + * (aria-hidden decoration — the visible "Edit" label carries the + * accessible name). Same stroke family as RETRY_ICON: fill="none", + * currentColor stroke (the CSS themes it — ink-soft → ink on hover, + * the phase-08 palette), 24×24 viewBox, stroke-width 1.7. No icon + * font, no CDN (A11). */ +const EDIT_ICON = + ''; + /* "Retry" button in the last brain bubble's actions row — the redo * action of phase 49 (owner-locked 2026-08-29, TODO.md L4). The house * pattern through getActionsRow (phase 130 task 02 — the role=list @@ -627,6 +648,30 @@ function appendRetryButton(wrap) { meta.appendChild(btn); } +/* "Edit" button in the last USER bubble's actions row (phase 159, + * task 01, TODO.md L3 — the trim-and-re-ask remedy: a "Question too + * long — trim it and re-ask" turn, or any mistyped / half-formed + * question, is fixed by editing, never by retyping). The house + * pattern through getActionsRow (phase 130 task 02 — the role=list + * row joins the button as a listitem so ARIA stays valid); one + * button per bubble. The visible "Edit" label carries the accessible + * name (no ellipsis clipping needed at this width). Click → + * openEditor(wrap, btn). NOT admin-gated, unlike appendTuneButton — + * chat is public, so every visitor who can chat can edit the last + * question (the phase-49 Retry precedent). */ +function appendEditButton(wrap) { + const meta = getActionsRow(wrap); + if (!meta) return; + if (meta.querySelector(".edit-btn")) return; // one per bubble + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "edit-btn"; + if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem"); + btn.innerHTML = EDIT_ICON + "Edit"; + btn.addEventListener("click", () => openEditor(wrap, btn)); + meta.appendChild(btn); +} + /* Last-bubble-only management (owner-locked 2026-08-29): the Retry * button lives on exactly ONE bubble — the last brain answer. Remove * every rendered .retry-btn FIRST (an earlier bubble's button is stale @@ -662,6 +707,44 @@ function markLastRetryable() { appendRetryButton(lastBrainWrap); } +/* Phase 159 (task 01, TODO.md L3): the Edit button's single-target + * management — the sibling of markLastRetryable (the phase-49 + * discipline, one `who` over): exactly ONE editable bubble, the + * NEWEST question (owner-locked 2026-10-05; earlier bubbles are + * never editable, so no mid-conversation branch exists and the + * "what happens to the turns after it" question never arises for an + * old message). Remove every rendered .edit-btn FIRST (an older + * bubble's button is stale the moment a newer question lands), then + * bail while a turn is in flight (the retryLastTurn guard — the + * button never appears or moves mid-turn; runTurn's idle settle + * re-runs this at rest), then scan `conversation` NEWEST-FIRST for + * `who === "user"` through lastUserRecordIndex() (ASSUMPTION: the + * index comes from the record scan — the markLastRetryable + * last-brain-index rule, one `who` over — NOT from a DOM↔record + * index map: the DOM and the record list are parallel today, but + * re-deriving the index from the record keeps the editor correct + * after the task-02 truncation), and bail on -1 or a missing + * lastUserWrap — the wrap is assigned exactly where a user bubble + * is created (runTurn's fresh send, the restore's user branch) and + * cleared by startNewChat, so a stale wrap can never carry the + * button. startNewChat needs no call: its list reset removes the + * buttons along with the list (the phase-49 reasoning). Task 02 + * reuses lastUserRecordIndex() for the truncation. */ +function lastUserRecordIndex() { + for (let i = conversation.length - 1; i >= 0; i -= 1) { + if (conversation[i].who === "user") return i; + } + return -1; +} + +function markLastEditable() { + messagesEl.querySelectorAll(".edit-btn").forEach((b) => b.remove()); + if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return; + if (lastUserRecordIndex() === -1) return; // no user record — nothing to edit + if (!lastUserWrap) return; // no tracked bubble — a stale wrap never carries the button + appendEditButton(lastUserWrap); +} + /* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right * "Save as doc" action of EVERY completed brain bubble (deflected * included — same scope as Tune; a stopped partial is a note, not an @@ -940,6 +1023,212 @@ function openTuneForm(wrap, toggleBtn) { form.querySelector("textarea").focus(); } +/* Phase 159 (task 01, TODO.md L3): the INLINE editor in the bubble — + * the openTuneForm family (owner-locked 2026-10-05: editing happens + * IN the bubble, not the composer — the composer keeps its own draft + * untouched by editing). A labeled textarea (rows 3, maxlength = + * MAX_QUESTION_CHARS — the 4000 cap stays single-sourced) prefilled + * from the record's text (through .value — never an HTML string, the + * house no-injection rule), Save (submit) + Cancel, and a + * role=alert .edit-error line. While open the rendered .bubble is + * hidden (.is-editing — the question appears ONCE); Cancel / Escape + * restore it and return focus to the toggle button (the openTuneForm + * cancel rule); Ctrl/Cmd+Enter submits. One editor at a time. + * Stale-click guard (the phase-49 discipline): the click's wrap must + * still be lastUserWrap — a superseded bubble's button is inert. + * + * Save (task 02 — the resend): guards first, in order — the + * in-flight no-op (a save can never start a second turn) and the + * empty trim (the .edit-error line, the form stays open — + * ChatRequest.message / ChatMessage.text both require min_length=1; + * the >4000 case is unreachable: the textarea carries the + * maxlength). Then: the trimmed text REPLACES the record in place + * (conversation[editIdx].text); the TAIL TRUNCATION drops every + * record the OLD question produced (conversation.splice(editIdx + 1) + * — stale by construction, never rewritten — the phase-114 + * whole-turn rule), the matching DOM bubbles are removed (every + * .msg after lastUserWrap), and the turn bookkeeping resets + * (lastBrainWrap = null; leavePartialIndex cleared to -1 when it + * pointed past editIdx — a partial saved from the dropped turn + * must not be re-persisted into the new shape). The bubble text + * re-renders through renderMarkdown (the addMessage renderer — the + * markdown rules stay in one place); the attachment (if the + * question carried one) survives the re-render (the img is + * re-prepended — phase 123's one bubble-image position). The + * truncated shape persists — saveConversation() (localStorage, + * trimToBudget rides it unchanged) + persistConversation() (the + * phase-55 auto-save — fire-and-forget, never blocks the turn; a + * shortened messages array is already legal — no new field, no + * migration, A10 stays stateless). markLastRetryable() re-targets: + * no brain record exists after the drop, so the call simply clears + * every .retry-btn (the re-ask's settle re-appends it on the fresh + * bubble). The editor closes, the in-flight guard re-checks (the + * handler is ONE synchronous stretch — no await between the guard + * and the call), and runTurn(text, { reask: true }) re-asks: reask + * skips the user append + save point 1 (the edited question is the + * LAST record, so history = conversation.slice(0, -1) is exactly + * the prior turns and the question is never duplicated), and NO + * image is passed (the phase-49/123 locked A7 rule: a re-ask sends + * text only — the record keeps its image key, the bubble keeps the + * attachment). The promise is returned (the retryLastTurn + * convention) — runTurn never rejects; a failure lands in the + * phase-120 failed-turn path. The composer is never touched (the + * unsent draft survives the edit — input.value / autoGrow / + * updateCharCount stay untouched). The visible re-render is the + * feedback — no new live region (the #send-status announcer stays + * the sole a11y announcer, the phase-55 convention). */ +let editSeq = 0; // unique ids for one open edit form's inputs + +function openEditor(wrap, toggleBtn) { + if (wrap !== lastUserWrap) return; // stale click — the button moved on + document.querySelectorAll(".edit-form").forEach((f) => f.remove()); // one at a time + const body = wrap.querySelector(".msg-body"); + if (!body) return; + const editIdx = lastUserRecordIndex(); + if (editIdx === -1) return; + editSeq += 1; + const inputId = `edit-input-${editSeq}`; + const form = document.createElement("form"); + form.className = "edit-form"; + form.noValidate = true; + const label = document.createElement("label"); + label.setAttribute("for", inputId); + label.textContent = "Edit your question"; + const ta = document.createElement("textarea"); + ta.id = inputId; + ta.name = "question"; + ta.setAttribute("rows", "3"); + ta.setAttribute("maxlength", String(MAX_QUESTION_CHARS)); + ta.value = conversation[editIdx].text; // prefill through .value — never an HTML string + if (input && input.placeholder) ta.placeholder = input.placeholder; // the composer's placeholder, reused + form.append(label, ta); + const actions = document.createElement("div"); + actions.className = "edit-form-actions"; + const editSaveBtn = document.createElement("button"); + editSaveBtn.type = "submit"; + editSaveBtn.className = "edit-save"; + editSaveBtn.textContent = "Save"; + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "edit-cancel"; + cancelBtn.textContent = "Cancel"; + actions.append(editSaveBtn, cancelBtn); + form.appendChild(actions); + const status = document.createElement("p"); + status.className = "edit-error"; + status.setAttribute("role", "alert"); + status.hidden = true; + form.appendChild(status); + + const bubble = wrap.querySelector(".bubble"); + function closeEditor() { + form.remove(); + if (bubble) bubble.classList.remove("is-editing"); + toggleBtn.focus(); + } + ta.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + e.preventDefault(); + closeEditor(); // cancel — restore the bubble, return focus to the toggle + } else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + form.requestSubmit(); // Ctrl/Cmd+Enter submits + } + }); + cancelBtn.addEventListener("click", closeEditor); + form.addEventListener("submit", (e) => { + e.preventDefault(); + // In-flight re-check (the in-flight discipline): a save can never + // start a second turn — no-op while a turn is in flight (the + // form stays open; the user can Cancel or wait for the settle). + if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return; + const text = ta.value.trim(); + if (!text) { + // Empty guard: the question can't be empty (the schema's + // min_length=1, both sides) — refused in-form, the form stays + // open (the note is never lost, the openTuneForm failure way). + status.textContent = "Ask something first — the question can't be empty."; + status.hidden = false; + return; + } + // Replace in place (LOCKED): the edited text IS the record from + // this point on. + conversation[editIdx].text = text; + // Truncate the tail (LOCKED — the phase-49 redo-in-place rule, + // one level up: the question is REPLACED instead of kept + // verbatim): every record the OLD question produced is stale by + // construction and is dropped outright (never rewritten — the + // phase-114 whole-turn rule). The edited question is now the + // LAST record — runTurn's history = conversation.slice(0, -1) + // invariant holds unchanged, and the question is never + // duplicated into the history. + // Phase 159 (task 03): this truncation happens BEFORE the re-ask's turn starts, so the new turn's rememberBrainTurn index bookkeeping (the replaceIndex/leavePartialIndex correlation — reset to -1 when it points past editIdx, and again at runTurn's start; trimToBudget only drops HEAD-most records) sees the post-splice array and can never be confused by the drop. + conversation.splice(editIdx + 1); + // The matching DOM bubbles are removed: every .msg after the + // question's bubble (#messages' direct children are the bubbles, + // in order — the empty-state div is a leading child, so the + // trailing siblings are exactly the dropped tail). + let n = lastUserWrap.nextSibling; + while (n) { + const next = n.nextSibling; + n.remove(); + n = next; + } + // Turn bookkeeping resets with the drop: the removed answer is + // no longer a retry target, and the phase-73 pagehide partial + // pointer must not survive the truncation — a partial saved from + // the dropped turn must not be re-persisted into the new shape + // (the settle's identity guard would fall back to an append). + // -1 = none (the safe value; the next runTurn also resets it + // turn-locally). + lastBrainWrap = null; + if (leavePartialIndex > editIdx) leavePartialIndex = -1; + // The bubble text re-renders through renderMarkdown (the + // addMessage renderer — the markdown rules stay in one place); + // the attachment (if any) survives the re-render (re-prepended — + // phase 123's one bubble-image position). + if (bubble) { + const img = bubble.querySelector(".msg-image"); + bubble.innerHTML = renderMarkdown(text); + if (img) bubble.prepend(img); + } + // The truncated shape persists NOW (a crash never resurrects the + // pre-edit text or the stale tail): saveConversation() writes + // localStorage (trimToBudget rides it unchanged) + + // persistConversation() auto-saves the row (the phase-55 path — + // fire-and-forget, never blocks the turn; a shortened messages + // array is already legal — no new field, no migration, A10 + // stays stateless). + saveConversation(); + persistConversation(); + // The Retry button re-targets the (now removed) tail: no brain + // record exists after the drop, so the call simply clears every + // .retry-btn (the re-ask's settle re-appends it on the fresh + // bubble). + markLastRetryable(); + closeEditor(); + // In-flight re-check (the double-check — earned by the handler + // being ONE synchronous stretch from the guard above to this + // call: there is no await in between, so uiState cannot change; + // the re-check keeps the invariant explicit): a save can never + // start a second turn. + if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return; + // Re-ask (LOCKED): reask skips the user-bubble append + the + // persistence save point 1 (the question is already in the DOM + // and in the record — it is never duplicated), and NO image is + // passed (the phase-49/123 locked A7 rule: a re-ask sends text + // only — the record keeps its image key, the bubble keeps the + // attachment). The promise is returned (the retryLastTurn + // convention) — runTurn never rejects; a failure lands in the + // phase-120 failed-turn path. The composer was never touched + // (the unsent draft survives the edit). + return runTurn(text, { reask: true }); + }); + if (bubble) bubble.classList.add("is-editing"); + body.appendChild(form); + ta.focus(); +} + /* ---------- avatar glyphs (phase 08: emoji-free chrome) ---------- * Inline SVG as string constants so the message renderer and the typing * indicator share exactly the same marks. currentColor lets the CSS theme @@ -2260,6 +2549,7 @@ function renderStoredMessage(m) { // instead of the old forced "auto" — noted per the phase-42 task. if (m.who === "user") { const wrap = addMessage("user", renderMarkdown(m.text), true); + lastUserWrap = wrap; // phase 159: the LAST restored user bubble wins (the mirror of the brain line below) // Phase 123 (task 03, TODO L6): the restored record may carry the // question's attached image — `m.image`, the STORED PATH (A5: // never base64; a pre-phase / text-only record has no key at all, @@ -2413,6 +2703,7 @@ function restoreConversation() { currentChatId = record ? record.chatId : null; // phase 55: the link survives reloads for (const m of conversation) renderStoredMessage(m); markLastRetryable(); // phase 49: the restored last brain bubble is retryable + markLastEditable(); // phase 159: the restored last user bubble is editable } /* ---------- saved-chats row link (phase 50; auto-saved since phase 55) ---------- @@ -2480,6 +2771,7 @@ async function restoreSavedChatFromUrl() { conversation = messages; // REPLACES the local conversation (owner-locked) for (const m of conversation) renderStoredMessage(m); markLastRetryable(); // parity with the local restore: Retry on the last brain bubble + markLastEditable(); // phase 159: parity with the local restore: Edit on the last user bubble currentChatId = chatId; // linked: a subsequent Save updates THIS row saveConversation(); // mirror to localStorage — a plain refresh returns here // Phase 53 (task 05): the `stale` flag is server-computed (task 03 — @@ -2887,6 +3179,7 @@ function startNewChat() { if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return; conversation = []; currentChatId = null; // phase 55: unlinked — a fresh row on its first message + lastUserWrap = null; // phase 159: the editable wrap resets with the conversation (buttons die with the list reset below) if (staleBanner) staleBanner.hidden = true; // phase 53: the banner described the cleared conversation clearStoredConversation(); removeTyping(); @@ -3245,6 +3538,7 @@ function finalizeFailedTurn( lastBrainWrap = fwrap; } markLastRetryable(); // the in-bubble Retry lands on the failed bubble + markLastEditable(); // phase 159: the failed turn's question stays editable (the trim-and-re-ask remedy — the settle re-runs at idle) } /* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the chat turn — @@ -3266,12 +3560,13 @@ async function runTurn(text, { reask = false, image = null } = {}) { // text-only send, every re-ask — A7: a redo re-sends the text // only) leaves the bubble and the record byte-identical to // pre-phase. - addMessage( + const userWrap = addMessage( "user", renderMarkdown(text), true, // reveal my message (owner-kept) image ? { src: image.src || image.path, alt: image.alt } : null ); + lastUserWrap = userWrap; // phase 159: the newest question bubble is the editable one // Persistence save point 1: the question is stored the moment it is // sent, so a failed/interrupted turn never loses it. The `image` // key (the stored path) joins the `bor.chat.v1` record only when an @@ -3647,6 +3942,7 @@ async function runTurn(text, { reask = false, image = null } = {}) { appendRelated(wrap, ev.related); // phase 113 — above the actions row appendTuneButton(wrap); // every completed brain bubble is tunable markLastRetryable(); // phase 49: the Retry button is last-bubble-only + markLastEditable(); // phase 159: the done turn's question is editable (the in-flight guard no-ops here — the runTurn idle settle lands it) // Phase 59: the RAW persisted markdown (never the rendered // HTML) — exactly the string rememberBrainTurn stores above, // so a reload (the restore path) offers the identical draft. @@ -3732,12 +4028,24 @@ async function runTurn(text, { reask = false, image = null } = {}) { }, leavePartialIndex); // persist what the user actually saw lastBrainWrap = fwrap; markLastRetryable(); // phase 49: the fallback bubble is retryable too + markLastEditable(); // phase 159: the failed turn's question stays editable (the in-flight guard no-ops here — the runTurn idle settle lands it) appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path } } catch (err) { if (aborted) { // The 300s guard already took the turn to the error state — its // own abort surfaces here, and it is never read as a user stop. + // Phase 159 (task 03): the aborted settle must not strand the + // question — the same mark pair as the other non-`done` settles. + // markLastRetryable re-targets (a timed-out turn persists NO + // brain record — the phase-120 exclusion — so the pair re-lands + // the previous turn's target and clears stale buttons), and + // markLastEditable lands Edit on the question NOW (the guard + // bails only on thinking/streaming — the error state passes; + // the finally idle-settle re-runs it — the remove-then-append + // keeps the double pass harmless). + markLastRetryable(); + markLastEditable(); } else if (stoppedByUser || err?.name === "AbortError") { // Phase 48 (owner-locked): the STOP path — no error banner. When // answer text streamed, keep the partial on screen and persist it @@ -3767,6 +4075,7 @@ async function runTurn(text, { reask = false, image = null } = {}) { }, leavePartialIndex); lastBrainWrap = wrap; // the stopped partial is the prime retry candidate markLastRetryable(); // phase 49: Retry on the stopped partial + markLastEditable(); // phase 159: the stopped turn's question stays editable (the in-flight guard no-ops here — the runTurn idle settle lands it) } // Pre-token / thinking-only stop: persist NOTHING brain-side // (phase-20 convention — the question is already saved on send). @@ -3817,6 +4126,13 @@ async function runTurn(text, { reask = false, image = null } = {}) { cancelStream(res); // the reader lock is released — no unhandled rejection turnAbort = null; // phase 48: the turn's abort owner is spent if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle); + // Phase 159 (task 01): the terminal settle (idle) is the ONE mark + // pass that lands the Edit button at rest — the mid-turn paired + // call sites (done / zero-frame / stop / failed funnel) no-op on + // the in-flight guard, so this re-run at idle is what actually + // appends (a New-chat-mid-turn clears the records + the wrap, so + // this is a no-op there by construction). + markLastEditable(); if (stoppedByUser) sendStatus.textContent = "Answer stopped."; // Phase 143 (TODO L5): the focus-back is a DESKTOP courtesy — on a // coarse pointer (a phone/tablet) focusing the composer pops the diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 32effc5..ef0d50e 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -1027,6 +1027,37 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und .retry-btn svg { width: 14px; height: 14px; display: block; } .retry-btn:hover { background: var(--brand-soft); color: var(--ink); } +/* Phase 159 (task 01, TODO L3): the "Edit" button — the + * trim-and-re-ask action in the .msg-meta.msg-actions row (phase 130) + * of the NEWEST user bubble (markLastEditable — the phase-49 + * single-target discipline, one `who` over). The exact visual family + * of .retry-btn (same pill size/spacing, the global :focus-visible + * ring, >=44px via min-height) so the meta actions read as a set — + * but neutral: ink-soft -> ink on hover (Tune keeps the brand pair). + * The pencil glyph rides currentColor. No per-button auto margin + * (phase 130 — the row's justify right-aligns the cluster). Contrast: + * ink-soft on --bg ~8.6:1, ink ~15:1, hover ink on --brand-soft + * ~15.7:1 — all AA. */ +.edit-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + min-height: 44px; + padding: 0.35rem 0.8rem; + border-radius: 999px; + border: 1px solid var(--line); + background: transparent; + color: var(--ink-soft); + font: inherit; + font-weight: 600; + font-size: 0.82rem; + white-space: nowrap; + cursor: pointer; +} +.edit-btn svg { width: 14px; height: 14px; display: block; } +.edit-btn:hover { background: var(--brand-soft); color: var(--ink); } + /* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the "Save as doc" button — the bottom-right action of every completed brain bubble's meta row (the JS gates: admin + a configured docs repo; this rule @@ -1193,6 +1224,84 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und font-weight: 600; } +/* Phase 159 (task 01, TODO L3): the inline EDIT form in the newest + * user bubble — the .tune-form card family (labeled textarea + + * Save/Cancel, both ≥44px; the textarea's maxlength = the 4000 cap, + * single-sourced from MAX_QUESTION_CHARS in app.js). While open the + * rendered .bubble is hidden (.is-editing) so the question appears + * ONCE; Cancel / Escape restore it and return focus to the toggle. + * Save rejection (role=alert) keeps the form open (the note is never + * lost — the openTuneForm failure way). */ +.msg.user .bubble.is-editing { display: none; } +.edit-form { + display: flex; + flex-direction: column; + gap: 0.5rem; + background: var(--surface); + border: 1px solid var(--brand-soft); + border-radius: var(--radius-sm); + padding: 0.75rem 0.85rem; +} +.edit-form label { + font-size: 0.85rem; + font-weight: 600; + color: var(--ink-soft); +} +.edit-form textarea { + font: inherit; + font-size: 0.9rem; + color: var(--ink); + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + padding: 0.5rem 0.6rem; + resize: vertical; + min-height: 2.6rem; +} +.edit-form-actions { display: flex; gap: 0.5rem; } +.edit-save { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0.4rem 1.1rem; + border: 0; + border-radius: var(--radius-sm); + background: var(--brand); + color: var(--bg); + font: inherit; + font-weight: 700; + cursor: pointer; +} +.edit-save:hover:not(:disabled) { background: var(--brand-hover); } +.edit-save:disabled { opacity: 0.6; cursor: wait; } +.edit-cancel { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0.4rem 1rem; + border-radius: var(--radius-sm); + border: 1px solid var(--line); + background: transparent; + color: var(--ink-soft); + font: inherit; + font-weight: 600; + cursor: pointer; +} +.edit-cancel:hover { background: var(--brand-soft); color: var(--brand-ink); } +/* Inline save rejection (role=alert): err pair ≈9.1:1 — form is kept. */ +.edit-error { + margin: 0 0 0 0.25rem; + background: var(--err-bg); + color: var(--err-ink); + border: 1px solid var(--err-line); + border-radius: var(--radius-sm); + padding: 0.45rem 0.8rem; + font-size: 0.85rem; + font-weight: 600; +} + /* Header panel above the messages: notes newest-first, per-note delete, designed empty state. */ .steering-panel { @@ -5707,6 +5816,7 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und .app-main > .steering-panel { width: calc(100% - 1.8rem); } .tune-btn { min-height: 44px; } .retry-btn { min-height: 44px; } + .edit-btn { min-height: 44px; } /* Phase 27: the tuning page squeezes like the other cards — the row padding tightens; the icon+label actions keep their 44px floor and the note text ellipsizes (min-width: 0). */ diff --git a/pyproject.toml b/pyproject.toml index 9020b0b..913116e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,14 @@ target-version = "py312" [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B", "SIM"] +# The phase-159 task-01 test embeds the FULL byte-pin of app.js's +# ``runTurn`` (32KB, 584 lines — the house byte-pin convention for the +# phase's "NOT touched" contract). The pin must stay byte-identical to +# the JS source, so its long JS comment lines cannot be wrapped — E501 +# is ignored for that file only (the test code itself is wrapped). +[tool.ruff.lint.per-file-ignores] +"tests/unit/test_frontend_edit_message.py" = ["E501"] + [tool.pyright] pythonVersion = "3.12" typeCheckingMode = "standard" diff --git a/tests/e2e/test_edit_and_resend.py b/tests/e2e/test_edit_and_resend.py new file mode 100644 index 0000000..a589c9a --- /dev/null +++ b/tests/e2e/test_edit_and_resend.py @@ -0,0 +1,622 @@ +"""Phase 159 E2E (Playwright): edit the last question and re-ask it. + +Source: ``TODO.md`` L3 — "Need to be able to edit and resend a message. +Got 'Question too long — trim it and re-ask' but I can't trim it +because I can't edit it." Run in isolation (DB must be up: +``podman compose up -d db``): + + uv run pytest tests/e2e/test_edit_and_resend.py -v --no-cov + +MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the failure +shape is the deterministic injection in ``tests/e2e/mock_llm.py`` +(the phase-67 ``always fail`` trigger). The phase-114 "Question too +long — trim it and re-ask." frame takes the IDENTICAL settle path +(this suite's driver case rides it through the mock's +``ALWAYS_FAIL_TRIGGER`` 500s → retry-budget exhaustion → the terminal +``error`` frame → ``finalizeFailedTurn``): the EXACT detail string is +real-endpoint-only (it is the server's embed-input-too-large +short-circuit — ``app/api/chat.py`` — which the mock's embeddings +endpoint never raises), so the mock's 500 exhaustion (the phase-120 +zero-frame shape) is the driver case's stand-in. + +The contract under test (phase 159, owner-locked 2026-10-05): + +* EXACTLY ONE editable bubble — the most recent user message (the + ``markLastRetryable`` single-target discipline, one ``who`` over); + earlier bubbles are never editable. +* The Edit opens an INLINE editor in the bubble (the ``.tune-form`` + family — the textarea prefilled with the question, + ``maxlength`` = the 4000 cap, Save/Cancel, a ``role=alert`` error + line); Cancel/``Escape`` restore the bubble and return focus to the + toggle. +* Save = REPLACE + TRUNCATE + RE-ASK (the phase-49 redo-in-place rule, + one level up): the edited text replaces the record in place, every + record after it is dropped (DOM + ``bor.chat.v1`` + the saved chat + row), and a fresh answer streams from the edited text with + ``runTurn(text, { reask: true })`` — the question is never + duplicated, the composer's unsent draft survives. +* In-flight discipline: no Edit button while a turn runs; the button + lands at the settle. + +KB seed (the phase-120 ``test_failed_turn_retry.py`` direct-seed +pattern, the task's ``docs/homelab/kubernetes.md``): ONE fixture +document — the "How is my Kubernetes cluster set up?" questions +FTS-hit it → HIGH → the grounded path (the re-asked turns stream the +grounded ``MOCK_ANSWER_MARKER`` answer). + +Test → contract mapping: + +1. ``test_edit_last_question_reasks_in_place`` — send, grounded answer + (ONE old brain bubble), the user bubble carries Edit; the editor + prefills the EXACT question; append " in one line" + Save: exactly + ONE user bubble (the edited text), the old answer bubble is gone, + the new answer streams with the marker, ``bor.chat.v1`` holds + ``[user(edited), brain]`` with NO duplicate question, and the + auto-saved ``saved_chats`` row matches the localStorage shape key- + for-key (polled — the fire-and-forget PUTs land in order, the + truncated shape is the final state; the DB stores the + schema-normalized shape, the integration ``_expect`` convention). +2. ``test_only_the_last_question_is_editable`` — two turns: exactly + ONE ``.edit-btn`` exists, on the SECOND user bubble; the first has + none (the single-target discipline, owner-locked). +3. ``test_edit_cancel_is_a_no_op`` — open the editor, ``Escape``: the + bubble text, the record, and the answer are unchanged (the + ``localStorage`` payload is byte-identical before/after), the + ``.edit-form`` is gone, and focus RETURNS to the Edit toggle + (``document.activeElement`` is the ``.edit-btn``). +4. ``test_edit_drops_a_stale_tail_and_survives_refresh`` — after an + edit-save, ``page.reload()``: the edited question + the new answer + restore, the DROPPED answer never reappears (one brain bubble, the + fresh one — the old text is absent), and Edit is back on the + question (the restore's mark pair). +5. ``test_failed_turn_question_is_editable_and_trim_reasks`` — the + driver case: the ``always fail`` trigger settles the turn with the + terminal ``error`` frame + the failed bubble (the phase-120 + zero-frame shape — the phase-114 "question too long" frame takes + this identical settle path, its exact detail string is + real-endpoint-only, see the module docstring); Edit exists on the + question BESIDE the failed bubble's Retry; trimming (the trigger + stripped) + Save streams the grounded answer (the failed record is + part of the dropped tail — ``failed: true`` never survives). +6. ``test_edit_is_inert_while_the_turn_runs`` — the ``write a long + answer`` trigger (~8 s): NO ``.edit-btn`` while the turn runs + (the in-flight guard — the button never appears or moves + mid-turn); at the ``done`` settle the button lands. +7. ``test_composer_draft_survives_an_edit`` — negative: a typed, + UNSSENT composer draft survives the edit-save (the save handler + never touches ``input.value`` / ``autoGrow`` / + ``updateCharCount`` — the composer keeps its own draft). +""" +from __future__ import annotations + +import hashlib +import json +import re +import time +from collections.abc import Callable, Iterator +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from playwright.sync_api import Page, expect +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.db import SessionLocal +from app.models import Chunk, Document +from e2e.auth_helpers import login +from tests.e2e.mock_llm import ( + ALWAYS_FAIL_TRIGGER, + LONG_ANSWER_TRIGGER, + embed_text, +) + +REPO = Path(__file__).resolve().parents[2] + +# -------------------------------------------------------------------------- +# Seed + questions (see the module docstring for the gate notes) +# -------------------------------------------------------------------------- + +SEED_SOURCE = "docs" +SEED_PATH = "homelab/kubernetes.md" +KUB_CONTENT = (REPO / "tests" / "fixtures" / "docs" / SEED_PATH).read_text() + +#: The grounded question (the phase-120 suite's — FTS-hits the seed). +Q = "How is my Kubernetes cluster set up?" +#: Test 2's second turn (the seed's workloads section — FTS-hits too). +Q2 = "Which workloads does my Kubernetes cluster run?" +#: Test 4's edit — the original tokens plus one (grounding preserved — +#: a superset of the seed's hit tokens), DISTINCT from the original so +#: the mock's question echo makes the old vs the new answer +#: distinguishable byte-for-byte. +Q_EDITED = "How is my Kubernetes cluster set up, briefly?" +#: Test 1's edit — append the task's " in one line" to the question. +Q_TEST1_EDITED = "How is my Kubernetes cluster set up? in one line" +#: Test 5's failure question (the phase-67 trigger — every streaming +#: request 500s → the retry-budget exhaustion, the phase-120 shape). +FAIL_Q = f"{Q} {ALWAYS_FAIL_TRIGGER}" +#: Test 6's long answer (the mock's ~8 s pacing — the in-flight window). +LONG_Q = f"{Q} {LONG_ANSWER_TRIGGER}" +#: Test 7's unsent composer draft. +DRAFT = "draft question about backups — do not send yet" + +#: The app's terminal LLM-failure copy (app/api/chat.py's LLMError +#: frame) — the error detail the note/banner/record all carry. +ERROR_COPY = "The chat model dropped the connection — try again?" +MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E" + +STORAGE_KEY = "bor.chat.v1" + + +def _js_const(name: str) -> str: + """A frontend string constant, read from app.js — the E2E asserts + against the SAME text the page renders (no JS/Python drift).""" + js = (REPO / "frontend" / "assets" / "app.js").read_text(encoding="utf-8") + m = re.search(rf'const {name}\s*=\s*\n?\s*"([^"]*)"', js) + assert m, f"const {name} not found in app.js" + return m.group(1) + + +FAILED_TURN_TEXT = _js_const("FAILED_TURN_TEXT") + + +# -------------------------------------------------------------------------- +# DB seeding (TRUNCATE-then-seed, the phase-120 pattern) +# -------------------------------------------------------------------------- + + +def _seed(db: Session) -> None: + """The single fixture document (see the module docstring).""" + md = Document( + source=SEED_SOURCE, + path=SEED_PATH, + full_path=f"/tmp/{SEED_PATH}", + title="Kubernetes", + content=KUB_CONTENT, + content_hash=hashlib.sha256(KUB_CONTENT.encode()).hexdigest(), + indexed_at=datetime.now(UTC), + ) + db.add(md) + db.flush() + # One chunk carrying the mock's own embedding → genuine token overlap + # for the grounded questions (the FTS path carries them to HIGH). + db.add( + Chunk( + document_id=md.id, + position=0, + content=KUB_CONTENT, + embedding=embed_text(KUB_CONTENT), + ) + ) + + +def _reset_db() -> None: + """Truncate the KB (plus the prompt-shaping tables), then re-seed. + + ``steering_notes`` / ``kb_overview`` are truncated too, so the + prompts are byte-stable regardless of leftovers from other suites. + """ + with SessionLocal() as db: + db.execute( + text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview") + ) + db.commit() + _seed(db) + db.commit() + + +@pytest.fixture(autouse=True) +def clean_chats() -> Iterator[None]: + """The send auto-saves a ``saved_chats`` row per turn (phase 55) — + truncate around every test so the suite starts from (and leaves) + an empty deployment (the phase-80/103 isolation pattern).""" + with SessionLocal() as db: + db.execute(text("TRUNCATE saved_chats")) + db.commit() + yield + # The auto-save is fire-and-forget (the browser never awaits the + # PUT): a short margin lets the last turn's PUT land server-side + # before the TRUNCATE, so the teardown never 500s an in-flight + # upsert (the "Could not refresh instance" race). This runs AFTER + # the page closes (LIFO finalization), so the PUT is either done + # or aborted — never in flight. + time.sleep(0.75) + with SessionLocal() as db: + db.execute(text("TRUNCATE saved_chats")) + db.commit() + + +# -------------------------------------------------------------------------- +# localStorage reads + the auto-saved row poll + the turn flow +# -------------------------------------------------------------------------- + + +def _stored_raw(page: Page) -> str: + """The raw ``bor.chat.v1`` payload (the byte-identity check's + unit — a string, not a parsed dict).""" + raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')") + assert raw is not None, "the conversation key must exist in localStorage" + return raw + + +def _stored(page: Page) -> dict: + """The persisted ``bor.chat.v1`` conversation (localStorage — the + phase-14 store the restore reads on refresh).""" + return json.loads(_stored_raw(page)) + + +def _poll_saved_messages( + title: str, want: Callable[[list[dict]], bool] +) -> list[dict]: + """Wait for the auto-saved row's ``messages`` to match *want* (the + phase-55 upserts are fire-and-forget — there is no status line to + wait on; the ``#send-label`` settle only proves the LAST save + point fired, not that its PUT landed). The row's title keeps the + FIRST question on re-Save (title-keep), so the edit's row is the + same row, polled by the original title.""" + deadline = time.monotonic() + 15 + last: list[dict] | None = None + while time.monotonic() < deadline: + with SessionLocal() as db: + row = db.execute( + text("SELECT messages FROM saved_chats WHERE title = :t"), + {"t": title}, + ).scalar_one_or_none() + if row is not None: + last = row + if want(row): + return row + time.sleep(0.2) + raise AssertionError( + f"the saved_chats row for {title!r} never settled (last: {last!r})" + ) + + +def _shape_matches(row: list[dict], stored: list[dict]) -> bool: + """The row's shape matches the localStorage shape key-for-key. + + The DB stores the schema-NORMALIZED shape (``ChatMessage`` + re-validation — every optional key present as an explicit + ``None``, the integration suite's ``_expect`` convention), while + localStorage keeps the RAW client records (a key subset). The + match: every localStorage key equals the row's value, and the row + carries no NON-NULL key the record lacks (no stale metadata, + no missing content).""" + if len(row) != len(stored): + return False + for r, s in zip(row, stored, strict=True): + for k, v in s.items(): + if r.get(k) != v: + return False + for k, v in r.items(): + if k not in s and v is not None: + return False + return True + + +def _submit(page: Page, question: str) -> None: + page.fill("#message-input", question) + page.click("#send-btn") + # The user bubble lands synchronously with the submit handler. + expect(page.locator(".msg.user .bubble").last).to_contain_text(question) + + +def _assert_settled(page: Page) -> None: + """The turn has FULLY settled: the ``done`` frame is processed + (save point 2 — the answer record persisted through + ``rememberBrainTurn`` — synchronously in the frame handler) BEFORE + the finally's idle state flips the label to Send. The label is + the settle signal, so a localStorage / row read after it is + race-free for the record's shape (the marker can be visible a few + frames before ``done`` — the last deltas carry it).""" + expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000) + + +def _assert_grounded_settled(page: Page) -> str: + """The grounded answer streamed + the turn settled: returns the + brain bubble's text (the phase-120 ``_assert_redo_succeeded`` + shape, minus the redo's failed-marker checks).""" + bubble = page.locator(".msg.brain .bubble").last + expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000) + _assert_settled(page) + return bubble.inner_text() + + +def _open_editor(page: Page, expected_prefill: str) -> None: + """Click the Edit toggle (it must exist on the last user bubble) + and pin the textarea's prefill (the record's text, exact).""" + expect(page.locator(".msg.user .edit-btn")).to_have_count(1) + page.click(".msg.user .edit-btn") + ta = page.locator(".edit-form textarea") + expect(ta).to_be_visible(timeout=10_000) + expect(ta).to_have_value(expected_prefill) + + +def _save_editor(page: Page, new_text: str) -> None: + page.fill(".edit-form textarea", new_text) + page.click(".edit-form .edit-save") + + +# -------------------------------------------------------------------------- +# 1 — the flow: send → Edit (prefilled) → trim + Save → the old answer +# is gone, the new one streams, the record + the row are truncated +# -------------------------------------------------------------------------- + + +def test_edit_last_question_reasks_in_place( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + _reset_db() + page.set_default_timeout(30_000) + login(page, app_url, next="/") + + _submit(page, Q) + old_answer = _assert_grounded_settled(page) + # The old answer is the ONLY brain bubble (the pre-edit shape). + expect(page.locator(".msg.brain")).to_have_count(1) + + _open_editor(page, Q) + _save_editor(page, Q_TEST1_EDITED) + + # The old answer bubble is GONE (the truncation removed it); the + # question appears ONCE, edited; the fresh answer streams from the + # edited text. + expect(page.locator(".msg.user .bubble")).to_have_count(1) + expect(page.locator(".msg.user .bubble")).to_have_text(Q_TEST1_EDITED) + new_answer = _assert_grounded_settled(page) + expect(page.locator(".msg.brain")).to_have_count(1) + assert new_answer != old_answer, "the old answer is gone — the new one streams" + + # The record (localStorage): [user(edited), brain] — the edited + # text replaces the record in place, the tail is dropped, and the + # question is never DUPLICATED (no record carries the old text; + # the new answer merely echoes the edited question). + stored = _stored(page) + assert [m["who"] for m in stored["messages"]] == ["user", "brain"] + assert stored["messages"][0]["text"] == Q_TEST1_EDITED + assert Q not in [m["text"] for m in stored["messages"]], ( + "no duplicate question — the old text is gone from the record" + ) + assert MOCK_ANSWER_MARKER in stored["messages"][1]["text"] + + # The auto-saved row (polled — the title keeps the FIRST question + # on re-Save): the truncated shape [user(edited), brain], matching + # the localStorage shape key-for-key (the DB is the durable boundary). + row = _poll_saved_messages( + Q, lambda msgs: len(msgs) == 2 and msgs[0].get("text") == Q_TEST1_EDITED + ) + assert _shape_matches(row, stored["messages"]), ( + "the saved_chats row matches the localStorage shape — the " + "truncated conversation persisted (no stale tail, no duplicate)" + ) + + +# -------------------------------------------------------------------------- +# 2 — the single-target discipline: exactly ONE editable bubble, the +# NEWEST question (owner-locked 2026-10-05) +# -------------------------------------------------------------------------- + + +def test_only_the_last_question_is_editable( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + _reset_db() + page.set_default_timeout(30_000) + login(page, app_url, next="/") + + _submit(page, Q) + _assert_grounded_settled(page) + _submit(page, Q2) + _assert_grounded_settled(page) + + # Exactly ONE .edit-btn in the whole DOM, on the SECOND user + # bubble; the first (the older question) carries none — the + # remove-then-append single target, one `who` over. + expect(page.locator(".edit-btn")).to_have_count(1) + expect(page.locator(".msg.user").nth(1).locator(".edit-btn")).to_have_count(1) + expect(page.locator(".msg.user").nth(0).locator(".edit-btn")).to_have_count(0) + + +# -------------------------------------------------------------------------- +# 3 — negative: Cancel (Escape) is a no-op — the bubble, the record, +# and the answer are unchanged; focus returns to the toggle +# -------------------------------------------------------------------------- + + +def test_edit_cancel_is_a_no_op( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + _reset_db() + page.set_default_timeout(30_000) + login(page, app_url, next="/") + + _submit(page, Q) + answer = _assert_grounded_settled(page) + question_text = page.locator(".msg.user .bubble").inner_text() + before = _stored_raw(page) + + expect(page.locator(".msg.user .edit-btn")).to_have_count(1) + page.click(".msg.user .edit-btn") + ta = page.locator(".edit-form textarea") + expect(ta).to_be_visible(timeout=10_000) + # A real edit attempt (so the no-op is meaningful): the textarea is + # changed, then Escape cancels. + page.fill(".edit-form textarea", "something entirely different") + ta.press("Escape") + + # The form is gone; the bubble + the answer are byte-identical; the + # localStorage payload is byte-identical (nothing persisted — + # cancel never touches the record). + expect(page.locator(".edit-form")).to_have_count(0) + expect(page.locator(".msg.user .bubble")).to_have_text(question_text) + assert page.locator(".msg.brain .bubble").last.inner_text() == answer + assert _stored_raw(page) == before, ( + "cancel is a no-op — the localStorage payload is byte-identical" + ) + # Focus RETURNED to the Edit toggle (the openTuneForm cancel rule). + is_toggle = page.evaluate( + "() => !!(document.activeElement && document.activeElement.classList" + " && document.activeElement.classList.contains('edit-btn'))" + ) + assert is_toggle, "focus must return to the Edit toggle on Escape" + + +# -------------------------------------------------------------------------- +# 4 — the truncated shape survives a refresh: the edited question + the +# new answer restore; the dropped answer never reappears; Edit is +# back on the question +# -------------------------------------------------------------------------- + + +def test_edit_drops_a_stale_tail_and_survives_refresh( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + _reset_db() + page.set_default_timeout(30_000) + login(page, app_url, next="/") + + _submit(page, Q) + old_answer = _assert_grounded_settled(page) + + _open_editor(page, Q) + _save_editor(page, Q_EDITED) + new_answer = _assert_grounded_settled(page) + assert new_answer != old_answer + + # The refresh: the restore reads the truncated localStorage shape — + # the edited question + the fresh answer. + page.reload() + expect(page.locator("#messages > .msg")).to_have_count(2) + expect(page.locator(".msg.user .bubble")).to_have_text(Q_EDITED) + restored = page.locator(".msg.brain .bubble").last + expect(restored).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000) + assert page.locator(".msg.brain .bubble").last.inner_text() != old_answer, ( + "the DROPPED answer never reappears — the restore shows the " + "fresh one (no stale tail resurrected)" + ) + # Edit is back on the restored question (the restore's mark pair — + # the mirror of the live settle). + expect(page.locator(".msg.user .edit-btn")).to_have_count(1) + # The restored record is the truncated shape (the restore is + # lossless: the edited text, the fresh answer, no duplicate). + stored = _stored(page) + assert [m["who"] for m in stored["messages"]] == ["user", "brain"] + assert stored["messages"][0]["text"] == Q_EDITED + assert Q not in [m["text"] for m in stored["messages"]] + + +# -------------------------------------------------------------------------- +# 5 — the driver case (TODO.md L3): a failed turn's question is +# editable; trimming + Save re-asks successfully (the phase-114 +# "Question too long" frame takes this identical settle path — its +# exact detail string is real-endpoint-only, see the module +# docstring) +# -------------------------------------------------------------------------- + + +def test_failed_turn_question_is_editable_and_trim_reasks( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + _reset_db() + page.set_default_timeout(30_000) + login(page, app_url, next="/") + + _submit(page, FAIL_Q) + + # The ``always fail`` trigger: every streaming request 500s → the + # retry-budget exhaustion → the terminal error frame — the + # phase-120 zero-frame shape (banner + the failed bubble). + banner = page.locator("#kb-banner") + expect(banner).to_have_attribute("role", "alert", timeout=60_000) + expect(banner).to_contain_text(ERROR_COPY) + expect(page.locator("#banner-retry")).to_be_visible(timeout=10_000) + expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000) + brain = page.locator(".msg.brain") + expect(brain).to_have_count(1) + expect(brain.locator(".bubble")).to_have_text(FAILED_TURN_TEXT) + note = brain.locator(".failed-note") + expect(note).to_have_count(1) + expect(note).to_contain_text(ERROR_COPY) + expect(brain.locator(".retry-btn")).to_have_count(1) + + # THE remedy: Edit exists on the question BESIDE the failed + # bubble's Retry (the guard bails on thinking/streaming only — the + # error state passes; the failed record is the stale tail the + # edit-save truncates). + expect(page.locator(".msg.user .edit-btn")).to_have_count(1) + + _open_editor(page, FAIL_Q) + _save_editor(page, Q) # the trigger stripped out + + # The trimmed question re-asks successfully: the grounded answer + # streams in place of the failed bubble (the failed record is + # dropped — never kept, never rewritten). + _assert_grounded_settled(page) + expect(page.locator(".msg.user .bubble")).to_have_text(Q) + expect(page.locator(".msg.brain")).to_have_count(1) + expect(page.locator(".failed-note")).to_have_count(0) + stored = _stored(page) + assert [m["who"] for m in stored["messages"]] == ["user", "brain"] + assert stored["messages"][0]["text"] == Q + assert stored["messages"][-1].get("failed") in (None, False), ( + "the failed record is part of the dropped tail — it never " + "survives the edit" + ) + assert FAILED_TURN_TEXT not in stored["messages"][-1]["text"] + + +# -------------------------------------------------------------------------- +# 6 — in-flight discipline: no Edit button while a turn runs; the +# button lands at the settle +# -------------------------------------------------------------------------- + + +def test_edit_is_inert_while_the_turn_runs( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + _reset_db() + page.set_default_timeout(30_000) + login(page, app_url, next="/") + + # The ~8 s long answer — a comfortable in-flight window (the + # mock's 12-char / 0.02 s pacing). + _submit(page, LONG_Q) + # The turn is in flight (the button IS the Stop control — + # phase 48). While it runs the Edit button never appears (the + # in-flight guard — the button never appears or moves mid-turn). + expect(page.locator("#send-label")).to_have_text("Stop", timeout=30_000) + expect(page.locator(".edit-btn")).to_have_count(0) + # At the ``done`` settle the button lands (the idle-settle mark + # pass — the at-rest landing). + expect(page.locator("#send-label")).to_have_text("Send", timeout=60_000) + expect(page.locator(".msg.user .edit-btn")).to_have_count(1) + + +# -------------------------------------------------------------------------- +# 7 — negative: the composer's unsent draft survives an edit (the save +# handler never touches the composer — task 02's work item) +# -------------------------------------------------------------------------- + + +def test_composer_draft_survives_an_edit( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + _reset_db() + page.set_default_timeout(30_000) + login(page, app_url, next="/") + + _submit(page, Q) + _assert_grounded_settled(page) + + # The unsent draft (typed AFTER the settle — the composer keeps its + # own draft, untouched by the edit below). + page.fill("#message-input", DRAFT) + + _open_editor(page, Q) + _save_editor(page, Q_EDITED) + _assert_grounded_settled(page) + + # The draft is STILL in the composer (the save handler never writes + # input.value / autoGrow / updateCharCount — the edit is the + # in-bubble editor's job, the composer's is separate). + assert page.locator("#message-input").input_value() == DRAFT diff --git a/tests/integration/test_chats_api.py b/tests/integration/test_chats_api.py index 51a2f15..e27efba 100644 --- a/tests/integration/test_chats_api.py +++ b/tests/integration/test_chats_api.py @@ -1036,6 +1036,170 @@ def test_shared_serve_includes_the_brain_answer_image_ref( assert "answer_image" not in messages[0] +# --------------------------------------------------------------------------- +# Phase 159 (task 02): edit-and-resend — the SAVE shape. The client +# edits the last question: the record is replaced in place, EVERY +# record after it is dropped, and the edited question becomes the LAST +# record. The auto-save then PUTs that shortened list. This pins the +# claim "no schema change is needed": a shortened ``messages`` array +# with the edited last user record is ALREADY legal on the existing +# ``SavedChatUpdate`` path (no new field, no migration — A10 stays +# stateless; the ≤4000 / ≤32 000 caps already cover the edited text). +# --------------------------------------------------------------------------- + +#: The driver case's question (the phase-114 "Question too long" turn) +#: — long, near the 4 000-char ``ChatRequest.message`` cap, so the +#: edit trims it down to a short re-askable question. +LONG_QUESTION = "How do I prune deleted docs " + "and rotate the certificates " * 90 +TRIMMED_QUESTION = "How do I prune deleted docs?" + + +def test_put_edited_truncated_tail_round_trips_byte_identical( + admin_client: TestClient, db: Session +) -> None: + """The phase-159 client shape round-trips byte-identically through + the EXISTING PUT path: the conversation was + [user Q1, brain A1, user Q2-long, brain A2]; the owner trims Q2 + → Q2', and the auto-save PUTs [user Q1, brain A1, user Q2'] — + the tail (A2) is dropped, the last record is the EDITED user + record, and the array is SHORTER. The PUT is a plain re-Save + (no new field): 200 + ``message_count`` drops to 3, the stored + JSONB + the admin GET + the 200 body all round-trip the edited + record + the truncated list byte-identically (``_expect``'s full + bor.chat.v1 shape), and the title keeps the FIRST question + (title-keep on re-Save — the edited second question never + re-titles the row).""" + q1 = "What is my Traefik port?" + a1 = {"who": "brain", "text": "8080."} + created = admin_client.post( + "/api/chats", + json={ + "messages": [ + _user(q1), + a1, + _user(LONG_QUESTION), + {"who": "brain", "text": "A long answer."}, + ] + }, + ).json() + assert created["message_count"] == 4 + + edited = [_user(q1), a1, _user(TRIMMED_QUESTION)] + r = admin_client.put(f"/api/chats/{created['id']}", json={"messages": edited}) + assert r.status_code == 200, r.text # a shortened array is already legal + body = r.json() + assert body["message_count"] == 3, "the tail (A2) is dropped — 2 + the edit" + assert body["title"] == q1 # title-keep: the edit re-titles nothing + assert body["messages"] == _expect(edited) + + # The RAW stored JSONB (the DB is the durable boundary — the + # phase-123/124 precedent): the edited last user record + the + # truncated list, byte-for-byte, explicit nulls included. + raw = db.execute( + text("SELECT messages FROM saved_chats WHERE id = :id"), + {"id": created["id"]}, + ).scalar_one() + assert raw == _expect(edited) + + # The admin GET round-trips the same shape (the restore path reads + # from this surface — a refresh shows the edited question and no + # stale tail). + got = admin_client.get(f"/api/chats/{created['id']}") + assert got.status_code == 200 + assert got.json()["messages"] == _expect(edited) + + +def test_put_edited_record_keeps_the_user_image_key( + admin_client: TestClient, +) -> None: + """The image survives the edit (the phase-49/123 A7 rule's record + side): the record KEEPS its ``image`` key when the question is + replaced in place + the tail dropped — the re-ask sends text + only, but the saved shape (and the bubble's attachment) carry the + attachment through the round-trip. The key is present-and-carrying + (the phase-123 shape — ``_expect`` covers the base keys, the + optional ``image`` key is asserted on top, the phase-123 + precedent).""" + edited = [ + _user_with_image("What is in this image?"), # edited text, image kept + # (the tail — a brain answer — is dropped by the edit) + ] + created = admin_client.post( + "/api/chats", + json={ + "messages": [ + _user_with_image("What is in this picture?"), + {"who": "brain", "text": "A cat, by the look of it."}, + ] + }, + ).json() + + r = admin_client.put(f"/api/chats/{created['id']}", json={"messages": edited}) + assert r.status_code == 200, r.text + msgs = r.json()["messages"] + assert len(msgs) == 1, "the tail is dropped — one record (the edit)" + expected = _expect(edited)[0] + expected["image"] = IMAGE_PATH + assert msgs[0] == expected, ( + "the attachment key survives the in-place edit (text-only " + "re-ask, attachment kept)" + ) + + +def test_shared_serve_carries_the_edited_truncated_shape( + admin_client: TestClient, +) -> None: + """The phase-159 claim pinned on the PUBLIC surface: the + edited/truncated conversation needs NO schema change on the shared + serve either. The conversation is [user Q1, brain A1, user Q2, + brain A2], shared BEFORE the edit (the token predates the shape + change), then the owner trims Q2 → Q2' and the auto-save PUTs the + post-edit shape [user Q1, brain A1, user Q2'] (the tail dropped, + the array shorter, the last record the edited user record). The + anonymous ``GET /api/shared/`` still serves exactly the + public shape (``title`` + ``messages`` — no id, no timestamps, no + token) with the CURRENT row content: the edited record + the + truncated list, byte-identical (the read is a point-in-time read + of the live row, so the pre-share token rides the post-edit shape + — the shared page renders the edited question, no stale tail, no + new field).""" + q1 = "What is my Traefik port?" + a1 = {"who": "brain", "text": "8080."} + created = admin_client.post( + "/api/chats", + json={ + "messages": [ + _user(q1), + a1, + _user(LONG_QUESTION), + {"who": "brain", "text": "A long answer."}, + ] + }, + ).json() + share_url = _share(admin_client, created["id"])["share_url"] + + # The edit + the auto-save's PUT (the post-edit shape — the same + # shape as the admin-surface pin above, now served publicly). + edited = [_user(q1), a1, _user(TRIMMED_QUESTION)] + r = admin_client.put(f"/api/chats/{created['id']}", json={"messages": edited}) + assert r.status_code == 200, r.text + + # The public read: the token (issued BEFORE the shape change) serves + # the CURRENT row — the edited user record as the LAST message, + # the tail gone, the array shorter, the byte-identical shape. + anon = TestClient(fastapi_app) # fresh jar: truly anonymous + got = anon.get(f"/api{share_url}") + assert got.status_code == 200, got.text + body = got.json() + assert set(body) == SHARED_OUT_KEYS # no new shared-shape field + assert body["title"] == q1 # title-keep: the edit re-titles nothing + assert len(body["messages"]) == 3, "the tail (A2) is dropped — 2 + the edit" + assert body["messages"] == _expect(edited), ( + "the public surface serves the edited/truncated shape " + "byte-identically — no schema change, no stale tail" + ) + + def test_public_read_wrong_and_revoked_tokens_404_with_one_detail( admin_client: TestClient, ) -> None: diff --git a/tests/unit/test_big_read_progress.py b/tests/unit/test_big_read_progress.py index bd17bc6..59f4658 100644 --- a/tests/unit/test_big_read_progress.py +++ b/tests/unit/test_big_read_progress.py @@ -66,10 +66,19 @@ def test_typing_elapsed_hint_is_built_with_createelement_and_textcontent() -> No ) # No innerHTML on the typing bubble — the skeleton template # (wrap.innerHTML in addTyping) is the only bubble-adjacent HTML - # write, and it must not gain a second one. - assert js.count("bubble.innerHTML") == 0, ( - "the clock must never assign innerHTML to the typing bubble" - ) + # write of the typing indicator, and the clock must not gain a + # second one (the typing-clock context: addTyping / startThinking + # Clock / stopThinkingClock / removeTyping — the phase-159 + # openEditor re-render of a USER bubble's .bubble is a different + # context and does not count). + for name in ("function addTyping", "function startThinkingClock", + "function stopThinkingClock", "function removeTyping"): + f = js.find(name) + assert f != -1, f"{name} must exist" + b = js[f : js.find("\n}\n", f)] + assert "bubble.innerHTML" not in b, ( + f"{name} must never assign innerHTML to the typing bubble" + ) def test_typing_elapsed_hint_lives_in_start_thinking_clock() -> None: diff --git a/tests/unit/test_frontend_actions_cluster.py b/tests/unit/test_frontend_actions_cluster.py index 4352c4f..7c29952 100644 --- a/tests/unit/test_frontend_actions_cluster.py +++ b/tests/unit/test_frontend_actions_cluster.py @@ -365,10 +365,13 @@ def test_stop_finalize_appends_actions_last() -> None: def test_failed_funnel_note_and_retry_join_the_actions_row() -> None: """The phase-120 failure funnel (finalizeFailedTurn): the Failed - note is appended and the funnel ENDS with markLastRetryable — the - note + Retry in the failed bubble's actions row (the partial + note is appended and the funnel ENDS with markLastRetryable + immediately followed by markLastEditable (phase 159 task 01 — + the failed turn's question is editable too) — the note + Retry + + Edit in the failed bubble's actions row (the partial keeps its streamed text; the zero-frame shape gets a plain - FAILED_TURN_TEXT bubble). Both shapes end retryable.""" + FAILED_TURN_TEXT bubble). Both shapes end retryable + + editable.""" js = _js() header = ( "function finalizeFailedTurn(\n" @@ -382,10 +385,13 @@ def test_failed_funnel_note_and_retry_join_the_actions_row() -> None: ) lines = [line for line in body.splitlines() if line.strip()] assert lines[-1].strip() == "}" and lines[-2].strip().startswith( + "markLastEditable();" + ) and lines[-3].strip().startswith( "markLastRetryable();" ), ( - "the funnel still ends with the markLastRetryable() call — the " - "Retry button lands on the failed bubble's actions row" + "the funnel ends with the markLastRetryable() + " + "markLastEditable() pair — the Retry + Edit buttons land on " + "the failed bubble's actions row" ) diff --git a/tests/unit/test_frontend_edit_message.py b/tests/unit/test_frontend_edit_message.py new file mode 100644 index 0000000..9bd1b4b --- /dev/null +++ b/tests/unit/test_frontend_edit_message.py @@ -0,0 +1,1867 @@ +"""Unit: the edit-and-resend frontend contract (phase 159, tasks 01–04). + +Pins the phase-159 task-01 + task-02 client contract as source +assertions (the house ``test_frontend_*`` style — no browser): the Edit +button joins the bubble's ``.msg-actions`` row (getActionsRow — the +phase-130 house pattern) as a listitem of the ``role=list`` row, +``markLastEditable`` removes every rendered button FIRST and then +targets exactly ONE bubble — the newest question (the phase-49 +single-target discipline, one ``who`` over — bailing on the in-flight +guard, a missing user record, and a missing tracked wrap), +``lastUserRecordIndex`` is a newest-first ``conversation`` scan, +``lastUserWrap``'s lifecycle sites, ``openEditor`` renders the +``openTuneForm`` family (a labeled ``textarea[maxlength]`` bound to +``MAX_QUESTION_CHARS`` — the 4000 cap stays single-sourced — prefilled +through ``.value``, never an HTML string; Save/Cancel; a ``role=alert`` +rejection line), the Cancel / ``Escape`` restore paths, the +Ctrl/Cmd+Enter submit, the empty-guard rejection (the form stays +open), the in-flight save no-op, and the task-02 SAVE = replace + +truncate + re-ask flow: the guards (in-flight first, then the empty +trim) run before the record edit; the trimmed text replaces the record +in place; ``conversation.splice(editIdx + 1)`` drops the whole tail; +the matching DOM bubbles are removed from ``lastUserWrap.nextSibling`` +onward; ``lastBrainWrap = null`` + the phase-73 ``leavePartialIndex`` +reset (when it pointed past ``editIdx``) precede the re-ask; +``saveConversation()`` + ``persistConversation()`` persist the +truncated shape before the re-ask; ``markLastRetryable()`` re-targets +(clears — no brain record remains); the editor closes; the double +in-flight check sits on one synchronous stretch (no ``await``); and +the re-ask is exactly ``runTurn(text, { reask: true })`` — no image +(the phase-49/123 A7 text-only rule). The composer stays untouched. +And — the phase's explicit "NOT touched" contract — ``retryLastTurn`` +is byte-unchanged, ``runTurn`` is pinned at exactly the state the +phase left it (its sanctioned additions — the task-01 +``lastUserWrap`` assignment + the idle-settle ``markLastEditable()`` +mark pass, and the task-03 aborted-settle mark pair — included; +no task may restructure it), and — the task-04 regression pins — +``handleSend`` and ``rememberBrainTurn`` are byte-unchanged (the +plain send's pre-work and the brain-record funnel the re-ask's +settle rides are touched by no phase-159 task). Task 03 (the +driver case — a failed or +rejected question stays editable) is pinned here too: the +non-``done`` settle pairings (the ``finalizeFailedTurn`` funnel that +every HTTP-422 / SSE-``error``-frame turn settles through, the +zero-frame fallback, the stop finalize, and the 300s guard's +``aborted`` catch branch — a timed-out turn must not strand the +question), ``markLastEditable``'s guard naming ONLY +``UI_STATE.thinking`` / ``UI_STATE.streaming`` (never the error +state — a failed turn IS idle-or-error at rest), the save guard +mirroring it byte-identically, the failed ``{ who: "brain", +failed: true, error }`` record being DROPPED (not kept) by the +edit-save truncation with ``lastBrainWrap = null`` ahead of the +re-ask, the restore paths (``renderStoredMessage``'s ``lastUserWrap`` +tracking + the two restore mark pairs — the ``?chat=`` boot load +confirmed as an editable working copy, no read-only gate), the +truncation-ordering note (the new turn's ``rememberBrainTurn`` sees +the post-splice array), and ``showErrorBanner`` byte-unchanged (the +phase-120 shared pin — Edit is an in-bubble affordance beside +Retry, never a banner control). +""" +from __future__ import annotations + +import re +from pathlib import Path + +from tests.unit.test_frontend_failed_turn import ( + PINNED_RETRY_LAST_TURN, + PINNED_SHOW_ERROR_BANNER, +) + +FRONTEND = Path(__file__).resolve().parents[2] / "frontend" +APP_JS = FRONTEND / "assets" / "app.js" +STYLES_CSS = FRONTEND / "assets" / "styles.css" + + +def _js() -> str: + return APP_JS.read_text(encoding="utf-8") + + +def _css() -> str: + return STYLES_CSS.read_text(encoding="utf-8") + + +def _find_body_brace(js: str, start: int) -> int: + """The index of the REAL opening brace of a function at *start* — + the first ``{`` OUTSIDE the parameter list (paren depth 0), so + empty object defaults (``opts = {}``) and destructured parameters + (``{ acc, thinking }``) are skipped (the house convention).""" + i = start + paren = 0 + while i < len(js): + c = js[i] + if c == "(": + paren += 1 + elif c == ")": + paren -= 1 + elif c == "{" and paren == 0: + return i + i += 1 + return -1 + + +def _fn_source(js: str, name: str) -> str: + """The full source of ``[async] function name(…){…}`` (signature + + body).""" + start = js.index(f"function {name}(") + brace = _find_body_brace(js, start) + depth = 0 + i = brace + while True: + c = js[i] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + break + i += 1 + return js[start : i + 1] + + +def _fn_body(js: str, name: str) -> str: + """Just the body of ``[async] function name(…){…}`` (between the + braces).""" + src = _fn_source(js, name) + start = js.index(f"function {name}(") + brace = _find_body_brace(js, start) + rel = brace - start + return src[rel + 1 : len(src) - 1] + + +# --------------------------------------------------------------------------- +# EDIT_ICON — the pencil glyph (the retry/tune icon family) +# --------------------------------------------------------------------------- + + +def test_edit_icon_is_an_aria_hidden_stroke_svg() -> None: + """``EDIT_ICON`` is an inline ``aria-hidden`` decoration in the + house stroke family (``fill="none"`` + ``stroke="currentColor"`` + + ``stroke-width="1.7"`` + the 24×24 viewBox — the RETRY_ICON + parameters): no icon font, no CDN (A11); the CSS themes the + stroke (ink-soft → ink on hover).""" + js = _js() + m = re.search(r"const EDIT_ICON\s*=\s*\n?\s*'([^']*)'", js) + assert m, "const EDIT_ICON must exist as a single-quoted SVG string" + icon = m.group(1) + assert 'aria-hidden="true"' in icon, "the glyph is decoration" + assert 'viewBox="0 0 24 24"' in icon, "the 24×24 viewBox (the family)" + assert 'fill="none"' in icon, "stroke family — never a filled glyph" + assert 'stroke="currentColor"' in icon, "currentColor — the CSS themes it" + assert 'stroke-width="1.7"' in icon, "stroke-width 1.7 (the RETRY_ICON family)" + + +# --------------------------------------------------------------------------- +# appendEditButton — the Edit button in the actions row +# --------------------------------------------------------------------------- + + +def test_append_edit_button_joins_the_actions_row() -> None: + """``appendEditButton(wrap)`` is shaped exactly like + ``appendRetryButton``: it routes through ``getActionsRow(wrap)`` + (the phase-130 house pattern — the row's LAST row, right-aligned), + bails without a ``.msg-body``, keeps ONE button per bubble (the + duplicate guard), joins the ``role=list`` row as a ``listitem`` + (ARIA valid), carries the visible "Edit" label (the accessible + name — no ellipsis clipping at this width), and the click opens + the editor with the toggle button (for the focus return).""" + js = _js() + body = _fn_body(js, "appendEditButton") + assert "getActionsRow(wrap)" in body, ( + "the button joins the dedicated .msg-actions row (getActionsRow — " + "phase 130 task 02), never the first .msg-meta row" + ) + assert "if (!meta) return;" in body + assert ( + 'if (meta.querySelector(".edit-btn")) return;' in body + ), "one .edit-btn per bubble (the duplicate guard, the retry-btn way)" + assert 'btn.type = "button";' in body + assert 'btn.className = "edit-btn";' in body + assert ( + 'if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");' + in body + ), "the role=list row joins the button as a listitem (ARIA valid)" + assert 'btn.innerHTML = EDIT_ICON + "Edit";' in body, ( + "the visible 'Edit' label carries the accessible name" + ) + assert 'btn.addEventListener("click", () => openEditor(wrap, btn));' in body, ( + "the click opens the editor with the toggle (the focus return)" + ) + assert "meta.appendChild(btn);" in body + + +def test_edit_button_is_not_admin_gated() -> None: + """Unlike ``appendTuneButton``, ``appendEditButton`` has NO admin + gate (the phase-49 Retry precedent — chat is public, so every + visitor who can chat can edit the last question; B5's admin-only + views are the header views, not the chat actions).""" + js = _js() + body = _fn_body(js, "appendEditButton") + assert "isAdmin" not in body, ( + "the Edit button is NOT admin-gated (the phase-49 Retry precedent)" + ) + + +# --------------------------------------------------------------------------- +# markLastEditable — the single-target discipline (the phase-49 mirror) +# --------------------------------------------------------------------------- + + +def test_mark_last_editable_removes_first_bails_then_appends_the_single_target() -> None: + """``markLastEditable`` mirrors ``markLastRetryable``: it removes + EVERY rendered ``.edit-btn`` FIRST (an older bubble's button is + stale the moment a newer question lands), then bails on the + in-flight guard (thinking/streaming — the ``retryLastTurn`` guard), + then bails on a missing user record (``lastUserRecordIndex() === + -1``), then bails on a missing ``lastUserWrap`` (a stale wrap can + never carry the button), and appends to ``lastUserWrap`` ONLY — + exactly one bubble, the newest question (owner-locked 2026-10-05; + earlier bubbles are never editable — no mid-conversation + branching).""" + js = _js() + body = _fn_body(js, "markLastEditable") + idx_remove = body.index( + 'messagesEl.querySelectorAll(".edit-btn").forEach((b) => b.remove());' + ) + idx_inflight = body.index( + "if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;" + ) + idx_norecord = body.index("if (lastUserRecordIndex() === -1) return;") + idx_nowrap = body.index("if (!lastUserWrap) return;") + idx_append = body.index("appendEditButton(lastUserWrap);") + assert idx_remove < idx_inflight < idx_norecord < idx_nowrap < idx_append, ( + "remove FIRST, then the in-flight guard, the no-record bail, the " + "no-wrap bail, and the single-target append" + ) + assert body.count("appendEditButton(") == 1, ( + "exactly ONE append — the single-target discipline" + ) + + +def test_last_user_record_index_is_a_newest_first_scan() -> None: + """``lastUserRecordIndex()`` scans ``conversation`` NEWEST-FIRST + (``i`` from ``length - 1`` down to 0) for ``who === "user"`` and + returns that index — or -1 when the conversation holds no user + record (the markLastRetryable last-brain-index rule, one ``who`` + over; the ASSUMPTION: the index comes from the record scan, NOT a + DOM↔record index map — re-deriving keeps the editor correct after + the task-02 truncation).""" + js = _js() + body = _fn_body(js, "lastUserRecordIndex") + assert "for (let i = conversation.length - 1; i >= 0; i -= 1) {" in body, ( + "the scan is newest-first (i from length-1 down)" + ) + assert 'if (conversation[i].who === "user") return i;' in body + assert "return -1;" in body, "-1 when there is no user record" + + +# --------------------------------------------------------------------------- +# lastUserWrap — the lifecycle (the exact mirror of lastBrainWrap) +# --------------------------------------------------------------------------- + + +def test_last_user_wrap_lifecycle_sites() -> None: + """``lastUserWrap`` is a module-scope variable (exactly one + declaration) with the three sanctioned lifecycle sites: (a) + assigned in ``runTurn``'s fresh-send block — the ``addMessage`` + wrap is captured and the wrap assigned BEFORE the persistence save + point (the question record push); (b) assigned in + ``renderStoredMessage``'s user branch — the LAST restored user + bubble wins (the mirror of the ``lastBrainWrap`` line below the + brain branch); (c) cleared by ``startNewChat`` (the editable wrap + resets with the conversation; the list reset removes the buttons + with the list, so startNewChat needs no markLastEditable call — + the phase-49 reasoning).""" + js = _js() + assert js.count("let lastUserWrap = null;") == 1, ( + "exactly one module-scope declaration" + ) + # (a) the runTurn fresh-send assignment (before the save point) + rt = _fn_source(js, "runTurn") + assert "const userWrap = addMessage(" in rt, ( + "the fresh send captures the wrap (the addMessage return)" + ) + assert "lastUserWrap = userWrap;" in rt + assert rt.index("lastUserWrap = userWrap;") < rt.index("conversation.push("), ( + "the wrap is assigned before the persistence save point" + ) + # (b) the restore user-branch assignment + rst = _fn_body(js, "renderStoredMessage") + assert "lastUserWrap = wrap;" in rst, ( + "the restore user branch assigns the wrap (the mirror of the " + "lastBrainWrap line below the brain branch)" + ) + # (c) the startNewChat clear + snc = _fn_body(js, "startNewChat") + assert "lastUserWrap = null;" in snc, ( + "startNewChat clears the wrap (the buttons die with the list reset)" + ) + + +# --------------------------------------------------------------------------- +# openEditor — the inline form (the openTuneForm family) +# --------------------------------------------------------------------------- + + +def test_open_editor_renders_a_labelled_capped_textarea_prefilled_from_the_record() -> None: + """``openEditor`` renders the ``openTuneForm`` family: one editor + at a time (every existing ``.edit-form`` removed first), a + ``form.edit-form`` with ``noValidate``, a ``