phase: 192_verbatim_inline_midturn
Phase 192 — final verification pass complete. - **Verified (all 4 tasks already complete):** additive `verbatim_block` SSE frame (`VerbatimBlockPiece` → `ChatVerbatimBlockEvent` → chat dispatch, after the round's `tool`/`tool_result`, before the next round); `hydrateVerbatimNow` matched-only mid-turn pass + app.js branch; `dropPlacedTwin` per-locator done/restore dedupe; the dedicated E2E suite. - **Root cause of the rejected attempt:** my own concurrent background `pytest` run deadlocked the harness validation run on the shared Postgres (`psycopg.errors.DeadlockDetected`). No code defect — re-ran every gate **exclusively**. - `uv run pytest -q --cov=app --cov-report=term-missing` → **exit 0**, `TOTAL 7181 16 99%` (>90%) - `uv run pytest tests/e2e/test_verbatim_inline_midturn.py --no-cov` → **6 passed** (isolated) - `uv run pytest tests/e2e/test_verbatim_show_text.py tests/e2e/test_chat_history.py --no-cov` → **18 passed** (`test_chat_history.py` byte-unedited; the phase-182 suite's "no eighth type" pin was tightened, not loosened, to seven + `verbatim_block` with frame-order asserts) - `uv run ruff check .` → All checks passed (exit 0) · `uv run pyright` → **0 errors** - **Completion criteria:** single frame per success + zero per refusal ✅ · `BOR_ANSWER_TEXT=0` wire byte-identical (integration spelled baseline + E2E App B) ✅ · fence hydrates in place / unfenced lands at the call moment, before `done` ✅ · append+fence → 1 panel, two ranges → 2 panels ✅ · restore 1 panel/locator, `done.answer_text` + record key + `verbatim_blocks=N` unchanged ✅ · suite green, coverage 99%, lint/types clean ✅ · no-show_text turn byte-identical ✅ · commit → left to the harness (no git index/history commands run). - **Docs:** README reviewed line-by-line (2399 lines) + AGENTS.md in full; mechanically verified all 86 `BOR_*` settings, every `.env.example` var, table defaults, internal anchors (0 broken), ports 8123/8901, tree depth 1–8, pagination 25/1–100, 17 palette colors, `0029`/`0030` migrations, and every cited test file. Fixed 1 inconsistency: the verbatim "Parity" bullet claimed the live turn/reload/shared page share "the same three globals" — now names the settle-path trio and states that `hydrateVerbatimNow` is the ONE chat-only addition (a shared page has no stream). - **Notable:** `run_agent`'s four repeated piece unions were collapsed into the ONE exported `AgentPiece` alias, so a future piece type is spelled in one place; `test_frontend_edit_message.py`'s `runTurn` byte-pin was regenerated, not weakened. - **Next pending phase:** `193_sync_remaining_count`.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# 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` — the `appendUnlocatedVerbatim` placed-scan convention) carrying the same canonical `locator` 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`.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Task 01 — The additive `verbatim_block` SSE frame (A1)
|
||||
|
||||
**Phase:** `192_verbatim_inline_midturn` · **Source:** `TODO.md:6 — "The verbatim tag appear at the end and only when the llm is done answering …"`
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
The server half: a `show_text` success yields a new `VerbatimBlockPiece` → a new additive `verbatim_block` SSE frame lands AFTER the round's `tool_result` frame and BEFORE the next model round — the block the agent already holds, sent at the moment it exists. Success-only by construction; `BOR_ANSWER_TEXT=0` → zero frames (byte-identical sequence).
|
||||
|
||||
## Work
|
||||
1. `app/rag/agent.py`:
|
||||
- a new `@dataclass VerbatimBlockPiece` beside `ToolResultPiece` (the same style — a field docstring naming the phase-95/182 lineage: `block: VerbatimBlock` — the class is already imported at L343);
|
||||
- the round loop: `shown_before = len(holder.shown_texts)` immediately BEFORE `_execute_tool` (L2640, the `trunc_before` pattern); after the round's `ToolResultPiece` yield (L2690–2697), `for block in holder.shown_texts[shown_before:]: yield VerbatimBlockPiece(block=block)` — one line, the same snapshot-then-yield shape (a round can add at most one block — the already-shown dedupe is a refusal — so the loop is a one-iteration shape, but the snapshot keeps a future multi-add correct);
|
||||
- the generator union types (L928/L992) gain `VerbatimBlockPiece`; the module docstring's piece paragraph (the L215–225 display-tools region) gains the one-line clause (the mid-turn frame, the done-frame count UNCHANGED — `verbatim_blocks=N` still counts the done frame's blocks, never the mid-turn frames);
|
||||
- the `run()` docstring: the emission-order contract (the frame is after the round's `tool_result`, before the next round — a refused call emits nothing, the `shown_texts.append` at L2322 is the single success gate).
|
||||
2. `app/schemas.py` — `ChatVerbatimBlockEvent(BaseModel)` (the `ChatToolResultEvent` L321 pattern, directly after it):
|
||||
- `type: Literal["verbatim_block"] = "verbatim_block"` + the EXACT `VerbatimBlockRef` field set (L185 — `source` ≤ 120, `path` ≤ 1000, `title` ≤ 500, `start_line`/`end_line`/`total_lines` `ge=0`, `text` ≤ 600 000, `truncated: bool = False`, `locator` ≤ 2100) — the docstring: the mid-turn twin of the done frame's `answer_text` item; the client's `isVerbatimBlock` check (verbatim.js L70) accepts the event as-is (the key set matches — a pin); the sibling events' `model_config`/`extra` convention is mirrored EXACTLY (verify `ChatToolResultEvent`'s config and copy it).
|
||||
3. `app/api/chat.py` — the dispatch branch after the `ToolResultPiece` branch (L1104–1119):
|
||||
```python
|
||||
if isinstance(piece, VerbatimBlockPiece):
|
||||
# Phase 192 (task 01, A1): the mid-turn verbatim frame — the block
|
||||
# the show_text call just displayed, sent at the moment it exists.
|
||||
# Additive: a refused call yields no piece (no frame); a
|
||||
# answer_text=False turn offers no tool (no frame) — the frame
|
||||
# sequence is byte-identical to pre-phase in both cases.
|
||||
yield sse_event(
|
||||
ChatVerbatimBlockEvent(
|
||||
source=piece.block.source,
|
||||
path=piece.block.path,
|
||||
title=piece.block.title,
|
||||
start_line=piece.block.start_line,
|
||||
end_line=piece.block.end_line,
|
||||
total_lines=piece.block.total_lines,
|
||||
text=piece.block.text,
|
||||
truncated=piece.block.truncated,
|
||||
locator=piece.block.locator,
|
||||
).model_dump()
|
||||
)
|
||||
continue
|
||||
```
|
||||
(import `VerbatimBlockPiece` beside `ToolResultPiece` at L290; import `ChatVerbatimBlockEvent` beside the event imports at L310–322).
|
||||
4. `tests/unit/test_agent.py` (verify the module name — the agent mock suite):
|
||||
- `test_show_text_success_yields_one_verbatim_block_piece` — a mock turn whose answer calls `show_text` (the phase-182 mock precedent — verify the helper in the module) → exactly ONE `VerbatimBlockPiece`, positioned AFTER the round's `ToolResultPiece` (if a truncation piece exists) and BEFORE the next model round's first `StreamPiece`; the piece's `block` is the `holder.shown_texts` entry (same object);
|
||||
- `test_show_text_refusals_yield_no_verbatim_block_piece` — the five refusal paths (the missing-arg `MISSING_SHOW_TEXT_ARGS`, the `answer_text=False` `UNKNOWN_TOOL`, the no-document refusal, the `is_image` refusal, the block-cap refusal, the already-shown dedupe — six pins, the module's refusal fixtures) → zero `VerbatimBlockPiece`s in the stream;
|
||||
- `test_kill_switch_turn_yields_no_verbatim_block_piece` — the `answer_text=False` app posture → the tool is absent from the offered tools (the phase-182 D8 pin stays green) AND zero `VerbatimBlockPiece`s.
|
||||
5. `tests/integration/test_chat_api.py` (verify the module name — the SSE contract suite):
|
||||
- `test_verbatim_block_frame_lands_before_done` — a mock-LLM turn with a `show_text` call → the captured SSE stream carries a `type="verbatim_block"` frame with the EXACT `VerbatimBlockRef` field set (the pin asserts every key of `model_dump()`) positioned after the matching `tool_result`/`tool` frames and before `done`; the `done` frame's `answer_text` list is the same list the pre-phase resolution produced (the phase-182 pin, extended — the done-time resolution is untouched);
|
||||
- `test_kill_switch_stream_is_byte_identical` — the `BOR_ANSWER_TEXT=0` module app: the captured frame sequence (types + payloads) equals the pre-phase expected sequence (the documented baseline in the test — a turn with the mock's `show_text` attempt gets the phase-182 refusal path; NO `verbatim_block` frame anywhere).
|
||||
6. Verify: `uv run pytest tests/unit/test_agent.py tests/integration/test_chat_api.py -v` green; `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the success-position pin, the six refusal pins, the kill-switch pin.
|
||||
- Integration: the frame contract pin, the kill-switch wire pin, the done-frame parity pin.
|
||||
- Coverage: **>90%** on `app/`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] a successful `show_text` yields exactly one `verbatim_block` frame (after `tool_result`, before the next round) — unit + integration pins.
|
||||
- [ ] every refusal path yields zero frames; the `answer_text=False` stream is byte-identical to pre-phase (the wire pin).
|
||||
- [ ] the done frame's `answer_text` and the `verbatim_blocks=N` log line are unchanged (the phase-182 pins green unedited).
|
||||
- [ ] full test suite **100% passing**.
|
||||
- [ ] no behavior change in completed work (a turn without `show_text` is byte-identical — the pre-existing SSE pins green).
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
# Task 02 — Mid-turn landing: the fence hydrates in place, the unfenced block lands at the call moment (A2)
|
||||
|
||||
**Phase:** `192_verbatim_inline_midturn` · **Source:** `TODO.md:6 — "… which makes the chat read strangely."`
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
The client half: a `verbatim_block` frame settles the pending fence placeholder IN PLACE (the matching semantics of the done path) or, when nothing matches, lands the panel at the answer position AT THE MOMENT the call succeeds — so the reading order is the model's order: "…here it is:" → panel → "…as you can see…". Unmatched placeholders STAY pending (settling is the done/failure path's job).
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/verbatim.js` — a NEW `hydrateVerbatimNow(root, block, opts)` (after `hydrateVerbatim`, L446):
|
||||
```js
|
||||
/* hydrateVerbatimNow(root, block, opts) — the MID-TURN pass (phase 192,
|
||||
* A2): settle the PENDING placeholders THIS ONE block matches, leave
|
||||
* every other placeholder pending.
|
||||
*
|
||||
* The matching is the done path's semantics (matchVerbatimBlock with a
|
||||
* ONE-block list): the exact canonical locator, or the identity
|
||||
* fallback when the block is the only one of that identity — a fence
|
||||
* for range B of a shown file A hydrates with A's server extent (the
|
||||
* caption shows the SERVER's range, the phase-182 contract), so the
|
||||
* mid-turn pass and the done pass can never disagree.
|
||||
*
|
||||
* ONLY matched nodes are replaced (buildVerbatimPanel); the unmatched
|
||||
* pending nodes STAY .verbatim-pending — settling them is the
|
||||
* done/failure path's job (settleVerbatimPlaceholders), and a mid-turn
|
||||
* pass must never burn a placeholder for a not-yet-shown fence.
|
||||
*
|
||||
* Returns the number of placeholders settled (the caller appends the
|
||||
* block — appendUnlocatedVerbatim — only when it is 0). Never throws:
|
||||
* a missing root → 0. */
|
||||
```
|
||||
- implementation: `const list = [block]` guarded by `isVerbatimBlock(block)` (a malformed frame → 0, the house never-throw contract); the same `querySelectorAll(".verbatim-pending[data-bor-locator]")` walk as `hydrateVerbatim`; `matchVerbatimBlock(list, key)`; replace only when matched; count the replacements.
|
||||
2. `frontend/assets/app.js` — the new branch beside the `tool_result` branch (L4612):
|
||||
```js
|
||||
} else if (ev.type === "verbatim_block") {
|
||||
// Phase 192 (task 02, A2): the show_text panel lands at the moment
|
||||
// the call succeeds — the fence it names hydrates IN PLACE; an
|
||||
// unfenced block lands at the answer position now, not at done.
|
||||
// The done pass re-runs the same functions: hydrateVerbatim is
|
||||
// idempotent (only .verbatim-pending is touched) and
|
||||
// appendUnlocatedVerbatim skips an already-placed locator, so the
|
||||
// done frame and this frame can never double a panel.
|
||||
const n = hydrateVerbatimNow(
|
||||
wrap.querySelector(".bubble"), ev, VERBATIM_LINK_OPTS
|
||||
);
|
||||
if (n === 0) appendUnlocatedVerbatim(wrap, [ev], VERBATIM_LINK_OPTS);
|
||||
}
|
||||
```
|
||||
- the import line: `hydrateVerbatimNow` joins `hydrateVerbatim` / `appendUnlocatedVerbatim` in the existing verbatim.js import (verify the import site);
|
||||
- the `answer_text=False` gate is SERVER-side only (the frame never arrives) — NO client-side settings check (a client gate would be a second, drift-prone gate — the docstring says so).
|
||||
3. `tests/unit/` — the JS marker pins (the verbatim JS pin module — verify the name; the `test_save_chat_ui.py` convention):
|
||||
- `hydrateVerbatimNow` exists with the matched-only contract (the marker names the `matchVerbatimBlock` reuse + the "unmatched stay pending" clause + the never-throw guard);
|
||||
- the app.js handler references `verbatim_block` + `hydrateVerbatimNow` + `appendUnlocatedVerbatim` in the `n === 0` shape (the source marker — the append only on zero matches);
|
||||
- the `isVerbatimBlock` guard marker (a malformed frame settles nothing).
|
||||
4. Verify: `uv run pytest tests/unit/<js-pin-module> -v` green; `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the marker pins above (the matched-only contract is the task's heart — the E2E proves the behavior, the markers prove the shape).
|
||||
- E2E: task 04 (the owner-visible arm).
|
||||
- Coverage: **>90%** on `app/` (unchanged — the task is frontend; the app-side gate stays green).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `hydrateVerbatimNow` settles ONLY the pending placeholders the single block matches; the rest stay `.verbatim-pending`; returns the match count; never throws (marker pins).
|
||||
- [ ] the app.js `verbatim_block` branch hydrates in place and appends ONLY on zero matches (marker pins); no client-side kill-switch gate (the marker asserts its absence).
|
||||
- [ ] the existing `hydrateVerbatim` / `appendUnlocatedVerbatim` / `settleVerbatimPlaceholders` functions are UNTOUCHED (the phase-182 pins green unedited).
|
||||
- [ ] no behavior change in completed work (a turn without the frame type never enters the branch — the pre-existing E2E suites green).
|
||||
@@ -0,0 +1,36 @@
|
||||
# Task 03 — The done/restore dedupe and the unchanged-contract pins (A3/A4)
|
||||
|
||||
**Phase:** `192_verbatim_inline_midturn` · **Source:** `TODO.md:6 — "… makes the chat read strangely."`
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
Close the one remaining duplicate: a mid-turn APPENDED panel and a later fence for the SAME canonical locator must end as ONE panel (the in-text one). The dedupe lives in `hydrateVerbatim` (the done AND restore path — one function, so the shared page rides it for free); per-locator, never per-identity — two DIFFERENT ranges of one file are legitimate and stay.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/verbatim.js` — `hydrateVerbatim` (L423–445) gains the placed-scan, inside the per-node loop:
|
||||
- before `node.replaceWith(buildVerbatimPanel(block, opts))` for a MATCHED key: `const wrap = root.closest(".msg") || root;` (the message wrap — the same host `appendUnlocatedVerbatim` scans); remove every PLACED resolved panel (`.verbatim[data-bor-locator]`, NOT `.verbatim-unresolved` — the `appendUnlocatedVerbatim` placed-scan convention, L463–467) whose `data-bor-locator` equals the key (the canonical-locator comparison — the exact `===`, the phase-182 D7 key), then build the in-text panel in the node's place;
|
||||
- UNMATCHED nodes: UNCHANGED (still settle to `buildVerbatimUnresolved`);
|
||||
- the docstring gains the A3 paragraph: the mid-turn appended panel is superseded by the in-text fence panel (the fence is where the model wanted it); the scan is PER LOCATOR — two different ranges of one file (the `shown_texts` per-locator dedupe admits both) are both legitimate placements and BOTH survive (the scan never compares identities);
|
||||
- `appendUnlocatedVerbatim` is UNTOUCHED (its state-free idempotence — the `placed` set skip, L468–472 — already makes the done pass a no-op for an appended block).
|
||||
2. `tests/unit/` — the JS marker pins (the verbatim JS pin module — the task-02 module):
|
||||
- the `hydrateVerbatim` placed-scan marker: the scan targets `.verbatim[data-bor-locator]` (the class + attribute pair), EXCLUDES `.verbatim-unresolved`, removes on the exact `data-bor-locator` key (the three markers — the per-locator contract), and the "two ranges stay" clause is named in the docstring (the marker asserts the docstring contains the per-locator-not-per-identity sentence — the house docstring-pinning convention);
|
||||
- the `root.closest(".msg")` wrap marker.
|
||||
3. The unchanged-contract pins (A4) — the existing suites stay green UNEDITED, and three NEW integration/E2E-preparation pins:
|
||||
- `tests/integration/test_chat_api.py` — `test_done_frame_and_log_line_unchanged_by_midturn_frames` — a mock turn WITH a `show_text` call: the `done` frame's `answer_text` list equals the phase-182 resolution (the task-01 pin, now extended with the per-turn log line assertion: `verbatim_blocks=N` where `N` = the done-frame block count, the mid-turn frames NOT counted — the captured log line pin, the house log-pin convention).
|
||||
- `tests/unit/test_schemas.py` (or the phase-182 pin module — verify): `ChatVerbatimBlockEvent`'s `model_dump()` key set == `VerbatimBlockRef`'s key set + `type` (the `isVerbatimBlock` compatibility pin — the client check's required keys are present).
|
||||
- the restore-parity marker (the app.js restore path L3044–3046 UNTOUCHED — a marker asserting the restore path still calls `hydrateVerbatim` + `appendUnlocatedVerbatim` with `m.answer_text` — the shared page's hydration call site likewise untouched; the BEHAVIOR pin is the E2E arm).
|
||||
4. Verify: `uv run pytest tests/unit/<js-pin-module> tests/integration/test_chat_api.py -v` green; `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the placed-scan markers (per-locator, unresolved-excluded, wrap derivation, the two-ranges-stay clause).
|
||||
- Integration: the done-frame + log-line parity pin; the schema compatibility pin.
|
||||
- E2E: task 04 (the no-double arm, the two-ranges arm, the restore arm).
|
||||
- Coverage: **>90%** on `app/` (unchanged).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] a mid-turn appended panel + a same-locator fence → exactly ONE panel after `done` (the marker pins now; the E2E proves it) — the in-text panel wins, the placed panel removed.
|
||||
- [ ] two different ranges of one file → two panels after `done` (the marker clause + the E2E arm).
|
||||
- [ ] the restore path and the shared page's call sites are untouched (marker pins); the `done` frame's `answer_text` and the `verbatim_blocks=N` log line are unchanged (integration pins).
|
||||
- [ ] `appendUnlocatedVerbatim` / `settleVerbatimPlaceholders` / the task-02 functions are untouched (the phase-182 pins green unedited).
|
||||
- [ ] full test suite **100% passing**.
|
||||
- [ ] no behavior change in completed work.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Task 04 — The phase E2E suite, the full gates, the docs pass, the commit
|
||||
|
||||
**Phase:** `192_verbatim_inline_midturn` · **Source:** `TODO.md:6`
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
The dedicated Playwright suite proves the owner-visible contract — the panel is on screen WHILE the answer is still streaming, the fence lands in place, nothing doubles, the kill switch is a byte-identical wire — run in isolation per AGENTS rule 4; the full gates pass; the docs are consistent; one atomic commit.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_verbatim_inline_midturn.py` (NEW — the module docstring names the TODO item and the A4 unchanged-contract clause) — the module-app fixture pattern (the phase-182 E2E convention — verify in `tests/e2e/conftest.py` / the sibling verbatim suites; the mock LLM from `tests/e2e/mock_llm.py` — verify the phase-182 mock's `show_text` arm; if the mock drives `show_text` via a fence-only turn, extend the mock with a direct `show_text` tool call arm in `tests/e2e/mock_llm.py` — the mock is a test fixture, the extension is additive and the phase-182 pins must stay green):
|
||||
- the fixture: a small KB with one document whose content the mock's `show_text` call targets (the phase-182 fixture convention).
|
||||
- **(a) the mid-turn arm (the TODO regression):** a mock turn: an intro delta ("…here is the exact text:") → the `show_text` call (the mock arm) → the continuation deltas → `done`. Assert: the panel (`.verbatim[data-bor-locator]` with the call's locator — caption + gutter + body) is in the DOM BEFORE the `done` frame is consumed (the Playwright shape: `page.wait_for_selector` on the panel resolves while the stream is still running — a sentinel delta after the call proves the stream is live; the panel's DOM position is BELOW the intro text and ABOVE the final sentinel delta — the inline position assertion, the `answer_image`-position contract of `appendUnlocatedVerbatim`); the `done` pass adds nothing (the final panel count == 1).
|
||||
- **(b) the fence-in-place arm:** a mock turn: an intro delta → the `show_text` call for `source/path#12-20` → the final answer containing the ` ```bor ` fence for the SAME locator → `done`. Assert: the fence placeholder is hydrated IN PLACE (the panel is at the fence position — between the intro and the final sentinel delta, NOT at the answer tail) before `done` (the mid-turn `hydrateVerbatimNow` pass); after `done`, the total panel count for that locator == 1 (the dedupe — the task-03 arm).
|
||||
- **(c) the two-ranges arm:** a mock turn with TWO `show_text` calls — range 1–10 and range 11–20 of the same document (the `shown_texts` per-locator dedupe admits both) — no fences. Assert: exactly TWO panels after `done`, both at the answer position in call order, both with their own captions (the per-locator-not-per-identity pin, owner-visible).
|
||||
- **(d) the kill-switch wire arm:** the `BOR_ANSWER_TEXT=0` module app — the same mock turn (the mock's `show_text` attempt hits the phase-182 refusal) → the captured SSE frame sequence (types + payloads via the house SSE-capture pattern) equals the pre-phase baseline (documented in the test); NO `verbatim_block` frame, NO panel, NO placeholder in the DOM.
|
||||
- **(e) the restore arm:** run arm (a) to completion (a saved conversation — the phase-158 auto-save), reload the page, restore → exactly ONE panel for the locator (the record's `answer_text` through the same dedupe functions — the A4 parity pin, owner-visible).
|
||||
2. Regression, run in isolation: the phase-182 E2E suites (verify the exact names in `tests/e2e/` — the verbatim display suite, the fence suite), `tests/e2e/test_chat_history.py` (the restore contract) — green unedited.
|
||||
3. The full gates, in order:
|
||||
- `uv run pytest` (unit + integration) — 100% passing;
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` — **>90%** on `app/`;
|
||||
- `uv run pytest tests/e2e/test_verbatim_inline_midturn.py -v --no-cov` — green in isolation;
|
||||
- `uv run ruff check . && uv run pyright` — clean.
|
||||
4. Documentation pass (the phase file's Documentation section): `README.md` + `AGENTS.md` reviewed in their entirety against the post-phase app; 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/shared contract is unchanged, the kill switch byte-identical); `AGENTS.md` corrected only where it names the done-frame resolution as the ONLY landing point.
|
||||
5. Commit: one atomic, professional commit (Conventional Commits, `--no-gpg-sign`), e.g.
|
||||
`feat(verbatim): show_text panels land mid-turn, at the moment the call succeeds (phase 192)` — the body names the additive `verbatim_block` frame, the `hydrateVerbatimNow` matched-only pass, the per-locator dedupe, and the A4 unchanged contract. `.agents/phases/todo/192_verbatim_inline_midturn/` moves to `.agents/phases/complete/` in the SAME commit (AGENTS rule 8).
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: the suite above, isolated; the regression suites green.
|
||||
- Coverage: **>90%** on `app/`.
|
||||
- Lint/types: clean.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `tests/e2e/test_verbatim_inline_midturn.py` green in isolation — (a) the panel is on screen before `done`, at the inline position; (b) the same-locator fence hydrates in place mid-turn and never doubles; (c) two ranges of one file → two panels; (d) the `answer_text=False` wire is byte-identical with no panel/placeholder; (e) the restore shows one panel per locator.
|
||||
- [ ] `uv run pytest` 100% passing; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] `README.md` / `AGENTS.md` consistent (the mid-turn landing clause).
|
||||
- [ ] the phase directory is in `.agents/phases/complete/`; one atomic commit with `--no-gpg-sign`.
|
||||
Reference in New Issue
Block a user