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
+49 -4
View File
@@ -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. <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:
"""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