phase: 161_reasoning_bubble
Build and Push Containers / build-and-push-app (push) Successful in 1m55s
Build and Push Containers / build-and-push-db (push) Successful in 14s

All gates verified. Final check — AGENTS.md rules against the project (commands, gates, protocols) — the quick-reference commands all exist and the rules (PLAN §2/§7/§9 references, phase protocol, No CDN, Debugpy, git gates) accurately describe the project; the reasoning bubble is a pure UI change with no new commands or env vars, so no AGENTS.md change is needed.

**Verification complete — full results:**

- **Verified** (implemented by prior task passes, all in working tree): `createReasoningWrap`/`ensureReasoningWrap` live container (D1), `settleReasoning` fold at the 3 settle sites + not at delta (D2), 320px `.reasoning-body` window + pinned-tail follow (D3), zero persistence change (D4), collapsed restore + shared-page parity with D6 step counts, `styles.css` family treatment + reduced-motion still, mock `REASONING_MIDROUND_TRIGGER` flow, 6-test story suite, phase-158 pin updates
- **Full suite:** `uv run pytest` → **3738 passed, 0 failed** · `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99% (>90%)** · `uv run ruff check .` → all checks passed · `uv run pyright` → **0 errors, 0 warnings**
- **E2E in isolation** (DB up; port-8123 collision with an unrelated `undercooked` service → `E2E_APP_PORT=8125`/`E2E_MOCK_PORT=8902`, no live change made): `test_reasoning_bubble.py` **6/6** · `test_thinking_timeline.py` ✓ · `test_thinking_display.py` ✓ · `test_tool_call_compact.py` ✓ · `test_big_read_progress.py` ✓ · `test_turn_progress_loader.py` ✓ · `test_share_chat.py` ✓ · `test_chat_persistence.py` ✓ · `test_smoke.py` ✓ (all exit 0, zero failures)
- **Completion criteria:** all 9 checked against the code + test outcomes — container open/live/nested ✓, stays open past mid-round delta ✓, folds at done ✓, restore + shared collapsed with "3 steps" ✓, plain turn = no container ✓, regression suites green ✓, suite/coverage/lint green ✓, diff scope limited to `frontend/assets/{app.js,shared.js,styles.css}` + `tests/` + `README.md` + phase files (no `app/` — D4) ✓
- **Documentation:** README.md "Thinking" section names the Reasoning bubble accurately (320px window, open-until-settle incl. mid-round deltas, "N steps", byte-identical no-container, collapsed on restore/shared) — verified consistent, no changes needed; AGENTS.md — verified consistent, no changes needed; all `.env.example` vars documented
- **Decisions:** commit + phase-dir move left to the harness (per pass rules — no git index/history commands executed); `undercooked` dev server on port 8123 untouched, E2E used the env port overrides
- **Next pending phase:** none — `161_reasoning_bubble` is the last in `todo/`
This commit is contained in:
2026-10-06 06:36:09 -04:00
parent 8172956c15
commit 051f62deb7
48 changed files with 4194 additions and 166 deletions
+254
View File
@@ -206,6 +206,51 @@ Implements just enough of the aipi surface:
TRIGGER`` / ``SCAFFOLD_ALWAYS_TRIGGER`` precedent). Verified
2026-10-04: no existing E2E question or fixture file contains
the phrase, so every other suite is unaffected.
- user message containing ``think out loud, answer, then check
your work`` (``REASONING_MIDROUND_TRIGGER``, phase 161 task 04
— the reasoning-bubble story's dedicated suite
``tests/e2e/test_reasoning_bubble.py``) **and** the system
prompt carries the ``<tools>`` section -> the deterministic
MID-ROUND tool flow: the owner's 2026-10-05 TODO's stays-open
wire shape — a ``reasoning_content`` run, then a MID-ROUND
``content`` delta (the React-style partial answer, the content
stream BEFORE the tool call), then the tool call; the follow-up
request thinks, closes the answer, and FINISHES with a content
finish (no further tool call — the ``done`` convention).
Discriminated statelessly from the messages (streaming only —
the app's chat path is the only streaming consumer; a
non-streaming marker request never issues and falls through to
the plain ``think out loud`` scratchpad answer, like the other
marker flows): the ``tools`` parameter gates the first step (a
no-tools request with no tool results is not the flow), and the
final step is reached when a ``tool``-role result is in the
messages (regardless of the ``tools`` parameter):
* request 1 (``tools`` offered, no tool results yet): stream
``reasoning_content`` (``Step 1: I think I know the
answer — let me check the documents.``) FIRST — the 12-char
``_sse_stream`` convention — then the MID-ROUND ``content``
delta (``The reference server is ref-server-1.`` — the SAME
12-char / 0.02 s convention), and ONLY then
``tool_calls`` deltas: ``ls`` (synthetic id ``call_0``, no
arguments), ``finish_reason: "tool_calls"`` — the tool call
AFTER the delta (the exact mid-round ordering the TODO
describes);
* request 2 (a ``tool``-role result in the messages): stream
``reasoning_content`` (``Step 2: the listing confirms
it.``) FIRST, then the closing ``content`` delta (`` —
confirmed by the listing.`` — it concatenates onto the
mid-round delta, so the turn's full answer is byte-stable),
then ``finish_reason: "stop"`` + ``[DONE]`` — the flow ends
with a content finish (NO further tool call).
Checked BEFORE the phase-158 ``think out loud and use your
tools`` check: the phrases share only ``think out loud`` and
NEITHER is a substring of the other (verified 2026-10-06), and
no existing E2E question or fixture file contains the phrase
(verified 2026-10-06: every other suite is unaffected) — the
more-specific-phrase-wins convention (the ``SLOW_PRETOKEN_-
TRIGGER`` / ``THINK_PARAS_TRIGGER`` / ``SCAFFOLD_ALWAYS_TRIGGER``
precedent). The trigger needs the ``<tools>`` section, so
deflected turns never hit it.
- user message containing ``search your documents``
(``SEARCH_TRIGGER``, phase 68 search tool — renamed to the
harness-aligned ``grep`` in phase 70, same match/output contract)
@@ -1117,6 +1162,36 @@ THINKING_TRIGGER = "think out loud"
#: exactly like the other marker flows.
THINK_TOOLS_TRIGGER = "think out loud and use your tools"
#: Phase 161 (task 04 — the reasoning-bubble story): a user message
#: containing this substring (case-insensitive) — combined with the
#: ``<tools>`` section in the system prompt — drives the
#: deterministic MID-ROUND flow documented in the module docstring:
#: a ``reasoning_content`` run, then a MID-ROUND ``delta`` (the
#: React-style partial answer — the content stream BEFORE the tool
#: call, the exact mid-round ordering the owner's 2026-10-05 TODO
#: describes), then the tool call; the follow-up request (a
#: ``tool``-role result in the messages) thinks, emits the closing
#: ``delta``, and FINISHES with a content finish (NO further tool
#: call — the ``done`` convention). Verified 2026-10-06: no
#: existing E2E question or fixture file contains the phrase, and
#: the dispatch is disjoint from the other ``think out loud``
#: triggers — the phrase shares only ``think out loud`` with
#: ``THINKING_TRIGGER`` and ``THINK_TOOLS_TRIGGER`` ("think out
#: loud and use your tools"): NEITHER ``THINK_TOOLS_TRIGGER`` nor
#: the new phrase is a substring of the other (the existing
#: continues ``…and use your tools``, the new continues ``…answer,
#: then check…``). NOTE: the phrase CONTAINS ``THINKING_TRIGGER``
#: ("think out loud") — the more-specific-phrase-wins convention
#: (``SLOW_PRETOKEN_TRIGGER`` / ``THINK_PARAS_TRIGGER`` /
#: ``SCAFFOLD_ALWAYS_TRIGGER``): the streaming dispatch checks the
#: flow BEFORE the phase-158 check and before the generic
#: ``think out loud`` path (so the plain scratchpad never sees the
#: marker). A non-streaming marker request (never issued by the app
#: — the chat path is the only streaming consumer) falls through to
#: the plain ``think out loud`` scratchpad answer, exactly like the
#: other marker flows.
REASONING_MIDROUND_TRIGGER = "think out loud, answer, then check your work"
#: 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``
@@ -2133,6 +2208,55 @@ def _thinking_tools_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
return ("list", "Step 1: list the documents to find the reference server config.")
def _reasoning_midround_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
"""Classify a phase-161 marker request into one step of the
MID-ROUND thinking + delta + tool flow (see the module
docstring).
The two-request flow — the owner's 2026-10-05 TODO's
stays-open wire shape (the mid-round ``delta`` BEFORE the tool
call is what the client's stays-open container contract needs).
Discriminated statelessly from the messages, like the other
marker flows:
* ``("midround", <step 1 thinking>, <mid-round delta>)`` —
``tools`` are offered and no tool results are in the
messages yet: the model thinks, then emits a MID-ROUND
``delta`` (the content stream BEFORE the tool call), then
calls ``ls`` (no arguments, id ``call_0``) — the tool call
AFTER the delta.
* ``("final", <step 2 thinking>, <closing delta>)`` — a
``tool``-role result is in the messages: the model thinks,
then emits the closing ``delta`` and FINISHES with a content
finish (``finish_reason: "stop"`` — the ``done`` convention;
NO further tool call). Reached regardless of the ``tools``
parameter (phase 45 keeps the tools offered until the round
cap).
* ``None`` — not the marker flow: the marker is absent, the
``<tools>`` section is not in the system prompt, or a
no-tools request with no tool results (e.g. ``agent_max_
rounds=0``) — the request behaves exactly as before.
"""
user = _user(body).lower()
if REASONING_MIDROUND_TRIGGER not in user:
return None
if "<tools>" not in _system(body):
return None
if _tool_results(body):
return (
"final",
"Step 2: the listing confirms it.",
" — confirmed by the listing.",
)
if not body.get("tools"):
return None
return (
"midround",
"Step 1: I think I know the answer — let me check the documents.",
"The reference server is ref-server-1.",
)
def _ls_teach_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
"""Classify a phase-72 LS-TEACH request (see the module docstring).
@@ -4226,6 +4350,96 @@ def _tool_call_stream(
yield "data: [DONE]\n\n"
def _midround_tool_stream(
thinking: str,
mid_round_delta: str,
name: str,
arguments: dict[str, Any],
call_id: str,
) -> Any:
"""SSE frames for one MID-ROUND tool completion (phase 161, task
04 — the stays-open contract's wire shape).
The three-part ordering the owner's 2026-10-05 TODO describes:
the ``reasoning_content`` run FIRST (the 12-char
``_tool_call_stream`` convention — the 0.02 s cadence, the
aipi wire convention: reasoning before content), then the
MID-ROUND ``content`` delta (the SAME 12-char / 0.02 s
convention — the React-style partial answer, the content stream
BEFORE the tool call), and ONLY then the ``_tool_call_stream``
tool-call frames (the deterministic 16-char argument split, the
0.1 s pacing) + ``finish_reason: "tool_calls"`` + ``[DONE]``.
The app accumulates exactly this (``app/rag/llm.py`` — the
content-before-tools flush: the mid-round content reaches the
client as a ``delta`` frame BEFORE the ``tool`` frame; a rare
stream with both content and a tool call keeps the content AND
runs the tool, ``app/rag/agent.py``), so the client sees
thinking → mid-round delta → tool call → next round — the D2
ordering the stays-open container contract needs.
"""
model = "turbo"
chunk_id = f"chatcmpl-{uuid.uuid4()}"
for piece in re.findall(r".{1,12}", thinking, re.S):
payload = {
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [
{"index": 0, "delta": {"reasoning_content": piece}, "finish_reason": None}
],
}
yield f"data: {json_dumps(payload)}\n\n"
time.sleep(0.02)
for piece in re.findall(r".{1,12}", mid_round_delta, re.S):
payload = {
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{"index": 0, "delta": {"content": piece}, "finish_reason": None}],
}
yield f"data: {json_dumps(payload)}\n\n"
time.sleep(0.02)
raw_args = json_dumps(arguments) if arguments else "{}"
frags = [raw_args[i : i + 16] for i in range(0, len(raw_args), 16)] or ["{}"]
for i, frag in enumerate(frags):
tc: dict[str, Any] = {"index": 0, "function": {"arguments": frag}}
delta: dict[str, Any] = {"tool_calls": [tc]}
if i == 0:
tc = {
"index": 0,
"id": call_id,
"type": "function",
"function": {"name": name, "arguments": frag},
}
delta = {"role": "assistant", "tool_calls": [tc]}
payload = {
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{"index": 0, "delta": delta, "finish_reason": None}],
}
yield f"data: {json_dumps(payload)}\n\n"
time.sleep(0.1)
yield (
"data: "
+ json_dumps(
{
"id": chunk_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
}
)
+ "\n\n"
)
yield "data: [DONE]\n\n"
@app.post("/v1/chat/completions")
def chat_completions(body: dict[str, Any]) -> Any:
# Phase 123 (task 04): the OBSERVATIONAL capture (the ring buffer —
@@ -4839,6 +5053,46 @@ def chat_completions(body: dict[str, Any]) -> Any:
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
# content stream BEFORE the tool call, the exact ordering
# the owner's 2026-10-05 TODO describes), then the tool
# call; the follow-up request thinks + the closing
# ``delta`` + the content finish (see the module
# docstring). Checked BEFORE the phase-158 check: the
# phrases share only ``think out loud`` and NEITHER is a
# substring of the other (verified 2026-10-06), and no
# existing E2E question carries the phrase (verified
# 2026-10-06: every other suite is unaffected) — the
# more-specific-phrase-wins convention (the SLOW_PRETOKEN /
# THINK_PARAS / SCAFFOLD_ALWAYS precedent). The trigger
# needs the ``<tools>`` section, so deflected turns never
# hit it.
midround = _reasoning_midround_flow(body)
if midround is not None:
if midround[0] == "midround":
# The mid-round ordering: the thinking run, then the
# MID-ROUND content delta, then the tool call — the
# ``_midround_tool_stream`` three-part shape.
stream = _midround_tool_stream(
midround[1],
midround[2],
"ls",
{},
"call_0",
)
else: # "final" — think, the closing delta, the content finish
stream = _sse_stream(
midround[2],
0.0,
thinking=midround[1],
)
return StreamingResponse(
stream,
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Phase 158 (task 04 — the thinking/tool-call timeline
# story): the deterministic INTERLEAVED flow — a DISTINCT
# reasoning run ahead of every tool call and ahead of the