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
@@ -1,27 +0,0 @@
# Phase 74 — Chat history (with prior thinking) reaches the LLM
**Source:** `TODO.md` L4 — "Chat history isn't being passed to the LLM. When the LLM responds and you ask a follow-up question the previous question/answer isn't passed to the model. Since my models support preserve thinking, make sure to pass previous thinking blocks as well. 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." (this phase covers the first three sentences; the "save as doc" sentences are phase 75)
**Story:** n/a (TODO-derived)
**Context:** `app/api/chat.py` (today `messages = [system, user]` only — the deflected path at ~L381 and the grounded `run_agent` call at ~L414), `app/rag/agent.py` (`run_agent` builds its own `[system, user]` at ~L863), `app/rag/llm.py` (`chat_stream`/`chat`/`chat_stream_retried` take `list[dict[str, str]]`; the wire already carries prior-model reasoning as `reasoning_content` deltas), `app/schemas.py` (`ChatRequest`), `app/config.py` (`BOR_` settings), `frontend/assets/app.js` (`runTurn` ~L1859 is the SINGLE funnel for send / retry-reask / stale-regen; the `conversation` array is the `bor.chat.v1` record whose brain entries already carry the `thinking` key since phase 17), `tests/e2e/mock_llm.py` (the deterministic marker-trigger convention).
## Objective
A follow-up question reaches the model together with the conversation so far — every prior user/brain turn, and the prior thinking blocks on brain turns (preserve-thinking, via the endpoint's existing `reasoning_content` convention) — while `/api/chat` stays stateless: the client provides the history in the request body and the server stores nothing new (owner-locked A10).
## Dependencies
- `73_hidden_tab_stream` (todo) — keeps the chat turn path green first (its E2E pins the stream/record lifecycle this phase's client change touches).
## Tasks
1. `01_server_history_ingestion.md` — `ChatRequest.history` schema + caps settings + the history→messages mapper (assistant turns carry `reasoning_content`) + wiring through BOTH the deflected path and `run_agent` + the per-turn log line + unit/integration tests.
2. `02_client_sends_history.md` — `runTurn` sends the `conversation` record (minus the current question, with `thinking`) as `history`; retry/reask and stale-regen ride the same single funnel.
3. `03_mock_marker_e2e.md` — the mock's `echo my history` marker + the story E2E proving prior Q/A **and** prior thinking blocks reach the model on grounded, deflected, and first-question paths; regressions + commit.
## Testing & Quality
- Unit/integration: mapper (mapping, thinking passthrough, oldest-first trim on turn AND char budgets, empty default), schema validation (bad `who`, oversized list/text), message ordering in BOTH branches (grounded `run_agent` + deflected), the per-turn log line's new field — in `tests/unit/` + `tests/integration/test_chat_api.py`.
- Coverage: **>90%** on `app/` (all new server code is unit/integration tested).
- E2E: new story suite `tests/e2e/test_llm_history.py`, run in isolation, against the deterministic mock (its new `echo my history` marker makes the wire contents byte-assertable).
## Completion Criteria
- [ ] `POST /api/chat` with a `history` body: both the deflected turn and the grounded agent turn send the prior turns to the model in chronological order, with `reasoning_content` on prior brain turns that had thinking (proven by the mock-echo E2E).
- [ ] A request without `history` is byte-identical in behavior to today (default empty — existing suites' mocks see no extra messages; regression runs green).
- [ ] The per-turn log line (PLAN §9) gains `history_msgs=N`; `uv run pytest` green; coverage >90%; ruff + pyright clean.
- [ ] One atomic `--no-gpg-sign` commit (e.g. `feat(rag): pass chat history with prior thinking to the LLM`); phase dir moved to `.agents/phases/complete/`.
@@ -1,34 +0,0 @@
# Task 03 — Mock `echo my history` marker + story E2E + regressions + commit
**Phase:** `74_llm_chat_history` · **Source:** `TODO.md:4` — "Chat history isn't being passed to the LLM. When the LLM responds and you ask a follow-up question the previous question/answer isn't passed to the model. Since my models support preserve thinking, make sure to pass previous thinking blocks as well."
**Story:** n/a (TODO-derived)
## Objective
Prove on the wire, deterministically, that a follow-up question reaches the model WITH the prior Q/A and the prior thinking blocks — grounded and deflected branches alike — then run the regressions and commit the phase.
## Work
1. `tests/e2e/mock_llm.py` — new marker, following the module's established convention (trigger phrase in the user message → deterministic, byte-stable answer; checked BEFORE the `DEFLECT_MODE` branch exactly like `TABLE_TRIGGER`, so a marker question always gets the echo whatever the honesty gate says):
- `HISTORY_TRIGGER = "echo my history"` — first verify no existing E2E question or fixture file contains the phrase (the module's standing convention).
- The echo answer (byte-stable), derived statelessly from the request messages:
- `history: N prior messages` — N = the count of non-`system` messages before the LAST `user` message (i.e. everything the client sent as prior turns; the current question itself is excluded).
- `last answer tail: <…>` — the LAST 24 chars of the content of the most recent `assistant` message before the current user message, or `none` when there is no prior assistant message.
- `thinking: yes|no` — `yes` iff that most recent prior `assistant` message carries a non-empty `reasoning_content` field (the client's phase-74 history mapping), `no` otherwise (or when there is no prior assistant message).
- Suffix `(Deterministic mock answer for E2E.)` like the other composed answers.
- Docstring entry documenting the marker + the invariant it relies on: **client history contains only `user`/`assistant` messages — never `tool`-role ones** (the client never sends tool calls/results) — so every existing marker flow (which classifies statelessly from tool results) is unaffected by the now-always-present history.
2. `tests/e2e/test_llm_history.py` (Playwright; conftest app-boot + KB fixture pattern from `tests/e2e/test_chat_rag.py`; the file name deliberately differs from phase 50's `test_chat_history.py`):
- `test_followup_receives_history_and_thinking` — turn 1: an on-topic question carrying `think out loud` (deterministic reasoning stream) → `done`; capture the rendered answer text. Turn 2: an on-topic question carrying `echo my history` → `done`; assert the bubble contains `history: 2 prior messages`, `thinking: yes`, and `last answer tail: ` + the last 24 chars of turn 1's answer (byte-exact from the mock).
- `test_first_question_has_no_history` — fresh context, one on-topic `echo my history` question → `history: 0 prior messages`, `thinking: no`, `last answer tail: none` (no phantom history on a cold start).
- `test_deflected_followup_receives_history` — turn 1 on-topic with `think out loud`; turn 2 an OFF-TOPIC question carrying `echo my history` (mock embeddings → LOW gate → deflected branch) → the echo answer still appears with `history: 2 prior messages` + `thinking: yes` (ASSUMPTION A3: both branches carry history — the marker is checked before the `DEFLECT_MODE` branch, so the deflected path is what this test proves).
3. Regression runs (isolation, AGENTS.md rule 9) — the client now ALWAYS sends history, so the suites that touch the chat wire run: `test_chat_rag.py`, `test_chat_history.py` (phase 50), `test_agent_document_tools.py`, `test_harness_aligned_tools.py` (the agent's message list changed shape), `test_stop_generation.py`, `test_retry_answer.py` (retry's history = the re-ask, per task 02), `test_response_to_docs.py` (saveAsDoc untouched in this phase).
4. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
5. Commit (Conventional Commits, `--no-gpg-sign`) — e.g. `feat(rag): pass chat history with prior thinking to the LLM`; move `.agents/phases/todo/74_llm_chat_history/` → `.agents/phases/complete/`.
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_llm_history.py -v --no-cov` green in isolation (DB up, mock LLM).
- Coverage: **>90%** on `app/` (task 01's new code is unit/integration-pinned; this task adds no `app/` code).
## Completion Criteria
- [ ] The three E2E tests pass: prior Q/A AND prior `reasoning_content` reach the model on follow-ups (grounded + deflected); cold start carries no history.
- [ ] The mock's docstring carries the marker + the user/assistant-only invariant; no existing suite's trigger phrases or fixtures collide with `echo my history`.
- [ ] Regression suites green in isolation; full suite + coverage >90% + ruff + pyright clean.
- [ ] One atomic `--no-gpg-sign` commit; phase dir moved to `.agents/phases/complete/`.
@@ -1,37 +0,0 @@
# Task 01 — `saveAsDoc` posts a full-session transcript; single-turn E2E expectation updated
**Phase:** `75_save_doc_full_session` · **Source:** `TODO.md:4` — "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."
**Story:** n/a (TODO-derived)
## Objective
The "Save as doc" draft's body becomes the whole conversation — every user question and brain answer up to the click, in order — instead of only the clicked bubble's answer; the user then edits out unwanted turns in the existing doc-edit body field before pushing.
## Work
1. `frontend/assets/app.js` — add `buildSessionTranscript()` next to `defaultDocTitle()`/`saveAsDoc()` (~L562–L615):
- Walks the `conversation` record in order. Each `who === "user"` entry opens a section; each following brain entry (up to the next user entry) is appended under it:
```markdown
## 1. <user question, raw text>
<brain answer, raw text>
## 2. <user question, raw text>
<brain answer, raw text>
```
- Numbering: 1-based per USER turn (a section per question; normally one answer per section).
- Trailing whitespace collapsed to a single final newline.
- - ASSUMPTION A6 (owner-confirmed 2026-09-08): the transcript format is the numbered `## N. <question>` + raw answer markdown shown above; ALL turns at click time are included (even when the button is on an earlier bubble); NO thinking blocks, NO source chips, NO tune metadata travel into the document; the default TITLE is unchanged (`defaultDocTitle()` — last user question, ≤120 chars).
- - ASSUMPTION A7 (owner-confirmed 2026-09-08): "edit out anything they don't want to keep" = free-form editing in the EXISTING doc-edit body field (`frontend/doc-edit.html` / `frontend/assets/doc-edit.js` already expose the body as an editable textarea) — no new per-turn selection UI in this phase.
- Stopped/partial brain turns appear as-is (their `text` is what the user saw); the user edits them out if unwanted (A7).
2. `frontend/assets/app.js` — `saveAsDoc`: the draft's `body` becomes `buildSessionTranscript()` instead of the bubble's `markdown` (the POST to `/api/doc-drafts` at ~L623 otherwise unchanged: same `title` from `defaultDocTitle()`, same `docs/<slug>.md` path, same 201→`/doc-edit.html?draft=<token>` hand-off, same double-click guard). The `markdown` parameter of `saveAsDoc` is then unused — drop the parameter and update its single call site (`appendSaveAsDocButton`'s click binding ~L607); keep `appendSaveAsDocButton(wrap, markdown)`'s signature (the button is still appended per bubble).
3. `tests/e2e/test_response_to_docs.py` — `test_save_edit_push`'s body expectation: today "body == the rendered answer's markdown source" → the SINGLE-turn transcript `## 1. <the question>\n\n<the mock answer>` (byte-stable: the mock's default composed answer embeds the question's first 80 chars). Everything else in that suite (push → git verification, guest/unconfigured pins) is unchanged.
4. No backend change: `app/api/doc_drafts.py` accepts any body string; `frontend/doc-edit.html` renders it into the editable body as before.
## Testing & Quality
- No `app/` code — coverage floor unaffected; the wire-level proof is the E2E (this task updates the existing phase-59 suite; task 02 adds the multi-turn suite).
- Manual smoke: 2-turn conversation → Save as doc → the edit screen's body shows both Q/A sections before any editing.
## Completion Criteria
- [ ] `buildSessionTranscript()` produces the exact shape above for multi-turn records (section per user turn, raw texts, single trailing newline).
- [ ] `saveAsDoc` posts the transcript; the parameter cleanup is done (no dead `markdown` argument); the double-click guard and hand-off are unchanged.
- [ ] `tests/e2e/test_response_to_docs.py` (updated) green in isolation; `uv run pytest` green; ruff + pyright clean.