phase: 115_doc_draft_discard
Build and Push Containers / build-and-push-app (push) Successful in 2m12s
Build and Push Containers / build-and-push-db (push) Successful in 14s

All green — this was the final verification pass; everything from the four completed tasks was already in the working tree and verified.

**Phase 115 — Doc drafts: Discard + DELETE route + title fix — verification report**

- Verified all 4 task deliverables present: DELETE route (`app/api/doc_drafts.py`), Discard UI (`doc-edit.html` + `doc-edit.js` + `.discard-draft` CSS), title fix (`defaultDocTitle(wrap)` pairing + `saveAsDoc` call site), and all test pins (integration, frontend unit, E2E). No code changes needed.
- **Completion criteria:**
  1. ✅ Orphaned draft discardable from edit screen; row gone — `test_delete_removes_row_and_invalidates_token` (204 → GET 404), unknown-token 404, admin-gate 403 on all routes, E2E `test_discard_draft_from_edit_screen` all pass.
  2. ✅ Title after retry redo = redone answer's own question — E2E `test_save_title_is_the_redo_question_after_retry` passes.
  3. ✅ Push flow byte-identical — `git diff` shows only the new DELETE route + module docstring; all 7 existing push tests green.
  4. ✅ `uv run pytest --cov=app` → **2457 passed**, app coverage **99%** (>90%); `uv run pytest tests/e2e/test_save_doc_session.py -v --no-cov` → **4 passed**; `uv run ruff check .` → clean; `uv run pyright` → 0 errors.
  5. ⏳ Commit + phase-dir move left to the harness (per executor rules, no `git` run; all changes left in the working tree).
