phase: 159_edit_and_resend_message
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`
This commit is contained in:
+22
@@ -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`
|
||||||
+122
@@ -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
|
||||||
+22
@@ -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)
|
||||||
+122
@@ -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
|
||||||
+8
@@ -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`.
|
||||||
+122
@@ -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
|
||||||
+18
@@ -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`
|
||||||
+122
@@ -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
|
||||||
+17
@@ -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`
|
||||||
+122
@@ -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
|
||||||
+19
@@ -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.
|
||||||
+122
@@ -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
|
||||||
@@ -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 —
|
full path in an inline fallback field that selects itself on focus —
|
||||||
click or Tab, then copy (phase 153).
|
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`)
|
### RAG / Knowledge base (`/sources.html`)
|
||||||
|
|
||||||
The indexed document list. The *Path* column opens each document in the same
|
The indexed document list. The *Path* column opens each document in the same
|
||||||
|
|||||||
+317
-1
@@ -600,12 +600,33 @@ function appendFailedNote(wrap, detail) {
|
|||||||
* key off it. */
|
* key off it. */
|
||||||
let lastBrainWrap = null;
|
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"
|
/* The Retry button's redo glyph (aria-hidden decoration — the "Retry"
|
||||||
* text carries the accessible name), currentColor so the CSS themes the
|
* text carries the accessible name), currentColor so the CSS themes the
|
||||||
* stroke (ink-soft → ink on hover, the phase-08 palette). */
|
* stroke (ink-soft → ink on hover, the phase-08 palette). */
|
||||||
const RETRY_ICON =
|
const RETRY_ICON =
|
||||||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>';
|
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>';
|
||||||
|
|
||||||
|
/* 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 =
|
||||||
|
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>';
|
||||||
|
|
||||||
/* "Retry" button in the last brain bubble's actions row — the redo
|
/* "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
|
* action of phase 49 (owner-locked 2026-08-29, TODO.md L4). The house
|
||||||
* pattern through getActionsRow (phase 130 task 02 — the role=list
|
* pattern through getActionsRow (phase 130 task 02 — the role=list
|
||||||
@@ -627,6 +648,30 @@ function appendRetryButton(wrap) {
|
|||||||
meta.appendChild(btn);
|
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 + "<span>Edit</span>";
|
||||||
|
btn.addEventListener("click", () => openEditor(wrap, btn));
|
||||||
|
meta.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
/* Last-bubble-only management (owner-locked 2026-08-29): the Retry
|
/* Last-bubble-only management (owner-locked 2026-08-29): the Retry
|
||||||
* button lives on exactly ONE bubble — the last brain answer. Remove
|
* button lives on exactly ONE bubble — the last brain answer. Remove
|
||||||
* every rendered .retry-btn FIRST (an earlier bubble's button is stale
|
* every rendered .retry-btn FIRST (an earlier bubble's button is stale
|
||||||
@@ -662,6 +707,44 @@ function markLastRetryable() {
|
|||||||
appendRetryButton(lastBrainWrap);
|
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
|
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right
|
||||||
* "Save as doc" action of EVERY completed brain bubble (deflected
|
* "Save as doc" action of EVERY completed brain bubble (deflected
|
||||||
* included — same scope as Tune; a stopped partial is a note, not an
|
* 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();
|
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) ----------
|
/* ---------- avatar glyphs (phase 08: emoji-free chrome) ----------
|
||||||
* Inline SVG as string constants so the message renderer and the typing
|
* Inline SVG as string constants so the message renderer and the typing
|
||||||
* indicator share exactly the same marks. currentColor lets the CSS theme
|
* 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.
|
// instead of the old forced "auto" — noted per the phase-42 task.
|
||||||
if (m.who === "user") {
|
if (m.who === "user") {
|
||||||
const wrap = addMessage("user", renderMarkdown(m.text), true);
|
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
|
// Phase 123 (task 03, TODO L6): the restored record may carry the
|
||||||
// question's attached image — `m.image`, the STORED PATH (A5:
|
// question's attached image — `m.image`, the STORED PATH (A5:
|
||||||
// never base64; a pre-phase / text-only record has no key at all,
|
// 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
|
currentChatId = record ? record.chatId : null; // phase 55: the link survives reloads
|
||||||
for (const m of conversation) renderStoredMessage(m);
|
for (const m of conversation) renderStoredMessage(m);
|
||||||
markLastRetryable(); // phase 49: the restored last brain bubble is retryable
|
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) ----------
|
/* ---------- 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)
|
conversation = messages; // REPLACES the local conversation (owner-locked)
|
||||||
for (const m of conversation) renderStoredMessage(m);
|
for (const m of conversation) renderStoredMessage(m);
|
||||||
markLastRetryable(); // parity with the local restore: Retry on the last brain bubble
|
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
|
currentChatId = chatId; // linked: a subsequent Save updates THIS row
|
||||||
saveConversation(); // mirror to localStorage — a plain refresh returns here
|
saveConversation(); // mirror to localStorage — a plain refresh returns here
|
||||||
// Phase 53 (task 05): the `stale` flag is server-computed (task 03 —
|
// 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;
|
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
|
||||||
conversation = [];
|
conversation = [];
|
||||||
currentChatId = null; // phase 55: unlinked — a fresh row on its first message
|
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
|
if (staleBanner) staleBanner.hidden = true; // phase 53: the banner described the cleared conversation
|
||||||
clearStoredConversation();
|
clearStoredConversation();
|
||||||
removeTyping();
|
removeTyping();
|
||||||
@@ -3245,6 +3538,7 @@ function finalizeFailedTurn(
|
|||||||
lastBrainWrap = fwrap;
|
lastBrainWrap = fwrap;
|
||||||
}
|
}
|
||||||
markLastRetryable(); // the in-bubble Retry lands on the failed bubble
|
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 —
|
/* 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
|
// text-only send, every re-ask — A7: a redo re-sends the text
|
||||||
// only) leaves the bubble and the record byte-identical to
|
// only) leaves the bubble and the record byte-identical to
|
||||||
// pre-phase.
|
// pre-phase.
|
||||||
addMessage(
|
const userWrap = addMessage(
|
||||||
"user",
|
"user",
|
||||||
renderMarkdown(text),
|
renderMarkdown(text),
|
||||||
true, // reveal my message (owner-kept)
|
true, // reveal my message (owner-kept)
|
||||||
image ? { src: image.src || image.path, alt: image.alt } : null
|
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
|
// Persistence save point 1: the question is stored the moment it is
|
||||||
// sent, so a failed/interrupted turn never loses it. The `image`
|
// sent, so a failed/interrupted turn never loses it. The `image`
|
||||||
// key (the stored path) joins the `bor.chat.v1` record only when an
|
// 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
|
appendRelated(wrap, ev.related); // phase 113 — above the actions row
|
||||||
appendTuneButton(wrap); // every completed brain bubble is tunable
|
appendTuneButton(wrap); // every completed brain bubble is tunable
|
||||||
markLastRetryable(); // phase 49: the Retry button is last-bubble-only
|
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
|
// Phase 59: the RAW persisted markdown (never the rendered
|
||||||
// HTML) — exactly the string rememberBrainTurn stores above,
|
// HTML) — exactly the string rememberBrainTurn stores above,
|
||||||
// so a reload (the restore path) offers the identical draft.
|
// 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
|
}, leavePartialIndex); // persist what the user actually saw
|
||||||
lastBrainWrap = fwrap;
|
lastBrainWrap = fwrap;
|
||||||
markLastRetryable(); // phase 49: the fallback bubble is retryable too
|
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
|
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (aborted) {
|
if (aborted) {
|
||||||
// The 300s guard already took the turn to the error state — its
|
// 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.
|
// 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") {
|
} else if (stoppedByUser || err?.name === "AbortError") {
|
||||||
// Phase 48 (owner-locked): the STOP path — no error banner. When
|
// Phase 48 (owner-locked): the STOP path — no error banner. When
|
||||||
// answer text streamed, keep the partial on screen and persist it
|
// answer text streamed, keep the partial on screen and persist it
|
||||||
@@ -3767,6 +4075,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
|||||||
}, leavePartialIndex);
|
}, leavePartialIndex);
|
||||||
lastBrainWrap = wrap; // the stopped partial is the prime retry candidate
|
lastBrainWrap = wrap; // the stopped partial is the prime retry candidate
|
||||||
markLastRetryable(); // phase 49: Retry on the stopped partial
|
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
|
// Pre-token / thinking-only stop: persist NOTHING brain-side
|
||||||
// (phase-20 convention — the question is already saved on send).
|
// (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
|
cancelStream(res); // the reader lock is released — no unhandled rejection
|
||||||
turnAbort = null; // phase 48: the turn's abort owner is spent
|
turnAbort = null; // phase 48: the turn's abort owner is spent
|
||||||
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
|
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.";
|
if (stoppedByUser) sendStatus.textContent = "Answer stopped.";
|
||||||
// Phase 143 (TODO L5): the focus-back is a DESKTOP courtesy — on a
|
// Phase 143 (TODO L5): the focus-back is a DESKTOP courtesy — on a
|
||||||
// coarse pointer (a phone/tablet) focusing the composer pops the
|
// coarse pointer (a phone/tablet) focusing the composer pops the
|
||||||
|
|||||||
@@ -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 svg { width: 14px; height: 14px; display: block; }
|
||||||
.retry-btn:hover { background: var(--brand-soft); color: var(--ink); }
|
.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"
|
/* 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
|
button — the bottom-right action of every completed brain bubble's
|
||||||
meta row (the JS gates: admin + a configured docs repo; this rule
|
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;
|
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,
|
/* Header panel above the messages: notes newest-first, per-note delete,
|
||||||
designed empty state. */
|
designed empty state. */
|
||||||
.steering-panel {
|
.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); }
|
.app-main > .steering-panel { width: calc(100% - 1.8rem); }
|
||||||
.tune-btn { min-height: 44px; }
|
.tune-btn { min-height: 44px; }
|
||||||
.retry-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
|
/* Phase 27: the tuning page squeezes like the other cards — the row
|
||||||
padding tightens; the icon+label actions keep their 44px floor and
|
padding tightens; the icon+label actions keep their 44px floor and
|
||||||
the note text ellipsizes (min-width: 0). */
|
the note text ellipsizes (min-width: 0). */
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ target-version = "py312"
|
|||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
|
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]
|
[tool.pyright]
|
||||||
pythonVersion = "3.12"
|
pythonVersion = "3.12"
|
||||||
typeCheckingMode = "standard"
|
typeCheckingMode = "standard"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1036,6 +1036,170 @@ def test_shared_serve_includes_the_brain_answer_image_ref(
|
|||||||
assert "answer_image" not in messages[0]
|
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/<token>`` 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(
|
def test_public_read_wrong_and_revoked_tokens_404_with_one_detail(
|
||||||
admin_client: TestClient,
|
admin_client: TestClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -66,9 +66,18 @@ def test_typing_elapsed_hint_is_built_with_createelement_and_textcontent() -> No
|
|||||||
)
|
)
|
||||||
# No innerHTML on the typing bubble — the skeleton template
|
# No innerHTML on the typing bubble — the skeleton template
|
||||||
# (wrap.innerHTML in addTyping) is the only bubble-adjacent HTML
|
# (wrap.innerHTML in addTyping) is the only bubble-adjacent HTML
|
||||||
# write, and it must not gain a second one.
|
# write of the typing indicator, and the clock must not gain a
|
||||||
assert js.count("bubble.innerHTML") == 0, (
|
# second one (the typing-clock context: addTyping / startThinking
|
||||||
"the clock must never assign innerHTML to the typing bubble"
|
# 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -365,10 +365,13 @@ def test_stop_finalize_appends_actions_last() -> None:
|
|||||||
|
|
||||||
def test_failed_funnel_note_and_retry_join_the_actions_row() -> None:
|
def test_failed_funnel_note_and_retry_join_the_actions_row() -> None:
|
||||||
"""The phase-120 failure funnel (finalizeFailedTurn): the Failed
|
"""The phase-120 failure funnel (finalizeFailedTurn): the Failed
|
||||||
note is appended and the funnel ENDS with markLastRetryable — the
|
note is appended and the funnel ENDS with markLastRetryable
|
||||||
note + Retry in the failed bubble's actions row (the partial
|
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
|
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()
|
js = _js()
|
||||||
header = (
|
header = (
|
||||||
"function finalizeFailedTurn(\n"
|
"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()]
|
lines = [line for line in body.splitlines() if line.strip()]
|
||||||
assert lines[-1].strip() == "}" and lines[-2].strip().startswith(
|
assert lines[-1].strip() == "}" and lines[-2].strip().startswith(
|
||||||
|
"markLastEditable();"
|
||||||
|
) and lines[-3].strip().startswith(
|
||||||
"markLastRetryable();"
|
"markLastRetryable();"
|
||||||
), (
|
), (
|
||||||
"the funnel still ends with the markLastRetryable() call — the "
|
"the funnel ends with the markLastRetryable() + "
|
||||||
"Retry button lands on the failed bubble's actions row"
|
"markLastEditable() pair — the Retry + Edit buttons land on "
|
||||||
|
"the failed bubble's actions row"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -175,14 +175,21 @@ def test_finalize_failed_turn_persists_failed_in_both_shapes() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_finalize_failed_turn_ends_retryable() -> None:
|
def test_finalize_failed_turn_ends_retryable() -> None:
|
||||||
"""The funnel ENDS with ``markLastRetryable()`` (the last statement
|
"""The funnel ENDS with ``markLastRetryable()`` immediately
|
||||||
— a trailing comment is fine) — the in-bubble Retry button lands
|
followed by ``markLastEditable()`` (phase 159 task 01 — the
|
||||||
on the failed bubble (the last brain wrap)."""
|
failed turn's question is editable too: the trim-and-re-ask
|
||||||
|
remedy) — the in-bubble Retry + Edit buttons land on the failed
|
||||||
|
bubble (the last brain wrap / the last user wrap)."""
|
||||||
js = _js()
|
js = _js()
|
||||||
body = _fn_body(js, "finalizeFailedTurn").rstrip()
|
body = _fn_body(js, "finalizeFailedTurn").rstrip()
|
||||||
last_line = body.splitlines()[-1].strip()
|
lines = [line for line in body.splitlines() if line.strip()]
|
||||||
assert last_line.startswith("markLastRetryable();"), (
|
assert lines[-1].strip().startswith("markLastEditable();"), (
|
||||||
"finalizeFailedTurn must end with the markLastRetryable() call"
|
"finalizeFailedTurn must end with the markLastEditable() call "
|
||||||
|
"(phase 159 — the failed turn's question stays editable)"
|
||||||
|
)
|
||||||
|
assert lines[-2].strip().startswith("markLastRetryable();"), (
|
||||||
|
"the markLastEditable() call must sit immediately after "
|
||||||
|
"markLastRetryable() (the paired settle)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -319,8 +319,22 @@ def test_stop_branch_keeps_partial_and_persists_stopped() -> None:
|
|||||||
assert "stopped: true" in branch, "the owner-locked optional marker"
|
assert "stopped: true" in branch, "the owner-locked optional marker"
|
||||||
assert "thinking: thinkingAcc || undefined" in branch
|
assert "thinking: thinkingAcc || undefined" in branch
|
||||||
assert "tools: toolAcc.length ? toolAcc : undefined" in branch
|
assert "tools: toolAcc.length ? toolAcc : undefined" in branch
|
||||||
# The confirmation rides the single settle in the finally.
|
# The confirmation rides the single settle in the finally —
|
||||||
finally_block = js[finally_idx : finally_idx + 900]
|
# brace-matched from the finally's opening brace, so the
|
||||||
|
# phase-159 settle additions (the markLastEditable pass + its
|
||||||
|
# comment) cannot push the line out of a fixed window.
|
||||||
|
finally_brace = js.index("{", finally_idx)
|
||||||
|
depth = 0
|
||||||
|
finally_end = finally_brace
|
||||||
|
for k in range(finally_brace, len(js)):
|
||||||
|
if js[k] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif js[k] == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
finally_end = k
|
||||||
|
break
|
||||||
|
finally_block = js[finally_idx : finally_end + 1]
|
||||||
assert 'if (stoppedByUser) sendStatus.textContent = "Answer stopped."' in finally_block
|
assert 'if (stoppedByUser) sendStatus.textContent = "Answer stopped."' in finally_block
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,13 +27,25 @@ def test_leave_partial_index_is_a_module_turn_local() -> None:
|
|||||||
"""`leavePartialIndex` is a module-scope turn local (like
|
"""`leavePartialIndex` is a module-scope turn local (like
|
||||||
persistedOnLeave): declared -1, reset at the top of runTurn, and
|
persistedOnLeave): declared -1, reset at the top of runTurn, and
|
||||||
written ONLY by the pagehide handler (the one `= conversation.length
|
written ONLY by the pagehide handler (the one `= conversation.length
|
||||||
- 1` assignment). No other code may move the correlation."""
|
- 1` assignment) — plus the phase-159 (task 02) truncation reset
|
||||||
|
(the SANCTIONED third write: the edit's Save clears it to -1 WHEN
|
||||||
|
IT POINTED PAST editIdx — a partial saved from the dropped tail
|
||||||
|
must not be re-persisted into the new shape; the settle's identity
|
||||||
|
guard would otherwise fall back to an append; the next runTurn
|
||||||
|
also resets it turn-locally regardless). No other code may move
|
||||||
|
the correlation."""
|
||||||
js = _js()
|
js = _js()
|
||||||
assert "let leavePartialIndex = -1" in js, "module-scope declaration"
|
assert "let leavePartialIndex = -1" in js, "module-scope declaration"
|
||||||
# The two -1 writes are the declaration and the runTurn reset.
|
# The -1 writes: the declaration, the runTurn reset, and the
|
||||||
assert js.count("leavePartialIndex = -1;") == 2, (
|
# phase-159 truncation reset (the sanctioned third).
|
||||||
"only the declaration and the runTurn reset write -1"
|
assert js.count("leavePartialIndex = -1;") == 3, (
|
||||||
|
"only the declaration, the runTurn reset, and the phase-159 "
|
||||||
|
"truncation reset write -1"
|
||||||
)
|
)
|
||||||
|
# The phase-159 (task 02) truncation reset is GATED: it fires only
|
||||||
|
# when the pointer pointed past the edited record (a partial on or
|
||||||
|
# before the edited record is untouched).
|
||||||
|
assert "if (leavePartialIndex > editIdx) leavePartialIndex = -1;" in js
|
||||||
# The one and only index write is the pagehide handler's.
|
# The one and only index write is the pagehide handler's.
|
||||||
assert js.count("leavePartialIndex = conversation.length - 1") == 1, (
|
assert js.count("leavePartialIndex = conversation.length - 1") == 1, (
|
||||||
"only the pagehide handler records the partial's index"
|
"only the pagehide handler records the partial's index"
|
||||||
|
|||||||
Reference in New Issue
Block a user