phase: 192_verbatim_inline_midturn
Build and Push Containers / build-and-push-app (push) Successful in 1m56s
Build and Push Containers / build-and-push-db (push) Successful in 14s

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:
2026-10-19 12:19:51 -04:00
parent 4f20c256d7
commit fb17d15fbe
49 changed files with 17313 additions and 102 deletions
+59 -9
View File
@@ -251,6 +251,24 @@ keeps its exact pre-phase bytes). The per-turn log line records
ACTUALLY SENT (after dedupe and the ``show_text_max_blocks`` cut), 0 on
every turn without them including deflected ones (the PLAN §9 additive
revision).
Mid-turn verbatim frame (phase 192, task 01, A1; ``TODO.md`` L5): the
same block ALSO reaches the client the moment the ``show_text`` call
succeeds, as the additive ``verbatim_block`` event —
``{"type": "verbatim_block", "source", "path", "title", "start_line",
"end_line", "total_lines", "text", "truncated", "locator"}`` (the exact
:class:`~app.schemas.VerbatimBlockRef` field set, so the one panel client
accepts it as-is). It lands AFTER the round's ``tool`` / ``tool_result`` frames and
BEFORE the next model round — while the answer is still streaming, so the
panel appears where the model introduced the bytes instead of popping in
after it finished talking. Success-only: every refusal (missing ``path``,
no document, an image row, the block cap, the already-shown dedupe) emits
nothing, and a ``BOR_ANSWER_TEXT=0`` turn offers no tool, so its frame
sequence is byte-identical to pre-phase. Everything downstream of the
turn keeps the phase-182 contract EXACTLY: the ``done`` frame's
``answer_text`` list, the saved record key, the restore path and the
shared page are untouched, and ``verbatim_blocks=N`` still counts the
``done``-frame blocks (never the mid-turn frames).
"""
from __future__ import annotations
@@ -275,7 +293,9 @@ from app.models import Document, QueryLog
from app.rag.agent import (
CORRECTION_INSTRUCTION, # phase 71: the harness-owned recovery line
AgentHolder,
AgentPiece, # the ONE union run_agent yields (phase 192: + VerbatimBlockPiece)
MalformedReplyError, # phase 71: the recovery policy's terminal signal
VerbatimBlockPiece, # phase 192: one shown verbatim block (mid-turn frame)
run_agent,
suggested_folder_lines, # phase 119 (D3): the suggested-folder context lines
)
@@ -285,7 +305,6 @@ from app.rag.llm import (
LLMClient,
LLMError,
RetryPiece, # phase 67: one LLM request restart (an SSE retry frame)
StreamPiece, # type of the answer pieces streamed by the agent loop
ToolCallPiece, # phase 37: one model-requested tool call
ToolResultPiece, # phase 95: one truncated tool result (SSE frame = task 02)
chat_stream_retried, # phase 67: the retry-before-first-piece primitive
@@ -323,6 +342,7 @@ from app.schemas import (
ChatThinkingEvent,
ChatToolEvent,
ChatToolResultEvent,
ChatVerbatimBlockEvent,
SourceRef,
VerbatimBlockRef,
)
@@ -974,9 +994,11 @@ async def chat(
# ``agent_max_rounds=0`` ``run_agent`` is a single
# ``tools=None`` request anyway (the kill switch).
holder = AgentHolder()
answer_stream: AsyncIterator[
StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece
]
# Phase 192: ``AgentPiece`` is the ONE union ``run_agent``
# yields (the phase-95 truncation piece plus the new
# ``VerbatimBlockPiece``); the deflected path's narrower
# ``chat_stream_retried`` union flows into it unchanged.
answer_stream: AsyncIterator[AgentPiece]
deflected_filter: ScaffoldingFilter | None = None
if plan.deflected:
# Phase 67: the deflected stream goes through the retry
@@ -1038,9 +1060,7 @@ async def chat(
tools_offered = not plan.deflected
async def _pump(
pieces: AsyncIterator[
StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece
],
pieces: AsyncIterator[AgentPiece],
tools_offered: bool = True,
) -> AsyncIterator[str]:
"""One request's piece loop (phase 71 extraction): the
@@ -1058,10 +1078,14 @@ async def chat(
frame, no visible markup) and counted in a local so one
debug line can name it. The grounded first pass keeps its
current behavior (``tools_offered=True``; ``run_agent``
suppresses the cap-path calls internally)."""
suppresses the cap-path calls internally). Phase 192
(task 01): the ``VerbatimBlockPiece`` branch emits the
additive ``verbatim_block`` frame — the verbatim block a
successful ``show_text`` just displayed, so its panel
lands mid-stream instead of at ``done``."""
nonlocal thinking_chars, content_chars, retries_used
dropped_calls = 0 # phase 160: tools=None requests only
async for piece in pieces: # StreamPiece | ToolCallPiece | RetryPiece
async for piece in pieces: # app.rag.agent.AgentPiece
if isinstance(piece, ToolCallPiece):
if not tools_offered:
# Phase 160 (task 02): a tools=None request
@@ -1170,6 +1194,32 @@ async def chat(
).model_dump()
)
continue
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 (the
# panel lands while the answer is still
# streaming instead of at ``done``). 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, and the ``done``
# frame's ``answer_text`` list and its
# ``verbatim_blocks=N`` count are untouched.
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
if piece.kind == "thinking":
thinking_chars += len(piece.text)
if settings.stream_thinking: