fix(chat): keep the in-flight answer when navigating away mid-turn — partial answer restored on return

This commit is contained in:
2026-08-24 15:53:07 -04:00
parent 824914ca3d
commit 76c6a01199
5 changed files with 578 additions and 91 deletions
+30 -3
View File
@@ -18,6 +18,10 @@ Implements just enough of the aipi surface:
- user message containing ``think out loud`` -> the answer is preceded by
~800 chars of deterministic ``reasoning_content`` chunks (the
thinking-display story, phase 17).
- user message containing ``think out loud then hesitate`` -> the
``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).
- 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.
@@ -89,6 +93,16 @@ LONG_ANSWER_END = "LONG-ANSWER-END"
#: contain the substring, so every other suite is unaffected.
THINKING_TRIGGER = "think out loud"
#: 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
#: navigation window for the "leave during pure thinking" scenario
#: (owner-confirmed A1.2: nothing brain-side may be persisted then).
#: Strictly longer than ``THINKING_TRIGGER``, so the phase-17 suite's
#: questions are unaffected.
SLOW_PRETOKEN_TRIGGER = "think out loud then hesitate"
PRE_CONTENT_PAUSE_S = 4.0
def long_answer() -> str:
"""~900-word deterministic walkthrough (phase 11): numbered steps plus
@@ -222,7 +236,9 @@ def embeddings(body: dict[str, Any]) -> dict[str, Any]:
}
def _sse_stream(answer: str, delay: float, thinking: str = "") -> Any:
def _sse_stream(
answer: str, delay: float, thinking: str = "", pre_content_delay: float = 0.0
) -> Any:
"""SSE frames for one chat completion (phase 17: + reasoning).
When ``thinking`` is non-empty its 12-char slices go out FIRST as
@@ -230,6 +246,11 @@ def _sse_stream(answer: str, delay: float, thinking: str = "") -> Any:
as the content frames, the aipi wire convention (reasoning before
content). Without ``thinking`` the output is byte-identical to the
content-only stream, so the other story suites are unaffected.
``pre_content_delay`` (phase 20) inserts a silence gap between the end
of the thinking stream and the first content frame — the client stays
in its pre-token "thinking" state the whole time (0.02s cadence and
frame shapes are unchanged, so 0.0 is byte-identical to before).
"""
model = "turbo"
chunk_id = f"chatcmpl-{uuid.uuid4()}"
@@ -247,6 +268,8 @@ def _sse_stream(answer: str, delay: float, thinking: str = "") -> Any:
}
yield f"data: {json_dumps(payload)}\n\n"
time.sleep(0.02)
if pre_content_delay:
time.sleep(pre_content_delay)
for piece in re.findall(r".{1,12}", answer, re.S):
payload = {
"id": chunk_id,
@@ -295,7 +318,11 @@ def _apply_max_tokens(answer: str, max_tokens: Any) -> str:
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(body).lower() else ""
user_lower = _user(body).lower()
thinking = compose_thinking(body) if THINKING_TRIGGER in user_lower else ""
pre_content = (
PRE_CONTENT_PAUSE_S if SLOW_PRETOKEN_TRIGGER in user_lower else 0.0
)
if not body.get("stream"):
message: dict[str, Any] = {"role": "assistant", "content": answer}
@@ -315,7 +342,7 @@ def chat_completions(body: dict[str, Any]) -> Any:
}
return StreamingResponse(
_sse_stream(answer, delay, thinking=thinking),
_sse_stream(answer, delay, thinking=thinking, pre_content_delay=pre_content),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)