From 31bd903a9361ab38be41fd2307880ed3dbf895af Mon Sep 17 00:00:00 2001 From: ducoterra Date: Mon, 5 Oct 2026 02:52:55 -0400 Subject: [PATCH] phase: 158_thinking_timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../158_thinking_timeline/00_phase.md | 0 .../01_schema_segments.md | 0 .../158_thinking_timeline/02_live_timeline.md | 0 .../03_restore_and_shared.md | 0 .../158_thinking_timeline/04_mock_and_e2e.md | 0 .../05_regression_update_and_commit.md | 0 .../158_thinking_timeline__00_phase.a1.err | 0 .../158_thinking_timeline__00_phase.a1.md | 17 + ...58_thinking_timeline__00_phase.a1.validate | 122 +++ ...inking_timeline__01_schema_segments.a1.err | 0 ...hinking_timeline__01_schema_segments.a1.md | 13 + ...g_timeline__01_schema_segments.a1.validate | 121 +++ ...thinking_timeline__02_live_timeline.a1.err | 0 ..._thinking_timeline__02_live_timeline.a1.md | 10 + ...ing_timeline__02_live_timeline.a1.validate | 122 +++ ...ing_timeline__03_restore_and_shared.a1.err | 0 ...king_timeline__03_restore_and_shared.a1.md | 10 + ...imeline__03_restore_and_shared.a1.validate | 122 +++ ..._thinking_timeline__04_mock_and_e2e.a1.err | 0 ...8_thinking_timeline__04_mock_and_e2e.a1.md | 20 + ...king_timeline__04_mock_and_e2e.a1.validate | 122 +++ ...ne__05_regression_update_and_commit.a1.err | 0 ...ine__05_regression_update_and_commit.a1.md | 9 + ...5_regression_update_and_commit.a1.validate | 122 +++ README.md | 25 +- app/schemas.py | 137 +++ frontend/assets/app.js | 583 +++++++++---- frontend/assets/shared.js | 137 ++- tests/e2e/mock_llm.py | 204 ++++- tests/e2e/test_thinking_timeline.py | 757 ++++++++++++++++ tests/e2e/test_tool_call_compact.py | 2 +- tests/e2e/test_turn_progress_loader.py | 98 ++- tests/integration/test_chats_api.py | 282 ++++++ tests/unit/test_big_read_progress.py | 35 +- tests/unit/test_chat_persistence.py | 2 +- tests/unit/test_frontend_actions_cluster.py | 6 +- tests/unit/test_frontend_failed_turn.py | 7 +- tests/unit/test_frontend_feedback.py | 56 +- tests/unit/test_frontend_hidden_tab.py | 13 +- tests/unit/test_frontend_tool_states.py | 77 +- tests/unit/test_frontend_turn_loader.py | 152 ++-- tests/unit/test_read_range_tool_lines.py | 68 +- tests/unit/test_read_truncation_frontend.py | 4 +- tests/unit/test_schemas.py | 222 +++++ tests/unit/test_search_tool_scope.py | 88 +- tests/unit/test_sources_midstream.py | 12 +- tests/unit/test_thinking_scroll.py | 4 +- tests/unit/test_thinking_timeline.py | 825 ++++++++++++++++++ tests/unit/test_tool_call_compact.py | 156 ++-- 49 files changed, 4280 insertions(+), 482 deletions(-) rename .agents/phases/{todo => complete}/158_thinking_timeline/00_phase.md (100%) rename .agents/phases/{todo => complete}/158_thinking_timeline/01_schema_segments.md (100%) rename .agents/phases/{todo => complete}/158_thinking_timeline/02_live_timeline.md (100%) rename .agents/phases/{todo => complete}/158_thinking_timeline/03_restore_and_shared.md (100%) rename .agents/phases/{todo => complete}/158_thinking_timeline/04_mock_and_e2e.md (100%) rename .agents/phases/{todo => complete}/158_thinking_timeline/05_regression_update_and_commit.md (100%) create mode 100644 .agents/reports/158_thinking_timeline/158_thinking_timeline__00_phase.a1.err create mode 100644 .agents/reports/158_thinking_timeline/158_thinking_timeline__00_phase.a1.md create mode 100644 .agents/reports/158_thinking_timeline/158_thinking_timeline__00_phase.a1.validate create mode 100644 .agents/reports/158_thinking_timeline/158_thinking_timeline__01_schema_segments.a1.err create mode 100644 .agents/reports/158_thinking_timeline/158_thinking_timeline__01_schema_segments.a1.md create mode 100644 .agents/reports/158_thinking_timeline/158_thinking_timeline__01_schema_segments.a1.validate create mode 100644 .agents/reports/158_thinking_timeline/158_thinking_timeline__02_live_timeline.a1.err create mode 100644 .agents/reports/158_thinking_timeline/158_thinking_timeline__02_live_timeline.a1.md create mode 100644 .agents/reports/158_thinking_timeline/158_thinking_timeline__02_live_timeline.a1.validate create mode 100644 .agents/reports/158_thinking_timeline/158_thinking_timeline__03_restore_and_shared.a1.err create mode 100644 .agents/reports/158_thinking_timeline/158_thinking_timeline__03_restore_and_shared.a1.md create mode 100644 .agents/reports/158_thinking_timeline/158_thinking_timeline__03_restore_and_shared.a1.validate create mode 100644 .agents/reports/158_thinking_timeline/158_thinking_timeline__04_mock_and_e2e.a1.err create mode 100644 .agents/reports/158_thinking_timeline/158_thinking_timeline__04_mock_and_e2e.a1.md create mode 100644 .agents/reports/158_thinking_timeline/158_thinking_timeline__04_mock_and_e2e.a1.validate create mode 100644 .agents/reports/158_thinking_timeline/158_thinking_timeline__05_regression_update_and_commit.a1.err create mode 100644 .agents/reports/158_thinking_timeline/158_thinking_timeline__05_regression_update_and_commit.a1.md create mode 100644 .agents/reports/158_thinking_timeline/158_thinking_timeline__05_regression_update_and_commit.a1.validate create mode 100644 tests/e2e/test_thinking_timeline.py create mode 100644 tests/unit/test_thinking_timeline.py diff --git a/.agents/phases/todo/158_thinking_timeline/00_phase.md b/.agents/phases/complete/158_thinking_timeline/00_phase.md similarity index 100% rename from .agents/phases/todo/158_thinking_timeline/00_phase.md rename to .agents/phases/complete/158_thinking_timeline/00_phase.md diff --git a/.agents/phases/todo/158_thinking_timeline/01_schema_segments.md b/.agents/phases/complete/158_thinking_timeline/01_schema_segments.md similarity index 100% rename from .agents/phases/todo/158_thinking_timeline/01_schema_segments.md rename to .agents/phases/complete/158_thinking_timeline/01_schema_segments.md diff --git a/.agents/phases/todo/158_thinking_timeline/02_live_timeline.md b/.agents/phases/complete/158_thinking_timeline/02_live_timeline.md similarity index 100% rename from .agents/phases/todo/158_thinking_timeline/02_live_timeline.md rename to .agents/phases/complete/158_thinking_timeline/02_live_timeline.md diff --git a/.agents/phases/todo/158_thinking_timeline/03_restore_and_shared.md b/.agents/phases/complete/158_thinking_timeline/03_restore_and_shared.md similarity index 100% rename from .agents/phases/todo/158_thinking_timeline/03_restore_and_shared.md rename to .agents/phases/complete/158_thinking_timeline/03_restore_and_shared.md diff --git a/.agents/phases/todo/158_thinking_timeline/04_mock_and_e2e.md b/.agents/phases/complete/158_thinking_timeline/04_mock_and_e2e.md similarity index 100% rename from .agents/phases/todo/158_thinking_timeline/04_mock_and_e2e.md rename to .agents/phases/complete/158_thinking_timeline/04_mock_and_e2e.md diff --git a/.agents/phases/todo/158_thinking_timeline/05_regression_update_and_commit.md b/.agents/phases/complete/158_thinking_timeline/05_regression_update_and_commit.md similarity index 100% rename from .agents/phases/todo/158_thinking_timeline/05_regression_update_and_commit.md rename to .agents/phases/complete/158_thinking_timeline/05_regression_update_and_commit.md diff --git a/.agents/reports/158_thinking_timeline/158_thinking_timeline__00_phase.a1.err b/.agents/reports/158_thinking_timeline/158_thinking_timeline__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/158_thinking_timeline/158_thinking_timeline__00_phase.a1.md b/.agents/reports/158_thinking_timeline/158_thinking_timeline__00_phase.a1.md new file mode 100644 index 0000000..6f2f7b8 --- /dev/null +++ b/.agents/reports/158_thinking_timeline/158_thinking_timeline__00_phase.a1.md @@ -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. diff --git a/.agents/reports/158_thinking_timeline/158_thinking_timeline__00_phase.a1.validate b/.agents/reports/158_thinking_timeline/158_thinking_timeline__00_phase.a1.validate new file mode 100644 index 0000000..edafeb6 --- /dev/null +++ b/.agents/reports/158_thinking_timeline/158_thinking_timeline__00_phase.a1.validate @@ -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 diff --git a/.agents/reports/158_thinking_timeline/158_thinking_timeline__01_schema_segments.a1.err b/.agents/reports/158_thinking_timeline/158_thinking_timeline__01_schema_segments.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/158_thinking_timeline/158_thinking_timeline__01_schema_segments.a1.md b/.agents/reports/158_thinking_timeline/158_thinking_timeline__01_schema_segments.a1.md new file mode 100644 index 0000000..aa8ec00 --- /dev/null +++ b/.agents/reports/158_thinking_timeline/158_thinking_timeline__01_schema_segments.a1.md @@ -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` diff --git a/.agents/reports/158_thinking_timeline/158_thinking_timeline__01_schema_segments.a1.validate b/.agents/reports/158_thinking_timeline/158_thinking_timeline__01_schema_segments.a1.validate new file mode 100644 index 0000000..dc8189d --- /dev/null +++ b/.agents/reports/158_thinking_timeline/158_thinking_timeline__01_schema_segments.a1.validate @@ -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 diff --git a/.agents/reports/158_thinking_timeline/158_thinking_timeline__02_live_timeline.a1.err b/.agents/reports/158_thinking_timeline/158_thinking_timeline__02_live_timeline.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/158_thinking_timeline/158_thinking_timeline__02_live_timeline.a1.md b/.agents/reports/158_thinking_timeline/158_thinking_timeline__02_live_timeline.a1.md new file mode 100644 index 0000000..5dc9af1 --- /dev/null +++ b/.agents/reports/158_thinking_timeline/158_thinking_timeline__02_live_timeline.a1.md @@ -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`. diff --git a/.agents/reports/158_thinking_timeline/158_thinking_timeline__02_live_timeline.a1.validate b/.agents/reports/158_thinking_timeline/158_thinking_timeline__02_live_timeline.a1.validate new file mode 100644 index 0000000..50e490f --- /dev/null +++ b/.agents/reports/158_thinking_timeline/158_thinking_timeline__02_live_timeline.a1.validate @@ -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 diff --git a/.agents/reports/158_thinking_timeline/158_thinking_timeline__03_restore_and_shared.a1.err b/.agents/reports/158_thinking_timeline/158_thinking_timeline__03_restore_and_shared.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/158_thinking_timeline/158_thinking_timeline__03_restore_and_shared.a1.md b/.agents/reports/158_thinking_timeline/158_thinking_timeline__03_restore_and_shared.a1.md new file mode 100644 index 0000000..95ab25a --- /dev/null +++ b/.agents/reports/158_thinking_timeline/158_thinking_timeline__03_restore_and_shared.a1.md @@ -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` diff --git a/.agents/reports/158_thinking_timeline/158_thinking_timeline__03_restore_and_shared.a1.validate b/.agents/reports/158_thinking_timeline/158_thinking_timeline__03_restore_and_shared.a1.validate new file mode 100644 index 0000000..edafeb6 --- /dev/null +++ b/.agents/reports/158_thinking_timeline/158_thinking_timeline__03_restore_and_shared.a1.validate @@ -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 diff --git a/.agents/reports/158_thinking_timeline/158_thinking_timeline__04_mock_and_e2e.a1.err b/.agents/reports/158_thinking_timeline/158_thinking_timeline__04_mock_and_e2e.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/158_thinking_timeline/158_thinking_timeline__04_mock_and_e2e.a1.md b/.agents/reports/158_thinking_timeline/158_thinking_timeline__04_mock_and_e2e.a1.md new file mode 100644 index 0000000..a75cd67 --- /dev/null +++ b/.agents/reports/158_thinking_timeline/158_thinking_timeline__04_mock_and_e2e.a1.md @@ -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` diff --git a/.agents/reports/158_thinking_timeline/158_thinking_timeline__04_mock_and_e2e.a1.validate b/.agents/reports/158_thinking_timeline/158_thinking_timeline__04_mock_and_e2e.a1.validate new file mode 100644 index 0000000..edafeb6 --- /dev/null +++ b/.agents/reports/158_thinking_timeline/158_thinking_timeline__04_mock_and_e2e.a1.validate @@ -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 diff --git a/.agents/reports/158_thinking_timeline/158_thinking_timeline__05_regression_update_and_commit.a1.err b/.agents/reports/158_thinking_timeline/158_thinking_timeline__05_regression_update_and_commit.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/158_thinking_timeline/158_thinking_timeline__05_regression_update_and_commit.a1.md b/.agents/reports/158_thinking_timeline/158_thinking_timeline__05_regression_update_and_commit.a1.md new file mode 100644 index 0000000..393aef9 --- /dev/null +++ b/.agents/reports/158_thinking_timeline/158_thinking_timeline__05_regression_update_and_commit.a1.md @@ -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. diff --git a/.agents/reports/158_thinking_timeline/158_thinking_timeline__05_regression_update_and_commit.a1.validate b/.agents/reports/158_thinking_timeline/158_thinking_timeline__05_regression_update_and_commit.a1.validate new file mode 100644 index 0000000..edafeb6 --- /dev/null +++ b/.agents/reports/158_thinking_timeline/158_thinking_timeline__05_regression_update_and_commit.a1.validate @@ -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 diff --git a/README.md b/README.md index 5e4adc2..a47dd1c 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/app/schemas.py b/app/schemas.py index ba01c7b..03be9ff 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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 diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 3145a5e..8b07a20 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -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
block ABOVE - * the answer bubble: created OPEN on the first `thinking` event, then - * user-toggleable (native
/ — a real focusable - * control). +/* ---------- the per-run timeline (phase 158, D1/D2/D5) ---------- + * The model's reasoning streams into collapsible
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 = - `Thinking
`; - 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 = + `Thinking
`; 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 *
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 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 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
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 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 - // 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 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 + // 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 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 }); diff --git a/frontend/assets/shared.js b/frontend/assets/shared.js index 1e13c08..46fde2e 100644 --- a/frontend/assets/shared.js +++ b/frontend/assets/shared.js @@ -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); diff --git a/tests/e2e/mock_llm.py b/tests/e2e/mock_llm.py index 13252af..2983bd5 100644 --- a/tests/e2e/mock_llm.py +++ b/tests/e2e/mock_llm.py @@ -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 ```` 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 + :"`` header): ``Step 4: compose the answer from + the read document.`` first, then the phase-37 content + answer, deterministic: ``Read . `` — 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 ```` 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", )`` — ``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, )`` — 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", )`` — 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, )`` — a + ``tool``-role read result (``"Document :\n + "``): 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 + ```` 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 "" 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 ```` 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": diff --git a/tests/e2e/test_thinking_timeline.py b/tests/e2e/test_thinking_timeline.py new file mode 100644 index 0000000..da9ba53 --- /dev/null +++ b/tests/e2e/test_thinking_timeline.py @@ -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 +```` 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 → ````). + +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 `` 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/`` 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 `` 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 . " (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 ```` + ```` + + ```` 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 `` 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
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
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 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 `` 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) diff --git a/tests/e2e/test_tool_call_compact.py b/tests/e2e/test_tool_call_compact.py index 2dceb75..528eb27 100644 --- a/tests/e2e/test_tool_call_compact.py +++ b/tests/e2e/test_tool_call_compact.py @@ -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).""" diff --git a/tests/e2e/test_turn_progress_loader.py b/tests/e2e/test_turn_progress_loader.py index 190a1fb..04bc473 100644 --- a/tests/e2e/test_turn_progress_loader.py +++ b/tests/e2e/test_turn_progress_loader.py @@ -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) diff --git a/tests/integration/test_chats_api.py b/tests/integration/test_chats_api.py index 894f160..51a2f15 100644 --- a/tests/integration/test_chats_api.py +++ b/tests/integration/test_chats_api.py @@ -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"] == [] diff --git a/tests/unit/test_big_read_progress.py b/tests/unit/test_big_read_progress.py index 9c064fa..bd17bc6 100644 --- a/tests/unit/test_big_read_progress.py +++ b/tests/unit/test_big_read_progress.py @@ -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 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 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: diff --git a/tests/unit/test_chat_persistence.py b/tests/unit/test_chat_persistence.py index 1a4a19a..56252cb 100644 --- a/tests/unit/test_chat_persistence.py +++ b/tests/unit/test_chat_persistence.py @@ -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 diff --git a/tests/unit/test_frontend_actions_cluster.py b/tests/unit/test_frontend_actions_cluster.py index 935fc5d..4352c4f 100644 --- a/tests/unit/test_frontend_actions_cluster.py +++ b/tests/unit/test_frontend_actions_cluster.py @@ -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, ( diff --git a/tests/unit/test_frontend_failed_turn.py b/tests/unit/test_frontend_failed_turn.py index 8dbc98d..ce7a571 100644 --- a/tests/unit/test_frontend_failed_turn.py +++ b/tests/unit/test_frontend_failed_turn.py @@ -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 diff --git a/tests/unit/test_frontend_feedback.py b/tests/unit/test_frontend_feedback.py index 4d5420d..6c0863e 100644 --- a/tests/unit/test_frontend_feedback.py +++ b/tests/unit/test_frontend_feedback.py @@ -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" diff --git a/tests/unit/test_frontend_hidden_tab.py b/tests/unit/test_frontend_hidden_tab.py index 40dd3e9..b7da3b5 100644 --- a/tests/unit/test_frontend_hidden_tab.py +++ b/tests/unit/test_frontend_hidden_tab.py @@ -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" diff --git a/tests/unit/test_frontend_tool_states.py b/tests/unit/test_frontend_tool_states.py index d4647fd..0b1379c 100644 --- a/tests/unit/test_frontend_tool_states.py +++ b/tests/unit/test_frontend_tool_states.py @@ -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 path. 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 path. 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: diff --git a/tests/unit/test_frontend_turn_loader.py b/tests/unit/test_frontend_turn_loader.py index 7d66075..a79d034 100644 --- a/tests/unit/test_frontend_turn_loader.py +++ b/tests/unit/test_frontend_turn_loader.py @@ -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)" diff --git a/tests/unit/test_read_range_tool_lines.py b/tests/unit/test_read_range_tool_lines.py index 910db5b..bdb00eb 100644 --- a/tests/unit/test_read_range_tool_lines.py +++ b/tests/unit/test_read_range_tool_lines.py @@ -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" diff --git a/tests/unit/test_read_truncation_frontend.py b/tests/unit/test_read_truncation_frontend.py index 5a7b3a1..d2522c4 100644 --- a/tests/unit/test_read_truncation_frontend.py +++ b/tests/unit/test_read_truncation_frontend.py @@ -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 diff --git a/tests/unit/test_schemas.py b/tests/unit/test_schemas.py index 2c15d30..567fe07 100644 --- a/tests/unit/test_schemas.py +++ b/tests/unit/test_schemas.py @@ -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": "x", + "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) diff --git a/tests/unit/test_search_tool_scope.py b/tests/unit/test_search_tool_scope.py index bf59409..be578a6 100644 --- a/tests/unit/test_search_tool_scope.py +++ b/tests/unit/test_search_tool_scope.py @@ -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 - ```` — 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 ```` — 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)) diff --git a/tests/unit/test_sources_midstream.py b/tests/unit/test_sources_midstream.py index 4a085a3..3577be0 100644 --- a/tests/unit/test_sources_midstream.py +++ b/tests/unit/test_sources_midstream.py @@ -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 diff --git a/tests/unit/test_thinking_scroll.py b/tests/unit/test_thinking_scroll.py index 724320e..aaf0465 100644 --- a/tests/unit/test_thinking_scroll.py +++ b/tests/unit/test_thinking_scroll.py @@ -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" diff --git a/tests/unit/test_thinking_timeline.py b/tests/unit/test_thinking_timeline.py new file mode 100644 index 0000000..02b648e --- /dev/null +++ b/tests/unit/test_thinking_timeline.py @@ -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 "`Thinking
`" 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 diff --git a/tests/unit/test_tool_call_compact.py b/tests/unit/test_tool_call_compact.py index e2ea2b3..270c1be 100644 --- a/tests/unit/test_tool_call_compact.py +++ b/tests/unit/test_tool_call_compact.py @@ -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 ```` 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 ```` 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: