chore(agent): phase roadmap from TODO.md, 2 phases (108 history-wire check, 109 turn progress loader)
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
# Phase 108 — History wire check: verify (or fix) the "missing first turn" follow-up bug
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L4 (owner 2026-09-16): "I've noticed at least one instance where a follow-up chat is missing the first message and response as context. So if I ask 'What is my name' and then 'What did I just ask you?' the model responds 'This is the first question you've asked'. But if I send a third message 'What was the previous question' the model responds correctly 'What did I just ask you?' Just check if there's a bug, there may not be and this was user error"
|
||||||
|
|
||||||
|
**Story:** n/a (owner bug report, phase-74 follow-up — the phase's E2E proves the wire end to end).
|
||||||
|
|
||||||
|
**Context (traced 2026-09-16):** the chat-history wire landed in phase 74 and is three layers deep: (1) the CLIENT maps the `bor.chat.v1` conversation record minus the current question into the request body — `conversation.slice(0, -1)` → `{who, text, thinking?}` per turn (`frontend/assets/app.js` L2247, invariant comment L2229-2246 — the phase-49 retry and phase-53 stale-regen paths pop the old answer before re-sending, so `slice(0,-1)` is exactly the prior turns); (2) the SERVER trims + maps — `history_to_messages` (`app/rag/prompts.py` L199-252): walks NEWEST-FIRST, keeps turns while BOTH budgets hold (`history_max_turns` default **40**, `history_max_chars` default **24_000** — `app/config.py` L94/L101), drops a whole turn on overflow, returns the kept window chronological; `user`→user, `brain`→assistant with `reasoning_content` ONLY when thinking is non-empty (A4); (3) the ENDPOINT splices the block between the system prompt and the current user message on BOTH turn branches (deflected + grounded — `app/api/chat.py` L353-358, "BOTH branches below … reuse the same block"). A short 2-turn conversation is orders of magnitude under both budgets, and both the client mapping and the trimmer READ correctly — so this phase is a deterministic three-layer VERIFICATION with a built-in fix branch, not a rewrite. The wire oracle already exists: the mock LLM's `HISTORY_TRIGGER = "echo my history"` (`tests/e2e/mock_llm.py` L597; checked at L1671 BEFORE the DEFLECT_MODE branch — the echo fires on both branches) answers with `_history_echo(body)` (L1492): a byte-stable `history: N prior messages; last answer tail: <last 24 chars of the most recent prior assistant message, or "none">; thinking: yes|no` — exactly what the owner's scenario needs. The phase-74 E2E (`tests/e2e/test_llm_history.py`) already asserts on this echo, deriving the expected tail from the localStorage record; the existing suites to extend live at `tests/unit/test_history.py` (the pure trimmer) and `tests/integration/test_chat_api.py` (the `HISTORY`/`HISTORY_MESSAGES` idiom L1620-1656 + the `_stream_chat_with_history` helper L1659).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove — at the trimmer, the endpoint, and the full browser wire — that a follow-up question carries the COMPLETE prior conversation (the owner's exact 2- and 3-turn scenarios, byte-exact via the history echo), and either ship the minimal fix at the layer that reproduces the missing-first-turn symptom or record the verdict "no bug — model behavior/user error" with the pins as the permanent guard.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `74_llm_chat_history` (complete) — the feature under verification: the `history` request field, the trimmer, both-branch splicing, the echo marker, and the suites this phase extends. All its pins are regression gates.
|
||||||
|
- `17_thinking_display` (complete) — the record's `thinking` key and the A4 `reasoning_content` wire convention the echo's `thinking: yes|no` term covers.
|
||||||
|
- `49_retry_answer` / `53_stale_saved_chats` (complete) — the client paths (retry, stale-regen) that POP the old answer before re-sending; the client-invariant comment names them — if the client layer ever reproduces, their pop logic is the first suspect.
|
||||||
|
|
||||||
|
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||||
|
|
||||||
|
- **The three layers, each isolating a suspect (tasks 01-02):**
|
||||||
|
1. **Unit — the trimmer** (task 01): the owner's exact shape — a 2-turn history (user Q1, brain R1) under the default budgets → ALL turns kept, chronological, roles mapped, thinking mapped (non-empty → `reasoning_content`, empty/absent → key absent). If this fails, the bug is in `history_to_messages` and nothing else needs running.
|
||||||
|
2. **Integration — the endpoint** (task 01): the SAME 2-turn history through the real `POST /api/chat` (the `test_chat_api.py::_stream_chat_with_history` idiom): the SSE turn completes AND the LLM request the turn made carries exactly `[system, user Q1, assistant R1, user Q2]` (captured per the house fake-LLM pattern). If layer 1 passes and this fails, the bug is in the endpoint plumbing (the `request.history` → `hist` → prompt splice, one of the two branches).
|
||||||
|
3. **E2E — the full client wire** (task 02): the owner's exact 3-message scenario in the browser, echo markers on turns 2 and 3 (see task 02 for the messages + expected echoes). If layers 1-2 pass and this fails, the bug is in the CLIENT record→history mapping (push/pop timing, the phase-49/53 paths, localStorage restore).
|
||||||
|
- **The verdict (task 03, D13):** all three green → NO BUG: the wire is proven complete at every layer; the reported instance is model behavior/user error (the owner's own hypothesis). The pins stay as the permanent guard (a future regression that drops the first turn fails layer 1, 2, or 3). A failure at layer N → the bug reproduces at layer N; the executor makes the MINIMAL fix in that layer, re-runs the failing layer green, and the verdict records the fix + evidence. `VERDICT.md` (NEW file inside this phase dir) is written BEFORE the commit and states: the layer outcomes, the verdict, and (if fixed) the one-line root cause.
|
||||||
|
- **NOT touched (D13/D14):** the A10 stateless contract, the budget defaults (40 turns / 24k chars), the `bor.chat.v1` record schema, the echo's format (the existing marker IS the oracle — D14: NO new mock marker this phase), any phase-74 pin (regression), `PLAN.md`, completed phases.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_server_wire_verification.md` — layer 1 (unit pins on the trimmer for the owner's 2-turn shape) + layer 2 (integration: real endpoint, captured LLM request = full prior history).
|
||||||
|
2. `02_client_e2e_owner_scenario.md` — layer 3: new E2E `tests/e2e/test_history_wire_check.py` (isolation) — the owner's exact 3-message scenario, byte-exact echo assertions on turns 2 and 3.
|
||||||
|
3. `03_verdict_fix_or_pin.md` — read the layer outcomes; fix the reproducing layer (or record "no bug"); `VERDICT.md`; full gate; atomic commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit — `tests/unit/test_history.py` (extended): the 2-turn-under-budget keep-all pin + the role/thinking mapping for that shape (the existing budget/trim pins stay green — regression).
|
||||||
|
- Integration — `tests/integration/test_chat_api.py` (extended): the 2-turn request through the real endpoint with the captured-LLM-request assertion (the house fake-LLM capture pattern; the existing phase-74 history tests stay green).
|
||||||
|
- E2E (mandatory, A16) — `uv run pytest tests/e2e/test_history_wire_check.py -v --no-cov` with the DB up.
|
||||||
|
- Coverage: **>90%** on `app/` (`uv run pytest --cov=app --cov-report=term-missing` — the validate.sh gate).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] Layer 1 green: a 2-turn history under the default budgets survives `history_to_messages` whole, chronological, correctly mapped (unit pin).
|
||||||
|
- [ ] Layer 2 green: a real `POST /api/chat` with a 2-turn history makes the LLM request `[system, user Q1, assistant R1, user Q2]` — the server wire is proven complete (or the bug is fixed here).
|
||||||
|
- [ ] Layer 3 green: the owner's scenario in the browser — turn 2's echo shows `history: 2 prior messages` + R1's exact 24-char tail; turn 3's echo shows `history: 4 prior messages` + R2's tail (or the bug is fixed at the client).
|
||||||
|
- [ ] `VERDICT.md` exists in the phase dir: layer outcomes + the verdict (fixed-at-layer-N with root cause, or "no bug — model behavior") — written before the commit.
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%; `uv run pytest tests/e2e/test_history_wire_check.py -v --no-cov` green in isolation; `uv run pytest tests/e2e/test_llm_history.py -v --no-cov` (phase 74) green in isolation; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] One `--no-gpg-sign` commit (message per the verdict — see Commit); phase dir moved to `.agents/phases/complete/` by the pipeline gate.
|
||||||
|
|
||||||
|
## Locked decisions
|
||||||
|
- **D13 — Verify-or-fix protocol (owner-instructed: "Just check if there's a bug, there may not be").** The phase's deliverable is the three-layer pins + a recorded verdict. Code changes happen ONLY when a layer reproduces the missing-turn symptom, are MINIMAL, and are confined to the reproducing layer — no A10 contract change, no budget-default change, no record-schema change, no new endpoint. If no layer reproduces, the phase ships tests-only.
|
||||||
|
- **D14 — The existing echo IS the oracle.** The phase-74 `echo my history` marker (`_history_echo`) is reused unmodified — its `N prior messages` count + `last answer tail` are exactly the owner-scenario assertions; NO new mock marker is added this phase (new markers land only in phases that change prompt/tool shapes).
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
```bash
|
||||||
|
# verdict = no bug (tests-only):
|
||||||
|
git add tests/ .agents/phases/ && git commit --no-gpg-sign -m "test(chat): history-wire verification pins — TODO L4 verdict: no bug (model behavior)"
|
||||||
|
|
||||||
|
# verdict = bug found (adjust <layer> to the fix site):
|
||||||
|
git add <fixed files> tests/ .agents/phases/ && git commit --no-gpg-sign -m "fix(chat): <layer> — follow-up turns carry the full prior history (TODO L4)"
|
||||||
|
```
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Task 01 — Server wire verification: the trimmer (unit) + the endpoint (integration)
|
||||||
|
|
||||||
|
**Phase:** `108_history_wire_check` · **Source:** `TODO.md` L4 — "a follow-up chat is missing the first message and response as context … Just check if there's a bug."
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove (or disprove) the two SERVER layers of the history wire for the owner's exact 2-turn shape: a short history must survive `history_to_messages` whole and reach the LLM as the complete prior conversation on the real `POST /api/chat`.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/unit/test_history.py` (EXISTING — extend, keep every pin green) — add the owner-shape pins for the DEFAULT budgets (no env overrides; construct `Settings` the file's existing `_settings()` way):
|
||||||
|
- `test_short_two_turn_history_kept_whole_and_chronological` — `history = [user "What is my name?", brain "Your name is Reese."]` (the owner's own Q1/R1) → `history_to_messages` returns exactly `[{"role": "user", "content": "What is my name?"}, {"role": "assistant", "content": "Your name is Reese."}]` — both turns, chronological, no trim, no reordering.
|
||||||
|
- `test_two_turn_history_thinking_mapping` — the same 2-turn history with the brain turn carrying a non-empty `thinking` → the assistant message gains `reasoning_content` (A4); with `thinking` empty/absent → the key is ABSENT (not an empty string).
|
||||||
|
- (If either pin fails: STOP — layer 1 reproduces the bug. Fix `app/rag/prompts.py::history_to_messages` minimally (D13), keep this task's pins + the existing suite green, and note the root cause for task 03's `VERDICT.md`. Do not touch the budget defaults.)
|
||||||
|
2. `tests/integration/test_chat_api.py` (EXISTING — extend next to the phase-74 history block, L1620-1670) — add the endpoint-layer pin:
|
||||||
|
- Reuse the file's `_stream_chat`/`_stream_chat_with_history` helpers + fake-LLM capture pattern (read the file's existing setup first — match its house idiom for capturing what the LLM was called with).
|
||||||
|
- `test_endpoint_two_turn_history_reaches_the_llm` — `POST /api/chat {message: "What did I just ask you?", history: [{who: user, text: "What is my name?"}, {who: brain, text: "Your name is Reese."}]}` → the SSE stream completes (`done`), and the chat request the turn made to the LLM carries, IN ORDER, the system prompt, `user "What is my name?"`, `assistant "Your name is Reese."`, then the current `user` question — i.e. the 2 prior turns are NOT dropped (the owner's symptom would be their absence). Assert on the captured `messages` list (roles + contents, exact).
|
||||||
|
- The turn may be LOW/deflected with an empty KB (the history block is branch-independent — pinned in phase 74) or HIGH with one seeded fixture doc (the file's existing seeding idiom) — either is fine; pick what the file's helpers make easiest and say so in a comment.
|
||||||
|
- (If this fails while layer 1 passed: the bug is in the endpoint plumbing — `app/api/chat.py`'s `request.history` → `hist` → prompt splice. Fix minimally (D13), keep this pin + the phase-74 pins green, note the root cause for task 03.)
|
||||||
|
3. Run `uv run pytest tests/unit/test_history.py tests/integration/test_chat_api.py -v` (DB up: `podman compose up -d db`) — green.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the owner-shape trimmer pins (keep-all + mapping) alongside the existing budget pins.
|
||||||
|
- Integration: the real endpoint with a captured LLM request — the server wire proven (or fixed) at the exact layer.
|
||||||
|
- Coverage: **>90%** on `app/` (no `app/` change unless a fix is needed; the gate still passes).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `tests/unit/test_history.py` green with the two new owner-shape pins (or the trimmer fixed + pinned)
|
||||||
|
- [ ] `tests/integration/test_chat_api.py` green with `test_endpoint_two_turn_history_reaches_the_llm` (or the endpoint fixed + pinned)
|
||||||
|
- [ ] Every pre-existing pin in both files still green (no regression)
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean; the layer-1/layer-2 outcome is noteable for task 03's `VERDICT.md` (pass, or pass-after-fix with root cause)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Task 02 — Client E2E: the owner's exact 3-message scenario, byte-exact via the history echo
|
||||||
|
|
||||||
|
**Phase:** `108_history_wire_check` · **Source:** `TODO.md` L4 — the owner's repro: Q1 "What is my name?" → Q2 "What did I just ask you?" (model claims it's the first question) → Q3 "What was the previous question?" (model answers correctly).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove (or disprove) the THIRD layer — the full browser wire: the localStorage conversation record → `conversation.slice(0,-1)` mapping → request body → the LLM — using the owner's exact scenario and the phase-74 `echo my history` oracle (D14: no new marker).
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_history_wire_check.py` (NEW — copy the app-server + fixture idiom from `tests/e2e/test_llm_history.py`: module-scoped mock-LLM app, the fixture-docs import for a non-empty KB, `e2e.auth_helpers.login`, the localStorage `bor.chat.v1` record reads, per-test conversation reset; module docstring: story n/a — owner bug report 2026-09-16, the isolation command, and what each test pins). Isolation: `uv run pytest tests/e2e/test_history_wire_check.py -v --no-cov` (DB up).
|
||||||
|
- **The echo oracle, recalled** (mock `_history_echo`, byte-stable): `history: N prior messages; last answer tail: <LAST 24 CHARS of the most recent prior assistant message's content, or "none">; thinking: yes|no` — N = non-system messages before the LAST user message (the current question excluded); checked BEFORE the DEFLECT_MODE branch, so the echo fires whatever gate branch the turn takes (the owner's questions may deflect — that's fine, the marker is in the USER message).
|
||||||
|
- **Tests:**
|
||||||
|
1. `test_cold_start_echo_shows_no_phantom_history` — fresh conversation; ask `echo my history` as the FIRST message → the answer bubble contains `history: 0 prior messages; last answer tail: none; thinking: no` (the cold-start pin: no phantom prior turns).
|
||||||
|
2. `test_owner_scenario_three_turns_carry_the_full_prior_history` — the owner's exact scenario, echo marker APPENDED to turns 2 and 3 (their words preserved verbatim as the prefix):
|
||||||
|
- T1: `What is my name?` → R1 (the mock's deterministic answer — read R1's raw text from the `bor.chat.v1` record's brain entry, NOT from the rendered DOM).
|
||||||
|
- T2: `What did I just ask you? echo my history` → R2's bubble text must contain `history: 2 prior messages; last answer tail: {R1[-24:]}; thinking: no` (R1 = the record's brain text; `thinking: no` — T1 never triggered the thinking marker). **THE regression pin: the owner's bug renders this as `0 prior messages` / `last answer tail: none`.**
|
||||||
|
- T3: `What was the previous question? echo my history` → R3's bubble text must contain `history: 4 prior messages; last answer tail: {R2[-24:]}` (R2 = the echo answer itself — also from the record).
|
||||||
|
- Read the expected tails from the localStorage record AFTER each turn persists (the `test_llm_history.py` pattern — the record the client saved IS what the client sends next, so what the record shows is what the model received).
|
||||||
|
- If this test fails while tasks 01's layers passed: the bug is in the CLIENT mapping (suspects, in order: the `conversation.slice(0,-1)` sites, the phase-49 retry / phase-53 stale-regen pop paths, the record persistence timing — `frontend/assets/app.js` L2187/L2247). Fix minimally (D13), keep this test + `tests/e2e/test_llm_history.py` green, note the root cause for task 03's `VERDICT.md`.
|
||||||
|
2. Run the suite in isolation — green (or pass-after-fix).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E (the task IS the test): the full browser wire, byte-exact via the existing echo oracle.
|
||||||
|
- Coverage: `--no-cov` suite; it exercises `app/` (chat endpoint, history mapping) for real — the `app/` >90% gate is unaffected.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_history_wire_check.py -v --no-cov` green in isolation (DB up) — both tests
|
||||||
|
- [ ] The regression pin holds: turn 2's echo shows `2 prior messages` + R1's exact tail; turn 3 shows `4 prior messages` + R2's exact tail (or the client bug is fixed + pinned)
|
||||||
|
- [ ] `tests/e2e/test_llm_history.py` (phase 74) still green in isolation (regression)
|
||||||
|
- [ ] The layer-3 outcome is noteable for task 03's `VERDICT.md` (pass, or pass-after-fix with root cause)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Task 03 — Verdict (fix or pin), full gate, atomic commit
|
||||||
|
|
||||||
|
**Phase:** `108_history_wire_check` · **Source:** `TODO.md` L4 — "Just check if there's a bug, there may not be and this was user error"; AGENTS.md rules 8/9 — the test gates are non-negotiable.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Record the phase's verdict with its evidence, run the complete quality gate, and land the single `--no-gpg-sign` commit — tests-only if no bug was found (the owner's expected outcome), fix + tests if one layer reproduced.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. **The verdict** — read the layer outcomes from tasks 01-02 (their test results + any fix notes):
|
||||||
|
- **All three layers green (no fix needed):** the wire is proven complete at the trimmer, the endpoint, and the full browser wire → verdict **NO BUG**: the owner's reported instance was model behavior/user error. The pins stay as the permanent guard (a future regression that drops the first turn fails layer 1, 2, or 3).
|
||||||
|
- **A layer reproduced (pass-after-fix):** the bug is fixed at that layer → verdict **BUG FOUND + FIXED at <layer>**, with the one-line root cause.
|
||||||
|
- Write `.agents/phases/todo/108_history_wire_check/VERDICT.md` BEFORE the commit: the three layer outcomes (pass / pass-after-fix + root cause / fail-should-not-occur), the verdict, and the evidence (which test names carry the pins). Keep it short — it is the durable record the owner asked for ("just check").
|
||||||
|
2. **The full gate** (DB up: `podman compose up -d db`; every command must pass before the commit):
|
||||||
|
- `uv run pytest` — green.
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` — TOTAL >90%.
|
||||||
|
- E2E in isolation: `uv run pytest tests/e2e/test_history_wire_check.py -v --no-cov` (NEW) and `uv run pytest tests/e2e/test_llm_history.py -v --no-cov` (phase-74 regression).
|
||||||
|
- `uv run ruff check . && uv run pyright` — clean.
|
||||||
|
- No-regression spot check: `git diff --stat` shows ONLY the files this phase may touch — `tests/**`, `VERDICT.md`, `.agents/phases/**`, and (only if a bug was fixed) the single reproducing layer's file. If the diff shows anything else, stop and fix the scope before committing.
|
||||||
|
3. **The commit** (exactly one, `--no-gpg-sign`, per the 00_phase.md branch):
|
||||||
|
- no bug: `git add tests/ .agents/phases/ && git commit --no-gpg-sign -m "test(chat): history-wire verification pins — TODO L4 verdict: no bug (model behavior)"`
|
||||||
|
- bug found: `git add <fixed files> tests/ .agents/phases/ && git commit --no-gpg-sign -m "fix(chat): <layer> — follow-up turns carry the full prior history (TODO L4)"`
|
||||||
|
4. Move the phase directory: `mv .agents/phases/todo/108_history_wire_check .agents/phases/complete/` (the pipeline gate does this on success — do it only after the commit, and match how prior phases recorded the move).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- This task runs, not writes, the gate: every command above must pass before the commit exists.
|
||||||
|
- Coverage: **>90%** on `app/` (TOTAL line of the `term-missing` report).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `VERDICT.md` in the phase dir: layer outcomes + verdict (no bug / fixed-at-<layer> + root cause) + the pin test names
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%
|
||||||
|
- [ ] `tests/e2e/test_history_wire_check.py` + `tests/e2e/test_llm_history.py` green in isolation (DB up)
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean; the diff is scoped to this phase's allowed files
|
||||||
|
- [ ] Exactly one new commit with the verdict-branch message, `--no-gpg-sign`; `git status` clean afterwards (only gitignored runtime artifacts aside)
|
||||||
|
- [ ] Phase dir at `.agents/phases/complete/108_history_wire_check/`
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Phase 109 — Never-frozen turn: re-expanding thinking block + the persistent in-turn loader
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L3 (owner 2026-09-16): "Thinking can happen after the model starts responding. This sometimes results in a the chat appearing 'frozen' because the model responds, calls a tool, then continues thinking without re-expanding the thinking block. There should be a visual that the chat is still progressing regardless of what state it's in (some kind of loader will do)."
|
||||||
|
|
||||||
|
**Story:** n/a (owner request — extends the phase 17/48/87 thinking/tool feedback under the PLAN §7.4 never-stale contract; the phase's E2E proves the reported repro no longer freezes).
|
||||||
|
|
||||||
|
**Context (traced 2026-09-16):** the turn's visible feedback is state-driven in `frontend/assets/app.js`: the `UI_STATE` machine (L340-345: `idle`/`thinking`/`streaming`/`error`) is owned by `setUiState` (L1243) — the typing bubble (a `#typing-indicator` message with the animated `.bubble.typing` dots, `addTyping` L859, the 10s elapsed-seconds clock L1137) shows ONLY in `thinking`; `inFlight = thinking|streaming` drives the Stop button (L1251-1255); `#send-status` is the sole a11y live region (L347-353; visual elements are `aria-hidden` — the L1791 house pattern). The reported freeze, exactly as the owner described it: the `delta` handler (L2357-2370) runs `setUiState(streaming)` on the FIRST delta — which `removeTyping()`s the dots — then `closeThinkingBlock(wrap)` (L905-909: "auto-collapse; idempotent, **never reopens**"). A LATER `thinking` frame (the next agent round — the model answered, called a tool, then thinks again) hits the `thinking` handler (L2268-2301), which only appends to the collapsed block's `.thinking-text` — nothing is visible: the answer text is static, the dots are gone, the scratchpad is closed → the chat reads as frozen. Every OTHER state already has a cue: pre-delta thinking = live open block + dots; tool = the `.tool-call` line with the phase-87 `(Ns)` elapsed counter (plus relabeled dots pre-delta); streaming = growing text; retry = the status line. The post-delta thinking gap is the ONE uncovered state — and the owner wants a constant cue anyway ("regardless of what state it's in"). The CSS lives in `frontend/assets/styles.css` (the typing-dots rules there; `prefers-reduced-motion` is house law, §7.2). House patterns: unit pins read the assets as text (`tests/unit/test_frontend_tool_states.py` / `test_frontend_feedback.py` already pin the thinking-block + typing behavior); the mock LLM has `THINKING_TRIGGER = "think out loud"` (mock_llm.py L500 — streams ~700 chars of `reasoning_content` ahead of content) and the multi-round `tool_calls` markers (L77-191); the house rule is that a marker/regex change lands WITH its consuming task (PLAN §4).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The chat never reads as frozen: (1) a `thinking` frame RE-OPENS the thinking block after the answer has started (the block is open-while-thinking / closed-while-answering — the reported symptom, fixed at the handler), and (2) a compact persistent loader is visible for the ENTIRE active turn (send → terminal frame) in the composer status area — the constant progress cue the owner asked for, driven by the single `setUiState` owner so it can never go stale (§7.4).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `17_thinking_display` (complete) — the thinking block, the follow-the-tail pin contract (`THINKING_NEAR_BOTTOM_PX`), the restore-path collapsed rendering; the block's unit pins live in the `test_frontend_*` suites this phase extends.
|
||||||
|
- `48_stop_generation` (complete) — the `inFlight`/Stop-button state ownership the loader's single-owner toggle joins.
|
||||||
|
- `87_big_read_progress` (complete) — the `.tool-call` line + the `armToolLineClock`/`settleToolLine` `(Ns)` counter (the "at least one cue" inventory's tool entry).
|
||||||
|
- `06_loading_feedback` (complete) — the UI state machine + the never-stale feedback contract this phase extends (every state keeps a defined UI).
|
||||||
|
|
||||||
|
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||||
|
|
||||||
|
- **D15 — The thinking block becomes a TOGGLE (task 01).** The `thinking` SSE handler gains `block.open = true` after `ensureThinkingBlock(wrap)` (idempotent — a no-op while already open, so the pre-delta live flow is byte-identical in behavior); the `delta` handler KEEPS its `closeThinkingBlock(wrap)`. The contract flips from "never reopens" to **open-while-thinking, closed-while-answering** — the block reflects the model's current activity in every agent round. `closeThinkingBlock`'s docstring/comment updates (the "never reopens" claim is gone — the delta handler closes, the thinking handler opens). The follow-the-tail pin logic (L2293: `block.open && isThinkingNearBottom(textEl)` measured BEFORE the re-render) is UNCHANGED — it already keys off `block.open`, so a re-opened block resumes pinned tail-following exactly like the live pre-delta block. The phase-14 RESTORE path (`renderStoredMessage` L1517-1518) still renders stored blocks collapsed — untouched.
|
||||||
|
- **D16 — The turn loader (task 02): a static shell element, single-owner visibility.**
|
||||||
|
- `frontend/index.html` — ONE static element in the composer's status row (next to the `#send-status` live region): `<div id="turn-loader" class="turn-loader" aria-hidden="true" hidden></div>` — static markup, hidden by default (no JS-built HTML — the createElement/textContent house rule; no document-derived data anywhere near it).
|
||||||
|
- `frontend/assets/app.js` — `setUiState` (L1243) is the SOLE owner, exactly like the existing `is-stop` toggle: `turnLoader.hidden = !inFlight` (shown iff `uiState ∈ {thinking, streaming}`). Every terminal path funnels through `setUiState` (done → `idle`, error → `error`, stop/timeout → `error`/`idle` per the existing handlers), so the loader CANNOT be left visible in a terminal state — the §7.4 never-stale guarantee comes from the single-owner pattern, not from per-handler cleanup.
|
||||||
|
- `frontend/assets/styles.css` — `.turn-loader` next to the typing-dots rules: compact, reuses the EXISTING typing-dot animation (same keyframes/dot styling — no new animation family), provenance comment citing phase 109 + `TODO.md` L3, and a `prefers-reduced-motion` variant mirroring the typing dots' treatment (static dots, no pulse). Contrast N/A (the dots are decorative — `aria-hidden` + the `#send-status` announcer carry meaning; §7.2 "text + color, never color alone" — the state TEXT stays in `#send-status`).
|
||||||
|
- **The invariant (unit + E2E):** while a turn is active, at least one visible progress cue is ALWAYS present — the loader (constant, D16), the open thinking block (thinking frames, D15), the tool-line `(Ns)` counter (tool frames, phase 87), or the growing answer text (streaming). D16 makes it true by construction; task 03 proves the reported repro (delta → tool → thinking-after-delta) no longer freezes.
|
||||||
|
- **NOT touched:** the `UI_STATE` set, the `SEND_STATUS` copy, the typing bubble's own lifecycle (it still shows only pre-delta, per the phase-17 contract — the loader is a SEPARATE constant cue, not a re-homing of the dots), `#send-status` (unchanged — still the sole a11y announcer), the mock's EXISTING markers, the server (this is a pure UI phase — `app/` is untouched, so `app/` coverage is a regression check only), `PLAN.md`, completed phases.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_thinking_block_reexpand.md` — the `thinking` handler re-opens the collapsed block; the delta handler keeps closing; the "never reopens" narrative updated; unit pins (read-the-assets pattern).
|
||||||
|
2. `02_turn_active_loader.md` — the static `#turn-loader` element, the `setUiState` single-owner toggle, the CSS (reused dot animation + reduced-motion + provenance), unit pins.
|
||||||
|
3. `03_e2e_and_gate.md` — the new mock marker forcing the reported `delta → tool → thinking-after-delta` sequence (lands WITH this task), the dedicated E2E `tests/e2e/test_turn_progress_loader.py` (isolation), the a11y pass, the full gate, the atomic commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit — `tests/unit/test_frontend_turn_loader.py` (NEW, the house read-the-assets-as-text pattern): task 01 pins — the `thinking` handler contains the `block.open = true` re-open (after `ensureThinkingBlock`), the `delta` handler still calls `closeThinkingBlock`, `closeThinkingBlock`'s docstring no longer claims "never reopens", the restore path (`renderStoredMessage`) still sets `block.open = false`; task 02 pins — `index.html` carries exactly one `#turn-loader` with `aria-hidden="true"` + `hidden`, `setUiState` is the SOLE writer of `turnLoader.hidden` (cross-file single-owner check: `turnLoader.hidden` appears nowhere else in `app.js`), the `.turn-loader` CSS rule exists next to the typing rules with the reduced-motion variant + the phase-109 provenance comment.
|
||||||
|
- E2E (mandatory, A16) — `tests/e2e/test_turn_progress_loader.py` (task 03): `uv run pytest tests/e2e/test_turn_progress_loader.py -v --no-cov` with the DB up.
|
||||||
|
- Coverage: **>90%** on `app/` (`uv run pytest --cov=app --cov-report=term-missing` — unchanged by this UI phase; the gate is the regression check).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] A `thinking` frame after the answer has started RE-OPENS the thinking block (the block is open-while-thinking / closed-while-answering; pre-delta flow + restore path unchanged — unit-pinned).
|
||||||
|
- [ ] `#turn-loader` is visible for the entire active turn and hidden in every terminal state — owned solely by `setUiState` (unit-pinned single-owner + the E2E's start/mid/end samples).
|
||||||
|
- [ ] E2E green in isolation: the reported repro (delta → tool → thinking-after-delta) shows the re-opened scratchpad with the new thinking text, the loader visible throughout, hidden after `done`; `#send-status` carries the state text (the loader is `aria-hidden`).
|
||||||
|
- [ ] The phase-17/48/87/6 regressions green in isolation: `uv run pytest tests/e2e/test_thinking_display.py -v --no-cov`, `tests/e2e/test_stop_generation.py -v --no-cov`, `tests/e2e/test_big_read_progress.py -v --no-cov`, `tests/e2e/test_loading_feedback.py -v --no-cov`; `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agents/phases/complete/` by the pipeline gate.
|
||||||
|
|
||||||
|
## Locked decisions
|
||||||
|
- **D15 — The thinking block is a toggle, not a one-way door (owner-instructed: "continues thinking without re-expanding the thinking block" is THE reported defect).** `thinking` frames open the block (idempotent), `delta` frames close it; the follow-the-tail pin contract and the restore path are unchanged. No new block, no new state — the existing scratchpad reflects the model's current activity in every round.
|
||||||
|
- **D16 — The constant cue is a separate static loader, owned by `setUiState` (owner-instructed: "a visual that the chat is still progressing regardless of what state it's in — some kind of loader will do").** A static `#turn-loader` in the composer status row (reused typing-dot animation, `aria-hidden`, `#send-status` stays the sole announcer), shown iff `inFlight` — the single-owner pattern makes a stale loader impossible. The typing bubble's own pre-delta lifecycle is NOT re-homed (the phase-17 contract stands); the loader ADDS the constant the owner asked for.
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
```bash
|
||||||
|
git add frontend/index.html frontend/assets/app.js frontend/assets/styles.css tests/ .agents/phases/ && git commit --no-gpg-sign -m "feat(chat): never-frozen turn — re-expanding thinking block + the persistent in-turn loader"
|
||||||
|
```
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Task 01 — The thinking block becomes a toggle: re-open on `thinking` frames, close on `delta`
|
||||||
|
|
||||||
|
**Phase:** `109_turn_progress_loader` · **Source:** `TODO.md` L3 — "the model responds, calls a tool, then continues thinking without re-expanding the thinking block."
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Fix the reported freeze at its source: a `thinking` frame that arrives after the answer has started re-opens the collapsed thinking block (D15) — the scratchpad is visible exactly while the model is thinking, in every agent round — while the `delta` handler keeps closing it and nothing else about the block's lifecycle changes.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/app.js` — the `thinking` SSE handler (L2268-2301): after `const block = ensureThinkingBlock(wrap);`, add `block.open = true;` (idempotent — while the block is already open (the pre-delta live flow) this is a no-op, so that flow's behavior is unchanged; after a `delta` closed it, this re-opens it for the new round's thinking). Update the handler's comment to state the toggle contract (D15): open-while-thinking, closed-while-answering — phase 109, `TODO.md` L3.
|
||||||
|
- The follow-the-tail logic below it (`const pinned = block.open && isThinkingNearBottom(textEl);`, L2293) is UNCHANGED — it already reads `block.open` before the re-render, so a re-opened block resumes pinned tail-following exactly like the live pre-delta block.
|
||||||
|
2. `frontend/assets/app.js` — `closeThinkingBlock` (L905-909): keep the function exactly as-is (the `delta` handler still calls it, L2369); update its docstring/comment — the "never reopens" claim is replaced by the toggle contract (the `thinking` handler re-opens; the `delta` handler closes).
|
||||||
|
- `renderStoredMessage` (L1517-1518 — the phase-14 restore path) is UNTOUCHED: stored blocks still render collapsed.
|
||||||
|
3. `tests/unit/test_frontend_turn_loader.py` (NEW — the house read-the-assets-as-text pattern; copy the file header/docstring conventions from `tests/unit/test_frontend_tool_states.py`):
|
||||||
|
- `test_thinking_handler_reopens_the_collapsed_block` — the `thinking` handler source contains `block.open = true` positioned AFTER the `ensureThinkingBlock(wrap)` line (order asserted — the block must exist before it opens).
|
||||||
|
- `test_delta_handler_still_closes_the_block` — the `delta` handler still calls `closeThinkingBlock(wrap)` (the close side of the toggle survives).
|
||||||
|
- `test_close_thinking_block_docstring_says_toggle_not_one_way` — `closeThinkingBlock`'s comment no longer contains "never reopens"; the toggle contract (open-while-thinking / closed-while-answering) is documented (assert the new wording, e.g. it names the `thinking` handler's re-open).
|
||||||
|
- `test_restore_path_still_collapses_stored_blocks` — `renderStoredMessage` still sets `block.open = false` (phase-14 contract regression).
|
||||||
|
- Cross-file: the `THINKING_NEAR_BOTTOM_PX` pin + `isThinkingNearBottom` logic are untouched (assert the pre-render `block.open &&` guard line still exists in the handler).
|
||||||
|
4. Run `uv run pytest tests/unit/test_frontend_turn_loader.py -v` + the existing frontend suites that pin this area (`tests/unit/test_frontend_tool_states.py tests/unit/test_frontend_feedback.py tests/unit/test_frontend_scroll.py tests/unit/test_big_read_progress.py`) — green.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the toggle contract pinned at the source level (the house pattern for `app.js` behavior — no browser).
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged — pure frontend task; the gate is the regression check).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The `thinking` handler re-opens the block (unit-pinned, correct order); the `delta` handler still closes it; the "never reopens" narrative is gone (unit-pinned)
|
||||||
|
- [ ] The restore path + the follow-the-tail pin logic are untouched (unit-pinned regressions)
|
||||||
|
- [ ] The existing frontend unit suites green; `uv run ruff check . && uv run pyright` clean
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Task 02 — The persistent in-turn loader: static `#turn-loader`, single-owner visibility in `setUiState`
|
||||||
|
|
||||||
|
**Phase:** `109_turn_progress_loader` · **Source:** `TODO.md` L3 — "There should be a visual that the chat is still progressing regardless of what state it's in (some kind of loader will do)."
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Add the constant progress cue (D16): a compact animated loader in the composer status row that is visible for the ENTIRE active turn (send → terminal frame), owned solely by `setUiState` so it can never be left stale — and hidden in every terminal state by construction.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/index.html` — ONE static element in the composer's status row (the L290-300 region holding `#char-count` / `#send-btn` / `#send-status` — inspect the actual markup and place it adjacent to `#send-status` so it reads as the status line's companion):
|
||||||
|
```html
|
||||||
|
<div id="turn-loader" class="turn-loader" aria-hidden="true" hidden></div>
|
||||||
|
```
|
||||||
|
Static markup, `hidden` by default (idle on load). No JS-built HTML anywhere (the createElement/textContent house rule — this element is never constructed in JS).
|
||||||
|
2. `frontend/assets/app.js` — the single-owner toggle (D16):
|
||||||
|
- At the top of the module with the other element lookups (near `const sendStatus = document.querySelector("#send-status");` L307): `const turnLoader = document.querySelector("#turn-loader");`
|
||||||
|
- Inside `setUiState` (L1243), next to the existing `sendBtn.classList.toggle("is-stop", inFlight);` (L1251-1255): `turnLoader.hidden = !inFlight;` — shown iff `uiState ∈ {thinking, streaming}`. This is the SOLE writer of `turnLoader.hidden` in the file: every terminal path (done → `idle`, error → `error`, stop/timeout → the existing error/idle landings) funnels through `setUiState`, so the loader is hidden in every terminal state BY CONSTRUCTION — the §7.4 never-stale guarantee, no per-handler cleanup (that is the point; comment it that way).
|
||||||
|
- Do NOT touch the typing bubble's lifecycle (`addTyping`/`removeTyping` stay exactly as-is — the phase-17 pre-delta contract stands; the loader is a separate constant cue), the `SEND_STATUS` copy, or `#send-status` (still the sole a11y announcer — the loader is `aria-hidden` decoration, the L1791 house pattern).
|
||||||
|
3. `frontend/assets/styles.css` — the `.turn-loader` rule NEXT TO the typing-dots rules (find the `.bubble.typing` / dots animation block):
|
||||||
|
- Compact horizontal three-dot indicator, REUSING the existing typing-dot keyframes/dot styling (same animation name — no new animation family; sized down for the status row).
|
||||||
|
- Provenance comment: phase 109, `TODO.md` L3 — the constant in-turn progress cue; decorative (`aria-hidden`), `#send-status` carries the meaning.
|
||||||
|
- A `prefers-reduced-motion` variant mirroring the typing dots' treatment (static dots, no pulse — §7.2 house law).
|
||||||
|
4. `tests/unit/test_frontend_turn_loader.py` (EXTEND the file task 01 created):
|
||||||
|
- `test_index_html_carries_exactly_one_turn_loader` — `frontend/index.html` contains exactly ONE `id="turn-loader"`, with `aria-hidden="true"` and the `hidden` attribute (hidden by default).
|
||||||
|
- `test_set_ui_state_is_the_sole_owner_of_the_loader` — in `app.js`: `turnLoader.hidden` appears EXACTLY ONCE, inside `setUiState` (the cross-file single-owner check — grep the file text; any second write site fails the test, keeping the never-stale guarantee structural).
|
||||||
|
- `test_loader_css_reuses_the_typing_animation_and_reduced_motion` — the `.turn-loader` rule exists in `styles.css` after (or adjacent to) the typing-dots rules, references the SAME animation name as the typing dots, and a `prefers-reduced-motion` block covers it (static, no pulse); the provenance comment names phase 109.
|
||||||
|
- `test_loader_is_aria_hidden_and_status_untouched` — the loader element is `aria-hidden`; `#send-status`'s attributes are unchanged in `index.html` (still the live region — the house a11y split).
|
||||||
|
5. Run `uv run pytest tests/unit/test_frontend_turn_loader.py tests/unit/test_frontend_tool_states.py tests/unit/test_theme_frontend.py -v` (the theme suite parses `styles.css` — a new CSS rule must not break the built-in-theme pins) — green.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the single-owner invariant + the markup/CSS contract pinned at the source level (the house pattern).
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged — pure frontend task; the gate is the regression check).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `#turn-loader` exists exactly once in `index.html` (static, `aria-hidden`, hidden by default); `setUiState` is its sole visibility owner (unit-pinned)
|
||||||
|
- [ ] The CSS reuses the typing-dot animation, has the reduced-motion variant + the phase-109 provenance comment (unit-pinned); the theme CSS-parsing suites stay green
|
||||||
|
- [ ] The typing bubble's lifecycle, `SEND_STATUS`, and `#send-status` are byte-unchanged in behavior (the existing frontend suites green)
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean; full `uv run pytest` green
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Task 03 — E2E: the reported repro (delta → tool → thinking-after-delta) + full gate + commit
|
||||||
|
|
||||||
|
**Phase:** `109_turn_progress_loader` · **Source:** `TODO.md` L3 — "the model responds, calls a tool, then continues thinking without re-expanding the thinking block" + "a visual that the chat is still progressing regardless of what state it's in"; AGENTS.md rules 4/8/9.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove the fix end to end with a dedicated Playwright suite: a deterministic mock sequence that replays the owner's exact repro (answer starts, tool call, thinking AFTER the answer) must show the re-opened scratchpad, the loader visible throughout, and clean terminal states — then run the full gate and land the atomic commit.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/mock_llm.py` — ONE new marker (the house rule: marker/regex changes land WITH their consuming task — this task; document it in the module docstring next to the existing markers):
|
||||||
|
- A new `*_TRIGGER` constant + branch (checked like the other user-message markers, BEFORE the DEFLECT_MODE branch) whose question forces the reported sequence with BAKED-IN DELAYS (mid-turn windows of ≥1 s each, so Playwright assertions are deterministic — the `slow_llm.py` precedent for deliberate pacing):
|
||||||
|
- model call 1: ~2 s pre-delay (model latency — the loader's start-state window), then a short `content` delta (2-3 chunks; NO reasoning), then an `ls` `tool_calls` delta (synthetic id, no arguments — the L77-81 pattern), `finish_reason: "tool_calls"`.
|
||||||
|
- model call 2 (after the server's `tool_result`): `reasoning_content` chunks (~10 × ~0.3 s), then a `content` delta (2-3 chunks ending in a DISTINCTIVE final sentence the tests can match), then a FINAL `reasoning_content` chunk (3 × ~0.3 s), then finish.
|
||||||
|
- The server is position-independent over the wire (each `reasoning_content` chunk → a `thinking` SSE frame, each `content` chunk → a `delta` frame — `app/rag/llm.py` L572+), so the resulting SSE is exactly `delta → tool → tool_result → thinking → delta → thinking → done` — the owner's repro, deterministic.
|
||||||
|
2. `tests/e2e/test_turn_progress_loader.py` (NEW — copy the app-server + fixture idiom from `tests/e2e/test_llm_history.py`: module-scoped mock-LLM app, fixture-docs import, `login`, per-test fresh conversation; module docstring: story n/a — owner request 2026-09-16, the isolation command, the marker contract, and what each test pins). Isolation: `uv run pytest tests/e2e/test_turn_progress_loader.py -v --no-cov` (DB up). Each test sends the marker question in its OWN fresh conversation (one full turn per test):
|
||||||
|
- `test_loader_visible_from_send_through_the_tool_gap` — right after the send (inside call 1's 2 s pre-delay window): `#turn-loader` is VISIBLE (the thinking state, no frame yet); then wait for the `.tool-call` line to appear (the turn is provably in flight) → the loader is STILL visible, the tool line carries the phase-87 elapsed counter, and `#send-status` carries a state text (not empty).
|
||||||
|
- `test_thinking_block_reopens_after_delta_with_visible_loader` — wait until the thinking block is open with non-empty `.thinking-text` AND the answer bubble already carries call 1's content (i.e. the post-delta re-open — THE reported symptom's state): assert the block is `open`, the new thinking text is VISIBLE in it, and the loader is STILL visible (the frozen window is gone). Then wait for the terminal state (call 2's distinctive final sentence in the bubble, or the send button back to "Send"): the loader is HIDDEN, the block is still open (the LAST frame was thinking), `.thinking-text` is non-empty, the bubble contains BOTH call 1's and call 2's content, and the send button reads "Send" (not "Stop").
|
||||||
|
- `test_loader_a11y_and_reduced_motion` — after a full turn: the loader element is `aria-hidden="true"` in the DOM (the `#send-status` live region remains the sole announcer — assert its post-done text follows the `SEND_STATUS` idle shape, i.e. not stuck on a mid-turn label); then, in a context with `reducedMotion: "reduce"` (Playwright context option), send a second turn and assert the loader is still visible mid-turn (the reduced-motion variant renders the static dots — the CSS rule, not the visibility, is what changes).
|
||||||
|
3. Run the suite in isolation — all three tests green.
|
||||||
|
4. **The full gate** (DB up; every command passes before the commit):
|
||||||
|
- `uv run pytest` — green.
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` — TOTAL >90%.
|
||||||
|
- Regressions in isolation (the feedback-state history this phase extends): `uv run pytest tests/e2e/test_thinking_display.py -v --no-cov`, `tests/e2e/test_stop_generation.py -v --no-cov`, `tests/e2e/test_big_read_progress.py -v --no-cov`, `tests/e2e/test_loading_feedback.py -v --no-cov`.
|
||||||
|
- `uv run ruff check . && uv run pyright` — clean.
|
||||||
|
- Scope check: `git diff --stat` shows only `frontend/index.html`, `frontend/assets/app.js`, `frontend/assets/styles.css`, `tests/**`, `.agents/phases/**` (this is a UI phase — NO `app/` changes; if the diff shows any, stop and fix the scope).
|
||||||
|
5. **The commit** (exactly one, `--no-gpg-sign`):
|
||||||
|
```bash
|
||||||
|
git add frontend/index.html frontend/assets/app.js frontend/assets/styles.css tests/ .agents/phases/ && git commit --no-gpg-sign -m "feat(chat): never-frozen turn — re-expanding thinking block + the persistent in-turn loader"
|
||||||
|
```
|
||||||
|
6. Move the phase directory: `mv .agents/phases/todo/109_turn_progress_loader .agents/phases/complete/` (the pipeline gate does this on success — do it only after the commit, matching how prior phases recorded the move).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: the owner's repro replayed deterministically (the marker's baked delays make every window assertion race-free); the a11y split (visual loader + `#send-status` announcer) + the reduced-motion variant pinned.
|
||||||
|
- Coverage: **>90%** on `app/` (unchanged by this UI phase — the gate is the regression check).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_turn_progress_loader.py -v --no-cov` green in isolation (DB up) — all three tests
|
||||||
|
- [ ] The reported repro is pinned: post-delta thinking re-opens the block with visible text while the loader stays visible; terminal states are clean (loader hidden, button "Send", status not stuck)
|
||||||
|
- [ ] The regression suites (`test_thinking_display`, `test_stop_generation`, `test_big_read_progress`, `test_loading_feedback`) green in isolation; `uv run pytest` green; coverage TOTAL >90%; ruff + pyright clean
|
||||||
|
- [ ] Exactly one new commit with the phase message, `--no-gpg-sign`; the diff scoped to `frontend/` + `tests/` + `.agents/phases/`; `git status` clean afterwards
|
||||||
|
- [ ] Phase dir at `.agents/phases/complete/109_turn_progress_loader/`
|
||||||
Reference in New Issue
Block a user