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
|
||||
|
||||
@@ -0,0 +1,881 @@
|
||||
"""Phase 161 E2E (Playwright, mock-only): ONE live-open, condensed
|
||||
reasoning bubble (owner TODO 2026-10-05 — "the reasoning process
|
||||
should be one collapsible section that stays open during the
|
||||
reasoning process … This new thinking/tool calling bubble should only
|
||||
collapse once the final response has been returned and the session has
|
||||
stopped").
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_reasoning_bubble.py -v --no-cov
|
||||
|
||||
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the story's
|
||||
gate is the deterministic marker flow in ``tests/e2e/mock_llm.py``
|
||||
(user message contains ``think out loud, answer, then check your
|
||||
work`` (``REASONING_MIDROUND_TRIGGER``) **and** the system prompt
|
||||
carries the ``<tools>`` section of the HIGH prompt): the
|
||||
MID-ROUND flow — a ``reasoning_content`` run (``Step 1``), then a
|
||||
MID-ROUND ``content`` delta (the React-style partial answer — the
|
||||
content stream BEFORE the tool call, the exact mid-round ordering the
|
||||
TODO describes), then the ``ls`` tool call; the follow-up request
|
||||
(thinking ``Step 2`` + the closing delta + the content finish — no
|
||||
further tool call). The client renders the ENTIRE per-run timeline
|
||||
inside ONE ``details.reasoning`` container (D1): open from creation
|
||||
until the turn settles (D2 — it STAYS OPEN through a mid-round
|
||||
delta; only ``done`` / stop / the failed-turn funnel fold it),
|
||||
condensed in its bounded 320px ``.reasoning-body`` live window (D3),
|
||||
the summary carrying the D6 status literals (``Thinking…`` /
|
||||
``Calling <name>…`` live, ``N steps`` at rest). The restore path and
|
||||
the shared page render the SAME container COLLAPSED (D5) with the
|
||||
at-rest step count (D6). A turn with no thinking and no tools creates
|
||||
NO container (D1/D4 — the byte-identical DOM).
|
||||
|
||||
KB fixture — mirrored from ``test_thinking_timeline.py`` (the
|
||||
deterministic two-document pair): the marker question cosines
|
||||
≈0.595 against the retrievable document (well past the E2E 0.30
|
||||
threshold) plus FTS hits, so the turn is solidly grounded (HIGH
|
||||
prompt → ``<tools>``).
|
||||
|
||||
Test → phase mapping (Playwright Mapping Rule):
|
||||
1. ``test_container_open_during_the_turn`` — D1/D3: during the turn
|
||||
(a predicate over the mock's 0.1 s pacing window — the house
|
||||
deadline-poll pattern) — ONE ``details.reasoning`` exists, is ``open``, contains a per-run
|
||||
block NESTED inside its ``.reasoning-body`` (the phase-158
|
||||
direct-children layout is superseded), and its
|
||||
``.reasoning-status`` carries a live suffix (``Thinking…`` or
|
||||
``Calling …``). The D3 computed-style pin lives here too: the
|
||||
``.reasoning-body``'s computed ``max-height`` ≤ 320px +
|
||||
``overflow-y: auto`` (the bounded-window contract — the CSS
|
||||
verified in the browser, not just in source).
|
||||
2. ``test_container_stays_open_past_a_mid_round_delta`` — D2 (the
|
||||
TODO's core): while the answer bubble is NON-EMPTY (the mid-round
|
||||
delta rendered) AND the turn is not settled (the in-flight
|
||||
``Stop`` control), the container is STILL ``open`` with a run
|
||||
visible.
|
||||
3. ``test_container_folds_at_done`` — D2/D5/D6: after ``done`` the
|
||||
container has NO ``open`` attribute, its summary is visible, EVERY
|
||||
inner run has no ``open`` attribute (the phase-158 fold-at-rest
|
||||
contract preserved inside the fold), and the status is ``3 steps``
|
||||
(the flow's contiguous-run count — ``thinking (Step 1)``,
|
||||
``tool (ls)``, ``thinking (Step 2)`` — the D6 live-settle
|
||||
literal); the bubble carries the mid-round + closing answer.
|
||||
4. ``test_restored_container_collapsed_with_step_count`` — D5/D6: a
|
||||
same-context RELOAD renders ONE ``details.reasoning`` (NO ``open``
|
||||
attribute) around the same per-run timeline, with the status
|
||||
``3 steps`` (the D6 at-rest count — the restore's incremental
|
||||
count matches the live ``segRuns.length`` semantics).
|
||||
5. ``test_shared_page_container_collapsed`` — D5 (the phase-51 save +
|
||||
share flow): a FRESH context on ``/shared/<token>`` renders ONE
|
||||
``details.reasoning`` (collapsed) with the same timeline + step
|
||||
count (the ``shared.js`` parity — ``addSegments``).
|
||||
6. ``test_no_reasoning_turn_has_no_container`` — D1/D4: a plain
|
||||
answer question renders ZERO ``details.reasoning`` (the
|
||||
byte-identical DOM); a plain ``use your tools`` question (NO
|
||||
thinking trigger) DOES create the container (the tool frames
|
||||
create it), wrapping the ONE phase-117 disclosure — open live
|
||||
(a deadline-poll window over the 0.1 s tool-frame pacing),
|
||||
folded at ``done`` — with the status
|
||||
``1 steps`` (the D6 uniform literal — the no-thinking turn's
|
||||
single tool run; the phase-117 single-disclosure behavior
|
||||
preserved, now wrapped).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from playwright.sync_api import Browser, BrowserContext, Page, expect
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.models import Chunk, Document, GitSource
|
||||
from e2e.auth_helpers import login
|
||||
from tests.e2e.mock_llm import embed_text
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fixture documents (deterministic, token-controlled — mirrored from
|
||||
# test_thinking_timeline.py so the gate is byte-stable)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
SEED_SOURCE = "Homelab"
|
||||
SEED_PATH = "aws-route53.md"
|
||||
SEED_SP = f"{SEED_SOURCE}/{SEED_PATH}"
|
||||
|
||||
READ_SOURCE = "Deployments"
|
||||
READ_PATH = "example-record-file.json"
|
||||
READ_SP = f"{READ_SOURCE}/{READ_PATH}"
|
||||
|
||||
#: The retrievable document: references the JSON file but never
|
||||
#: includes it. The repeated record-file lines carry the marker
|
||||
#: question's key tokens (aws, route53, hosted, zone, reeselink,
|
||||
#: json, exact, shape) — verified ≈0.595 cosine against the mock's
|
||||
#: embeddings (E2E threshold 0.30) plus FTS hits, so the turn is
|
||||
#: solidly grounded.
|
||||
ROUTE53_CONTENT = (
|
||||
"# AWS Route 53 Notes\n\n"
|
||||
"## Record file\n\n"
|
||||
+ (
|
||||
"The aws route53 hosted zone for reeselink keeps every record in "
|
||||
"reseelink.json — the exact JSON shape of reeselink.json is "
|
||||
"documented in example-record-file.json.\n"
|
||||
)
|
||||
* 10
|
||||
+ "\n## Sync job\n\n"
|
||||
"A cron job pushes reeselink.json to the aws route53 hosted zone "
|
||||
"every fifteen minutes; the diff is applied through the route53 api.\n"
|
||||
)
|
||||
|
||||
#: The referenced document: the exact JSON shape (indexed, catalogued,
|
||||
#: readable — but NO chunks, so retrieval never puts it in context).
|
||||
RECORD_FILE_CONTENT = (
|
||||
'{ "version": 3, "comment": "ReeseLink hosted zone records — the exact '
|
||||
'JSON shape of reeselink.json",\n'
|
||||
' "hosted_zone_id": "Z0RESEELINK01",\n'
|
||||
' "record_sets": [\n'
|
||||
' { "name": "www.reeselink.example", "type": "A", "ttl": 300,\n'
|
||||
' "resource_records": [ { "value": "10.0.0.20" } ] },\n'
|
||||
' { "name": "api.reeselink.example", "type": "CNAME", "ttl": 300,\n'
|
||||
' "resource_records": [ { "value": "www.reeselink.example" } ] }\n'
|
||||
" ]\n"
|
||||
"}\n"
|
||||
)
|
||||
|
||||
#: The phase-161 marker question (``REASONING_MIDROUND_TRIGGER`` + the
|
||||
#: KB topic — the ≈0.595 cosine keeps the turn grounded over the
|
||||
#: mock-calibrated E2E threshold).
|
||||
MIDROUND_QUESTION = (
|
||||
"Think out loud, answer, then check your work: what is the exact "
|
||||
"JSON shape of reeselink.json for my aws route53 hosted zone?"
|
||||
)
|
||||
|
||||
#: The NO-thinking tool control — the plain ``use your tools`` marker
|
||||
#: (``test_thinking_timeline.py``'s question, mirrored so the 3-call
|
||||
#: flow is the phase-37/117 one).
|
||||
PLAIN_TOOLS_QUESTION = (
|
||||
"Use your tools: what is the exact JSON shape of reeselink.json "
|
||||
"for my aws route53 hosted zone?"
|
||||
)
|
||||
|
||||
#: The plain-answer control (NO trigger at all — the generic
|
||||
#: ``compose_answer`` path: no thinking, no tools → NO container,
|
||||
#: the byte-identical DOM — D1/D4).
|
||||
PLAIN_ANSWER_QUESTION = (
|
||||
"What is the exact JSON shape of reeselink.json for my aws "
|
||||
"route53 hosted zone?"
|
||||
)
|
||||
|
||||
#: The mock's mid-round delta + the closing delta — the turn's full
|
||||
#: answer (byte-stable: the closing delta concatenates onto the
|
||||
#: mid-round delta).
|
||||
MIDROUND_DELTA = "The reference server is ref-server-1."
|
||||
CLOSING_DELTA = " — confirmed by the listing."
|
||||
MIDROUND_ANSWER = MIDROUND_DELTA + CLOSING_DELTA
|
||||
|
||||
#: The mock's two DISTINCT reasoning runs (the ``Step N`` markers —
|
||||
#: their distinctness proves the runs are separate blocks, not one
|
||||
#: lump).
|
||||
STEP1_MARKER = "Step 1"
|
||||
STEP2_MARKER = "Step 2"
|
||||
|
||||
#: The plain-tools flow's answer prefix (the phase-37 shape —
|
||||
#: ``Read <source/path>.``).
|
||||
ANSWER_PREFIX = f"Read {READ_SP}."
|
||||
|
||||
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# DB seeding (TRUNCATE-then-seed, mirrored from test_thinking_timeline.py)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed(db: Session) -> None:
|
||||
"""The two-document pair (see the module docstring), plus the
|
||||
phase-94 registry rows: both sources registered, ``Deployments``
|
||||
FIRST (the operator's ``BOR_GIT_SOURCES`` is ignored — a
|
||||
non-empty table wins). COMMIT between the inserts (not flush):
|
||||
``added_at`` is ``server_default now()`` and the tie-break is the
|
||||
random uuid ``id`` — one-transaction rows order nondeterministically."""
|
||||
db.add(GitSource(url=READ_SOURCE, kind="local"))
|
||||
db.commit()
|
||||
db.add(GitSource(url=SEED_SOURCE, kind="local"))
|
||||
md = Document(
|
||||
source=SEED_SOURCE,
|
||||
path=SEED_PATH,
|
||||
full_path=f"/tmp/{SEED_PATH}",
|
||||
title="AWS Route 53 Notes",
|
||||
content=ROUTE53_CONTENT,
|
||||
content_hash=hashlib.sha256(ROUTE53_CONTENT.encode()).hexdigest(),
|
||||
indexed_at=datetime.now(UTC),
|
||||
created_at=datetime(2024, 6, 15, tzinfo=UTC),
|
||||
)
|
||||
db.add(md)
|
||||
db.flush()
|
||||
# One chunk carrying the mock's own embedding → genuine token
|
||||
# overlap between the marker question and this document.
|
||||
db.add(
|
||||
Chunk(
|
||||
document_id=md.id,
|
||||
position=0,
|
||||
content=ROUTE53_CONTENT,
|
||||
embedding=embed_text(ROUTE53_CONTENT),
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
Document(
|
||||
source=READ_SOURCE,
|
||||
path=READ_PATH,
|
||||
full_path=f"/tmp/{READ_PATH}",
|
||||
title="Example Record File",
|
||||
content=RECORD_FILE_CONTENT,
|
||||
content_hash=hashlib.sha256(RECORD_FILE_CONTENT.encode()).hexdigest(),
|
||||
indexed_at=datetime.now(UTC),
|
||||
created_at=datetime(2024, 6, 15, tzinfo=UTC),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _reset_db(seed: Callable[[Session], None] | None = None) -> None:
|
||||
"""Truncate the KB (plus the prompt-shaping tables and the
|
||||
phase-55 auto-save rows), then re-seed — the E2E isolation
|
||||
pattern. ``steering_notes`` / ``kb_overview`` are truncated too,
|
||||
so the HIGH prompt is exactly ``<relevance>`` + ``<documents>`` +
|
||||
``<tools>`` regardless of leftovers — byte-stable prompts,
|
||||
byte-stable answers."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text(
|
||||
"TRUNCATE chunks, documents, query_log, "
|
||||
"steering_notes, kb_overview, saved_chats, git_sources"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
if seed is not None:
|
||||
seed(db)
|
||||
db.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Page helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: The D1/D3 live predicate — the LAST brain message's container
|
||||
#: exists, is ``open``, NESTS a per-run block in its
|
||||
#: ``.reasoning-body`` (the phase-158 direct-children layout is
|
||||
#: superseded), and its status carries a LIVE suffix (``Thinking…``
|
||||
#: or ``Calling …`` — the D6 live literals).
|
||||
CONTAINER_LIVE_JS = """
|
||||
() => {
|
||||
const wraps = document.querySelectorAll('#messages .msg.brain');
|
||||
const wrap = wraps.length ? wraps[wraps.length - 1] : null;
|
||||
if (!wrap) return false;
|
||||
const cont = wrap.querySelector('details.reasoning');
|
||||
if (!cont || !cont.hasAttribute('open')) return false;
|
||||
const status = cont.querySelector('.reasoning-status');
|
||||
const st = status ? status.textContent : '';
|
||||
const live = st === 'Thinking…' || st.indexOf('Calling ') === 0;
|
||||
if (!live) return false;
|
||||
return !!cont.querySelector(
|
||||
'.reasoning-body details.thinking, ' +
|
||||
'.reasoning-body details.tool-calls-disclosure');
|
||||
}
|
||||
"""
|
||||
|
||||
#: The D2 predicate (test 2's core) — the answer bubble is NON-EMPTY
|
||||
#: (the mid-round delta has rendered) AND the turn is NOT settled
|
||||
#: (the in-flight ``Stop`` control — the settled label is ``Send``)
|
||||
#: AND the container is STILL ``open`` with a run nested in its
|
||||
#: ``.reasoning-body`` (the TODO's "only collapse once the final
|
||||
#: response has been returned and the session has stopped").
|
||||
MIDROUND_STILL_OPEN_JS = """
|
||||
() => {
|
||||
const wraps = document.querySelectorAll('#messages .msg.brain');
|
||||
const wrap = wraps.length ? wraps[wraps.length - 1] : null;
|
||||
if (!wrap) return false;
|
||||
const body = wrap.querySelector('.msg-body');
|
||||
const bubble = body ? body.querySelector('.bubble') : null;
|
||||
if (!bubble || bubble.textContent === '') return false;
|
||||
const label = document.querySelector('#send-label');
|
||||
if (!label || label.textContent !== 'Stop') return false;
|
||||
const cont = wrap.querySelector('details.reasoning');
|
||||
if (!cont || !cont.hasAttribute('open')) return false;
|
||||
return !!cont.querySelector(
|
||||
'.reasoning-body details.thinking, ' +
|
||||
'.reasoning-body details.tool-calls-disclosure');
|
||||
}
|
||||
"""
|
||||
|
||||
#: The no-thinking live predicate (test 6's tool half) — the tool
|
||||
#: frames created the container; it is ``open`` with a
|
||||
#: ``.tool-calls-disclosure`` nested in its ``.reasoning-body``
|
||||
#: (the phase-117 disclosure, now wrapped — D1 for tool-only turns).
|
||||
TOOL_ONLY_LIVE_JS = """
|
||||
() => {
|
||||
const wraps = document.querySelectorAll('#messages .msg.brain');
|
||||
const wrap = wraps.length ? wraps[wraps.length - 1] : null;
|
||||
if (!wrap) return false;
|
||||
const cont = wrap.querySelector('details.reasoning');
|
||||
if (!cont || !cont.hasAttribute('open')) return false;
|
||||
return !!cont.querySelector(
|
||||
'.reasoning-body details.tool-calls-disclosure');
|
||||
}
|
||||
"""
|
||||
|
||||
#: The D3 computed-style pin — the ``.reasoning-body``'s computed
|
||||
#: ``max-height`` (≤ 320px) + ``overflow-y`` (the bounded-window
|
||||
#: contract, verified in the browser, not just in source).
|
||||
REASONING_BODY_STYLE_JS = """
|
||||
() => {
|
||||
const wraps = document.querySelectorAll('#messages .msg.brain');
|
||||
const wrap = wraps.length ? wraps[wraps.length - 1] : null;
|
||||
if (!wrap) return null;
|
||||
const el = wrap.querySelector('details.reasoning .reasoning-body');
|
||||
if (!el) return null;
|
||||
const cs = getComputedStyle(el);
|
||||
return { maxH: cs.maxHeight, ovy: cs.overflowY };
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _submit(page: Page, question: str) -> None:
|
||||
page.fill("#message-input", question)
|
||||
page.click("#send-btn")
|
||||
# The user bubble lands synchronously with the submit handler.
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
|
||||
|
||||
|
||||
def _wait_settled(page: Page) -> None:
|
||||
"""The turn is complete: answer text in the bubble, button
|
||||
recovered (phase 48 — the in-flight button is the enabled Stop
|
||||
control, so the label assertion carries the settle wait)."""
|
||||
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("", timeout=30_000)
|
||||
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||||
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Phase-51 save + share flow (the test_thinking_timeline.py pattern —
|
||||
# the auto-saved row is shared from the chat page; the row is
|
||||
# deleted in a finally)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _admin_cookies(page: Page) -> dict[str, str]:
|
||||
return {
|
||||
c["name"]: c["value"]
|
||||
for c in page.context.cookies()
|
||||
if "name" in c and "value" in c
|
||||
}
|
||||
|
||||
|
||||
def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]:
|
||||
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
|
||||
assert r.status_code == 200
|
||||
return r.json()["chats"]
|
||||
|
||||
|
||||
def _auto_title(question: str) -> str:
|
||||
"""The phase-50 auto-title convention: the first question,
|
||||
whitespace-collapsed, capped at 120 chars."""
|
||||
return " ".join(question.split())[:120]
|
||||
|
||||
|
||||
def _find_row(rows: list[dict[str, Any]], title: str) -> dict[str, Any] | None:
|
||||
return next((c for c in rows if c["title"] == title), None)
|
||||
|
||||
|
||||
def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
|
||||
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
|
||||
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
|
||||
|
||||
|
||||
def _wait_saved_row(
|
||||
app_url: str,
|
||||
cookies: dict[str, str],
|
||||
title: str,
|
||||
messages: int = 2,
|
||||
) -> dict[str, Any]:
|
||||
"""Wait for the auto-saved row (phase 55 — auto-saves are SILENT,
|
||||
so there is no status line to wait on). The upsert is
|
||||
fire-and-forget from the UI's point of view, so poll the admin
|
||||
list until the row with the conversation's auto-title appears
|
||||
with the expected message count."""
|
||||
deadline = time.monotonic() + 15
|
||||
last: dict[str, Any] | None = None
|
||||
while time.monotonic() < deadline:
|
||||
last = _find_row(_chats(app_url, cookies), title)
|
||||
if last is not None and last["message_count"] >= messages:
|
||||
return last
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
|
||||
|
||||
|
||||
def _grant_clipboard(page: Page, app_url: str) -> None:
|
||||
"""Grant the async-clipboard permissions on the admin context
|
||||
(headless Chromium requires the grant before ``writeText``
|
||||
resolves — without it the inline-link fallback fires)."""
|
||||
page.context.grant_permissions(
|
||||
["clipboard-read", "clipboard-write"], origin=app_url
|
||||
)
|
||||
|
||||
|
||||
def _click_share_and_assert_status(page: Page, app_url: str) -> None:
|
||||
"""Press the chat page's Share pill and pin the outcome on the
|
||||
live region (the assertion branches on the API's availability —
|
||||
the test_share_chat.py convention)."""
|
||||
page.locator("#share-chat-btn").click()
|
||||
if page.evaluate("() => !!navigator.clipboard"):
|
||||
expect(page.locator("#send-status")).to_have_text(
|
||||
"Share link copied.", timeout=15_000
|
||||
)
|
||||
else:
|
||||
expect(page.locator("#send-status")).to_have_text(
|
||||
"Share link ready — copy it from the field.", timeout=15_000
|
||||
)
|
||||
field = page.locator(".share-link-fallback")
|
||||
expect(field).to_be_visible()
|
||||
href = field.get_attribute("href")
|
||||
assert href is not None
|
||||
assert href.startswith(app_url)
|
||||
assert SHARE_URL_RE.fullmatch(href.removeprefix(app_url))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. D1/D3: the live turn renders ONE open container with the runs
|
||||
# NESTED inside its bounded .reasoning-body live window
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_container_open_during_the_turn(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
_reset_db(_seed)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
page.fill("#message-input", MIDROUND_QUESTION)
|
||||
page.click("#send-btn")
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(MIDROUND_QUESTION)
|
||||
|
||||
# D1/D3: during the turn — the mock's 0.1 s pacing window (the
|
||||
# 0.1 s tool frames + the thinking/delta streams) — the
|
||||
# container exists, is OPEN, NESTS a per-run block in its
|
||||
# .reasoning-body (the phase-158 direct-children layout is
|
||||
# superseded by the container nesting), and its status carries a
|
||||
# LIVE suffix (Thinking… / Calling …). The house deadline-poll
|
||||
# pattern (a 50 ms stride over the ~2 s turn) comfortably
|
||||
# samples the open window.
|
||||
seen_live = False
|
||||
deadline = time.monotonic() + 20.0
|
||||
while time.monotonic() < deadline:
|
||||
if page.evaluate(CONTAINER_LIVE_JS):
|
||||
seen_live = True
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert seen_live, (
|
||||
"no open reasoning container with a NESTED per-run block "
|
||||
"and a live status suffix was observed during the turn "
|
||||
"(the D1/D3 live window — mid-round turn)"
|
||||
)
|
||||
|
||||
# The turn settles (the D2 fold — test 3's contract).
|
||||
_wait_settled(page)
|
||||
|
||||
# The D3 computed-style pin (folded in per the phase overview):
|
||||
# the .reasoning-body is the ONE bounded live window — the
|
||||
# computed max-height is ≤ 320px and overflow-y is auto (the CSS
|
||||
# verified in the browser, not just in source).
|
||||
style = page.evaluate(REASONING_BODY_STYLE_JS)
|
||||
assert style is not None, "the .reasoning-body must exist after the turn"
|
||||
max_h = style["maxH"]
|
||||
assert max_h != "none", f"the .reasoning-body must be bounded, got {max_h!r}"
|
||||
if max_h.endswith("px"):
|
||||
assert float(max_h[:-2]) <= 320, f"max-height must be ≤ 320px, got {max_h!r}"
|
||||
assert style["ovy"] == "auto", (
|
||||
f"the .reasoning-body must be overflow-y: auto, got {style['ovy']!r}"
|
||||
)
|
||||
|
||||
# The turn's full answer (the mid-round delta + the closing
|
||||
# delta — the byte-stable concatenation).
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MIDROUND_ANSWER)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. D2 (the TODO's core): the container STAYS OPEN through a
|
||||
# mid-round delta — it folds only once the final response has
|
||||
# been returned and the session has stopped
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_container_stays_open_past_a_mid_round_delta(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
_reset_db(_seed)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
page.fill("#message-input", MIDROUND_QUESTION)
|
||||
page.click("#send-btn")
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(MIDROUND_QUESTION)
|
||||
|
||||
# D2: while the answer bubble is NON-EMPTY (the mid-round delta
|
||||
# has rendered — the React-style partial answer) AND the turn is
|
||||
# NOT settled (the in-flight Stop control), the container is
|
||||
# STILL ``open`` with a run nested in its .reasoning-body. The
|
||||
# house deadline-poll pattern (a 50 ms stride over the ~1 s
|
||||
# window — the tool call + the app's ls execution + the
|
||||
# follow-up request) comfortably samples it.
|
||||
seen_still_open = False
|
||||
deadline = time.monotonic() + 20.0
|
||||
while time.monotonic() < deadline:
|
||||
if page.evaluate(MIDROUND_STILL_OPEN_JS):
|
||||
seen_still_open = True
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert seen_still_open, (
|
||||
"the container was not observed OPEN while the answer bubble "
|
||||
"was non-empty and the turn was in flight (the D2 window — "
|
||||
"the mid-round delta must NOT fold the container)"
|
||||
)
|
||||
|
||||
# The turn settles with the full answer (the fold is test 3's
|
||||
# contract — the settle proves the window ended in a completed
|
||||
# turn, not a failure).
|
||||
_wait_settled(page)
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MIDROUND_ANSWER)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. D2/D5/D6: the container folds at done — every inner run folds
|
||||
# with it, and the status reads the live run count
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_container_folds_at_done(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
_reset_db(_seed)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
_submit(page, MIDROUND_QUESTION)
|
||||
_wait_settled(page)
|
||||
|
||||
# D2/D5: at rest the container has NO ``open`` attribute (the
|
||||
# done settle folded it) — and EVERY inner run has no ``open``
|
||||
# attribute either (the phase-158 fold-at-rest contract
|
||||
# preserved inside the fold).
|
||||
expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0)
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.reasoning details.thinking[open]")
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning details.tool-calls-disclosure[open]"
|
||||
)
|
||||
).to_have_count(0)
|
||||
|
||||
# The collapsed summary is still visible (the bubble stays
|
||||
# readable at rest).
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.reasoning > .reasoning-summary").first
|
||||
).to_be_visible()
|
||||
|
||||
# D6: the at-rest status is the live run count — the flow's 3
|
||||
# contiguous runs (thinking Step 1, tool ls, thinking Step 2).
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.reasoning .reasoning-status").first
|
||||
).to_have_text("3 steps")
|
||||
|
||||
# The per-run timeline inside the fold: the two DISTINCT
|
||||
# ``Step N`` thinking runs + the one tool line (the phase-37 ls
|
||||
# echo — no-arg top-level listing), in arrival order.
|
||||
texts = page.locator(
|
||||
"#messages .msg.brain details.reasoning .thinking-text"
|
||||
)
|
||||
expect(texts).to_have_count(2)
|
||||
expect(texts.nth(0)).to_contain_text(STEP1_MARKER)
|
||||
expect(texts.nth(1)).to_contain_text(STEP2_MARKER)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning .tool-calls-disclosure"
|
||||
)
|
||||
).to_have_count(1)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning .tool-call"
|
||||
)
|
||||
).to_have_count(1)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning .tool-calls-summary"
|
||||
)
|
||||
).to_have_text("Tool call (1)")
|
||||
|
||||
# The turn's full answer (mid-round delta + closing delta).
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MIDROUND_ANSWER)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. D5/D6: a RELOAD restores the SAME container COLLAPSED around the
|
||||
# per-run timeline, with the at-rest step count
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_restored_container_collapsed_with_step_count(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
_reset_db(_seed)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
_submit(page, MIDROUND_QUESTION)
|
||||
_wait_settled(page)
|
||||
|
||||
# RELOAD (same context) — the phase-14 record restores through
|
||||
# the restore path (restoreTimeline — the segments take
|
||||
# precedence).
|
||||
page.reload()
|
||||
expect(page.locator("#empty-state")).to_be_hidden(timeout=30_000)
|
||||
|
||||
# D5: ONE ``details.reasoning`` per restored brain message, NO
|
||||
# ``open`` attribute (the restore renders collapsed; the live
|
||||
# open state is exclusively the live turn's).
|
||||
expect(page.locator("#messages .msg.brain details.reasoning")).to_have_count(1)
|
||||
expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0)
|
||||
|
||||
# The SAME per-run timeline, nested in the container (all
|
||||
# collapsed — the D5 restore convention).
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.reasoning details.thinking")
|
||||
).to_have_count(2)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning details.thinking[open]"
|
||||
)
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning details.tool-calls-disclosure"
|
||||
)
|
||||
).to_have_count(1)
|
||||
texts = page.locator(
|
||||
"#messages .msg.brain details.reasoning .thinking-text"
|
||||
)
|
||||
expect(texts.nth(0)).to_contain_text(STEP1_MARKER)
|
||||
expect(texts.nth(1)).to_contain_text(STEP2_MARKER)
|
||||
|
||||
# D6: the at-rest step count — the restore's INCREMENTAL count
|
||||
# (each thinking entry + one per contiguous tool group) matches
|
||||
# the live segRuns.length semantics: 3 contiguous runs.
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.reasoning .reasoning-status").first
|
||||
).to_have_text("3 steps")
|
||||
|
||||
# The restored answer (the restore is synchronous at boot).
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
|
||||
MIDROUND_ANSWER, timeout=30_000
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. D5: the shared page (phase-51 save + share flow) renders the
|
||||
# SAME container COLLAPSED — the addSegments parity
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_shared_page_container_collapsed(
|
||||
page: Page,
|
||||
browser: Browser,
|
||||
app_url: str,
|
||||
mock_llm: int,
|
||||
db_ready: None,
|
||||
) -> None:
|
||||
_reset_db(_seed)
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
login(page, app_url, next="/")
|
||||
expect(page).to_have_url(app_url + "/", timeout=30_000)
|
||||
|
||||
_submit(page, MIDROUND_QUESTION)
|
||||
_wait_settled(page)
|
||||
|
||||
# The phase-51 flow: the conversation is AUTO-SAVED by the time
|
||||
# the answer settles (phase 55) — the Share click shares the
|
||||
# linked row.
|
||||
cookies = _admin_cookies(page)
|
||||
_grant_clipboard(page, app_url)
|
||||
_wait_saved_row(app_url, cookies, _auto_title(MIDROUND_QUESTION))
|
||||
share = page.locator("#share-chat-btn")
|
||||
expect(share).to_be_visible()
|
||||
_click_share_and_assert_status(page, app_url)
|
||||
row = _find_row(_chats(app_url, cookies), _auto_title(MIDROUND_QUESTION))
|
||||
assert row is not None and row.get("share_url"), (
|
||||
"the shared row must carry a share_url"
|
||||
)
|
||||
share_url: str = row["share_url"]
|
||||
assert SHARE_URL_RE.fullmatch(share_url), f"bad share_url shape: {share_url}"
|
||||
created: str = row["id"]
|
||||
anon_ctx: BrowserContext | None = None
|
||||
try:
|
||||
# A FRESH context: a separate session with no cookies — the
|
||||
# guest's only credential is the token in the URL.
|
||||
anon_ctx = browser.new_context()
|
||||
anon = anon_ctx.new_page()
|
||||
anon.set_default_timeout(30_000)
|
||||
anon.goto(app_url + share_url)
|
||||
|
||||
# The title = the auto-title (the shared chat's h1).
|
||||
expect(anon.locator("#shared-title")).to_have_text(
|
||||
_auto_title(MIDROUND_QUESTION)
|
||||
)
|
||||
|
||||
# D5 (the shared-page half): ONE ``details.reasoning``, NO
|
||||
# ``open`` attribute (the pure-render convention — the
|
||||
# shared page is a finished conversation), wrapping the SAME
|
||||
# per-run timeline, all collapsed.
|
||||
expect(anon.locator("#messages .msg.brain details.reasoning")).to_have_count(1)
|
||||
expect(
|
||||
anon.locator("#messages .msg.brain details.reasoning[open]")
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
anon.locator(
|
||||
"#messages .msg.brain details.reasoning details.thinking[open]"
|
||||
)
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
anon.locator(
|
||||
"#messages .msg.brain details.reasoning "
|
||||
"details.tool-calls-disclosure[open]"
|
||||
)
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
anon.locator("#messages .msg.brain details.reasoning details.thinking")
|
||||
).to_have_count(2)
|
||||
expect(
|
||||
anon.locator(
|
||||
"#messages .msg.brain details.reasoning "
|
||||
"details.tool-calls-disclosure"
|
||||
)
|
||||
).to_have_count(1)
|
||||
anon_texts = anon.locator(
|
||||
"#messages .msg.brain details.reasoning .thinking-text"
|
||||
)
|
||||
expect(anon_texts.nth(0)).to_contain_text(STEP1_MARKER)
|
||||
expect(anon_texts.nth(1)).to_contain_text(STEP2_MARKER)
|
||||
|
||||
# D6: the SAME at-rest step count (the addSegments parity —
|
||||
# the same incremental count semantics as the restore).
|
||||
expect(
|
||||
anon.locator(
|
||||
"#messages .msg.brain details.reasoning .reasoning-status"
|
||||
).first
|
||||
).to_have_text("3 steps")
|
||||
|
||||
# The shared answer bubble carries the turn's full answer.
|
||||
expect(anon.locator(".msg.brain .bubble").first).to_contain_text(MIDROUND_ANSWER)
|
||||
finally:
|
||||
if anon_ctx is not None:
|
||||
anon_ctx.close()
|
||||
if created:
|
||||
_delete_chat(app_url, cookies, created)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 6. D1/D4: a plain answer turn creates NO container (the
|
||||
# byte-identical DOM); a no-thinking tool turn DOES (the tool
|
||||
# frames create it — the phase-117 disclosure, now wrapped)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_reasoning_turn_has_no_container(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
_reset_db(_seed)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
# (a) The plain answer question (NO trigger — the generic
|
||||
# compose_answer path: no thinking, no tools): D1/D4 — the
|
||||
# byte-identical DOM, ZERO ``details.reasoning`` (and no
|
||||
# thinking blocks / disclosures either).
|
||||
_submit(page, PLAIN_ANSWER_QUESTION)
|
||||
_wait_settled(page)
|
||||
expect(page.locator("#messages .msg.brain details.reasoning")).to_have_count(0)
|
||||
expect(page.locator("#messages .msg.brain details.thinking")).to_have_count(0)
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.tool-calls-disclosure")
|
||||
).to_have_count(0)
|
||||
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("")
|
||||
|
||||
# (b) The plain ``use your tools`` marker (NO thinking trigger —
|
||||
# the mock's tool frames carry no ``reasoning_content``): the
|
||||
# tool frames CREATE the container (D1 for tool-only turns),
|
||||
# wrapping the ONE phase-117 disclosure.
|
||||
page.fill("#message-input", PLAIN_TOOLS_QUESTION)
|
||||
page.click("#send-btn")
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(PLAIN_TOOLS_QUESTION)
|
||||
|
||||
# D1 (the tool-only live half): during the turn — the mock's
|
||||
# 0.1 s pacing window (the 0.1 s tool frames; the 3 phase-37
|
||||
# calls) — the container is ``open`` with the disclosure
|
||||
# NESTED in its .reasoning-body. The house deadline-poll
|
||||
# pattern (a 50 ms stride over the ~1 s window) comfortably
|
||||
# samples it.
|
||||
seen_tool_live = False
|
||||
deadline = time.monotonic() + 20.0
|
||||
while time.monotonic() < deadline:
|
||||
if page.evaluate(TOOL_ONLY_LIVE_JS):
|
||||
seen_tool_live = True
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert seen_tool_live, (
|
||||
"no open reasoning container with the NESTED tool disclosure "
|
||||
"was observed while the turn streamed (the D1 tool-only "
|
||||
"live window)"
|
||||
)
|
||||
|
||||
# The turn settles: the container FOLDS at done (the D2 settle —
|
||||
# the no-thinking turn's single tool run), and the status reads
|
||||
# the D6 UNIFORM literal — 1 run → ``1 steps``.
|
||||
_wait_settled(page)
|
||||
expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0)
|
||||
expect(page.locator("#messages .msg.brain details.reasoning")).to_have_count(1)
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.reasoning .reasoning-status").first
|
||||
).to_have_text("1 steps")
|
||||
|
||||
# The phase-117 single-disclosure behavior, preserved and now
|
||||
# wrapped: ONE disclosure with the 3 ``.tool-call`` lines, zero
|
||||
# thinking blocks, folded at rest.
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning details.tool-calls-disclosure"
|
||||
)
|
||||
).to_have_count(1)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning .tool-call"
|
||||
)
|
||||
).to_have_count(3)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning .tool-calls-summary"
|
||||
)
|
||||
).to_have_text("Tool calls (3)")
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.reasoning details.thinking")
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning details.thinking[open]"
|
||||
)
|
||||
).to_have_count(0)
|
||||
|
||||
# The phase-37 answer echo (the read document reached the model).
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(ANSWER_PREFIX)
|
||||
@@ -164,7 +164,12 @@ def test_thinking_block_streams_open_then_collapses(
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Toggle: after settle the block is closed; the summary re-opens it
|
||||
# (a real keyboard-focusable control) and closes it again
|
||||
# (a real keyboard-focusable control) and closes it again.
|
||||
# Phase 161 (D2): at rest the run sits INSIDE the folded reasoning
|
||||
# container — the container's summary is the at-rest focusable
|
||||
# surface (native <details> nesting semantics: a closed details
|
||||
# hides its content, so the inner summary is focusable only after
|
||||
# the container opens).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -177,8 +182,18 @@ def test_thinking_toggle_after_done(page: Page, app_url: str, seeded_kb: None) -
|
||||
details = last.locator("details.thinking")
|
||||
expect(details).to_have_count(1)
|
||||
expect(details).not_to_have_attribute("open") # auto-collapsed at first token
|
||||
# Phase 161 (D2): the run nests in the reasoning container, which
|
||||
# folds at done (the at-rest focusable surface is the container's
|
||||
# summary — open it to reach the inner run's summary).
|
||||
container = last.locator("details.reasoning")
|
||||
expect(container).to_have_count(1)
|
||||
expect(container).not_to_have_attribute("open")
|
||||
container.locator("summary.reasoning-summary").focus()
|
||||
assert page.evaluate("() => document.activeElement.tagName") == "SUMMARY"
|
||||
container.locator("summary.reasoning-summary").click()
|
||||
expect(container).to_have_attribute("open", "")
|
||||
|
||||
# The summary is a real, keyboard-focusable control.
|
||||
# The inner run's summary is a real, keyboard-focusable control.
|
||||
details.locator("summary").focus()
|
||||
assert page.evaluate("() => document.activeElement.tagName") == "SUMMARY"
|
||||
|
||||
|
||||
@@ -25,7 +25,15 @@ own ``details.thinking`` / ``details.tool-calls-disclosure`` (D1), all
|
||||
above the answer bubble in arrival order (D2 — Option B,
|
||||
owner-chosen), open while the run's frames stream and folded at rest
|
||||
(D5), and persists the run order in the record's ``segments`` array
|
||||
(D4).
|
||||
(D4). Phase 161 (the documented supersession, owner TODO direction
|
||||
2026-10-05): the per-run blocks are NESTED inside ONE
|
||||
``details.reasoning`` container (``.reasoning-body``) that sits
|
||||
before the bubble — open during the turn, folded at done / on
|
||||
restore / on the shared page; the per-run open/fold behavior itself
|
||||
is unchanged. The sequence pin is shape-unchanged, read from the
|
||||
container's ``.reasoning-body`` children (the container is the one
|
||||
new parent — the pre-bubble ``.msg-body`` children are exactly
|
||||
``["details.reasoning"]``).
|
||||
|
||||
KB fixture — mirrored from ``test_agent_document_tools.py`` (the
|
||||
deterministic two-document pair): ``Deployments`` registered FIRST so
|
||||
@@ -37,37 +45,46 @@ prompt → ``<tools>``).
|
||||
|
||||
Test → phase mapping (Playwright Mapping Rule):
|
||||
1. ``test_timeline_order_after_completed_turn`` — D1/D2: after ``done``
|
||||
the brain wrap's ``.msg-body`` children before the bubble are
|
||||
EXACTLY the 7-element arrival-order sequence (4 thinking blocks +
|
||||
3 one-line disclosures, alternating); the four ``.thinking-text``s
|
||||
carry the DISTINCT ``Step N`` markers (distinctness = separate
|
||||
blocks, not one lump); each disclosure summary is ``Tool call
|
||||
(1)``; the answer bubble carries the ``Read <source/path>`` echo.
|
||||
2. ``test_runs_fold_at_rest`` — D5: after ``done`` NO ``.thinking`` /
|
||||
``.tool-calls-disclosure`` has the ``open`` attribute (the settle
|
||||
folded the runs); the summaries are still visible.
|
||||
the pre-bubble ``.msg-body`` children are EXACTLY the reasoning
|
||||
container (the one new parent — phase 161), and its
|
||||
``.reasoning-body`` children are EXACTLY the 7-element
|
||||
arrival-order sequence (4 thinking blocks + 3 one-line
|
||||
disclosures, alternating); the four ``.thinking-text``s carry the
|
||||
DISTINCT ``Step N`` markers (distinctness = separate blocks, not
|
||||
one lump); each disclosure summary is ``Tool call (1)``; the
|
||||
answer bubble carries the ``Read <source/path>`` echo.
|
||||
2. ``test_runs_fold_at_rest`` — D5 (phase 161 D2): after ``done``
|
||||
the container AND every ``.thinking`` /
|
||||
``.tool-calls-disclosure`` inside it has NO ``open`` attribute
|
||||
(the settle folded the runs + the container); the at-rest visible
|
||||
line is the container's ``Reasoning`` summary (``7 steps``);
|
||||
opening it reveals the folded run summaries.
|
||||
3. ``test_live_runs_open_while_streaming`` — D5 (the live half):
|
||||
DURING the turn (the mock's pacing window — the 0.1 s tool frames +
|
||||
the thinking stream, the answer bubble still EMPTY) the active run's
|
||||
block is ``open`` (the live feedback the owner keeps).
|
||||
4. ``test_restored_timeline_and_record`` — the restore path + D4: a
|
||||
same-context RELOAD re-renders the same 7-element sequence (all
|
||||
collapsed), and the localStorage ``bor.chat.v1`` record (phase 14)
|
||||
carries a ``segments`` array of EXACTLY 7 entries in the
|
||||
same-context RELOAD re-renders the same 7-element sequence in ONE
|
||||
collapsed reasoning container (``7 steps``, phase 161 D5/D6), and
|
||||
the localStorage ``bor.chat.v1`` record (phase 14) carries a
|
||||
``segments`` array of EXACTLY 7 entries in the
|
||||
``thinking, tool, ×3`` pattern (the ``Step N`` text on the thinking
|
||||
entries, the ``name``/``argument`` on the tool entries); the legacy
|
||||
``thinking`` (full concatenation) + ``tools`` (3 records) keys are
|
||||
STILL present (the old-client graceful degradation).
|
||||
5. ``test_shared_page_timeline`` — the shared-page parity (phase 51
|
||||
save + share flow): a FRESH context on ``/shared/<token>`` renders
|
||||
the same 7-element sequence, all collapsed (``addSegments``), the
|
||||
``Step N`` markers visible on summary click (expand ONE), the answer
|
||||
bubble carries the ``Read <source/path>`` echo.
|
||||
the same 7-element sequence in ONE collapsed reasoning container
|
||||
(``addSegments``), the ``Step N`` markers visible on container +
|
||||
summary click (expand ONE each), the answer bubble carries the
|
||||
``Read <source/path>`` echo.
|
||||
6. ``test_no_thinking_turn_single_disclosure`` — the run-grouping
|
||||
contract for no-thinking turns (the phase-117 behavior preserved): a
|
||||
plain ``use your tools`` turn (NO thinking trigger) renders EXACTLY
|
||||
ONE ``.tool-calls-disclosure`` (3 ``.tool-call`` lines, summary
|
||||
``Tool calls (3)``) and ZERO ``.thinking``.
|
||||
contract for no-thinking turns (the phase-117 behavior preserved,
|
||||
now wrapped — phase 161): a plain ``use your tools`` turn (NO
|
||||
thinking trigger) renders ONE folded reasoning container (``1
|
||||
steps``) wrapping EXACTLY ONE ``.tool-calls-disclosure`` inside its
|
||||
``.reasoning-body`` (3 ``.tool-call`` lines, summary ``Tool calls
|
||||
(3)``) and ZERO ``.thinking``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -169,9 +186,11 @@ ANSWER_QUOTE = "date: 2024-06-15" + RECORD_FILE_CONTENT[:63]
|
||||
#: lump).
|
||||
STEP_MARKERS = ("Step 1", "Step 2", "Step 3", "Step 4")
|
||||
|
||||
#: The 7-element pre-bubble segment sequence the timeline renders, in
|
||||
#: arrival order (D1: alternating thinking/tool runs — one block per
|
||||
#: run) — tests 1/4 (chat page) and 5 (shared page) pin it.
|
||||
#: The 7-element sequence the timeline renders in the reasoning
|
||||
#: container's ``.reasoning-body`` children, in arrival order (D1:
|
||||
#: alternating thinking/tool runs — one block per run; phase 161: the
|
||||
#: sequence is shape-unchanged — the container is the one new parent)
|
||||
#: — tests 1/4 (chat page) and 5 (shared page) pin it.
|
||||
TIMELINE_SEQ = [
|
||||
"details.thinking",
|
||||
"details.tool-calls-disclosure",
|
||||
@@ -269,10 +288,12 @@ def _reset_db(seed: Callable[[Session], None] | None = None) -> None:
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: The last ``.msg.brain`` wrap's ``.msg-body`` children BEFORE the
|
||||
#: bubble, as ``tag.className`` strings — the pre-bubble segment
|
||||
#: sequence (the timeline's order pin; the ``.msg-meta`` row lands
|
||||
#: AFTER the bubble, so it never enters the sequence).
|
||||
BODY_SEQUENCE_JS = """
|
||||
#: bubble, as ``tag.className`` strings. Phase 161 (D1): EXACTLY ONE
|
||||
#: — the ``details.reasoning`` container (the one new anchor before
|
||||
#: the bubble; the per-run blocks live inside it, not as direct
|
||||
#: children; the ``.msg-meta`` row lands AFTER the bubble, so it
|
||||
#: never enters the sequence).
|
||||
PRE_BUBBLE_JS = """
|
||||
() => {
|
||||
const wrap = document.querySelector('#messages .msg.brain');
|
||||
if (!wrap) return null;
|
||||
@@ -286,6 +307,24 @@ BODY_SEQUENCE_JS = """
|
||||
}
|
||||
"""
|
||||
|
||||
#: The last ``.msg.brain`` wrap's reasoning container's
|
||||
#: ``.reasoning-body`` children, as ``tag.className`` strings — the
|
||||
#: timeline's order pin (phase 161: the 7-element arrival-order
|
||||
#: sequence nested in the container — shape unchanged from phase 158).
|
||||
BODY_SEQUENCE_JS = """
|
||||
() => {
|
||||
const wrap = document.querySelector('#messages .msg.brain');
|
||||
if (!wrap) return null;
|
||||
const body = wrap.querySelector('details.reasoning .reasoning-body');
|
||||
if (!body) return null;
|
||||
const out = [];
|
||||
for (const child of body.children) {
|
||||
out.push(child.tagName.toLowerCase() + '.' + child.className);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
"""
|
||||
|
||||
#: D5's live-open predicate — the active run's block is ``open`` while
|
||||
#: the answer bubble is STILL EMPTY (the turn is in flight; the mock's
|
||||
#: pacing window — the 0.1 s tool frames + the thinking stream — keeps
|
||||
@@ -435,13 +474,19 @@ def test_timeline_order_after_completed_turn(
|
||||
_submit(page, TIMELINE_QUESTION)
|
||||
_wait_settled(page)
|
||||
|
||||
# D1: the pre-bubble segment sequence is EXACTLY the 7-element
|
||||
# arrival-order timeline — thinking, tool, ×3 (one block per
|
||||
# contiguous run; the plain ``use your tools`` flow's 3 calls land
|
||||
# in 3 SEPARATE one-line disclosures, one per thinking run).
|
||||
# Phase 161 (D1): the container is the ONE pre-bubble anchor —
|
||||
# the per-run blocks are no longer direct ``.msg-body`` children.
|
||||
assert page.evaluate(PRE_BUBBLE_JS) == ["details.reasoning"], (
|
||||
"the pre-bubble children must be exactly the reasoning "
|
||||
"container (phase 161 D1)"
|
||||
)
|
||||
# D1: the container's ``.reasoning-body`` sequence is EXACTLY the
|
||||
# 7-element arrival-order timeline — thinking, tool, ×3 (one block
|
||||
# per contiguous run; the plain ``use your tools`` flow's 3 calls
|
||||
# land in 3 SEPARATE one-line disclosures, one per thinking run).
|
||||
seq = page.evaluate(BODY_SEQUENCE_JS)
|
||||
assert seq == TIMELINE_SEQ, (
|
||||
f"the pre-bubble segment sequence is not the timeline: {seq}"
|
||||
f"the in-container segment sequence is not the timeline: {seq}"
|
||||
)
|
||||
|
||||
# D1/D3: the four thinking blocks carry the DISTINCT ``Step N``
|
||||
@@ -482,8 +527,22 @@ def test_runs_fold_at_rest(
|
||||
_submit(page, TIMELINE_QUESTION)
|
||||
_wait_settled(page)
|
||||
|
||||
# D5: at rest NOTHING is open — the ``done`` settle (closeAllRuns)
|
||||
# folded every run (a closed <details> has no ``open`` attribute).
|
||||
# Phase 161 (D2): the container folds at done — the at-rest
|
||||
# visible line is its ``Reasoning`` summary (the D6 settled status
|
||||
# is the run count — 7 runs → "7 steps").
|
||||
container = page.locator("#messages .msg.brain details.reasoning")
|
||||
expect(container).to_have_count(1)
|
||||
expect(container).not_to_have_attribute("open")
|
||||
expect(
|
||||
page.locator("#messages .msg.brain .reasoning-summary").first
|
||||
).to_be_visible()
|
||||
expect(
|
||||
page.locator("#messages .msg.brain .reasoning-status")
|
||||
).to_have_text("7 steps")
|
||||
|
||||
# D5: at rest NOTHING inside is open — the ``done`` settle
|
||||
# (closeAllRuns) folded every run (a closed <details> has no
|
||||
# ``open`` attribute).
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.thinking[open]")
|
||||
).to_have_count(0)
|
||||
@@ -493,8 +552,11 @@ def test_runs_fold_at_rest(
|
||||
)
|
||||
).to_have_count(0)
|
||||
|
||||
# The collapsed summaries are still visible (the timeline stays
|
||||
# readable at rest — 4 "Thinking" + 3 "Tool call (1)" lines).
|
||||
# The inner runs stay in the DOM inside the fold — opening the
|
||||
# container (the native toggle) reveals the 4 "Thinking" + 3
|
||||
# "Tool call (1)" lines (the timeline stays readable at rest).
|
||||
page.locator("#messages .msg.brain .reasoning-summary").click()
|
||||
expect(container).to_have_attribute("open", "")
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.thinking > summary").first
|
||||
).to_be_visible()
|
||||
@@ -566,11 +628,28 @@ def test_restored_timeline_and_record(
|
||||
page.reload()
|
||||
expect(page.locator("#empty-state")).to_be_hidden(timeout=30_000)
|
||||
|
||||
# The restored message renders the SAME 7-element timeline, all
|
||||
# COLLAPSED (D5 — the restore renders collapsed).
|
||||
# Phase 161 (D5/D6): the restore renders ONE collapsed container
|
||||
# around the timeline (the D6 at-rest step count). The pre-bubble
|
||||
# children are exactly the container (the live pin, D1).
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.reasoning")
|
||||
).to_have_count(1)
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.reasoning")
|
||||
).not_to_have_attribute("open")
|
||||
expect(
|
||||
page.locator("#messages .msg.brain .reasoning-status")
|
||||
).to_have_text("7 steps")
|
||||
assert page.evaluate(PRE_BUBBLE_JS) == ["details.reasoning"], (
|
||||
"the restored pre-bubble children must be exactly the "
|
||||
"container (phase 161 D1)"
|
||||
)
|
||||
# The restored message renders the SAME 7-element timeline
|
||||
# (in-container), all COLLAPSED (D5 — the restore renders
|
||||
# collapsed).
|
||||
seq = page.evaluate(BODY_SEQUENCE_JS)
|
||||
assert seq == TIMELINE_SEQ, (
|
||||
f"the restored pre-bubble sequence is not the timeline: {seq}"
|
||||
f"the restored in-container sequence is not the timeline: {seq}"
|
||||
)
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.thinking[open]")
|
||||
@@ -680,12 +759,24 @@ def test_shared_page_timeline(
|
||||
# The title = the auto-title (the shared chat's h1).
|
||||
expect(anon.locator("#shared-title")).to_have_text(_auto_title(TIMELINE_QUESTION))
|
||||
|
||||
# The SAME 7-element timeline renders (the ``addSegments``
|
||||
# parity), all COLLAPSED (D5 — the shared page is a finished
|
||||
# conversation; a closed <details> has no ``open`` attribute).
|
||||
# Phase 161 (D5/D6): the shared page renders ONE collapsed
|
||||
# container (the D6 at-rest step count) around the timeline.
|
||||
expect(
|
||||
anon.locator("#messages .msg.brain details.reasoning")
|
||||
).to_have_count(1)
|
||||
expect(
|
||||
anon.locator("#messages .msg.brain details.reasoning")
|
||||
).not_to_have_attribute("open")
|
||||
expect(
|
||||
anon.locator("#messages .msg.brain .reasoning-status")
|
||||
).to_have_text("7 steps")
|
||||
# The SAME 7-element timeline renders inside it (the
|
||||
# ``addSegments`` parity), all COLLAPSED (D5 — the shared
|
||||
# page is a finished conversation; a closed <details> has no
|
||||
# ``open`` attribute).
|
||||
seq = anon.evaluate(BODY_SEQUENCE_JS)
|
||||
assert seq == TIMELINE_SEQ, (
|
||||
f"the shared pre-bubble sequence is not the timeline: {seq}"
|
||||
f"the shared in-container sequence is not the timeline: {seq}"
|
||||
)
|
||||
expect(
|
||||
anon.locator("#messages .msg.brain details.thinking[open]")
|
||||
@@ -697,7 +788,10 @@ def test_shared_page_timeline(
|
||||
).to_have_count(0)
|
||||
|
||||
# The ``Step N`` markers are visible on a summary click (expand
|
||||
# ONE — the native <summary> is a real focusable toggle).
|
||||
# ONE — the native <summary> is a real focusable toggle; phase
|
||||
# 161: the container's own summary first — the inner runs'
|
||||
# summaries sit inside its closed fold).
|
||||
anon.locator("#messages .msg.brain .reasoning-summary").click()
|
||||
anon.locator("#messages .msg.brain details.thinking > summary").first.click()
|
||||
expect(
|
||||
anon.locator("#messages .msg.brain details.thinking").first
|
||||
@@ -736,22 +830,51 @@ def test_no_thinking_turn_single_disclosure(
|
||||
_submit(page, PLAIN_TOOLS_QUESTION)
|
||||
_wait_settled(page)
|
||||
|
||||
# D1 (the grouping contract): no thinking frames → ONE tool run →
|
||||
# EXACTLY ONE disclosure (the phase-117 behavior for no-thinking
|
||||
# turns is preserved), with the 3 ``.tool-call`` lines and the
|
||||
# plural summary.
|
||||
# Phase 161 (D1/D2): the tool frames create the container — it
|
||||
# exists and folds at done; the D6 settled status is the single
|
||||
# tool run ("1 steps").
|
||||
expect(
|
||||
page.locator("#messages .msg.brain .tool-calls-disclosure")
|
||||
page.locator("#messages .msg.brain details.reasoning")
|
||||
).to_have_count(1)
|
||||
expect(page.locator("#messages .msg.brain .tool-call")).to_have_count(3)
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.reasoning")
|
||||
).not_to_have_attribute("open")
|
||||
expect(
|
||||
page.locator("#messages .msg.brain .reasoning-status")
|
||||
).to_have_text("1 steps")
|
||||
# D1 (the grouping contract): no thinking frames → ONE tool run →
|
||||
# EXACTLY ONE disclosure INSIDE THE CONTAINER (the phase-117
|
||||
# behavior for no-thinking turns is preserved, now wrapped), with
|
||||
# the 3 ``.tool-call`` lines and the plural summary.
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning .reasoning-body "
|
||||
".tool-calls-disclosure"
|
||||
)
|
||||
).to_have_count(1)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning .reasoning-body "
|
||||
".tool-call"
|
||||
)
|
||||
).to_have_count(3)
|
||||
expect(
|
||||
page.locator("#messages .msg.brain .tool-calls-summary")
|
||||
).to_have_text("Tool calls (3)")
|
||||
# And ZERO thinking blocks (no thinking ran — no empty block).
|
||||
expect(page.locator("#messages .msg.brain details.thinking")).to_have_count(0)
|
||||
# And ZERO thinking blocks inside (no thinking ran — no empty
|
||||
# block).
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning .reasoning-body "
|
||||
"details.thinking"
|
||||
)
|
||||
).to_have_count(0)
|
||||
|
||||
# Folded at rest (the first delta / done settle) + the answer.
|
||||
expect(
|
||||
page.locator("#messages .msg.brain .tool-calls-disclosure[open]")
|
||||
page.locator(
|
||||
"#messages .msg.brain details.reasoning .reasoning-body "
|
||||
".tool-calls-disclosure[open]"
|
||||
)
|
||||
).to_have_count(0)
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(ANSWER_PREFIX)
|
||||
|
||||
@@ -144,6 +144,10 @@ SUMMARY = "#messages .msg.brain .tool-calls-summary"
|
||||
DISCLOSURE = "#messages .msg.brain .tool-calls-disclosure"
|
||||
LINES = "#messages .msg.brain .tool-call"
|
||||
|
||||
#: Phase 161 (D1/D2): the ONE reasoning container — created on the
|
||||
#: turn's first tool frame, OPEN live, folded at the settle (D2).
|
||||
REASONING = "#messages .msg.brain details.reasoning"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# DB seeding (TRUNCATE-then-seed, cf. test_agent_document_tools.py)
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -261,7 +265,12 @@ def _submit_tools_turn(page: Page, app_url: str) -> None:
|
||||
|
||||
def _expand_disclosure(page: Page) -> None:
|
||||
"""Tap the summary — the native ``<summary>`` is a real focusable
|
||||
toggle (AA), so a plain click is the user contract."""
|
||||
toggle (AA), so a plain click is the user contract. Phase 161
|
||||
(D2): at rest the disclosure sits INSIDE the folded reasoning
|
||||
container, so the user flow is two native toggles — open the
|
||||
container first (its own summary — a direct child, so the inner
|
||||
runs' summaries are not matched), then the disclosure's summary."""
|
||||
page.locator(f"{REASONING} > summary").click()
|
||||
page.locator(SUMMARY).click()
|
||||
|
||||
|
||||
@@ -274,16 +283,27 @@ def test_tool_calls_fold_to_one_line_after_turn(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
"""Pin 1 (the space fix): after the deterministic ``ls`` →
|
||||
``ls(scoped)`` → ``read`` turn COMPLETES, the record is ONE visible
|
||||
"Tool calls (3)" summary — the disclosure is CLOSED (no ``open``
|
||||
``ls(scoped)`` → ``read`` turn COMPLETES, the record is ONE
|
||||
visible summary line — the disclosure is CLOSED (no ``open``
|
||||
attribute: the first answer delta folded it, D3), the three
|
||||
``.tool-call`` lines are still in the DOM (the permanent record)
|
||||
but hidden (the first line is not visible), and the answer bubble
|
||||
is present."""
|
||||
is present. Phase 161 (D2): the at-rest record is wrapped in the
|
||||
FOLDED reasoning container — the at-rest visible line is the
|
||||
container's "Reasoning" summary; the disclosure's "Tool calls (3)"
|
||||
count is the in-container record, revealed by opening it."""
|
||||
page.set_default_timeout(30_000)
|
||||
_reset_db(_seed)
|
||||
_submit_tools_turn(page, app_url)
|
||||
|
||||
# Phase 161 (D2): the container exists (the tool frames created
|
||||
# it) and folds at done — the at-rest visible line is its summary.
|
||||
container = page.locator(REASONING)
|
||||
expect(container).to_have_count(1)
|
||||
expect(container).not_to_have_attribute("open")
|
||||
page.locator(f"{REASONING} > summary").click()
|
||||
expect(container).to_have_attribute("open", "")
|
||||
|
||||
# One compact summary line, visible, with the live count.
|
||||
summary = page.locator(SUMMARY)
|
||||
expect(summary).to_be_visible()
|
||||
@@ -396,10 +416,15 @@ def test_restored_turn_renders_folded(
|
||||
record is written at the save points, so the reload sees the full
|
||||
conversation), a same-context RELOAD re-renders the persisted
|
||||
record through the restore path (``renderStoredMessage`` →
|
||||
``appendToolLine`` → the after-loop fold): the disclosure is
|
||||
``restoreTimeline`` → the after-loop fold): the disclosure is
|
||||
present, CLOSED (no ``open`` attribute — no auto-expand on load),
|
||||
the summary reads "Tool calls (3)", the three lines are in the DOM
|
||||
and the first is hidden (folded on load)."""
|
||||
and the first is hidden (folded on load). Phase 161 (D2/D5): the
|
||||
restored record is wrapped in the FOLDED reasoning container —
|
||||
the at-rest visible line is the container's "Reasoning" summary;
|
||||
the disclosure's "Tool calls (3)" count is the in-container
|
||||
record, revealed by opening the container (the same two-native-
|
||||
toggle user flow as the live at-rest pins)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_reset_db(_seed)
|
||||
_submit_tools_turn(page, app_url)
|
||||
@@ -413,10 +438,22 @@ def test_restored_turn_renders_folded(
|
||||
page.locator(".msg.brain .bubble").last
|
||||
).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||
|
||||
# Folded on load: one compact summary line, disclosure closed.
|
||||
# Folded on load (phase 161, D2/D5): the record is wrapped in the
|
||||
# FOLDED reasoning container — the at-rest visible line is the
|
||||
# container's "Reasoning" summary.
|
||||
container = page.locator(REASONING)
|
||||
expect(container).to_have_count(1)
|
||||
expect(container).not_to_have_attribute("open")
|
||||
expect(container.locator("> summary")).to_be_visible()
|
||||
expect(container.locator("> summary")).to_contain_text("Reasoning")
|
||||
|
||||
# The disclosure is CLOSED (no auto-expand on load) — and the
|
||||
# in-container record is revealed by opening the container (the
|
||||
# two-native-toggle user flow).
|
||||
assert page.get_attribute(DISCLOSURE, "open") is None
|
||||
page.locator(f"{REASONING} > summary").click()
|
||||
expect(page.locator(SUMMARY)).to_be_visible()
|
||||
expect(page.locator(SUMMARY)).to_contain_text("Tool calls (3)")
|
||||
assert page.get_attribute(DISCLOSURE, "open") is None
|
||||
|
||||
# The three lines are in the DOM (the permanent record) but folded:
|
||||
# the first line is not rendered — no auto-expand.
|
||||
|
||||
Reference in New Issue
Block a user