- No defects found; no deviations.
- Next pending phase: none in `todo/` other than this one (`115_doc_draft_discard` is the last).
This commit is contained in:
2026-09-15 05:35:05 -04:00
parent 3846f26a58
commit 990c8adf13
29 changed files with 1384 additions and 30 deletions
+185 -4
View File
@@ -43,9 +43,19 @@ App boots (the conftest pattern, module-scoped — as in
``test_chat_rag.py`` fixture) — the three questions gate HIGH, so
every turn is a grounded answer with the deterministic marker.
Phase 115 (TODO L7) extends this suite with the discard + title
acceptance: an orphaned draft can be DISCARDED from the edit screen
(confirm → DELETE → back on the chat, the row gone — the API check
confirms the 404), and a save-as-doc after a Retry redo-in-place
(phase 49) is titled with the redone answer's OWN question (the
paired user bubble), not the unrelated trailing question that landed
in the conversation after the redo (the pre-115 last-record rule).
Test → story mapping (Playwright Mapping Rule):
1. ``test_full_session_save_and_edit_out``
2. ``test_earlier_bubble_button_saves_whole_session``
3. ``test_discard_draft_from_edit_screen`` (phase 115)
4. ``test_save_title_is_the_redo_question_after_retry`` (phase 115)
"""
from __future__ import annotations
@@ -63,7 +73,7 @@ from urllib.parse import parse_qs, urlsplit
import httpx
import pytest
from playwright.sync_api import Locator, Page, expect
from playwright.sync_api import Dialog, Locator, Page, expect
from sqlalchemy import text
from app.config import Settings
@@ -443,6 +453,21 @@ def _assert_no_thinking_leak(body: str) -> None:
assert line not in body, f"thinking scratchpad text leaked into the doc: {line!r}"
def _draft_status(app_url: str, token: str) -> httpx.Response:
"""One GET of the draft by its uuid4 token, as the ADMIN (the
drafts API is admin-only — anonymous gets 403, which would hide a
real 404): 200 while the draft exists, 404 ``draft not found``
once discarded (the phase-115 acceptance's row-gone check — the
API is the authority, the same trust model as the edit screen)."""
client = httpx.Client(timeout=30.0)
try:
r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, "the E2E admin login must succeed"
return client.get(f"{app_url}/api/doc-drafts/{token}")
finally:
client.close()
# ---------------------------------------------------------------------------
# 1. The whole loop: 3 turns → save → all turns in order → edit a
# previous reply out → push → the bare repo agrees byte-for-byte
@@ -543,9 +568,14 @@ def test_earlier_bubble_button_saves_whole_session(
_open_edit_screen(
page, page.locator(".msg.brain .save-as-doc-btn").first
)
# …with the UNCHANGED title (the last question, not the first).
expect(page.locator("#draft-title")).to_have_value(q3)
expect(page.locator("#draft-path")).to_have_value(f"docs/{doc_slug(q3)}.md")
# …with the phase-115 title: the QUESTION THE ANSWER ANSWERED —
# the first bubble's paired user bubble (Q1, whitespace-free and
# ≤120 chars, so it arrives verbatim), no longer the
# conversation's last record (the retry-redo mismatch fix —
# saving from an earlier bubble titles that bubble's own
# question, while the LAST bubble's button still yields Q3).
expect(page.locator("#draft-title")).to_have_value(q1)
expect(page.locator("#draft-path")).to_have_value(f"docs/{doc_slug(q1)}.md")
body = session_transcript(turns)
expect(page.locator("#draft-body")).to_have_value(body)
_assert_no_thinking_leak(body)
@@ -558,3 +588,154 @@ def test_earlier_bubble_button_saves_whole_session(
assert tip_after == tip_before, (
f"canceling moved the docs branch: {tip_before} -> {tip_after}"
)
# ---------------------------------------------------------------------------
# 3. Phase 115 (TODO L7): an orphaned draft can be DISCARDED from the
# edit screen — confirm → DELETE → back on the chat; the row is
# gone afterward (the API 404s, the row is out of Postgres)
# ---------------------------------------------------------------------------
def test_discard_draft_from_edit_screen(
page: Page,
app_url: str,
mock_llm: int,
db_ready: None,
) -> None:
"""The discard acceptance: save as doc → the edit screen →
Discard → the confirm dialog (destructive + irreversible) →
back on the chat page, and the draft is GONE (the API check:
GET by the now-dead token 404s with the unknown-token message, and
the row is out of Postgres). The confirm is the gate: DISMISSING
it leaves the screen AND the draft untouched (no request, no
navigation, the button re-armable)."""
page.set_default_timeout(30_000)
_login_admin(page, app_url)
_ask(page, Q3)
# One grounded turn → one save action → the edit screen (token).
expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(1)
token = _open_edit_screen(page, page.locator(".msg.brain .save-as-doc-btn").last)
alive = _draft_status(app_url, token)
assert alive.status_code == 200, "the draft must exist before the discard"
# The confirm is the gate (a listener-less dialog is auto-dismissed
# by Playwright, so the policy rides a single registered handler —
# the house pattern, e.g. test_steering.py): the FIRST confirm is
# dismissed, the second accepted. The handler runs while the page's
# JS thread is blocked in confirm() — during the click call.
dialogs: list[Dialog] = []
def _handle(d: Dialog) -> None:
dialogs.append(d)
if len(dialogs) == 1:
d.dismiss() # first attempt: the user changes their mind
else:
d.accept() # second attempt: the discard is meant
page.on("dialog", _handle)
# DISMISS the confirm: no delete, no navigation — the screen stays,
# the draft is intact, the control is armed for a real attempt.
page.click("#discard-draft")
assert len(dialogs) == 1, "the Discard control must confirm before deleting"
dismissed = dialogs[0]
assert dismissed.type == "confirm"
assert "cannot be undone" in dismissed.message
expect(page).to_have_url(f"{APP_URL}/doc-edit.html?draft={token}")
expect(page.locator("#discard-draft")).to_be_enabled()
still = _draft_status(app_url, token)
assert still.status_code == 200, (
"a dismissed confirm must not delete the draft"
)
# ACCEPT the confirm: the DELETE lands, the 204 returns to the
# chat (the draft's only other home — no drafts list exists).
page.click("#discard-draft")
assert len(dialogs) == 2, "the second attempt must confirm again"
page.wait_for_url(APP_URL + "/", timeout=30_000)
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=30_000)
# The acceptance: the draft row is GONE — the token is dead (GET
# 404s with the exact unknown-token message) …
gone = _draft_status(app_url, token)
assert gone.status_code == 404
assert gone.json() == {"detail": "draft not found"}
# …and the row is out of Postgres (the 404 alone would pass for
# any unknown token — the row count is the acceptance).
with SessionLocal() as db:
n = db.execute(
text("SELECT count(*) FROM doc_drafts WHERE token = :t"),
{"t": token},
).scalar_one()
assert n == 0, "the discarded draft's row must be gone from Postgres"
# ---------------------------------------------------------------------------
# 4. Phase 115 (TODO L7): the draft title after a Retry redo-in-place
# — the redone answer's OWN question (its paired user bubble), not
# the unrelated trailing question that followed the redo
# ---------------------------------------------------------------------------
def test_save_title_is_the_redo_question_after_retry(
page: Page,
app_url: str,
mock_llm: int,
db_ready: None,
) -> None:
"""The title acceptance: ask → the answer → Retry (redo-in-place,
phase 49) → an UNRELATED trailing question → save as doc on the
REDONE answer. The pre-115 rule (the LAST user record in the
conversation) titled the draft with the trailing question — the
junk-title edge case. The fixed rule pairs the answer with ITS
question: the edit screen's title field is the REDONE question
(the body stays the whole session — the redo replaced turn 1 in
place, so the transcript is the same shape as a fresh two-turn
session)."""
page.set_default_timeout(30_000)
_login_admin(page, app_url)
_ask(page, Q2)
# Redo in place: tag the current (only) wrap, click the Retry
# button on it — the OLD wrap must leave the DOM and the fresh
# answer stream into its place (the mock is byte-stable: the redo
# of Q2 quotes Q2). The question is never duplicated.
page.evaluate(
"""() => {
const wraps = document.querySelectorAll("#messages > .msg.brain");
wraps[wraps.length - 1].setAttribute("data-retry-marker", "old-b1");
}"""
)
expect(page.locator(".retry-btn")).to_have_count(1)
page.locator(".retry-btn").click()
expect(page.locator("#send-label")).to_have_text("Stop", timeout=5_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
expect(page.locator("[data-retry-marker='old-b1']")).to_have_count(0)
expect(page.locator(".msg.user .bubble")).to_have_count(1)
# The unrelated trailing question — the LAST user record once it
# lands (the pre-115 title source, the junk-question mismatch).
_ask(page, Q3)
# Save the REDONE answer (the FIRST brain bubble): the title is the
# redone question — NOT the trailing one …
expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(2)
_open_edit_screen(page, page.locator(".msg.brain .save-as-doc-btn").first)
expect(page.locator("#draft-title")).to_have_value(Q2)
expect(page.locator("#draft-path")).to_have_value(f"docs/{doc_slug(Q2)}.md")
assert Q3 not in page.input_value("#draft-title"), (
"the title must be the redone question, not the trailing one"
)
# The body is still the WHOLE session (the redo replaced turn 1 in
# place — the transcript is the fresh two-turn shape, byte-exact
# against the mock: the composed answer is a pure function of the
# last user message + the document context, history-independent).
a2 = _stream_chat_answer(app_url, Q2)
a3 = _stream_chat_answer(app_url, Q3)
expect(page.locator("#draft-body")).to_have_value(
session_transcript([(Q2, a2), (Q3, a3)])
)
_assert_no_thinking_leak(page.input_value("#draft-body"))