diff --git a/.agents/phases/todo/74_llm_chat_history/00_phase.md b/.agents/phases/complete/74_llm_chat_history/00_phase.md similarity index 100% rename from .agents/phases/todo/74_llm_chat_history/00_phase.md rename to .agents/phases/complete/74_llm_chat_history/00_phase.md diff --git a/.agents/phases/todo/74_llm_chat_history/03_mock_marker_e2e.md b/.agents/phases/complete/74_llm_chat_history/03_mock_marker_e2e.md similarity index 100% rename from .agents/phases/todo/74_llm_chat_history/03_mock_marker_e2e.md rename to .agents/phases/complete/74_llm_chat_history/03_mock_marker_e2e.md diff --git a/.agents/phases/todo/75_save_doc_full_session/01_session_transcript.md b/.agents/phases/complete/75_save_doc_full_session/01_session_transcript.md similarity index 100% rename from .agents/phases/todo/75_save_doc_full_session/01_session_transcript.md rename to .agents/phases/complete/75_save_doc_full_session/01_session_transcript.md diff --git a/.agents/reports/74_llm_chat_history/74_llm_chat_history__00_phase.a1.err b/.agents/reports/74_llm_chat_history/74_llm_chat_history__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/74_llm_chat_history/74_llm_chat_history__00_phase.a1.md b/.agents/reports/74_llm_chat_history/74_llm_chat_history__00_phase.a1.md new file mode 100644 index 0000000..76966aa --- /dev/null +++ b/.agents/reports/74_llm_chat_history/74_llm_chat_history__00_phase.a1.md @@ -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**. diff --git a/.agents/reports/74_llm_chat_history/74_llm_chat_history__00_phase.a1.validate b/.agents/reports/74_llm_chat_history/74_llm_chat_history__00_phase.a1.validate new file mode 100644 index 0000000..ee95cca --- /dev/null +++ b/.agents/reports/74_llm_chat_history/74_llm_chat_history__00_phase.a1.validate @@ -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 diff --git a/.agents/reports/74_llm_chat_history/74_llm_chat_history__03_mock_marker_e2e.a1.md b/.agents/reports/74_llm_chat_history/74_llm_chat_history__03_mock_marker_e2e.a1.md new file mode 100644 index 0000000..522dc9b --- /dev/null +++ b/.agents/reports/74_llm_chat_history/74_llm_chat_history__03_mock_marker_e2e.a1.md @@ -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`. diff --git a/.agents/reports/74_llm_chat_history/74_llm_chat_history__03_mock_marker_e2e.a1.validate b/.agents/reports/74_llm_chat_history/74_llm_chat_history__03_mock_marker_e2e.a1.validate new file mode 100644 index 0000000..ee95cca --- /dev/null +++ b/.agents/reports/74_llm_chat_history/74_llm_chat_history__03_mock_marker_e2e.a1.validate @@ -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 diff --git a/.agents/reports/75_save_doc_full_session/75_save_doc_full_session__01_session_transcript.a1.err b/.agents/reports/75_save_doc_full_session/75_save_doc_full_session__01_session_transcript.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/75_save_doc_full_session/75_save_doc_full_session__01_session_transcript.a1.md b/.agents/reports/75_save_doc_full_session/75_save_doc_full_session__01_session_transcript.a1.md new file mode 100644 index 0000000..8c1e9cf --- /dev/null +++ b/.agents/reports/75_save_doc_full_session/75_save_doc_full_session__01_session_transcript.a1.md @@ -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. ` + 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=` 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` diff --git a/.agents/reports/75_save_doc_full_session/75_save_doc_full_session__01_session_transcript.a1.validate b/.agents/reports/75_save_doc_full_session/75_save_doc_full_session__01_session_transcript.a1.validate new file mode 100644 index 0000000..f23e3c3 --- /dev/null +++ b/.agents/reports/75_save_doc_full_session/75_save_doc_full_session__01_session_transcript.a1.validate @@ -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 diff --git a/.agents/reports/75_save_doc_full_session/75_save_doc_full_session__02_e2e_full_session_save.a1.err b/.agents/reports/75_save_doc_full_session/75_save_doc_full_session__02_e2e_full_session_save.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 0914854..201059b 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -552,19 +552,24 @@ function markLastRetryable() { * answer, so m.stopped records never get it — the restore call site * gates on it). Gate: admin (the whoami gate Tune uses) AND a * configured docs repo (docsRepoConfigured — /api/config, settled in - * the boot IIFE before any bubble renders). `markdown` is the RAW - * persisted answer text — m.text on the restore path, the - * done/fallback raw text on the live path — NEVER the rendered HTML. + * the boot IIFE before any bubble renders). `markdown` — the RAW + * persisted answer text, m.text on the restore path, the + * 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 * row's right edge (the TODO's "bottom right"); markLastRetryable * keeps it rightmost when the last bubble also carries the Retry * button. * - * Click: default title (the LAST user question, whitespace-collapsed, - * ≤120 chars — the phase-50 auto-title convention) + default in-repo - * path (docs/.md) → POST /api/doc-drafts {title, path, body} → - * 201 → /doc-edit.html?draft= (the edit screen, task 06, owns - * the rest). Failure → the neutral one-line banner (phase-55 + * Click: default title (UNCHANGED by phase 75 — the LAST user + * question, whitespace-collapsed, ≤120 chars, the phase-50 auto-title + * convention) + default in-repo path (docs/.md) + the + * FULL-SESSION transcript as the body (phase 75 A6) → POST + * /api/doc-drafts {title, path, body} → 201 → + * /doc-edit.html?draft= (the edit screen, task 06, owns the + * rest). Failure → the neutral one-line banner (phase-55 * convention), the conversation unblocked, no navigation. */ const SAVE_AS_DOC_ICON = ''; @@ -603,6 +608,52 @@ function docSlug(title) { 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. + * + * + * + * ## 2. + * + * + * + * — 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. ", 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 * pattern: reuses the .msg-meta row when it exists (role=list → the * 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 if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem"); btn.innerHTML = SAVE_AS_DOC_ICON + "Save as doc"; - btn.addEventListener("click", () => saveAsDoc(btn, markdown)); + btn.addEventListener("click", () => saveAsDoc(btn)); meta.appendChild(btn); } -/* Create the draft from the bubble's RAW markdown and hand off to the - * edit screen. Double-click guard: one save at a time (the button is - * disabled until the outcome — released in the finally, never stale, - * PLAN §7.4). */ -async function saveAsDoc(btn, markdown) { +/* Create the draft from the FULL-SESSION transcript (phase 75 A6 — + * every Q/A up to the click, in order — replacing the phase-59 + * single-bubble body; the title and path rules are unchanged) and + * hand off to the edit screen. Double-click guard: one save at a time + * (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) btn.disabled = true; try { @@ -640,7 +693,7 @@ async function saveAsDoc(btn, markdown) { const res = await fetch("/api/doc-drafts", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title, path, body: markdown }), + body: JSON.stringify({ title, path, body: buildSessionTranscript() }), }); if (!res.ok) { // Neutral one-line copy (phase-55 convention) — the detail may diff --git a/tests/e2e/test_response_to_docs.py b/tests/e2e/test_response_to_docs.py index 0e2b426..5803fe5 100644 --- a/tests/e2e/test_response_to_docs.py +++ b/tests/e2e/test_response_to_docs.py @@ -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=`` prefilled (auto-title from the -last question, path ``docs/.md``, body = the answer's MARKDOWN -SOURCE — never the rendered HTML) → Push commits + pushes to the +last question, path ``docs/.md``, body = the FULL-SESSION +TRANSCRIPT — phase 75 A6; every session in this suite is single-turn, +so it is ``## 1. `` + 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 :``, ``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. `` + 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. " + 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 diff --git a/tests/e2e/test_save_doc_session.py b/tests/e2e/test_save_doc_session.py new file mode 100644 index 0000000..62be1ef --- /dev/null +++ b/tests/e2e/test_save_doc_session.py @@ -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. `` + 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 :`` +== 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=/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= + /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 — commit .` +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. `` + 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/.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}" + ) diff --git a/tests/unit/test_save_as_doc_button.py b/tests/unit/test_save_as_doc_button.py index 0e035fa..4778324 100644 --- a/tests/unit/test_save_as_doc_button.py +++ b/tests/unit/test_save_as_doc_button.py @@ -240,19 +240,64 @@ def test_app_js_slug_rule() -> None: 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. " + 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. " — 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: - """Click → POST /api/doc-drafts {title, path, body: markdown} (the - raw markdown is the body — never HTML) → 201 → + """Click → POST /api/doc-drafts {title, path, body: the + 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 guard disables the button until the outcome (released in the finally — never stale); failure shows the neutral one-line banner (phase-55 convention) and never navigates.""" 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" fn_body = js[fn_idx : fn_idx + 3000] 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 "btn.disabled = true" in fn_body assert "btn.disabled = false" in fn_body