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
@@ -0,0 +1,11 @@
**Phase 74 final verification pass — all green** (work was already implemented & committed; this pass verified every criterion)
- Verified: `ChatRequest.history` + `history_to_messages` mapper (turn/char budgets, drop-whole, `reasoning_content` on brain turns only when non-empty); wired through both deflected and grounded branches; `runTurn` sends `conversation.slice(0,-1)` with `thinking`; mock `echo my history` marker; per-turn log gains `history_msgs=N` (seen live in e2e app logs).
- `uv run pytest --cov=app` → all pass, TOTAL coverage **99%** (>90% criterion).
- `uv run ruff check . && uv run pyright` → clean (0 errors).
- Story E2E `uv run pytest tests/e2e/test_llm_history.py -v --no-cov` → **3 passed** (grounded follow-up, cold-start no-history, deflected follow-up — prior Q/A + prior thinking proven byte-exact via mock echo).
- Regression suites in isolation (AGENTS.md rule 9): test_chat_rag, test_chat_history, test_agent_document_tools, test_harness_aligned_tools, test_stop_generation, test_retry_answer, test_response_to_docs → **all pass** (exit 0, no failures).
- Commit `055c0b5 feat(rag): pass chat history with prior thinking to the LLM`: atomic, covers all phase code, **no gpg signature** (`--no-gpg-sign` honored); no uncommitted app code; `echo my history` collides with no existing phrase/fixture.
- Completion criteria: 1) both branches carry history + reasoning ✅ 2) no-history byte-identical (integration pins + green regressions) ✅ 3) log field + suite + coverage + lint ✅ 4) commit + phase dir in `complete/` ✅.
- Notable: running multiple e2e suites in ONE pytest process causes a port-8123 bind clash (several suites by design boot their own app on `APP_PORT`, assuming per-suite isolation). Pre-existing infra property, not a phase-74 defect — the mandated isolation gate passes everywhere.
- Next pending phase: **75_save_doc_full_session**.
@@ -0,0 +1,78 @@
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 13%]
........................................................................ [ 18%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 32%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 46%]
........................................................................ [ 51%]
........................................................................ [ 55%]
........................................................................ [ 60%]
........................................................................ [ 65%]
........................................................................ [ 69%]
........................................................................ [ 74%]
........................................................................ [ 79%]
........................................................................ [ 83%]
........................................................................ [ 88%]
........................................................................ [ 93%]
........................................................................ [ 97%]
................................... [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 22 0 100%
app/api/chat.py 177 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 93 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 8 0 100%
app/api/sync.py 101 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 20 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/logging.py 13 0 100%
app/db.py 21 0 100%
app/main.py 52 0 100%
app/models.py 86 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 216 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 210 0 100%
-----------------------------------------------
TOTAL 2989 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,13 @@
All done. Final report:
**Phase 74 task 03 — complete**
- Implemented: mock `HISTORY_TRIGGER` (`echo my history`) with byte-stable stateless echo (`history: N prior messages; last answer tail: <24 chars>; thinking: yes|no`), checked before the `DEFLECT_MODE` branch; module docstring entry incl. the user/assistant-only history invariant; new E2E suite `tests/e2e/test_llm_history.py` (3 tests: grounded follow-up, cold-start, deflected follow-up — all byte-exact vs the persisted `bor.chat.v1` record).
- Gate questions verified against the app's real retrieval path (HIGH: 0.365/0.385 ≥ 0.30; deflected: 0.089, fts=0).
- E2E: `uv run pytest tests/e2e/test_llm_history.py -v --no-cov` → 3 passed (ran twice).
- Regressions in isolation, all green: chat_rag (3), chat_history (5), agent_document_tools (4), harness_aligned_tools (3), stop_generation (3), retry_answer (4), response_to_docs (4).
- `uv run pytest --cov=app --cov-report=term-missing` → 1547 passed, **99%** coverage (app/).
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors.
- Committed `055c0b5 feat(rag): pass chat history with prior thinking to the LLM` (`--no-gpg-sign`, 29 files; records the harness moves of 73/74 task files to `complete/`).
- Deviations: (1) per harness rules I left `todo/74_llm_chat_history/` (00_phase + 03) in place — the harness moves it on success (same pattern as phase 73); (2) Playwright 1.62 sync API has no `expect.poll` — used `page.wait_for_function` for the record wait.
- Next pending task: `.agents/phases/todo/75_save_doc_full_session/01_session_transcript.md`.
@@ -0,0 +1,78 @@
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 13%]
........................................................................ [ 18%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 32%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 46%]
........................................................................ [ 51%]
........................................................................ [ 55%]
........................................................................ [ 60%]
........................................................................ [ 65%]
........................................................................ [ 69%]
........................................................................ [ 74%]
........................................................................ [ 79%]
........................................................................ [ 83%]
........................................................................ [ 88%]
........................................................................ [ 93%]
........................................................................ [ 97%]
................................... [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 22 0 100%
app/api/chat.py 177 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 93 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 8 0 100%
app/api/sync.py 101 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 20 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/logging.py 13 0 100%
app/db.py 21 0 100%
app/main.py 52 0 100%
app/models.py 86 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 216 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 210 0 100%
-----------------------------------------------
TOTAL 2989 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,10 @@
All green. Final report:
**Phase 75 · Task 01 — `saveAsDoc` posts the full-session transcript**
- **`frontend/assets/app.js`**: added `buildSessionTranscript()` (next to `defaultDocTitle()`/`saveAsDoc()`) — one numbered section per user turn (`## N. <raw question>` + blank line + raw answer text), sections blank-line separated, trailing whitespace collapsed to a single final newline; only `m.who`/`m.text` travel (no thinking/sources/tune); orphan-brain and answerless-user edges handled. `saveAsDoc(btn)` now posts `body: buildSessionTranscript()`; dead `markdown` param dropped, single call site updated, `appendSaveAsDocButton(wrap, markdown)` signature kept; double-click guard + 201→`/doc-edit.html?draft=<token>` hand-off unchanged. Title/path rules untouched (A6).
- **Tests**: new unit pin `test_app_js_transcript_covers_the_whole_session` + updated `test_app_js_post_payload_and_navigation` in `tests/unit/test_save_as_doc_button.py`; phase-59 E2E body expectations moved to the single-turn transcript (new `transcript()` port helper) in `tests/e2e/test_response_to_docs.py` — push→git verification, guest/unconfigured pins unchanged; no backend change.
- **Builder byte-verified** via node harness against the real extracted function (single, multi, trailing-collapse, orphan, answerless cases).
- **Results**: `uv run pytest tests/e2e/test_response_to_docs.py -v --no-cov` → 4 passed; `uv run pytest` → 1548 passed; `uv run pytest --cov=app` → TOTAL 99% (>90%); `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- **Decisions**: E2E expects the server-stored (`.strip()`-ed) transcript — the builder's final `\n` is stripped by the draft API, so the assertion helper omits it; no commit (phase 75's atomic commit belongs to task 02 per the phase overview).
- **Next pending task**: `.agents/phases/todo/75_save_doc_full_session/02_e2e_full_session_save.md`
@@ -0,0 +1,78 @@
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 13%]
........................................................................ [ 18%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 32%]
........................................................................ [ 37%]
........................................................................ [ 41%]
........................................................................ [ 46%]
........................................................................ [ 51%]
........................................................................ [ 55%]
........................................................................ [ 60%]
........................................................................ [ 65%]
........................................................................ [ 69%]
........................................................................ [ 74%]
........................................................................ [ 79%]
........................................................................ [ 83%]
........................................................................ [ 88%]
........................................................................ [ 93%]
........................................................................ [ 97%]
.................................... [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 22 0 100%
app/api/chat.py 177 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 93 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 8 0 100%
app/api/sync.py 101 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 20 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/logging.py 13 0 100%
app/db.py 21 0 100%
app/main.py 52 0 100%
app/models.py 86 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 216 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 210 0 100%
-----------------------------------------------
TOTAL 2989 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
+68 -15
View File
@@ -552,19 +552,24 @@ function markLastRetryable() {
* answer, so m.stopped records never get it — the restore call site * answer, so m.stopped records never get it — the restore call site
* gates on it). Gate: admin (the whoami gate Tune uses) AND a * gates on it). Gate: admin (the whoami gate Tune uses) AND a
* configured docs repo (docsRepoConfigured — /api/config, settled in * configured docs repo (docsRepoConfigured — /api/config, settled in
* the boot IIFE before any bubble renders). `markdown` is the RAW * the boot IIFE before any bubble renders). `markdown` — the RAW
* persisted answer text — m.text on the restore path, the * persisted answer text, m.text on the restore path, the
* done/fallback raw text on the live path — NEVER the rendered HTML. * done/fallback raw text on the live path, NEVER the rendered HTML —
* stays in the signature (the three call sites are unchanged) but no
* longer travels: phase 75 (TODO L4, A6) drafts the WHOLE conversation
* from the `conversation` record at click time (buildSessionTranscript).
* The .save-as-doc-btn's margin-inline-start: auto pushes it to the * The .save-as-doc-btn's margin-inline-start: auto pushes it to the
* row's right edge (the TODO's "bottom right"); markLastRetryable * row's right edge (the TODO's "bottom right"); markLastRetryable
* keeps it rightmost when the last bubble also carries the Retry * keeps it rightmost when the last bubble also carries the Retry
* button. * button.
* *
* Click: default title (the LAST user question, whitespace-collapsed, * Click: default title (UNCHANGED by phase 75 — the LAST user
* ≤120 chars — the phase-50 auto-title convention) + default in-repo * question, whitespace-collapsed, ≤120 chars, the phase-50 auto-title
* path (docs/<slug>.md) → POST /api/doc-drafts {title, path, body} → * convention) + default in-repo path (docs/<slug>.md) + the
* 201 → /doc-edit.html?draft=<token> (the edit screen, task 06, owns * FULL-SESSION transcript as the body (phase 75 A6) → POST
* the rest). Failure → the neutral one-line banner (phase-55 * /api/doc-drafts {title, path, body} → 201 →
* /doc-edit.html?draft=<token> (the edit screen, task 06, owns the
* rest). Failure → the neutral one-line banner (phase-55
* convention), the conversation unblocked, no navigation. */ * convention), the conversation unblocked, no navigation. */
const SAVE_AS_DOC_ICON = const SAVE_AS_DOC_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="M14 3H6a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 16h4"/></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="M14 3H6a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 16h4"/></svg>';
@@ -603,6 +608,52 @@ function docSlug(title) {
return slug || "note"; return slug || "note";
} }
/* Phase 75 (TODO L4; A6, owner-confirmed 2026-09-08): the draft BODY
* is the WHOLE conversation — every user question and the brain
* answers that followed it, in order, as the RAW persisted text. The
* user edits out unwanted turns in the doc-edit body (A7 — the
* existing free-form body field; no new UI surface). The shape: a
* numbered section per USER turn —
*
* ## 1. <user question, raw text>
*
* <brain answer, raw text>
*
* ## 2. <user question, raw text>
*
* <brain answer, raw text>
*
* — 1-based per user turn (normally one answer per section; more
* answers join under the same heading), sections blank-line
* separated, ALL trailing whitespace collapsed to a single final
* newline. ONLY the raw text travels (m.who + m.text — nothing else
* off the record); a stopped/partial brain turn appears as-is (its
* text is what the user saw — A7); a brain record before the first
* user record (unproducible from the UI) is skipped, and a user turn
* whose brain record never landed is a heading-only section. */
function buildSessionTranscript() {
const sections = []; // { heading: "## N. <q>", answers: [raw text] }
let open = null; // answers of the current user section (null while
// no user record has opened one yet)
for (const m of conversation) {
if (m.who === "user") {
open = [];
sections.push({
heading: `## ${sections.length + 1}. ${m.text}`,
answers: open,
});
} else if (open) {
open.push(m.text);
}
}
const body = sections
.map((s) =>
s.answers.length ? `${s.heading}\n\n${s.answers.join("\n\n")}` : s.heading
)
.join("\n\n");
return body.replace(/\s+$/, "") + "\n";
}
/* The bottom-right "Save as doc" button — the appendTuneButton /* The bottom-right "Save as doc" button — the appendTuneButton
* pattern: reuses the .msg-meta row when it exists (role=list → the * pattern: reuses the .msg-meta row when it exists (role=list → the
* button joins as a listitem so ARIA stays valid), otherwise creates * button joins as a listitem so ARIA stays valid), otherwise creates
@@ -623,15 +674,17 @@ function appendSaveAsDocButton(wrap, markdown) {
btn.className = "save-as-doc-btn"; // margin-inline-start: auto → bottom-right btn.className = "save-as-doc-btn"; // margin-inline-start: auto → bottom-right
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem"); if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
btn.innerHTML = SAVE_AS_DOC_ICON + "<span>Save as doc</span>"; btn.innerHTML = SAVE_AS_DOC_ICON + "<span>Save as doc</span>";
btn.addEventListener("click", () => saveAsDoc(btn, markdown)); btn.addEventListener("click", () => saveAsDoc(btn));
meta.appendChild(btn); meta.appendChild(btn);
} }
/* Create the draft from the bubble's RAW markdown and hand off to the /* Create the draft from the FULL-SESSION transcript (phase 75 A6 —
* edit screen. Double-click guard: one save at a time (the button is * every Q/A up to the click, in order — replacing the phase-59
* disabled until the outcome — released in the finally, never stale, * single-bubble body; the title and path rules are unchanged) and
* PLAN §7.4). */ * hand off to the edit screen. Double-click guard: one save at a time
async function saveAsDoc(btn, markdown) { * (the button is disabled until the outcome — released in the
* finally, never stale, PLAN §7.4). */
async function saveAsDoc(btn) {
if (btn.disabled) return; // one save at a time (double-click guard) if (btn.disabled) return; // one save at a time (double-click guard)
btn.disabled = true; btn.disabled = true;
try { try {
@@ -640,7 +693,7 @@ async function saveAsDoc(btn, markdown) {
const res = await fetch("/api/doc-drafts", { const res = await fetch("/api/doc-drafts", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, path, body: markdown }), body: JSON.stringify({ title, path, body: buildSessionTranscript() }),
}); });
if (!res.ok) { if (!res.ok) {
// Neutral one-line copy (phase-55 convention) — the detail may // Neutral one-line copy (phase-55 convention) — the detail may
+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 The loop under test: a completed brain bubble carries a bottom-right
"Save as doc" action (admin + a configured ``BOR_DOCS_REPO``) → it "Save as doc" action (admin + a configured ``BOR_DOCS_REPO``) → it
opens ``/doc-edit.html?draft=<token>`` prefilled (auto-title from the opens ``/doc-edit.html?draft=<token>`` prefilled (auto-title from the
last question, path ``docs/<slug>.md``, body = the answer's MARKDOWN last question, path ``docs/<slug>.md``, body = the FULL-SESSION
SOURCE — never the rendered HTML) → Push commits + pushes to the 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 ``.env``-configured branch of the ``.env``-configured repo. Every
success assertion reads the **bare repo itself** (``git show success assertion reads the **bare repo itself** (``git show
<branch>:<path>``, ``git rev-list``, ``git rev-parse``) — the UI text <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 mock LLM keeps every answer byte-deterministic: the suite replays
the same question through ``POST /api/chat`` (raw SSE, the the same question through ``POST /api/chat`` (raw SSE, the
``test_chat_rag.py`` pattern) to recover the exact markdown source the ``test_chat_rag.py`` pattern) to recover the exact answer bytes the
draft must carry — so "body == the answer's markdown source" is an draft must carry — so "body == the single-turn transcript of that
exact-byte assertion, not a contains check. answer" is an exact-byte assertion, not a contains check.
Test → story mapping (Playwright Mapping Rule): Test → story mapping (Playwright Mapping Rule):
1. ``test_save_edit_push`` 1. ``test_save_edit_push``
@@ -142,6 +144,16 @@ def doc_slug(title: str) -> str:
return slug or "note" 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]: def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login """The signed session cookies the browser holds after a form login
— used to call the admin API with plain httpx (the — 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( expect(page.locator("#draft-path")).to_have_value(
f"docs/{doc_slug(QUESTION_1)}.md" f"docs/{doc_slug(QUESTION_1)}.md"
) )
# Body == the rendered answer's MARKDOWN SOURCE: the SSE replay # Body == the FULL-SESSION transcript (phase 75 A6): this
# recovers the exact bytes the UI accumulated (the mock is # 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 # byte-deterministic on the same KB + question) — and they are
# plain markdown, not rendered HTML. # plain markdown, not rendered HTML.
raw = _stream_chat_answer(app_url, QUESTION_1) raw = _stream_chat_answer(app_url, QUESTION_1)
assert MOCK_ANSWER_MARKER in raw and QUESTION_1 in raw 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" 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 # Modify the doc (the story's "modify before [pushing]"): a
# distinctive marker line the bare repo must show after the push. # 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) page.fill("#draft-body", edited)
# Push → the live region reports the branch + a 7-char commit sha… # 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) _login_admin(page, app_url)
_ask(page, app_url, QUESTION_2) _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) expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(1)
_open_edit_screen(page) _open_edit_screen(page)
expect(page.locator("#draft-title")).to_have_value(QUESTION_2) 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" f"docs/{doc_slug(QUESTION_2)}.md"
) )
raw2 = _stream_chat_answer(app_url, QUESTION_2) 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. # …and push WITHOUT editing — a new commit on the same branch.
_push_and_read_sha(page) _push_and_read_sha(page)
@@ -511,13 +528,21 @@ def test_second_push_fast_forwards(
.strip() .strip()
== "2" == "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" 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 # …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" 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 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}"
)
+49 -4
View File
@@ -240,19 +240,64 @@ def test_app_js_slug_rule() -> None:
assert "docs/${docSlug(title)}.md" in js assert "docs/${docSlug(title)}.md" in js
def test_app_js_transcript_covers_the_whole_session() -> None:
"""Phase 75 (TODO L4, A6): buildSessionTranscript() — the draft
body — is the WHOLE conversation: a numbered section per USER turn
("## N. <raw question>" + 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 user turn whose brain record
never landed is a heading-only section. saveAsDoc POSTS the
transcript (the phase-59 single-bubble body no longer travels)."""
js = _text(APP_JS)
fn_idx = js.find("function buildSessionTranscript() {")
assert fn_idx != -1, "buildSessionTranscript missing"
fn_end = js.find("function appendSaveAsDocButton", fn_idx)
assert fn_end > fn_idx
fn_body = js[fn_idx:fn_end]
# One section per USER turn, numbered 1-based in record order…
assert 'm.who === "user"' in fn_body
assert "sections.length + 1" in fn_body
# …"## N. <question, RAW text>" — the record's text verbatim
# (no title transform, no HTML).
assert "`## ${sections.length + 1}. ${m.text}`" in fn_body
# …the RAW answer text joins under its question; the question and
# its answer(s) are blank-line separated, and the sections too.
assert "open.push(m.text)" in fn_body
assert 's.answers.join("\\n\\n")' in fn_body
assert '.join("\\n\\n")' in fn_body
# ALL trailing whitespace collapses to a single final newline.
assert 'body.replace(/\\s+$/, "") + "\\n"' in fn_body
# Only raw text travels: no record field beyond who/text is read.
assert "m.thinking" not in fn_body
assert "m.sources" not in fn_body
assert "m.deflected" not in fn_body
assert "m.tools" not in fn_body
# saveAsDoc POSTS the transcript as the body (and the dead
# single-bubble markdown argument is gone from its signature).
save_idx = js.find("async function saveAsDoc(btn) {")
assert save_idx > fn_idx, "saveAsDoc missing (or still carries markdown)"
save_body = js[save_idx : save_idx + 3000]
assert "body: buildSessionTranscript()" in save_body
assert "markdown" not in save_body
def test_app_js_post_payload_and_navigation() -> None: def test_app_js_post_payload_and_navigation() -> None:
"""Click → POST /api/doc-drafts {title, path, body: markdown} (the """Click → POST /api/doc-drafts {title, path, body: the
raw markdown is the body — never HTML) → 201 → FULL-SESSION transcript} (phase 75 A6 — every Q/A up to the click,
in order — never HTML) → 201 →
location.assign("/doc-edit.html?draft=" + token). A double-click location.assign("/doc-edit.html?draft=" + token). A double-click
guard disables the button until the outcome (released in the guard disables the button until the outcome (released in the
finally — never stale); failure shows the neutral one-line banner finally — never stale); failure shows the neutral one-line banner
(phase-55 convention) and never navigates.""" (phase-55 convention) and never navigates."""
js = _text(APP_JS) js = _text(APP_JS)
fn_idx = js.find("async function saveAsDoc(btn, markdown) {") fn_idx = js.find("async function saveAsDoc(btn) {")
assert fn_idx != -1, "saveAsDoc missing" assert fn_idx != -1, "saveAsDoc missing"
fn_body = js[fn_idx : fn_idx + 3000] fn_body = js[fn_idx : fn_idx + 3000]
assert 'fetch("/api/doc-drafts"' in fn_body assert 'fetch("/api/doc-drafts"' in fn_body
assert 'JSON.stringify({ title, path, body: markdown })' in fn_body assert "body: buildSessionTranscript()" in fn_body
assert 'location.assign("/doc-edit.html?draft=" + draft.token)' in fn_body assert 'location.assign("/doc-edit.html?draft=" + draft.token)' in fn_body
assert "btn.disabled = true" in fn_body assert "btn.disabled = true" in fn_body
assert "btn.disabled = false" in fn_body assert "btn.disabled = false" in fn_body