phase: 161_reasoning_bubble
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user