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

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

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

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

Also lands the phase-74 file moves (00_phase.md /
03_mock_marker_e2e.md -> complete/) and the phase reports — the
house convention of committing .agents/ with the phase.
This commit is contained in:
2026-09-05 16:59:31 -04:00
parent 055c0b5d85
commit 0e4651c779
16 changed files with 974 additions and 34 deletions
+40 -15
View File
@@ -12,8 +12,10 @@ on PATH — the suite skips without it):
The loop under test: a completed brain bubble carries a bottom-right
"Save as doc" action (admin + a configured ``BOR_DOCS_REPO``) → it
opens ``/doc-edit.html?draft=<token>`` prefilled (auto-title from the
last question, path ``docs/<slug>.md``, body = the answer's MARKDOWN
SOURCE — never the rendered HTML) → Push commits + pushes to the
last question, path ``docs/<slug>.md``, body = the FULL-SESSION
TRANSCRIPT — phase 75 A6; every session in this suite is single-turn,
so it is ``## 1. <the question>`` + the answer's markdown source —
never the rendered HTML) → Push commits + pushes to the
``.env``-configured branch of the ``.env``-configured repo. Every
success assertion reads the **bare repo itself** (``git show
<branch>:<path>``, ``git rev-list``, ``git rev-parse``) — the UI text
@@ -34,9 +36,9 @@ App boots (the conftest pattern, module-scoped — as in
The mock LLM keeps every answer byte-deterministic: the suite replays
the same question through ``POST /api/chat`` (raw SSE, the
``test_chat_rag.py`` pattern) to recover the exact markdown source the
draft must carry — so "body == the answer's markdown source" is an
exact-byte assertion, not a contains check.
``test_chat_rag.py`` pattern) to recover the exact answer bytes the
draft must carry — so "body == the single-turn transcript of that
answer" is an exact-byte assertion, not a contains check.
Test → story mapping (Playwright Mapping Rule):
1. ``test_save_edit_push``
@@ -142,6 +144,16 @@ def doc_slug(title: str) -> str:
return slug or "note"
def transcript(question: str, answer: str) -> str:
"""The phase-75 draft body (app.js ``buildSessionTranscript``,
A6) for the SINGLE-turn sessions this suite drives, as stored:
``## 1. <question>`` + blank line + the answer's raw markdown.
The builder's single trailing newline is stripped by the draft
API's ``.strip()`` (and the edit screen's push trims again), so
the stored — and pushed — bytes end at the answer."""
return f"## 1. {question}\n\n{answer}"
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login
— used to call the admin API with plain httpx (the
@@ -440,18 +452,21 @@ def test_save_edit_push(
expect(page.locator("#draft-path")).to_have_value(
f"docs/{doc_slug(QUESTION_1)}.md"
)
# Body == the rendered answer's MARKDOWN SOURCE: the SSE replay
# recovers the exact bytes the UI accumulated (the mock is
# Body == the FULL-SESSION transcript (phase 75 A6): this
# session is single-turn, so it is "## 1. <the question>" + blank
# line + the answer's raw markdown. The SSE replay recovers the
# exact answer bytes the UI accumulated (the mock is
# byte-deterministic on the same KB + question) — and they are
# plain markdown, not rendered HTML.
raw = _stream_chat_answer(app_url, QUESTION_1)
assert MOCK_ANSWER_MARKER in raw and QUESTION_1 in raw
assert "<" not in raw and ">" not in raw, "the draft body must be markdown, not HTML"
expect(page.locator("#draft-body")).to_have_value(raw)
body = transcript(QUESTION_1, raw)
expect(page.locator("#draft-body")).to_have_value(body)
# Modify the doc (the story's "modify before [pushing]"): a
# distinctive marker line the bare repo must show after the push.
edited = f"{raw}\n\n{E2E_MARKER}"
edited = f"{body}\n\n{E2E_MARKER}"
page.fill("#draft-body", edited)
# Push → the live region reports the branch + a 7-char commit sha…
@@ -491,7 +506,9 @@ def test_second_push_fast_forwards(
_login_admin(page, app_url)
_ask(page, app_url, QUESTION_2)
# Save the second answer (different question → different slug)…
# Save the second answer (different question → different slug);
# its session is single-turn too, so the body is the single-turn
# transcript of that answer…
expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(1)
_open_edit_screen(page)
expect(page.locator("#draft-title")).to_have_value(QUESTION_2)
@@ -499,7 +516,7 @@ def test_second_push_fast_forwards(
f"docs/{doc_slug(QUESTION_2)}.md"
)
raw2 = _stream_chat_answer(app_url, QUESTION_2)
expect(page.locator("#draft-body")).to_have_value(raw2)
expect(page.locator("#draft-body")).to_have_value(transcript(QUESTION_2, raw2))
# …and push WITHOUT editing — a new commit on the same branch.
_push_and_read_sha(page)
@@ -511,13 +528,21 @@ def test_second_push_fast_forwards(
.strip()
== "2"
)
# …file 2 landed with its unedited body…
# …file 2 landed with its unedited body (the single-turn
# transcript)…
path2 = f"docs/{doc_slug(QUESTION_2)}.md"
assert _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path2}"]) == raw2
assert (
_git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path2}"])
== transcript(QUESTION_2, raw2)
)
# …and file 1 from test 1 is still at its path, byte-for-byte
# (deterministic reconstruction: the mock answer + the marker line).
# (deterministic reconstruction: the single-turn transcript + the
# marker line).
path1 = f"docs/{doc_slug(QUESTION_1)}.md"
expected_first = f"{_stream_chat_answer(app_url, QUESTION_1)}\n\n{E2E_MARKER}"
expected_first = (
f"{transcript(QUESTION_1, _stream_chat_answer(app_url, QUESTION_1))}"
f"\n\n{E2E_MARKER}"
)
assert _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path1}"]) == expected_first