phase: 158_thinking_timeline
All verification complete. Every gate is green and the docs check out. Final report:
## Phase 158 final verification pass — COMPLETE
**Verified (all tasks pre-completed; this pass re-ran the full gate):**
- Unit + integration: `uv run pytest --cov=app --cov-report=term-missing` → **3618 passed, 99% coverage on `app/`** (>90% gate)
- Phase E2E in isolation: `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` → **6/6 passed** (order pin, fold-at-rest, live-open, restore+`segments` array, shared page, no-thinking single disclosure)
- Regression E2E, each in isolation: `test_thinking_display` 5✓, `test_tool_call_compact` 4✓, `test_turn_progress_loader` 3✓ (per-run pin updated), `test_big_read_progress` 4✓, `test_agent_document_tools` 4✓, `test_share_chat` 4✓, `test_chat_persistence` 4✓, `test_smoke` 3✓
- Lint/types: `uv run ruff check . && uv run pyright` → **clean (0 errors)**
**Completion criteria:** all 9 met — timeline E2E pins ✓, restore/shared `segments` ✓, no-thinking single-disclosure + the three UNCHANGED regression suites green ✓, pre-phase byte-identical round-trip (integration pin: key absent, never null; >50 / >32 000 / empty text / unknown kind all 422) ✓, full suite + coverage + E2E + lint ✓, `git diff --stat` footprint limited to `app/schemas.py`, `frontend/assets/{app,shared}.js`, `tests/`, README, phase files (a pre-existing owner `TODO.md` edit is out of scope, not staged by the phase commit) ✓, D15→per-run supersession recorded in the updated pin ✓. (Commit + phase move are the harness's job; task dirs already in `complete/`.)
**Documentation:** README.md — Thinking section correctly describes the phase-158 timeline; config reference matches `.env.example` exactly; page table matches registered routes; no stale single-block wording. AGENTS.md — rules and quick-reference commands all accurate. **Verified consistent — no changes needed.**
**Decisions/deviations:** none. `test_tool_call_compact.py` carries a one-line docstring-only update (referenced the removed `closeToolCalls`); its test logic is byte-identical to the phase-117 contract and green.
**Next pending phase:** none — `todo/` contains only 158.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
All verification complete. Every gate is green and the docs check out. Final report:
|
||||
|
||||
## Phase 158 final verification pass — COMPLETE
|
||||
|
||||
**Verified (all tasks pre-completed; this pass re-ran the full gate):**
|
||||
- Unit + integration: `uv run pytest --cov=app --cov-report=term-missing` → **3618 passed, 99% coverage on `app/`** (>90% gate)
|
||||
- Phase E2E in isolation: `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` → **6/6 passed** (order pin, fold-at-rest, live-open, restore+`segments` array, shared page, no-thinking single disclosure)
|
||||
- Regression E2E, each in isolation: `test_thinking_display` 5✓, `test_tool_call_compact` 4✓, `test_turn_progress_loader` 3✓ (per-run pin updated), `test_big_read_progress` 4✓, `test_agent_document_tools` 4✓, `test_share_chat` 4✓, `test_chat_persistence` 4✓, `test_smoke` 3✓
|
||||
- Lint/types: `uv run ruff check . && uv run pyright` → **clean (0 errors)**
|
||||
|
||||
**Completion criteria:** all 9 met — timeline E2E pins ✓, restore/shared `segments` ✓, no-thinking single-disclosure + the three UNCHANGED regression suites green ✓, pre-phase byte-identical round-trip (integration pin: key absent, never null; >50 / >32 000 / empty text / unknown kind all 422) ✓, full suite + coverage + E2E + lint ✓, `git diff --stat` footprint limited to `app/schemas.py`, `frontend/assets/{app,shared}.js`, `tests/`, README, phase files (a pre-existing owner `TODO.md` edit is out of scope, not staged by the phase commit) ✓, D15→per-run supersession recorded in the updated pin ✓. (Commit + phase move are the harness's job; task dirs already in `complete/`.)
|
||||
|
||||
**Documentation:** README.md — Thinking section correctly describes the phase-158 timeline; config reference matches `.env.example` exactly; page table matches registered routes; no stale single-block wording. AGENTS.md — rules and quick-reference commands all accurate. **Verified consistent — no changes needed.**
|
||||
|
||||
**Decisions/deviations:** none. `test_tool_call_compact.py` carries a one-line docstring-only update (referenced the removed `closeToolCalls`); its test logic is byte-identical to the phase-117 contract and green.
|
||||
|
||||
**Next pending phase:** none — `todo/` contains only 158.
|
||||
@@ -0,0 +1,122 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
.................. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 270 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 196 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 266 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 135 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 431 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 764 4 99%
|
||||
app/rag/llm.py 246 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 155 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/schemas.py 466 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5742 17 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,13 @@
|
||||
That `TODO.md` edit predates my work (visible in the initial `git status`) — leaving it untouched. Task 01 is complete and 100% green.
|
||||
|
||||
**Report**
|
||||
|
||||
- **Implemented** (`app/schemas.py` only, per task scope):
|
||||
- `ChatThinkingSegment` (`kind: Literal["thinking"]`, `text` min 1 / max 32 000 — HistoryTurn's cap), `ChatToolSegment` (exact `ToolCall` field set, mirrored not weakened — incl. the phase-146 `path` omission serializer), `ChatSegment = Annotated[ChatThinkingSegment | ChatToolSegment, Field(discriminator="kind")]`
|
||||
- `ChatMessage.segments: list[ChatSegment] | None = Field(default=None, max_length=50)` (phase-83 voice cap comment), phase-158 docstring paragraph, and the `_drop_image_when_absent` serializer now pops `segments` when `None` (ABSENT, never `null`)
|
||||
- No migration, no router change, `app/rag/` untouched — `POST/PUT /api/chats` + admin GET + public shared read inherit the field end-to-end through the existing `model_dump`/`model_validate` paths
|
||||
- **Tests**: 8 new integration pins in `tests/integration/test_chats_api.py` (accept + order-preserving round-trip via 201/raw-JSONB/GET/PUT, shared read carries segments, 51 entries → 422, 32 001-char/empty text → 422, mirrored tool caps → 422, unknown `kind` → 422, stray top-level key still → 422, absent-key byte-identity pin + `[]` round-trips as `[]`); 7 new unit pins in `tests/unit/test_schemas.py` (caps, discriminator, omission, extra-forbid boundary)
|
||||
- **Results**: `uv run pytest` → **3593 passed**; `uv run pytest --cov=app` → **99% on app/** (`schemas.py` 100%); `uv run pytest tests/integration/test_chats_api.py` → 75 passed; `uv run ruff check .` → clean; `uv run pyright` → 0 errors
|
||||
- **Docs**: no doc impact — the README "## Thinking" section describes the *rendered* block, which changes in task 02 (live timeline); this task is a server-side validation boundary only
|
||||
- **Notable**: used `X | Y` instead of the spec's `Union[...]` because the project's ruff config (UP007) enforces it; pydantic 2.13 names the union variant in validation `loc`s (test pins account for `('segments', 0, 'thinking', 'text')`)
|
||||
- **Next pending task**: `.agents/phases/todo/158_thinking_timeline/02_live_timeline.md`
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 98%]
|
||||
................................................................. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 270 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 196 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 266 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 135 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 431 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 764 4 99%
|
||||
app/rag/llm.py 246 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 155 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/schemas.py 466 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5742 17 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,10 @@
|
||||
All gates green. Task 02 is complete.
|
||||
|
||||
**Report**
|
||||
|
||||
- **Implemented** (`frontend/assets/app.js` + `tests/`): per-run live timeline — `ensureThinkingRun`/`ensureToolRun` (one `details.thinking` / `details.tool-calls-disclosure` per contiguous run, inserted before the bubble in arrival order — D2), `closeCurrentRun` (delta fold) + `closeAllRuns` (done/stop/failed settle), phase-87 clock re-target (`armToolLineClock(wrap, lineEl)`, `:last-child` query gone), `segRuns`/`currentRun` turn state + `buildSegments`, and `segments` (non-empty only) at all five save sites (done / `finalizeFailedTurn` + 2 call sites / stop / pagehide); `appendToolLine` now container-signature + returns the line; legacy restore path moved onto the shared builders (collapsed, byte-identical behavior).
|
||||
- **Unit pins**: new `tests/unit/test_thinking_timeline.py` (17 pins); updated 14 existing pin files (turn_loader, feedback, tool_states, tool_call_compact, big_read_progress, failed_turn, hidden_tab, actions_cluster, thinking_scroll, chat_persistence, read_range, read_truncation, search_scope, sources_midstream) — only the pins the redesign breaks.
|
||||
- **Results**: `uv run pytest --no-cov` → **3610 passed, 0 failed**; `uv run pytest --cov=app` → **99%** on `app/` (`schemas.py` 100%); `uv run ruff check .` clean; `uv run pyright` → 0 errors; `node --check` OK; E2E smoke (isolation): `test_thinking_display` 5 ✓, `test_tool_call_compact` 4 ✓, `test_big_read_progress` 4 ✓, `test_chat_persistence` 4 ✓, `test_thinking_scroll` 8 ✓.
|
||||
- **Docs**: README "## Thinking" section rewritten for the per-run timeline + persisted `segments`; AGENTS.md has no thinking/tool display description (no change). Fixed stale E2E docstring in `test_tool_call_compact.py` (`closeToolCalls` → after-loop fold).
|
||||
- **Decisions**: `currentRun = {kind, el, run}` module-scope (reset in `runTurn`, readable from the pagehide partial like `thinkingAcc`); run `records` hold the *same* `toolAcc` objects so the phase-95 truncation stamp flows into `segments` with zero extra wiring; legacy restore keeps the old lifecycle (create open, fold after the loop) so task 03's `restoreTimeline` dispatch layers on cleanly; `test_turn_progress_loader.py`'s D15 pin fails until task 05's planned update (owner-directed exception).
|
||||
- **Next pending task**: `03_restore_and_shared.md`.
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
.......... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 270 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 196 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 266 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 135 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 431 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 764 4 99%
|
||||
app/rag/llm.py 246 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 155 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/schemas.py 466 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5742 17 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
**Task 03 complete — restore path + shared-page timeline**
|
||||
|
||||
- `frontend/assets/app.js`: `restoreTimeline(wrap, segments)` (placed before `renderStoredMessage` so the task-02 legacy-region pin stays intact) — per-run collapsed `details.thinking` (plain `Thinking` literal, escape-first `renderMarkdown`) + consecutive-tool grouping into one collapsed `createToolDisclosure` per run, verbatim phase-139/146 guards + `appendTruncatedNote`, unknown-`kind`/malformed entries skipped; `renderStoredMessage` dispatch guard (`Array.isArray(m.segments) && m.segments.length` → `restoreTimeline`, else legacy byte-identical)
|
||||
- `frontend/assets/shared.js`: `addSegments` parity copy (same grouping, same line bytes as `addToolLines` — four label literals + phase-139 suffix + phase-95 note, per-run `Tool call(s) (N)`, all collapsed) + `renderSharedMessage` dispatch guard
|
||||
- `tests/unit/test_thinking_timeline.py`: 8 new pins (restore structure/collapsed D5, grouping, guards+note verbatim, skip, single-escape-first-write, both dispatch guards, legacy shared builders untouched) — 25/25 pass
|
||||
- Verified legacy blocks byte-identical modulo wrapper (programmatic dedent compare: `True`); no existing pin needed updating
|
||||
- Gates: `uv run pytest` → **3618 passed, exit 0**; `--cov=app` → **99%** (>90%); `ruff check .` clean; `pyright` 0 errors; E2E regression in isolation: `test_chat_persistence.py` 4 passed, `test_share_chat.py` 4 passed
|
||||
- Docs: no change (README already names the timeline incl. restore/shared from tasks 01/02; AGENTS.md unaffected)
|
||||
- Decision: "innerHTML ABSENT" pinned as structure-only (createElement/textContent) with exactly one escape-first `renderMarkdown` write into `.thinking-text` — the explicit "existing restore convention" from the task; `addSegments` mirrors `addToolLines` exactly (shared-page parity, no phase-146 scope span, matching the page's existing convention)
|
||||
- Next pending task: `04_mock_and_e2e.md`
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
.................. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 270 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 196 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 266 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 135 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 431 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 764 4 99%
|
||||
app/rag/llm.py 246 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 155 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/schemas.py 466 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5742 17 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,20 @@
|
||||
Task 04 is complete and fully green. Final report:
|
||||
|
||||
**Task 04 complete — mock interleaved flow + dedicated E2E suite**
|
||||
|
||||
**Implemented**
|
||||
- `tests/e2e/mock_llm.py`: `THINK_TOOLS_TRIGGER` ("think out loud and use your tools") with verification notes + module-docstring entry; `_thinking_tools_flow` (4-stage stateless: list → drill → read → answer, each with a distinct `Step N` reasoning run streamed first); `_tool_call_stream` optional `thinking=""` param (12-char `reasoning_content` slices first, 0.02 s cadence — existing frames unchanged; default → byte-identical); dispatch checked **before** `_tool_flow`
|
||||
- `tests/e2e/test_thinking_timeline.py` (new): all six tests — 7-element arrival-order timeline + distinct Step markers + per-run `Tool call (1)` summaries; fold-at-rest; live-open during the pacing window; reload restore + the `bor.chat.v1` record's exact 7-entry `segments` array (kinds + Step text + tool names/args) with `thinking`/`tools` keys still present; fresh-context shared-page parity (collapsed, expand-one); no-thinking turn → one disclosure, 3 lines, `Tool calls (3)`, zero `.thinking`
|
||||
|
||||
**Gates (all green)**
|
||||
- `uv run pytest` → **3618 passed** (unit + integration, 100%)
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **99%** on `app/` (`schemas.py` 100%; floor >90%)
|
||||
- `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` → **6 passed in isolation**
|
||||
- `uv run ruff check . && uv run pyright` → **clean / 0 errors**
|
||||
- Regression isolation: `test_agent_document_tools` 4/4, `test_tool_call_compact` 4/4, `test_big_read_progress` 4/4 — pre-phase callers byte-unchanged (git diff: only the signature line); `test_turn_progress_loader` — only the D15 pin fails (the known task-05 supersession), its 2 other tests green
|
||||
|
||||
**Docs**: no doc impact (test-only change; the README's "## Thinking" section already documents the timeline from task 02; AGENTS.md has no thinking/tool display description)
|
||||
|
||||
**Decisions**: the task's "no existing trigger phrase is a substring" is literally unsatisfiable (the phrase contains `think out loud` + `use your tools`) — I followed the mock's established more-specific-phrase-wins convention (SLOW_PRETOKEN/THINK_PARAS/SCAFFOLD_ALWAYS precedent): the flow is checked before `_tool_flow`, and I verified by grep that no existing E2E question contains the phrase (documented in the constant comment, docstring, and dispatch comment). `expect.poll` doesn't exist in this Playwright version → used the house deadline-poll pattern (`test_agent_document_tools.py::_frames` convention).
|
||||
|
||||
**Next pending task**: `.agents/phases/todo/158_thinking_timeline/05_regression_update_and_commit.md`
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
.................. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 270 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 196 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 266 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 135 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 431 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 764 4 99%
|
||||
app/rag/llm.py 246 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 155 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/schemas.py 466 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5742 17 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
All done. Final report:
|
||||
|
||||
**Task 05 — complete (commit/phase-move deferred to the harness per its binding rules):**
|
||||
- `tests/e2e/test_turn_progress_loader.py`: D15 re-open pin → per-run pin (`test_thinking_runs_open_in_live_window_with_visible_loader` — one open `.thinking` block with its run's text while the bubble already carries the first delta + loader visible; at rest two blocks, each with its own round's sentinel, both folded); module docstring records the phase-158 supersession (owner direction 2026-10-04); the other two loader tests untouched and green.
|
||||
- Gate — `uv run pytest`: **3618 passed, 100% green**; `--cov=app`: **99%** (>90%; `app/schemas.py` 100%); `test_thinking_timeline.py` isolated: **6/6**; regression E2E each isolated — `test_thinking_display` 5/5, `test_tool_call_compact` 4/4, `test_turn_progress_loader` **3/3 (updated)**, `test_big_read_progress` 4/4 (unchanged), `test_agent_document_tools` 4/4, `test_share_chat` 4/4, `test_chat_persistence` 4/4, `test_smoke` 3/3; `ruff check . && pyright`: **clean (0 errors, 0 warnings)**.
|
||||
- Docs: README's Thinking section already carries the timeline wording (verified in full); AGENTS.md has no thinking/tool-call display description — accurate, no change; schemas.py/app.js/shared.js comment voice spot-checked, in the established phase-citation style.
|
||||
- Scope check: `git diff --stat` limited to `app/schemas.py`, `frontend/assets/app.js`, `frontend/assets/shared.js`, `tests/`, `README.md`, phase files (plus pre-existing owner note in `TODO.md`, outside the phase commit spec). No extra pin updates were needed — no other suite failed.
|
||||
- No live-infrastructure changes.
|
||||
- Next pending task: none — 05 is the last task of phase 158.
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
.................. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 270 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 196 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 266 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 68 0 100%
|
||||
app/models.py 135 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 431 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 764 4 99%
|
||||
app/rag/llm.py 246 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 155 0 100%
|
||||
app/rag/retriever.py 248 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/schemas.py 466 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5742 17 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -405,10 +405,27 @@ whole git-source refresh in one click:
|
||||
## Thinking
|
||||
|
||||
The self-hosted `turbo` model reasons before it answers. That reasoning is
|
||||
streamed with the turn as `thinking` SSE events and shown in a
|
||||
**collapsible "Thinking" block** above the answer bubble: it opens and
|
||||
fills in live while the model thinks, tucks itself away the moment the
|
||||
first answer token lands, and stays click-toggleable afterwards.
|
||||
streamed with the turn as `thinking` SSE events and shown in **
|
||||
collapsible "Thinking" blocks** above the answer bubble — one block per
|
||||
contiguous thinking run, in arrival order (phase 158): the turn renders
|
||||
as a chronological timeline (Thinking → Tool call (1) → Thinking → … →
|
||||
answer). Each block opens and fills in live while its run streams,
|
||||
and the runs tuck themselves away when the first answer token lands,
|
||||
when the turn ends, or when the turn is stopped/failed — and every
|
||||
block and tool-call disclosure stays click-toggleable afterwards. A
|
||||
thinking run that starts after the answer began (the model replied,
|
||||
called a tool, then thinks again) gets its own block above the answer
|
||||
instead of resuming on top of it.
|
||||
|
||||
The tool calls ride the same timeline: each contiguous run of tool
|
||||
calls gets its own collapsed-when-idle "Tool call(s) (N)" disclosure
|
||||
(one per run, in arrival order), and the per-line elapsed-seconds
|
||||
suffix targets the newest line of the active run.
|
||||
|
||||
The timeline is persisted with the saved turn (an optional `segments`
|
||||
array — the per-run order; the concatenated `thinking` and the `tools`
|
||||
record stay for history and older clients), so a reload and the shared
|
||||
page re-render the same timeline, collapsed.
|
||||
|
||||
To hide it, set `BOR_STREAM_THINKING=0`.
|
||||
|
||||
|
||||
+137
@@ -1092,6 +1092,88 @@ class ToolCall(BaseModel):
|
||||
return data
|
||||
|
||||
|
||||
class ChatThinkingSegment(BaseModel):
|
||||
"""One CONTIGUOUS thinking run in the turn timeline (phase 158,
|
||||
D1/D4) — the per-run ``details.thinking`` block the chat page
|
||||
renders in arrival order (the chronological timeline; the
|
||||
phase-109 lumped single-block layout is replaced, owner direction
|
||||
2026-10-04).
|
||||
|
||||
``text`` is the run's accumulated thinking text — the same text
|
||||
that streamed live into that run's block. ``min_length=1``: an
|
||||
EMPTY run stores NOTHING (a zero-char run — e.g. a
|
||||
``BOR_STREAM_THINKING``-suppressed turn) is absent from the
|
||||
record, so a stored entry is always non-empty. ``max_length=
|
||||
32_000`` mirrors :class:`HistoryTurn.thinking`'s cap (phase 83,
|
||||
SEC-05 — a single turn longer than that is already rejected on
|
||||
the chat path, so a saved chat can never legitimately carry
|
||||
more).
|
||||
"""
|
||||
|
||||
kind: Literal["thinking"]
|
||||
text: str = Field(min_length=1, max_length=32_000)
|
||||
|
||||
|
||||
class ChatToolSegment(BaseModel):
|
||||
"""One tool call in the turn timeline (phase 158, D1/D4) — the
|
||||
EXACT :class:`ToolCall` field set, mirrored, not weakened:
|
||||
``name`` / ``argument`` / ``offset`` / ``limit`` / ``path`` /
|
||||
``truncated`` / ``chars_shown`` / ``chars_total`` with their
|
||||
existing types and caps (``ToolCall`` is the shape source — the
|
||||
phase-37 ``{name, argument}`` pair, the phase-95 truncation
|
||||
stamp, the phase-139 requested range, the phase-146 scoped grep
|
||||
``path``). ``kind`` is the discriminator (see
|
||||
:data:`ChatSegment`); everything else is byte-for-byte the record
|
||||
the UI's ``toolAcc`` keeps, so a ``segments`` entry and the
|
||||
matching ``tools`` entry of the same record are the same data
|
||||
(``tools`` stays for old clients — D4's graceful degradation).
|
||||
|
||||
``path`` (the phase-146 additive scope field) follows the
|
||||
:class:`ToolCall` omission rule: ABSENT, never ``null``, on
|
||||
every non-scoped record (the :meth:`_serialize` below — the
|
||||
phase-50 backward-compat rule: a pre-phase-146 entry validates
|
||||
unchanged and round-trips byte-identically).
|
||||
"""
|
||||
|
||||
kind: Literal["tool"]
|
||||
name: str = Field(max_length=100)
|
||||
argument: str | None = Field(default=None, max_length=2000)
|
||||
truncated: bool = False
|
||||
chars_shown: int | None = Field(default=None, ge=0)
|
||||
chars_total: int | None = Field(default=None, ge=0)
|
||||
offset: int | None = Field(default=None, ge=1)
|
||||
limit: int | None = Field(default=None, ge=1)
|
||||
# Phase 146 (task 02, mirrored from ToolCall): grep only — the
|
||||
# scoped re-search's document (the frame's additive ``path``);
|
||||
# None on every non-scoped record → the key is ABSENT, never
|
||||
# null (the serializer below).
|
||||
path: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||||
# Phase 146 (task 02, mirrored from ToolCall): the scope is
|
||||
# ABSENT (never null) on every non-scoped record — the
|
||||
# pre-phase saved shape stays byte-identical (the ChatToolEvent
|
||||
# omission pattern; the phase-50 round-trip pin keys on it).
|
||||
data = handler(self)
|
||||
if self.path is None:
|
||||
data.pop("path", None)
|
||||
return data
|
||||
|
||||
|
||||
#: One timeline segment (phase 158, D4) — the discriminated union:
|
||||
#: ``kind`` picks the variant (``"thinking"`` →
|
||||
#: :class:`ChatThinkingSegment`, ``"tool"`` → :class:`ChatToolSegment`).
|
||||
#: The JSON shape is a flat object (the discriminator rides as a
|
||||
#: field — pydantic's ``Field(discriminator="kind")``, no wrapper).
|
||||
#: An unknown ``kind`` 422s at the boundary (the ``extra="forbid"``
|
||||
#: voice: nothing outside the shape crosses).
|
||||
ChatSegment = Annotated[
|
||||
ChatThinkingSegment | ChatToolSegment,
|
||||
Field(discriminator="kind"),
|
||||
]
|
||||
|
||||
|
||||
#: One suggestion chip (phase 83, A1): a short deterministic string —
|
||||
#: ``derive_suggestions``'s output runs to ~80 chars, so 200 chars is
|
||||
#: the boundary sanity cap. Annotated alias: the JSON shape stays a
|
||||
@@ -1147,6 +1229,33 @@ class ChatMessage(BaseModel):
|
||||
serializer change: ``None`` values flow as absent/None exactly like
|
||||
``stopped`` today — the phase-50 byte-identical round-trip contract
|
||||
covers the new keys automatically.
|
||||
|
||||
Phase 158 (task 01, D4/D6 — owner direction 2026-10-04): the
|
||||
turn's CHRONOLOGICAL run order — ``segments``, a list of the
|
||||
discriminated union :data:`ChatSegment` (``kind`` = ``"thinking"``
|
||||
→ :class:`ChatThinkingSegment`, ``"tool"`` →
|
||||
:class:`ChatToolSegment`): one ``thinking`` entry per CONTIGUOUS
|
||||
thinking run (the run's accumulated text) and one ``tool`` entry
|
||||
per tool call (the EXACT :class:`ToolCall` record shape — the
|
||||
phase-37 ``{name, argument}`` pair plus the phase-95 truncation
|
||||
stamp, the phase-139 requested range, and the phase-146 scoped
|
||||
grep ``path``). It is ADDITIVE (D4): ``thinking`` (the full
|
||||
concatenation — feeds the history wire + the per-turn log) and
|
||||
``tools`` (every call record — old-client graceful degradation)
|
||||
are KEPT and UNCHANGED; ``segments`` is omitted when ``None`` (the
|
||||
:meth:`_drop_image_when_absent` serializer below — ABSENT, never
|
||||
``null`` — the phase-50 round-trip contract), so a
|
||||
no-thinking/no-tools record — and every pre-phase record —
|
||||
round-trips byte-identically to pre-phase. The field EXISTS so
|
||||
the ``extra="forbid"`` boundary does NOT 422 the done-time
|
||||
auto-save carrying it (the phase-113 lesson, applied up front;
|
||||
the phase-124 ``answer_image`` precedent). ``max_length=50`` (the
|
||||
phase-83 voice): far past any realistic turn — the round cap is
|
||||
10 → ≤ 20 entries; 50 matches the ``tools`` cap. Segments never
|
||||
travel the history wire (A10 unchanged — the concatenated
|
||||
``thinking`` travels as ``reasoning_content`` exactly as today;
|
||||
:class:`HistoryTurn` and
|
||||
:func:`app.rag.prompts.history_to_messages` are untouched).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -1159,6 +1268,24 @@ class ChatMessage(BaseModel):
|
||||
suggestions: list[_Chip] | None = Field(default=None, max_length=50)
|
||||
thinking: str | None = Field(default=None, max_length=32_000)
|
||||
tools: list[ToolCall] | None = Field(default=None, max_length=50)
|
||||
# Phase 158 (task 01, D4/D6 — owner direction 2026-10-04): the
|
||||
# turn's CHRONOLOGICAL run order — one ``thinking`` entry per
|
||||
# contiguous thinking run (that run's accumulated text), one
|
||||
# ``tool`` entry per tool call (the exact ToolCall record shape,
|
||||
# the ChatSegment discriminated union). ADDITIVE (D4):
|
||||
# ``thinking`` (the full concatenation — the history wire + the
|
||||
# per-turn log) and ``tools`` (every call record — old clients)
|
||||
# stay KEPT and UNCHANGED; ``segments`` is omitted when None (the
|
||||
# _drop_image_when_absent serializer below — ABSENT, never null,
|
||||
# the phase-50 round-trip contract), so a no-thinking/no-tools
|
||||
# record — and every pre-phase record — round-trips
|
||||
# byte-identically to pre-phase. The field EXISTS so the
|
||||
# extra="forbid" boundary does NOT 422 the done-time auto-save
|
||||
# carrying it (the phase-113 lesson, applied up front; the
|
||||
# phase-124 answer_image precedent). max_length=50 (the phase-83
|
||||
# voice, the tools cap): far past any realistic turn — the round
|
||||
# cap is 10 → ≤ 20 entries; 50 matches the tools cap.
|
||||
segments: list[ChatSegment] | None = Field(default=None, max_length=50)
|
||||
stopped: bool | None = None
|
||||
# Phase 120 (task 01, locked A1): the failed-turn marker + the
|
||||
# persisted error detail (the phase-48 ``stopped`` precedent).
|
||||
@@ -1218,6 +1345,16 @@ class ChatMessage(BaseModel):
|
||||
data.pop("image", None)
|
||||
if self.answer_image is None:
|
||||
data.pop("answer_image", None)
|
||||
# Phase 158 (task 01, D4): the timeline is ABSENT (never
|
||||
# null) when there is no run to render — the pre-phase /
|
||||
# no-thinking-no-tools shape stays byte-identical (the
|
||||
# phase-50 round-trip contract; the phase-122
|
||||
# SourceRef.image_url omission precedent). A record WITH
|
||||
# segments keeps them (the restore path re-renders the
|
||||
# timeline from them; shared.js's parity copy does the same
|
||||
# on the shared page).
|
||||
if self.segments is None:
|
||||
data.pop("segments", None)
|
||||
return data
|
||||
|
||||
|
||||
|
||||
+427
-156
@@ -1035,67 +1035,154 @@ function removeTyping() {
|
||||
document.querySelector("#typing-indicator")?.remove();
|
||||
}
|
||||
|
||||
/* ---------- thinking block (phase 17; phase 109 makes it a toggle) ----------
|
||||
* The model's reasoning streams into a collapsible <details> block ABOVE
|
||||
* the answer bubble: created OPEN on the first `thinking` event, then
|
||||
* user-toggleable (native <details>/<summary> — a real focusable
|
||||
* control).
|
||||
/* ---------- the per-run timeline (phase 158, D1/D2/D5) ----------
|
||||
* The model's reasoning streams into collapsible <details> blocks ABOVE
|
||||
* the answer bubble, and the tool calls stream into per-run
|
||||
* disclosures — one CONTIGUOUS thinking run gets its own
|
||||
* details.thinking block and one CONTIGUOUS tool-call run gets its own
|
||||
* details.tool-calls-disclosure, in arrival order (D1). The phase-109
|
||||
* D15 "re-open the one shared block" layout is REPLACED (owner
|
||||
* direction 2026-10-04): the turn renders as a chronological timeline —
|
||||
* Thinking → Tool call (1) → Thinking → … → answer. All segments sit
|
||||
* ABOVE the answer bubble, in arrival order (D2, owner-chosen Option B
|
||||
* — including runs that start after the first `delta`, the mid-round
|
||||
* reply case); the bubble stays ONE element, its content accumulating
|
||||
* in arrival order.
|
||||
*
|
||||
* Phase 109 (TODO.md L3, D15) — the block is a TOGGLE, not a one-way
|
||||
* door: open-while-thinking, closed-while-answering. The `thinking` SSE
|
||||
* handler re-opens it (`block.open = true`, idempotent) — a `thinking`
|
||||
* frame after the answer has started (the next agent round: the model
|
||||
* answered, called a tool, then thinks again) re-opens the collapsed
|
||||
* scratchpad — and the `delta` handler closes it (closeThinkingBlock,
|
||||
* idempotent). The block reflects the model's current activity in every
|
||||
* agent round — the "frozen chat" repro from TODO.md L3 is gone. The
|
||||
* phase-14 restore path (renderStoredMessage) still renders stored
|
||||
* blocks COLLAPSED — untouched.
|
||||
* ensureThinkingBlock is idempotent (returns the existing block if any);
|
||||
* closeThinkingBlock is a no-op without a block. */
|
||||
function ensureThinkingBlock(wrap) {
|
||||
let block = wrap.querySelector(".thinking");
|
||||
if (!block) {
|
||||
block = document.createElement("details");
|
||||
block.className = "thinking";
|
||||
block.open = true;
|
||||
block.innerHTML =
|
||||
`<summary>Thinking</summary><div class="thinking-text"></div>`;
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
// Phase 37: the scratchpad stays the TOP row of the wrap — if tool
|
||||
// lines are already there (a `tool` frame preceded the first
|
||||
// `thinking` frame), the block lands above them, not below.
|
||||
// Phase 117: the anchor is the WHOLE disclosure (the bare .tool-calls
|
||||
// list term stays as the pre-117 fallback).
|
||||
const anchor =
|
||||
body.querySelector(".tool-calls-disclosure") ??
|
||||
body.querySelector(".tool-calls") ?? body.querySelector(".bubble");
|
||||
body.insertBefore(block, anchor);
|
||||
}
|
||||
* D5: the active run's block is OPEN while its frames stream; the first
|
||||
* frame of a new kind closes the previous run and opens the new one;
|
||||
* the first `delta`, `done`, and the stop/abort settle fold the open
|
||||
* runs (closeCurrentRun / closeAllRuns). The restore path
|
||||
* (renderStoredMessage) and the shared page render everything
|
||||
* COLLAPSED.
|
||||
*
|
||||
* The thinking block's STATIC structure is the phase-17 template (a
|
||||
* static template with NO data — the plain `Thinking` literal, D3; the
|
||||
* data flows through renderMarkdown / textContent exactly as today).
|
||||
* The tool-calls disclosure keeps the phase-117 createElement/textContent
|
||||
* shape (innerHTML ABSENT). The runs are tracked in segRuns (module
|
||||
* scope — the pagehide partial reads it, like thinkingAcc); each entry
|
||||
* is { kind, text, records } and rides the save payloads through
|
||||
* buildSegments (D4). The per-tool-line elapsed clock (phase 87)
|
||||
* targets the TRACKED line reference (armToolLineClock's second param)
|
||||
* — the `:last-child` query breaks with multiple disclosures and is
|
||||
* gone (the re-target, phase 158 D1). */
|
||||
function createThinkingBlock() {
|
||||
const block = document.createElement("details");
|
||||
block.className = "thinking";
|
||||
block.innerHTML =
|
||||
`<summary>Thinking</summary><div class="thinking-text"></div>`;
|
||||
return block;
|
||||
}
|
||||
|
||||
function closeThinkingBlock(wrap) {
|
||||
const block = wrap?.querySelector?.(".thinking");
|
||||
if (block) block.open = false; // idempotent; no-op without a block
|
||||
/* The tool-calls disclosure's STATIC structure — the phase-117 shape
|
||||
* (summary.tool-calls-summary first + the .tool-calls list, role=list
|
||||
* + aria-label "Tool calls"), built with createElement only (innerHTML
|
||||
* ABSENT — the house rule). The caller owns open/fold (the live
|
||||
* per-run path opens it, D5; the restore path creates it collapsed,
|
||||
* phase 117 D3) and inserts it before the bubble. */
|
||||
function createToolDisclosure() {
|
||||
const container = document.createElement("details");
|
||||
container.className = "tool-calls-disclosure";
|
||||
const summary = document.createElement("summary");
|
||||
summary.className = "tool-calls-summary";
|
||||
container.appendChild(summary);
|
||||
const listEl = document.createElement("div");
|
||||
listEl.className = "tool-calls";
|
||||
listEl.setAttribute("role", "list");
|
||||
listEl.setAttribute("aria-label", "Tool calls");
|
||||
container.appendChild(listEl);
|
||||
return container;
|
||||
}
|
||||
|
||||
/* Phase 117 (D3): the tool-calls disclosure settles FOLDED at rest —
|
||||
* the same sites that settle the Thinking block: the answer began
|
||||
* (`delta`), the turn ended (`done`), or the turn was stopped (the
|
||||
* abort settle). The N-line record collapses to one compact
|
||||
* "Tool call(s) (N)" line. Idempotent; a no-op without a disclosure
|
||||
* (deflected answers, pre-tool turns, …). */
|
||||
function closeToolCalls(wrap) {
|
||||
const disc = wrap?.querySelector?.(".tool-calls-disclosure");
|
||||
if (disc) disc.open = false; // idempotent; no-op without a disclosure
|
||||
/* The active thinking run's block — or, when the previous run is a
|
||||
* different kind (or there is none): close it, create a NEW
|
||||
* details.thinking (OPEN — D5), insert it BEFORE the bubble (D2 — the
|
||||
* anchor is the bubble, in arrival order), and open a fresh run. The
|
||||
* phase-109 D15 re-open of one shared block is superseded: a
|
||||
* post-delta thinking frame lands in a NEW block, not a re-open. */
|
||||
function ensureThinkingRun(wrap) {
|
||||
if (currentRun?.kind === "thinking") return currentRun.el;
|
||||
closeCurrentRun();
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
const block = createThinkingBlock();
|
||||
block.open = true; // D5: open while the run's frames stream
|
||||
body.insertBefore(block, body.querySelector(".bubble"));
|
||||
const run = { kind: "thinking", text: "", records: [] };
|
||||
segRuns.push(run);
|
||||
currentRun = { kind: "thinking", el: block, run };
|
||||
return block;
|
||||
}
|
||||
|
||||
/* The active tool run's disclosure — or, when the previous run is a
|
||||
* different kind (or there is none): close it, create a NEW
|
||||
* details.tool-calls-disclosure (OPEN — D5), insert it BEFORE the
|
||||
* bubble (D2, in arrival order), and open a fresh run. One disclosure
|
||||
* per CONTIGUOUS tool-call run (the phase-117 structure, now per run;
|
||||
* the summary count is per run — "Tool call(s) (N)"). */
|
||||
function ensureToolRun(wrap) {
|
||||
if (currentRun?.kind === "tool") return currentRun.el;
|
||||
closeCurrentRun();
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
const disc = createToolDisclosure();
|
||||
disc.open = true; // D5: open while the run's frames stream
|
||||
body.insertBefore(disc, body.querySelector(".bubble"));
|
||||
const run = { kind: "tool", text: "", records: [] };
|
||||
segRuns.push(run);
|
||||
currentRun = { kind: "tool", el: disc, run };
|
||||
return disc;
|
||||
}
|
||||
|
||||
/* D5's fold side: close the ACTIVE run (idempotent — an already
|
||||
* closed block is a no-op; a no-op without a run). The run itself
|
||||
* stays in segRuns — the fold only settles the element; the save
|
||||
* payloads still carry every run. */
|
||||
function closeCurrentRun() {
|
||||
if (currentRun) currentRun.el.open = false;
|
||||
currentRun = null;
|
||||
}
|
||||
|
||||
/* The turn's terminal settle (done + the stop/abort + the failed-turn
|
||||
* funnel): close EVERY open details in the wrap — defensive: by
|
||||
* construction at most the last run is open, but a late double-close
|
||||
* must be a no-op. Idempotent; a no-op without a wrap. */
|
||||
function closeAllRuns(wrap) {
|
||||
wrap?.querySelectorAll?.(
|
||||
"details.thinking, details.tool-calls-disclosure"
|
||||
)?.forEach((el) => (el.open = false));
|
||||
}
|
||||
|
||||
/* D4: the record's `segments` array, built at save time from the turn's
|
||||
* runs — in order. A thinking run stores its accumulated text ONLY when
|
||||
* it is non-empty (a zero-char run — e.g. a BOR_STREAM_THINKING-
|
||||
* suppressed turn — stores nothing); a tool run stores one entry per
|
||||
* record, in call order (the record's exact shape: the phase-37
|
||||
* {name, argument} pair, the phase-139 offset/limit, the phase-146
|
||||
* path, and the phase-95 truncation stamp when present — the SAME
|
||||
* objects toolAcc holds, so the tool_result stamp rides through with
|
||||
* no extra wiring). A turn with no thinking and no tools returns [] —
|
||||
* the save sites drop the key for an empty array (the
|
||||
* undefined-drops-the-key house pattern). No re-validation on the
|
||||
* client: the server's ChatMessage boundary validates. */
|
||||
function buildSegments(runs) {
|
||||
const out = [];
|
||||
for (const r of runs) {
|
||||
if (r.kind === "thinking") {
|
||||
if (r.text) out.push({ kind: "thinking", text: r.text });
|
||||
continue;
|
||||
}
|
||||
for (const rec of r.records) out.push({ kind: "tool", ...rec });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------- tool-call lines (phase 37, PLAN §4 extension) ----------
|
||||
* One visible "calling tool" row per `tool` SSE frame, in the same wrap
|
||||
* the Thinking block uses — above the answer, below the Thinking
|
||||
* summary (ensureThinkingBlock keeps the scratchpad on top). The first
|
||||
* frame creates the .tool-calls list; later frames — any tool, any
|
||||
* One visible "calling tool" row per `tool` SSE frame. Phase 158
|
||||
* (D1): the CALLER passes the run's disclosure (ensureToolRun — one
|
||||
* details.tool-calls-disclosure per CONTIGUOUS tool-call run), which
|
||||
* sits above the answer, below the Thinking blocks (both insert
|
||||
* before the bubble, so document order is preserved). The first frame
|
||||
* of a run creates the disclosure; later frames — any tool, any
|
||||
* interleaving with thinking frames, even after the first delta (the
|
||||
* agent loop never emits one, but a late frame must not crash) — just
|
||||
* append another line, in order. The SAME helper re-renders the
|
||||
@@ -1106,11 +1193,13 @@ function closeToolCalls(wrap) {
|
||||
*
|
||||
* Phase 117 (owner report 2026-09-15): the list rides in a native
|
||||
* <details> disclosure (details.tool-calls-disclosure + a plain-text
|
||||
* "Tool call(s) (N)" summary) — OPEN while the turn is live, folded by
|
||||
* closeToolCalls (delta/done/stop) and by the restore path (a restored
|
||||
* turn is one compact line, not N stacked cards). The line bytes, the
|
||||
* four label literals, and the <code> textContent arguments are
|
||||
* untouched (phase-87 clock + emoji-guard pins stay green).
|
||||
* "Tool call(s) (N)" summary). Phase 158 moved the disclosure's BUILD
|
||||
* to createToolDisclosure (the caller — the live run builder or the
|
||||
* restore path — owns open/fold + the before-the-bubble insertion),
|
||||
* and the count rides the summary on every append — per run (one
|
||||
* disclosure per contiguous run, D3). The line bytes, the four label
|
||||
* literals, and the <code> textContent arguments are untouched
|
||||
* (phase-87 clock + emoji-guard pins stay green).
|
||||
*
|
||||
* Phase 70 (owner permission 2026-09-03): the server tools were remapped
|
||||
* to the harness-aligned surface — ls / read(path) / grep(pattern,
|
||||
@@ -1136,28 +1225,8 @@ function closeToolCalls(wrap) {
|
||||
* string: a bare grep's line stays byte-identical to pre-phase (the
|
||||
* phase-70/87/117 pins and the frontend emoji guard stay green — no
|
||||
* new emoji). */
|
||||
function appendToolLine(wrap, name, argument, offset = null, limit = null, scope = null) {
|
||||
const body = wrap?.querySelector?.(".msg-body");
|
||||
if (!body) return;
|
||||
// Phase 117 (D2): the lines ride in a native <details> disclosure —
|
||||
// one compact "Tool call(s) (N)" line instead of N stacked cards.
|
||||
let container = body.querySelector(".tool-calls-disclosure");
|
||||
if (!container) {
|
||||
container = document.createElement("details");
|
||||
container.className = "tool-calls-disclosure";
|
||||
container.open = true; // D3: open while the turn is live; closeToolCalls folds it
|
||||
const summary = document.createElement("summary");
|
||||
summary.className = "tool-calls-summary";
|
||||
container.appendChild(summary);
|
||||
const listEl = document.createElement("div");
|
||||
listEl.className = "tool-calls";
|
||||
listEl.setAttribute("role", "list");
|
||||
listEl.setAttribute("aria-label", "Tool calls");
|
||||
container.appendChild(listEl);
|
||||
// Before the bubble; below an existing Thinking block (both insert
|
||||
// before the bubble, so document order is preserved).
|
||||
body.insertBefore(container, body.querySelector(".bubble"));
|
||||
}
|
||||
function appendToolLine(container, name, argument, offset = null, limit = null, scope = null) {
|
||||
if (!container) return;
|
||||
const list = container.querySelector(".tool-calls");
|
||||
const line = document.createElement("span");
|
||||
line.className = "tool-call";
|
||||
@@ -1214,10 +1283,13 @@ function appendToolLine(wrap, name, argument, offset = null, limit = null, scope
|
||||
}
|
||||
list.appendChild(line);
|
||||
// Phase 117 (D5): the count rides the summary — every append (live
|
||||
// frames and the restore path) re-renders through here.
|
||||
// frames and the restore path) re-renders through here. Phase 158
|
||||
// (D3): the count is PER RUN (one disclosure per contiguous run —
|
||||
// the per-run "Tool call(s) (N)").
|
||||
const n = list.children.length;
|
||||
container.querySelector("summary").textContent =
|
||||
`Tool call${n === 1 ? "" : "s"} (${n})`;
|
||||
return line; // phase 158: the caller arms the line's clock with this element
|
||||
}
|
||||
|
||||
/* Phase 139 (task 01): the requested line range on the Reading line —
|
||||
@@ -1375,12 +1447,22 @@ let turnTimeoutCb = null; // the guard's callback — lets visibilitychange re-a
|
||||
let toolLineTimer = 0; // setInterval id — the latest tool line's "(Ns)" suffix
|
||||
let toolLineStart = 0; // Date.now() when the latest tool line armed the clock
|
||||
let toolLineWrap = null; // the live wrap the clock suffixes (null when stopped)
|
||||
let toolLineEl = null; // phase 158: the TRACKED line the clock suffixes (the :last-child query is gone — multi-disclosure)
|
||||
/* Turn accumulators + the navigate-away flag (phase 20, owner choice
|
||||
* 2026-08-24 A1): module scope because the `pagehide` handler reads them
|
||||
* while a turn is still in flight; reset per turn at the top of
|
||||
* handleSend, so they stay turn-scoped like the rest of the turn locals. */
|
||||
let acc = ""; // accumulated answer text this turn
|
||||
let thinkingAcc = ""; // accumulated thinking text (persisted with the turn)
|
||||
/* Phase 158 (D1/D4): the turn's CONTIGUOUS runs, in arrival order —
|
||||
* { kind: "thinking", text, records: [] } / { kind: "tool", text,
|
||||
* records } entries (a tool entry's records hold the SAME toolAcc
|
||||
* record objects, so the phase-95 tool_result stamp flows into both).
|
||||
* Module scope, like thinkingAcc: the pagehide partial reads them while
|
||||
* a turn is in flight. Reset per turn at the top of runTurn; persisted
|
||||
* through buildSegments at the save points (non-empty only). */
|
||||
let segRuns = []; // this turn's runs, in arrival order ([] = nothing to store)
|
||||
let currentRun = null; // the active run — { kind, el, run } (run = the segRuns entry); null when no run is open
|
||||
let persistedOnLeave = false; // pagehide partial-persist at most once
|
||||
/* Phase 120 verification (navigate-away regression): a pagehide has
|
||||
* fired. A REAL departure tears the document down and the browser
|
||||
@@ -1489,22 +1571,30 @@ function clearTurnTimeout() {
|
||||
* + the <code> argument) — never a rewrite: appendToolLine's exact
|
||||
* line.textContent literals stay byte-identical (the unit + emoji-guard
|
||||
* pins), and the restore path (phase 14) re-renders lines with no clock
|
||||
* at all (A6 — the indication is live-only). */
|
||||
function armToolLineClock(wrap) {
|
||||
* at all (A6 — the indication is live-only).
|
||||
*
|
||||
* Phase 158 (D1): the re-target — with per-run disclosures (one
|
||||
* details.tool-calls-disclosure per contiguous tool-call run), the
|
||||
* phase-87 `.tool-calls .tool-call:last-child` query would find the
|
||||
* last line of the WRONG run's list. The suffix now targets the
|
||||
* TRACKED line reference (this function's second param — the line the
|
||||
* caller just appended; the re-arm resets it). The visible behavior is
|
||||
* byte-identical (the suffix on the newest line; settle still removes
|
||||
* every .tool-elapsed in the wrap). */
|
||||
function armToolLineClock(wrap, lineEl) {
|
||||
toolLineWrap = wrap;
|
||||
toolLineEl = lineEl; // the NEW target line (the re-arm resets it)
|
||||
toolLineStart = Date.now();
|
||||
if (!toolLineTimer) {
|
||||
toolLineTimer = setInterval(() => {
|
||||
const secs = Math.round((Date.now() - toolLineStart) / 1000);
|
||||
if (secs * 1000 < TOOL_LINE_ELAPSED_AFTER_MS) return; // A5: below 5s the gap reads as normal latency
|
||||
const line =
|
||||
toolLineWrap?.querySelector?.(".tool-calls .tool-call:last-child");
|
||||
if (!line) return; // the wrap was reset mid-gap (New Chat) — no-op
|
||||
let el = line.querySelector(".tool-elapsed");
|
||||
if (!toolLineEl) return; // the wrap was reset mid-gap (New Chat) — no-op
|
||||
let el = toolLineEl.querySelector(".tool-elapsed");
|
||||
if (!el) {
|
||||
el = document.createElement("span");
|
||||
el.className = "tool-elapsed";
|
||||
line.appendChild(el);
|
||||
toolLineEl.appendChild(el);
|
||||
}
|
||||
el.textContent = `(${secs}s)`;
|
||||
}, 1000);
|
||||
@@ -1521,6 +1611,7 @@ function settleToolLine() {
|
||||
toolLineTimer = 0;
|
||||
}
|
||||
toolLineWrap?.querySelectorAll?.(".tool-elapsed").forEach((el) => el.remove());
|
||||
toolLineEl = null; // phase 158: forget the line target (stopToolLineClock resets via here)
|
||||
}
|
||||
|
||||
/* The state-machine entry (next to stopThinkingClock / clearTurnTimeout
|
||||
@@ -2075,6 +2166,91 @@ export function clearStoredConversation() {
|
||||
}
|
||||
}
|
||||
|
||||
/* Phase 158 (task 03, D4/D5): the RESTORE of a record's `segments`
|
||||
* array — the per-run timeline, COLLAPSED. A record WITH segments
|
||||
* (the live per-run save — task 02's buildSegments) renders each
|
||||
* contiguous thinking run as its own details.thinking block (the
|
||||
* plain Thinking literal, D3 — position distinguishes the runs) and
|
||||
* each CONTIGUOUS run of tool entries as ONE
|
||||
* details.tool-calls-disclosure (the phase-117 structure via
|
||||
* createToolDisclosure — the shared builder, innerHTML ABSENT), with
|
||||
* the per-run "Tool call(s) (N)" count riding appendToolLine's
|
||||
* summary update (the SAME container signature as the live path). All
|
||||
* blocks are created CLOSED (D5 — the restore renders collapsed; the
|
||||
* live path opens while its frames stream) and inserted BEFORE the
|
||||
* bubble (D2, in arrival order). The tOffset/tLimit/tPath
|
||||
* well-formedness guards (Number.isInteger(x) && x >= 1 / the string
|
||||
* guard) and the phase-95 appendTruncatedNote call move VERBATIM from
|
||||
* the legacy m.tools loop (the phase-139/146 restore precedent) — a
|
||||
* malformed entry (no name / a non-string text) or an entry with an
|
||||
* UNKNOWN `kind` is SKIPPED: never render from storage — the
|
||||
* textContent-only house rule keeps a corrupted row from poisoning
|
||||
* the render. The thinking text flows through the SAME escape-first
|
||||
* renderMarkdown write the legacy path uses (XSS-safe; the structure
|
||||
* is createElement/textContent — the only HTML write is that
|
||||
* renderMarkdown into .thinking-text). A record WITHOUT segments
|
||||
* never reaches here — the legacy path restores it byte-identically.
|
||||
* The restore path NEVER arms the elapsed clock (A6 — the live
|
||||
* frames only do that). */
|
||||
function restoreTimeline(wrap, segments) {
|
||||
const body = wrap?.querySelector?.(".msg-body");
|
||||
if (!body) return;
|
||||
const bubble = body.querySelector(".bubble");
|
||||
let toolDisc = null; // the CURRENT run of consecutive tool entries (one disclosure per run, D1)
|
||||
for (const entry of segments || []) {
|
||||
// a malformed entry (or one with an unknown `kind`) is SKIPPED —
|
||||
// never render from storage (textContent only, the house rule).
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
if (entry.kind === "thinking") {
|
||||
if (typeof entry.text !== "string") continue; // malformed: skipped
|
||||
if (toolDisc) toolDisc = null; // the run is sealed — its count rode the last append
|
||||
const block = document.createElement("details");
|
||||
block.className = "thinking";
|
||||
// D5: created CLOSED — the restore renders collapsed (the
|
||||
// live per-run path owns the open state; the live builders
|
||||
// are the only place a block starts open).
|
||||
const summary = document.createElement("summary");
|
||||
summary.textContent = "Thinking"; // the phase-17 literal (D3 — position distinguishes the runs)
|
||||
const textEl = document.createElement("div");
|
||||
textEl.className = "thinking-text";
|
||||
textEl.innerHTML = renderMarkdown(entry.text); // the existing restore convention — escape-first, XSS-safe
|
||||
block.append(summary, textEl);
|
||||
body.insertBefore(block, bubble); // D2: above the bubble, in arrival order
|
||||
} else if (entry.kind === "tool") {
|
||||
if (typeof entry.name !== "string") continue; // malformed: skipped
|
||||
if (!toolDisc) {
|
||||
toolDisc = createToolDisclosure(); // the phase-117 structure (innerHTML ABSENT)
|
||||
// D5: created CLOSED — details defaults closed; explicit for the pin.
|
||||
toolDisc.open = false;
|
||||
body.insertBefore(toolDisc, bubble); // D2: above the bubble, in arrival order
|
||||
}
|
||||
const arg =
|
||||
typeof entry.argument === "string" && entry.argument
|
||||
? entry.argument
|
||||
: null;
|
||||
// Phase 139/146 restore guards — VERBATIM from the legacy loop
|
||||
// (the phase-139/146 restore precedent): a record saved before
|
||||
// the phase (no fields) renders byte-identically.
|
||||
const tOffset =
|
||||
Number.isInteger(entry.offset) && entry.offset >= 1 ? entry.offset : null;
|
||||
const tLimit =
|
||||
Number.isInteger(entry.limit) && entry.limit >= 1 ? entry.limit : null;
|
||||
const tPath = typeof entry.path === "string" && entry.path ? entry.path : null;
|
||||
appendToolLine(toolDisc, entry.name, arg, tOffset, tLimit, tPath);
|
||||
// Phase 95: a stored truncation stamp re-renders the SAME marker
|
||||
// next to its Reading line (moved verbatim inside the grouping;
|
||||
// old records without the field render unchanged).
|
||||
if (entry.truncated && arg) {
|
||||
appendTruncatedNote(wrap, arg, Number(entry.chars_shown) || 0, Number(entry.chars_total) || 0);
|
||||
}
|
||||
}
|
||||
// an entry with an unknown `kind` (or a malformed shape) is
|
||||
// SKIPPED — never render from storage (textContent only, the
|
||||
// house rule); a guard comment names the skip.
|
||||
}
|
||||
if (toolDisc) toolDisc.open = false; // defensive — already closed by construction
|
||||
}
|
||||
|
||||
function renderStoredMessage(m) {
|
||||
// Phase-14 restore landing (kept by the phase-42 direction): the
|
||||
// one-shot load-time scroll — scroll=true so a restored conversation
|
||||
@@ -2102,47 +2278,83 @@ function renderStoredMessage(m) {
|
||||
return;
|
||||
}
|
||||
const wrap = addMessage("brain", renderMarkdown(m.text), true);
|
||||
if (m.thinking) {
|
||||
// Phase 17: restore the thinking block COLLAPSED above the bubble.
|
||||
const block = ensureThinkingBlock(wrap);
|
||||
block.open = false;
|
||||
block.querySelector(".thinking-text").innerHTML = renderMarkdown(m.thinking);
|
||||
}
|
||||
if (Array.isArray(m.tools)) {
|
||||
// Phase 37: restore the tool lines in saved order through the SAME
|
||||
// append helper as the live frames (no HTML from storage, ever).
|
||||
// Phase 95: a stored truncation record (truncated + the counts, the
|
||||
// live tool_result frame's stamp) re-renders the SAME marker next to
|
||||
// its Reading line — old records without the field render
|
||||
// unchanged (t.truncated falsy → no marker).
|
||||
for (const t of m.tools) {
|
||||
if (!t || typeof t.name !== "string") continue;
|
||||
const arg =
|
||||
typeof t.argument === "string" && t.argument ? t.argument : null;
|
||||
// Phase 139 (task 02): a saved range read (the live toolAcc
|
||||
// stamp — additive on the bor.chat.v1 record, the phase-95
|
||||
// truncated-fields precedent) re-renders the SAME suffix through
|
||||
// the SAME helper: the same Number.isInteger guard → a record
|
||||
// saved before the phase (no fields) renders byte-identically
|
||||
// (the phase-95 truncation note is untouched — it keys on the
|
||||
// <code> argument, not the range).
|
||||
const tOffset =
|
||||
Number.isInteger(t.offset) && t.offset >= 1 ? t.offset : null;
|
||||
const tLimit =
|
||||
Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null;
|
||||
// Phase 146 (task 02): a saved SCOPED grep (the live toolAcc
|
||||
// stamp — the additive `path` key on the bor.chat.v1 record)
|
||||
// re-renders the SAME scope through the SAME helper: the same
|
||||
// string guard → a record saved before the phase (no `path`
|
||||
// key) renders byte-identically (the phase-95 truncation note
|
||||
// is untouched — it keys on the <code> argument, not the scope).
|
||||
const tPath = typeof t.path === "string" && t.path ? t.path : null;
|
||||
appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath);
|
||||
if (t.truncated && arg) {
|
||||
appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0, Number(t.chars_total) || 0);
|
||||
// Phase 158 (task 03, D4): a record WITH `segments` renders the
|
||||
// per-run timeline through restoreTimeline (collapsed) — the runs
|
||||
// carry the SAME data as `m.thinking` + `m.tools`, in order, so
|
||||
// the legacy blocks are SKIPPED (rendering both would double the
|
||||
// runs). A record WITHOUT `segments` — every pre-phase record —
|
||||
// restores BYTE-IDENTICALLY through the legacy path below; its
|
||||
// folded-at-rest result is the same as the pre-phase code's
|
||||
// (the pre-phase settle pair became closeAllRuns(wrap) — task 02 —
|
||||
// the legacy thinking block is created closed and the legacy
|
||||
// disclosure opens during the loop and folds below, phase 117 D3).
|
||||
if (Array.isArray(m.segments) && m.segments.length) {
|
||||
restoreTimeline(wrap, m.segments);
|
||||
} else {
|
||||
if (m.thinking) {
|
||||
// Phase 17: restore the thinking block COLLAPSED above the bubble.
|
||||
// Phase 158 (task 02): the builder is SHARED with the live per-run
|
||||
// path (createThinkingBlock) — the legacy path keeps ONE collapsed
|
||||
// block (the full concatenation). A record WITH `segments` takes
|
||||
// the restoreTimeline dispatch instead (task 03 — segments take
|
||||
// precedence; this legacy path stays for pre-phase records).
|
||||
const block = createThinkingBlock();
|
||||
block.open = false;
|
||||
wrap
|
||||
.querySelector(".msg-body")
|
||||
.insertBefore(block, wrap.querySelector(".bubble"));
|
||||
block.querySelector(".thinking-text").innerHTML = renderMarkdown(m.thinking);
|
||||
}
|
||||
if (Array.isArray(m.tools)) {
|
||||
// Phase 37: restore the tool lines in saved order through the SAME
|
||||
// append helper as the live frames (no HTML from storage, ever).
|
||||
// Phase 95: a stored truncation record (truncated + the counts, the
|
||||
// live tool_result frame's stamp) re-renders the SAME marker next to
|
||||
// its Reading line — old records without the field render
|
||||
// unchanged (t.truncated falsy → no marker).
|
||||
// Phase 158 (task 02): the single-container derivation moved out of
|
||||
// appendToolLine (the caller passes the run's disclosure) — the
|
||||
// legacy path keeps ONE disclosure per message (all m.tools),
|
||||
// created with the live lifecycle (open during the loop, folded
|
||||
// below) so a restored turn is one compact "Tool calls (N)" line
|
||||
// (phase 117, D3), exactly as before.
|
||||
let restoreDisc = null;
|
||||
for (const t of m.tools) {
|
||||
if (!t || typeof t.name !== "string") continue;
|
||||
const arg =
|
||||
typeof t.argument === "string" && t.argument ? t.argument : null;
|
||||
// Phase 139 (task 02): a saved range read (the live toolAcc
|
||||
// stamp — additive on the bor.chat.v1 record, the phase-95
|
||||
// truncated-fields precedent) re-renders the SAME suffix through
|
||||
// the SAME helper: the same Number.isInteger guard → a record
|
||||
// saved before the phase (no fields) renders byte-identically
|
||||
// (the phase-95 truncation note is untouched — it keys on the
|
||||
// <code> argument, not the range).
|
||||
const tOffset =
|
||||
Number.isInteger(t.offset) && t.offset >= 1 ? t.offset : null;
|
||||
const tLimit =
|
||||
Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null;
|
||||
// Phase 146 (task 02): a saved SCOPED grep (the live toolAcc
|
||||
// stamp — the additive `path` key on the bor.chat.v1 record)
|
||||
// re-renders the SAME scope through the SAME helper: the same
|
||||
// string guard → a record saved before the phase (no `path`
|
||||
// key) renders byte-identically (the phase-95 truncation note
|
||||
// is untouched — it keys on the <code> argument, not the scope).
|
||||
const tPath = typeof t.path === "string" && t.path ? t.path : null;
|
||||
if (!restoreDisc) {
|
||||
restoreDisc = createToolDisclosure();
|
||||
restoreDisc.open = true; // the live lifecycle — folded below (D3)
|
||||
wrap
|
||||
.querySelector(".msg-body")
|
||||
.insertBefore(restoreDisc, wrap.querySelector(".bubble"));
|
||||
}
|
||||
appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath);
|
||||
if (t.truncated && arg) {
|
||||
appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0, Number(t.chars_total) || 0);
|
||||
}
|
||||
}
|
||||
if (restoreDisc) restoreDisc.open = false; // phase 117 (D3): a restored turn renders folded
|
||||
}
|
||||
closeToolCalls(wrap); // phase 117 (D3): a restored turn renders folded
|
||||
}
|
||||
if (m.deflected) {
|
||||
wrap.classList.add("is-deflected");
|
||||
@@ -2994,19 +3206,25 @@ async function handleSend(e) {
|
||||
* the 300s abort (the `aborted` branch), and the zero-frame-but-completed
|
||||
* case (the stream settled — runTurn's inline fallback branch keeps its
|
||||
* EMPTY_ANSWER_FALLBACK bubble and only gains the failed marker). */
|
||||
function finalizeFailedTurn(detail, { acc, thinking, tools, wrap, leavePartialIndex }) {
|
||||
function finalizeFailedTurn(
|
||||
detail,
|
||||
{ acc, thinking, tools, segments, wrap, leavePartialIndex }
|
||||
) {
|
||||
const error = (detail || "").trim().slice(0, 500);
|
||||
if (wrap) {
|
||||
// Partial streamed: keep the answer text the user saw, settle the
|
||||
// block + calls closed (the stop-finalize pattern), mark it failed.
|
||||
closeThinkingBlock(wrap);
|
||||
closeToolCalls(wrap);
|
||||
// Partial streamed: keep the answer text the user saw, settle every
|
||||
// open run closed (phase 158, D5 — the stop-finalize pattern),
|
||||
// mark it failed.
|
||||
closeAllRuns(wrap);
|
||||
appendFailedNote(wrap, error);
|
||||
rememberBrainTurn(
|
||||
acc,
|
||||
{
|
||||
thinking: thinking || undefined,
|
||||
tools: tools.length ? tools : undefined,
|
||||
// Phase 158 (D4): the failed partial's timeline persists too
|
||||
// (non-empty only — the undefined-drops-the-key house pattern).
|
||||
segments: segments?.length ? segments : undefined,
|
||||
failed: true,
|
||||
error: error || undefined,
|
||||
},
|
||||
@@ -3083,6 +3301,8 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// turn yet (the pagehide handler records it if one lands).
|
||||
acc = "";
|
||||
thinkingAcc = "";
|
||||
segRuns = []; // phase 158: a fresh turn starts with no runs (the pagehide partial reads the current turn's runs)
|
||||
currentRun = null;
|
||||
persistedOnLeave = false;
|
||||
leftThePage = false;
|
||||
leavePartialIndex = -1;
|
||||
@@ -3168,24 +3388,24 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// "thinking" (the button stays the enabled "Stop" control —
|
||||
// phase 48 — #send-status unchanged) — the live block simply
|
||||
// replaces the typing dots as the visible feedback.
|
||||
// Phase 109 (TODO.md L3, D15): the block is a TOGGLE —
|
||||
// open-while-thinking, closed-while-answering. The re-open
|
||||
// assignment below (right after ensureThinkingBlock) re-opens a
|
||||
// block a previous `delta` closed — the next agent round's
|
||||
// thinking (the model answered, called a tool, then thinks
|
||||
// again) no longer lands in an invisible collapsed scratchpad;
|
||||
// while the block is already open (the pre-delta live flow) it
|
||||
// is a no-op — that flow is unchanged.
|
||||
// Phase 158 (D1, owner direction 2026-10-04): PER-RUN blocks —
|
||||
// the D15 "re-open the one shared block" is superseded. Each
|
||||
// CONTIGUOUS thinking run streams into its OWN
|
||||
// details.thinking (ensureThinkingRun: a frame of a new kind
|
||||
// closes the previous run and opens a new block; D5 — the new
|
||||
// block is OPEN while its frames stream). A post-delta
|
||||
// thinking frame (the next agent round: the model answered,
|
||||
// called a tool, then thinks again) lands in a NEW block above
|
||||
// the answer — not a re-open of the block that sits on top of
|
||||
// it (the owner's "thinking resumed above the answer" awkwardness
|
||||
// is gone; the runs stay in arrival order, D2).
|
||||
thinkingAcc += ev.text || "";
|
||||
sawThinking = true;
|
||||
clearTurnTimeout(); // the stream is alive — as the first delta says
|
||||
if (!wrap) wrap = addMessage("brain", "");
|
||||
removeTyping(); // the live block replaces the dots as feedback
|
||||
const block = ensureThinkingBlock(wrap);
|
||||
// Phase 109 (D15): open-while-thinking — idempotent: a no-op
|
||||
// while already open (the pre-delta live flow), a re-open after
|
||||
// a `delta` closed the block (the next agent round).
|
||||
block.open = true;
|
||||
const block = ensureThinkingRun(wrap);
|
||||
currentRun.run.text += ev.text || ""; // the RUN's text — the per-run block renders it
|
||||
const textEl = block.querySelector(".thinking-text");
|
||||
// Pin state is measured BEFORE the re-render: a chunk taller
|
||||
// than the 32px band — any paragraph break ("\n\n") or a few
|
||||
@@ -3194,9 +3414,11 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// the distance AFTER the update reads the chunk's height, not
|
||||
// the user's position, and the follow died at the first
|
||||
// 2-newline gap. Pre-update, the distance is where the user
|
||||
// actually is.
|
||||
// actually is. Phase 158: the same pin logic, now operating on
|
||||
// THIS block (per-run — each run's block pins its own tail;
|
||||
// the pre-update capture is verbatim).
|
||||
const pinned = block.open && isThinkingNearBottom(textEl);
|
||||
textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe
|
||||
textEl.innerHTML = renderMarkdown(currentRun.run.text); // escape-first, XSS-safe
|
||||
if (pinned) {
|
||||
// Follow the live tail only while the user was pinned to the
|
||||
// window bottom before this chunk (owner direction
|
||||
@@ -3275,12 +3497,33 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
.querySelector("#typing-indicator .bubble")
|
||||
?.setAttribute("aria-label", toolStatus);
|
||||
}
|
||||
appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath);
|
||||
// Phase 158 (D1): PER-RUN disclosure — each CONTIGUOUS tool-call
|
||||
// run streams into its OWN details.tool-calls-disclosure
|
||||
// (ensureToolRun: a frame of a new kind closes the previous run
|
||||
// and opens a new disclosure; D5 — OPEN while its frames stream),
|
||||
// inserted BEFORE the bubble in arrival order (D2). The record
|
||||
// rides the run's `records` too — the SAME toolAcc objects, so
|
||||
// the phase-95 tool_result stamp (truncated / chars_shown /
|
||||
// chars_total) flows into the segments payload with no extra
|
||||
// wiring.
|
||||
const disc = ensureToolRun(wrap);
|
||||
currentRun.run.records.push(toolRecord);
|
||||
const line = appendToolLine(
|
||||
disc,
|
||||
name,
|
||||
argument,
|
||||
frameOffset,
|
||||
frameLimit,
|
||||
framePath
|
||||
);
|
||||
// Phase 87 (TODO.md L5): arm this line's elapsed clock — the
|
||||
// baseline resets per line, so each line counts its OWN
|
||||
// frameless silence. This live branch is the ONLY arm call site
|
||||
// (A6): the phase-14 restore path re-renders lines without it.
|
||||
armToolLineClock(wrap);
|
||||
// Phase 158: the clock TARGETS this line (the tracked reference
|
||||
// — the `:last-child` query is gone; multiple disclosures make
|
||||
// it the wrong line).
|
||||
armToolLineClock(wrap, line);
|
||||
// No page scroll (phase 42): tool lines never yank the viewport.
|
||||
} else if (ev.type === "retry") {
|
||||
// Phase 87 (TODO.md L5): a frame arrived — settle the latest
|
||||
@@ -3315,14 +3558,20 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
acc += ev.text || "";
|
||||
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
|
||||
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
|
||||
closeThinkingBlock(wrap); // auto-collapse; idempotent — the next `thinking` frame re-opens it (phase 109, D15)
|
||||
closeToolCalls(wrap); // the answer began — fold the tool record (phase 117, D3)
|
||||
// Phase 158 (D5): the first delta FOLDS the open run (the
|
||||
// active block closes; a later thinking frame opens a NEW run
|
||||
// above the bubble — the D15 re-open of the shared block is
|
||||
// gone). Idempotent — a no-op without an open run.
|
||||
closeCurrentRun();
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
|
||||
// No page scroll (phase 42): the answer never follows the viewport.
|
||||
} else if (ev.type === "done") {
|
||||
sawDone = true;
|
||||
closeThinkingBlock(wrap); // the turn is over: settle the block closed
|
||||
closeToolCalls(wrap); // the turn ended — settle the record folded (phase 117, D3)
|
||||
// Phase 158 (D5): the turn is over — fold EVERY open run
|
||||
// (defensive: by construction at most the last run is open; a
|
||||
// late double-close is a no-op). Replaces the phase-117
|
||||
// closeToolCalls fold + the D15 block close.
|
||||
closeAllRuns(wrap);
|
||||
if (!wrap) {
|
||||
setUiState(UI_STATE.streaming);
|
||||
wrap = addMessage("brain", "…");
|
||||
@@ -3365,9 +3614,16 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// Phase 73 (task 02): if a pagehide partial landed mid-turn, the
|
||||
// final record REPLACES it in place (leavePartialIndex = -1 when
|
||||
// there was none — the append, exactly as before).
|
||||
// Phase 158 (D4): the turn's CHRONOLOGICAL run order persists
|
||||
// with the turn — segments (non-empty only: the
|
||||
// undefined-drops-the-key house pattern; a no-thinking/no-tools
|
||||
// turn stores no key). Additive: thinking + tools stay UNCHANGED
|
||||
// (the history wire + the old-client degradation path).
|
||||
const turnSegments = buildSegments(segRuns);
|
||||
rememberBrainTurn(finalText || acc, {
|
||||
thinking: thinkingAcc || undefined,
|
||||
tools: toolAcc.length ? toolAcc : undefined,
|
||||
segments: turnSegments.length ? turnSegments : undefined,
|
||||
deflected: !!ev.deflected,
|
||||
sources: ev.sources,
|
||||
// Phase 124 (task 06, TODO L3): the answer image persists
|
||||
@@ -3449,6 +3705,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
acc,
|
||||
thinking: thinkingAcc,
|
||||
tools: toolAcc,
|
||||
segments: buildSegments(segRuns), // phase 158 (D4): the partial's timeline
|
||||
wrap,
|
||||
leavePartialIndex,
|
||||
});
|
||||
@@ -3489,17 +3746,23 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// The persistedOnLeave guard (phase 20) keeps a navigate-away —
|
||||
// which aborts the same fetch — from saving the partial twice.
|
||||
if (wrap && acc && !persistedOnLeave) {
|
||||
closeThinkingBlock(wrap); // settle the block closed, like `done`
|
||||
closeToolCalls(wrap); // the turn was stopped — fold the record (phase 117, D3)
|
||||
// Phase 158 (D5): the turn was stopped — fold every open run
|
||||
// (replaces the phase-117 closeToolCalls fold + the D15 block
|
||||
// close, like `done`).
|
||||
closeAllRuns(wrap);
|
||||
persistedOnLeave = true;
|
||||
appendTuneButton(wrap); // admin-only; parity with the restore path
|
||||
appendStoppedNote(wrap);
|
||||
// Phase 73 (task 02): the stopped partial REPLACES a pagehide
|
||||
// partial (leavePartialIndex) in place — one brain turn for the
|
||||
// question, marked stopped, never two records.
|
||||
// question, marked stopped, never two records. Phase 158
|
||||
// (D4): the stopped partial's timeline persists too
|
||||
// (non-empty only).
|
||||
const stopSegments = buildSegments(segRuns);
|
||||
rememberBrainTurn(acc, {
|
||||
thinking: thinkingAcc || undefined,
|
||||
tools: toolAcc.length ? toolAcc : undefined,
|
||||
segments: stopSegments.length ? stopSegments : undefined,
|
||||
stopped: true,
|
||||
}, leavePartialIndex);
|
||||
lastBrainWrap = wrap; // the stopped partial is the prime retry candidate
|
||||
@@ -3534,6 +3797,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
acc,
|
||||
thinking: thinkingAcc,
|
||||
tools: toolAcc,
|
||||
segments: buildSegments(segRuns), // phase 158 (D4): the partial's timeline
|
||||
wrap,
|
||||
leavePartialIndex,
|
||||
});
|
||||
@@ -3660,7 +3924,14 @@ window.addEventListener("pagehide", () => {
|
||||
return;
|
||||
if (!acc) return; // nothing brain-side to save yet
|
||||
persistedOnLeave = true;
|
||||
rememberBrainTurn(acc, { thinking: thinkingAcc || undefined });
|
||||
// Phase 158 (D4): the navigate-away partial's timeline persists too
|
||||
// (segRuns is module scope — readable here, like thinkingAcc;
|
||||
// non-empty only — the undefined-drops-the-key house pattern).
|
||||
const pagehideSegments = buildSegments(segRuns);
|
||||
rememberBrainTurn(acc, {
|
||||
thinking: thinkingAcc || undefined,
|
||||
segments: pagehideSegments.length ? pagehideSegments : undefined,
|
||||
});
|
||||
leavePartialIndex = conversation.length - 1; // the record it just pushed
|
||||
});
|
||||
|
||||
|
||||
+134
-3
@@ -247,6 +247,125 @@ function addToolLines(wrap, tools) {
|
||||
body.insertBefore(disclosure, body.querySelector(".bubble"));
|
||||
}
|
||||
|
||||
/* Phase 158 (task 03, D4/D5): the SHARED page's per-run timeline —
|
||||
* the parity copy of the chat page's restoreTimeline: the record's
|
||||
* `segments` array (the per-run order, task 01's ChatMessage field)
|
||||
* renders each CONTIGUOUS thinking run as its own details.thinking
|
||||
* block and each CONTIGUOUS run of tool entries as ONE
|
||||
* details.tool-calls-disclosure ("Tool call(s) (N)" per run), in
|
||||
* arrival order, ABOVE the answer — a pure render, so everything is
|
||||
* created CLOSED (D5; the same collapsed convention as
|
||||
* addThinkingBlock / addToolLines). The structure is
|
||||
* createElement/textContent; the line bytes mirror addToolLines
|
||||
* EXACTLY (the four pinned label literals + the phase-70 legacy
|
||||
* names + the phase-139 readRangeSuffix + the phase-95 truncation
|
||||
* note — the shared-page parity), and the thinking text flows
|
||||
* through the SAME escape-first renderMarkdown write addThinkingBlock
|
||||
* uses (XSS-safe). An entry with an UNKNOWN `kind` (or a malformed
|
||||
* shape) is SKIPPED — never render from storage (textContent only,
|
||||
* the house rule). A record WITHOUT `segments` never reaches here —
|
||||
* the dispatch keeps the legacy addThinkingBlock + addToolLines path
|
||||
* byte-identical. */
|
||||
function addSegments(wrap, segments) {
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
if (!body) return;
|
||||
const bubble = body.querySelector(".bubble");
|
||||
let toolDisc = null; // the CURRENT run of consecutive tool entries (one disclosure per run, D1)
|
||||
for (const entry of segments || []) {
|
||||
// a malformed entry (or one with an unknown `kind`) is SKIPPED —
|
||||
// never render from storage (textContent only, the house rule).
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
if (entry.kind === "thinking") {
|
||||
if (typeof entry.text !== "string") continue; // malformed: skipped
|
||||
if (toolDisc) toolDisc = null; // the run is sealed — the count is final
|
||||
const block = document.createElement("details");
|
||||
block.className = "thinking";
|
||||
block.open = false; // D5: collapsed (the addThinkingBlock convention)
|
||||
const summary = document.createElement("summary");
|
||||
summary.textContent = "Thinking"; // the phase-17 literal (D3 — position distinguishes the runs)
|
||||
const textEl = document.createElement("div");
|
||||
textEl.className = "thinking-text";
|
||||
textEl.innerHTML = renderMarkdown(entry.text); // escape-first, XSS-safe (parity with addThinkingBlock)
|
||||
block.append(summary, textEl);
|
||||
body.insertBefore(block, bubble); // D2: above the bubble, in arrival order
|
||||
} else if (entry.kind === "tool") {
|
||||
if (typeof entry.name !== "string") continue; // malformed: skipped
|
||||
if (!toolDisc) {
|
||||
toolDisc = document.createElement("details");
|
||||
toolDisc.className = "tool-calls-disclosure";
|
||||
toolDisc.open = false; // D5: collapsed (parity with addToolLines — phase 117 D3)
|
||||
const summary = document.createElement("summary");
|
||||
summary.className = "tool-calls-summary";
|
||||
toolDisc.appendChild(summary);
|
||||
const container = document.createElement("div");
|
||||
container.className = "tool-calls";
|
||||
container.setAttribute("role", "list");
|
||||
container.setAttribute("aria-label", "Tool calls");
|
||||
toolDisc.appendChild(container);
|
||||
body.insertBefore(toolDisc, bubble); // D2: above the bubble, in arrival order
|
||||
}
|
||||
const container = toolDisc.querySelector(".tool-calls");
|
||||
const line = document.createElement("span");
|
||||
line.className = "tool-call";
|
||||
line.setAttribute("role", "listitem");
|
||||
const argument =
|
||||
typeof entry.argument === "string" && entry.argument
|
||||
? entry.argument
|
||||
: null;
|
||||
// The line bytes MIRROR addToolLines exactly (the shared-page
|
||||
// parity — phase 70: new names first, legacy names kept; the
|
||||
// phase-139 range suffix; the phase-95 truncation note).
|
||||
if ((entry.name === "read" || entry.name === "read_document") && argument) {
|
||||
line.textContent = "📄 Reading ";
|
||||
const code = document.createElement("code");
|
||||
code.textContent = argument; // the path is data, never markup
|
||||
line.appendChild(code);
|
||||
const rangeSuffix = readRangeSuffix(entry.offset, entry.limit);
|
||||
if (rangeSuffix) {
|
||||
const range = document.createElement("span");
|
||||
range.className = "read-range";
|
||||
range.textContent = rangeSuffix; // the range is data, never markup
|
||||
line.appendChild(range);
|
||||
}
|
||||
} else if (
|
||||
(entry.name === "grep" || entry.name === "search_documents") && argument
|
||||
) {
|
||||
line.textContent = "🔎 Searching for ";
|
||||
const code = document.createElement("code");
|
||||
code.textContent = argument; // the pattern is data, never markup
|
||||
line.appendChild(code);
|
||||
} else if (entry.name === "ls" && argument) {
|
||||
line.textContent = "🔎 Listing documents in ";
|
||||
const code = document.createElement("code");
|
||||
code.textContent = argument; // the source scope is data, never markup
|
||||
line.appendChild(code);
|
||||
} else {
|
||||
line.textContent = "🔎 Listing documents";
|
||||
}
|
||||
container.appendChild(line);
|
||||
// Phase 95: the stored truncation record — the same marker
|
||||
// addToolLines renders (plain integers, no separators); only
|
||||
// argument-bearing (Reading) lines can carry it.
|
||||
if (entry.truncated && argument) {
|
||||
const note = document.createElement("span");
|
||||
note.className = "truncated-note";
|
||||
note.textContent =
|
||||
" (truncated — showing " + (Number(entry.chars_shown) || 0) + " of " + (Number(entry.chars_total) || 0) + " chars)";
|
||||
line.appendChild(note);
|
||||
}
|
||||
// The per-run count (parity with addToolLines — one disclosure
|
||||
// per contiguous run, D3) rides every append; the final
|
||||
// append leaves the final count.
|
||||
const n = container.children.length;
|
||||
toolDisc.querySelector("summary").textContent =
|
||||
`Tool call${n === 1 ? "" : "s"} (${n})`;
|
||||
}
|
||||
}
|
||||
// an entry with an unknown `kind` (or a malformed shape) is
|
||||
// SKIPPED — never render from storage (textContent only, the
|
||||
// house rule); a guard comment names the skip.
|
||||
}
|
||||
|
||||
/* Phase 139 (task 01): the shared page's parity copy of app.js's
|
||||
* readRangeSuffix — the requested line range on the Reading line. The
|
||||
* saved record now carries the chat page's offset/limit (the toolAcc
|
||||
@@ -453,10 +572,22 @@ function renderSharedMessage(m) {
|
||||
return;
|
||||
}
|
||||
const wrap = addSharedMessage("brain", renderMarkdown(m.text));
|
||||
if (typeof m.thinking === "string" && m.thinking) {
|
||||
addThinkingBlock(wrap, m.thinking);
|
||||
// Phase 158 (task 03, D4): a record WITH `segments` renders the
|
||||
// per-run timeline through addSegments (collapsed — the parity
|
||||
// copy of the chat page's restoreTimeline); the runs carry the
|
||||
// SAME data as `m.thinking` + `m.tools`, in order, so the legacy
|
||||
// addThinkingBlock + addToolLines are SKIPPED (rendering both
|
||||
// would double the runs). A record WITHOUT `segments` — every
|
||||
// pre-phase record — renders BYTE-IDENTICALLY through the legacy
|
||||
// path below.
|
||||
if (Array.isArray(m.segments) && m.segments.length) {
|
||||
addSegments(wrap, m.segments);
|
||||
} else {
|
||||
if (typeof m.thinking === "string" && m.thinking) {
|
||||
addThinkingBlock(wrap, m.thinking);
|
||||
}
|
||||
addToolLines(wrap, m.tools);
|
||||
}
|
||||
addToolLines(wrap, m.tools);
|
||||
if (m.deflected) {
|
||||
wrap.classList.add("is-deflected");
|
||||
addMaybeTry(wrap, m.suggestions);
|
||||
|
||||
+203
-1
@@ -153,6 +153,59 @@ Implements just enough of the aipi surface:
|
||||
offered — e.g. ``agent_max_rounds=0``) behave exactly as today.
|
||||
``E2E_REAL_LLM=1`` ignores the mock entirely (the real model does
|
||||
what it does).
|
||||
- user message containing ``think out loud and use your tools``
|
||||
(``THINK_TOOLS_TRIGGER``, phase 158 task 04 — the
|
||||
thinking/tool-call timeline story's dedicated suite
|
||||
``tests/e2e/test_thinking_timeline.py``) **and** the system
|
||||
prompt carries the ``<tools>`` section -> the deterministic
|
||||
INTERLEAVED tool flow: the phase-37 single-read shape (list →
|
||||
drill → read → the quoted answer) with a DISTINCT
|
||||
``reasoning_content`` run streamed AHEAD of every tool call and
|
||||
ahead of the final answer — the wire order the client's
|
||||
per-run timeline renders (Thinking → Tool call (1) → Thinking →
|
||||
Tool call (1) → Thinking → Tool call (1) → Thinking → answer).
|
||||
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 list/drill/read
|
||||
steps (a no-tools request with no tool results is not the flow),
|
||||
and the answer is reached regardless of the ``tools`` parameter
|
||||
(phase 45 keeps the tools offered until the round cap):
|
||||
* request 1 (``tools`` offered, no tool results yet): stream
|
||||
``reasoning_content`` (``Step 1: list the documents to find
|
||||
the reference server config.``) FIRST — the 12-char
|
||||
``_sse_stream`` convention — then ONLY ``tool_calls``
|
||||
deltas: ``ls`` (synthetic id ``call_0``, no arguments),
|
||||
``finish_reason: "tool_calls"``;
|
||||
* request 2 (the top-level source listing in the messages —
|
||||
the agent's ``ls`` tree format, phase 94: no file lines
|
||||
yet): ``Step 2: drill into the first source.`` first, then
|
||||
``tool_calls`` — ``ls`` scoped to the FIRST source of the
|
||||
listing (id ``call_1``) — the drill step (``_drill_target``
|
||||
skips already-drilled sources, so the second listing is the
|
||||
folder level);
|
||||
* request 3 (a ``tool``-role folder listing with file lines in
|
||||
the messages — the labeled ``source:`` / ``path:`` fields,
|
||||
phase 63/70): ``Step 3: read the file line's document.``
|
||||
first, then ``tool_calls`` — ``read`` on the JOINED
|
||||
combined ``source/path`` of the FIRST file line (id
|
||||
``call_2``);
|
||||
* request 4 (a ``tool``-role read result in the messages —
|
||||
content starting with the agent's ``"Document
|
||||
<source/path>:"`` header): ``Step 4: compose the answer from
|
||||
the read document.`` first, then the phase-37 content
|
||||
answer, deterministic: ``Read <source/path>. <first 80
|
||||
chars of the read document's content>`` — so a suite can
|
||||
assert the read document reached the model and landed in
|
||||
the answer.
|
||||
Checked BEFORE the plain ``use your tools`` flow: the trigger
|
||||
CONTAINS ``use your tools`` (and ``think out loud``), so the
|
||||
plain tool flow must not see it (the more-specific-phrase-wins
|
||||
convention — the ``SLOW_PRETOKEN_TRIGGER`` / ``THINK_PARAS_-
|
||||
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 ``search your documents``
|
||||
(``SEARCH_TRIGGER``, phase 68 search tool — renamed to the
|
||||
harness-aligned ``grep`` in phase 70, same match/output contract)
|
||||
@@ -993,6 +1046,26 @@ LONG_ANSWER_END = "LONG-ANSWER-END"
|
||||
#: contain the substring, so every other suite is unaffected.
|
||||
THINKING_TRIGGER = "think out loud"
|
||||
|
||||
#: Phase 158 (task 04 — the thinking/tool-call timeline story): a
|
||||
#: user message containing this substring (case-insensitive) —
|
||||
#: combined with the ``<tools>`` section in the system prompt —
|
||||
#: drives the deterministic INTERLEAVED flow documented in the module
|
||||
#: docstring: a DISTINCT ``reasoning_content`` run ahead of EVERY
|
||||
#: tool call and ahead of the final answer (the ``Step N`` markers
|
||||
#: make the runs individually assertable). Verified 2026-10-04: no
|
||||
#: existing E2E question or fixture file contains the phrase, so
|
||||
#: every other suite is unaffected. NOTE: the phrase CONTAINS
|
||||
#: ``THINKING_TRIGGER`` ("think out loud") and ``TOOLS_TRIGGER``
|
||||
#: ("use your tools") — the more-specific-phrase-wins convention
|
||||
#: (``SLOW_PRETOKEN_TRIGGER`` / ``THINK_PARAS_TRIGGER`` /
|
||||
#: ``SCAFFOLD_ALWAYS_TRIGGER``): the streaming dispatch checks the
|
||||
#: flow BEFORE the plain ``_tool_flow`` (so the plain tool flow
|
||||
#: 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.
|
||||
THINK_TOOLS_TRIGGER = "think out loud and use your tools"
|
||||
|
||||
#: 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``
|
||||
@@ -1907,6 +1980,72 @@ def _tool_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
||||
return ("list", "", "")
|
||||
|
||||
|
||||
def _thinking_tools_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
||||
"""Classify a phase-158 marker request into one step of the
|
||||
INTERLEAVED thinking + tool flow (see the module docstring).
|
||||
|
||||
The phase-37 single-read shape — list → drill → read → the
|
||||
quoted answer — with a DISTINCT ``reasoning_content`` run
|
||||
streamed AHEAD of every tool call and ahead of the final answer
|
||||
(the ``Step N`` markers make each run individually assertable —
|
||||
the per-run timeline the story E2E pins):
|
||||
|
||||
* ``("list", <step 1 thinking>)`` — ``tools`` are offered and no
|
||||
tool results are in the messages yet: the model thinks, then
|
||||
lists the catalog (``ls``, id ``call_0``, no arguments).
|
||||
* ``("drill", source, <step 2 thinking>)`` — the top-level
|
||||
source listing is in the messages but no file line yet (phase
|
||||
94: the drill — ``_drill_target`` skips already-drilled
|
||||
sources, so the second listing is the folder level): the model
|
||||
thinks, then drills into the first source (id ``call_1``).
|
||||
* ``("read", "source/path", <step 3 thinking>)`` — a
|
||||
``tool``-role folder listing with file lines: the model
|
||||
thinks, then reads the FIRST file line's document — the
|
||||
labeled ``source:`` / ``path:`` fields (phase 63) joined into
|
||||
the combined ``source/path`` (phase 70), id ``call_2``.
|
||||
* ``("answer", "source/path", content, <step 4 thinking>)`` — a
|
||||
``tool``-role read result (``"Document <source/path>:\n
|
||||
<content>"``): the model thinks, then answers, quoting the
|
||||
read document (the phase-37 shape). 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 THINK_TOOLS_TRIGGER not in user:
|
||||
return None
|
||||
if "<tools>" not in _system(body):
|
||||
return None
|
||||
reads = _read_results(body)
|
||||
if reads:
|
||||
sp, content = reads[0]
|
||||
return (
|
||||
"answer",
|
||||
sp,
|
||||
content,
|
||||
"Step 4: compose the answer from the read document.",
|
||||
)
|
||||
if not body.get("tools"):
|
||||
return None
|
||||
first = _first_file_line(body)
|
||||
if first:
|
||||
match = _CATALOG_LINE_RE.match(first)
|
||||
if match:
|
||||
return (
|
||||
"read",
|
||||
f"{match.group('source')}/{match.group('path')}",
|
||||
"Step 3: read the file line's document.",
|
||||
)
|
||||
return None # a malformed line (never at runtime — defensive)
|
||||
drill = _drill_target(body)
|
||||
if drill is not None:
|
||||
return ("drill", drill, "Step 2: drill into the first source.")
|
||||
return ("list", "Step 1: list the documents to find the reference server config.")
|
||||
|
||||
|
||||
def _ls_teach_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
||||
"""Classify a phase-72 LS-TEACH request (see the module docstring).
|
||||
|
||||
@@ -3814,7 +3953,12 @@ def _apply_max_tokens(answer: str, max_tokens: Any) -> str:
|
||||
return " ".join(words[:max_tokens])
|
||||
|
||||
|
||||
def _tool_call_stream(name: str, arguments: dict[str, Any], call_id: str) -> Any:
|
||||
def _tool_call_stream(
|
||||
name: str,
|
||||
arguments: dict[str, Any],
|
||||
call_id: str,
|
||||
thinking: str = "",
|
||||
) -> Any:
|
||||
"""SSE frames for one tool-call-only chat completion (phase 37).
|
||||
|
||||
The OpenAI wire convention the app accumulates (``app/rag/llm.py``):
|
||||
@@ -3831,9 +3975,32 @@ def _tool_call_stream(name: str, arguments: dict[str, Any], call_id: str) -> Any
|
||||
from the first ``tool`` frame until the first answer ``delta``) is a
|
||||
comfortable observation window for the story E2E (~1 s across the
|
||||
two tool requests).
|
||||
|
||||
Phase 158 (task 04 — the phase-158 flow): the optional fourth
|
||||
parameter — when ``thinking`` is non-empty its 12-char
|
||||
``_sse_stream``-convention slices go out FIRST as
|
||||
``delta.reasoning_content`` frames (the 0.02 s cadence, the
|
||||
aipi wire convention — reasoning before the tool call), then the
|
||||
tool-call frames + ``finish_reason: "tool_calls"`` below are
|
||||
UNCHANGED. Default ``""`` → every pre-phase 3-arg caller
|
||||
streams BYTE-IDENTICAL (the other marker flows' byte-
|
||||
identity pins stay green).
|
||||
"""
|
||||
model = "turbo"
|
||||
chunk_id = f"chatcmpl-{uuid.uuid4()}"
|
||||
if thinking:
|
||||
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)
|
||||
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):
|
||||
@@ -4449,6 +4616,41 @@ def chat_completions(body: dict[str, Any]) -> Any:
|
||||
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
|
||||
# final answer (see the module docstring). Checked BEFORE
|
||||
# the plain TOOLS_TRIGGER flow: the trigger CONTAINS
|
||||
# ``use your tools`` (and ``think out loud``), so the plain
|
||||
# tool flow must not see it (the more-specific-phrase-wins
|
||||
# convention — the SLOW_PRETOKEN / THINK_PARAS / SCAFFOLD_
|
||||
# ALWAYS precedent; verified 2026-10-04: no existing E2E
|
||||
# question contains the phrase, so every other suite is
|
||||
# unaffected). The trigger needs the ``<tools>`` section, so
|
||||
# deflected turns never hit it.
|
||||
think_tools = _thinking_tools_flow(body)
|
||||
if think_tools is not None:
|
||||
if think_tools[0] == "list":
|
||||
stream = _tool_call_stream("ls", {}, "call_0", think_tools[1])
|
||||
elif think_tools[0] == "drill":
|
||||
stream = _tool_call_stream(
|
||||
"ls", {"path": think_tools[1]}, "call_1", think_tools[2]
|
||||
)
|
||||
elif think_tools[0] == "read":
|
||||
stream = _tool_call_stream(
|
||||
"read", {"path": think_tools[1]}, "call_2", think_tools[2]
|
||||
)
|
||||
else: # "answer" — the phase-37 shape, thinking first
|
||||
answer = _apply_max_tokens(
|
||||
f"Read {think_tools[1]}. {think_tools[2][:80]}",
|
||||
body.get("max_tokens"),
|
||||
)
|
||||
stream = _sse_stream(answer, 0.0, thinking=think_tools[3])
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
flow = _tool_flow(body)
|
||||
if flow is not None:
|
||||
if flow[0] == "list":
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
"""Phase 158 E2E (Playwright, mock-only): the chronological thinking /
|
||||
tool-call timeline (the owner's 2026-10-04 report — "the thinking block
|
||||
happens first … and then went back to thinking. This is awkward. Then
|
||||
the tool calling happens, and the thinking block reads strangely because
|
||||
the tool calls are shown below the thinking block").
|
||||
|
||||
Source: owner report (live interactive-browser session, 2026-10-04,
|
||||
shared page ``https://brain.reeseapps.com/shared/
|
||||
95dd7a2a-8cda-478b-b694-53936070b9fb`` — a real ``turbo`` turn with 6
|
||||
tool calls). Run in isolation (DB must be up:
|
||||
``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_thinking_timeline.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 and use your tools``
|
||||
(``THINK_TOOLS_TRIGGER``) **and** the system prompt carries the
|
||||
``<tools>`` section of the HIGH prompt): the phase-37 single-read
|
||||
shape (list → drill → read → the quoted answer) with a DISTINCT
|
||||
``reasoning_content`` run streamed AHEAD of every tool call and ahead
|
||||
of the final answer — the ``Step N`` markers make each run
|
||||
individually assertable. The client renders each CONTIGUOUS run in its
|
||||
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).
|
||||
|
||||
KB fixture — mirrored from ``test_agent_document_tools.py`` (the
|
||||
deterministic two-document pair): ``Deployments`` registered FIRST so
|
||||
the mock's drill (first source of the top-level listing) — and
|
||||
therefore the read — lands on the JSON file; 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_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.
|
||||
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
|
||||
``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.
|
||||
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``.
|
||||
"""
|
||||
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_agent_document_tools.py so the flow 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 "for the exact
|
||||
#: JSON shape of reeselink.json" 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. Its FIRST line is
|
||||
#: longer than 80 chars, so the mock's first-80-chars quote content
|
||||
#: part is newline-free (the rendered-text assertions below match it
|
||||
#: after the date line). Pinned by the assert below.
|
||||
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"
|
||||
)
|
||||
assert "\n" not in RECORD_FILE_CONTENT[:63] # the quote's content part stays one line
|
||||
|
||||
#: The phase-158 trigger question (``THINK_TOOLS_TRIGGER`` + the KB
|
||||
#: topic — the ≈0.595 cosine keeps the turn grounded over the mock-
|
||||
#: calibrated E2E threshold).
|
||||
TIMELINE_QUESTION = (
|
||||
"Think out loud and use your tools: what is the exact JSON shape of "
|
||||
"reseelink.json for my aws route53 hosted zone?"
|
||||
)
|
||||
|
||||
#: The NO-thinking control — the plain ``use your tools`` marker
|
||||
#: (``test_agent_document_tools.py`` / ``test_tool_call_compact.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 mock's deterministic single-read answer —
|
||||
#: "Read <source/path>. <first 80 chars of the read document>" (the
|
||||
#: phase-37 shape). Phase 106 (D5): the read result's ``date:`` SECOND
|
||||
#: line rides into the first-80-chars quote — the quote is the date
|
||||
#: line (the fixture's fixed ``created_at`` UTC date part, 17 chars) +
|
||||
#: the first 63 content chars (80 − 17).
|
||||
ANSWER_PREFIX = f"Read {READ_SP}."
|
||||
ANSWER_QUOTE = "date: 2024-06-15" + RECORD_FILE_CONTENT[:63]
|
||||
|
||||
#: The mock's four DISTINCT reasoning runs (the ``Step N`` markers —
|
||||
#: their distinctness proves the runs are separate blocks, not one
|
||||
#: 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.
|
||||
TIMELINE_SEQ = [
|
||||
"details.thinking",
|
||||
"details.tool-calls-disclosure",
|
||||
"details.thinking",
|
||||
"details.tool-calls-disclosure",
|
||||
"details.thinking",
|
||||
"details.tool-calls-disclosure",
|
||||
"details.thinking",
|
||||
]
|
||||
|
||||
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# DB seeding (TRUNCATE-then-seed, cf. test_agent_document_tools.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 mock's drill (first source of the top-level listing)
|
||||
lands on the JSON file deterministically, independent of the
|
||||
operator's ``BOR_GIT_SOURCES`` (a non-empty table ignores the env
|
||||
fallback)."""
|
||||
# COMMIT between the inserts (not flush): ``added_at`` is
|
||||
# ``server_default now()`` — the transaction timestamp — and the
|
||||
# tie-break is the random uuid ``id``, so 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),
|
||||
# Phase 106 (D5): explicit dates — byte-stable prompts/quotes
|
||||
# (the mock's first-80-chars read quote carries the date line).
|
||||
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),
|
||||
)
|
||||
)
|
||||
# The referenced JSON: indexed, catalogued, readable — but NO
|
||||
# chunks, so retrieval never puts it in context (the failure the
|
||||
# tools fix).
|
||||
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 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 = """
|
||||
() => {
|
||||
const wrap = document.querySelector('#messages .msg.brain');
|
||||
if (!wrap) return null;
|
||||
const body = wrap.querySelector('.msg-body');
|
||||
const out = [];
|
||||
for (const child of body.children) {
|
||||
if (child.classList.contains('bubble')) break;
|
||||
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
|
||||
#: the window comfortably open for a 100 ms poll).
|
||||
LIVE_OPEN_JS = """
|
||||
() => {
|
||||
const wrap = document.querySelector('#messages .msg.brain');
|
||||
if (!wrap) return false;
|
||||
const bubble = wrap.querySelector('.msg-body .bubble');
|
||||
if (!bubble || bubble.textContent !== '') return false;
|
||||
return !!wrap.querySelector(
|
||||
'details.thinking[open], details.tool-calls-disclosure[open]');
|
||||
}
|
||||
"""
|
||||
|
||||
#: The phase-14 localStorage record's LAST brain message (the
|
||||
#: ``bor.chat.v1`` — ``{ v: 1, chatId, messages: [...] }``; the
|
||||
#: filter mirrors ``loadStoredRecord``'s well-formedness).
|
||||
READ_RECORD_JS = """
|
||||
() => {
|
||||
const raw = localStorage.getItem('bor.chat.v1');
|
||||
if (!raw) return null;
|
||||
const data = JSON.parse(raw);
|
||||
const brain = (data.messages || []).filter(
|
||||
(m) => m && m.who === 'brain' && typeof m.text === 'string');
|
||||
return brain.length ? brain[brain.length - 1] : null;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
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_share_chat.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/D2: the completed turn renders the 7-element arrival-order
|
||||
# timeline (the runs are separate blocks, not one lump)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_timeline_order_after_completed_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="/")
|
||||
|
||||
_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).
|
||||
seq = page.evaluate(BODY_SEQUENCE_JS)
|
||||
assert seq == TIMELINE_SEQ, (
|
||||
f"the pre-bubble segment sequence is not the timeline: {seq}"
|
||||
)
|
||||
|
||||
# D1/D3: the four thinking blocks carry the DISTINCT ``Step N``
|
||||
# markers — distinctness proves the runs are separate blocks, not
|
||||
# one lump (the pre-phase single-block layout would put all four
|
||||
# markers in one ``.thinking-text``).
|
||||
texts = page.locator("#messages .msg.brain .thinking-text")
|
||||
expect(texts).to_have_count(4)
|
||||
for i, marker in enumerate(STEP_MARKERS):
|
||||
expect(texts.nth(i)).to_contain_text(marker)
|
||||
|
||||
# D3: each tool run's disclosure summary is per-run — one line →
|
||||
# "Tool call (1)" (the phase-117 singular form).
|
||||
summaries = page.locator("#messages .msg.brain .tool-calls-summary")
|
||||
expect(summaries).to_have_count(3)
|
||||
for i in range(3):
|
||||
expect(summaries.nth(i)).to_have_text("Tool call (1)")
|
||||
|
||||
# The answer bubble carries the ``Read <source/path>`` echo (the
|
||||
# read document reached the model and landed in the answer).
|
||||
bubble = page.locator(".msg.brain .bubble").last
|
||||
expect(bubble).to_contain_text(ANSWER_PREFIX)
|
||||
expect(bubble).to_contain_text(ANSWER_QUOTE)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. D5: the runs fold at rest (done settled every open block)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_runs_fold_at_rest(
|
||||
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, 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).
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.thinking[open]")
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.tool-calls-disclosure[open]"
|
||||
)
|
||||
).to_have_count(0)
|
||||
|
||||
# The collapsed summaries are still visible (the timeline stays
|
||||
# readable at rest — 4 "Thinking" + 3 "Tool call (1)" lines).
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.thinking > summary").first
|
||||
).to_be_visible()
|
||||
expect(
|
||||
page.locator("#messages .msg.brain .tool-calls-summary").first
|
||||
).to_be_visible()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. D5: the active run's block stays OPEN while the turn streams
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_live_runs_open_while_streaming(
|
||||
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", TIMELINE_QUESTION)
|
||||
page.click("#send-btn")
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(TIMELINE_QUESTION)
|
||||
|
||||
# D5 (the live half): during the turn — the mock's pacing window
|
||||
# (the 0.1 s tool frames + the thinking stream; the answer bubble
|
||||
# is still EMPTY — the first delta has not landed) — the active
|
||||
# run's block is ``open`` (the live feedback the owner keeps:
|
||||
# thinking/tool lines stream into their OPEN blocks, folded only
|
||||
# by the first delta / done / stop). The house deadline-poll
|
||||
# pattern (the ``test_agent_document_tools.py`` ``_frames``
|
||||
# convention): a 50 ms stride over the ~2 s turn comfortably
|
||||
# samples the open windows.
|
||||
seen_live_open = False
|
||||
deadline = time.monotonic() + 30.0
|
||||
while time.monotonic() < deadline:
|
||||
if page.evaluate(LIVE_OPEN_JS):
|
||||
seen_live_open = True
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert seen_live_open, (
|
||||
"no open thinking/tool run was observed while the answer "
|
||||
"bubble was still empty (the D5 live-open window)"
|
||||
)
|
||||
|
||||
# The turn settles (the poll caught the live window; the settle is
|
||||
# the D5 fold — test 2's contract).
|
||||
_wait_settled(page)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. The restore path (the same 7-element timeline, collapsed) + the
|
||||
# record's ``segments`` array (D4 — the legacy keys ride along)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_restored_timeline_and_record(
|
||||
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, TIMELINE_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)
|
||||
|
||||
# The restored message renders the SAME 7-element timeline, 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}"
|
||||
)
|
||||
expect(
|
||||
page.locator("#messages .msg.brain details.thinking[open]")
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
page.locator(
|
||||
"#messages .msg.brain details.tool-calls-disclosure[open]"
|
||||
)
|
||||
).to_have_count(0)
|
||||
# The restored answer (the restore is synchronous at boot).
|
||||
expect(
|
||||
page.locator(".msg.brain .bubble").last
|
||||
).to_contain_text(ANSWER_PREFIX, timeout=30_000)
|
||||
|
||||
# D4: the localStorage record (``bor.chat.v1`` — phase 14) carries
|
||||
# a ``segments`` array of EXACTLY 7 entries in the arrival-order
|
||||
# pattern — 4 thinking runs (the ``Step N`` text) + 3 tool entries
|
||||
# (the call records, in order).
|
||||
rec = page.evaluate(READ_RECORD_JS)
|
||||
assert rec is not None, "the phase-14 record must exist after the reload"
|
||||
segments = rec.get("segments")
|
||||
assert isinstance(segments, list), (
|
||||
f"the record must carry a segments array, got {type(segments)}"
|
||||
)
|
||||
assert len(segments) == 7, f"expected EXACTLY 7 segments, got {len(segments)}"
|
||||
assert [s.get("kind") for s in segments] == [
|
||||
"thinking", "tool", "thinking", "tool", "thinking", "tool", "thinking"
|
||||
], f"the kind sequence is not the arrival-order pattern: {segments}"
|
||||
# The thinking entries carry the DISTINCT ``Step N`` runs.
|
||||
for i, marker in enumerate(STEP_MARKERS):
|
||||
text = segments[i * 2].get("text")
|
||||
assert isinstance(text, str) and marker in text, (
|
||||
f"thinking segment {i} must carry {marker!r}: {text!r}"
|
||||
)
|
||||
# The tool entries are the 3 call records, in call order.
|
||||
assert segments[1].get("name") == "ls"
|
||||
assert segments[1].get("argument") is None # the no-arg top-level ls
|
||||
assert segments[3].get("name") == "ls"
|
||||
assert segments[3].get("argument") == READ_SOURCE # the drill scope
|
||||
assert segments[5].get("name") == "read"
|
||||
assert segments[5].get("argument") == READ_SP # the combined identity
|
||||
|
||||
# D4 (the old-client graceful degradation): the legacy fallback
|
||||
# fields are STILL present — the full ``thinking`` concatenation
|
||||
# (feeds the history wire + the per-turn log) + every ``tools``
|
||||
# record (the old-client render path).
|
||||
thinking_full = rec.get("thinking")
|
||||
assert isinstance(thinking_full, str) and thinking_full, (
|
||||
"the full thinking concatenation must still be present"
|
||||
)
|
||||
for marker in STEP_MARKERS:
|
||||
assert marker in thinking_full, (
|
||||
f"the concatenated thinking must carry {marker!r}"
|
||||
)
|
||||
tools = rec.get("tools")
|
||||
assert isinstance(tools, list) and len(tools) == 3, (
|
||||
f"the tools record must still carry the 3 calls: {tools!r}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. The shared page (phase-51 save + share flow): the same 7-element
|
||||
# timeline, collapsed (the addSegments parity)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_shared_page_timeline(
|
||||
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, TIMELINE_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(TIMELINE_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(TIMELINE_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(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).
|
||||
seq = anon.evaluate(BODY_SEQUENCE_JS)
|
||||
assert seq == TIMELINE_SEQ, (
|
||||
f"the shared pre-bubble sequence is not the timeline: {seq}"
|
||||
)
|
||||
expect(
|
||||
anon.locator("#messages .msg.brain details.thinking[open]")
|
||||
).to_have_count(0)
|
||||
expect(
|
||||
anon.locator(
|
||||
"#messages .msg.brain details.tool-calls-disclosure[open]"
|
||||
)
|
||||
).to_have_count(0)
|
||||
|
||||
# The ``Step N`` markers are visible on a summary click (expand
|
||||
# ONE — the native <summary> is a real focusable toggle).
|
||||
anon.locator("#messages .msg.brain details.thinking > summary").first.click()
|
||||
expect(
|
||||
anon.locator("#messages .msg.brain details.thinking").first
|
||||
).to_have_attribute("open")
|
||||
first_text = anon.locator("#messages .msg.brain .thinking-text").first
|
||||
expect(first_text).to_be_visible()
|
||||
expect(first_text).to_contain_text(STEP_MARKERS[0])
|
||||
|
||||
# The answer bubble carries the ``Read <source/path>`` echo.
|
||||
expect(
|
||||
anon.locator(".msg.brain .bubble").first
|
||||
).to_contain_text(ANSWER_PREFIX)
|
||||
finally:
|
||||
if anon_ctx is not None:
|
||||
anon_ctx.close()
|
||||
if created:
|
||||
_delete_chat(app_url, cookies, created)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 6. The run-grouping contract for no-thinking turns (phase 117
|
||||
# preserved): one disclosure, 3 lines, zero thinking blocks
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_thinking_turn_single_disclosure(
|
||||
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="/")
|
||||
|
||||
# The plain ``use your tools`` marker (NO thinking trigger — the
|
||||
# mock's tool frames carry no ``reasoning_content``): the 3 calls
|
||||
# are ONE contiguous tool run.
|
||||
_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.
|
||||
expect(
|
||||
page.locator("#messages .msg.brain .tool-calls-disclosure")
|
||||
).to_have_count(1)
|
||||
expect(page.locator("#messages .msg.brain .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)
|
||||
|
||||
# Folded at rest (the first delta / done settle) + the answer.
|
||||
expect(
|
||||
page.locator("#messages .msg.brain .tool-calls-disclosure[open]")
|
||||
).to_have_count(0)
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(ANSWER_PREFIX)
|
||||
@@ -396,7 +396,7 @@ 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`` → ``closeToolCalls``): the disclosure is
|
||||
``appendToolLine`` → 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)."""
|
||||
|
||||
@@ -30,6 +30,18 @@ SSE is exactly ``delta → tool → thinking → delta → thinking → done``
|
||||
the owner's repro, and every window is deterministic (the mock's
|
||||
baked delays, the ``slow_llm.py`` pacing precedent).
|
||||
|
||||
Phase 158 supersession (owner direction 2026-10-04, recorded): phase 109's
|
||||
D15 re-open contract (ONE ``details.thinking`` re-opened by every later
|
||||
thinking frame) is REPLACED by the per-run timeline (D1/D5): each
|
||||
contiguous thinking run streams into its OWN block, and the first frame
|
||||
of a new kind closes the previous run. The SSE stream is unchanged, so
|
||||
this turn renders ONE ``.tool-calls-disclosure`` + TWO ``.thinking``
|
||||
blocks (arrival order, all ABOVE the answer bubble — D2), open in their
|
||||
live windows and folded at rest. The loader pins
|
||||
(``test_loader_visible_from_send_through_the_tool_gap``,
|
||||
``test_loader_a11y_and_reduced_motion``) stay unchanged — the
|
||||
never-stale loader contract (D16) is intact.
|
||||
|
||||
Each test sends the marker question in its OWN fresh conversation
|
||||
(one full turn per test — the ``page`` fixture is a fresh browser
|
||||
context, ``_reset_db`` reseeds the KB deterministically):
|
||||
@@ -40,14 +52,18 @@ context, ``_reset_db`` reseeds the KB deterministically):
|
||||
line lands (the turn is provably in flight), the tool line carries
|
||||
the phase-87 elapsed counter in the tool gap, and ``#send-status``
|
||||
carries a state text (not empty) throughout.
|
||||
* ``test_thinking_block_reopens_after_delta_with_visible_loader`` —
|
||||
THE reported symptom's state: the thinking block re-opens after the
|
||||
answer has started (open + non-empty ``.thinking-text`` while the
|
||||
bubble already carries call 1's content) with the loader STILL
|
||||
visible (the frozen window is gone); the terminal state is clean
|
||||
(loader hidden, the scratchpad carries both thinking rounds, the
|
||||
bubble carries BOTH answers, the send button reads "Send" not
|
||||
"Stop", ``#send-status`` back to the idle shape).
|
||||
* ``test_thinking_runs_open_in_live_window_with_visible_loader`` —
|
||||
THE reported symptom's state, re-pinned for phase 158 (D5 replaces
|
||||
phase-109 D15 — a NEW block instead of a re-open): the post-delta
|
||||
thinking frames open a per-run ``.thinking`` block (exactly one
|
||||
block, open + non-empty ``.thinking-text`` while the bubble already
|
||||
carries call 1's content — the run starts AFTER the first delta) with
|
||||
the loader STILL visible (the frozen window is gone — the user sees
|
||||
the new thinking in its own live block); the terminal state is clean
|
||||
(loader hidden, the scratchpad holds TWO ``.thinking`` blocks —
|
||||
EACH carrying its own round's text — BOTH folded at rest, the bubble
|
||||
carries BOTH answers, the send button reads "Send" not "Stop",
|
||||
``#send-status`` back to the idle shape).
|
||||
* ``test_loader_a11y_and_reduced_motion`` — after a full turn: the
|
||||
loader element is ``aria-hidden="true"`` in the DOM and
|
||||
``#send-status`` (the sole ``aria-live`` announcer) is back to the
|
||||
@@ -192,7 +208,7 @@ def test_loader_visible_from_send_through_the_tool_gap(
|
||||
expect(page.locator("#send-status")).not_to_be_empty()
|
||||
|
||||
|
||||
def test_thinking_block_reopens_after_delta_with_visible_loader(
|
||||
def test_thinking_runs_open_in_live_window_with_visible_loader(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
summary = _reset_db(mock_llm, seed=True)
|
||||
@@ -202,28 +218,33 @@ def test_thinking_block_reopens_after_delta_with_visible_loader(
|
||||
|
||||
_send(page)
|
||||
|
||||
blocks = page.locator(".msg.brain .thinking")
|
||||
bubble = _answer_bubble(page)
|
||||
block = page.locator(".msg.brain .thinking")
|
||||
text_el = page.locator(".msg.brain .thinking-text")
|
||||
loader = page.locator("#turn-loader")
|
||||
|
||||
# THE reported symptom's state (D15): the thinking block is open
|
||||
# with non-empty text WHILE the answer bubble already carries
|
||||
# call 1's content — the post-delta re-open (call 2's thinking
|
||||
# frames re-opened the block the first delta had closed).
|
||||
# THE reported symptom's state (phase 158 D5 replaces phase-109
|
||||
# D15 — the post-delta thinking frames open a NEW per-run block
|
||||
# instead of re-opening the first delta's block): exactly ONE
|
||||
# ``.thinking`` block exists, it is OPEN with non-empty text WHILE
|
||||
# the answer bubble already carries call 1's content — the first
|
||||
# thinking run starts AFTER the first delta (the owner's "thinking
|
||||
# after responding" repro), and the loader is STILL visible (the
|
||||
# frozen window is gone — the user sees the new thinking in its
|
||||
# own live block).
|
||||
page.wait_for_function(
|
||||
"""() => {
|
||||
const b = document.querySelector('.msg.brain .thinking');
|
||||
const t = b ? b.querySelector('.thinking-text') : null;
|
||||
return !!b && b.open && !!t && t.textContent.trim().length > 0;
|
||||
const b = document.querySelectorAll('.msg.brain .thinking');
|
||||
const t = b.length === 1 ? b[0].querySelector('.thinking-text') : null;
|
||||
return b.length === 1 && !!b[0].open && !!t
|
||||
&& t.textContent.trim().length > 0;
|
||||
}""",
|
||||
timeout=30_000,
|
||||
)
|
||||
expect(block).to_have_count(1)
|
||||
expect(block).to_have_attribute("open", "")
|
||||
expect(text_el).to_contain_text(SENTINEL_THOUGHT)
|
||||
# The re-open is genuinely AFTER a delta: call 1's content is
|
||||
# already in the bubble ...
|
||||
expect(blocks).to_have_count(1)
|
||||
expect(blocks).to_have_attribute("open", "")
|
||||
expect(blocks.nth(0).locator(".thinking-text")).to_contain_text(SENTINEL_THOUGHT)
|
||||
# The run is genuinely AFTER a delta: call 1's content is already
|
||||
# in the bubble ...
|
||||
expect(bubble).to_contain_text(SENTINEL_FIRST)
|
||||
# ... and the loader is STILL visible — the frozen window is gone.
|
||||
expect(loader).to_be_visible()
|
||||
@@ -231,23 +252,24 @@ def test_thinking_block_reopens_after_delta_with_visible_loader(
|
||||
# Terminal approach: call 2's distinctive final sentence landed in
|
||||
# the bubble ...
|
||||
expect(bubble).to_contain_text(SENTINEL_FINAL, timeout=30_000)
|
||||
# ... and the FINAL thinking round (the last frames before done —
|
||||
# thinking AFTER the answer's last delta) re-opened the block
|
||||
# AGAIN: the scratchpad carries the last round's text and the
|
||||
# block is open (the `thinking` handler opens it before it
|
||||
# renders — D15), with the loader still visible (the turn is
|
||||
# still in flight — no terminal frame yet).
|
||||
expect(text_el).to_contain_text(SENTINEL_FINAL_THOUGHT, timeout=30_000)
|
||||
expect(block).to_have_attribute("open", "")
|
||||
expect(loader).to_be_visible()
|
||||
|
||||
# The turn settles (done → idle through setUiState): the loader is
|
||||
# HIDDEN (its sole owner — D16, no per-handler cleanup) ...
|
||||
# ... and the turn settles (done → idle through setUiState): the
|
||||
# loader is HIDDEN (its sole owner — D16, no per-handler cleanup) ...
|
||||
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||||
expect(loader).to_be_hidden()
|
||||
# ... the scratchpad text is intact (BOTH thinking rounds) ...
|
||||
expect(text_el).to_contain_text(SENTINEL_THOUGHT)
|
||||
expect(text_el).to_contain_text(SENTINEL_FINAL_THOUGHT)
|
||||
# ... the scratchpad holds TWO ``.thinking`` blocks (the first
|
||||
# delta closed run 1; the final round — thinking AFTER the
|
||||
# answer's last delta — opened its OWN block rather than
|
||||
# re-opening run 1's) ...
|
||||
expect(blocks).to_have_count(2)
|
||||
# ... EACH carrying its OWN round's text (the flow's two distinct
|
||||
# reasoning streams — separate blocks, not one lump) ...
|
||||
expect(blocks.nth(0).locator(".thinking-text")).to_contain_text(SENTINEL_THOUGHT)
|
||||
expect(blocks.nth(1).locator(".thinking-text")).to_contain_text(SENTINEL_FINAL_THOUGHT)
|
||||
# ... and BOTH runs are FOLDED at rest (D5 — the per-run
|
||||
# fold-at-rest; the phase-109 symptom — the frozen chat — stays
|
||||
# fixed: a run is open in its live window, never re-opened after
|
||||
# done) ...
|
||||
expect(page.locator(".msg.brain .thinking[open]")).to_have_count(0)
|
||||
# ... the bubble carries BOTH answers (call 1 + call 2) ...
|
||||
expect(bubble).to_contain_text(SENTINEL_FIRST)
|
||||
expect(bubble).to_contain_text(SENTINEL_FINAL)
|
||||
|
||||
@@ -1543,3 +1543,285 @@ def test_shared_chat_with_failed_record_serves_public_shape(
|
||||
assert body["messages"][1]["failed"] is True
|
||||
assert body["messages"][1]["error"] == FAILED_BRAIN["error"]
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 158 (task 01): the validated ChatMessage.segments field — the
|
||||
# turn's chronological run order (D4/D6, owner direction 2026-10-04):
|
||||
# the discriminated thinking/tool union (ChatSegment), capped
|
||||
# phase-83-style, OMITTED when absent (the phase-50 byte-identical
|
||||
# round-trip contract — ABSENT, never null), and the extra="forbid"
|
||||
# boundary INTACT around the new key. The router is UNCHANGED (task
|
||||
# 01's spec): POST/PUT validate through ChatMessage and store
|
||||
# m.model_dump(); the admin GET and the public shared read re-validate
|
||||
# the stored JSONB through ChatMessage — so every surface inherits the
|
||||
# field + the omission rule from the schema alone.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The full segment array (task 01's spec): one ``thinking`` entry +
|
||||
#: two ``tool`` entries carrying EVERY additive field (offset/limit,
|
||||
#: path, truncated/chars_shown/chars_total — the phase-95/139/146
|
||||
#: shapes; the tool entries are byte-for-byte the ToolCall record
|
||||
#: shape, the ChatToolSegment mirror).
|
||||
SEGMENTS: list[dict[str, Any]] = [
|
||||
{"kind": "thinking", "text": "Step 1: list the documents to find the reference server config."},
|
||||
{
|
||||
"kind": "tool",
|
||||
"name": "read",
|
||||
"argument": "Homelab/kubernetes.md",
|
||||
"truncated": True,
|
||||
"chars_shown": 128_000,
|
||||
"chars_total": 204_000,
|
||||
"offset": 42,
|
||||
"limit": 30,
|
||||
"path": "search_docs/reese-notes.md",
|
||||
},
|
||||
{
|
||||
"kind": "tool",
|
||||
"name": "grep",
|
||||
"argument": "reese-sentinel-42",
|
||||
"truncated": False,
|
||||
"chars_shown": None,
|
||||
"chars_total": None,
|
||||
"offset": 1,
|
||||
"limit": 20,
|
||||
"path": "deployments/traefik.yaml",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _segmented_brain() -> dict[str, Any]:
|
||||
"""A full ``bor.chat.v1`` brain record (phase 113 shape) PLUS the
|
||||
``segments`` timeline (D4 — ``thinking`` + ``tools`` KEPT, the
|
||||
runs' order ADDITIVE)."""
|
||||
return {**FULL_BRAIN, "segments": SEGMENTS}
|
||||
|
||||
|
||||
def test_create_round_trips_segments(admin_client: TestClient, db: Session) -> None:
|
||||
"""A record WITH ``segments`` (both kinds, every additive tool
|
||||
field) is ACCEPTED by ``POST /api/chats`` (the phase-113 lesson,
|
||||
applied up front: the field exists so the ``extra="forbid"``
|
||||
boundary does not 422 the done-time auto-save carrying it — NOT a
|
||||
silent 422) and round-trips: the 201 body, the RAW stored JSONB
|
||||
(byte-for-byte), and the admin GET all carry the array with the
|
||||
entry ORDER preserved; the user record stays WITHOUT the key.
|
||||
The PUT upsert carries it too (the re-Save path's
|
||||
``m.model_dump()`` stores it unchanged)."""
|
||||
r = admin_client.post(
|
||||
"/api/chats", json={"messages": [_user(FIRST_QUESTION), _segmented_brain()]}
|
||||
)
|
||||
assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}"
|
||||
body = r.json()
|
||||
assert body["messages"][1]["segments"] == SEGMENTS
|
||||
assert "segments" not in body["messages"][0]
|
||||
|
||||
# The RAW stored JSONB (the DB is the durable boundary): the full
|
||||
# array, byte-for-byte.
|
||||
raw = db.execute(
|
||||
text("SELECT messages FROM saved_chats WHERE id = :id"),
|
||||
{"id": body["id"]},
|
||||
).scalar_one()
|
||||
assert raw[1]["segments"] == SEGMENTS
|
||||
assert "segments" not in raw[0]
|
||||
|
||||
# The admin GET round-trips the array losslessly (order included).
|
||||
got = admin_client.get(f"/api/chats/{body['id']}")
|
||||
assert got.status_code == 200
|
||||
assert got.json()["messages"][1]["segments"] == SEGMENTS
|
||||
|
||||
# The re-Save upsert keeps the array (the stored shape is
|
||||
# re-validated through ChatMessage on every write).
|
||||
updated = admin_client.put(
|
||||
f"/api/chats/{body['id']}",
|
||||
json={"messages": [_user(FIRST_QUESTION), _segmented_brain()]},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["messages"][1]["segments"] == SEGMENTS
|
||||
|
||||
|
||||
def test_shared_serve_includes_segments(admin_client: TestClient) -> None:
|
||||
"""The shared view is faithful (the shared page's ``addSegments``
|
||||
parity copy, task 03, renders from it): the PUBLIC snapshot's
|
||||
brain record carries the segments (the public shape gains no new
|
||||
top-level key — ``SHARED_OUT_KEYS`` is unchanged), with the entry
|
||||
order preserved; the user record stays WITHOUT the key."""
|
||||
created = admin_client.post(
|
||||
"/api/chats",
|
||||
json={"title": EXPLICIT_TITLE, "messages": [_user(FIRST_QUESTION), _segmented_brain()]},
|
||||
).json()
|
||||
share_url = _share(admin_client, created["id"])["share_url"]
|
||||
|
||||
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
|
||||
r = anon.get(f"/api{share_url}")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == SHARED_OUT_KEYS # no new shared-shape field
|
||||
assert body["title"] == EXPLICIT_TITLE
|
||||
assert body["messages"][1]["segments"] == SEGMENTS
|
||||
assert "segments" not in body["messages"][0]
|
||||
|
||||
|
||||
def test_segments_over_50_entries_rejected(admin_client: TestClient) -> None:
|
||||
"""``max_length=50`` (the phase-83 voice, the ``tools`` cap): 51
|
||||
entries 422 at the boundary — far past any realistic turn (the
|
||||
round cap is 10 → ≤ 20 entries)."""
|
||||
r = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"segments": [{"kind": "thinking", "text": "x"}] * 51,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
_assert_no_chats(admin_client)
|
||||
|
||||
|
||||
def test_thinking_segment_text_caps_rejected(admin_client: TestClient) -> None:
|
||||
"""The thinking entry's ``text`` cap (HistoryTurn's 32 000, phase
|
||||
83): 32 001 chars 422; the ``min_length=1`` bound — an EMPTY text
|
||||
(the zero-char run the client never stores) 422s too."""
|
||||
over = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"segments": [{"kind": "thinking", "text": "x" * 32_001}],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert over.status_code == 422
|
||||
empty = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"segments": [{"kind": "thinking", "text": ""}],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert empty.status_code == 422
|
||||
_assert_no_chats(admin_client)
|
||||
|
||||
|
||||
def test_tool_segment_caps_rejected(admin_client: TestClient) -> None:
|
||||
"""The tool entry mirrors ToolCall's caps (mirrored, not
|
||||
weakened): a ``name`` over 100 and an ``offset`` of 0 (the
|
||||
``ge=1`` whole-line bound) both 422 — the same values that 422 on
|
||||
the ``tools`` entry."""
|
||||
bad_name = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"segments": [
|
||||
{"kind": "tool", "name": "n" * 101, "argument": None}
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert bad_name.status_code == 422
|
||||
bad_offset = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"segments": [{"kind": "tool", "name": "read", "offset": 0}],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert bad_offset.status_code == 422
|
||||
_assert_no_chats(admin_client)
|
||||
|
||||
|
||||
def test_unknown_segment_kind_rejected(admin_client: TestClient) -> None:
|
||||
"""The discriminator (``kind`` = ``"thinking"`` | ``"tool"``): an
|
||||
unknown ``kind`` 422s at the boundary — nothing outside the union
|
||||
crosses (the ``extra="forbid"`` voice, applied to the variant
|
||||
pick)."""
|
||||
r = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"segments": [{"kind": "bogus", "text": "x"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
_assert_no_chats(admin_client)
|
||||
|
||||
|
||||
def test_stray_top_level_key_still_rejected_with_segments(
|
||||
admin_client: TestClient,
|
||||
) -> None:
|
||||
"""The ``extra="forbid"`` boundary is INTACT around the new key:
|
||||
a record that carries a well-formed ``segments`` AND a stray
|
||||
top-level key still 422s — adding the field changed nothing about
|
||||
which keys are accepted (the phase-83 voice)."""
|
||||
bad = _segmented_brain()
|
||||
bad["foo"] = 1
|
||||
r = admin_client.post("/api/chats", json={"messages": [bad]})
|
||||
assert r.status_code == 422
|
||||
_assert_no_chats(admin_client)
|
||||
|
||||
|
||||
def test_record_without_segments_round_trips_key_absent(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""The phase-50 byte-identical contract, pinned: a record WITHOUT
|
||||
``segments`` (the full phase-113 brain shape — every pre-phase
|
||||
chat) round-trips with the key ABSENT from every surface — the
|
||||
201 body, the RAW stored JSONB, and the admin GET — NEVER
|
||||
``null`` (the ``_drop_image_when_absent`` serializer's phase-158
|
||||
pop). An EXPLICIT empty list is a different thing: ``[]`` is a
|
||||
value, so it round-trips as ``[]`` (present, empty — the pop is
|
||||
``None``-gated only)."""
|
||||
r = admin_client.post(
|
||||
"/api/chats", json={"messages": [_user(FIRST_QUESTION), FULL_BRAIN]}
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["messages"] == _expect([_user(FIRST_QUESTION), FULL_BRAIN])
|
||||
assert "segments" not in body["messages"][0]
|
||||
assert "segments" not in body["messages"][1]
|
||||
|
||||
raw = db.execute(
|
||||
text("SELECT messages FROM saved_chats WHERE id = :id"),
|
||||
{"id": body["id"]},
|
||||
).scalar_one()
|
||||
assert "segments" not in raw[0]
|
||||
assert "segments" not in raw[1]
|
||||
|
||||
got = admin_client.get(f"/api/chats/{body['id']}")
|
||||
assert got.status_code == 200
|
||||
assert "segments" not in got.json()["messages"][0]
|
||||
assert "segments" not in got.json()["messages"][1]
|
||||
|
||||
# An explicit empty list round-trips as [] (present, not popped).
|
||||
r2 = admin_client.post(
|
||||
"/api/chats",
|
||||
json={"messages": [{"who": "brain", "text": "ok", "segments": []}]},
|
||||
)
|
||||
assert r2.status_code == 201, r2.text
|
||||
got2 = admin_client.get(f"/api/chats/{r2.json()['id']}")
|
||||
assert got2.json()["messages"][0]["segments"] == []
|
||||
|
||||
@@ -174,6 +174,7 @@ def test_tool_line_clock_state_is_turn_scoped_module_state() -> None:
|
||||
"let toolLineTimer = 0",
|
||||
"let toolLineStart = 0",
|
||||
"let toolLineWrap = null",
|
||||
"let toolLineEl = null",
|
||||
):
|
||||
assert decl in js, f"{decl} must be module-scope state"
|
||||
anchor = js.find("let turnTimeoutCb = null")
|
||||
@@ -196,11 +197,11 @@ def test_arm_has_exactly_one_live_call_site() -> None:
|
||||
tool_idx = js.find('ev.type === "tool"')
|
||||
delta_idx = js.find('ev.type === "delta"')
|
||||
branch = js[tool_idx:delta_idx]
|
||||
assert "armToolLineClock(wrap)" in branch, "the live tool branch arms the clock"
|
||||
append = branch.find(
|
||||
"appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath)"
|
||||
assert "armToolLineClock(wrap, line)" in branch, (
|
||||
"the live tool branch arms the clock with the tracked line (phase 158)"
|
||||
)
|
||||
arm = branch.find("armToolLineClock(wrap)")
|
||||
append = branch.find("const line = appendToolLine(")
|
||||
arm = branch.find("armToolLineClock(wrap, line)")
|
||||
assert -1 < append < arm, (
|
||||
"the arm follows the line's append — the baseline resets per line"
|
||||
)
|
||||
@@ -281,29 +282,33 @@ def test_suffix_is_textcontent_only_on_the_latest_line() -> None:
|
||||
Reading src/app.py (12s)" — the typing hint stays bare "Ns") written
|
||||
with ``textContent`` on a ``createElement`` span — never innerHTML —
|
||||
appended as a SIBLING after the line's existing children (the pinned
|
||||
template text + the <code> argument). It targets the LATEST line only
|
||||
(`.tool-call:last-child` — older lines keep their permanent record),
|
||||
and a missing container (New-Chat click mid-gap) makes the tick a
|
||||
no-op (the null-safe chain is the guard)."""
|
||||
template text + the <code> argument). Phase 158 (D1): it targets
|
||||
the TRACKED line only (the arm's second param — the
|
||||
``:last-child`` query is gone; multiple disclosures make it the
|
||||
wrong line), and a missing reference (New-Chat click mid-gap)
|
||||
makes the tick a no-op (the null guard)."""
|
||||
js = _js()
|
||||
fn = js.find("function armToolLineClock")
|
||||
assert fn != -1, "armToolLineClock must exist"
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert 'querySelector?.(".tool-calls .tool-call:last-child")' in body, (
|
||||
"the suffix targets the LATEST line only"
|
||||
assert ".tool-calls .tool-call:last-child" not in body, (
|
||||
"the :last-child query is GONE (multi-disclosure — phase 158)"
|
||||
)
|
||||
assert "toolLineEl = lineEl" in body, "the tracked line reference"
|
||||
assert "if (!toolLineEl) return" in body, (
|
||||
"null-guarded: a wrap reset mid-gap (New Chat) is a no-op"
|
||||
)
|
||||
assert 'toolLineEl.querySelector(".tool-elapsed")' in body, (
|
||||
"find-or-create the suffix span on the tracked line"
|
||||
)
|
||||
assert 'line.querySelector(".tool-elapsed")' in body, "find-or-create the suffix span"
|
||||
assert 'el.className = "tool-elapsed"' in body
|
||||
assert 'document.createElement("span")' in body, "the span is createElement'd"
|
||||
assert "line.appendChild(el)" in body, (
|
||||
assert "toolLineEl.appendChild(el)" in body, (
|
||||
"the suffix is a SIBLING appended AFTER the line's existing children"
|
||||
)
|
||||
assert "`(${secs}s)`" in body, ("the parenthesized '(Ns)' suffix, textContent-built")
|
||||
assert "innerHTML" not in body, "no HTML write in the clock — textContent only"
|
||||
assert "TOOL_LINE_ELAPSED_AFTER_MS" in body, "the tick gates on the named constant"
|
||||
assert "toolLineWrap?.querySelector?" in body, (
|
||||
"null-safe: a wrap reset mid-gap (New Chat) makes the tick a no-op"
|
||||
)
|
||||
|
||||
|
||||
def test_tool_line_template_literals_stay_byte_identical() -> None:
|
||||
|
||||
@@ -304,7 +304,7 @@ def test_restore_renders_collapsed_thinking_block() -> None:
|
||||
assert fn_start != -1
|
||||
body = js[fn_start : js.find("\n}\n", fn_start)]
|
||||
assert "if (m.thinking)" in body
|
||||
assert "ensureThinkingBlock(wrap)" in body
|
||||
assert "createThinkingBlock()" in body
|
||||
assert "block.open = false" in body, "restored blocks must be collapsed"
|
||||
assert "renderMarkdown(m.thinking)" in body
|
||||
|
||||
|
||||
@@ -371,8 +371,10 @@ def test_failed_funnel_note_and_retry_join_the_actions_row() -> None:
|
||||
FAILED_TURN_TEXT bubble). Both shapes end retryable."""
|
||||
js = _js()
|
||||
header = (
|
||||
"function finalizeFailedTurn(detail, "
|
||||
"{ acc, thinking, tools, wrap, leavePartialIndex }) {"
|
||||
"function finalizeFailedTurn(\n"
|
||||
" detail,\n"
|
||||
" { acc, thinking, tools, segments, wrap, leavePartialIndex }\n"
|
||||
") {"
|
||||
)
|
||||
body = _fn_body(js, header)
|
||||
assert body.count("appendFailedNote(") == 2, (
|
||||
|
||||
@@ -163,10 +163,9 @@ def test_finalize_failed_turn_persists_failed_in_both_shapes() -> None:
|
||||
# …the zero-frame shape creates the FAILED_TURN_TEXT bubble…
|
||||
assert 'addMessage("brain", FAILED_TURN_TEXT);' in body
|
||||
assert "appendFailedNote(fwrap, error);" in body
|
||||
# …and the partial shape settles the block + calls closed (the
|
||||
# stop-finalize pattern) and adds the note.
|
||||
assert "closeThinkingBlock(wrap);" in body
|
||||
assert "closeToolCalls(wrap);" in body
|
||||
# …and the partial shape settles every open run closed (phase 158,
|
||||
# D5 — the stop-finalize pattern) and adds the note.
|
||||
assert "closeAllRuns(wrap);" in body
|
||||
assert "appendFailedNote(wrap, error);" in body
|
||||
# Both shapes land the record through rememberBrainTurn (local
|
||||
# storage + the phase-55 auto-save ride) and set lastBrainWrap
|
||||
|
||||
@@ -135,32 +135,42 @@ def test_thinking_event_is_a_first_class_turn_branch() -> None:
|
||||
assert "sawThinking = true" in branch
|
||||
assert "clearTurnTimeout()" in branch, "first thinking frame clears the 300s guard"
|
||||
assert "removeTyping()" in branch, "the live block replaces the typing dots"
|
||||
assert "ensureThinkingBlock(wrap)" in branch
|
||||
assert "renderMarkdown(thinkingAcc)" in branch, "escape-first renderer (XSS-safe)"
|
||||
assert "ensureThinkingRun(wrap)" in branch
|
||||
assert "renderMarkdown(currentRun.run.text)" in branch, (
|
||||
"escape-first renderer (XSS-safe) — the RUN's text (phase 158, D1)"
|
||||
)
|
||||
assert "textEl.scrollTop = textEl.scrollHeight" in branch, "bottom-pinned while open"
|
||||
|
||||
|
||||
def test_thinking_block_helpers_are_idempotent() -> None:
|
||||
"""ensureThinkingBlock returns the existing `.thinking` details or
|
||||
creates it OPEN above the .bubble; closeThinkingBlock is a no-op
|
||||
without a block and never reopens one once the answer started."""
|
||||
def test_thinking_run_helpers_are_idempotent() -> None:
|
||||
"""Phase 158 (D1/D5 — the phase-109 D15 pin, replaced by owner
|
||||
direction 2026-10-04): ``ensureThinkingRun`` returns the active
|
||||
run's block or creates a NEW one OPEN above the .bubble (a frame
|
||||
of a new kind closes the previous run first); ``closeCurrentRun``
|
||||
is a no-op without a run and folds the active one (the D15
|
||||
re-open of the shared block is gone — a post-delta thinking frame
|
||||
opens a NEW block)."""
|
||||
js = _js()
|
||||
fn = js.find("function ensureThinkingBlock")
|
||||
assert fn != -1, "ensureThinkingBlock must exist (near addTyping/removeTyping)"
|
||||
fn = js.find("function ensureThinkingRun")
|
||||
assert fn != -1, "ensureThinkingRun must exist (near addTyping/removeTyping)"
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert "block.open = true" in body, "created open — the stream is the show"
|
||||
assert "closeCurrentRun()" in body, ("a frame of a new kind closes the "
|
||||
"previous run (D1)")
|
||||
assert "block.open = true" in body, "D5: created open — the stream is the show"
|
||||
assert "insertBefore" in body
|
||||
assert 'querySelector(".bubble")' in body, "the block sits ABOVE the bubble"
|
||||
fn2 = js.find("function closeThinkingBlock")
|
||||
assert fn2 != -1, "closeThinkingBlock must exist"
|
||||
fn2 = js.find("function closeCurrentRun")
|
||||
assert fn2 != -1, "closeCurrentRun must exist"
|
||||
body2 = js[fn2 : js.find("\n}\n", fn2)]
|
||||
assert "block.open = false" in body2
|
||||
assert "currentRun.el.open = false" in body2
|
||||
assert "currentRun = null" in body2, "a no-op without a run (idempotent)"
|
||||
|
||||
|
||||
def test_delta_branch_collapses_block_and_transitions_to_streaming() -> None:
|
||||
"""The first answer delta transitions thinking → streaming (even when
|
||||
thinking created the wrap first) and auto-collapses the block —
|
||||
idempotent, and it never reopens once the answer started."""
|
||||
thinking created the wrap first) and folds the active run — phase 158
|
||||
(D5): ``closeCurrentRun()`` (a later thinking frame opens a NEW run;
|
||||
the D15 re-open of the shared block is gone)."""
|
||||
js = _js()
|
||||
delta_idx = js.find('ev.type === "delta"')
|
||||
done_idx = js.find('ev.type === "done"')
|
||||
@@ -168,19 +178,20 @@ def test_delta_branch_collapses_block_and_transitions_to_streaming() -> None:
|
||||
branch = js[delta_idx:done_idx]
|
||||
assert "uiState === UI_STATE.thinking" in branch
|
||||
assert "setUiState(UI_STATE.streaming)" in branch
|
||||
assert "closeThinkingBlock(wrap)" in branch
|
||||
assert "closeCurrentRun()" in branch
|
||||
|
||||
|
||||
def test_done_branch_sets_sawdone_and_closes_block() -> None:
|
||||
"""On `done` the turn marks itself complete (sawDone — the stream-drop
|
||||
guard keys off it) and settles the thinking block closed."""
|
||||
guard keys off it) and settles EVERY open run closed (phase 158, D5:
|
||||
``closeAllRuns``)."""
|
||||
js = _js()
|
||||
done_idx = js.find('ev.type === "done"')
|
||||
error_idx = js.find('ev.type === "error"')
|
||||
assert -1 < done_idx < error_idx
|
||||
branch = js[done_idx:error_idx]
|
||||
assert "sawDone = true" in branch
|
||||
assert "closeThinkingBlock(wrap)" in branch
|
||||
assert "closeAllRuns(wrap)" in branch
|
||||
|
||||
|
||||
def test_stream_drop_guard_reports_severed_stream() -> None:
|
||||
@@ -282,10 +293,11 @@ def test_stop_turn_is_the_user_abort() -> None:
|
||||
def test_stop_branch_keeps_partial_and_persists_stopped() -> None:
|
||||
"""The stop path in handleSend's catch: no error state, no error
|
||||
banner; when answer text streamed the partial is kept on screen
|
||||
(thinking block closed, Tune + Stopped note appended — admin parity
|
||||
with the restore path) and persisted with the owner-locked optional
|
||||
`stopped: true` marker (+ optional thinking/tools); a pre-token stop
|
||||
persists nothing brain-side (phase-20 convention). The "Answer
|
||||
(every open run folded — phase 158, D5 — Tune + Stopped note
|
||||
appended — admin parity with the restore path) and persisted with
|
||||
the owner-locked optional `stopped: true` marker (+ optional
|
||||
thinking/tools/segments); a pre-token stop persists nothing
|
||||
brain-side (phase-20 convention). The "Answer
|
||||
stopped." live-region confirmation is set in the finally, AFTER the
|
||||
single settle, so setUiState(idle) can't overwrite it."""
|
||||
js = _js()
|
||||
@@ -301,7 +313,7 @@ def test_stop_branch_keeps_partial_and_persists_stopped() -> None:
|
||||
assert "if (wrap && acc && !persistedOnLeave)" in branch, (
|
||||
"only a partial WITH answer text is persisted (phase-20 dedupe)"
|
||||
)
|
||||
assert "closeThinkingBlock(wrap)" in branch
|
||||
assert "closeAllRuns(wrap)" in branch
|
||||
assert "appendTuneButton(wrap)" in branch, "admin parity with the restore path"
|
||||
assert "appendStoppedNote(wrap)" in branch
|
||||
assert "stopped: true" in branch, "the owner-locked optional marker"
|
||||
|
||||
@@ -63,7 +63,7 @@ def test_pagehide_records_the_partial_index_after_the_push() -> None:
|
||||
assert "if (persistedOnLeave) return;" in body, "phase-20 idempotency guard"
|
||||
assert "if (!acc) return;" in body, "nothing brain-side yet → no save"
|
||||
i_guard = body.index("persistedOnLeave = true;")
|
||||
i_push = body.index("rememberBrainTurn(acc, { thinking: thinkingAcc || undefined });")
|
||||
i_push = body.index("rememberBrainTurn(acc, {")
|
||||
i_index = body.index("leavePartialIndex = conversation.length - 1")
|
||||
assert i_guard < i_push < i_index, (
|
||||
"flag → push the partial → record its index (the record it just pushed)"
|
||||
@@ -125,11 +125,16 @@ def test_every_settle_writes_through_the_correlation() -> None:
|
||||
stop_idx = js.find('stoppedByUser || err?.name === "AbortError"', catch)
|
||||
stop_branch = js[stop_idx: js.find("} else {", stop_idx)]
|
||||
assert "leavePartialIndex" in stop_branch, "the stop settle is correlated"
|
||||
# The pagehide handler pushes the plain two-arg partial (the record
|
||||
# that the settles replace).
|
||||
# The pagehide handler pushes the partial through rememberBrainTurn
|
||||
# (phase 158, D4: + the optional segments — non-empty only).
|
||||
fn = js.find('window.addEventListener("pagehide"')
|
||||
body = js[fn: js.find("\n});", fn)]
|
||||
assert "rememberBrainTurn(acc, { thinking: thinkingAcc || undefined });" in body
|
||||
assert "rememberBrainTurn(acc, {" in body
|
||||
assert "thinking: thinkingAcc || undefined" in body
|
||||
assert (
|
||||
"segments: pagehideSegments.length ? pagehideSegments : undefined"
|
||||
in body
|
||||
), "phase 158: the partial's timeline persists (non-empty only)"
|
||||
assert "leavePartialIndex" not in body.replace(
|
||||
"leavePartialIndex = conversation.length - 1", ""
|
||||
), "the pagehide handler only records the index — it never settles"
|
||||
|
||||
@@ -63,8 +63,14 @@ def test_tool_branch_is_a_first_class_turn_branch() -> None:
|
||||
"label updates only while the state is still thinking"
|
||||
)
|
||||
assert (
|
||||
"appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath)"
|
||||
"const line = appendToolLine("
|
||||
in branch
|
||||
) and "armToolLineClock(wrap, line)" in branch, (
|
||||
"the run's disclosure gets the line; the clock targets it"
|
||||
)
|
||||
assert "ensureToolRun(wrap)" in branch, (
|
||||
"phase 158 (D1): the tool frame opens/extends the run's "
|
||||
"disclosure"
|
||||
)
|
||||
# No setUiState in the branch: the state stays "thinking" (never stale).
|
||||
assert "setUiState" not in branch, (
|
||||
@@ -131,22 +137,31 @@ def test_calling_tool_label_strings() -> None:
|
||||
|
||||
|
||||
def test_tool_lines_render_into_the_bubble_wrap() -> None:
|
||||
"""appendToolLine: first frame creates the .tool-calls list (role=list
|
||||
+ accessible name) BEFORE the bubble — below an existing Thinking
|
||||
block — and each line is a .tool-call listitem with the exact marks:
|
||||
🔎 Listing documents / 📄 Reading <code>path</code>. The path goes
|
||||
through textContent (storage can never inject HTML)."""
|
||||
"""Phase 158 (D1): the tool lines render into the run's
|
||||
disclosure — ``createToolDisclosure`` builds the phase-117
|
||||
structure (a ``.tool-calls`` list, role=list + accessible name
|
||||
"Tool calls") and ``ensureToolRun`` inserts it BEFORE the bubble
|
||||
(D2: above the answer, in arrival order); each line is a
|
||||
.tool-call listitem with the exact marks: 🔎 Listing documents /
|
||||
📄 Reading <code>path</code>. The path goes through textContent
|
||||
(storage can never inject HTML)."""
|
||||
js = _js()
|
||||
fn = js.find("function createToolDisclosure")
|
||||
disc = js[fn : js.find("\n}\n", fn)]
|
||||
assert 'listEl.className = "tool-calls"' in disc
|
||||
assert 'setAttribute("role", "list")' in disc
|
||||
assert 'setAttribute("aria-label", "Tool calls")' in disc, (
|
||||
"the list keeps its exact phase-37 shape"
|
||||
)
|
||||
fn2 = js.find("function ensureToolRun")
|
||||
run = js[fn2 : js.find("\n}\n", fn2)]
|
||||
assert 'insertBefore(disc, body.querySelector(".bubble"))' in run, (
|
||||
"the disclosure sits ABOVE the answer, in arrival order"
|
||||
)
|
||||
fn = js.find("function appendToolLine")
|
||||
assert fn != -1, "appendToolLine must exist"
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert 'querySelector(".tool-calls")' in body, "idempotent per wrap"
|
||||
assert 'className = "tool-calls"' in body
|
||||
assert 'setAttribute("role", "list")' in body
|
||||
assert 'setAttribute("aria-label", "Tool calls")' in body
|
||||
assert 'insertBefore(container, body.querySelector(".bubble"))' in body, (
|
||||
"the list sits ABOVE the answer"
|
||||
)
|
||||
assert 'querySelector(".tool-calls")' in body, "the line appends to the run's list"
|
||||
assert 'className = "tool-call"' in body
|
||||
assert 'setAttribute("role", "listitem")' in body
|
||||
assert 'line.textContent = "📄 Reading "' in body
|
||||
@@ -237,10 +252,10 @@ def test_tool_lines_re_render_on_restore() -> None:
|
||||
body = js[fn:end]
|
||||
assert "Array.isArray(m.tools)" in body
|
||||
assert (
|
||||
"appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body
|
||||
"appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)" in body
|
||||
), (
|
||||
"phase 139: the stored range + the phase-146 stored scope ride the "
|
||||
"SAME helper — a pre-phase record (no fields) renders byte-identical"
|
||||
"SAME helper (the caller passes the run's disclosure — phase 158)"
|
||||
)
|
||||
thinking_restore = body.find("if (m.thinking)")
|
||||
tools_restore = body.find("Array.isArray(m.tools)")
|
||||
@@ -250,20 +265,28 @@ def test_tool_lines_re_render_on_restore() -> None:
|
||||
assert "typeof t.name !== \"string\"" in body, "malformed entries skipped"
|
||||
|
||||
|
||||
def test_thinking_block_stays_on_top_of_tool_lines() -> None:
|
||||
"""If a tool frame precedes the first thinking frame, the Thinking
|
||||
block is still created ABOVE the tool lines (scratchpad on top),
|
||||
not below them."""
|
||||
def test_segments_keep_arrival_order_above_the_bubble() -> None:
|
||||
"""Phase 158 (D2): both run builders insert their element
|
||||
BEFORE the bubble — later insertions land after earlier ones, so
|
||||
document order is ARRIVAL order (a tool frame preceding the
|
||||
first thinking frame lands the scratchpad BELOW the tool lines —
|
||||
the timeline order; the old "scratchpad on top" anchor chain is
|
||||
gone)."""
|
||||
js = _js()
|
||||
fn = js.find("function ensureThinkingBlock")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert 'querySelector(".tool-calls")' in body, (
|
||||
"the thinking anchor must account for existing tool lines"
|
||||
fn = js.find("function ensureThinkingRun")
|
||||
t = js[fn : js.find("\n}\n", fn)]
|
||||
fn2 = js.find("function ensureToolRun")
|
||||
d = js[fn2 : js.find("\n}\n", fn2)]
|
||||
assert 'insertBefore(block, body.querySelector(".bubble"))' in t, (
|
||||
"the thinking run inserts before the bubble"
|
||||
)
|
||||
assert 'querySelector(".bubble")' in body
|
||||
assert "insertBefore" in body
|
||||
assert "block.open = true" in body
|
||||
assert 'insertBefore(disc, body.querySelector(".bubble"))' in d, (
|
||||
"the tool run inserts before the bubble"
|
||||
)
|
||||
assert "??" not in t, ("the old anchor chain is GONE — the bubble is "
|
||||
"the only anchor")
|
||||
assert "block.open = true" in t
|
||||
assert "disc.open = true" in d
|
||||
|
||||
|
||||
def test_tool_call_style_is_accent_and_contrast_safe() -> None:
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
"""Unit: the phase-109 never-frozen-turn frontend contract (tasks 01-02).
|
||||
"""Unit: the phase-109 never-frozen-turn frontend contract (tasks
|
||||
01-02), updated by phase 158 (task 02).
|
||||
|
||||
No new Python app logic exists for this task — the behavior lives in
|
||||
``frontend/assets/app.js`` and is E2E-gated by the story suite (task 03).
|
||||
Like the other frontend-adjacent unit files (the house
|
||||
read-the-assets-as-text pattern, cf. ``test_frontend_tool_states.py``),
|
||||
this module pins the JS markers the phase depends on: the thinking block
|
||||
becomes a TOGGLE (D15) — ``thinking`` frames open it (idempotent),
|
||||
``delta`` frames close it — while the phase-14 restore path and the
|
||||
phase-17 follow-the-tail pin logic stay byte-for-byte untouched. Task 02
|
||||
pins the persistent in-turn loader (D16): the static ``#turn-loader``
|
||||
markup, ``setUiState`` as its SOLE visibility owner (the structural
|
||||
never-stale guarantee), and the CSS contract (reused typing-dot
|
||||
animation + reduced-motion + provenance).
|
||||
this module pins the JS markers the phase depends on: the phase-109
|
||||
D15 re-open pin is REPLACED by the phase-158 per-run contract (owner
|
||||
direction 2026-10-04 — ``thinking`` frames open a NEW block per
|
||||
contiguous run, ``delta`` frames fold the active run, a later
|
||||
thinking frame opens a NEW run, not a re-open) — while the
|
||||
phase-14 restore path and the phase-17 follow-the-tail pin logic stay
|
||||
byte-for-byte untouched (the capture moves verbatim, per block).
|
||||
Task 02 (phase 109) pins the persistent in-turn loader (D16): the
|
||||
static ``#turn-loader`` markup, ``setUiState`` as its SOLE visibility
|
||||
owner (the structural never-stale guarantee), and the CSS contract
|
||||
(reused typing-dot animation + reduced-motion + provenance).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -48,87 +52,88 @@ def _thinking_branch() -> str:
|
||||
return js[thinking_idx:tool_idx]
|
||||
|
||||
|
||||
def test_thinking_handler_reopens_the_collapsed_block() -> None:
|
||||
"""Phase 109 (D15): the `thinking` handler re-opens the block —
|
||||
``block.open = true`` sits in the handler, positioned AFTER the
|
||||
``ensureThinkingBlock(wrap)`` line (the block must exist before it
|
||||
can be opened). Idempotent: a no-op while already open (the
|
||||
pre-delta live flow), a re-open after a `delta` closed it (the next
|
||||
agent round — the reported freeze, TODO.md L3)."""
|
||||
def test_thinking_handler_opens_a_new_block_per_run() -> None:
|
||||
"""Phase 158 (D1 — the phase-109 D15 re-open pin, replaced by
|
||||
owner direction 2026-10-04): the `thinking` handler opens a NEW
|
||||
block per contiguous run — ``ensureThinkingRun(wrap)`` sits in the
|
||||
handler (a frame of a new kind closes the previous run and opens
|
||||
a new block), the RUN's text accumulates + re-renders into THAT
|
||||
block, and the D15 re-open assignment (``block.open = true``) is
|
||||
GONE from the branch — the open state is owned by the builder
|
||||
(a new run opens; the active run stays open)."""
|
||||
branch = _thinking_branch()
|
||||
ensure_idx = branch.find("ensureThinkingBlock(wrap)")
|
||||
assert ensure_idx != -1, "the handler must ensure the block first"
|
||||
reopen_idx = branch.find("block.open = true")
|
||||
assert reopen_idx != -1, (
|
||||
"the `thinking` handler must open the block (D15: "
|
||||
"open-while-thinking)"
|
||||
ensure_idx = branch.find("ensureThinkingRun(wrap)")
|
||||
assert ensure_idx != -1, (
|
||||
"the handler must open/extend the run's block (D1: per-run — "
|
||||
"a new kind opens a NEW block)"
|
||||
)
|
||||
assert ensure_idx < reopen_idx, (
|
||||
"`block.open = true` must sit AFTER `ensureThinkingBlock(wrap)` "
|
||||
"— the block must exist before it is opened"
|
||||
assert "currentRun.run.text += ev.text || \"\"" in branch, (
|
||||
"the RUN's text accumulates (the block renders the run, not "
|
||||
"the whole-turn accumulator)"
|
||||
)
|
||||
assert "block.open = true" not in branch, (
|
||||
"the D15 re-open assignment is GONE — ensureThinkingRun owns "
|
||||
"the open state"
|
||||
)
|
||||
|
||||
|
||||
def test_delta_handler_still_closes_the_block() -> None:
|
||||
"""Phase 109 (D15): the close side of the toggle survives — the
|
||||
`delta` handler still calls ``closeThinkingBlock(wrap)`` (closed
|
||||
while answering); nothing else about the block's lifecycle changes."""
|
||||
def test_delta_handler_folds_the_active_run() -> None:
|
||||
"""Phase 158 (D5 — the phase-109 D15 close-side pin, updated):
|
||||
the close side of the per-run contract — the `delta` handler calls
|
||||
``closeCurrentRun()`` (folds the active run, idempotent); a later
|
||||
thinking frame opens a NEW run above the bubble (the D15 re-open
|
||||
of the shared block is gone)."""
|
||||
js = _js()
|
||||
delta_idx = js.find('ev.type === "delta"')
|
||||
done_idx = js.find('ev.type === "done"')
|
||||
assert -1 < delta_idx < done_idx, "the turn handler must branch on delta"
|
||||
branch = js[delta_idx:done_idx]
|
||||
assert "closeThinkingBlock(wrap)" in branch, (
|
||||
"the `delta` handler must keep closing the block (D15: "
|
||||
"closed-while-answering)"
|
||||
assert "closeCurrentRun()" in branch, (
|
||||
"the `delta` handler must fold the active run (D5: "
|
||||
"folded-while-answering)"
|
||||
)
|
||||
|
||||
|
||||
def test_close_thinking_block_docstring_says_toggle_not_one_way() -> None:
|
||||
"""Phase 109 (D15): the narrative flips. The section comment
|
||||
documenting ``closeThinkingBlock`` no longer claims "never reopens"
|
||||
— nowhere in app.js does — and documents the toggle contract instead:
|
||||
open-while-thinking / closed-while-answering, naming the `thinking`
|
||||
handler's re-open and the `delta` handler's close. The function
|
||||
body itself is unchanged (idempotent close, no-op without a
|
||||
block)."""
|
||||
def test_section_comment_says_per_run_not_reopen() -> None:
|
||||
"""Phase 158 (D1 — the phase-109 D15 narrative, replaced): the
|
||||
section comment documenting the run builders no longer claims the
|
||||
D15 toggle contract (the "re-opens" narrative is gone — nowhere
|
||||
in app.js does "never reopens" survive either) — it names the
|
||||
per-run timeline instead: each CONTIGUOUS run its own block,
|
||||
arrival order, D2 placement, and the D15 contract as replaced."""
|
||||
js = _js()
|
||||
fn = js.find("function closeThinkingBlock")
|
||||
assert fn != -1, "closeThinkingBlock must exist"
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert "block.open = false" in body, "the close side is unchanged"
|
||||
# The block comment that documents the helpers: walk back from the
|
||||
# function to its section comment start.
|
||||
comment_start = js.rfind("/*", 0, fn)
|
||||
assert comment_start != -1
|
||||
section = js[comment_start:fn]
|
||||
assert "never reopens" not in section, (
|
||||
"the one-way-door claim is gone (phase 109, D15)"
|
||||
)
|
||||
assert "never reopens" not in js, (
|
||||
"the 'never reopens' narrative must not survive anywhere in app.js"
|
||||
"the one-way-door narrative must not survive anywhere in app.js"
|
||||
)
|
||||
# The block comment that documents the builders: walk back from
|
||||
# the first builder to its section comment start.
|
||||
comment_start = js.rfind("/*", 0, js.find("function createThinkingBlock"))
|
||||
assert comment_start != -1
|
||||
section = js[comment_start : js.find("function createThinkingBlock")]
|
||||
assert "re-opens" not in section, (
|
||||
"the D15 re-open narrative is gone (phase 158, D1)"
|
||||
)
|
||||
assert "PER-RUN" in section or "per-run" in section, (
|
||||
"the contract is named: per-run"
|
||||
)
|
||||
assert "CONTIGUOUS" in section, "a contiguous run per block is named"
|
||||
assert "arrival order" in section, "D2: arrival order is named"
|
||||
assert "REPLACED" in section or "superseded" in section.lower(), (
|
||||
"the D15 contract is named as replaced"
|
||||
)
|
||||
# The toggle contract is documented — and it names both sides:
|
||||
# the `thinking` handler's re-open and the `delta` handler's close.
|
||||
assert "TOGGLE" in section, "the contract is named: a toggle"
|
||||
assert "open-while-thinking" in section
|
||||
assert "closed-while-answering" in section
|
||||
assert "`thinking`" in section, "the contract names the thinking handler"
|
||||
assert "re-opens" in section, "the contract names the thinking handler's re-open"
|
||||
assert "`delta`" in section, "the contract names the delta handler's close"
|
||||
|
||||
|
||||
def test_restore_path_still_collapses_stored_blocks() -> None:
|
||||
"""Phase-14 contract regression: ``renderStoredMessage`` restores
|
||||
stored thinking blocks COLLAPSED (``block.open = false``) — the
|
||||
phase-109 toggle only changes the LIVE `thinking` handler, the
|
||||
restore path is untouched."""
|
||||
stored thinking blocks COLLAPSED (``block.open = false`` — the
|
||||
shared ``createThinkingBlock`` builder, phase 158 task 02) — the
|
||||
phase-158 per-run live path never opens a restored block."""
|
||||
js = _js()
|
||||
fn = js.find("function renderStoredMessage")
|
||||
assert fn != -1, "renderStoredMessage must exist"
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert "if (m.thinking)" in body
|
||||
assert "ensureThinkingBlock(wrap)" in body
|
||||
assert "createThinkingBlock()" in body
|
||||
assert "block.open = false" in body, "stored blocks restore collapsed"
|
||||
# The live re-open must NOT have leaked into the restore path:
|
||||
# only the collapse assignment may appear in the function body.
|
||||
@@ -142,10 +147,11 @@ def test_follow_the_tail_pin_logic_is_untouched() -> None:
|
||||
unchanged — ``THINKING_NEAR_BOTTOM_PX`` (32px band) +
|
||||
``isThinkingNearBottom`` and the pre-render capture
|
||||
``const pinned = block.open && isThinkingNearBottom(textEl);``
|
||||
(measured BEFORE the re-render) still sit in the `thinking` handler.
|
||||
The capture already keys off ``block.open``, so a re-opened block
|
||||
resumes pinned tail-following exactly like the live pre-delta
|
||||
block — no pin logic change needed for D15."""
|
||||
(measured BEFORE the re-render) still sit in the `thinking`
|
||||
handler — now operating per block (phase 158: the re-render is
|
||||
the RUN's text, not the whole-turn accumulator). The capture
|
||||
already keys off ``block.open``, so a live-open block resumes
|
||||
pinned tail-following exactly like the phase-17 flow."""
|
||||
js = _js()
|
||||
assert "export const THINKING_NEAR_BOTTOM_PX = 32;" in js, (
|
||||
"the 32px window band is unchanged"
|
||||
@@ -158,12 +164,12 @@ def test_follow_the_tail_pin_logic_is_untouched() -> None:
|
||||
branch = _thinking_branch()
|
||||
capture = "const pinned = block.open && isThinkingNearBottom(textEl);"
|
||||
assert capture in branch, (
|
||||
"the pre-render `block.open &&` guard line is unchanged — D15 "
|
||||
"keeps the pin logic untouched (it already reads block.open "
|
||||
"before the re-render)"
|
||||
"the pre-render `block.open &&` guard line is unchanged — the "
|
||||
"pin logic moves verbatim into the per-run branch (phase 17 "
|
||||
"regression)"
|
||||
)
|
||||
capture_idx = branch.find(capture)
|
||||
render_idx = branch.find("textEl.innerHTML = renderMarkdown(thinkingAcc)")
|
||||
render_idx = branch.find("textEl.innerHTML = renderMarkdown(currentRun.run.text)")
|
||||
assert -1 < capture_idx < render_idx, (
|
||||
"pin state is still measured BEFORE the re-render (phase-17 "
|
||||
"regression — the 2-newline-gap fix)"
|
||||
|
||||
@@ -111,17 +111,21 @@ def test_read_range_suffix_shape_order() -> None:
|
||||
|
||||
|
||||
def test_append_tool_line_signature_takes_optional_range() -> None:
|
||||
"""appendToolLine gains the additive ``offset``/``limit`` params
|
||||
(default ``null`` — a pre-phase 3-arg call renders byte-identical),
|
||||
and the read arm renders the suffix through the SAME helper.
|
||||
Phase 146 (task 02) extends the signature one more additive param
|
||||
(``scope = null`` — the scoped grep's document) — the 5-arg
|
||||
pre-phase-146 call shape renders byte-identically."""
|
||||
"""appendToolLine takes the CONTAINER as its first param (phase
|
||||
158, D1 — the caller passes the run's disclosure; the
|
||||
single-container derivation is gone) + the additive
|
||||
``offset``/``limit`` params (default ``null`` — a pre-phase
|
||||
3-arg call renders byte-identical), and the read arm renders the
|
||||
suffix through the SAME helper. Phase 146 (task 02) extends the
|
||||
signature one more additive param (``scope = null`` — the scoped
|
||||
grep's document) — the 5-arg pre-phase-146 call shape renders
|
||||
byte-identically."""
|
||||
body = _extract(_js(), "appendToolLine")
|
||||
assert (
|
||||
"function appendToolLine(wrap, name, argument, offset = null, limit = null, "
|
||||
"scope = null)" in _js()
|
||||
), "the signature carries the additive offset/limit/scope (default null)"
|
||||
"function appendToolLine(container, name, argument, offset = null, "
|
||||
"limit = null, scope = null)" in _js()
|
||||
), ("the signature carries the container + the additive "
|
||||
"offset/limit/scope (default null)")
|
||||
assert "readRangeSuffix(offset, limit)" in body, (
|
||||
"the read arm renders the suffix through the SAME helper"
|
||||
)
|
||||
@@ -190,8 +194,8 @@ def test_live_tool_branch_reads_the_range_with_the_guard() -> None:
|
||||
assert "toolAcc.push(toolRecord)" in branch, "every tool frame is recorded"
|
||||
# Phase 146 (task 02): the scope joins the SAME two pass-throughs.
|
||||
assert (
|
||||
"appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath)"
|
||||
in branch
|
||||
"const line = appendToolLine("
|
||||
in branch and "armToolLineClock(wrap, line)" in branch
|
||||
)
|
||||
|
||||
|
||||
@@ -243,8 +247,9 @@ def test_restore_path_passes_the_stored_range_through() -> None:
|
||||
assert "Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null" in body, (
|
||||
"the stored limit reads through the house guard"
|
||||
)
|
||||
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body, (
|
||||
"the stored range + the phase-146 stored scope ride the SAME helper"
|
||||
assert "appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)" in body, (
|
||||
"the stored range + the phase-146 stored scope ride the SAME helper "
|
||||
"(the caller passes the run's disclosure — phase 158)"
|
||||
)
|
||||
|
||||
|
||||
@@ -371,6 +376,19 @@ function makeWrap() {
|
||||
wrap.appendChild(body);
|
||||
return wrap;
|
||||
}
|
||||
function makeDisclosure() {
|
||||
const container = document.createElement("details");
|
||||
container.className = "tool-calls-disclosure";
|
||||
const summary = document.createElement("summary");
|
||||
summary.className = "tool-calls-summary";
|
||||
container.appendChild(summary);
|
||||
const listEl = document.createElement("div");
|
||||
listEl.className = "tool-calls";
|
||||
container.appendChild(listEl);
|
||||
const wrap = makeWrap();
|
||||
wrap.querySelector(".msg-body").appendChild(container);
|
||||
return { wrap, container };
|
||||
}
|
||||
function describe(el) {
|
||||
return {
|
||||
text: el.textContent,
|
||||
@@ -427,12 +445,12 @@ def test_plain_line_byte_identical_and_range_line_carries_the_suffix() -> None:
|
||||
+ "\n"
|
||||
+ append
|
||||
+ '\nconst ARG = "samedoc/twice.md";\n'
|
||||
+ "const w1 = makeWrap();\n"
|
||||
+ 'appendToolLine(w1, "read", ARG);\n'
|
||||
+ 'const plain = describe(w1.querySelector(".tool-call"));\n'
|
||||
+ "const w2 = makeWrap();\n"
|
||||
+ 'appendToolLine(w2, "read", ARG, 10, 20);\n'
|
||||
+ 'const range = describe(w2.querySelector(".tool-call"));\n'
|
||||
+ "const a1 = makeDisclosure();\n"
|
||||
+ 'appendToolLine(a1.container, "read", ARG);\n'
|
||||
+ 'const plain = describe(a1.wrap.querySelector(".tool-call"));\n'
|
||||
+ "const a2 = makeDisclosure();\n"
|
||||
+ 'appendToolLine(a2.container, "read", ARG, 10, 20);\n'
|
||||
+ 'const range = describe(a2.wrap.querySelector(".tool-call"));\n'
|
||||
+ "console.log(JSON.stringify({ plain, range }));"
|
||||
)
|
||||
out = json.loads(_run_node(driver))
|
||||
@@ -477,12 +495,12 @@ def test_shared_page_line_parity_under_node() -> None:
|
||||
+ append
|
||||
+ '\nconst ARG = "samedoc/twice.md";\n'
|
||||
# app.js: a range read + a plain read
|
||||
+ "const wa = makeWrap();\n"
|
||||
+ 'appendToolLine(wa, "read", ARG, 10, 20);\n'
|
||||
+ 'const appRange = describe(wa.querySelector(".tool-call"));\n'
|
||||
+ "const wp = makeWrap();\n"
|
||||
+ 'appendToolLine(wp, "read", ARG);\n'
|
||||
+ 'const appPlain = describe(wp.querySelector(".tool-call"));\n'
|
||||
+ "const wa = makeDisclosure();\n"
|
||||
+ 'appendToolLine(wa.container, "read", ARG, 10, 20);\n'
|
||||
+ 'const appRange = describe(wa.wrap.querySelector(".tool-call"));\n'
|
||||
+ "const wp = makeDisclosure();\n"
|
||||
+ 'appendToolLine(wp.container, "read", ARG);\n'
|
||||
+ 'const appPlain = describe(wp.wrap.querySelector(".tool-call"));\n'
|
||||
# shared.js: the SAME record (range + no-fields) through addToolLines
|
||||
+ shared_suffix.replace("function readRangeSuffix", "function sharedSuffix")
|
||||
+ "\n"
|
||||
|
||||
@@ -163,7 +163,7 @@ def test_restore_path_renders_the_stored_marker() -> None:
|
||||
assert fn != -1
|
||||
end = js.find("function restoreConversation")
|
||||
body = js[fn:end]
|
||||
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body
|
||||
assert "appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)" in body
|
||||
assert "t.truncated && arg" in body, (
|
||||
"only an argument-bearing (Reading) record with the flag renders"
|
||||
" the marker"
|
||||
@@ -173,7 +173,7 @@ def test_restore_path_renders_the_stored_marker() -> None:
|
||||
" Number(t.chars_total) || 0)" in body
|
||||
)
|
||||
# The marker append sits INSIDE the tools loop, after the line append.
|
||||
line_idx = body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)")
|
||||
line_idx = body.find("appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)")
|
||||
note_idx = body.find("appendTruncatedNote(wrap, arg,")
|
||||
assert -1 < line_idx < note_idx
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ from pydantic import ValidationError
|
||||
from app.schemas import (
|
||||
ChatMessage,
|
||||
ChatRequest,
|
||||
ChatSegment,
|
||||
ChatThinkingSegment,
|
||||
ChatToolSegment,
|
||||
SavedChatCreate,
|
||||
SavedChatUpdate,
|
||||
SourceRef,
|
||||
@@ -682,3 +685,222 @@ def test_chat_tool_result_event_counts_reject_negative() -> None:
|
||||
ChatToolResultEvent(name="read", argument="x", chars_shown=-1, chars_total=5)
|
||||
with pytest.raises(ValidationError):
|
||||
ChatToolResultEvent(name="read", argument="x", chars_shown=5, chars_total=-1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 158 (task 01): the ChatMessage.segments field — the turn's
|
||||
# chronological run order (D4/D6, owner direction 2026-10-04): the
|
||||
# discriminated thinking/tool union (ChatSegment), capped phase-83-style
|
||||
# (mirrored, not weakened — ToolCall is the shape source), OMITTED when
|
||||
# absent (the phase-50 byte-identical round-trip contract — ABSENT,
|
||||
# never null), the extra="forbid" boundary intact around the new key.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_segments_absent_by_default_and_omitted_in_dump() -> None:
|
||||
"""A record WITHOUT ``segments`` (every pre-phase shape, every
|
||||
no-thinking/no-tools turn) validates with ``None`` and dumps
|
||||
WITHOUT the key — ABSENT, never ``null`` (the phase-50 round-trip
|
||||
contract; the wrap serializer does the dropping on every surface).
|
||||
The dump stays byte-identical to the pre-phase shape."""
|
||||
msg = ChatMessage.model_validate({"who": "brain", "text": "ok"})
|
||||
assert msg.segments is None
|
||||
dumped = msg.model_dump()
|
||||
assert "segments" not in dumped
|
||||
# Byte-identical to the pre-phase stored shape: every bor.chat.v1
|
||||
# optional key present (null), the three serializer-popped keys
|
||||
# (image / answer_image / segments) absent.
|
||||
assert dumped == {
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"sources": None,
|
||||
"related": None,
|
||||
"deflected": None,
|
||||
"suggestions": None,
|
||||
"thinking": None,
|
||||
"tools": None,
|
||||
"stopped": None,
|
||||
"failed": None,
|
||||
"error": None,
|
||||
}
|
||||
# An explicit empty list is a different thing: [] is a value, so it
|
||||
# round-trips as [] (present, empty — the pop is None-gated only).
|
||||
empty = ChatMessage.model_validate(
|
||||
{"who": "brain", "text": "ok", "segments": []}
|
||||
).model_dump()
|
||||
assert empty["segments"] == []
|
||||
|
||||
|
||||
def test_segments_thinking_entry_round_trips_and_caps() -> None:
|
||||
"""The thinking entry: ``min_length=1`` (the zero-char run the
|
||||
client never stores) + ``max_length=32_000`` (HistoryTurn's cap,
|
||||
phase 83) — at the cap validates, one past raises naming the
|
||||
offender; a missing ``text`` too."""
|
||||
at_cap = ChatMessage.model_validate(
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"segments": [{"kind": "thinking", "text": "t" * TEXT_CAP}],
|
||||
}
|
||||
)
|
||||
assert at_cap.segments == [
|
||||
ChatThinkingSegment(kind="thinking", text="t" * TEXT_CAP)
|
||||
]
|
||||
assert at_cap.model_dump()["segments"] == [
|
||||
{"kind": "thinking", "text": "t" * TEXT_CAP}
|
||||
]
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
ChatMessage.model_validate(
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"segments": [{"kind": "thinking", "text": "t" * (TEXT_CAP + 1)}],
|
||||
}
|
||||
)
|
||||
# Pydantic 2.13 names the union variant in the loc — the 422 still
|
||||
# points at the offender (segments[0].text).
|
||||
_failed_loc(exc.value, "segments", 0, "thinking", "text")
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
ChatMessage.model_validate(
|
||||
{"who": "brain", "text": "ok", "segments": [{"kind": "thinking", "text": ""}]}
|
||||
)
|
||||
_failed_loc(exc.value, "segments", 0, "thinking", "text")
|
||||
with pytest.raises(ValidationError):
|
||||
ChatMessage.model_validate(
|
||||
{"who": "brain", "text": "ok", "segments": [{"kind": "thinking"}]}
|
||||
)
|
||||
|
||||
|
||||
def test_segments_tool_entry_mirrors_toolcall_shape() -> None:
|
||||
"""The tool entry is the EXACT ToolCall field set (mirrored, not
|
||||
weakened — phase-37 pair + phase-95 truncation stamp + phase-139
|
||||
range + phase-146 scoped path): a full entry round-trips
|
||||
byte-for-byte; a NON-scoped entry dumps WITHOUT ``path`` (ABSENT,
|
||||
never ``null`` — the ToolCall omission rule mirrored, so a
|
||||
``segments`` entry and the matching ``tools`` entry of the same
|
||||
record stay the same data); a scoped one keeps it."""
|
||||
full = {
|
||||
"kind": "tool",
|
||||
"name": "read",
|
||||
"argument": "Homelab/kubernetes.md",
|
||||
"truncated": True,
|
||||
"chars_shown": 128_000,
|
||||
"chars_total": 204_000,
|
||||
"offset": 42,
|
||||
"limit": 30,
|
||||
"path": "search_docs/reese-notes.md",
|
||||
}
|
||||
msg = ChatMessage.model_validate(
|
||||
{"who": "brain", "text": "ok", "segments": [full]}
|
||||
)
|
||||
assert msg.model_dump()["segments"] == [full]
|
||||
# Round-trip: the dump re-validates and dumps back to itself.
|
||||
again = ChatMessage.model_validate(
|
||||
{"who": "brain", "text": "ok", "segments": msg.model_dump()["segments"]}
|
||||
)
|
||||
assert again.model_dump() == msg.model_dump()
|
||||
|
||||
unscoped = {
|
||||
"kind": "tool",
|
||||
"name": "ls",
|
||||
"argument": None,
|
||||
"truncated": False,
|
||||
"chars_shown": None,
|
||||
"chars_total": None,
|
||||
"offset": None,
|
||||
"limit": None,
|
||||
}
|
||||
dumped = ChatMessage.model_validate(
|
||||
{"who": "brain", "text": "ok", "segments": [unscoped]}
|
||||
).model_dump()["segments"][0]
|
||||
assert "path" not in dumped # ABSENT, never null
|
||||
assert dumped == {
|
||||
"kind": "tool",
|
||||
"name": "ls",
|
||||
"argument": None,
|
||||
"truncated": False,
|
||||
"chars_shown": None,
|
||||
"chars_total": None,
|
||||
"offset": None,
|
||||
"limit": None,
|
||||
}
|
||||
|
||||
|
||||
def test_segments_caps_mirrored_not_weakened() -> None:
|
||||
"""The ToolCall caps bind on the segment (mirrored, not
|
||||
weakened): ``name`` ≤ 100, ``argument`` ≤ 2 000, ``path`` ≤ 2 000,
|
||||
``offset``/``limit`` ≥ 1, the list ≤ 50 (the phase-83 voice — the
|
||||
``tools`` cap)."""
|
||||
bad: list[dict] = [
|
||||
{"kind": "tool", "name": "n" * (NAME_CAP + 1), "argument": None},
|
||||
{"kind": "tool", "name": "grep", "argument": "a" * (ARGUMENT_CAP + 1)},
|
||||
{"kind": "tool", "name": "grep", "argument": "x", "path": "p" * (ARGUMENT_CAP + 1)},
|
||||
{"kind": "tool", "name": "read", "offset": 0},
|
||||
{"kind": "tool", "name": "read", "limit": 0},
|
||||
{"kind": "tool", "name": "read", "chars_shown": -1},
|
||||
]
|
||||
for entry in bad:
|
||||
with pytest.raises(ValidationError):
|
||||
ChatMessage.model_validate(
|
||||
{"who": "brain", "text": "ok", "segments": [entry]}
|
||||
)
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
ChatMessage.model_validate(
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"segments": [{"kind": "thinking", "text": "x"}] * (TOOLS_CAP + 1),
|
||||
}
|
||||
)
|
||||
_failed_loc(exc.value, "segments")
|
||||
|
||||
|
||||
def test_unknown_segment_kind_rejected() -> None:
|
||||
"""The discriminator (``kind`` = ``"thinking"`` | ``"tool"``): an
|
||||
unknown ``kind`` is a ValidationError naming the offending entry —
|
||||
nothing outside the union crosses the boundary."""
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
ChatMessage.model_validate(
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"segments": [{"kind": "bogus", "text": "x"}],
|
||||
}
|
||||
)
|
||||
assert exc.value.errors()[0]["loc"][:2] == ("segments", 0), exc.value.errors()
|
||||
# A thinking variant without its ``text`` and a tool variant without
|
||||
# its ``name`` are both rejected (the required fields are real).
|
||||
with pytest.raises(ValidationError):
|
||||
ChatMessage.model_validate(
|
||||
{"who": "brain", "text": "ok", "segments": [{"kind": "tool"}]}
|
||||
)
|
||||
|
||||
|
||||
def test_extra_top_level_key_still_forbidden_with_segments() -> None:
|
||||
"""The ``extra="forbid"`` boundary is INTACT around the new key:
|
||||
a record with a well-formed ``segments`` AND a stray top-level key
|
||||
is still rejected — adding the field changed nothing about which
|
||||
keys are accepted (the phase-83 voice)."""
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
ChatMessage.model_validate(
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"html": "<b>x</b>",
|
||||
"segments": [{"kind": "thinking", "text": "x"}],
|
||||
}
|
||||
)
|
||||
_failed_loc(exc.value, "html")
|
||||
|
||||
|
||||
def test_segments_alias_is_the_discriminated_union() -> None:
|
||||
"""``ChatSegment`` is the Annotated discriminated alias (the
|
||||
``_Chip`` precedent, phase 83): the JSON shape is a flat object —
|
||||
the discriminator rides as a field, no wrapper."""
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
adapter = TypeAdapter(ChatSegment)
|
||||
thinking = adapter.validate_python({"kind": "thinking", "text": "x"})
|
||||
assert isinstance(thinking, ChatThinkingSegment)
|
||||
tool = adapter.validate_python({"kind": "tool", "name": "ls"})
|
||||
assert isinstance(tool, ChatToolSegment)
|
||||
|
||||
@@ -89,16 +89,20 @@ def _run_node(script: str) -> str:
|
||||
|
||||
|
||||
def test_append_tool_line_signature_takes_optional_scope() -> None:
|
||||
"""appendToolLine gains the additive ``scope`` param (fifth,
|
||||
default ``null`` — a pre-phase 5-arg call renders byte-identical),
|
||||
and the grep arm appends the scope span through the pinned
|
||||
structure (a ``.grep-scope`` span SIBLING after the pattern
|
||||
``<code>`` — the phase-139 read-range-suffix precedent)."""
|
||||
"""appendToolLine takes the CONTAINER as its first param (phase
|
||||
158, D1 — the caller passes the run's disclosure; the
|
||||
single-container derivation is gone) + the additive ``scope``
|
||||
param (fifth, default ``null`` — a pre-phase 5-arg call renders
|
||||
byte-identical), and the grep arm appends the scope span through
|
||||
the pinned structure (a ``.grep-scope`` span SIBLING after the
|
||||
pattern ``<code>`` — the phase-139 read-range-suffix
|
||||
precedent)."""
|
||||
js = _js()
|
||||
assert (
|
||||
"function appendToolLine(wrap, name, argument, offset = null, "
|
||||
"function appendToolLine(container, name, argument, offset = null, "
|
||||
"limit = null, scope = null)" in js
|
||||
), "the signature carries the additive scope (default null)"
|
||||
), ("the signature carries the container + the additive scope "
|
||||
"(default null)")
|
||||
body = _extract(js, "appendToolLine")
|
||||
assert "if (typeof scope === \"string\" && scope)" in body, (
|
||||
"the span is appended ONLY when the scope is a non-empty string"
|
||||
@@ -184,8 +188,8 @@ def test_live_tool_branch_reads_the_scope_with_the_guard() -> None:
|
||||
assert "toolAcc.push(toolRecord)" in branch, "every tool frame is recorded"
|
||||
# The scope rides the SAME helper (the line's fifth argument).
|
||||
assert (
|
||||
"appendToolLine(wrap, name, argument, frameOffset, frameLimit, "
|
||||
"framePath)" in branch
|
||||
"const line = appendToolLine(" in branch
|
||||
and "armToolLineClock(wrap, line)" in branch
|
||||
)
|
||||
|
||||
|
||||
@@ -229,8 +233,9 @@ def test_restore_path_passes_the_stored_scope_through() -> None:
|
||||
"typeof t.path === \"string\" && t.path ? t.path : null" in body
|
||||
), "the stored scope reads through the house string guard"
|
||||
assert (
|
||||
"appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body
|
||||
), "the stored scope rides the SAME helper"
|
||||
"appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)" in body
|
||||
), ("the stored scope rides the SAME helper (the caller passes the "
|
||||
"run's disclosure — phase 158)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -380,6 +385,19 @@ function makeWrap() {
|
||||
wrap.appendChild(body);
|
||||
return wrap;
|
||||
}
|
||||
function makeDisclosure() {
|
||||
const container = document.createElement("details");
|
||||
container.className = "tool-calls-disclosure";
|
||||
const summary = document.createElement("summary");
|
||||
summary.className = "tool-calls-summary";
|
||||
container.appendChild(summary);
|
||||
const listEl = document.createElement("div");
|
||||
listEl.className = "tool-calls";
|
||||
container.appendChild(listEl);
|
||||
const wrap = makeWrap();
|
||||
wrap.querySelector(".msg-body").appendChild(container);
|
||||
return { wrap, container };
|
||||
}
|
||||
function describe(el) {
|
||||
return {
|
||||
text: el.textContent,
|
||||
@@ -418,24 +436,24 @@ def test_scoped_grep_line_and_bare_byte_identity_under_node() -> None:
|
||||
+ '\nconst PATTERN = "reese-sentinel-42";\n'
|
||||
+ 'const SCOPE = "search_docs/reese-notes.md";\n'
|
||||
# bare grep (no scope)
|
||||
+ "const w0 = makeWrap();\n"
|
||||
+ 'appendToolLine(w0, "grep", PATTERN);\n'
|
||||
+ 'const bare = describe(w0.querySelector(".tool-call"));\n'
|
||||
+ "const a0 = makeDisclosure();\n"
|
||||
+ 'appendToolLine(a0.container, "grep", PATTERN);\n'
|
||||
+ 'const bare = describe(a0.wrap.querySelector(".tool-call"));\n'
|
||||
# bare grep, legacy name (no scope)
|
||||
+ "const w1 = makeWrap();\n"
|
||||
+ 'appendToolLine(w1, "search_documents", PATTERN);\n'
|
||||
+ 'const legacy = describe(w1.querySelector(".tool-call"));\n'
|
||||
+ "const a1 = makeDisclosure();\n"
|
||||
+ 'appendToolLine(a1.container, "search_documents", PATTERN);\n'
|
||||
+ 'const legacy = describe(a1.wrap.querySelector(".tool-call"));\n'
|
||||
# guarded fallbacks: empty string + non-string scope
|
||||
+ "const w2 = makeWrap();\n"
|
||||
+ 'appendToolLine(w2, "grep", PATTERN, null, null, "");\n'
|
||||
+ 'const emptyScope = describe(w2.querySelector(".tool-call"));\n'
|
||||
+ "const w3 = makeWrap();\n"
|
||||
+ 'appendToolLine(w3, "grep", PATTERN, null, null, 42);\n'
|
||||
+ 'const badScope = describe(w3.querySelector(".tool-call"));\n'
|
||||
+ "const a2 = makeDisclosure();\n"
|
||||
+ 'appendToolLine(a2.container, "grep", PATTERN, null, null, "");\n'
|
||||
+ 'const emptyScope = describe(a2.wrap.querySelector(".tool-call"));\n'
|
||||
+ "const a3 = makeDisclosure();\n"
|
||||
+ 'appendToolLine(a3.container, "grep", PATTERN, null, null, 42);\n'
|
||||
+ 'const badScope = describe(a3.wrap.querySelector(".tool-call"));\n'
|
||||
# the scoped grep
|
||||
+ "const w4 = makeWrap();\n"
|
||||
+ 'appendToolLine(w4, "grep", PATTERN, null, null, SCOPE);\n'
|
||||
+ 'const scoped = describe(w4.querySelector(".tool-call"));\n'
|
||||
+ "const a4 = makeDisclosure();\n"
|
||||
+ 'appendToolLine(a4.container, "grep", PATTERN, null, null, SCOPE);\n'
|
||||
+ 'const scoped = describe(a4.wrap.querySelector(".tool-call"));\n'
|
||||
"console.log(JSON.stringify("
|
||||
"{ bare, legacy, emptyScope, badScope, scoped }));"
|
||||
)
|
||||
@@ -511,15 +529,15 @@ def test_non_grep_arms_ignoring_the_scope_stay_byte_identical() -> None:
|
||||
+ range_suffix
|
||||
+ "\n"
|
||||
+ append
|
||||
+ "const w0 = makeWrap();\n"
|
||||
+ 'appendToolLine(w0, "read", "s/d.md", null, null, "x");\n'
|
||||
+ 'const readLine = describe(w0.querySelector(".tool-call"));\n'
|
||||
+ "const w1 = makeWrap();\n"
|
||||
+ 'appendToolLine(w1, "ls", "s", null, null, "x");\n'
|
||||
+ 'const lsLine = describe(w1.querySelector(".tool-call"));\n'
|
||||
+ "const w2 = makeWrap();\n"
|
||||
+ 'appendToolLine(w2, "ls", null, null, null, "x");\n'
|
||||
+ 'const plainLine = describe(w2.querySelector(".tool-call"));\n'
|
||||
+ "const a0 = makeDisclosure();\n"
|
||||
+ 'appendToolLine(a0.container, "read", "s/d.md", null, null, "x");\n'
|
||||
+ 'const readLine = describe(a0.wrap.querySelector(".tool-call"));\n'
|
||||
+ "const a1 = makeDisclosure();\n"
|
||||
+ 'appendToolLine(a1.container, "ls", "s", null, null, "x");\n'
|
||||
+ 'const lsLine = describe(a1.wrap.querySelector(".tool-call"));\n'
|
||||
+ "const a2 = makeDisclosure();\n"
|
||||
+ 'appendToolLine(a2.container, "ls", null, null, null, "x");\n'
|
||||
+ 'const plainLine = describe(a2.wrap.querySelector(".tool-call"));\n'
|
||||
"console.log(JSON.stringify({ readLine, lsLine, plainLine }));"
|
||||
)
|
||||
out = json.loads(_run_node(driver))
|
||||
|
||||
@@ -60,11 +60,17 @@ def test_pagehide_guard_requires_in_flight_state_and_streamed_text() -> None:
|
||||
def test_pagehide_reuses_remember_brain_turn_with_thinking() -> None:
|
||||
"""The partial is persisted through the EXISTING save-point helper —
|
||||
raw text, optional thinking field (`undefined` drops the key from the
|
||||
JSON), no sources/deflection: a plain brain message that restore
|
||||
re-renders exactly like a completed answer (no '(partial)' marker)."""
|
||||
JSON) + phase 158 (D4): the optional timeline (non-empty only), no
|
||||
sources/deflection: a plain brain message that restore re-renders
|
||||
exactly like a completed answer (no '(partial)' marker)."""
|
||||
js = _js()
|
||||
body = _pagehide_body(js)
|
||||
assert "rememberBrainTurn(acc, { thinking: thinkingAcc || undefined });" in body
|
||||
assert "rememberBrainTurn(acc, {" in body
|
||||
assert "thinking: thinkingAcc || undefined" in body
|
||||
assert (
|
||||
"segments: pagehideSegments.length ? pagehideSegments : undefined"
|
||||
in body
|
||||
), "phase 158: the partial's timeline persists (non-empty only)"
|
||||
# No duplicated storage code: the handler pushes nothing itself.
|
||||
assert "conversation.push" not in body
|
||||
assert "saveConversation()" not in body
|
||||
|
||||
@@ -123,7 +123,7 @@ def test_thinking_pin_is_gated_on_window_bottom() -> None:
|
||||
assert -1 < thinking_idx < delta_idx
|
||||
thinking_branch = js[thinking_idx:delta_idx]
|
||||
capture = "block.open && isThinkingNearBottom(textEl)"
|
||||
render = "textEl.innerHTML = renderMarkdown(thinkingAcc)"
|
||||
render = "textEl.innerHTML = renderMarkdown(currentRun.run.text)"
|
||||
gate = "if (pinned)"
|
||||
assert capture in thinking_branch, (
|
||||
"the combined gate must be captured (block.open + the window band)"
|
||||
@@ -160,5 +160,5 @@ def test_restore_path_renders_collapsed_block() -> None:
|
||||
start = js.find("function renderStoredMessage(m) {")
|
||||
assert start != -1, "app.js must keep the phase-14 restore renderer"
|
||||
body = js[start : js.find("\n}", start)]
|
||||
assert "ensureThinkingBlock(wrap)" in body
|
||||
assert "createThinkingBlock()" in body
|
||||
assert "block.open = false" in body, "restored blocks must be collapsed"
|
||||
|
||||
@@ -0,0 +1,825 @@
|
||||
"""Unit: the phase-158 per-run timeline contract (tasks 02 + 03).
|
||||
|
||||
The owner's report (2026-10-04, live shared page): the turn's thinking
|
||||
lumped into ONE block and every tool call into ONE disclosure, so the
|
||||
DOM always read "thought everything → called all the tools →
|
||||
answered" — and the phase-109 D15 re-open made it worse (thinking
|
||||
resumed ABOVE the in-progress answer). The fix (phase 158, owner
|
||||
decisions D1–D5): each CONTIGUOUS thinking run streams into its own
|
||||
``details.thinking`` block and each CONTIGUOUS tool-call run into its
|
||||
own ``details.tool-calls-disclosure``, in arrival order, all ABOVE
|
||||
the answer bubble (D2, Option B); the runs open live and fold at rest
|
||||
(D5); the phase-87 elapsed clock re-targets the TRACKED line
|
||||
reference (the ``:last-child`` query breaks with multiple disclosures
|
||||
and is gone); the save payloads persist the ``segments`` array
|
||||
(D4, non-empty only).
|
||||
|
||||
Task 03 adds the RESTORE-path and SHARED-page pins: a record WITH
|
||||
``segments`` renders the collapsed per-run timeline
|
||||
(``restoreTimeline`` in app.js / ``addSegments`` in shared.js — the
|
||||
parity copy); a record WITHOUT ``segments`` (every pre-phase record)
|
||||
restores byte-identically through the untouched legacy path (the
|
||||
dispatch guard is the only change); the builders are
|
||||
createElement/textContent for the structure (the only HTML write is
|
||||
the escape-first renderMarkdown into ``.thinking-text``); unknown
|
||||
``kind`` entries are skipped (never render from storage).
|
||||
|
||||
No new ``app/`` logic in this phase — the behavior lives in
|
||||
``frontend/assets/app.js`` / ``shared.js`` and is E2E-gated by the
|
||||
story suite (``tests/e2e/test_thinking_timeline.py``, task 04).
|
||||
Like the other frontend-adjacent unit files (the house
|
||||
read-the-assets-as-text pattern, cf. ``test_frontend_tool_states.py``),
|
||||
this module pins the JS markers the phase depends on.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
APP_JS = FRONTEND / "assets" / "app.js"
|
||||
SHARED_JS = FRONTEND / "assets" / "shared.js"
|
||||
|
||||
#: The four pinned tool-line label literals (phase 37/70/117 D6) —
|
||||
#: the emoji guard and the pins key off these exact bytes.
|
||||
PINNED_LABELS = (
|
||||
'line.textContent = "📄 Reading "',
|
||||
'line.textContent = "🔎 Searching for "',
|
||||
'line.textContent = "🔎 Listing documents in "',
|
||||
'line.textContent = "🔎 Listing documents"',
|
||||
)
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
return APP_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _js_shared() -> str:
|
||||
return SHARED_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _fn(src: str, name: str) -> str:
|
||||
"""The flat function body (house extraction: first ``\n}\n`` after
|
||||
the definition — the function must stay flat, no nested
|
||||
declarations)."""
|
||||
fn = src.find(f"function {name}")
|
||||
assert fn != -1, f"{name} must exist"
|
||||
return src[fn : src.find("\n}\n", fn)]
|
||||
|
||||
|
||||
def _branch(js: str, open_type: str, close_type: str) -> str:
|
||||
"""One turn-handler branch, between the named event types."""
|
||||
start = js.find(f'ev.type === "{open_type}"')
|
||||
end = js.find(f'ev.type === "{close_type}"')
|
||||
assert -1 < start < end, f"the {open_type} branch must exist"
|
||||
return js[start:end]
|
||||
|
||||
|
||||
# ---------- the run builders (D1/D2/D3/D5) ----------
|
||||
|
||||
|
||||
def test_ensure_thinking_run_builds_a_new_open_block_before_the_bubble() -> None:
|
||||
"""D1/D2/D3: ``ensureThinkingRun`` returns the active thinking
|
||||
run's block or — a NEW ``details.thinking`` (the phase-17 static
|
||||
structure via ``createThinkingBlock``) created OPEN (D5),
|
||||
inserted BEFORE the ``.bubble`` (D2 — the anchor is the bubble,
|
||||
in arrival order). The plain ``Thinking`` literal (D3 — the
|
||||
phase-17 literal, not a numbered variant) rides the static
|
||||
template; the fresh run joins segRuns + currentRun."""
|
||||
js = _js()
|
||||
body = _fn(js, "ensureThinkingRun")
|
||||
assert 'currentRun?.kind === "thinking"' in body, (
|
||||
"the active thinking run is extended, not recreated"
|
||||
)
|
||||
assert "closeCurrentRun()" in body, (
|
||||
"a frame of a new kind CLOSES the previous run first (D1)"
|
||||
)
|
||||
assert "createThinkingBlock()" in body, "the shared builder is reused"
|
||||
assert "block.open = true" in body, "D5: created OPEN while its frames stream"
|
||||
assert 'insertBefore(block, body.querySelector(".bubble"))' in body, (
|
||||
"D2: the anchor is the BUBBLE — above it, in arrival order"
|
||||
)
|
||||
assert 'kind: "thinking"' in body
|
||||
assert "segRuns.push(run)" in body
|
||||
assert "currentRun =" in body
|
||||
|
||||
|
||||
def test_create_thinking_block_is_the_phase_17_static_template_without_data() -> None:
|
||||
"""D3: the thinking block's STATIC structure is the phase-17
|
||||
template — the plain ``Thinking`` summary literal + the
|
||||
``.thinking-text`` div — built with NO data in the template (the
|
||||
house rule: a static template is fine, turn data must never flow
|
||||
through innerHTML; the live run writes
|
||||
``renderMarkdown(currentRun.run.text)`` into ``.thinking-text``,
|
||||
the restore/shared builders are createElement/textContent, task
|
||||
03)."""
|
||||
body = _fn(_js(), "createThinkingBlock")
|
||||
assert 'block.className = "thinking"' in body
|
||||
assert "block.innerHTML" in body, (
|
||||
"the phase-17 static-structure convention is kept (the live "
|
||||
"builder reuses the template)"
|
||||
)
|
||||
assert "`<summary>Thinking</summary><div class=\"thinking-text\"></div>`" in body, (
|
||||
"the plain Thinking literal (D3 — the phase-17 literal, zero "
|
||||
"new literals — position distinguishes the runs)"
|
||||
)
|
||||
assert "${" not in body, (
|
||||
"NO data in the static template — the run's text flows through "
|
||||
"renderMarkdown / textContent"
|
||||
)
|
||||
|
||||
|
||||
def test_ensure_tool_run_builds_a_new_open_disclosure_before_the_bubble() -> None:
|
||||
"""D1/D2/D5: ``ensureToolRun`` returns the active tool run's
|
||||
disclosure or — a NEW ``details.tool-calls-disclosure`` (the
|
||||
phase-117 structure via ``createToolDisclosure`` —
|
||||
summary.tool-calls-summary + the .tool-calls list, role=list +
|
||||
aria-label "Tool calls") created OPEN (D5), inserted BEFORE the
|
||||
bubble (D2, in arrival order). One disclosure per CONTIGUOUS
|
||||
tool-call run; the per-run "Tool call(s) (N)" count rides
|
||||
appendToolLine's summary update."""
|
||||
js = _js()
|
||||
body = _fn(js, "ensureToolRun")
|
||||
assert 'currentRun?.kind === "tool"' in body
|
||||
assert "closeCurrentRun()" in body
|
||||
assert "createToolDisclosure()" in body
|
||||
assert "disc.open = true" in body, "D5: created OPEN while its frames stream"
|
||||
assert 'insertBefore(disc, body.querySelector(".bubble"))' in body
|
||||
assert 'kind: "tool"' in body
|
||||
assert "segRuns.push(run)" in body
|
||||
disc = _fn(js, "createToolDisclosure")
|
||||
assert 'container.className = "tool-calls-disclosure"' in disc
|
||||
assert 'summary.className = "tool-calls-summary"' in disc
|
||||
assert 'listEl.className = "tool-calls"' in disc
|
||||
assert 'setAttribute("role", "list")' in disc
|
||||
assert 'setAttribute("aria-label", "Tool calls")' in disc
|
||||
assert "innerHTML" not in disc, (
|
||||
"the tool builder stays createElement/textContent (phase 117 "
|
||||
"style — innerHTML ABSENT)"
|
||||
)
|
||||
|
||||
|
||||
def test_the_single_block_and_single_container_derivations_are_gone() -> None:
|
||||
"""D1: the phase-109 single shared block and the phase-117
|
||||
single-container derivation are GONE — no ``ensureThinkingBlock``
|
||||
/ ``closeThinkingBlock`` / ``closeToolCalls`` anywhere, the old
|
||||
anchor chain (``.tool-calls-disclosure ?? .tool-calls ??
|
||||
.bubble``) is gone, and ``appendToolLine`` no longer derives a
|
||||
container (the caller passes the run's disclosure)."""
|
||||
js = _js()
|
||||
for gone in (
|
||||
"function ensureThinkingBlock",
|
||||
"function closeThinkingBlock",
|
||||
"function closeToolCalls",
|
||||
"closeThinkingBlock(wrap)",
|
||||
"closeToolCalls(wrap)",
|
||||
'body.querySelector(".tool-calls-disclosure") ??',
|
||||
'body.querySelector(".tool-calls") ?? body.querySelector(".bubble")',
|
||||
):
|
||||
assert gone not in js, f"the pre-phase-158 machinery {gone!r} must be gone"
|
||||
append = _fn(js, "appendToolLine")
|
||||
assert 'querySelector(".tool-calls-disclosure")' not in append, (
|
||||
"the single-container derivation is GONE — the caller passes "
|
||||
"the run's disclosure"
|
||||
)
|
||||
assert "document.createElement(\"details\")" not in append, (
|
||||
"the disclosure's build moved to createToolDisclosure"
|
||||
)
|
||||
|
||||
|
||||
# ---------- appendToolLine: the container signature ----------
|
||||
|
||||
|
||||
def test_append_tool_line_takes_the_container_and_returns_the_line() -> None:
|
||||
"""The signature is ``appendToolLine(container, name, argument,
|
||||
offset = null, limit = null, scope = null)`` — the CALLER passes
|
||||
the run's disclosure (no single-container derivation); everything
|
||||
else in the function stays byte-identical (the four pinned label
|
||||
literals, the phase-139 range suffix, the phase-146 scope span,
|
||||
the per-run ``Tool call(s) (N)`` count), and the function returns
|
||||
the appended ``line`` element (the caller arms its clock)."""
|
||||
js = _js()
|
||||
assert (
|
||||
"function appendToolLine(container, name, argument, offset = null, "
|
||||
"limit = null, scope = null)" in js
|
||||
), "the first param is the container"
|
||||
body = _fn(js, "appendToolLine")
|
||||
for label in PINNED_LABELS:
|
||||
assert label in body, f"pinned label literal changed: {label!r}"
|
||||
assert "const list = container.querySelector(\".tool-calls\")" in body
|
||||
assert "return line" in body, (
|
||||
"the function returns the appended line (armToolLineClock's "
|
||||
"second param)"
|
||||
)
|
||||
assert (
|
||||
"Tool call${n === 1 ? \"\" : \"s\"} (${n})" in body
|
||||
), "the per-run count template on every append"
|
||||
assert "innerHTML" not in body, "no HTML injection surface — textContent only"
|
||||
|
||||
|
||||
# ---------- the phase-87 clock re-target ----------
|
||||
|
||||
|
||||
def test_the_clock_targets_the_tracked_line_reference() -> None:
|
||||
"""Phase 158 (D1): the ``:last-child`` query is GONE — it would
|
||||
find the last line of the WRONG run's list (multiple
|
||||
disclosures). ``armToolLineClock`` takes the line as its SECOND
|
||||
param and stores it in the module-scope ``toolLineEl``; the tick
|
||||
is null-guarded on the reference (a New-Chat reset mid-gap is a
|
||||
no-op); the suffix element + the ``(Ns)`` text + the
|
||||
``TOOL_LINE_ELAPSED_AFTER_MS`` gate stay byte-identical."""
|
||||
js = _js()
|
||||
assert '".tool-calls .tool-call:last-child"' not in js, (
|
||||
"the :last-child query is GONE (multi-disclosure)"
|
||||
)
|
||||
assert "let toolLineEl = null" in js, "the tracked line is module scope"
|
||||
body = _fn(js, "armToolLineClock")
|
||||
assert "function armToolLineClock(wrap, lineEl)" in body, (
|
||||
"the NEW second param"
|
||||
)
|
||||
assert "toolLineEl = lineEl" in body, "the re-arm resets the reference"
|
||||
assert "if (!toolLineEl) return" in body, (
|
||||
"null-guarded: a wrap reset mid-gap (New Chat) is a no-op"
|
||||
)
|
||||
assert 'toolLineEl.querySelector(".tool-elapsed")' in body
|
||||
assert 'el.className = "tool-elapsed"' in body
|
||||
assert 'document.createElement("span")' in body
|
||||
assert "toolLineEl.appendChild(el)" in body
|
||||
assert "`(${secs}s)`" in body, "the parenthesized '(Ns)' suffix stays"
|
||||
assert "TOOL_LINE_ELAPSED_AFTER_MS" in body
|
||||
assert "innerHTML" not in body, "no HTML write in the clock"
|
||||
|
||||
|
||||
def test_settle_resets_the_tracked_line() -> None:
|
||||
"""``settleToolLine`` keeps the ``.tool-elapsed`` removal (it
|
||||
clears EVERY line in the wrap — correct with multiple
|
||||
disclosures) and resets ``toolLineEl = null`` alongside (the
|
||||
stop path — ``stopToolLineClock`` — resets via it)."""
|
||||
js = _js()
|
||||
body = _fn(js, "settleToolLine")
|
||||
assert "clearInterval(toolLineTimer)" in body
|
||||
assert 'querySelectorAll?.(".tool-elapsed")' in body and "el.remove()" in body
|
||||
assert "toolLineEl = null" in body, "the settle forgets the line target"
|
||||
stop = _fn(js, "stopToolLineClock")
|
||||
assert "settleToolLine();" in stop
|
||||
assert "toolLineWrap = null" in stop
|
||||
|
||||
|
||||
def test_the_arm_follows_the_line_append_with_the_new_signature() -> None:
|
||||
"""A6 (live-only): the tool branch arms the clock with the NEW
|
||||
signature — ``armToolLineClock(wrap, line)`` right after
|
||||
``appendToolLine(disc, …)`` (the baseline resets per line), and
|
||||
the arm is still the definition + exactly one live call site (the
|
||||
restore path never arms)."""
|
||||
js = _js()
|
||||
assert js.count("armToolLineClock(") == 2, (
|
||||
"the arm must be the definition + exactly one live call site"
|
||||
)
|
||||
branch = _branch(js, "tool", "retry")
|
||||
append = branch.find(
|
||||
"appendToolLine(\n disc,\n name,\n argument,\n"
|
||||
" frameOffset,\n frameLimit,\n framePath\n"
|
||||
" )"
|
||||
)
|
||||
arm = branch.find("armToolLineClock(wrap, line)")
|
||||
assert -1 < append < arm, (
|
||||
"the arm follows the line's append — the baseline resets per line"
|
||||
)
|
||||
assert "ensureToolRun(wrap)" in branch, (
|
||||
"the tool frame opens/extends the run's disclosure"
|
||||
)
|
||||
restore_fn = js.find("function renderStoredMessage")
|
||||
restore_end = js.find("function restoreConversation")
|
||||
assert -1 < restore_fn < restore_end
|
||||
assert "armToolLineClock" not in js[restore_fn:restore_end], (
|
||||
"the restore path must never arm the clock (A6)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- the fold sites ----------
|
||||
|
||||
|
||||
def test_close_current_run_and_close_all_runs_are_defined() -> None:
|
||||
"""D5's fold side: ``closeCurrentRun`` folds the ACTIVE run
|
||||
(idempotent; a no-op without a run) and ``closeAllRuns`` closes
|
||||
EVERY open ``details.thinking`` + ``details.tool-calls-disclosure``
|
||||
in the wrap (defensive — a late double-close is a no-op; a no-op
|
||||
without a wrap)."""
|
||||
js = _js()
|
||||
cur = _fn(js, "closeCurrentRun")
|
||||
assert "currentRun.el.open = false" in cur
|
||||
assert "currentRun = null" in cur, "the run is cleared (a later close is a no-op)"
|
||||
all_runs = _fn(js, "closeAllRuns")
|
||||
assert "details.thinking, details.tool-calls-disclosure" in all_runs
|
||||
assert "el.open = false" in all_runs
|
||||
|
||||
|
||||
def test_the_delta_folds_the_active_run_and_the_terminals_fold_every_run() -> None:
|
||||
"""D5: the FIRST `delta` folds the open run (``closeCurrentRun``
|
||||
in the delta branch — the D15 ``block.open = true`` re-open is
|
||||
gone), and the terminal settles — `done`, the stop/abort path,
|
||||
and ``finalizeFailedTurn`` — fold EVERY open run
|
||||
(``closeAllRuns(wrap)``)."""
|
||||
js = _js()
|
||||
delta = _branch(js, "delta", "done")
|
||||
assert "closeCurrentRun()" in delta, "the first delta folds the active run"
|
||||
assert "block.open = true" not in delta, "the D15 re-open is gone from the delta"
|
||||
done = _branch(js, "done", "tool_result")
|
||||
assert "closeAllRuns(wrap)" in done, "done folds every open run"
|
||||
stop_idx = js.find('stoppedByUser || err?.name === "AbortError"')
|
||||
stop = js[stop_idx : js.find("} else {", stop_idx)]
|
||||
assert "closeAllRuns(wrap)" in stop, "the stop path folds every open run"
|
||||
failed = _fn(js, "finalizeFailedTurn")
|
||||
assert "closeAllRuns(wrap)" in failed, "the failed-turn funnel folds every open run"
|
||||
|
||||
|
||||
# ---------- the run tracker + the save payloads (D4) ----------
|
||||
|
||||
|
||||
def test_seg_runs_is_module_scope_and_reset_in_run_turn() -> None:
|
||||
"""The run tracker is MODULE scope (the pagehide partial reads it
|
||||
while a turn is in flight — like ``thinkingAcc``) and resets with
|
||||
the other turn locals at the top of ``runTurn``."""
|
||||
js = _js()
|
||||
assert "let segRuns = []" in js, "module-scope run tracker"
|
||||
assert "let currentRun = null" in js, "the active run is module scope"
|
||||
anchor = js.find("let thinkingAcc = \"\"")
|
||||
assert anchor != -1
|
||||
assert js.find("let segRuns = []") - anchor < 1500, (
|
||||
"the run tracker sits next to thinkingAcc (the pagehide "
|
||||
"readers)"
|
||||
)
|
||||
turn_start = js.find("async function runTurn")
|
||||
turn_end = js.find('window.addEventListener("pagehide"')
|
||||
assert -1 < turn_start < turn_end
|
||||
turn = js[turn_start:turn_end]
|
||||
assert "segRuns = []" in turn, "reset with the other turn locals"
|
||||
assert "currentRun = null" in turn
|
||||
|
||||
|
||||
def test_build_segments_maps_runs_in_order_with_the_empty_run_skip() -> None:
|
||||
"""D4: ``buildSegments`` maps the runs IN ORDER — a thinking run
|
||||
→ ``{ kind: "thinking", text }`` ONLY when its text is
|
||||
non-empty (a zero-char run stores nothing); a tool run → one
|
||||
``{ kind: "tool", …record }`` per record, in order (the record
|
||||
object is spread — the additive offset/limit/path + the phase-95
|
||||
truncation stamp ride through; the records are the SAME toolAcc
|
||||
objects, so the stamp flows with no extra wiring). A turn with no
|
||||
thinking and no tools returns []. No re-validation on the client
|
||||
(the server's ChatMessage boundary validates)."""
|
||||
body = _fn(_js(), "buildSegments")
|
||||
assert "if (r.kind === \"thinking\")" in body
|
||||
assert "if (r.text) out.push({ kind: \"thinking\", text: r.text })" in body, (
|
||||
"the EMPTY thinking run stores NOTHING"
|
||||
)
|
||||
assert "for (const rec of r.records) out.push({ kind: \"tool\", ...rec })" in body, (
|
||||
"one entry per record, in order — the record spread"
|
||||
)
|
||||
assert "return out" in body
|
||||
|
||||
|
||||
def test_the_save_payload_sites_carry_segments_non_empty_only() -> None:
|
||||
"""D4: the FIVE save sites persist ``segments`` non-empty only
|
||||
(the undefined-drops-the-key house pattern) — the `done` save
|
||||
point, ``finalizeFailedTurn`` (the function record + BOTH call
|
||||
sites pass ``buildSegments(segRuns)``), the STOP path's partial,
|
||||
and the pagehide partial — while the ``thinking`` / ``tools``
|
||||
keys stay UNCHANGED at every site. The zero-frame fallback
|
||||
save needs nothing (zero frames → no runs → no key)."""
|
||||
js = _js()
|
||||
# done save point
|
||||
done = _branch(js, "done", "tool_result")
|
||||
assert "const turnSegments = buildSegments(segRuns)" in done
|
||||
assert "segments: turnSegments.length ? turnSegments : undefined" in done
|
||||
assert "thinking: thinkingAcc || undefined" in done
|
||||
assert "tools: toolAcc.length ? toolAcc : undefined" in done
|
||||
# finalizeFailedTurn: the parameter + the record + both call sites
|
||||
failed = _fn(js, "finalizeFailedTurn")
|
||||
assert "segments" in failed.split(") {")[0], "the parameter object gains segments"
|
||||
assert "segments: segments?.length ? segments : undefined" in failed
|
||||
assert js.count("segments: buildSegments(segRuns)") >= 2, (
|
||||
"both call sites (stream-drop + network error) pass the segments"
|
||||
)
|
||||
# the stop path
|
||||
stop_idx = js.find('stoppedByUser || err?.name === "AbortError"')
|
||||
stop = js[stop_idx : js.find("} else {", stop_idx)]
|
||||
assert "const stopSegments = buildSegments(segRuns)" in stop
|
||||
assert "segments: stopSegments.length ? stopSegments : undefined" in stop
|
||||
assert "stopped: true" in stop
|
||||
# the pagehide partial
|
||||
pagehide_start = js.find('window.addEventListener("pagehide"')
|
||||
assert pagehide_start != -1
|
||||
pagehide = js[pagehide_start : js.find("});", pagehide_start)]
|
||||
assert "const pagehideSegments = buildSegments(segRuns)" in pagehide
|
||||
assert (
|
||||
"segments: pagehideSegments.length ? pagehideSegments : undefined"
|
||||
in pagehide
|
||||
)
|
||||
|
||||
|
||||
def test_the_live_thinking_branch_extends_the_run_and_renders_the_run_text() -> None:
|
||||
"""D1: the `thinking` branch keeps the phase-17/87 pre-work
|
||||
(settleToolLine, thinkingAcc, sawThinking, clearTurnTimeout, the
|
||||
wrap get-or-create, removeTyping) and REPLACES the D15
|
||||
ensureThinkingBlock + re-open with ``ensureThinkingRun`` — the
|
||||
RUN's text accumulates ``ev.text`` and re-renders
|
||||
``renderMarkdown(currentRun.run.text)`` into THAT block's
|
||||
``.thinking-text`` (per-block — the pre-update pin capture +
|
||||
bottom-follow logic moves verbatim, measured BEFORE the
|
||||
re-render)."""
|
||||
js = _js()
|
||||
branch = _branch(js, "thinking", "tool")
|
||||
assert "settleToolLine();" in branch
|
||||
assert "thinkingAcc += ev.text" in branch
|
||||
assert "sawThinking = true" in branch
|
||||
assert "clearTurnTimeout()" in branch
|
||||
assert 'addMessage("brain", "")' in branch
|
||||
assert "removeTyping()" in branch
|
||||
assert "const block = ensureThinkingRun(wrap)" in branch
|
||||
assert "currentRun.run.text += ev.text || \"\"" in branch, (
|
||||
"the RUN's text accumulates (the block renders the run, not "
|
||||
"the whole-turn accumulator)"
|
||||
)
|
||||
capture = 'const pinned = block.open && isThinkingNearBottom(textEl);'
|
||||
assert capture in branch, "the pre-update pin capture moves verbatim (per-block)"
|
||||
assert "textEl.innerHTML = renderMarkdown(currentRun.run.text)" in branch
|
||||
cap_idx = branch.find(capture)
|
||||
render_idx = branch.find("textEl.innerHTML = renderMarkdown(currentRun.run.text)")
|
||||
assert -1 < cap_idx < render_idx, (
|
||||
"the pin state is measured BEFORE the re-render (phase-17 "
|
||||
"regression — the 2-newline-gap fix)"
|
||||
)
|
||||
assert "textEl.scrollTop = textEl.scrollHeight" in branch, (
|
||||
"pinned tail-following is intact"
|
||||
)
|
||||
assert "block.open = true" not in branch, (
|
||||
"the D15 re-open assignment is GONE — ensureThinkingRun owns "
|
||||
"the open state (a new run opens; the active run stays open)"
|
||||
)
|
||||
|
||||
|
||||
def test_the_tool_branch_records_the_run_and_arms_the_tracked_clock() -> None:
|
||||
"""D1: the `tool` branch keeps the phase-139/146 guards,
|
||||
``toolAcc.push(toolRecord)``, ``clearTurnTimeout()``, the wrap
|
||||
get-or-create and the status-label logic UNCHANGED, and REPLACES
|
||||
the single-container append + bare arm with:
|
||||
``ensureToolRun(wrap)`` → the run's ``records.push(toolRecord)``
|
||||
(the SAME object toolAcc holds — the phase-95 stamp flows into
|
||||
both) → ``appendToolLine(disc, …)`` →
|
||||
``armToolLineClock(wrap, line)``."""
|
||||
js = _js()
|
||||
branch = _branch(js, "tool", "retry")
|
||||
assert (
|
||||
"Number.isInteger(ev.offset) && ev.offset >= 1 ? ev.offset : null"
|
||||
in branch
|
||||
)
|
||||
assert "Number.isInteger(ev.limit) && ev.limit >= 1 ? ev.limit : null" in branch
|
||||
assert (
|
||||
'typeof ev.path === "string" && ev.path ? ev.path : null' in branch
|
||||
), "the phase-146 guard is unchanged"
|
||||
assert "const toolRecord = { name, argument }" in branch
|
||||
assert "toolAcc.push(toolRecord)" in branch
|
||||
assert "clearTurnTimeout()" in branch
|
||||
assert 'addMessage("brain", "")' in branch
|
||||
assert "const disc = ensureToolRun(wrap)" in branch
|
||||
assert "currentRun.run.records.push(toolRecord)" in branch, (
|
||||
"the run's records hold the SAME toolAcc objects"
|
||||
)
|
||||
assert "armToolLineClock(wrap, line)" in branch
|
||||
|
||||
|
||||
def test_the_legacy_restore_path_uses_the_shared_builders() -> None:
|
||||
"""The renderStoredMessage LEGACY path (a pre-phase record — no
|
||||
``segments``; the segments dispatch is task 03's) restores the
|
||||
thinking block COLLAPSED above the bubble through the SHARED
|
||||
``createThinkingBlock`` builder, and the tool lines through ONE
|
||||
disclosure (``createToolDisclosure``, created with the live
|
||||
lifecycle — folded after the loop, phase 117 D3) via the SAME
|
||||
``appendToolLine`` container signature. The tOffset/tLimit/tPath
|
||||
guards + the phase-95 truncation note stay verbatim."""
|
||||
js = _js()
|
||||
fn = js.find("function renderStoredMessage")
|
||||
end = js.find("function restoreConversation")
|
||||
assert -1 < fn < end
|
||||
body = js[fn:end]
|
||||
assert "if (m.thinking)" in body
|
||||
assert "createThinkingBlock()" in body
|
||||
assert "block.open = false" in body, "stored blocks restore collapsed"
|
||||
assert "block.open = true" not in body, (
|
||||
"the restore path must never open a stored block"
|
||||
)
|
||||
assert "Array.isArray(m.tools)" in body
|
||||
assert "createToolDisclosure()" in body
|
||||
assert "restoreDisc.open = false" in body, (
|
||||
"the restored disclosure folds after the loop (phase 117, D3)"
|
||||
)
|
||||
assert "appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)" in body
|
||||
assert "Number.isInteger(t.offset) && t.offset >= 1 ? t.offset : null" in body
|
||||
assert "Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null" in body
|
||||
assert "typeof t.path === \"string\" && t.path ? t.path : null" in body
|
||||
assert "appendTruncatedNote(wrap, arg" in body, "the phase-95 note is untouched"
|
||||
# The established phase-17 render is the ONLY innerHTML write in
|
||||
# the function — the new restore builders (createThinkingBlock /
|
||||
# createToolDisclosure) add no HTML injection surface.
|
||||
assert body.count("innerHTML") == 1
|
||||
assert (
|
||||
'block.querySelector(".thinking-text").innerHTML = renderMarkdown(m.thinking)'
|
||||
in body
|
||||
)
|
||||
|
||||
|
||||
def test_the_pre_phase_narrative_is_replaced() -> None:
|
||||
"""The narrative flips: the phase-109 D15 re-open contract is
|
||||
superseded — "never reopens" survives nowhere in app.js (the
|
||||
phase-109 pin), and the section comment names the per-run
|
||||
timeline (D1), the arrival-order placement (D2), and the
|
||||
clock re-target (the multi-disclosure fix, phase 158 D1)."""
|
||||
js = _js()
|
||||
assert "never reopens" not in js, (
|
||||
"the one-way-door narrative must not survive anywhere"
|
||||
)
|
||||
comment_start = js.rfind("/*", 0, js.find("function createThinkingBlock"))
|
||||
assert comment_start != -1
|
||||
section = js[comment_start : js.find("function createThinkingBlock")]
|
||||
assert "per-run" in section.lower(), "the contract is named: per-run"
|
||||
assert "arrival order" in section, "D2: arrival order is named"
|
||||
assert "REPLACED" in section or "superseded" in section.lower(), (
|
||||
"the D15 re-open contract is named as replaced"
|
||||
)
|
||||
clock_comment = js.rfind("/*", 0, js.find("function armToolLineClock"))
|
||||
assert clock_comment != -1
|
||||
clock_section = js[clock_comment : js.find("function armToolLineClock")]
|
||||
assert "re-target" in clock_section, (
|
||||
"the clock comment names the re-target (the multi-disclosure "
|
||||
"fix, phase 158 D1)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- task 03: the restore path (app.js) ----------
|
||||
|
||||
|
||||
def test_restore_timeline_builds_the_collapsed_per_run_structure() -> None:
|
||||
"""D5: ``restoreTimeline`` renders the record's ``segments``
|
||||
COLLAPSED — a ``kind:"thinking"`` entry gets its OWN
|
||||
``details.thinking`` (createElement structure, the plain
|
||||
``Thinking`` literal via textContent — D3; NO ``open = true`` —
|
||||
created closed), and CONSECUTIVE ``kind:"tool"`` entries group
|
||||
into ONE ``details.tool-calls-disclosure`` (the shared
|
||||
phase-117 builder — ``createToolDisclosure``, innerHTML ABSENT —
|
||||
created closed), each inserted BEFORE the bubble (D2, arrival
|
||||
order). The per-run count rides ``appendToolLine``'s summary
|
||||
update; a kind change seals the tool run (one disclosure per
|
||||
run, D1)."""
|
||||
body = _fn(_js(), "restoreTimeline")
|
||||
assert "document.createElement(\"details\")" in body, "the structure is createElement"
|
||||
assert 'block.className = "thinking"' in body
|
||||
assert "block.open = true" not in body, "D5: created COLLAPSED (no open state)"
|
||||
assert 'summary.textContent = "Thinking"' in body, (
|
||||
"the plain Thinking literal (D3 — the phase-17 literal; "
|
||||
"position distinguishes the runs) via textContent"
|
||||
)
|
||||
assert 'textEl.className = "thinking-text"' in body
|
||||
# the consecutive-tool grouping (one disclosure per run — the
|
||||
# grouping loop): create on the first entry, seal on a kind
|
||||
# change, insert before the bubble.
|
||||
assert "createToolDisclosure()" in body, "the shared phase-117 builder (innerHTML ABSENT)"
|
||||
assert "toolDisc.open = true" not in body, "D5: created COLLAPSED (no open state)"
|
||||
assert "toolDisc.open = false" in body
|
||||
assert "if (toolDisc) toolDisc = null" in body, (
|
||||
"a thinking entry seals the tool run — ONE disclosure per run (D1)"
|
||||
)
|
||||
assert "body.insertBefore(block, bubble)" in body, "D2: above the bubble"
|
||||
assert "body.insertBefore(toolDisc, bubble)" in body, "D2: above the bubble"
|
||||
assert "for (const entry of segments" in body, "iterate the runs in order"
|
||||
|
||||
|
||||
def test_restore_timeline_reuses_the_restore_guards_and_the_truncation_note() -> None:
|
||||
"""The line path reuses the SAME ``appendToolLine`` container
|
||||
signature with the tOffset/tLimit/tPath well-formedness guards
|
||||
(``Number.isInteger(x) && x >= 1`` / the string guard —
|
||||
byte-identical, the phase-139/146 restore precedent) and the
|
||||
phase-95 ``appendTruncatedNote`` call moved verbatim inside the
|
||||
grouping; a malformed entry (a non-string thinking ``text`` / a
|
||||
non-string tool ``name``) and an entry with an UNKNOWN ``kind``
|
||||
are SKIPPED (never render from storage — textContent only, the
|
||||
house rule)."""
|
||||
body = _fn(_js(), "restoreTimeline")
|
||||
assert (
|
||||
"Number.isInteger(entry.offset) && entry.offset >= 1 ? entry.offset : null"
|
||||
in body
|
||||
), "the phase-139 offset guard is verbatim"
|
||||
assert (
|
||||
"Number.isInteger(entry.limit) && entry.limit >= 1 ? entry.limit : null"
|
||||
in body
|
||||
), "the phase-139 limit guard is verbatim"
|
||||
assert 'typeof entry.path === "string" && entry.path ? entry.path : null' in body, (
|
||||
"the phase-146 path guard is verbatim"
|
||||
)
|
||||
assert "appendToolLine(toolDisc, entry.name, arg, tOffset, tLimit, tPath)" in body, (
|
||||
"the SAME container signature as the live path"
|
||||
)
|
||||
assert "appendTruncatedNote(wrap, arg" in body, "the phase-95 note moved verbatim"
|
||||
# the skips: malformed + unknown kind never render from storage.
|
||||
assert 'if (!entry || typeof entry !== "object") continue' in body
|
||||
assert 'if (typeof entry.text !== "string") continue' in body, "malformed thinking skipped"
|
||||
assert 'if (typeof entry.name !== "string") continue' in body, "malformed tool skipped"
|
||||
assert 'else if (entry.kind === "tool")' in body, (
|
||||
"an entry whose kind is neither thinking nor tool falls through "
|
||||
"— SKIPPED (the guard comment names the skip)"
|
||||
)
|
||||
assert "unknown" in body, "the skip is named in a guard comment"
|
||||
|
||||
|
||||
def _single_escape_first_write(body: str) -> None:
|
||||
"""The house rule for the restore/shared builders (the
|
||||
structure is createElement/textContent): the function has
|
||||
exactly ONE innerHTML write — the escape-first renderMarkdown
|
||||
into ``.thinking-text`` (the existing restore convention;
|
||||
renderMarkdown's escape-first contract keeps storage
|
||||
XSS-safe)."""
|
||||
writes = re.findall(r"\b(\w+)\.innerHTML\s*=\s*([^;\n]+)", body)
|
||||
assert len(writes) == 1, (
|
||||
f"exactly ONE innerHTML write (the structure is createElement): {writes}"
|
||||
)
|
||||
assert writes[0][0] == "textEl", "the single write is into .thinking-text"
|
||||
assert writes[0][1].strip() == "renderMarkdown(entry.text)", (
|
||||
"the single write is the escape-first renderMarkdown"
|
||||
)
|
||||
|
||||
|
||||
def test_restore_timeline_structure_is_create_element_and_the_only_write_is_escape_first() -> None:
|
||||
"""innerHTML ABSENT from the STRUCTURE — it is built via
|
||||
createElement/textContent (the thinking block's summary is
|
||||
textContent; the disclosure rides the innerHTML-absent
|
||||
``createToolDisclosure`` — the task-02 pin). The ONLY HTML
|
||||
write in the function is the escape-first renderMarkdown into
|
||||
``.thinking-text`` (the existing restore convention — the
|
||||
phase-17 pattern; renderMarkdown's escape-first contract keeps
|
||||
storage XSS-safe)."""
|
||||
body = _fn(_js(), "restoreTimeline")
|
||||
_single_escape_first_write(body)
|
||||
assert "document.createElement(\"summary\")" in body, (
|
||||
"the summary is createElement + textContent"
|
||||
)
|
||||
assert "document.createElement(\"div\")" in body, "the .thinking-text is createElement"
|
||||
|
||||
|
||||
def test_the_restore_dispatch_gives_segments_precedence_over_the_legacy_path() -> None:
|
||||
"""The ``renderStoredMessage`` dispatch: a record WITH
|
||||
``segments`` (a non-empty array) renders ``restoreTimeline`` and
|
||||
SKIPS the legacy ``m.thinking`` block + ``m.tools`` loop (the
|
||||
runs carry the same data in order — rendering both would double
|
||||
them); a record WITHOUT ``segments`` restores byte-identically
|
||||
through the legacy path (the dispatch guard is the only change
|
||||
— the legacy statements stay verbatim, the task-02 pins).
|
||||
``restoreTimeline`` is defined BEFORE renderStoredMessage —
|
||||
outside the legacy region the task-02 pin slices (that region's
|
||||
single innerHTML write stays the established phase-17 render)."""
|
||||
js = _js()
|
||||
fn = js.find("function renderStoredMessage")
|
||||
end = js.find("function restoreConversation")
|
||||
assert -1 < fn < end
|
||||
body = js[fn:end]
|
||||
assert "Array.isArray(m.segments) && m.segments.length" in body, "the dispatch guard"
|
||||
assert "restoreTimeline(wrap, m.segments)" in body
|
||||
# precedence: the legacy blocks sit in the ELSE branch.
|
||||
guard_i = body.find("Array.isArray(m.segments)")
|
||||
else_i = body.find("} else {", guard_i)
|
||||
think_i = body.find("if (m.thinking)")
|
||||
tools_i = body.find("if (Array.isArray(m.tools))")
|
||||
assert -1 < guard_i < else_i < think_i < tools_i, (
|
||||
"segments take precedence; the legacy path is the else branch"
|
||||
)
|
||||
# the legacy path is intact (the task-02 pin's markers).
|
||||
assert "createThinkingBlock()" in body
|
||||
assert "block.open = false" in body
|
||||
assert "renderMarkdown(m.thinking)" in body
|
||||
assert "createToolDisclosure()" in body
|
||||
assert "appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)" in body
|
||||
# restoreTimeline is OUTSIDE the sliced region (before the
|
||||
# renderer) — the legacy region's HTML write stays the ONE the
|
||||
# task-02 pin asserts.
|
||||
assert js.find("function restoreTimeline") < fn, (
|
||||
"restoreTimeline is defined before renderStoredMessage — the "
|
||||
"legacy-region pin's innerHTML count is untouched"
|
||||
)
|
||||
assert body.count("innerHTML") == 1, "the legacy path's single write is unchanged"
|
||||
|
||||
|
||||
# ---------- task 03: the shared page (shared.js) ----------
|
||||
|
||||
|
||||
def test_add_segments_builds_the_collapsed_parity_timeline() -> None:
|
||||
"""The shared-page parity copy (D5 — pure render, always
|
||||
COLLAPSED): the same structure (a per-run details.thinking with
|
||||
the plain ``Thinking`` literal via textContent + the phase-117
|
||||
disclosure structure — summary.tool-calls-summary + the
|
||||
.tool-calls list, role=list + aria-label "Tool calls"), the same
|
||||
LINE BYTES as ``addToolLines`` (the four pinned label literals
|
||||
+ the phase-70 legacy names + the phase-139 readRangeSuffix +
|
||||
the phase-95 truncation note), the per-run
|
||||
``Tool call(s) (N)`` count, and insertion BEFORE the bubble
|
||||
(D2, arrival order) — all created CLOSED (no ``open = true``
|
||||
anywhere; the addThinkingBlock / addToolLines collapsed
|
||||
convention)."""
|
||||
body = _fn(_js_shared(), "addSegments")
|
||||
assert "document.createElement(\"details\")" in body, "the structure is createElement"
|
||||
assert 'block.className = "thinking"' in body
|
||||
assert "block.open = true" not in body, "D5: created COLLAPSED"
|
||||
assert "block.open = false" in body
|
||||
assert 'summary.textContent = "Thinking"' in body, "the plain Thinking literal (D3)"
|
||||
assert 'textEl.className = "thinking-text"' in body
|
||||
assert "renderMarkdown(entry.text)" in body, "the escape-first renderer (parity)"
|
||||
# the phase-117 disclosure structure.
|
||||
assert 'toolDisc.className = "tool-calls-disclosure"' in body
|
||||
assert "toolDisc.open = true" not in body, "D5: created COLLAPSED"
|
||||
assert "toolDisc.open = false" in body
|
||||
assert 'summary.className = "tool-calls-summary"' in body
|
||||
assert 'container.className = "tool-calls"' in body
|
||||
assert 'setAttribute("role", "list")' in body
|
||||
assert 'setAttribute("aria-label", "Tool calls")' in body
|
||||
# the parity line bytes — the four pinned label literals + the
|
||||
# phase-139 suffix + the phase-95 note (the addToolLines parity).
|
||||
for label in PINNED_LABELS:
|
||||
assert label in body, f"pinned label literal changed: {label!r}"
|
||||
assert "readRangeSuffix(entry.offset, entry.limit)" in body, "the phase-139 suffix parity"
|
||||
assert '"truncated-note"' in body, "the phase-95 note parity"
|
||||
# the per-run count template.
|
||||
assert "Tool call${n === 1 ? \"\" : \"s\"} (${n})" in body
|
||||
# D2: above the bubble, in arrival order.
|
||||
assert "body.insertBefore(block, bubble)" in body
|
||||
assert "body.insertBefore(toolDisc, bubble)" in body
|
||||
# the consecutive-tool grouping (one disclosure per run, D1).
|
||||
assert "if (toolDisc) toolDisc = null" in body, (
|
||||
"a thinking entry seals the tool run — ONE disclosure per run"
|
||||
)
|
||||
|
||||
|
||||
def test_add_segments_skips_unknown_kinds_and_is_create_element() -> None:
|
||||
"""An entry with an UNKNOWN ``kind`` (or a malformed shape) is
|
||||
SKIPPED — never render from storage (textContent only, the
|
||||
house rule; the guard comment names the skip). innerHTML ABSENT
|
||||
from the structure — the ONLY HTML write is the escape-first
|
||||
renderMarkdown into ``.thinking-text`` (the addThinkingBlock
|
||||
convention)."""
|
||||
body = _fn(_js_shared(), "addSegments")
|
||||
assert 'if (!entry || typeof entry !== "object") continue' in body
|
||||
assert 'if (typeof entry.text !== "string") continue' in body, "malformed thinking skipped"
|
||||
assert 'if (typeof entry.name !== "string") continue' in body, "malformed tool skipped"
|
||||
assert 'else if (entry.kind === "tool")' in body, (
|
||||
"an unknown kind falls through — SKIPPED"
|
||||
)
|
||||
assert "unknown" in body, "the skip is named in a guard comment"
|
||||
_single_escape_first_write(body)
|
||||
|
||||
|
||||
def test_the_shared_dispatch_gives_segments_precedence_over_the_legacy_path() -> None:
|
||||
"""The ``renderSharedMessage`` dispatch: a record WITH
|
||||
``segments`` (a non-empty array) renders ``addSegments`` and
|
||||
SKIPS the legacy ``addThinkingBlock`` + ``addToolLines`` (the
|
||||
runs carry the same data in order); a record WITHOUT
|
||||
``segments`` — every pre-phase record — renders byte-identically
|
||||
through the legacy path (the guard is the only change; the
|
||||
``addThinkingBlock`` / ``addToolLines`` functions themselves are
|
||||
UNCHANGED — the test_shared_page.py pins)."""
|
||||
js = _js_shared()
|
||||
body = _fn(js, "renderSharedMessage")
|
||||
assert "Array.isArray(m.segments) && m.segments.length" in body, "the dispatch guard"
|
||||
assert "addSegments(wrap, m.segments)" in body
|
||||
# precedence: the legacy calls sit in the ELSE branch.
|
||||
guard_i = body.find("Array.isArray(m.segments)")
|
||||
else_i = body.find("} else {", guard_i)
|
||||
think_i = body.find("addThinkingBlock(wrap, m.thinking)")
|
||||
tools_i = body.find("addToolLines(wrap, m.tools)")
|
||||
assert -1 < guard_i < else_i < think_i < tools_i, (
|
||||
"segments take precedence; the legacy path is the else branch"
|
||||
)
|
||||
# the legacy path is intact.
|
||||
assert 'if (typeof m.thinking === "string" && m.thinking)' in body
|
||||
|
||||
|
||||
def test_the_legacy_shared_builders_are_untouched() -> None:
|
||||
"""The ``addThinkingBlock`` / ``addToolLines`` functions are
|
||||
UNCHANGED (the legacy path is intact — the shared page's
|
||||
pre-phase records render byte-identically; the
|
||||
test_shared_page.py pins stay green): the collapsed thinking
|
||||
block (the phase-17 convention) + the single-disclosure tool
|
||||
lines (the phase-117 structure, the four label literals, the
|
||||
phase-95 note, the per-render count)."""
|
||||
js = _js_shared()
|
||||
think = _fn(js, "addThinkingBlock")
|
||||
assert 'block.className = "thinking"' in think
|
||||
assert "block.open = false" in think
|
||||
assert 'summary.textContent = "Thinking"' in think
|
||||
assert "renderMarkdown(thinking)" in think
|
||||
tools = _fn(js, "addToolLines")
|
||||
for label in PINNED_LABELS:
|
||||
assert label in tools, f"pinned label literal changed: {label!r}"
|
||||
assert 'container.className = "tool-calls"' in tools
|
||||
assert 'disclosure.className = "tool-calls-disclosure"' in tools
|
||||
assert "disclosure.open = false" in tools
|
||||
assert 'summary.className = "tool-calls-summary"' in tools
|
||||
assert '"truncated-note"' in tools
|
||||
assert "Tool call${n === 1 ? \"\" : \"s\"} (${n})" in tools
|
||||
# no reference to the new builder from the legacy functions.
|
||||
assert "addSegments" not in think and "addSegments" not in tools
|
||||
@@ -5,12 +5,15 @@ per ``tool`` frame — 6+ stacked full-width bordered cards on a phone —
|
||||
swamped a completed answer, and the flex row broke the label
|
||||
mid-word. The fix (phase 117, owner decisions D1–D6) wraps the
|
||||
existing ``.tool-calls`` list in a native ``details`` disclosure with a
|
||||
plain-text "Tool call(s) (N)" summary — created OPEN on the first live
|
||||
``tool`` frame, folded at rest (delta/done/stop via ``closeToolCalls``),
|
||||
and rendered CLOSED on the restore path and the shared page (D3). The
|
||||
line bytes, the four label literals, and the ``<code>`` textContent
|
||||
arguments stay byte-identical (D6), so the phase-37/70/87/95 pins
|
||||
stay green.
|
||||
plain-text "Tool call(s) (N)" summary — phase 158 (D1): one
|
||||
disclosure per CONTIGUOUS tool-call run, created OPEN on the first
|
||||
live ``tool`` frame of the run, folded at rest (delta folds the
|
||||
active run via ``closeCurrentRun``; done/stop/finalizeFailedTurn fold
|
||||
every run via ``closeAllRuns``), and rendered CLOSED on the restore
|
||||
path and the shared page (D3). The count is per run (the per-run
|
||||
"Tool call(s) (N)", D3). The line bytes, the four label literals, and
|
||||
the ``<code>`` textContent arguments stay byte-identical (D6), so the
|
||||
phase-37/70/87/95 pins stay green.
|
||||
|
||||
No ``app/`` logic (frontend-only, D1) — like
|
||||
``test_frontend_tool_states.py``, this module pins the JS/CSS markers
|
||||
@@ -59,36 +62,46 @@ def _fn(src: str, name: str) -> str:
|
||||
return src[fn : src.find("\n}\n", fn)]
|
||||
|
||||
|
||||
def test_append_tool_line_builds_an_open_details_disclosure() -> None:
|
||||
"""appendToolLine (D2/D3/D5): the FIRST frame creates a native
|
||||
``details.tool-calls-disclosure`` — a ``summary.tool-calls-summary``
|
||||
(first child) + the existing ``.tool-calls`` list (role=list +
|
||||
aria-label "Tool calls", second child) — inserted before the bubble
|
||||
where the list used to be. It starts OPEN while the turn is live;
|
||||
every append re-renders the plain-text "Tool call(s) (N)" count on
|
||||
the summary through textContent (no innerHTML anywhere)."""
|
||||
body = _fn(_js(), "appendToolLine")
|
||||
assert "document.createElement(\"details\")" in body
|
||||
assert "className = \"tool-calls-disclosure\"" in body
|
||||
assert "container.open = true", (
|
||||
"D3: created OPEN on the first live tool frame — the calls and "
|
||||
"the phase-87 .tool-elapsed suffix stay visible during the turn"
|
||||
)
|
||||
assert "document.createElement(\"summary\")" in body
|
||||
assert "className = \"tool-calls-summary\"" in body
|
||||
def test_tool_run_builds_an_open_details_disclosure() -> None:
|
||||
"""Phase 158 (D1/D2/D5): ``ensureToolRun`` creates a native
|
||||
``details.tool-calls-disclosure`` via ``createToolDisclosure`` —
|
||||
a ``summary.tool-calls-summary`` (first child) + the existing
|
||||
``.tool-calls`` list (role=list + aria-label "Tool calls",
|
||||
second child) — inserted before the bubble in arrival order (D2).
|
||||
It starts OPEN while the run is live; every append re-renders the
|
||||
plain-text per-run "Tool call(s) (N)" count on the summary
|
||||
through textContent (no innerHTML anywhere)."""
|
||||
js = _js()
|
||||
disc = _fn(js, "createToolDisclosure")
|
||||
assert "document.createElement(\"details\")" in disc
|
||||
assert "className = \"tool-calls-disclosure\"" in disc
|
||||
assert "document.createElement(\"summary\")" in disc
|
||||
assert "className = \"tool-calls-summary\"" in disc
|
||||
# The list keeps its exact phase-37 shape, nested in the disclosure.
|
||||
assert "className = \"tool-calls\"" in body
|
||||
assert 'setAttribute("role", "list")' in body
|
||||
assert 'setAttribute("aria-label", "Tool calls")' in body
|
||||
assert 'insertBefore(container, body.querySelector(".bubble"))' in body, (
|
||||
"the disclosure sits ABOVE the answer, below an existing Thinking block"
|
||||
assert "className = \"tool-calls\"" in disc
|
||||
assert 'setAttribute("role", "list")' in disc
|
||||
assert 'setAttribute("aria-label", "Tool calls")' in disc
|
||||
assert "innerHTML" not in disc, (
|
||||
"the tool builder stays createElement/textContent (phase 117 "
|
||||
"style — innerHTML ABSENT)"
|
||||
)
|
||||
run = _fn(js, "ensureToolRun")
|
||||
assert "createToolDisclosure()" in run
|
||||
assert "disc.open = true", (
|
||||
"D5: created OPEN on the first live tool frame of the run — the "
|
||||
"calls and the phase-87 .tool-elapsed suffix stay visible during "
|
||||
"the turn"
|
||||
)
|
||||
assert 'insertBefore(disc, body.querySelector(".bubble"))' in run, (
|
||||
"D2: above the answer, in arrival order"
|
||||
)
|
||||
# D5: the count rides the summary — the template fragment itself
|
||||
# (singular for one call, plural otherwise), textContent-built.
|
||||
assert "Tool call${n === 1 ? \"\" : \"s\"} (${n})" in body, (
|
||||
"the plain-text count template (D5) on every append"
|
||||
append = _fn(js, "appendToolLine")
|
||||
assert "Tool call${n === 1 ? \"\" : \"s\"} (${n})" in append, (
|
||||
"the per-run plain-text count template (D3) on every append"
|
||||
)
|
||||
assert "innerHTML" not in body, (
|
||||
assert "innerHTML" not in append, (
|
||||
"no HTML injection surface on tool lines — textContent only"
|
||||
)
|
||||
|
||||
@@ -106,55 +119,70 @@ def test_append_tool_line_keeps_the_four_pinned_labels() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_close_tool_calls_defined_and_called_at_every_rest_site() -> None:
|
||||
"""closeToolCalls (D3): defined next to closeThinkingBlock,
|
||||
idempotent (optional-chaining query → open=false), and called at
|
||||
ALL rest sites — the delta handler (the answer began), the done
|
||||
def test_close_all_runs_defined_and_called_at_every_rest_site() -> None:
|
||||
"""Phase 158 (D5): ``closeAllRuns`` — the terminal settle (the
|
||||
phase-117 ``closeToolCalls`` + the D15 block close, merged):
|
||||
closes EVERY open ``details.thinking`` +
|
||||
``details.tool-calls-disclosure`` in the wrap (defensive; a late
|
||||
double-close is a no-op), called at ALL rest sites — the done
|
||||
handler (the turn ended), the stop/abort settle (the turn was
|
||||
stopped), and the restore path — so the definition + ≥4 calls."""
|
||||
stopped), and the failed-turn funnel — so the definition + ≥3
|
||||
calls. The `delta` handler folds only the active run
|
||||
(``closeCurrentRun``)."""
|
||||
js = _js()
|
||||
body = _fn(js, "closeToolCalls")
|
||||
assert 'querySelector?.(".tool-calls-disclosure")' in body, (
|
||||
"targets the disclosure of the given wrap only"
|
||||
body = _fn(js, "closeAllRuns")
|
||||
assert "details.thinking, details.tool-calls-disclosure" in body, (
|
||||
"targets both run kinds in the given wrap"
|
||||
)
|
||||
assert "disc.open = false" in body, "fold: open=false (idempotent)"
|
||||
assert js.count("closeToolCalls(wrap)") >= 4, (
|
||||
"the definition + the delta/done/stop handler sites + the "
|
||||
"renderStoredMessage restore fold — the disclosure settles "
|
||||
"exactly where the Thinking block settles"
|
||||
assert "el.open = false" in body, "fold: open=false (idempotent)"
|
||||
assert js.count("closeAllRuns(wrap)") >= 3, (
|
||||
"the definition + the done handler + the stop/abort settle + "
|
||||
"the failed-turn funnel — the runs settle exactly where the "
|
||||
"Thinking blocks settle"
|
||||
)
|
||||
delta_idx = js.find('ev.type === "delta"')
|
||||
done_idx = js.find('ev.type === "done"')
|
||||
delta_branch = js[delta_idx:done_idx]
|
||||
assert "closeCurrentRun()" in delta_branch, (
|
||||
"the delta folds the ACTIVE run (not every run — a later "
|
||||
"thinking frame opens a new one)"
|
||||
)
|
||||
|
||||
|
||||
def test_restore_path_folds_the_disclosure() -> None:
|
||||
"""renderStoredMessage (D3): a restored brain record with `tools`
|
||||
re-renders through the SAME appendToolLine (byte-identical), and
|
||||
the disclosure is FOLDED right after the loop — a completed turn
|
||||
from storage is one compact "Tool calls (N)" line, not N stacked
|
||||
cards (the space fix; mirrors the thinking restore rendering
|
||||
collapsed)."""
|
||||
re-renders through the SAME appendToolLine (byte-identical — the
|
||||
caller passes the run's disclosure, phase 158), and the
|
||||
disclosure is FOLDED right after the loop (``restoreDisc.open =
|
||||
false``) — a completed turn from storage is one compact
|
||||
"Tool calls (N)" line, not N stacked cards (the space fix;
|
||||
mirrors the thinking restore rendering collapsed)."""
|
||||
js = _js()
|
||||
fn = js.find("function renderStoredMessage")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("function restoreConversation", fn)]
|
||||
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body
|
||||
assert "closeToolCalls(wrap)" in body
|
||||
assert "appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)" in body
|
||||
assert "restoreDisc.open = false" in body
|
||||
assert (
|
||||
body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)")
|
||||
< body.find("closeToolCalls(wrap)")
|
||||
body.find("appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath)")
|
||||
< body.find("restoreDisc.open = false")
|
||||
), "the fold sits AFTER the tool-line restore loop"
|
||||
|
||||
|
||||
def test_thinking_anchor_accounts_for_the_disclosure() -> None:
|
||||
"""ensureThinkingBlock: the anchor is the WHOLE disclosure (a
|
||||
`thinking` frame after the first `tool` frame lands the scratchpad
|
||||
ABOVE the disclosure, not inside it); the bare .tool-calls and
|
||||
.bubble terms stay as fallbacks, and the block still opens while
|
||||
active (phase-109 toggle behavior untouched)."""
|
||||
body = _fn(_js(), "ensureThinkingBlock")
|
||||
assert ".tool-calls-disclosure" in body, "the new anchor term"
|
||||
assert 'querySelector(".tool-calls")' in body, "the pre-117 fallback"
|
||||
assert 'querySelector(".bubble")' in body
|
||||
assert "block.open = true" in body
|
||||
def test_run_anchors_are_the_bubble_in_arrival_order() -> None:
|
||||
"""Phase 158 (D2): the anchor is the BUBBLE for both run kinds —
|
||||
the old ``.tool-calls-disclosure ?? .tool-calls ?? .bubble``
|
||||
anchor chain is GONE (with per-run disclosures, "above the
|
||||
tool lines" was the wrong order — arrival order is the
|
||||
contract), and the runs still open while active (D5)."""
|
||||
js = _js()
|
||||
run = _fn(js, "ensureThinkingRun")
|
||||
assert 'querySelector(".bubble")' in run
|
||||
assert "block.open = true" in run
|
||||
assert "??" not in run, "the old anchor chain is GONE"
|
||||
d = _fn(js, "ensureToolRun")
|
||||
assert 'querySelector(".bubble")' in d
|
||||
assert "disc.open = true" in d
|
||||
|
||||
|
||||
def test_shared_page_add_tool_lines_builds_a_closed_disclosure() -> None:
|
||||
|
||||
Reference in New Issue
Block a user