refactor(agents): migrate .agent/ planning tree to .agents/

Standardize on the .agents/ directory (shared with project skills):
phases/, user_stories/, reports/, screenshots/, validate.sh, and
phase-sessions/ + pipeline.log all move to .agents/ (git mv preserves
history; runtime artifacts move alongside).

Updates every reference in AGENTS.md, README.md, .gitignore, app
docstrings, and test story headers. Historical KB content in data/
and the runtime pipeline.log transcript are left untouched.
This commit is contained in:
2026-09-05 10:57:07 -04:00
parent 766702c750
commit dbf2af26c6
1118 changed files with 664 additions and 664 deletions
@@ -0,0 +1,122 @@
# Phase 20 — Sources Mid-Stream: an in-flight answer is not lost on navigation
**Source:** `TODO.md` L3 — *"Clicking "sources" while chat is generating
clears chat and result will never show up"*
**Story:** `.agents/user_stories/sources-midstream.md` (created by task 02)
**Context:** `frontend/assets/app.js` — the phase-14 persistence block
(`STORAGE_KEY = "bor.chat.v1"`, `conversation`, `saveConversation`,
`rememberBrainTurn`), the turn state machine (`UI_STATE.thinking` /
`.streaming`), the streaming accumulators (`acc` / `thinkingAcc` /
`sawThinking`), and the phase-14 restore path; `frontend/index.html`
(`#nav-sources` link); `frontend/assets/header.js` (`clearChatStorage` —
the deliberate New-Chat clear, NOT this bug).
## Objective
When the user leaves the chat page (the Sources nav link, the document
viewer, any link) while a turn is still in flight, the answer generated so
far must not vanish. Today the brain message is persisted only on `done`,
so navigating away aborts the stream and the partial answer is lost — the
user returns to their own question with no result, ever. After this phase,
returning to the chat shows the question **and** the partial answer that
had already streamed (rendered like any brain message, thinking block
restored if any).
## Owner-confirmed (2026-08-24, roadmap A1)
1. **A partial answer is persisted as a plain brain message** — no
"(partial)" marker, no sources/suggestions (the turn is dead; the user
can re-ask for the full answer).
2. Navigation **before the first answer token** (pure thinking) persists
nothing brain-side: the question is restored, no empty/partial bubble.
3. The deliberate **New Chat** `clearChatStorage()` (sources/viewer pages)
is untouched — that clear is by design (phase 14/19).
4. No server-side resume (A10 stays stateless) and no
"leave page?" confirmation dialog.
## Design
- **`app.js` — one new `pagehide` handler** (`window.addEventListener(
"pagehide", …)` — fires on navigate-away and bfcache store):
- Guard: only when a turn is in flight (current `uiState` is
`UI_STATE.thinking` or `UI_STATE.streaming`) **and** `acc` is
non-empty.
- Action: `rememberBrainTurn(acc, { thinking: thinkingAcc ||
undefined })` — reuse the existing save-point helper, so the partial
text is stored raw (the restore path re-renders through the
escape-first markdown renderer; the phase-17 `thinking` field
restores the collapsed Thinking block).
- **Idempotency guard:** a turn-local `persistedOnLeave` flag so a
second `pagehide` (or bfcache store+restore churn) never appends the
same partial message twice. The `done` save point is unaffected
(navigation means the stream is dead; if the user returns via
bfcache the turn is already aborted by the unloading page).
- **Restore path:** unchanged — a stored partial message is a well-formed
brain message and renders exactly like a completed one (minus
sources/deflection, which it simply doesn't carry).
- **Non-goals:** no resume of the SSE stream, no API changes, no changes
to the New Chat buttons, sign-out, or the document-viewer back link.
## Dependencies
- `14_chat_persistence` (complete) — `bor.chat.v1` shape, save points,
restore, and the `rememberBrainTurn` helper this phase reuses.
- `17_thinking_display` (complete) — the `thinking` field on persisted
brain messages and the `thinkingAcc` accumulator.
- `19_shared_header` (complete) — the `#nav-sources` link (admin-only)
the bug report clicks.
- `18_follow_bottom_scroll` (complete) — no overlap (scroll gating only).
## Tasks
1. `01_persist_inflight_turn.md` — the `pagehide` partial-persistence
handler in `app.js` + source-level unit pins.
2. `02_e2e_story_suite_commit.md` — `tests/e2e/test_sources_midstream_bug.py`
(the story gate, isolated), regression suites, story file, final
validation, the single atomic commit, phase move to `complete/`.
## Locked decisions
- **A10 untouched** — API stays stateless; no resume. **A11 untouched** —
vanilla JS, no CDN. **A16 honored** — one new story E2E suite +
adapted regressions. No anchor changed.
## Testing & Quality
- **Unit (source-level, new `tests/unit/test_sources_midstream.py`,
following the repo's source-pin pattern):** `app.js` registers a
`pagehide` listener; the guard references the in-flight `uiState` and a
non-empty `acc`; the partial path calls `rememberBrainTurn` with
`thinking: thinkingAcc || undefined`; a turn-local idempotency flag
exists; `STORAGE_KEY`/save-point comments updated to list the new
save point.
- **Integration:** none (no `app/` changes) — the `uv run pytest
--cov=app` number must stay at today's.
- **Coverage:** frontend-only; the >90% `app/` gate is unaffected,
re-run to prove it.
- **E2E:** `tests/e2e/test_sources_midstream_bug.py` (task 02), green
**in isolation** (prereq `podman compose up -d db`).
- **Lint/types:** `uv run ruff check . && uv run pyright` clean.
## Completion Criteria
- [ ] Admin, mid-stream, clicks **Sources** → returns to `/`: the
question **and** the already-streamed partial answer are both
rendered; no error banner; `bor.chat.v1` holds the partial brain
message.
- [ ] Navigate away before the first token → back: question restored,
no empty/partial brain bubble.
- [ ] A completed turn is persisted exactly as before (sources,
deflection, suggestions intact).
- [ ] New Chat from the sources page still clears the conversation.
- [ ] `uv run pytest` green; `uv run pytest --cov=app
--cov-report=term-missing` ≥ today's number.
- [ ] `uv run pytest tests/e2e/test_sources_midstream_bug.py -v --no-cov`
green in isolation; regressions green in isolation (one command
each): `test_chat_persistence.py`, `test_thinking_display.py`,
`test_shared_header.py`.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] UI Structure Check (AGENTS.md rule 5): no new UI surface — the
restored partial renders through the existing bubble/thinking
contract.
- [ ] `.agents/user_stories/sources-midstream.md` exists.
- [ ] One `--no-gpg-sign` commit (below);
`.agents/phases/todo/20_sources_midstream_bug/` moved to
`.agents/phases/complete/`.
## Commit
```bash
git add -A .agents/ frontend/ tests/ && git commit --no-gpg-sign -m "fix(chat): keep the in-flight answer when navigating away mid-turn — partial answer restored on return"
```
@@ -0,0 +1,83 @@
# Task 01 — app.js: persist the partial answer on navigate-away (pagehide)
**Phase:** `20_sources_midstream_bug` · **Source:** `TODO.md` L3 —
*"Clicking "sources" while chat is generating clears chat and result will
never show up"*
## Objective
`frontend/assets/app.js` gains exactly one new save point: on `pagehide`
(navigate-away / bfcache), if a turn is in flight and answer text has
already streamed, the partial answer is persisted via the existing
`rememberBrainTurn` helper — so returning to the chat restores the
question **and** what had been generated.
## Work
1. `frontend/assets/app.js`
- Next to the other turn-local state (around the `acc` /
`thinkingAcc` / `sawThinking` / `aborted` declarations, ~line 920+),
declare a turn-local flag:
```js
let persistedOnLeave = false; // pagehide partial-persist at most once
```
and reset it to `false` at the top of `runTurn` (where `acc`,
`thinkingAcc`, `sawThinking`, `wrap` are initialized), so it is
turn-scoped like the rest.
- Register one handler at module boot (next to the other
`window.addEventListener` calls):
```js
/* Navigate-away save point (phase 20, owner choice 2026-08-24 A1):
* leaving the chat mid-turn would otherwise drop the in-flight
* answer — the brain message persists only on `done`, and
* navigation aborts the stream. On `pagehide`, if a turn is in
* flight and answer text has streamed, persist the partial raw text
* (reusing the save-point helper, so restore re-renders it exactly
* like a completed answer — no "(partial)" marker, no sources).
* Thinking-only (no answer tokens yet) persists nothing brain-side:
* the question is already saved on send and the user can re-ask.
* `persistedOnLeave` makes this idempotent across pagehide/bfcache
* churn. */
window.addEventListener("pagehide", () => {
if (persistedOnLeave) return;
if (uiState !== UI_STATE.thinking && uiState !== UI_STATE.streaming)
return;
if (!acc) return; // nothing brain-side to save yet
persistedOnLeave = true;
rememberBrainTurn(acc, { thinking: thinkingAcc || undefined });
});
```
**ASSUMPTION (owner-confirmed A1):** the partial is a plain brain
message (no marker, no sources); the variables `uiState`, `acc`,
`thinkingAcc` are the turn's existing ones — match the names actually
in scope (the file keeps `let acc = ""` / `let thinkingAcc = ""`
turn-locals inside `runTurn`; if the handler needs them outside that
scope, hoist the turn-locals to module scope *without* changing any
behavior — smallest diff wins).
- Update the phase-14 persistence block comment (~line 631): the
"Save points:" sentence now lists three — the user message on send,
the brain message on `done`, and the **partial** brain message on
navigate-away (`pagehide`, phase 20).
2. `tests/unit/test_sources_midstream.py` (new — follow the repo's
source-level pin pattern used by `tests/unit/test_shared_header.py`):
- `app.js` contains `addEventListener("pagehide"` exactly once.
- The pagehide body is guarded by the in-flight states (`thinking`
and `streaming`) and a non-empty `acc` check.
- The pagehide body calls `rememberBrainTurn(acc,` with
`thinking: thinkingAcc || undefined`.
- `persistedOnLeave` is declared and reset in `runTurn`.
- The persistence block comment lists the `pagehide` save point.
- `clearChatStorage` (header.js) is untouched — still the only
deliberate clear.
## Testing & Quality
- `uv run pytest tests/unit/test_sources_midstream.py -v` green.
- `uv run ruff check . && uv run pyright` clean.
- Manual smoke (dev server, DEBUGPY optional): send a question against
the real LLM (or any slow stream), click Sources mid-stream, return —
the partial answer is visible.
## Completion Criteria
- [ ] The `pagehide` handler exists, is turn-scoped and idempotent, and
reuses `rememberBrainTurn` (no duplicated storage code).
- [ ] No other save point changed: send + `done` behave byte-identically
to before (existing persistence unit pins still pass).
- [ ] Unit pins green; lint/types clean.
@@ -0,0 +1,76 @@
# Task 02 — E2E story suite, story file, validation, commit
**Phase:** `20_sources_midstream_bug` · **Source:** `TODO.md` L3
## Objective
The story gate: `tests/e2e/test_sources_midstream_bug.py` proves the bug
is fixed end-to-end (navigate away mid-stream, come back, the partial
answer is there), plus the regression suites, the story file, final
validation, and the single atomic commit.
## Work
1. `tests/e2e/test_sources_midstream_bug.py` (new — mirror
`test_chat_persistence.py`'s scaffolding: fixture import via
`_import_fixtures`/`_run_in_thread`, mock LLM on a thread, `login`
from `e2e.auth_helpers`). The mock LLM must stream **slowly enough**
that the turn is still in flight when the test navigates (reuse the
streaming pattern from `test_thinking_display.py` / `mock_llm.py`;
tune the per-chunk delay until the navigation lands mid-stream).
Tests (Playwright Mapping Rule — one per numbered scenario):
1. `test_partial_answer_survives_sources_nav_midstream` — admin
(`login(page, app_url, next="/")`), send a question, wait for the
first streamed chunk to render (expect the first chunk's text in
the answer bubble), **click `#nav-sources`** (the actual nav link —
admin sees it), land on `/sources.html`, then `page.goto("/")`:
expect the question text AND the first-chunk text present, no
`role="alert"` banner. Read `localStorage` `bor.chat.v1`: the
messages contain a brain message whose text starts with the first
chunk.
2. `test_no_orphan_brain_message_when_navigated_before_first_token` —
mock streams a `thinking` event, then a long pre-token pause;
navigate (direct `page.goto("/sources.html")` is fine here) during
the pause, return to `/`: the question is present, exactly one
user message and **zero** brain messages in both the DOM and
`bor.chat.v1`.
3. `test_completed_turn_unaffected` — a turn that finishes normally
(`done`), then navigate to sources and back: full answer, sources
chips, and the done-metadata (sources array) intact in storage.
4. `test_new_chat_still_clears_conversation` — regression: completed
turn → `/sources.html` → click the sources-page New Chat button →
lands on `/` with the empty state and `bor.chat.v1` removed.
2. `.agents/user_stories/sources-midstream.md` (new) — the short story
file matching the repo's story format (goal, the bug report verbatim
from `TODO.md` L3, the owner-confirmed A1 decisions from
`00_phase.md`, the E2E mapping table test-name → scenario).
3. Run the suite **in isolation** (prereq `podman compose up -d db`):
`uv run pytest tests/e2e/test_sources_midstream_bug.py -v --no-cov`.
4. Regressions, in isolation, one command each (all must stay green):
- `uv run pytest tests/e2e/test_chat_persistence.py -v --no-cov`
- `uv run pytest tests/e2e/test_thinking_display.py -v --no-cov`
- `uv run pytest tests/e2e/test_shared_header.py -v --no-cov`
5. Final validation: `uv run pytest` green; `uv run pytest --cov=app
--cov-report=term-missing` ≥ today's number (>90% gate);
`uv run ruff check . && uv run pyright` clean.
6. **UI Structure Check** (AGENTS.md rule 5): the restored partial
renders through the existing bubble/thinking contract — no new
surface, no new ids, focus/contrast unchanged.
7. Write the phase report
(`.agents/reports/20_sources_midstream_bug/` — what changed, E2E
results, the manual-smoke note from task 01).
8. Commit (one atomic commit) and move the phase:
```bash
git add -A .agents/ frontend/ tests/
git commit --no-gpg-sign -m "fix(chat): keep the in-flight answer when navigating away mid-turn — partial answer restored on return"
mv .agents/phases/todo/20_sources_midstream_bug .agents/phases/complete/
```
## Testing & Quality
- Story suite green **in isolation**; the three regression suites green
in isolation; full unit+integration suite green; `app/` coverage at or
above today's number (>90%); ruff + pyright clean.
## Completion Criteria
- [ ] `test_sources_midstream_bug.py` 4/4 in isolation.
- [ ] Regressions (persistence, thinking display, shared header) green.
- [ ] Story file + phase report exist.
- [ ] One `--no-gpg-sign` commit; phase directory in `complete/`.