feat(docs): save the whole chat session as a doc
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user