feat(docs): save the whole chat session as a doc
Build and Push Containers / build-and-push-app (push) Successful in 1m46s
Build and Push Containers / build-and-push-db (push) Successful in 12s

Phase 75 (TODO.md L4): "Save as doc" now drafts a document from the
ENTIRE chat session — every question and answer up to the click, in
order — instead of only the clicked bubble's answer; the existing
doc-edit screen's free-form body editing is how the user edits out
anything they don't want to keep from previous replies (no new UI
surface).

Task 01 (frontend):
- app.js buildSessionTranscript(): walks the bor.chat.v1 conversation
  record in order — a numbered section per user turn ("## N.
  <question, raw>" + blank line + the raw answer text; more answers
  join under the same heading), sections blank-line separated, all
  trailing whitespace collapsed to one final newline. Only the raw
  persisted text travels (m.who + m.text — no thinking blocks, no
  source chips, no tune metadata); a brain record before the first
  user record is skipped; a heading-only section marks a user turn
  whose answer never landed (A6, owner-confirmed 2026-09-08).
- saveAsDoc(btn): the draft body is buildSessionTranscript(); the
  dead single-bubble markdown parameter is dropped (the button's
  appendSaveAsDocButton signature is unchanged — one button per
  bubble). Title/path/double-click guard/hand-off are unchanged
  (defaultDocTitle: the last question, whitespace-collapsed,
  <=120 chars; docs/<slug>.md).
- Unit: the app.js source pins move to the transcript shape (whole
  session, no thinking, no dead parameter).

