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
12 KiB
12 KiB
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)
- Panels materialize only on the
doneframe.app/api/chat.pyresolves the turn's verbatim blocks at done-time (L1319–1347:resolve_answer_blocksoveranswer_text→ the done frame's additiveanswer_textlist);app.jshydrates at done (L4506–4511:hydrateVerbatim(bubble, ev.answer_text, …)+appendUnlocatedVerbatim(wrap, ev.answer_text, …)) and at restore (L3044–3046, the record'sm.answer_text). - The
show_textresult already exists mid-turn — it just isn't sent. The agent's show_text branch (app/rag/agent.py L2238–2340) appends the resolvedVerbatimBlocktoholder.shown_texts(call order, theshow_text_max_blocksbudget) the instant the call succeeds, and the result text rides the round'stoolmessage — but the block itself reaches the client ONLY in the done frame. - 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 —
appendUnlocatedVerbatimappends them at the answer's tail (theanswer_imageposition), detached from the sentence that introduced them. - The fix is additive. The block is already in hand at the success point (the
shown_textsappend, L2322 area); the round loop already has the snapshot-then-yield pattern for phase-95 truncations (trunc_before, L2640 → theToolResultPieceyield at L2690). One new piece + one new SSE event + one client branch; thedoneframe, 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 ashow_textcall succeeds:app/rag/agent.py: a new@dataclass VerbatimBlockPiece(theToolResultPiecepattern, beside it; theblock: VerbatimBlockfield — the class is already imported at L343). In the round loop:shown_before = len(holder.shown_texts)BEFORE_execute_tool(thetrunc_beforepattern, L2640); after the round'sToolResultPieceyield (L2690),for block in holder.shown_texts[shown_before:]: yield VerbatimBlockPiece(block=block)— the frame lands AFTER the round'stool_resultframe and BEFORE the next model round. SUCCESS-ONLY by construction: every refusal path returns before theshown_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(theChatToolResultEventpattern, L321) —type: Literal["verbatim_block"]+ the EXACTVerbatimBlockReffield set (L185:source≤ 120 /path≤ 1000 /title≤ 500 /start_line/end_line/total_lines/text≤ 600 000 /truncated/locator≤ 2100) — the client'sisVerbatimBlockcheck (verbatim.js L70) accepts the event as-is; the framework'sextraconvention of the sibling events is mirrored.app/api/chat.py: the new dispatch branch after theToolResultPiecebranch (L1104):if isinstance(piece, VerbatimBlockPiece): yield sse_event(ChatVerbatimBlockEvent(**asdict-style fields).model_dump()); continue. The done-frameanswer_textresolution (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 NEWhydrateVerbatimNow(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 withbuildVerbatimPanel(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 missingroot→ 0).frontend/assets/app.js: the newev.type === "verbatim_block"branch beside thetool_resultbranch (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 theanswer_imageposition (theappendUnlocatedVerbatimcontract — 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 (aFalseturn 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 akey, if a PLACED resolved panel (``.verbatim[data-bor-locator], NOT.verbatim-unresolved— theappendUnlocatedVerbatimplaced-scan convention) carrying the same canonicallocatorexists 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 (theshown_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
doneframe'sanswer_textlist, the saved-record key, the restore path and the shared page are byte-identical to phase 182 for a turn that never callsshow_text; aBOR_ANSWER_TEXT=0turn is byte-identical end to end (no frame, no placeholder, no panel); theverbatim_blocks=Nper-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), theextraconvention;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— thetool_resultbranch (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 intests/e2e/), the JS marker-pin unit module (verify the name — thetest_save_chat_ui.pyconvention),tests/integration/the chat SSE contract module (verify the name).
Tasks
01_verbatim_block_sse.md— theVerbatimBlockPiece, theChatVerbatimBlockEvent, the chat dispatch branch; the agent mock pins, the SSE contract pins, the kill-switch wire pin.02_midturn_hydration_and_append.md—hydrateVerbatimNow+ the app.jsverbatim_blockbranch (in-place fence hydration at mid-turn; unfenced blocks land at the call moment; unmatched placeholders stay pending).03_done_and_restore_dedupe.md— thehydrateVerbatimplaced-scan dedupe (per-locator; two ranges stay; the restore/shared parity pin; the kill-switch and theverbatim_blocks=Npins stay green).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 oneVerbatimBlockPieceafter the round'stool_resultand before the next round; the five refusal paths → zero; theanswer_text=Falseposture → zero); theVerbatimBlockPiece/ChatVerbatimBlockEventshape pins; the JS marker pins (thehydrateVerbatimNowmatched-only contract, the app.js branch). - Integration: the chat SSE contract — a mock turn with
show_text→ the stream carries theverbatim_blockframe (the exactVerbatimBlockReffields) beforedone; aBOR_ANSWER_TEXT=0app → the captured frame sequence is byte-identical to the pre-phase sequence (the kill-switch wire pin); the done frame'sanswer_textis 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 (beforedone), 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.mdandAGENTS.mdin 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 (ashow_textpanel 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_textcall yields exactly oneverbatim_blockframe (the unit + integration pins); every refusal yields none. - a
BOR_ANSWER_TEXT=0turn'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
doneframe'sanswer_text, the record key and theverbatim_blocks=Nlog line are unchanged (the phase-182 pins green unedited). - test suite green, coverage >90%;
uv run ruff check . && uv run pyrightclean. - no behavior change in completed phases (a turn without
show_textis byte-identical end to end). - one atomic Conventional Commits commit with
--no-gpg-sign.