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:
@@ -0,0 +1,27 @@
|
||||
# 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/`.
|
||||
@@ -0,0 +1,34 @@
|
||||
# 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/`.
|
||||
Reference in New Issue
Block a user