Task 02 (E2E):
- tests/e2e/test_save_doc_session.py (bare-repo fixture, the
  phase-59 convention — git as source of truth): three DISTINCT
  on-topic turns in one session (turn 1 carries the phase-17
  "think out loud" trigger so its record has a thinking block the
  transcript must exclude) -> save on the LAST bubble -> the
  prefilled body is ## 1./## 2./## 3. in order, byte-exact against
  the deterministic mock, thinking-free -> edit the whole
  section-2 block out of the body -> push -> git show
  bor-docs:<path> equals the EDITED body byte-for-byte (section 2's
  question and answer provably absent; sections 1 and 3 byte-exact;
  the UI's sha prefix is git rev-parse bor-docs). Second test:
  the button on the FIRST bubble still drafts the whole session
  (A6 — the transcript is the session at click time, title stays
  the last question); canceling leaves the branch tip untouched.
- tests/e2e/test_response_to_docs.py: the phase-59 single-turn body
  expectation moves to the transcript shape ("## 1. <question>" +
  the answer's markdown) — the rest of the suite unchanged.

Also lands the phase-74 file moves (00_phase.md /
03_mock_marker_e2e.md -> complete/) and the phase reports — the
house convention of committing .agents/ with the phase.
This commit is contained in:
2026-09-05 16:59:31 -04:00
parent 055c0b5d85
commit 0e4651c779
16 changed files with 974 additions and 34 deletions
+40 -15
View File
@@ -12,8 +12,10 @@ on PATH — the suite skips without it):
The loop under test: a completed brain bubble carries a bottom-right
"Save as doc" action (admin + a configured ``BOR_DOCS_REPO``) → it
opens ``/doc-edit.html?draft=<token>`` prefilled (auto-title from the
last question, path ``docs/<slug>.md``, body = the answer's MARKDOWN
SOURCE — never the rendered HTML) → Push commits + pushes to the
last question, path ``docs/<slug>.md``, body = the FULL-SESSION
TRANSCRIPT — phase 75 A6; every session in this suite is single-turn,
so it is ``## 1. <the question>`` + the answer's markdown source —
never the rendered HTML) → Push commits + pushes to the
``.env``-configured branch of the ``.env``-configured repo. Every
success assertion reads the **bare repo itself** (``git show
<branch>:<path>``, ``git rev-list``, ``git rev-parse``) — the UI text
@@ -34,9 +36,9 @@ App boots (the conftest pattern, module-scoped — as in
The mock LLM keeps every answer byte-deterministic: the suite replays
the same question through ``POST /api/chat`` (raw SSE, the
``test_chat_rag.py`` pattern) to recover the exact markdown source the
draft must carry — so "body == the answer's markdown source" is an
exact-byte assertion, not a contains check.
``test_chat_rag.py`` pattern) to recover the exact answer bytes the
draft must carry — so "body == the single-turn transcript of that
answer" is an exact-byte assertion, not a contains check.
Test → story mapping (Playwright Mapping Rule):
1. ``test_save_edit_push``
@@ -142,6 +144,16 @@ def doc_slug(title: str) -> str:
return slug or "note"
def transcript(question: str, answer: str) -> str:
"""The phase-75 draft body (app.js ``buildSessionTranscript``,
A6) for the SINGLE-turn sessions this suite drives, as stored:
``## 1. <question>`` + blank line + the answer's raw markdown.
The builder's single trailing newline is stripped by the draft
API's ``.strip()`` (and the edit screen's push trims again), so
the stored — and pushed — bytes end at the answer."""
return f"## 1. {question}\n\n{answer}"
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login
— used to call the admin API with plain httpx (the
@@ -440,18 +452,21 @@ def test_save_edit_push(
expect(page.locator("#draft-path")).to_have_value(
f"docs/{doc_slug(QUESTION_1)}.md"
)
# Body == the rendered answer's MARKDOWN SOURCE: the SSE replay
# recovers the exact bytes the UI accumulated (the mock is
# Body == the FULL-SESSION transcript (phase 75 A6): this
# session is single-turn, so it is "## 1. <the question>" + blank
# line + the answer's raw markdown. The SSE replay recovers the
# exact answer bytes the UI accumulated (the mock is
# byte-deterministic on the same KB + question) — and they are
# plain markdown, not rendered HTML.
raw = _stream_chat_answer(app_url, QUESTION_1)
assert MOCK_ANSWER_MARKER in raw and QUESTION_1 in raw
assert "<" not in raw and ">" not in raw, "the draft body must be markdown, not HTML"
expect(page.locator("#draft-body")).to_have_value(raw)
body = transcript(QUESTION_1, raw)
expect(page.locator("#draft-body")).to_have_value(body)
# Modify the doc (the story's "modify before [pushing]"): a
# distinctive marker line the bare repo must show after the push.
edited = f"{raw}\n\n{E2E_MARKER}"
edited = f"{body}\n\n{E2E_MARKER}"
page.fill("#draft-body", edited)
# Push → the live region reports the branch + a 7-char commit sha…
@@ -491,7 +506,9 @@ def test_second_push_fast_forwards(
_login_admin(page, app_url)
_ask(page, app_url, QUESTION_2)
# Save the second answer (different question → different slug)…
# Save the second answer (different question → different slug);
# its session is single-turn too, so the body is the single-turn
# transcript of that answer…
expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(1)
_open_edit_screen(page)
expect(page.locator("#draft-title")).to_have_value(QUESTION_2)
@@ -499,7 +516,7 @@ def test_second_push_fast_forwards(
f"docs/{doc_slug(QUESTION_2)}.md"
)
raw2 = _stream_chat_answer(app_url, QUESTION_2)
expect(page.locator("#draft-body")).to_have_value(raw2)
expect(page.locator("#draft-body")).to_have_value(transcript(QUESTION_2, raw2))
# …and push WITHOUT editing — a new commit on the same branch.
_push_and_read_sha(page)
@@ -511,13 +528,21 @@ def test_second_push_fast_forwards(
.strip()
== "2"
)
# …file 2 landed with its unedited body…
# …file 2 landed with its unedited body (the single-turn
# transcript)…
path2 = f"docs/{doc_slug(QUESTION_2)}.md"
assert _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path2}"]) == raw2
assert (
_git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path2}"])
== transcript(QUESTION_2, raw2)
)
# …and file 1 from test 1 is still at its path, byte-for-byte
# (deterministic reconstruction: the mock answer + the marker line).
# (deterministic reconstruction: the single-turn transcript + the
# marker line).
path1 = f"docs/{doc_slug(QUESTION_1)}.md"
expected_first = f"{_stream_chat_answer(app_url, QUESTION_1)}\n\n{E2E_MARKER}"
expected_first = (
f"{transcript(QUESTION_1, _stream_chat_answer(app_url, QUESTION_1))}"
f"\n\n{E2E_MARKER}"
)
assert _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path1}"]) == expected_first
+549
View File
@@ -0,0 +1,549 @@
"""Phase 75 story E2E (Playwright): "Save as doc" captures the WHOLE
chat session.
TODO.md L4 (owner 2026-09-05): "Then, update the 'save as doc'
process to include the output from the entire chat session rather than
the last response. The user can edit out anything they don't want to
keep from previous replies."
Run in isolation (DB must be up: ``podman compose up -d db``; ``git``
on PATH — the suite skips without it):
uv run pytest tests/e2e/test_save_doc_session.py -v --no-cov
The loop under test: a MULTI-turn session (three DISTINCT on-topic
questions in one chat page — the mock's default composed answer embeds
each question's first 80 chars, so the three answers are byte-distinct
and assertable) → "Save as doc" on any completed brain bubble drafts
the FULL-SESSION transcript (phase 75 A6: ``## N. <question>`` + the
answer's raw markdown, every turn up to the click, in order — NOT just
the clicked bubble) → the edit screen shows it prefilled → the user
edits an unwanted previous reply OUT of the body (A7: the existing
free-form body field) → Push commits + pushes to the ``.env``-
configured branch of the ``.env``-configured repo. Every success
assertion reads the **bare repo itself** (``git show <branch>:<path>``
== the EDITED body byte-for-byte; ``git rev-parse`` for the sha the UI
reported) — the UI text is only the entry point (the phase-59
convention, D3: no PR is ever created or attempted).
Turn 1 is asked with the phase-17 ``think out loud`` trigger, so its
brain record carries a ``thinking`` block (the deterministic
scratchpad) — the transcript must EXCLUDE it (A6: only the raw
``m.text`` travels), and both the draft body and the pushed file are
asserted free of the scratchpad text.
App boots (the conftest pattern, module-scoped — as in
``test_response_to_docs.py``):
* the module app boots with ``BOR_DOCS_REPO=<tmp>/docs.git`` (a local
BARE repo seeded with one commit on ``main``), ``BOR_DOCS_BRANCH=
bor-docs``, ``BOR_DOCS_BASE_BRANCH=main``, ``BOR_DOCS_WORK_DIR=
<tmp>/docs-work``;
* the KB is the ``tests/fixtures/docs/`` set (the
``test_chat_rag.py`` fixture) — the three questions gate HIGH, so
every turn is a grounded answer with the deterministic marker.
Test → story mapping (Playwright Mapping Rule):
1. ``test_full_session_save_and_edit_out``
2. ``test_earlier_bubble_button_saves_whole_session``
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import subprocess
import sys
from collections.abc import Iterator
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from urllib.parse import parse_qs, urlsplit
import httpx
import pytest
from playwright.sync_api import Locator, Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
APP_URL = f"http://127.0.0.1:{APP_PORT}"
BRANCH = "bor-docs"
BASE_BRANCH = "main"
#: Three DISTINCT on-topic questions in ONE session — the house
#: phrasings proven HIGH-gate in other suites, so every turn renders a
#: grounded answer with the deterministic marker (never a deflection).
#: Turn 1 carries the phase-17 thinking trigger (its brain record then
#: carries a ``thinking`` block the transcript must exclude); the
#: phase-74 suite pins the exact grounded behavior of this phrasing.
Q1 = "think out loud — how is my Kubernetes cluster set up?"
Q2 = "What is in the new-service deployment?"
Q3 = "How did I install gitlab?"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
#: Fixed lines of the mock's deterministic scratchpad
#: (``mock_llm.compose_thinking``) — present in turn 1's persisted
#: ``thinking`` block, and ABSENT from every composed answer and from
#: the questions themselves, so their absence from the draft body and
#: the pushed file proves the thinking block never reached the doc.
THINKING_LINES = (
"Step 1: Read the question carefully",
"Scratch 3: versions and ports are the facts",
)
#: The edit screen's URL shape (the save action navigates with the
#: uuid4 token).
DRAFT_URL_RE = re.compile(r"/doc-edit\.html\?draft=[0-9a-f-]{36}")
#: The success line (doc-edit.js): `Pushed to <branch> — commit <sha7>.`
SUCCESS_SHA_RE = re.compile(r"commit ([0-9a-f]{7})\.$")
def _git_available() -> bool:
try:
return subprocess.run(
["git", "--version"], capture_output=True, timeout=10
).returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
pytestmark = pytest.mark.skipif(
not _git_available(), reason="git is not on PATH (the docs push is real git)"
)
def _git(args: list[str], cwd: Path | None = None) -> str:
"""One git command (the bare repo is the source of truth); fail loud."""
proc = subprocess.run(
["git", *args], cwd=cwd, capture_output=True, text=True, timeout=60
)
assert proc.returncode == 0, f"git {' '.join(args)} failed: {proc.stderr}"
return proc.stdout
def _branch_tip(bare: Path) -> str | None:
"""The branch's tip sha, or ``None`` while the branch does not
exist yet (a cancel-only test may run before any push created it)."""
proc = subprocess.run(
["git", "-C", str(bare), "rev-parse", BRANCH],
capture_output=True,
text=True,
timeout=30,
)
return proc.stdout.strip() if proc.returncode == 0 else None
def doc_slug(title: str) -> str:
"""The app.js slug rule (phase 59 locked assumption), ported:
lowercase, runs of non-alphanumerics → ``-``, trimmed, ≤60 chars,
empty → ``note`` (the trailing trim survives a mid-dash 60-cut)."""
slug = (
re.sub(r"[^a-z0-9]+", "-", title.lower())
.strip("-")[:60]
.rstrip("-")
)
return slug or "note"
def session_transcript(turns: list[tuple[str, str]]) -> str:
"""The phase-75 draft body (app.js ``buildSessionTranscript``, A6)
for an N-turn session, as STORED: a numbered section per user turn
(``## N. <question>`` + blank line + the answer's raw markdown),
sections blank-line separated. The builder's single trailing
newline is stripped by the draft API's ``.strip()`` (and the edit
screen's push trims again), so the stored — and pushed — bytes end
at the last answer's last char (``rstrip`` mirrors both)."""
sections = [
f"## {i}. {question}\n\n{answer}"
for i, (question, answer) in enumerate(turns, start=1)
]
return "\n\n".join(sections).rstrip()
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def docs_repo(tmp_path_factory: pytest.TempPathFactory) -> SimpleNamespace:
"""The local BARE docs repo (the .env remote, D3-generic): one
seed commit (``README.md``) pushed as ``main``. ``work`` is where
the app's ``BOR_DOCS_WORK_DIR`` checkout lands (it persists for the
whole module — the push exercises the existing-checkout path)."""
base = tmp_path_factory.mktemp("docs-git")
bare = base / "docs.git"
_git(["init", "--bare", str(bare)])
seed = base / "seed"
_git(["init", "-b", "main", str(seed)])
(seed / "README.md").write_text("# e2e docs repo\n", encoding="utf-8")
_git(["add", "--", "README.md"], cwd=seed)
# -c identity + no GPG signing: the machine's global git config
# (gpgsign=true here) must not leak into the fixture.
_git(
[
"-c", "user.name=E2E Seeder",
"-c", "user.email=e2e@local",
"-c", "commit.gpgsign=false",
"commit", "-m", "seed: README",
],
cwd=seed,
)
_git(["remote", "add", "origin", str(bare)], cwd=seed)
_git(["push", "origin", "main"], cwd=seed)
return SimpleNamespace(bare=bare, work=base / "docs-work")
def _spawn_app(port: int, mock_port: int, docs_env: dict[str, str]) -> subprocess.Popen:
"""One uvicorn boot (the conftest app_server env shape, the
``test_response_to_docs.py`` pattern)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1" if USE_REAL_LLM else f"http://127.0.0.1:{mock_port}/v1"
)
# Mock-calibrated threshold (conftest pattern): the fixture questions
# gate HIGH, so every turn is a grounded answer with the marker.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
env.update(docs_env)
return subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
cwd=REPO,
env=env,
)
def _stop(proc: subprocess.Popen) -> None:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_server(mock_llm: int, docs_repo: SimpleNamespace) -> Iterator[str]:
"""The configured app under test (module scope — shadows the
conftest session app; an isolated run never starts two)."""
proc = _spawn_app(
APP_PORT,
mock_llm,
{
"BOR_DOCS_REPO": str(docs_repo.bare),
"BOR_DOCS_BRANCH": BRANCH,
"BOR_DOCS_BASE_BRANCH": BASE_BRANCH,
"BOR_DOCS_WORK_DIR": str(docs_repo.work),
},
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
_stop(proc)
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
# ---------------------------------------------------------------------------
# KB + table hygiene (the E2E isolation pattern — this suite owns the
# KB tables and doc_drafts; both are reset around every test)
# ---------------------------------------------------------------------------
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {
"_env_file": None,
"llm_base_url": f"http://127.0.0.1:{mock_port}/v1",
}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread (the Playwright sync API keeps
an asyncio loop on the test thread — the test_chat_rag.py helper)."""
import threading
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = threading.Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int, seed: bool) -> None:
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, doc_drafts"))
db.commit()
if seed:
summary = _run_in_thread(_import_fixtures(mock_port))
assert summary.added == 13 # the A9 fixture set (test_chat_rag.py)
@pytest.fixture(autouse=True)
def _kb_and_clean_drafts(mock_llm: int, db_ready: None) -> Iterator[None]:
"""Fresh KB (the deterministic mock embeddings — the grounded
questions gate HIGH) + an empty ``doc_drafts`` table per test."""
_reset_db(mock_llm, seed=True)
yield
_reset_db(mock_llm, seed=False)
# ---------------------------------------------------------------------------
# Story helpers
# ---------------------------------------------------------------------------
def _stream_chat_answer(app_url: str, message: str) -> str:
"""Replay one turn through the raw SSE endpoint (the
``test_chat_rag.py`` transport pattern) and return the EXACT answer
text — the markdown source the UI accumulates into ``m.text``,
byte-identical for the deterministic mock. The mock's composed
answer is a pure function of the LAST user message + the document
context (both identical whether or not the browser's phase-74
history rode along), so a bare replay recovers the same bytes the
multi-turn browser session rendered."""
frames: list[dict[str, Any]] = []
with httpx.stream(
"POST", f"{app_url}/api/chat", json={"message": message}, timeout=120.0
) as r:
assert r.status_code == 200
buf = ""
for part in r.iter_text():
buf += part
while "\n\n" in buf:
frame, buf = buf.split("\n\n", 1)
if frame.strip().startswith("data:"):
frames.append(
json.loads(frame.strip().removeprefix("data:").strip())
)
deltas = [f for f in frames if f.get("type") == "delta"]
assert deltas, "the SSE stream must deliver deltas"
return "".join(d["text"] for d in deltas)
def _ask(page: Page, question: str) -> None:
"""One grounded turn to its DONE state — the phase-74 pattern: the
LAST user bubble carries the question, the LAST brain bubble the
marker, and the Send button is re-enabled (``done`` settled the
turn; the meta-row buttons have landed)."""
page.fill("#message-input", question)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
MOCK_ANSWER_MARKER, timeout=60_000
)
expect(page.locator("#send-label")).to_have_text("Send")
def _session(page: Page, app_url: str) -> list[tuple[str, str]]:
"""Drive the three-turn session and return each turn's EXACT
answer bytes (distinct: the composed answer embeds the question's
first 80 chars, and the three questions differ)."""
_ask(page, Q1)
_ask(page, Q2)
_ask(page, Q3)
answers = [
_stream_chat_answer(app_url, q) for q in (Q1, Q2, Q3)
]
for q, a in zip((Q1, Q2, Q3), answers, strict=True):
assert q in a, f"the composed answer must quote its question: {a!r}"
assert MOCK_ANSWER_MARKER in a
assert len(set(answers)) == 3, "the three answers must be byte-distinct"
return list(zip((Q1, Q2, Q3), answers, strict=True))
def _login_admin(page: Page, app_url: str) -> None:
"""Real form login landing on the chat (admin settled)."""
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=30_000)
def _open_edit_screen(page: Page, btn: Locator) -> str:
"""Click ONE bubble's save action, wait for the navigation, return
the draft token from the URL (the uuid4 credential)."""
btn.click()
page.wait_for_url(DRAFT_URL_RE, timeout=30_000)
token = parse_qs(urlsplit(page.url).query).get("draft", [""])[0]
assert re.fullmatch(r"[0-9a-f-]{36}", token), f"no draft token in {page.url}"
expect(page.locator("#doc-edit-gate")).to_be_hidden(timeout=30_000)
expect(page.locator("#doc-edit-content")).to_be_visible(timeout=30_000)
return token
def _push_and_read_sha(page: Page) -> tuple[str, str]:
"""Submit the edit screen's push; wait for the success line and
return (branch, sha7) exactly as the live region reported them."""
page.click("#push-doc-btn")
status = page.locator("#push-status")
expect(status).to_contain_text(f"Pushed to {BRANCH}", timeout=60_000)
line = status.inner_text().strip()
m = SUCCESS_SHA_RE.search(line)
assert m, f"the success line carries no commit sha: {line!r}"
return BRANCH, m.group(1)
def _assert_no_thinking_leak(body: str) -> None:
"""A6: the transcript carries ONLY the raw m.text of each record —
turn 1's persisted ``thinking`` block (the deterministic
scratchpad) must never reach the document."""
for line in THINKING_LINES:
assert line not in body, f"thinking scratchpad text leaked into the doc: {line!r}"
# ---------------------------------------------------------------------------
# 1. The whole loop: 3 turns → save → all turns in order → edit a
# previous reply out → push → the bare repo agrees byte-for-byte
# ---------------------------------------------------------------------------
def test_full_session_save_and_edit_out(
page: Page,
app_url: str,
mock_llm: int,
db_ready: None,
docs_repo: SimpleNamespace,
) -> None:
page.set_default_timeout(30_000)
_login_admin(page, app_url)
turns = _session(page, app_url)
(q1, a1), (q2, a2), (q3, a3) = turns
# Every completed brain bubble carries the bottom-right action…
expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(3)
# …and the one on the LAST bubble opens the edit screen…
_open_edit_screen(page, page.locator(".msg.brain .save-as-doc-btn").last)
# Title / path: UNCHANGED by phase 75 — the last question (the
# phase-50 auto-title convention; Q3 is whitespace-free and
# ≤120 chars, so it arrives verbatim) + docs/<slug>.md.
expect(page.locator("#draft-title")).to_have_value(q3)
expect(page.locator("#draft-path")).to_have_value(f"docs/{doc_slug(q3)}.md")
# Body: the WHOLE session (A6) — ## 1./## 2./## 3. IN ORDER, each
# followed by that turn's answer byte-exact against the mock
# (never the clicked bubble alone, never rendered HTML).
expected = session_transcript(turns)
expect(page.locator("#draft-body")).to_have_value(expected)
body = page.input_value("#draft-body")
i1, i2, i3 = (
body.index(f"## {i}. {q}") for i, q in ((1, q1), (2, q2), (3, q3))
)
assert i1 < i2 < i3, "the sections must appear in session order"
for _q, a in turns:
assert a in body, f"an answer is missing from the transcript: {a[:60]!r}…"
_assert_no_thinking_leak(body)
assert "<" not in body and ">" not in body, (
"the draft body must be markdown, not HTML"
)
# Edit out a PREVIOUS reply (A7: the free-form body field is the
# user's means) — delete the entire section-2 block (heading +
# answer) and push.
edited = f"## 1. {q1}\n\n{a1}\n\n## 3. {q3}\n\n{a3}".rstrip()
page.fill("#draft-body", edited)
branch, sha7 = _push_and_read_sha(page)
assert branch == BRANCH
# GIT-VERIFY: the bare repo's file is EXACTLY the edited body…
path = f"docs/{doc_slug(q3)}.md"
shown = _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path}"])
assert shown == edited
# …section 2 is provably gone (both question and answer)…
assert q2 not in shown, "section 2's question survived the edit-out"
assert a2 not in shown, "section 2's answer survived the edit-out"
# …sections 1 and 3 are byte-exact and in order…
assert shown.index(f"## 1. {q1}") < shown.index(f"## 3. {q3}")
assert a1 in shown and a3 in shown
# …and the UI's sha prefix is the branch's real tip.
tip = _git(["-C", str(docs_repo.bare), "rev-parse", BRANCH]).strip()
assert tip.startswith(sha7), f"UI sha {sha7} != bare repo tip {tip}"
_assert_no_thinking_leak(shown)
# ---------------------------------------------------------------------------
# 2. The button on an EARLIER bubble still drafts the whole session
# (A6: the transcript is the session at click time, not the bubble),
# and canceling leaves the repo untouched
# ---------------------------------------------------------------------------
def test_earlier_bubble_button_saves_whole_session(
page: Page,
app_url: str,
mock_llm: int,
db_ready: None,
docs_repo: SimpleNamespace,
) -> None:
page.set_default_timeout(30_000)
_login_admin(page, app_url)
turns = _session(page, app_url)
(q1, _a1), (_q2, _a2), (q3, _a3) = turns
expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(3)
# The branch tip BEFORE the attempt (None while no push has ever
# created it — the test must pass in file order AND alone).
tip_before = _branch_tip(docs_repo.bare)
# Click the save action on the FIRST brain bubble — the draft must
# still carry the ENTIRE session (all three sections, byte-exact)…
_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")
body = session_transcript(turns)
expect(page.locator("#draft-body")).to_have_value(body)
_assert_no_thinking_leak(body)
# …then cancel out (Back to chat — NO push): the branch is
# untouched — same tip as before (or still absent).
page.click("#doc-edit-content a.doc-edit-back")
page.wait_for_url(APP_URL + "/", timeout=30_000)
tip_after = _branch_tip(docs_repo.bare)
assert tip_after == tip_before, (
f"canceling moved the docs branch: {tip_before} -> {tip_after}"
)