phase: 158_thinking_timeline
Build and Push Containers / build-and-push-app (push) Successful in 8m38s
Build and Push Containers / build-and-push-db (push) Successful in 32s

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:
2026-10-05 02:52:55 -04:00
parent 3f752fb77f
commit 31bd903a93
49 changed files with 4280 additions and 482 deletions
@@ -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`
@@ -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`.
@@ -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,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`
@@ -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`
@@ -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,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.
@@ -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
+21 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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":
+757
View File
@@ -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)
+1 -1
View File
@@ -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)."""
+60 -38
View File
@@ -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)
+282
View File
@@ -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"] == []
+20 -15
View File
@@ -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:
+1 -1
View File
@@ -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
+4 -2
View File
@@ -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, (
+3 -4
View File
@@ -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
+34 -22
View File
@@ -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"
+9 -4
View File
@@ -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"
+50 -27
View File
@@ -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:
+79 -73
View File
@@ -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)"
+43 -25
View File
@@ -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"
+2 -2
View File
@@ -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
+222
View File
@@ -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)
+53 -35
View File
@@ -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))
+9 -3
View File
@@ -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
+2 -2
View File
@@ -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"
+825
View File
@@ -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
+92 -64
View File
@@ -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: