Files
brain-of-reese/.agents/phases/todo/192_verbatim_inline_midturn/00_phase.md
T
ducoterra 47456a44df chore(agent): phase roadmap from TODO.md, 4 phases (190-193)
Convert the four open TODO items into executable phases (append mode,
starting at 190 after the completed 189):

  190_app_name_settings_fix  - the Settings-panel app name wins over the
      Theme tab; the three brand strings stripped from the Theme tab
      (colors-only); live brand re-apply on save, no reload
  191_source_name_labels_tags - source_labels table + admin API + Sources
      page editor; the separate bounded source_name_hit_bonus retrieval
      boost; label surfacing in the <document> blocks and KB overview
  192_verbatim_inline_midturn - an additive verbatim_block SSE frame at
      the show_text success moment; in-place mid-turn fence hydration;
      the done/restore per-locator dedupe; the done frame, record key,
      restore and shared page stay byte-identical
  193_sync_remaining_count - the running sync/upload label counts down:
      "N remaining" (N = total - done), hidden at the final file; the
      chunk/image/summaries/terminal parts untouched

TODO.md is cleared (its items now live in .agents/phases/todo/).

LLM-Generated: true
2026-10-17 19:35:55 -04:00

12 KiB
Raw Blame History

Phase 192 — Verbatim panels land while the answer is still streaming

Source: TODO.md L5 — "The verbatim tag appear at the end and only when the llm is done answering which makes the chat read strangely." Story: n/a (owner report) Depends on: none functionally (builds on the completed phase-182 verbatim display, phase-95 tool_result frames, phase-158/164 saved-chat restore).

