phase: 166_hide_empty_answer_bubble
**Phase 166 — final verification pass: green.** All 3 task files already in `complete/`, so this was verification + defect/doc fixing.
- **Verified:** `.msg .bubble:empty { display: none; }` exists once in the Messages block with provenance; `frontend/` diff is styles.css only (**no app.js change**); `tests/unit/test_no_empty_answer_bubble.py` 26 pins pass; phase-158/161 anchor pins (thinking_timeline + tool_states + edit_message) **75 passed**.
- **Phase E2E in isolation:** `uv run pytest tests/e2e/test_no_empty_box.py --no-cov` → **5 passed**. Mutation check (rule removed) → 2 failures, so the suite genuinely pins the defect.
- **Regression set, isolated:** thinking_timeline 6 · turn_progress_loader 3 · chat_history 5 · share_chat 4 · save_share_ux 5 · failed_turn_retry 4 · chat_answer_clusters 4 · chat_error_honesty 4 → all pass.
- **Full gates:** `uv run pytest --cov=app --cov-report=term-missing` → **3880 passed, app/ 99%** (gate >90%); `uv run ruff check .` → clean; `uv run pyright` → **0 errors**.
- **Whole E2E tree swept file-by-file (150 files, own pytest process each).** 4 suites were broken by *earlier* phases; fixed and re-run green:
- `test_kb_tree`, `test_sync_summary_visibility`, `test_folder_summary_voice` — `td:nth-child(4) span` became a strict-mode violation when phase 156 wrapped the Edit label in `.cell-clip` → scoped to `span.kb-desc-text`.
- `test_search_tool_scope` — pinned the pre-`690fd98` per-run `<summary>`/`open` shape → re-pinned to the ONE `details.reasoning` container + `.tool-calls-summary`.
- `test_header_consistency` — `.doc-meta` pixel bound (`<34px`) was broken by phase 153's 44px touch-target floor at ≤640px → replaced with a shape-first single-line check that still fails a real wrap.
- **Completion criteria:** mid-turn box hidden ✅ · bubble appears at first delta, phase-130 rows below unchanged ✅ · restored/shared empty answer paints no box ✅ · no app.js change + anchors + full gates ✅ · commit/`complete/` move = harness's job (I ran no git index/history commands).
- **Docs:** README — added the phase-166 "no empty answer box mid-turn" note; fixed a **stale** Thinking lead ("collapsible Thinking blocks", pre-dated the 2026-10-06 one-disclosure refinement); QA section now states E2E isolation is mandatory (shared Postgres + fixed ports 8123/8901) with the per-file sweep loop. AGENTS.md — rule 5 UI checklist now names the phase-166 suppression rule.
- **Notable:** running all of `tests/e2e/` in one pytest session is not supported (fixture/port interleaving causes ~126 spurious failures) — documented in README. Restored the tracked `.agents/screenshots/doc_modal_scrollbar_1280.png` that `test_document_viewer.py` rewrites on every run.
- **Next pending phase:** `167_api_payload_diet`.
This commit is contained in:
@@ -590,6 +590,37 @@ Implements just enough of the aipi surface:
|
||||
phrases — the phase-71/72/94 ordering convention); verified
|
||||
2026-09-16: no existing E2E question or fixture file contains the
|
||||
phrase, so every other suite is unaffected.
|
||||
- user message containing ``paint the pre-answer window``
|
||||
(``EMPTY_BUBBLE_TRIGGER``, phase 166 task 03 — the no-empty-
|
||||
answer-box story's dedicated suite
|
||||
``tests/e2e/test_no_empty_box.py``) **and** the system prompt
|
||||
carries the ``<tools>`` section -> the deterministic PRE-ANSWER
|
||||
WINDOW turn (TODO.md L4: "There's an empty box where the LLM
|
||||
response will go that shows up before the LLM responds"):
|
||||
thinking frames and a tool call with ZERO content deltas — the
|
||||
exact shape that used to paint the empty answer bubble — with
|
||||
baked-in delays (every visible window ≥1 s — the ``slow_llm.py``
|
||||
/ phase-109 pacing precedent) so the story E2E's window
|
||||
assertions are race-free. Discriminated statelessly from the
|
||||
messages (streaming only):
|
||||
* request 1 (``tools`` offered, no tool results yet): ~1.2 s
|
||||
pre-delay (model latency while the typing indicator shows),
|
||||
then 3 ``reasoning_content`` chunks at ~0.4 s (≈ 1.2 s of
|
||||
thinking — this request NEVER emits ``delta.content``), then
|
||||
the ``ls`` tool call (synthetic id ``call_0``, no arguments
|
||||
— the phase-37 pattern), ``finish_reason: "tool_calls"``;
|
||||
* request 2 (a ``tool``-role result in the messages — the
|
||||
server ran the ``ls``): a ~2.0 s frameless gap (the
|
||||
post-tool pre-answer window), then 3 short ``delta.content``
|
||||
chunks 0.05 s apart (the answer box appears WITH the first
|
||||
chunk), ``finish_reason: "stop"``.
|
||||
The turn's SSE is exactly ``thinking → tool → delta → done`` —
|
||||
the mid-turn state the box must survive hidden in (typing
|
||||
indicator → reasoning container → answer at the first token).
|
||||
Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger
|
||||
phrases — the phase-71/72/94 ordering convention); verified
|
||||
2026-10-08: no existing E2E question or fixture file contains
|
||||
the phrase, so every other suite is unaffected.
|
||||
- user message containing ``repeat your folder map``
|
||||
(``FOLDER_MAP_TRIGGER``, phase 119, D3 — the suggested-folder
|
||||
context's story suite ``tests/e2e/test_name_signal_read_chips.py``)
|
||||
@@ -3783,6 +3814,160 @@ def _turn_progress_stream(step: str) -> Any:
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 166 (task 03, the no-empty-answer-box suite): the deterministic
|
||||
# PRE-ANSWER WINDOW turn (thinking + a tool call, zero content deltas
|
||||
# until the follow-up request) — see the module docstring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: A user message containing this substring (case-insensitive) —
|
||||
#: combined with the ``<tools>`` section in the system prompt — drives
|
||||
#: the phase-166 PRE-ANSWER WINDOW turn (TODO.md L4: "There's an
|
||||
#: empty box where the LLM response will go that shows up before the
|
||||
#: LLM responds"): ~2.4 s of thinking + a tool call with ZERO content
|
||||
#: deltas, then a ~2 s gap, then the answer — the exact shape that
|
||||
#: painted the empty answer bubble (the owner's image.png) before
|
||||
#: phase 166 hid it. Baked-in delays keep every visible window ≥1 s
|
||||
#: (the ``slow_llm.py`` / phase-109 pacing precedent) so the story
|
||||
#: E2E's window assertions are race-free. Verified 2026-10-08: no
|
||||
#: existing E2E question or fixture file contains the phrase, so
|
||||
#: every other suite is unaffected.
|
||||
EMPTY_BUBBLE_TRIGGER = "paint the pre-answer window"
|
||||
|
||||
#: Request 1's pre-delay (s) — model latency: the typing indicator's
|
||||
#: window (no frame of the request has arrived yet).
|
||||
EMPTY_BUBBLE_PREDELAY_S = 1.2
|
||||
|
||||
#: Per-chunk delay (s) of request 1's three ``reasoning_content``
|
||||
#: chunks — ≈ 1.2 s of visible thinking with NO ``delta.content``
|
||||
#: (the window in which the empty bubble used to paint).
|
||||
EMPTY_BUBBLE_THINK_DELAY_S = 0.4
|
||||
|
||||
#: Request 2's frameless gap (s) — the post-tool pre-answer window:
|
||||
#: ≥ 1 s by construction, so the E2E can assert nothing paints an
|
||||
#: answer box between the ``tool`` frame and the first token.
|
||||
EMPTY_BUBBLE_TOOL_GAP_S = 2.0
|
||||
|
||||
#: Per-chunk delay (s) of request 2's short content deltas.
|
||||
EMPTY_BUBBLE_CONTENT_DELAY_S = 0.05
|
||||
|
||||
#: Request 1's scratchpad (byte-stable): 3 chunks —
|
||||
#: ``marker-thought-166`` is the sentinel the story E2E matches in
|
||||
#: the open reasoning container during the pre-delta window.
|
||||
EMPTY_BUBBLE_THINKING_CHUNKS = (
|
||||
"The owner wants the exact JSON shape — ",
|
||||
"listing the deployments folder first, ",
|
||||
"then answering from the file itself. marker-thought-166.",
|
||||
)
|
||||
|
||||
#: Request 2's answer (byte-stable): 3 short chunks —
|
||||
#: ``marker-emptybox-1`` is the sentinel the story E2E matches on the
|
||||
#: bubble to prove the box appeared WITH the first token.
|
||||
EMPTY_BUBBLE_ANSWER_CHUNKS = (
|
||||
"Here is the shape from the listing: ",
|
||||
"version 3, two record sets, zone ",
|
||||
"Z0RESEELINK01. marker-emptybox-1.",
|
||||
)
|
||||
|
||||
#: The full answer (the three chunks joined) — what the bubble holds
|
||||
#: once the turn settles.
|
||||
EMPTY_BUBBLE_ANSWER = "".join(EMPTY_BUBBLE_ANSWER_CHUNKS)
|
||||
|
||||
|
||||
def _empty_bubble_flow(body: dict[str, Any]) -> str | None:
|
||||
"""Classify a phase-166 pre-answer-window request (see the module
|
||||
docstring). Stateless over the messages, like the other marker
|
||||
flows:
|
||||
|
||||
* ``"first"`` — ``tools`` are offered and no ``tool``-role result
|
||||
is in the messages yet: request 1 — thinking + the ``ls`` tool
|
||||
call, ZERO content deltas.
|
||||
* ``"second"`` — a ``tool``-role result is in the messages (the
|
||||
server ran the ``ls``): request 2 — the ~2 s gap, then the
|
||||
three content chunks.
|
||||
* ``None`` — not the flow: the trigger is absent, the
|
||||
``<tools>`` section is missing (deflected turns never carry
|
||||
it), or tools are not offered and no tool result is in the
|
||||
messages yet.
|
||||
"""
|
||||
if EMPTY_BUBBLE_TRIGGER not in _user(body).lower():
|
||||
return None
|
||||
if "<tools>" not in _system(body):
|
||||
return None
|
||||
if _tool_results(body):
|
||||
return "second"
|
||||
if not body.get("tools"):
|
||||
return None
|
||||
return "first"
|
||||
|
||||
|
||||
def _empty_bubble_stream(step: str) -> Any:
|
||||
"""SSE frames for one phase-166 pre-answer-window request.
|
||||
|
||||
``"first"`` (model call 1): the ~1.2 s pre-delay (model latency
|
||||
— the typing-indicator window), the 3 ``reasoning_content``
|
||||
chunks at 0.4 s each (≈ 1.2 s of thinking — this request emits
|
||||
NO ``delta.content`` at all: the pre-delta state that used to
|
||||
paint the empty box), the ``ls`` tool-call partial (synthetic id
|
||||
``call_0``, no arguments — the phase-37 ``_tool_call_stream``
|
||||
first-partial shape), and the ``finish_reason: "tool_calls"``
|
||||
frame.
|
||||
|
||||
``"second"`` (model call 2 — after the server ran the ``ls``):
|
||||
the ~2.0 s frameless gap (the post-tool pre-answer window), the
|
||||
3 short ``delta.content`` chunks 0.05 s apart (the box appears
|
||||
with the first chunk and grows from there), and the
|
||||
``finish_reason: "stop"`` frame.
|
||||
"""
|
||||
model = "turbo"
|
||||
chunk_id = f"chatcmpl-{uuid.uuid4()}"
|
||||
|
||||
def frame(delta: dict[str, Any], finish: str | None = None) -> str:
|
||||
return (
|
||||
"data: "
|
||||
+ json_dumps(
|
||||
{
|
||||
"id": chunk_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
"model": model,
|
||||
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
|
||||
}
|
||||
)
|
||||
+ "\n\n"
|
||||
)
|
||||
|
||||
if step == "first":
|
||||
time.sleep(EMPTY_BUBBLE_PREDELAY_S)
|
||||
for piece in EMPTY_BUBBLE_THINKING_CHUNKS:
|
||||
yield frame({"reasoning_content": piece})
|
||||
time.sleep(EMPTY_BUBBLE_THINK_DELAY_S)
|
||||
yield frame(
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"index": 0,
|
||||
"id": "call_0",
|
||||
"type": "function",
|
||||
"function": {"name": "ls", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
time.sleep(0.1)
|
||||
yield frame({}, "tool_calls")
|
||||
yield "data: [DONE]\n\n"
|
||||
return
|
||||
|
||||
time.sleep(EMPTY_BUBBLE_TOOL_GAP_S)
|
||||
for piece in EMPTY_BUBBLE_ANSWER_CHUNKS:
|
||||
yield frame({"content": piece})
|
||||
time.sleep(EMPTY_BUBBLE_CONTENT_DELAY_S)
|
||||
yield frame({}, "stop")
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
|
||||
def compose_answer(body: dict[str, Any]) -> str:
|
||||
system = _system(body)
|
||||
user = _user(body)
|
||||
@@ -5053,6 +5238,23 @@ def chat_completions(body: dict[str, Any]) -> Any:
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
# Phase 166 (task 03, the no-empty-answer-box suite): the
|
||||
# deterministic PRE-ANSWER WINDOW turn — request 1 streams
|
||||
# thinking + the ``ls`` tool call with ZERO content deltas,
|
||||
# request 2 answers after a ~2 s gap (TODO.md L4 — the empty
|
||||
# answer box that painted before the first token; see the
|
||||
# module docstring). Checked BEFORE the plain TOOLS_TRIGGER
|
||||
# flow (disjoint trigger phrases — the phase-71/72/94
|
||||
# ordering convention; the trigger needs the ``<tools>``
|
||||
# section, so deflected turns never hit it).
|
||||
empty_bubble = _empty_bubble_flow(body)
|
||||
if empty_bubble is not None:
|
||||
stream = _empty_bubble_stream(empty_bubble)
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
# Phase 161 (task 04 — the reasoning-bubble story): the
|
||||
# deterministic MID-ROUND flow — a thinking run, then a
|
||||
# MID-ROUND ``delta`` (the React-style partial answer — the
|
||||
|
||||
Reference in New Issue
Block a user