fix(chat): stop the submit up-hop and keep the thinking pin alive across paragraph breaks
- scrollReveal lands at the document bottom (window.scrollTo) instead of
scrollIntoView({ block: 'end' }): the old alignment sat above the
in-flow composer, so every Enter hopped the page up by the
composer+footer height and pushed the composer below the fold.
- The thinking window's pin state is now captured BEFORE the re-render
(const pinned = block.open && isThinkingNearBottom(textEl)): the
post-render distance read the new chunk's rendered height, not the
user's position, so any chunk taller than the 32px band (real-model
deltas, '\n\n' paragraph breaks) killed the follow at the first
2-newline gap.
- Mock LLM: new 'think in paragraphs' trigger (scratchpad with real
blank-line breaks, 60-char frames) — the 12-char mock frames never
rendered past the band, which is why the bug survived the E2E gates.
- E2E (both verified red against the old code):
test_submit_does_not_hop_up, test_thinking_window_follows_across_paragraph_breaks.
- Unit source-marker tests updated to the new contracts.
This commit is contained in:
+83
-6
@@ -30,6 +30,17 @@ Implements just enough of the aipi surface:
|
||||
``think out loud`` stream, then a 4s pause before the first content
|
||||
frame (the sources-midstream story, phase 20 — a deterministic
|
||||
"leave during pure thinking" navigation window).
|
||||
- user message containing ``think in paragraphs`` (``THINK_PARAS_TRIGGER``)
|
||||
-> the ``think out loud`` scratchpad WITH REAL paragraph breaks
|
||||
("\n\n"), streamed at 60-char frames (vs the mock's 12-char default).
|
||||
One frame renders several lines — a real-model-sized delta, the
|
||||
condition under which a POST-render pin-state reading (the old
|
||||
app.js) measured the chunk's height instead of the user's position
|
||||
and the think-window follow died at the first 2-newline gap. The
|
||||
regression pin for the pre-render capture in app.js (2026-08-29,
|
||||
owner report). Checked BEFORE ``think out loud`` (it is the more
|
||||
specific phrase); existing E2E questions carry neither, so every
|
||||
other suite is unaffected.
|
||||
- system prompt containing ``<tuning>`` (phase 15, steering notes) ->
|
||||
the composed answer ends with `` (tuning: <first note line>)`` —
|
||||
makes prompt injection observable in the UI deterministically.
|
||||
@@ -167,6 +178,22 @@ LONG_ANSWER_END = "LONG-ANSWER-END"
|
||||
#: contain the substring, so every other suite is unaffected.
|
||||
THINKING_TRIGGER = "think out loud"
|
||||
|
||||
#: Regression pin (2026-08-29, owner report): a user message containing
|
||||
#: this substring (case-insensitive) gets the phase-17 scratchpad WITH
|
||||
#: REAL paragraph breaks ("\n\n"), streamed at ``THINK_PARAS_CHUNK``
|
||||
#: chars/frame — a single frame renders several lines (a real-model-sized
|
||||
#: delta), which is the condition under which the old POST-render pin
|
||||
#: reading in app.js died at the first 2-newline gap. Existing E2E
|
||||
#: questions do not contain the phrase, so every other suite is
|
||||
#: unaffected (checked before ``THINKING_TRIGGER`` — the more specific
|
||||
#: phrase wins).
|
||||
THINK_PARAS_TRIGGER = "think in paragraphs"
|
||||
#: 60-char thinking frames for the paragraph trigger (the mock default is
|
||||
#: 12 — a 12-char frame renders at most one line ≈ 22px, always inside
|
||||
#: the 32px think-window band, which is why the old code passed the
|
||||
#: 12-char suites while the real model's larger deltas killed the pin).
|
||||
THINK_PARAS_CHUNK = 60
|
||||
|
||||
#: Phase 20 (sources-midstream bug): a user message containing this
|
||||
#: substring (case-insensitive) gets the phase-17 thinking stream followed
|
||||
#: by a multi-second pause before the FIRST content frame — the
|
||||
@@ -526,6 +553,13 @@ def compose_thinking(body: dict[str, Any]) -> str:
|
||||
THINKING_TAIL) are what the E2E assertions key off — both are preserved.
|
||||
"""
|
||||
q = _user(body).strip()[:60]
|
||||
return _thinking_template(q)
|
||||
|
||||
|
||||
def _thinking_template(q: str) -> str:
|
||||
"""The fixed Step/Scratch scratchpad (``compose_thinking`` and its
|
||||
paragraph variant share the exact same text — only the line
|
||||
separators differ)."""
|
||||
return (
|
||||
f"Step 1: Read the question carefully — “{q}” — and figure out what kind of "
|
||||
"answer it wants (a how-to, a lookup, or a design decision) before touching "
|
||||
@@ -572,6 +606,22 @@ def compose_thinking(body: dict[str, Any]) -> str:
|
||||
)
|
||||
|
||||
|
||||
def compose_thinking_paragraphs(body: dict[str, Any]) -> str:
|
||||
"""The phase-17 scratchpad with REAL paragraph breaks (\n\n, 2026-08-29
|
||||
regression pin): the same deterministic text as ``compose_thinking``,
|
||||
with a blank line inserted after scratchpad lines 2 and 6 (0-based) —
|
||||
two genuine \"2-newline gaps\" in the rendered scratchpad. Unique per
|
||||
question, byte-stable across runs (same length contract + 2 chars)."""
|
||||
base = compose_thinking(body)
|
||||
lines = base.split("\n")
|
||||
out: list[str] = []
|
||||
for i, line in enumerate(lines):
|
||||
out.append(line)
|
||||
if i in (2, 6):
|
||||
out.append("") # blank line -> a real \"\n\n\" gap
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
@app.post("/__shutdown__")
|
||||
def shutdown() -> dict[str, Any]:
|
||||
"""Test hook (loading-feedback story): terminate this mock process to
|
||||
@@ -617,11 +667,21 @@ def embeddings(body: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _sse_stream(
|
||||
answer: str, delay: float, thinking: str = "", pre_content_delay: float = 0.0
|
||||
answer: str,
|
||||
delay: float,
|
||||
thinking: str = "",
|
||||
pre_content_delay: float = 0.0,
|
||||
chunk: int = 12,
|
||||
) -> Any:
|
||||
"""SSE frames for one chat completion (phase 17: + reasoning).
|
||||
|
||||
When ``thinking`` is non-empty its 12-char slices go out FIRST as
|
||||
``chunk`` (default 12) is the slice size for BOTH the thinking and
|
||||
the content frames — the ``think in paragraphs`` trigger raises it
|
||||
to ``THINK_PARAS_CHUNK`` (60) so a single frame renders past the
|
||||
32px think-window band (see ``THINK_PARAS_TRIGGER``). At 12 the
|
||||
output is byte-identical to the original.
|
||||
|
||||
When ``thinking`` is non-empty its ``chunk``-sized slices go out FIRST as
|
||||
``delta.reasoning_content`` frames — same 0.02s cadence and envelope
|
||||
as the content frames, the aipi wire convention (reasoning before
|
||||
content). Without ``thinking`` the output is byte-identical to the
|
||||
@@ -636,7 +696,7 @@ def _sse_stream(
|
||||
chunk_id = f"chatcmpl-{uuid.uuid4()}"
|
||||
if delay:
|
||||
time.sleep(delay)
|
||||
for piece in re.findall(r".{1,12}", thinking, re.S):
|
||||
for piece in re.findall(rf".{{1,{chunk}}}", thinking, re.S):
|
||||
payload = {
|
||||
"id": chunk_id,
|
||||
"object": "chat.completion.chunk",
|
||||
@@ -650,7 +710,7 @@ def _sse_stream(
|
||||
time.sleep(0.02)
|
||||
if pre_content_delay:
|
||||
time.sleep(pre_content_delay)
|
||||
for piece in re.findall(r".{1,12}", answer, re.S):
|
||||
for piece in re.findall(rf".{{1,{chunk}}}", answer, re.S):
|
||||
payload = {
|
||||
"id": chunk_id,
|
||||
"object": "chat.completion.chunk",
|
||||
@@ -791,7 +851,18 @@ def chat_completions(body: dict[str, Any]) -> Any:
|
||||
|
||||
answer = _apply_max_tokens(compose_answer(body), body.get("max_tokens"))
|
||||
delay = 3.0 if "pretend to think slowly" in _user(body) else 0.0
|
||||
thinking = compose_thinking(body) if THINKING_TRIGGER in user_lower else ""
|
||||
# ``think in paragraphs`` wins over ``think out loud`` (more specific):
|
||||
# the same scratchpad WITH real "\n\n" paragraph breaks, at 60-char
|
||||
# frames (real-model-sized deltas — the 32px-band regression pin).
|
||||
if THINK_PARAS_TRIGGER in user_lower:
|
||||
thinking = compose_thinking_paragraphs(body)
|
||||
chunk = THINK_PARAS_CHUNK
|
||||
elif THINKING_TRIGGER in user_lower:
|
||||
thinking = compose_thinking(body)
|
||||
chunk = 12
|
||||
else:
|
||||
thinking = ""
|
||||
chunk = 12
|
||||
pre_content = (
|
||||
PRE_CONTENT_PAUSE_S if SLOW_PRETOKEN_TRIGGER in user_lower else 0.0
|
||||
)
|
||||
@@ -814,7 +885,13 @@ def chat_completions(body: dict[str, Any]) -> Any:
|
||||
}
|
||||
|
||||
return StreamingResponse(
|
||||
_sse_stream(answer, delay, thinking=thinking, pre_content_delay=pre_content),
|
||||
_sse_stream(
|
||||
answer,
|
||||
delay,
|
||||
thinking=thinking,
|
||||
pre_content_delay=pre_content,
|
||||
chunk=chunk,
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user