phase: 109_turn_progress_loader
All gates green. Final report: **Phase 109 — final verification pass (all 3 tasks were already complete; no re-implementation needed)** - Verified D15 (thinking-handler re-open `block.open = true` after `ensureThinkingBlock`; delta keeps closing; "never reopens" narrative removed; restore path still collapses) and D16 (single static `#turn-loader` in composer row, `aria-hidden`, sole `turnLoader.hidden = !inFlight` write in `setUiState`; CSS reuses `typing` keyframes + reduced-motion override after the main rule + provenance; global `[hidden]` guard intact; mock `TURN_PROGRESS_TRIGGER` repro marker; `app/` untouched — pure UI phase). - Unit pins: `tests/unit/test_frontend_turn_loader.py` (9 tests) — green via full run. - `uv run pytest tests/e2e/test_turn_progress_loader.py -v --no-cov` → **3 passed** (isolation, DB up): loader visible send→tool-gap→final-thinking, hidden after done; re-opened scratchpad carries `marker-thought-42`/`marker-final-thought-42`; `#send-status` carries state text, back to empty idle; reduced-motion context stills dots (computed `animation-name: none`) with loader still visible. - Regressions in isolation: `test_thinking_display` 5 passed, `test_stop_generation` 3 passed, `test_big_read_progress` 4 passed, `test_loading_feedback` 5 passed. - `uv run pytest` → **2314 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90%); `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings. - All terminal paths (done→idle L2554, stream error→error L2543, timeout→error L2247) funnel through `setUiState` — never-stale by construction. - No defects found; no code changes made in this pass. Commit + phase-dir move left to the harness per pipeline rules (working tree carries all changes). - Next pending phase: `108_history_wire_check` (still in `todo/`; out of scope here).
This commit is contained in:
@@ -357,6 +357,44 @@ Implements just enough of the aipi surface:
|
||||
client never sends tool calls/results) — so every existing marker
|
||||
flow (which classifies statelessly from TOOL results and the LAST
|
||||
user message) is unaffected by the now-always-present history.
|
||||
- user message containing ``answer first, then list, then think``
|
||||
(``TURN_PROGRESS_TRIGGER``, phase 109 task 03 — the never-frozen-
|
||||
turn story's dedicated suite
|
||||
``tests/e2e/test_turn_progress_loader.py``) **and** the system
|
||||
prompt carries the ``<tools>`` section -> the deterministic
|
||||
REPORTED-REPRO turn (TODO.md L3: "the model responds, calls a
|
||||
tool, then continues thinking without re-expanding the thinking
|
||||
block"): the owner's exact sequence, deterministic, with baked-in
|
||||
delays (mid-turn windows of ≥1 s each — the ``slow_llm.py``
|
||||
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): ~2 s pre-
|
||||
delay (model latency — the loader's start-state window), a
|
||||
short ``delta.content`` stream (3 chunks, NO reasoning — the
|
||||
answer starts FIRST), 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 ~7 s frameless gap (long enough that the
|
||||
phase-87 tool-line ``(Ns)`` counter — 5 s+ after the line's
|
||||
own arm — appears and ticks BEFORE the first ``thinking``
|
||||
frame settles the line), then the ``reasoning_content`` stream
|
||||
(10 × 0.3 s), a ``delta.content`` stream (3 chunks ending in
|
||||
the distinctive final sentence carrying
|
||||
``marker-progress-42``), and the FINAL ``reasoning_content``
|
||||
chunks (3 × 0.3 s — thinking AFTER the answer, the reported
|
||||
repro), ``finish_reason: "stop"``.
|
||||
The server is position-independent over the wire (each
|
||||
``reasoning_content`` chunk → a ``thinking`` SSE frame, each
|
||||
``content`` chunk → a ``delta`` frame — ``app/rag/llm.py``; the
|
||||
tool call materializes after its request's stream — the
|
||||
content-before-tools convention), so the turn's SSE is exactly
|
||||
``delta → tool → thinking → delta → thinking → done``.
|
||||
Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger
|
||||
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.
|
||||
|
||||
Failure injection (phase 67, LLM retry, TODO.md L3) — deterministic
|
||||
dead-endpoint behavior for the retry E2E suite (``tests/e2e/
|
||||
@@ -1597,6 +1635,207 @@ def _folder_summary_incident(body: dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 109 (task 03, the never-frozen-turn story suite): the
|
||||
# deterministic REPORTED-REPRO turn (delta → tool →
|
||||
# thinking-after-delta) — see the module docstring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: A user message containing this substring (case-insensitive) —
|
||||
#: combined with the ``<tools>`` section in the system prompt — drives
|
||||
#: the deterministic REPORTED-REPRO turn (TODO.md L3): the scripted
|
||||
#: first answer (no reasoning), the no-arg ``ls``, then the post-tool
|
||||
#: thinking → answer → FINAL thinking round — the owner's exact
|
||||
#: "the model responds, calls a tool, then continues thinking" sequence
|
||||
#: with baked-in delays (mid-turn windows of ≥1 s each — the
|
||||
#: ``slow_llm.py`` pacing precedent) so the story E2E's window
|
||||
#: assertions are race-free. Checked BEFORE the plain ``TOOLS_TRIGGER``
|
||||
#: flow (disjoint trigger 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.
|
||||
TURN_PROGRESS_TRIGGER = "answer first, then list, then think"
|
||||
|
||||
#: Request 1's pre-delay (s) — model latency: the loader's start-state
|
||||
#: window (the turn is in flight, NO frame has arrived yet).
|
||||
TURN_PROGRESS_PREDELAY_S = 2.0
|
||||
|
||||
#: Request 2's frameless gap (s) — the silence after the ``tool`` frame
|
||||
#: before the first ``thinking`` frame. Deliberately past the phase-87
|
||||
#: tool-line counter's 5 s gate (``TOOL_LINE_ELAPSED_AFTER_MS`` in
|
||||
#: app.js) with ≥2 s of margin: the line's ``(Ns)`` suffix appears
|
||||
#: (5 s tick) and ticks (6 s) BEFORE the first thinking frame settles
|
||||
#: the line, so the story E2E can pin the counter in the tool gap
|
||||
#: without a race.
|
||||
TURN_PROGRESS_TOOL_GAP_S = 7.0
|
||||
|
||||
#: Per-chunk delays (s) for the two streams of request 2.
|
||||
TURN_PROGRESS_THINK_DELAY_S = 0.3 # reasoning chunks (~10 × 0.3 s, ~3 × 0.3 s)
|
||||
TURN_PROGRESS_CONTENT_DELAY_S = 0.05 # the short content deltas (3 + 3 chunks)
|
||||
|
||||
#: Chunk sizes (chars): the content deltas stay SHORT (2-3 chunks each),
|
||||
#: the reasoning streams run 10 × 30 and 3 × 30.
|
||||
TURN_PROGRESS_CONTENT_CHUNK = 40
|
||||
TURN_PROGRESS_THINK_CHUNK = 30
|
||||
|
||||
#: Request 1's answer (byte-stable): 3 × 40-char content chunks, NO
|
||||
#: reasoning — the answer starts FIRST (the repro's "the model
|
||||
#: responds"). ``marker-progress-41`` is the sentinel the story E2E
|
||||
#: matches to prove call 1's content landed in the bubble.
|
||||
TURN_PROGRESS_FIRST_ANSWER = (
|
||||
"Checking the listing first — opening answer: the kubernetes setup, "
|
||||
"short and sweet. marker-progress-41."
|
||||
)
|
||||
|
||||
#: Request 2's scratchpad (byte-stable): 10 × 30-char reasoning chunks —
|
||||
#: thinking AFTER the answer, the reported repro. ``marker-thought-42``
|
||||
#: is the sentinel the story E2E matches in the re-opened block.
|
||||
TURN_PROGRESS_THINKING = (
|
||||
"The listing just landed — now I can see which documents exist, so the "
|
||||
"answer can anchor to the kubernetes file first and the deployments note "
|
||||
"second, citing each fact by the exact path it came from. Hosts, "
|
||||
"versions, and ports stay exactly as the notes write them. "
|
||||
"marker-thought-42."
|
||||
)
|
||||
|
||||
#: Request 2's answer (byte-stable): 3 × 40-char content chunks ending in
|
||||
#: the DISTINCTIVE final sentence the story E2E matches on to pin the
|
||||
#: post-delta state (``marker-progress-42`` — the repro's second answer).
|
||||
TURN_PROGRESS_FINAL_ANSWER = (
|
||||
"Here is the plan after the listing: step one, step two, step three — "
|
||||
"that is the whole of it. marker-progress-42."
|
||||
)
|
||||
|
||||
#: Request 2's FINAL reasoning (byte-stable): 3 × 30-char chunks — the
|
||||
#: thinking that arrives AFTER the answer's last delta. The story E2E
|
||||
#: waits for the re-opened block after the final sentence landed and
|
||||
#: matches ``marker-final-thought-42`` to prove the last round's
|
||||
#: thinking rendered in the scratchpad.
|
||||
TURN_PROGRESS_FINAL_THINKING = (
|
||||
"Final check — hosts and ports are verbatim. marker-final-thought-42."
|
||||
)
|
||||
|
||||
|
||||
def _turn_progress_flow(body: dict[str, Any]) -> str | None:
|
||||
"""Classify a phase-109 reported-repro 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 — the ~2 s pre-delay (model
|
||||
latency — the loader's start-state window), the short
|
||||
``delta.content`` stream (3 chunks, NO reasoning — the answer
|
||||
starts first), then the ``ls`` tool call (synthetic id
|
||||
``call_0``, no arguments), ``finish_reason: "tool_calls"``.
|
||||
* ``"second"`` — a ``tool``-role result is in the messages (the
|
||||
server ran the ``ls``): request 2 — the ~7 s frameless gap
|
||||
(the phase-87 window), the ``reasoning_content`` stream (10 ×
|
||||
0.3 s), the ``delta.content`` stream (3 chunks ending in the
|
||||
distinctive final sentence), and the FINAL
|
||||
``reasoning_content`` chunks (3 × 0.3 s) — thinking AFTER the
|
||||
answer — then ``finish_reason: "stop"``.
|
||||
* ``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 (e.g. ``agent_max_rounds=0``).
|
||||
"""
|
||||
if TURN_PROGRESS_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 _turn_progress_stream(step: str) -> Any:
|
||||
"""SSE frames for one phase-109 reported-repro request.
|
||||
|
||||
``"first"`` (model call 1): the ~2 s pre-delay, the short
|
||||
``delta.content`` stream (3 chunks — the answer starts first, NO
|
||||
reasoning), 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. The app is
|
||||
position-independent over the wire (``app/rag/llm.py``): the
|
||||
content chunks stream as ``delta`` SSE frames as they arrive and
|
||||
the tool call materializes AFTER the stream (the content-before-
|
||||
tools convention), so the request's SSE is exactly ``delta →
|
||||
tool``.
|
||||
|
||||
``"second"`` (model call 2 — after the server ran the ``ls``): the
|
||||
~7 s frameless gap (the phase-87 window — the tool line's
|
||||
``(Ns)`` counter appears and ticks before the first frame settles
|
||||
it), the ``reasoning_content`` stream (10 × 30 chars, 0.3 s each),
|
||||
the ``delta.content`` stream (3 chunks ending in the distinctive
|
||||
final sentence), the FINAL ``reasoning_content`` chunks (3 × 30
|
||||
chars, 0.3 s each — thinking AFTER the answer, the reported
|
||||
repro), 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(TURN_PROGRESS_PREDELAY_S)
|
||||
for piece in re.findall(
|
||||
rf".{{1,{TURN_PROGRESS_CONTENT_CHUNK}}}", TURN_PROGRESS_FIRST_ANSWER, re.S
|
||||
):
|
||||
yield frame({"content": piece})
|
||||
time.sleep(TURN_PROGRESS_CONTENT_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(TURN_PROGRESS_TOOL_GAP_S)
|
||||
for piece in re.findall(
|
||||
rf".{{1,{TURN_PROGRESS_THINK_CHUNK}}}", TURN_PROGRESS_THINKING, re.S
|
||||
):
|
||||
yield frame({"reasoning_content": piece})
|
||||
time.sleep(TURN_PROGRESS_THINK_DELAY_S)
|
||||
for piece in re.findall(
|
||||
rf".{{1,{TURN_PROGRESS_CONTENT_CHUNK}}}", TURN_PROGRESS_FINAL_ANSWER, re.S
|
||||
):
|
||||
yield frame({"content": piece})
|
||||
time.sleep(TURN_PROGRESS_CONTENT_DELAY_S)
|
||||
for piece in re.findall(
|
||||
rf".{{1,{TURN_PROGRESS_THINK_CHUNK}}}", TURN_PROGRESS_FINAL_THINKING, re.S
|
||||
):
|
||||
yield frame({"reasoning_content": piece})
|
||||
time.sleep(TURN_PROGRESS_THINK_DELAY_S)
|
||||
yield frame({}, "stop")
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
|
||||
def compose_answer(body: dict[str, Any]) -> str:
|
||||
system = _system(body)
|
||||
user = _user(body)
|
||||
@@ -2222,6 +2461,23 @@ def chat_completions(body: dict[str, Any]) -> Any:
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
# Phase 109 (task 03, never-frozen-turn story suite): the
|
||||
# deterministic REPORTED-REPRO turn (delta → tool →
|
||||
# thinking-after-delta — TODO.md L3): the scripted first answer,
|
||||
# the no-arg ls, then the post-tool thinking → answer → FINAL
|
||||
# thinking round — the owner's exact repro with baked-in delays
|
||||
# (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).
|
||||
turn_progress = _turn_progress_flow(body)
|
||||
if turn_progress is not None:
|
||||
stream = _turn_progress_stream(turn_progress)
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
flow = _tool_flow(body)
|
||||
if flow is not None:
|
||||
if flow[0] == "list":
|
||||
|
||||
Reference in New Issue
Block a user