Files
brain-of-reese/.agents/phases/complete/73_hidden_tab_stream/00_phase.md
T
ducoterra 055c0b5d85
Build and Push Containers / build-and-push-app (push) Successful in 1m39s
Build and Push Containers / build-and-push-db (push) Successful in 11s
feat(rag): pass chat history with prior thinking to the LLM
Phase 74 (TODO.md L4): a follow-up question now reaches the model WITH
the conversation so far — every prior user/brain turn and the prior
thinking blocks on brain turns (preserve-thinking) — while
POST /api/chat stays stateless (A10): the client provides the history
in the request body and the server stores nothing new.

Server (task 01):
- ChatRequest.history: optional list[HistoryTurn] (who: user|brain,
  text, optional thinking) — absent/empty keeps the request
  byte-identical to pre-phase-74 (the two-message [system, user]
  request; the kill-switch semantics are pinned in the integration
  suite).
- app.rag.prompts.history_to_messages: pure mapper — walks the turns
  newest-first against the settings budgets (history_max_turns=40 /
  history_max_chars=24000, BOR_HISTORY_MAX_TURNS /
  BOR_HISTORY_MAX_CHARS); a capped turn is dropped WHOLE (never cut
  mid-answer); the kept window is returned oldest-first; brain turns
  carry their thinking as reasoning_content (A4) only when
  non-empty.
- Both branches feed it: the deflected path splices it between the
  system prompt and the current user message (the phase-71 recovery
  still rebuilds from messages[1:]), the grounded agent receives
  run_agent(..., history=hist); llm.py's message params widen to
  list[dict[str, Any]] (string-only messages stay byte-identical on
  the wire — the SDK passes message dicts through verbatim).
- The per-turn log line (PLAN §9) gains history_msgs=N after
  kb_chars=N.
- Pins: tests/unit/test_history.py (mapper: mapping, reasoning
  gating, both budgets, drop-whole, ordering, empty default),
  tests/unit/test_config.py (the two settings + env overrides),
  tests/unit/test_agent.py (the history splice + the default),
  tests/integration/test_chat_api.py (deflected AND grounded forward
  the history incl. reasoning_content, no-history byte-identity, 422
  pins, the log field).

Client (task 02):
- runTurn — the single funnel for fresh send / phase-49 retry /
  phase-53 stale-regen — sends history = the conversation record
  minus the current question, with thinking only on brain records
  that streamed one (undefined drops the key from the JSON, the
  record's convention); the question is never duplicated into the
  history.

Wire proof (task 03):
- The mock's echo my history marker (HISTORY_TRIGGER) answers with
  the deterministic history echo — history: N prior messages; last
  answer tail: <last 24 chars>; thinking: yes|no — checked BEFORE
  the DEFLECT_MODE branch (like TABLE_TRIGGER), so it fires on both
  turn branches whatever the gate says; the module docstring records
  the user/assistant-only history invariant that keeps every
  existing (tool-result-classified) marker flow unaffected.
- tests/e2e/test_llm_history.py (isolated): a grounded follow-up and
  a deflected follow-up both receive history: 2 prior messages +
  thinking: yes + the byte-exact tail of turn 1's answer (derived
  from the persisted bor.chat.v1 record — the same array the client
  maps into the body); a cold start receives history: 0 prior
  messages / last answer tail: none / thinking: no.
- Regressions green in isolation: chat_rag, chat_history (phase 50),
  agent_document_tools, harness_aligned_tools, stop_generation,
  retry_answer, response_to_docs.
2026-09-05 16:04:40 -04:00

3.4 KiB
Raw Blame History

Phase 73 — Hidden tab never stops a generating answer

Source: TODO.md L3 — "Clicking on another tab while an answer is generating stops that answer from being generated. Reponses should continue to generate unless you outright close the tab." Story: n/a (TODO-derived) Context: frontend/assets/app.js (the SSE turn machine: runTurn ~L1859, the pagehide partial-persist handler ~L2162, the 120s pre-token guard TURN_TIMEOUT_MS ~L287 / armTurnTimeout ~L982, readSSE ~L1044, the settle paths done ~L2011 / stop ~L2079), app/api/chat.py (the finally "turn cancelled" log line — the server-side signal that the SSE consumer really went away), phase-48 teardown contract (a REAL consumer departure — tab closed, navigation, Stop — still cancels the fetch and stops the model: that behavior is correct and must survive this phase).

Objective

An in-flight answer keeps generating while the browser tab is merely hidden (switched away from) and completes when the user returns; only closing the tab, navigating away, or clicking Stop aborts the turn. Also fixes the latent record-corruption on that path (a pagehide partial persist can leave a duplicated brain turn in the saved conversation, which makes the answer look truncated on restore).

Dependencies

— (none)

Tasks

  1. 01_repro_root_cause.md — bounded repro with an instrumentation decision tree; pin down WHICH mechanism stops the answer on tab switch (no permanent code changes).
  2. 02_fix_hidden_tab.md — the fix: correlate the pagehide partial with the turn's settle so done/stop replaces it (never appends a second brain turn); if the repro implicates the 120s guard, make hidden time not count toward it; keep phase-48 teardown for real departures.
  3. 03_e2e_hidden_tab_stream.md — Playwright regression: synthetic pagehide mid-stream → the answer completes exactly once, the record has one brain turn, reload restores it; regressions + commit.

Testing & Quality

  • Unit/integration: frontend-only phase — no app/ changes expected (coverage floor unaffected, must stay >90% on app/).
  • E2E: new story suite tests/e2e/test_hidden_tab_stream.py, run in isolation (uv run pytest tests/e2e/test_hidden_tab_stream.py -v --no-cov) against the deterministic mock LLM (long/slow deterministic streams give a guaranteed mid-stream window).
  • Regression runs in isolation: test_chat_rag.py, test_chat_persistence.py, test_chat_history.py (phase 50), test_stop_generation.py (phase 48 contract: real Stop/cancel still tears down), test_retry_answer.py.

Completion Criteria

  • The repro's root cause is named in the phase-73 commit message body (one line: which candidate from task 01 fired, or "none of C1–C3 — ").
  • uv run pytest tests/e2e/test_hidden_tab_stream.py -v --no-cov green in isolation: a tab switch (synthetic pagehide) mid-turn never stops the answer, and the persisted conversation holds exactly one brain turn for that question.
  • Real departures unchanged: Stop button, tab close, and navigation still cancel the fetch (phase-48 test_stop_generation.py + test_chat_persistence.py green).
  • uv run pytest green; uv run pytest --cov=app --cov-report=term-missing >90%; uv run ruff check . && uv run pyright clean.
  • One atomic --no-gpg-sign Conventional-Commits commit (e.g. fix(chat): keep generating while the tab is hidden); phase dir moved to .agents/phases/complete/.