Evidence (diagnosed from the code, read this not the chat)

  1. Panels materialize only on the done frame. app/api/chat.py resolves the turn's verbatim blocks at done-time (L1319–1347: resolve_answer_blocks over answer_text → the done frame's additive answer_text list); app.js hydrates at done (L4506–4511: hydrateVerbatim(bubble, ev.answer_text, …) + appendUnlocatedVerbatim(wrap, ev.answer_text, …)) and at restore (L3044–3046, the record's m.answer_text).
  2. The show_text result already exists mid-turn — it just isn't sent. The agent's show_text branch (app/rag/agent.py L2238–2340) appends the resolved VerbatimBlock to holder.shown_texts (call order, the show_text_max_blocks budget) the instant the call succeeds, and the result text rides the round's tool message — but the block itself reaches the client ONLY in the done frame.
  3. So the reading order inverts. The prose streams first; every verbatim panel (fenced or not) pops in at the END, after the model has already finished talking about the bytes: "…here is the doc: [model finishes] [panel appears]". The unfenced blocks are the worst case — appendUnlocatedVerbatim appends them at the answer's tail (the answer_image position), detached from the sentence that introduced them.
  4. The fix is additive. The block is already in hand at the success point (the shown_texts append, L2322 area); the round loop already has the snapshot-then-yield pattern for phase-95 truncations (trunc_before, L2640 → the ToolResultPiece yield at L2690). One new piece + one new SSE event + one client branch; the done frame, the record key, the restore and the shared page keep their exact contract.

Design (shared by all tasks — the executor reads this, not the chat)

  • A1 — the mid-turn frame (task 01). A new additive SSE event verbatim_block, emitted as soon as a show_text call succeeds:
    • app/rag/agent.py: a new @dataclass VerbatimBlockPiece (the ToolResultPiece pattern, beside it; the block: VerbatimBlock field — the class is already imported at L343). In the round loop: shown_before = len(holder.shown_texts) BEFORE _execute_tool (the trunc_before pattern, L2640); after the round's ToolResultPiece yield (L2690), for block in holder.shown_texts[shown_before:]: yield VerbatimBlockPiece(block=block) — the frame lands AFTER the round's tool_result frame and BEFORE the next model round. SUCCESS-ONLY by construction: every refusal path returns before the shown_texts.append (L2322), and the already-shown dedupe is a refusal — a refused call yields no piece. The generator union types (L928/L992) gain the piece.
    • app/schemas.py: ChatVerbatimBlockEvent (the ChatToolResultEvent pattern, L321) — type: Literal["verbatim_block"] + the EXACT VerbatimBlockRef field set (L185: source ≤ 120 / path ≤ 1000 / title ≤ 500 / start_line / end_line / total_lines / text ≤ 600 000 / truncated / locator ≤ 2100) — the client's isVerbatimBlock check (verbatim.js L70) accepts the event as-is; the framework's extra convention of the sibling events is mirrored.
    • app/api/chat.py: the new dispatch branch after the ToolResultPiece branch (L1104): if isinstance(piece, VerbatimBlockPiece): yield sse_event(ChatVerbatimBlockEvent(**asdict-style fields).model_dump()); continue. The done-frame answer_text resolution (L1319–1347) and the per-turn log line (verbatim_blocks=N, done-frame count) are UNTOUCHED — the mid-turn frames are not counted there (the pin stays green).
    • KILL-SWITCH: BOR_ANSWER_TEXT=0 → the tool is not offered (the phase-182 D8 posture) → no success → NO piece, NO frame: the frame sequence is byte-identical to pre-phase (a wire pin, task 01 + the E2E arm).
  • A2 — the client lands the panel mid-stream (task 02).
    • frontend/assets/verbatim.js: a NEW hydrateVerbatimNow(root, block, opts) — walks the PENDING placeholders (.verbatim-pending[data-bor-locator]); for each, matchVerbatimBlock([block], key) (the SAME matching semantics as the done path — the exact locator, or the identity fallback when the list holds exactly one block of that identity); REPLACES ONLY the matched nodes with buildVerbatimPanel(block, opts); the UNMATCHED pending nodes STAY PENDING (settling them is the done/failure path's job — a mid-turn pass must never burn a placeholder for a not-yet-shown fence); returns the number matched (0 → the caller appends). Never throws (the house contract — a missing root → 0).
    • frontend/assets/app.js: the new ev.type === "verbatim_block" branch beside the tool_result branch (L4612): const n = hydrateVerbatimNow(bubble, ev, VERBATIM_LINK_OPTS); if (n === 0) appendUnlocatedVerbatim(wrap, [ev], VERBATIM_LINK_OPTS); — a fenced block lands IN PLACE at its fence (the model said "here it is" → the panel is there); an unfenced block lands at the answer_image position (the appendUnlocatedVerbatim contract — below the current text, above the sources) AT THE MOMENT the call succeeds, not at the end of the answer. appendUnlocatedVerbatim's state-free idempotence (same canonical locator already placed → skip, L463–472) makes the done pass's re-run a no-op for the same block. No client-side kill-switch gate: the server's gate is the only one (a False turn sends no frame — the branch never fires).
  • A3 — the done/restore dedupe (task 03). The one remaining duplicate: a mid-turn APPENDED panel (locator A) + the model later fences the SAME locator A → the fence placeholder hydrates at done into a second panel. hydrateVerbatim (the done AND restore path — L423) gains the placed-scan: before building a matched panel for a key, if a PLACED resolved panel (``.verbatim[data-bor-locator], NOT .verbatim-unresolved— theappendUnlocatedVerbatimplaced-scan convention) carrying the same canonicallocator exists in the message wrap (root.closest(".msg")), that placed panel is REMOVED first — the in-text panel wins (the fence is where the model wanted it; the appended panel has served its mid-turn purpose). TWO DIFFERENT ranges of the same file are LEGITIMATE (the shown_texts` dedupe is per canonical locator, not per identity) and stay — the scan is per-locator, never per-identity. The restore path (L3044–3046) and the shared page ride the SAME functions — no separate change; the parity pin is the test.
  • A4 — the unchanged contract (pinned across tasks). The done frame's answer_text list, the saved-record key, the restore path and the shared page are byte-identical to phase 182 for a turn that never calls show_text; a BOR_ANSWER_TEXT=0 turn is byte-identical end to end (no frame, no placeholder, no panel); the verbatim_blocks=N per-turn log line counts the done-frame blocks as before.

Context

  • app/rag/agent.py — VerbatimBlock (imported L343), holder.shown_texts (L1848), the show_text branch (L2238–2340), the round loop (L2640–2697), the piece union types (L928/L992); app/api/chat.py — the piece dispatch (L1096–1130), the done-time resolution (L1319–1347); app/schemas.py — VerbatimBlockRef (L185), ChatToolResultEvent (L321), the extra convention; app/rag/verbatim.py — VerbatimBlock (L94), block_for, canonical_locator (D7).
  • frontend/assets/verbatim.js — isVerbatimBlock (L70), matchVerbatimBlock (L404), hydrateVerbatim (L423), appendUnlocatedVerbatim (L447), settleVerbatimPlaceholders (L480); frontend/assets/app.js — the tool_result branch (L4612), the done branch's hydrate/append (L4506–4511), the restore hydrate/append (L3044–3046).
  • tests/e2e/mock_llm.py (the mock's show_text flow — the phase-182 E2E precedent; verify the exact mock arm), the phase-182 E2E suites (verify the names in tests/e2e/), the JS marker-pin unit module (verify the name — the test_save_chat_ui.py convention), tests/integration/ the chat SSE contract module (verify the name).

Tasks

  1. 01_verbatim_block_sse.md — the VerbatimBlockPiece, the ChatVerbatimBlockEvent, the chat dispatch branch; the agent mock pins, the SSE contract pins, the kill-switch wire pin.
  2. 02_midturn_hydration_and_append.md — hydrateVerbatimNow + the app.js verbatim_block branch (in-place fence hydration at mid-turn; unfenced blocks land at the call moment; unmatched placeholders stay pending).
  3. 03_done_and_restore_dedupe.md — the hydrateVerbatim placed-scan dedupe (per-locator; two ranges stay; the restore/shared parity pin; the kill-switch and the verbatim_blocks=N pins stay green).
  4. 04_e2e_and_gates.md — the dedicated Playwright suite (the mid-turn arm, the fence arm, the no-double arm, the kill-switch wire arm, the restore arm), the full gates, the docs pass, the atomic commit.

Testing & Quality

  • Unit: the agent mock pins (a successful show_text → exactly one VerbatimBlockPiece after the round's tool_result and before the next round; the five refusal paths → zero; the answer_text=False posture → zero); the VerbatimBlockPiece/ChatVerbatimBlockEvent shape pins; the JS marker pins (the hydrateVerbatimNow matched-only contract, the app.js branch).
  • Integration: the chat SSE contract — a mock turn with show_text → the stream carries the verbatim_block frame (the exact VerbatimBlockRef fields) before done; a BOR_ANSWER_TEXT=0 app → the captured frame sequence is byte-identical to the pre-phase sequence (the kill-switch wire pin); the done frame's answer_text is unchanged (the phase-182 pins stay green).
  • E2E (AGENTS rule 4, task 04): tests/e2e/test_verbatim_inline_midturn.py, run in isolation — the owner-visible arm: the panel is on screen WHILE the answer is still streaming (before done), the fence hydrates in place mid-turn, no double panel, the kill-switch wire arm, the restore parity.
  • Regression: the phase-182 E2E suites green unedited; tests/e2e/test_chat_history.py (the restore contract) green.
  • Coverage: >90% on app/ (uv run pytest --cov=app --cov-report=term-missing); the frontend change is marker-pinned per the house JS convention.
  • Lint/types: uv run ruff check . && uv run pyright.

Documentation & Consistency

  • Final pass reviews README.md and AGENTS.md in their entirety against the post-phase app and fixes every inconsistency found, pre-existing ones included.
  • README.md: the verbatim section notes the mid-turn landing (a show_text panel appears the moment the call succeeds — in place when fenced, at the answer position when not; the done/restore contract is unchanged) (task 04). AGENTS.md: the phase-182 clause gains the mid-turn frame clause if it names the done-frame resolution (verify, do not add if absent).

Completion Criteria

  • a successful show_text call yields exactly one verbatim_block frame (the unit + integration pins); every refusal yields none.
  • a BOR_ANSWER_TEXT=0 turn's frame sequence is byte-identical to pre-phase (the wire pin) — no frame, no placeholder, no panel.
  • a pending fence placeholder for a shown block hydrates IN PLACE at mid-turn (before done); an unfenced block is on screen at the call moment (the E2E arms).
  • a mid-turn appended panel + a same-locator fence → exactly ONE panel after done (the dedupe pin); two different ranges of one file → two panels (the pin).
  • the restore path shows exactly one panel per locator after a reload (the E2E arm); the done frame's answer_text, the record key and the verbatim_blocks=N log line are unchanged (the phase-182 pins green unedited).
  • test suite green, coverage >90%; uv run ruff check . && uv run pyright clean.
  • no behavior change in completed phases (a turn without show_text is byte-identical end to end).
  • one atomic Conventional Commits commit with --no-gpg-sign.