phase: 117_tool_call_compact
Build and Push Containers / build-and-push-app (push) Successful in 2m2s
Build and Push Containers / build-and-push-db (push) Successful in 12s

All verification complete — every gate green. Final report:

**Phase 117 final verification pass — all criteria verified** (tasks 01–04 were already in `complete/`; I re-verified the full gate myself, no code changes needed)
- **Verified:** `appendToolLine` wraps lines in `details.tool-calls-disclosure` (open live, `closeToolCalls` at delta/done/stop + restore-closed, count via textContent); `addToolLines` parity (closed); `.tool-call` deboxed / `code` dechipped; new disclosure/summary CSS; only the one pinned CSS assert updated
- `uv run pytest` → **2466 passed**
- `uv run pytest --cov=app --cov-report=term-missing` → **99%** on `app/` (>90% floor)
- E2E in isolation: `test_tool_call_compact.py` **4 passed** (fold/expand/deboxed-flow/restored-folded); `test_agent_document_tools.py` **4 passed**; `test_big_read_progress.py` **4 passed** (live disclosure open + `.tool-elapsed` visible); `test_thinking_display.py` **5 passed**; `test_smoke.py` **3 passed**
- `uv run ruff check . && uv run pyright` → clean (pyright 0 errors, 0 warnings)
- Completion criteria: pins 1–4 ✅ (E2E above); live "calling tool" state byte-identical ✅ (`test_frontend_tool_states.py` 10 passed incl. `test_calling_tool_label_strings`); scope ✅ — `git diff` limited to the 3 frontend assets + 2 unit tests + E2E story + phase files, **0 changes in `app/`**
- Notable: `TODO.md` carries a **pre-existing uncommitted owner edit** (new retrieval-context idea) already in the tree before this pass — left untouched, will ride along in the harness commit; pre-existing committed quirk: the brain-wave commit's styles.css comment mislabels itself "Phase 117" (cosmetic, out of this diff)
- No commit made (harness commits + moves the phase); **next pending phase: none** — `todo/` holds only phase 117
This commit is contained in:
2026-09-15 18:26:38 -04:00
parent 2ac3fc89c2
commit c851d1a1c5
27 changed files with 1682 additions and 34 deletions
@@ -0,0 +1,90 @@
# Phase 117 — Compact, well-wrapped tool-call lines
**Source:** owner visual-glitch report (live chat, 2026-09-15) — "how much space the tool calls take up, and the tool call text is spit and wrapped poorly."
**Story:** n/a (owner bug report — mobile viewport, `https://brain.reeseapps.com`, reproduced 2026-09-15)
**Context:** `frontend/assets/app.js` (`appendToolLine` — renders one `.tool-call` line per `tool` SSE frame into a `.tool-calls` list above the answer bubble; `ensureThinkingBlock` — places the Thinking `<details>` above the `.tool-calls` list via `anchor = body.querySelector(".tool-calls") ?? body.querySelector(".bubble")`; the delta/done/stop handlers each call `closeThinkingBlock(wrap)`; `renderStoredMessage` re-renders persisted tools through the SAME `appendToolLine`); `frontend/assets/shared.js` (`addToolLines` — the shared page's local copy of the same renderer, pinned byte-parity with `appendToolLine`); `frontend/assets/styles.css` (`.tool-calls` = flex column + gap; `.tool-call` = `display:flex; align-items:baseline` + a full card: `background` + `border` + `border-left:3px var(--accent-line)` + `border-radius` + `padding`; `.tool-call code` = a chip: `var(--brand-soft)` background + padding + radius on top of `var(--mono)`/`var(--ink)`); `tests/unit/test_frontend_tool_states.py` (pins the exact tool-line template literals + the `.tool-calls`/`.tool-call` DOM shape + the `.tool-call` CSS — `test_tool_call_style_is_accent_and_contrast_safe` asserts `display: flex` + `var(--accent-line)` on `.tool-call`); `tests/unit/test_big_read_progress.py` (pins phase 87's `.tool-elapsed` clock, which queries `.tool-calls .tool-call:last-child` and appends a SIBLING suffix — the line's own text/literals stay byte-identical); `tests/e2e/test_agent_document_tools.py` + `tests/e2e/test_big_read_progress.py` (the tool-line E2E — the former asserts `.tool-call` count + `to_contain_text` after a completed turn, the latter asserts the FIRST `.tool-call` line and the `.tool-elapsed` suffix are `to_be_visible` DURING the live frameless gap, before the answer's delta).
## Bug basis (code-traced + reproduced live, 2026-09-15)
Reproduced at 390×844 (mobile) on the live site. One answer to "Generate a change log … last 5 phases" rendered **6+ stacked full-width cards** — one per `tool` frame — each a complete bordered card (accent left border + surface background + radius) holding a mono path *chip* inside it. Two distinct defects:
- **Space:** each `tool` frame appends a full-width bordered `.tool-call` card spanning the whole chat column. The agent loop is round-capped and fires several calls per turn (`ls → read → read …`), so a single answer stacks N cards above it. On a phone the tool process visually swamps the answer.
- **Wrapping:** `.tool-call` is `display:flex; align-items:baseline`, so the label text node (`"📄 Reading "`) and the `<code>` path are **two separate flex items**. The long mono path squeezes the label flex-item, and `overflow-wrap: anywhere` breaks the label **mid-word** (`Reading` → `Rea` / `ding`); the path wraps to 3 lines indented to the right of that narrow broken label. The "spit and wrapped poorly" look.
- **The live-feedback that must be preserved:** the "calling tool" live state lives in `#send-status` (aria-live) + the typing-indicator aria-label (pinned by `test_calling_tool_label_strings`), NOT the tool lines — so folding the tool lines does not remove any live feedback. Phase 87's ticking `.tool-elapsed` suffix, however, IS on a `.tool-call` line and its E2E asserts it is **visible during the live gap** — so the lines must stay visible while the turn is in flight and fold only once the answer begins.
## Objective
Turn the per-call tool "cards" into a single collapsible disclosure — one compact "Tool calls (N)" line by default, expandable to the individual calls — and restyle the individual lines as deboxed, inline-flowing text so the path wraps to the left edge like a normal sentence and the label never breaks mid-word. Collapsed by default for completed/restored turns (the space fix), open during a live turn (keeps phase 87's live suffix visible), with the "calling tool" live state unchanged.
## Owner decisions (chat, 2026-09-15 — recorded per AGENTS.md rule 3)
- **D1 — Frontend-only.** No `app/` change, no new SSE frame, no persistence-format change. The stored record stays `{name, argument}` (+ `truncated`); only the *rendering* changes. The server and the SSE event set are byte-identical.
- **D2 — Reuse the Thinking-block convention.** The tool calls become a native `<details>/<summary>` disclosure in the same `.msg-body` wrap, mirroring `details.thinking` (open-while-active, closed-when-done). No new component, no new JS dependency.
- **D3 — Open live, folded at rest.** The disclosure is created **open** on the first live `tool` frame (the calls + phase 87's `.tool-elapsed` suffix stay visible during the turn); it folds when the answer's first `delta` arrives, on `done`, and on stop/abort (`closeToolCalls`, idempotent — the exact sites that call `closeThinkingBlock`). The restore path (`renderStoredMessage`) and the shared page (`addToolLines`) render it **closed**. This is what makes the user's screenshot (a completed turn) collapse to one line while keeping the live behavior green.
- **D4 — Debox + inline flow.** The `.tool-call` line loses its card (no `display:flex` / background / border / left border / radius / padding) so the label + inline `<code>` flow as one continuous run (fixes the mid-word label break + indented wrap); the `<code>` loses its chip background but keeps `var(--mono)` + `var(--ink)`. The accent is carried by the line's `color` (`var(--accent-ink)`), not a border.
- **D5 — Plain-text summary, no emoji.** The summary reads `Tool call (1)` / `Tool calls (N)` — plain text, matching the emoji-free chrome (the Thinking summary is just "Thinking") and the phase-08 emoji-free-chrome lean. The existing `📄`/`🔎` line glyphs stay (they are pinned literals + the emoji-guard strip set).
- **D6 — Keep the pinned literals + DOM shape.** The four `line.textContent = "…"` label literals, the `.tool-calls` list (role=list, aria-label "Tool calls"), the `.tool-call` listitems, and the `<code>` `textContent` arguments stay **byte-identical** — so `test_frontend_tool_states.py` (all but the one CSS assert), `test_big_read_progress.py`, the emoji guard, and phase 87's `.tool-calls .tool-call:last-child` query all stay green unchanged.
## Design (shared by all tasks — the executor reads this, not the chat)
### `frontend/assets/app.js`
- **`appendToolLine(wrap, name, argument)`** — wrap the list in a disclosure; keep everything else byte-identical:
- `let container = body.querySelector(".tool-calls-disclosure");` (idempotent per wrap — was `.tool-calls`).
- On first frame: `container = document.createElement("details"); container.className = "tool-calls-disclosure"; container.open = true;` then a `<summary class="tool-calls-summary">` (createElement, textContent only) appended first, then the existing list — `const listEl = document.createElement("div"); listEl.className = "tool-calls"; listEl.setAttribute("role","list"); listEl.setAttribute("aria-label","Tool calls");` — appended second; then `body.insertBefore(container, body.querySelector(".bubble"))` (the disclosure goes where the list did: above the answer, below an existing Thinking block).
- `const list = container.querySelector(".tool-calls");` — build the line EXACTLY as today (`line.className="tool-call"`, `role="listitem"`, the four pinned `line.textContent` label literals, the `<code>` with `code.textContent = argument`), `list.appendChild(line)`.
- Update the summary count on every append (live + restore): `const n = list.children.length; container.querySelector("summary").textContent = \`Tool call${n === 1 ? "" : "s"} (${n})\`;`.
- **No `innerHTML` anywhere in the function** (house rule; pinned). The function stays flat (no nested function declaration) so the `js[fn : js.find("\n}\n", fn)]` body-extraction pins keep working.
- **`closeToolCalls(wrap)`** (new, next to `closeThinkingBlock`): `const disc = wrap?.querySelector?.(".tool-calls-disclosure"); if (disc) disc.open = false;` — idempotent, no-op without a disclosure. Called at **every** existing `closeThinkingBlock(wrap)` site: the `delta` handler (≈L2514), the `done` handler (≈L2519), and the stop/abort settle (≈L2646).
- **`renderStoredMessage`** — after the existing `if (Array.isArray(m.tools)) { …appendToolLine(wrap, t.name, arg)… }` loop, add `closeToolCalls(wrap);` so a restored turn renders folded (the space fix; mirrors the thinking restore rendering collapsed).
- **`ensureThinkingBlock`** — the anchor becomes `body.querySelector(".tool-calls-disclosure") ?? body.querySelector(".tool-calls") ?? body.querySelector(".bubble")` so the Thinking block lands above the WHOLE disclosure (not inside it). `block.open = true` unchanged.
### `frontend/assets/shared.js`
- **`addToolLines(wrap, tools)`** — parity with `appendToolLine`, but **created closed** (pure render, always folded): build the `details.tool-calls-disclosure` (`container.open = false`) + `summary.tool-calls-summary` + the existing `.tool-calls` list (role=list, aria-label) exactly as today; render each line byte-identical (the same four label literals + `<code>` + the phase-95 `truncated-note`); set the summary count once at the end (`Tool call (N)` / `Tool calls (N)`). Keep the `code.textContent = argument` count at 3 and no `innerHTML` (the `test_shared_page_tool_lines_…` / `test_frontend_tool_states.py` shared pins).
### `frontend/assets/styles.css`
- **`.tool-call`** — DEBOX: remove `display:flex`, `align-items:baseline`, `gap`, `background`, `border`, `border-left`, `border-radius`, `padding`. KEEP `color: var(--accent-ink)`, `font-size: 0.8rem`, `line-height: 1.4`, `overflow-wrap: anywhere`. As a flex item of the `.tool-calls` column it stays block-level per line, but its label + inline `<code>` now flow as one continuous run → the path wraps to the left edge and the label no longer breaks mid-word.
- **`.tool-call code`** — DECHIP: remove `background: var(--brand-soft)`, `padding`, `border-radius`. KEEP `font-family: var(--mono)`, `font-size: 0.95em`, `color: var(--ink)` (≈11.5:1, AA).
- **NEW `.tool-calls-disclosure` + `.tool-calls-summary`** — model the disclosure on the existing `details.thinking` styling (house AA palette, no new hue): the summary is a native focusable toggle that carries the house 3px `:focus-visible` ring (the `details.thinking summary` already has it — reuse that language), small status font, `color: var(--accent-ink)` (≈10.4:1 on the surface — same pairing the deboxed line uses). No new color literal (phase-92 zero-literal invariant; B5 text+color, never color alone — the count is text).
- **`.tool-calls`** (the list) — UNCHANGED: stays `display:flex; flex-direction:column; gap: 0.25rem` (spacing between the now-deboxed lines).
- **`.tool-elapsed` / `.truncated-note`** — UNCHANGED (phase 87 / phase 95).
### `tests/unit/test_tool_call_compact.py` (NEW — source-level house pattern)
- **app.js:** `appendToolLine` body contains `document.createElement("details")` + `className = "tool-calls-disclosure"` + `document.createElement("summary")`; `container.open = true` (the live default, D3); the count literal `Tool call${n === 1 ? "" : "s"} (${n})` (or the equivalent template) is built with `textContent` (no `innerHTML`); `closeToolCalls` is defined and called at **3** handler sites (delta/done/stop) + once in `renderStoredMessage` (pin: `closeToolCalls(wrap)` appears ≥4×); `ensureThinkingBlock`'s anchor includes `.tool-calls-disclosure`; the four pinned `line.textContent = "…"` label literals are STILL present (mirror the guard); `innerHTML` is NOT in the `appendToolLine` body.
- **shared.js:** `addToolLines` body contains `document.createElement("details")` + `className = "tool-calls-disclosure"` + `document.createElement("summary")` + `open = false` (closed on the shared page, D3); the four label literals are still present; `body.count("code.textContent = argument") == 3`; no `innerHTML`.
- **styles.css:** the `.tool-call` rule has **NO** `display: flex` and **NO** `var(--accent-line)` (deboxed, D4) but still has `var(--accent-ink)`; the `.tool-call code` rule still has `var(--mono)` + `var(--ink)` and has **NO** `background`; `.tool-calls-disclosure` and `.tool-calls-summary` rules exist.
### `tests/unit/test_frontend_tool_states.py` (UPDATE — the only existing test that changes)
- **`test_tool_call_style_is_accent_and_contrast_safe`** — the `.tool-call` rule is deboxed: REMOVE the `assert "display: flex" in row` and `assert "var(--accent-line)" in row` lines; KEEP `assert "var(--accent-ink)" in row` (the accent is now the line's color) and the `.tool-call code` `var(--mono)` + `var(--ink)` asserts. Update the docstring to describe the deboxed, inline-flow line (phase 117). Every OTHER test in this file stays green unchanged (the DOM-shape, literal, branch-order, persist, restore, shared-parity, and no-CDN pins are all preserved by D6).
### `tests/e2e/test_tool_call_compact.py` (NEW story suite — mock "use your tools" flow, 3 tool calls)
The conftest `app_server`/`page` fixtures + the phase-37 admin login + the marker question that drives the mock's `ls → ls(scoped) → read` flow (mirror `test_agent_document_tools.py`). Four tests:
1. **Folds at rest:** after the turn completes, the `.tool-calls-summary` is visible with text `Tool calls (3)`; the disclosure is **not** open (`.tool-calls-disclosure` has no `[open]`); the `.tool-call` lines are present in the DOM (count 3) but hidden; the answer bubble is present.
2. **Expands on tap:** clicking the summary opens the disclosure; the three `.tool-call` lines become visible with the correct text (nth 0 "Listing documents", nth 1 "Listing documents in", nth 2 "Reading ").
3. **Deboxed inline flow:** on a visible (expanded) `.tool-call` line, `getComputedStyle(line).display !== "flex"` (the label + path are one inline run, not two flex items) and the line `to_contain_text("Reading ")` (label immediately followed by the path in the same run).
4. **Restored folded:** RELOAD (same context — the phase-14/50 persisted conversation restores); the restored brain message's `.tool-calls-disclosure` is present, closed, summary `Tool calls (3)`, `.tool-call` count 3 in the DOM — no auto-expand on load.
## Dependencies
- `95_read_truncation_cap`, `87_big_read_progress`, `70_harness_aligned_tools`, `37` (the tool-line rendering this restyles — complete). NO code dependency beyond the shared frontend files; the only pipeline predecessor is execution order (todo/ is empty — this is the next phase).
## Tasks
1. `01_disclosure_wrapper.md` — the `<details>/<summary>` wrapper + count + open-live/close-at-rest + restore-closed + shared.js parity (app.js + shared.js + the new unit module's JS pins).
2. `02_debox_and_inline_flow.md` — the CSS debox/dechip + disclosure/summary rules, and the one `test_frontend_tool_states.py` CSS-pin update (styles.css + unit CSS pins + the existing CSS test).
3. `03_e2e_story_suite.md` — `tests/e2e/test_tool_call_compact.py` (fold / expand / deboxed-flow / restored-folded).
4. `04_verify_and_commit.md` — full gate (unit + integration, coverage >90%, the new E2E story in isolation, `test_agent_document_tools.py` + `test_big_read_progress.py` + `test_thinking_display.py` + smoke in isolation, ruff + pyright) + atomic commit + move to complete/.
## Testing & Quality
- Unit — `tests/unit/test_tool_call_compact.py` (new): the JS/CSS pins listed above (disclosure + summary + count + open-live, close-at-4-sites, restore-closed, shared parity, deboxed CSS, disclosure/summary rules, the pinned literals still present, no innerHTML).
- Existing unit suites MUST stay green: `tests/unit/test_big_read_progress.py` (phase-87 clock + the byte-identical tool-line literals), `tests/unit/test_shared_page.py` (shared-page tool-line parity), and `tests/unit/test_frontend_tool_states.py` (all tests except the ONE deboxed CSS assert updated in task 02).
- E2E — `tests/e2e/test_tool_call_compact.py` (new; isolation gate per AGENTS.md rule 9): the four tests above, mock LLM (no slow proxy needed — the fold is about a completed turn).
- Regression E2E (run in isolation by task 04): `test_agent_document_tools.py` (tool-line count + text after a completed turn — the lines stay in the DOM inside the folded disclosure), `test_big_read_progress.py` (the FIRST `.tool-call` line + the `.tool-elapsed` suffix are visible DURING the live gap — the disclosure is still open then), `test_thinking_display.py` (the Thinking block ordering vs. the disclosure), `test_smoke.py`.
- Coverage: **>90%** on `app/` — no `app/` code changes (the floor is held by the untouched suite).
## Completion Criteria
- [ ] A completed tool turn renders as ONE collapsed "Tool calls (N)" line (E2E pin 1); tapping it reveals the individual calls (E2E pin 2); the restored/shared view is folded on load (E2E pin 4). The 6+ stacked cards from the screenshot are gone.
- [ ] An expanded `.tool-call` line is deboxed inline-flow text (E2E pin 3 + the CSS unit pins): the path wraps to the left edge and the label never breaks mid-word — the "spit and wrapped poorly" glitch is fixed at 390×844 and at desktop.
- [ ] Live behavior unchanged: during a live frameless gap the calls + phase-87 `.tool-elapsed` suffix stay visible (the disclosure is open until the first delta) — `test_big_read_progress.py` green in isolation; the "calling tool" `#send-status`/aria state is byte-identical (`test_calling_tool_label_strings` green).
- [ ] `uv run pytest` green (including `test_big_read_progress.py`, `test_shared_page.py`, and the updated `test_frontend_tool_states.py`); `uv run pytest --cov=app --cov-report=term-missing` >90%; the new E2E story + `test_agent_document_tools.py` + `test_big_read_progress.py` + `test_thinking_display.py` + `test_smoke.py` green in isolation; `uv run ruff check . && uv run pyright` clean.
- [ ] `git diff --stat` limited to `frontend/assets/app.js`, `frontend/assets/shared.js`, `frontend/assets/styles.css`, `tests/unit/test_tool_call_compact.py`, `tests/unit/test_frontend_tool_states.py`, `tests/e2e/test_tool_call_compact.py`, and the phase files — nothing in `app/`.
- [ ] One atomic `--no-gpg-sign` commit (e.g. `feat(ui): fold tool calls into a compact collapsible disclosure`); phase dir moved to `.agents/phases/complete/`.
## Locked decisions
- **Frontend-only (D1)** — `app/`, the SSE event set, and the persistence format are byte-identical; only the rendering changes.
- **Reuse the native `<details>` Thinking-block convention (D2/D3)** — open while the turn is live, folded at rest and on restore; no new component or dependency. This is what keeps phase 87's live-suffix E2E and the tool-line count/text E2E green.
- **Pinned literals + DOM shape stay (D6)** — the four `line.textContent` labels, the `.tool-calls` list, the `.tool-call` listitems, and the `<code>` `textContent` arguments are byte-identical; the emoji guard and phase 87's `.tool-calls .tool-call:last-child` query are unaffected. Only the ONE CSS assert in `test_frontend_tool_states.py` changes (the debox).
- **Debox, don't restyle the accent away (D4)** — the line keeps `var(--accent-ink)` as its text color (≈10.4:1 AA on the surface); the accent moves off the border onto the text, so the line still reads as distinct from the brand-ink Thinking summary.
@@ -0,0 +1,63 @@
# Task 01 — The `<details>/<summary>` disclosure wrapper + count + open-live/close-at-rest
**Phase:** `117_tool_call_compact` · **Source:** owner visual-glitch report (2026-09-15) — tool-call cards take too much space and wrap poorly.
**Story:** n/a (owner bug report)
## Objective
Wrap the existing `.tool-calls` list in a native `details.tool-calls-disclosure` with a `summary.tool-calls-summary` that carries a `Tool call (N)` / `Tool calls (N)` count. The disclosure is created **open** on the first live `tool` frame, **folds** when the answer starts / the turn ends / is stopped (`closeToolCalls`), and renders **closed** on the restore path and the shared page. The `.tool-calls` list, the `.tool-call` lines, the four label literals, and the `<code>` arguments stay byte-identical (D6) — only the wrapper is added.
## Work
1. `frontend/assets/app.js` — `appendToolLine` (house comment style, citing phase 117):
- `let container = body.querySelector(".tool-calls-disclosure");` (was `.tool-calls`).
- First-frame branch (the `if (!container)` block): create the disclosure + summary + the existing list, in this order:
```js
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);
body.insertBefore(container, body.querySelector(".bubble"));
```
- After the `if`: `const list = container.querySelector(".tool-calls");` then build the line EXACTLY as today (`line.className="tool-call"`, `role="listitem"`, the four pinned `line.textContent` label literals, the `<code>` with `code.textContent = argument`) and `list.appendChild(line)`.
- Summary count on every append (live + restore): `const n = list.children.length; container.querySelector("summary").textContent = \`Tool call${n === 1 ? "" : "s"} (${n})\`;`.
- **No `innerHTML`** anywhere in the function; keep it flat (no nested function declaration) so the `js[fn : js.find("\n}\n", fn)]` body-extraction pins keep working.
2. `frontend/assets/app.js` — `closeToolCalls(wrap)` (new, defined next to `closeThinkingBlock`):
```js
function closeToolCalls(wrap) {
const disc = wrap?.querySelector?.(".tool-calls-disclosure");
if (disc) disc.open = false; // idempotent; no-op without a disclosure
}
```
Add `closeToolCalls(wrap);` on the line next to each existing `closeThinkingBlock(wrap);` — the `delta` handler (≈L2514), the `done` handler (≈L2519), and the stop/abort settle (≈L2646). Comment each: the answer began / the turn ended / the turn was stopped — fold the record (mirrors the Thinking block settling closed).
3. `frontend/assets/app.js` — `renderStoredMessage`: immediately after the existing `if (Array.isArray(m.tools)) { …appendToolLine(wrap, t.name, arg)… }` loop, add `closeToolCalls(wrap);` (a restored turn renders folded — the space fix; mirrors the thinking restore rendering collapsed).
4. `frontend/assets/app.js` — `ensureThinkingBlock`: change the anchor to
`const anchor = body.querySelector(".tool-calls-disclosure") ?? body.querySelector(".tool-calls") ?? body.querySelector(".bubble");`
so the Thinking block lands above the whole disclosure. `block.open = true` and everything else unchanged.
5. `frontend/assets/shared.js` — `addToolLines` (parity, but **created closed** — pure render, always folded):
- Build the same `details.tool-calls-disclosure` with `container.open = false`, a `summary.tool-calls-summary` (first child), and the existing `.tool-calls` list (role=list, aria-label "Tool calls") — appended to the same `.msg-body` anchor as today.
- Render each line byte-identical (the same four label literals + `<code>` + the phase-95 `truncated-note`), appending to the list.
- Set the summary count once at the end: `const n = list.children.length; summary.textContent = \`Tool call${n === 1 ? "" : "s"} (${n})\`;`.
- Keep `body.count("code.textContent = argument") == 3` and no `innerHTML` (the shared-parity pins).
6. `tests/unit/test_tool_call_compact.py` (NEW module) — the source-level JS pins (house pattern, `_js()` / `_shared_js()` readers like `test_frontend_tool_states.py`):
- **app.js `appendToolLine`:** body contains `document.createElement("details")`, `className = "tool-calls-disclosure"`, `document.createElement("summary")`; `container.open = true`; the count template `Tool call${n === 1 ? "" : "s"} (${n})` (assert the template fragment, not the rendered value); `innerHTML` NOT in the body; the four pinned `line.textContent = "…"` label literals are still present.
- **app.js `closeToolCalls`:** defined (`function closeToolCalls`); `closeToolCalls(wrap)` appears **≥4×** (the 3 handler sites + `renderStoredMessage`).
- **app.js `ensureThinkingBlock`:** the body contains `.tool-calls-disclosure` (the new anchor term) AND still contains `querySelector(".tool-calls")` + `querySelector(".bubble")` + `block.open = true`.
- **shared.js `addToolLines`:** body contains `document.createElement("details")`, `className = "tool-calls-disclosure"`, `document.createElement("summary")`, `open = false` (closed on the shared page); the four label literals present; `body.count("code.textContent = argument") == 3`; no `innerHTML`.
## Testing & Quality
- Unit: `uv run pytest tests/unit/test_tool_call_compact.py -v` green (all JS pins). `uv run pytest tests/unit/test_frontend_tool_states.py tests/unit/test_big_read_progress.py tests/unit/test_shared_page.py -v` green **unchanged** (the CSS pin is task 02; the JS/DOM pins are preserved by D6).
- Quick live sanity (session log): dev server, trigger a tool turn — during the turn the disclosure is open (calls + any `.tool-elapsed` suffix visible); when the answer starts it folds to "Tool calls (N)"; a reload shows it folded. (The deterministic E2E is task 03 — this is a wiring smoke only.)
- Coverage: **>90%** on `app/` unaffected (no `app/` change).
## Completion Criteria
- [ ] `git diff frontend/assets/app.js` shows: the disclosure/summary/list creation in `appendToolLine`, the summary count line, the new `closeToolCalls`, the three handler call sites + the `renderStoredMessage` call, and the `ensureThinkingBlock` anchor — and NOTHING else; the four `line.textContent` label literals and the `<code>` `textContent` bytes byte-identical.
- [ ] `git diff frontend/assets/shared.js` shows the parallel closed-disclosure build + count + the unchanged line/literal bytes.
- [ ] `uv run pytest tests/unit/test_tool_call_compact.py -v` green; `tests/unit/test_frontend_tool_states.py` + `tests/unit/test_big_read_progress.py` + `tests/unit/test_shared_page.py` green unchanged (CSS test still asserts the OLD flex/border — that flips in task 02, so run it here to confirm only that one assert is the pending delta).
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] No behavior change in completed work (the fold/expand proof is task 03; the live-suffix regression is task 04).
@@ -0,0 +1,53 @@
# Task 02 — Debox + inline-flow CSS, and the one CSS-pin update
**Phase:** `117_tool_call_compact` · **Source:** owner visual-glitch report (2026-09-15) — the tool-call "cards" are too heavy and the label breaks mid-word.
**Story:** n/a (owner bug report)
## Objective
Debox the `.tool-call` line (no card, no flex) so the label + inline `<code>` flow as one continuous run (fixes the `Rea`/`ding` mid-word break and the indented 3-line wrap), dechip the `.tool-call code` (no background chip, keep mono + ink), and add the `.tool-calls-disclosure` / `.tool-calls-summary` rules modeled on the Thinking disclosure. Update the ONE existing CSS assert in `test_frontend_tool_states.py` to the deboxed contract, and add the CSS pins to the new unit module.
## Work
1. `frontend/assets/styles.css` — **`.tool-call`** (the debox, D4). Remove `display: flex;`, `align-items: baseline;`, `gap: 0.45rem;`, `background: var(--surface);`, `border: 1px solid var(--line);`, `border-left: 3px solid var(--accent-line);`, `border-radius: var(--radius-sm);`, `padding: 0.3rem 0.75rem;`. KEEP `color: var(--accent-ink);`, `font-size: 0.8rem;`, `line-height: 1.4;`, `overflow-wrap: anywhere;`. Update the rule's comment: phase 117 deboxed the line — the accent now rides the text color (≈10.4:1 on the surface) instead of a border; the label + inline `code` flow as one run so the path wraps to the left edge (no more mid-word label break).
2. `frontend/assets/styles.css` — **`.tool-call code`** (the dechip). Remove `background: var(--brand-soft);`, `padding: 0.05em 0.35em;`, `border-radius: 5px;`. KEEP `font-family: var(--mono);`, `font-size: 0.95em;`, `color: var(--ink);`, `overflow-wrap: anywhere;`. Comment: the path keeps the mono + ink treatment (≈11.5:1 AA) but loses the chip background — it is inline text in the line's run now.
3. `frontend/assets/styles.css` — **NEW `.tool-calls-disclosure` + `.tool-calls-summary`** rules, placed next to the `details.thinking` rules and modeled on them (house AA palette, no new hue — the phase-92 zero-literal invariant):
```css
/* Phase 117: the tool-call record folds into a single disclosure line,
reusing the Thinking block's native <details> convention. Open while
the turn is live (phase 87's elapsed suffix stays visible), folded at
rest / on restore. The summary is a native focusable toggle — it
inherits the house 3px :focus-visible ring (see details.thinking
summary). Accent rides the TEXT (var(--accent-ink), ≈10.4:1 on the
surface) — text + color, never color alone (B5). No new literal. */
.tool-calls-disclosure { margin: 0.25rem 0; }
.tool-calls-summary {
cursor: pointer;
color: var(--accent-ink);
font-size: 0.8rem;
line-height: 1.4;
padding: 0.15rem 0;
list-style: none; /* the native marker is redundant with the count text */
}
.tool-calls-summary::-webkit-details-marker { display: none; }
```
(If the `details.thinking summary` uses a chevron/marker rather than the native one, match that treatment so the two disclosures read as one family — the count text is the accessible label either way.)
4. `frontend/assets/styles.css` — **`.tool-calls`** (the list) — UNCHANGED: keep `display: flex; flex-direction: column; gap: 0.25rem;` (spacing between the now-deboxed lines). **`.tool-elapsed`** and **`.tool-call .truncated-note`** — UNCHANGED (phase 87 / phase 95).
5. `tests/unit/test_tool_call_compact.py` (extend the module from task 01) — the CSS pins:
- the `.tool-call` rule (regex `\.tool-call \{([^}]*)\}`) has **NO** `display: flex` and **NO** `var(--accent-line)` (deboxed), but still has `var(--accent-ink)`;
- the `.tool-call code` rule still has `var(--mono)` + `var(--ink)` and has **NO** `background`;
- `.tool-calls-disclosure` and `.tool-calls-summary` rules exist (the summary carries `var(--accent-ink)`).
6. `tests/unit/test_frontend_tool_states.py` — **`test_tool_call_style_is_accent_and_contrast_safe`** (the ONE existing test that changes, D4/D6):
- REMOVE the two now-false asserts: `assert "display: flex" in row` and `assert "var(--accent-line)" in row`.
- KEEP `assert "var(--accent-ink)" in row` (the accent is now the line's color) and the `.tool-call code` `var(--mono)` + `var(--ink)` asserts and the `.tool-calls` `gap` assert.
- Update the docstring: the line is now a deboxed inline-flow row (phase 117) — the accent rides the text color; the `code` is inline mono text (no chip). Everything else in this file is untouched.
## Testing & Quality
- Unit: `uv run pytest tests/unit/test_tool_call_compact.py -v` green (JS pins from task 01 + the new CSS pins); `uv run pytest tests/unit/test_frontend_tool_states.py -v` green with the updated CSS test; `tests/unit/test_big_read_progress.py` + `tests/unit/test_shared_page.py` green unchanged.
- Quick live sanity (session log): dev server, expand a completed tool turn at 390×844 — the lines are plain text rows (no card border/background), the path wraps to the left edge, and "Reading" never breaks as "Rea/ding". (The deterministic E2E is task 03.)
- Coverage: **>90%** on `app/` unaffected.
## Completion Criteria
- [ ] `git diff frontend/assets/styles.css` shows: the deboxed `.tool-call`, the dechipped `.tool-call code`, the two new disclosure/summary rules — and NOTHING else (`.tool-calls` gap, `.tool-elapsed`, `.truncated-note` unchanged).
- [ ] `git diff tests/unit/test_frontend_tool_states.py` shows ONLY the two removed asserts + the docstring in `test_tool_call_style_is_accent_and_contrast_safe`.
- [ ] `uv run pytest tests/unit/test_tool_call_compact.py tests/unit/test_frontend_tool_states.py tests/unit/test_big_read_progress.py tests/unit/test_shared_page.py -v` all green.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] No behavior change in completed work (E2E proof in task 03/04).
@@ -0,0 +1,41 @@
# Task 03 — The E2E story suite (fold / expand / deboxed-flow / restored-folded)
**Phase:** `117_tool_call_compact` · **Source:** owner visual-glitch report (2026-09-15).
**Story:** n/a (owner bug report)
## Objective
Prove the new behavior end-to-end against the deterministic mock: a completed 3-call tool turn folds to one "Tool calls (3)" line, expands on tap to the deboxed inline-flow lines, and renders folded on reload. Reuses the phase-37 admin login + the mock "use your tools" flow (exactly the 3 tool calls `test_agent_document_tools.py` drives: `ls` → `ls(scoped)` → `read`).
## Work
1. `tests/e2e/test_tool_call_compact.py` (NEW module) — header docstring cites phase 117 + the owner report; the conftest `app_url` + `page` fixtures; `from e2e.auth_helpers import login`. Constants:
- `MARKER_QUESTION = "Use your tools: what is the exact JSON shape of reeselink.json for my aws route53 hosted zone?"` (the SAME marker that drives the mock's 3-tool flow — mirror `test_agent_document_tools.py` so the flow is deterministic).
- `SUMMARY = "#messages .msg.brain .tool-calls-summary"` (the disclosure toggle).
- `DISCLOSURE = "#messages .msg.brain .tool-calls-disclosure"`.
- `LINES = "#messages .msg.brain .tool-call"`.
- A `_submit_tools_turn(page, app_url)` helper: `login(page, app_url, next="/")`; `page.fill("#message-input", MARKER_QUESTION)`; `page.click("#send-btn")`; wait for the answer bubble (`MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"`) — i.e. the turn is COMPLETE (the disclosure has already folded on the first delta).
2. **`test_tool_calls_fold_to_one_line_after_turn`** (pin 1): after `_submit_tools_turn`:
- `expect(page.locator(SUMMARY)).to_be_visible()` and `to_contain_text("Tool calls (3)")`.
- the disclosure is NOT open: `page.get_attribute(DISCLOSURE, "open")` is `None` (a closed `<details>` has no `open` attribute).
- the lines are present in the DOM but folded: `expect(page.locator(LINES)).to_have_count(3)` (DOM count) and the FIRST line is NOT visible (`expect(page.locator(LINES).first).not_to_be_visible()`).
- the answer is present: `expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)`.
3. **`test_summary_click_expands_the_calls`** (pin 2): after `_submit_tools_turn`:
- `page.locator(SUMMARY).click()` (native `<summary>` — a real focusable toggle, AA).
- `page.get_attribute(DISCLOSURE, "open")` is now `"true"` (or the attribute present).
- `expect(page.locator(LINES)).to_have_count(3)` and every line is now visible; assert the text (mirror `test_agent_document_tools.py`): nth 0 `to_contain_text("Listing documents")`, nth 1 `to_contain_text("Listing documents in")`, nth 2 `to_contain_text("Reading ")`.
4. **`test_expanded_line_is_deboxed_inline_flow`** (pin 3): after expanding (reuse the click from pin 2 in a fresh turn):
- take the visible `.tool-call` "Reading" line; assert `page.evaluate` on its computed style: `getComputedStyle(el).display !== "flex"` (the label + path are one inline run, not two flex items — the debox).
- `expect(line).to_contain_text("Reading ")` (the label is immediately followed by the path in the same run — no separate indented code column).
5. **`test_restored_turn_renders_folded`** (pin 4): after `_submit_tools_turn`:
- `page.reload()` (same context — the phase-14/50 persisted conversation restores).
- wait for the restored brain message's answer; then `expect(page.locator(SUMMARY)).to_be_visible()` + `to_contain_text("Tool calls (3)")`; the disclosure is NOT open (`get_attribute(...) is None`); `expect(page.locator(LINES)).to_have_count(3)` in the DOM; the FIRST line is NOT visible (folded on load — no auto-expand).
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_tool_call_compact.py -v --no-cov` green **in isolation** (DB up: `podman compose up -d db`). Mock LLM by default (no slow proxy — the fold is about a completed turn, not a live gap).
- No `app/` change — the conftest app-server + mock flow are used as-is.
- Coverage: **>90%** on `app/` unaffected.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_tool_call_compact.py -v --no-cov` green in isolation (all four pins).
- [ ] The suite proves: folded-after-turn (pin 1), expand-on-tap (pin 2), deboxed inline-flow (pin 3), restored-folded (pin 4).
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] No behavior change in completed work (the adjacent-suite regression is task 04).
@@ -0,0 +1,33 @@
# Task 04 — Full gate + atomic commit
**Phase:** `117_tool_call_compact` · **Source:** owner visual-glitch report (2026-09-15).
**Story:** n/a (owner bug report)
## Objective
Run the complete phase gate, land the phase as one atomic commit, and move the phase directory to `complete/`.
## Work
1. **Full regression gate** (AGENTS.md rule 9):
- `uv run pytest` — unit + integration green (including `tests/unit/test_tool_call_compact.py`, the updated `tests/unit/test_frontend_tool_states.py`, and the untouched `tests/unit/test_big_read_progress.py` + `tests/unit/test_shared_page.py`).
- `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage **>90%** (no `app/` change this phase — confirm the floor is held).
- `uv run pytest tests/e2e/test_tool_call_compact.py -v --no-cov` — green **in isolation** (this phase's E2E story — fold / expand / deboxed-flow / restored-folded).
- `uv run pytest tests/e2e/test_agent_document_tools.py -v --no-cov` — green in isolation (the tool-line count + text after a completed turn — the lines stay in the DOM inside the folded disclosure).
- `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` — green in isolation (the FIRST `.tool-call` line + the `.tool-elapsed` suffix are visible DURING the live gap — the disclosure is still open then; this is the key live-behavior regression proof for D3).
- `uv run pytest tests/e2e/test_thinking_display.py -v --no-cov` — green in isolation (the Thinking block ordering vs. the new disclosure anchor).
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov` — green in isolation.
- `uv run ruff check . && uv run pyright` — clean.
2. **Manual live check** (keep the output in the session log): the live/dev app at a **mobile viewport (390×844)** — trigger a multi-call tool turn (e.g. "Generate a change log … last 5 phases"). Confirm: the 6+ stacked cards from the original report are now ONE collapsed "Tool calls (N)" line; tapping it reveals deboxed text rows whose paths wrap to the left edge with the label never breaking mid-word (`Reading` stays intact); during a live frameless gap the calls + any "(Ns)" suffix are visible (the disclosure is open); on reload the turn is folded.
3. **Commit** (AGENTS.md rule 8 — one atomic, Conventional-Commits commit, always `--no-gpg-sign`), staging `frontend/assets/app.js`, `frontend/assets/shared.js`, `frontend/assets/styles.css`, `tests/unit/test_tool_call_compact.py`, `tests/unit/test_frontend_tool_states.py`, `tests/e2e/test_tool_call_compact.py`, and the phase files:
`feat(ui): fold tool calls into a compact collapsible disclosure`
— body: owner visual-glitch report (2026-09-15) — each tool call rendered as a full-width bordered card, so a multi-call turn stacked N cards over the answer, and the flex label+code layout broke the label mid-word ("Reading" → "Rea/ding") with the path indented to 3 lines. Two changes, frontend-only: (1) the tool-call record now folds into a single native `<details>` "Tool calls (N)" line reusing the Thinking-block convention — open while the turn is live (phase 87's elapsed suffix stays visible), folded when the answer starts / on done / on stop / on restore, closed by default on the shared page; (2) the individual lines are deboxed (no card, no flex) and dechipped (no code background) so the label + path flow as one inline run that wraps to the left edge. The four pinned line literals, the `.tool-calls` list, the `<code>` textContent arguments, the emoji-guard set, the persistence format, and the server are byte-identical; only the ONE deboxed CSS assert in `test_frontend_tool_states.py` changes. New source-level unit pins + a dedicated fold/expand/debox/restore E2E story.
4. Move the phase directory: `mv .agents/phases/todo/117_tool_call_compact .agents/phases/complete/` and include the move in the same commit.
## Testing & Quality
- This task IS the phase-level gate — the commands above are the completion evidence.
- Coverage: >90% held.
## Completion Criteria
- [ ] All seven gate commands green (unit + integration, coverage >90%, the new E2E story + `test_agent_document_tools.py` + `test_big_read_progress.py` + `test_thinking_display.py` + smoke in isolation, ruff + pyright).
- [ ] The live mobile check shows the stacked cards replaced by one collapsed "Tool calls (N)" line that expands to deboxed inline-flow rows (path wraps to the left edge, label intact), live calls still visible during a gap, and restored turns folded (output kept in the session log).
- [ ] `git show --stat HEAD` lists the staged files above + the phase files (todo → complete move) — nothing else (in particular `app/`, `pyproject.toml` / `uv.lock`, `conftest.py`, `mock_llm.py`, `slow_llm.py` untouched).
- [ ] `.agents/phases/complete/117_tool_call_compact/` exists; `todo/` no longer contains it.
@@ -0,0 +1,11 @@
All verification complete — every gate green. Final report:
**Phase 117 final verification pass — all criteria verified** (tasks 01–04 were already in `complete/`; I re-verified the full gate myself, no code changes needed)
- **Verified:** `appendToolLine` wraps lines in `details.tool-calls-disclosure` (open live, `closeToolCalls` at delta/done/stop + restore-closed, count via textContent); `addToolLines` parity (closed); `.tool-call` deboxed / `code` dechipped; new disclosure/summary CSS; only the one pinned CSS assert updated
- `uv run pytest` → **2466 passed**
- `uv run pytest --cov=app --cov-report=term-missing` → **99%** on `app/` (>90% floor)
- E2E in isolation: `test_tool_call_compact.py` **4 passed** (fold/expand/deboxed-flow/restored-folded); `test_agent_document_tools.py` **4 passed**; `test_big_read_progress.py` **4 passed** (live disclosure open + `.tool-elapsed` visible); `test_thinking_display.py` **5 passed**; `test_smoke.py` **3 passed**
- `uv run ruff check . && uv run pyright` → clean (pyright 0 errors, 0 warnings)
- Completion criteria: pins 1–4 ✅ (E2E above); live "calling tool" state byte-identical ✅ (`test_frontend_tool_states.py` 10 passed incl. `test_calling_tool_label_strings`); scope ✅ — `git diff` limited to the 3 frontend assets + 2 unit tests + E2E story + phase files, **0 changes in `app/`**
- Notable: `TODO.md` carries a **pre-existing uncommitted owner edit** (new retrieval-context idea) already in the tree before this pass — left untouched, will ride along in the harness commit; pre-existing committed quirk: the brain-wave commit's styles.css comment mislabels itself "Phase 117" (cosmetic, out of this diff)
- No commit made (harness commits + moves the phase); **next pending phase: none** — `todo/` holds only phase 117
@@ -0,0 +1,103 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 8%]
........................................................................ [ 11%]
........................................................................ [ 14%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 26%]
........................................................................ [ 29%]
........................................................................ [ 32%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 46%]
........................................................................ [ 49%]
........................................................................ [ 52%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 64%]
........................................................................ [ 67%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 78%]
........................................................................ [ 81%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 90%]
........................................................................ [ 93%]
........................................................................ [ 96%]
........................................................................ [ 99%]
.................. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain-of-reese/.venv/lib64/python3.14/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.14.7-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 224 1 99%
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 156 1 99%
app/api/git_sources.py 232 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 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 210 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 66 0 100%
app/models.py 128 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 317 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 215 3 99%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 192 3 98%
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 24 0 100%
app/schemas.py 329 0 100%
--------------------------------------------------
TOTAL 4147 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,22 @@
All completion criteria verified. Task 01 is complete.
---
**Phase 117 · Task 01 — disclosure wrapper: DONE**
- `app.js` `appendToolLine`: `.tool-calls` list now wrapped in `details.tool-calls-disclosure` + `summary.tool-calls-summary`; created **open** on first live frame; count `Tool call(s) (N)` via textContent on every append; no innerHTML; function stays flat
- New `closeToolCalls(wrap)` next to `closeThinkingBlock`; called at 4 sites: delta (answer began), done (turn ended), stop/abort settle, `renderStoredMessage` (restore renders folded)
- `ensureThinkingBlock` anchor → `.tool-calls-disclosure` ?? `.tool-calls` ?? `.bubble`
- `shared.js` `addToolLines`: parallel disclosure, created **closed** (pure render), count set once at end
- New `tests/unit/test_tool_call_compact.py`: 6 source-level JS pins (disclosure/summary/count, close-at-4-sites, restore-fold, anchor, shared parity, pinned literals, no innerHTML)
**Results:**
- `uv run pytest tests/unit/test_tool_call_compact.py -v` → 6 passed; `test_frontend_tool_states.py` + `test_big_read_progress.py` + `test_shared_page.py` (+`test_read_truncation_frontend.py`) → 43 passed unchanged
- `uv run pytest` → 2463 passed (started the down `brain-of-reese_db_1` container — initial 6 failures were environment-only, confirmed pre-existing via stash)
- `uv run pytest --cov=app` → **99%** (>90%); `uv run ruff check .` clean; `uv run pyright` → 0 errors
- E2E smoke (temp, deleted): at rest one folded "Tool calls (3)" disclosure, tap expands, reload restores folded — passed
- Regression E2E in isolation: `test_agent_document_tools.py` 4 ✓, `test_big_read_progress.py` 4 ✓ (live suffix still visible — disclosure open mid-turn), `test_thinking_display.py` 5 ✓, `test_smoke.py` 3 ✓
**Decisions:** D6 held — all four label literals + `<code>` textContent bytes byte-identical (diff-verified); nothing in `app/`. Note: `TODO.md` carries a new uncommitted owner note added mid-session — not mine, left untouched.
**Next pending task:** `02_debox_and_inline_flow.md` (CSS debox/dechip + disclosure/summary rules + the one CSS-pin update).
@@ -0,0 +1,103 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 8%]
........................................................................ [ 11%]
........................................................................ [ 14%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 26%]
........................................................................ [ 29%]
........................................................................ [ 32%]
........................................................................ [ 35%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 46%]
........................................................................ [ 49%]
........................................................................ [ 52%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 64%]
........................................................................ [ 67%]
........................................................................ [ 70%]
........................................................................ [ 73%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 81%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 90%]
........................................................................ [ 93%]
........................................................................ [ 96%]
........................................................................ [ 99%]
............... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain-of-reese/.venv/lib64/python3.14/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.14.7-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 224 1 99%
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 156 1 99%
app/api/git_sources.py 232 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 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 210 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 66 0 100%
app/models.py 128 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 317 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 215 3 99%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 192 3 98%
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 24 0 100%
app/schemas.py 329 0 100%
--------------------------------------------------
TOTAL 4147 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,19 @@
All green. Final verification of the targeted suites was already done (46 passed). Summary:
## Task 02 complete — Debox + inline-flow CSS, one CSS-pin update
**Implemented**
- `styles.css`: `.tool-call` deboxed (removed flex/align/gap/background/borders/radius/padding; kept accent-ink, 0.8rem, 1.4, overflow-wrap) with the phase-117 comment
- `styles.css`: `.tool-call code` dechipped (kept `var(--mono)` + `var(--ink)` + overflow-wrap; removed chip bg/padding/radius)
- `styles.css`: new `.tool-calls-disclosure` / `.tool-calls-summary` rules next to `details.thinking`; since the Thinking summary uses a CSS chevron, the summary carries the matching `▸` chevron (rotates on `[open]`, stills in the reduced-motion block) per the task's family-match parenthetical — no new literals, focus ring via the global rule
- `tests/unit/test_tool_call_compact.py`: 3 new CSS pins (deboxed row, dechipped code, disclosure/summary rules) + docstring/reader updates
- `tests/unit/test_frontend_tool_states.py`: exactly the two removed asserts + docstring in `test_tool_call_style_is_accent_and_contrast_safe`
**Test / lint / coverage**
- `uv run pytest tests/unit/test_tool_call_compact.py tests/unit/test_frontend_tool_states.py tests/unit/test_big_read_progress.py tests/unit/test_shared_page.py -v` → 46 passed
- `uv run pytest` (full) → 2466 passed; `uv run pytest --cov=app` → 99% (>90%)
- `uv run ruff check . && uv run pyright` → clean, 0 errors
**Decisions** — chevron + reduced-motion still added to the new summary (task step 3's parenthetical requires matching the Thinking chevron treatment); `.tool-calls` gap / `.tool-elapsed` / `.truncated-note` untouched; no `app/` changes.
**Next pending task:** `.agents/phases/todo/117_tool_call_compact/03_e2e_story_suite.md`
@@ -0,0 +1,103 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 8%]
........................................................................ [ 11%]
........................................................................ [ 14%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 26%]
........................................................................ [ 29%]
........................................................................ [ 32%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 46%]
........................................................................ [ 49%]
........................................................................ [ 52%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 64%]
........................................................................ [ 67%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 78%]
........................................................................ [ 81%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 90%]
........................................................................ [ 93%]
........................................................................ [ 96%]
........................................................................ [ 99%]
.................. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain-of-reese/.venv/lib64/python3.14/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.14.7-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 224 1 99%
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 156 1 99%
app/api/git_sources.py 232 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 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 210 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 66 0 100%
app/models.py 128 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 317 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 215 3 99%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 192 3 98%
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 24 0 100%
app/schemas.py 329 0 100%
--------------------------------------------------
TOTAL 4147 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,14 @@
All gates green and deterministic. Final verification of the completion criteria is done:
**Report — task 03 (`117_tool_call_compact` / E2E story suite)**
- Implemented `tests/e2e/test_tool_call_compact.py` (new, mock-only): conftest `app_url`/`page` fixtures, phase-37 admin `login(next="/")`, `_submit_tools_turn` helper (fill → click → wait for answer bubble + settle), and all four pins:
- pin 1: completed 3-call turn → visible "Tool calls (3)" summary, disclosure closed (`open` attr `None`), 3 `.tool-call` lines in DOM with first not visible, answer present
- pin 2: summary click opens the disclosure; 3 lines visible with pinned text (Listing documents / Listing documents in / Reading)
- pin 3: expanded "Reading" line's computed `display != "flex"` (deboxed inline run, via in-page `getComputedStyle`)
- pin 4: same-context reload restores the turn folded (no auto-expand), answer intact
- KB seed byte-identical mirror of `test_agent_document_tools.py` (2-doc pair + phase-94 registry ordering) so the mock runs the deterministic `ls → ls(scoped) → read` flow.
- Deviation: the task's `MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"` is the *plain grounded* answer tail — the tool flow's answer is `Read <source/path>. <first 80 chars>` and never contains it (a literal wait would time out). Kept the constant name/role, set it to `Read Deployments/example-record-file.json.`; rationale documented in the module docstring.
- Results: `uv run pytest tests/e2e/test_tool_call_compact.py -v --no-cov` → 4 passed (×2 runs, ~8 s each, in isolation, DB up); `uv run pytest` → 2466 passed; `uv run pytest --cov=app --cov-report=term-missing` → 99% on `app/` (>90%); `uv run ruff check . && uv run pyright` → clean (0 errors).
- Footprint: only the new test file; no `app/` change. Adjacent-suite regression E2E (`test_agent_document_tools`, `test_big_read_progress`, `test_thinking_display`, smoke) is task 04's gate, per the task.
- Next pending task: `.agents/phases/todo/117_tool_call_compact/04_verify_and_commit.md`.
@@ -0,0 +1,103 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 8%]
........................................................................ [ 11%]
........................................................................ [ 14%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 26%]
........................................................................ [ 29%]
........................................................................ [ 32%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 46%]
........................................................................ [ 49%]
........................................................................ [ 52%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 64%]
........................................................................ [ 67%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 78%]
........................................................................ [ 81%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 90%]
........................................................................ [ 93%]
........................................................................ [ 96%]
........................................................................ [ 99%]
.................. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain-of-reese/.venv/lib64/python3.14/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.14.7-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 224 1 99%
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 156 1 99%
app/api/git_sources.py 232 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 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 210 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 66 0 100%
app/models.py 128 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 317 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 215 3 99%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 192 3 98%
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 24 0 100%
app/schemas.py 329 0 100%
--------------------------------------------------
TOTAL 4147 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,16 @@
Task 04 complete — the full phase gate is green. Report:
**Phase 117 (tasks 01–03 already landed; task 04 = full gate):**
- Ran the complete regression gate; every command green:
- `uv run pytest` — **2466 passed** (unit + integration)
- `uv run pytest --cov=app --cov-report=term-missing` — **99%** on `app/` (>90% floor held; no `app/` change)
- `uv run pytest tests/e2e/test_tool_call_compact.py -v --no-cov` — **4 passed** (fold / expand / deboxed-flow / restored-folded)
- `uv run pytest tests/e2e/test_agent_document_tools.py -v --no-cov` — **4 passed**
- `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` — **4 passed** (live-gap: disclosure still open, `.tool-elapsed` visible)
- `uv run pytest tests/e2e/test_thinking_display.py -v --no-cov` — **5 passed**
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov` — **3 passed**
- `uv run ruff check . && uv run pyright` — clean (pyright: 0 errors, 0 warnings)
- Manual live check at 390×844 (dev app + mock LLM, screenshots verified): in-flight turn → disclosure **open** (D3); completed turn → one collapsed **"Tool calls (3)"** line; expand → deboxed inline-flow rows, **"Reading" on a single line box** (no "Rea/ding"), path wraps to the **left edge** (wrapped line x=58.0 ≤ label x=77.3); reload → **folded** again. The 6+ stacked cards are gone.
- Working tree scope verified: only `frontend/assets/{app.js,shared.js,styles.css}`, the two test files, and phase/report files — nothing in `app/`, `pyproject.toml`/`uv.lock`, `conftest.py`, or the mock LLM files.
- **Notable:** per harness rules I did NOT commit or move the phase dir — all changes left in the working tree for the harness's atomic commit. `TODO.md` shows a pre-existing owner edit (new retrieval-context idea) already in the tree before this task; I left it untouched (it will ride along in the harness commit). Pre-existing quirk: the earlier brain-wave commit also labels itself "Phase 117" in styles.css — cosmetic, not part of this diff.
- Next pending task: **none** — task 04 is the last task of phase 117; `todo/` holds nothing else.
@@ -0,0 +1,103 @@
........................................................................ [ 2%]
........................................................................ [ 5%]
........................................................................ [ 8%]
........................................................................ [ 11%]
........................................................................ [ 14%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 23%]
........................................................................ [ 26%]
........................................................................ [ 29%]
........................................................................ [ 32%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 46%]
........................................................................ [ 49%]
........................................................................ [ 52%]
........................................................................ [ 55%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 64%]
........................................................................ [ 67%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 78%]
........................................................................ [ 81%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 90%]
........................................................................ [ 93%]
........................................................................ [ 96%]
........................................................................ [ 99%]
.................. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain-of-reese/.venv/lib64/python3.14/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.14.7-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 224 1 99%
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 156 1 99%
app/api/git_sources.py 232 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 139 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 210 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 66 0 100%
app/models.py 128 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 317 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 206 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 123 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 215 3 99%
app/rag/llm.py 244 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 192 3 98%
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 24 0 100%
app/schemas.py 329 0 100%
--------------------------------------------------
TOTAL 4147 16 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
+2
View File
@@ -1 +1,3 @@
# TODO # TODO
- [ ] I want to change the way the initial chat context works. Right now we feed two entire docments into context before the LLM responds. That was a good idea before we had complex tools and document summaries, but now we have faster ways for the LLM to identify what information it needs without reading 2 files. Moving forward, after the question is embedded, I want the top 5 related documents to be suggested to the LLM for reading, with their summaries loaded into context rather than their entire content - basically a "start here if these summaries seem right to you" approach. That way the LLM responds faster and doesn't begin by reading massive amounts of data that might be irrelevant because the similarity search had a bad day.
+47 -6
View File
@@ -942,7 +942,10 @@ function ensureThinkingBlock(wrap) {
// Phase 37: the scratchpad stays the TOP row of the wrap — if tool // Phase 37: the scratchpad stays the TOP row of the wrap — if tool
// lines are already there (a `tool` frame preceded the first // lines are already there (a `tool` frame preceded the first
// `thinking` frame), the block lands above them, not below. // `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 = const anchor =
body.querySelector(".tool-calls-disclosure") ??
body.querySelector(".tool-calls") ?? body.querySelector(".bubble"); body.querySelector(".tool-calls") ?? body.querySelector(".bubble");
body.insertBefore(block, anchor); body.insertBefore(block, anchor);
} }
@@ -954,6 +957,17 @@ function closeThinkingBlock(wrap) {
if (block) block.open = false; // idempotent; no-op without a block if (block) block.open = false; // idempotent; no-op without a block
} }
/* 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
}
/* ---------- tool-call lines (phase 37, PLAN §4 extension) ---------- /* ---------- tool-call lines (phase 37, PLAN §4 extension) ----------
* One visible "calling tool" row per `tool` SSE frame, in the same wrap * One visible "calling tool" row per `tool` SSE frame, in the same wrap
* the Thinking block uses — above the answer, below the Thinking * the Thinking block uses — above the answer, below the Thinking
@@ -967,6 +981,14 @@ function closeThinkingBlock(wrap) {
* HTML-shaped can come from storage. Lines are not interactive (no * HTML-shaped can come from storage. Lines are not interactive (no
* focus targets). * focus targets).
* *
* Phase 117 (owner report 2026-09-15): the list rides in a native
* <details> disclosure (details.tool-calls-disclosure + a plain-text
* "Tool call(s) (N)" summary) — OPEN while the turn is live, folded by
* closeToolCalls (delta/done/stop) and by the restore path (a restored
* turn is one compact line, not N stacked cards). The line bytes, the
* four label literals, and the <code> textContent arguments are
* untouched (phase-87 clock + emoji-guard pins stay green).
*
* Phase 70 (owner permission 2026-09-03): the server tools were remapped * Phase 70 (owner permission 2026-09-03): the server tools were remapped
* to the harness-aligned surface — ls / read(path) / grep(pattern, * to the harness-aligned surface — ls / read(path) / grep(pattern,
* path?) — so the NEW names get their own lines (read → the Reading * path?) — so the NEW names get their own lines (read → the Reading
@@ -981,16 +1003,26 @@ function closeThinkingBlock(wrap) {
function appendToolLine(wrap, name, argument) { function appendToolLine(wrap, name, argument) {
const body = wrap?.querySelector?.(".msg-body"); const body = wrap?.querySelector?.(".msg-body");
if (!body) return; if (!body) return;
let container = body.querySelector(".tool-calls"); // Phase 117 (D2): the lines ride in a native <details> disclosure —
// one compact "Tool call(s) (N)" line instead of N stacked cards.
let container = body.querySelector(".tool-calls-disclosure");
if (!container) { if (!container) {
container = document.createElement("div"); container = document.createElement("details");
container.className = "tool-calls"; container.className = "tool-calls-disclosure";
container.setAttribute("role", "list"); container.open = true; // D3: open while the turn is live; closeToolCalls folds it
container.setAttribute("aria-label", "Tool calls"); 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; below an existing Thinking block (both insert
// before the bubble, so document order is preserved). // before the bubble, so document order is preserved).
body.insertBefore(container, body.querySelector(".bubble")); body.insertBefore(container, body.querySelector(".bubble"));
} }
const list = container.querySelector(".tool-calls");
const line = document.createElement("span"); const line = document.createElement("span");
line.className = "tool-call"; line.className = "tool-call";
line.setAttribute("role", "listitem"); line.setAttribute("role", "listitem");
@@ -1015,7 +1047,12 @@ function appendToolLine(wrap, name, argument) {
} else { } else {
line.textContent = "🔎 Listing documents"; line.textContent = "🔎 Listing documents";
} }
container.appendChild(line); list.appendChild(line);
// Phase 117 (D5): the count rides the summary — every append (live
// frames and the restore path) re-renders through here.
const n = list.children.length;
container.querySelector("summary").textContent =
`Tool call${n === 1 ? "" : "s"} (${n})`;
} }
/* Phase 95 (task 02): the truncation marker on a Reading line. The /* Phase 95 (task 02): the truncation marker on a Reading line. The
@@ -1636,6 +1673,7 @@ function renderStoredMessage(m) {
appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0, Number(t.chars_total) || 0); appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0, Number(t.chars_total) || 0);
} }
} }
closeToolCalls(wrap); // phase 117 (D3): a restored turn renders folded
} }
if (m.deflected) { if (m.deflected) {
wrap.classList.add("is-deflected"); wrap.classList.add("is-deflected");
@@ -2512,11 +2550,13 @@ async function runTurn(text, { reask = false } = {}) {
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming); if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in 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) 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)
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc); wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
// No page scroll (phase 42): the answer never follows the viewport. // No page scroll (phase 42): the answer never follows the viewport.
} else if (ev.type === "done") { } else if (ev.type === "done") {
sawDone = true; sawDone = true;
closeThinkingBlock(wrap); // the turn is over: settle the block closed closeThinkingBlock(wrap); // the turn is over: settle the block closed
closeToolCalls(wrap); // the turn ended — settle the record folded (phase 117, D3)
if (!wrap) { if (!wrap) {
setUiState(UI_STATE.streaming); setUiState(UI_STATE.streaming);
wrap = addMessage("brain", "…"); wrap = addMessage("brain", "…");
@@ -2644,6 +2684,7 @@ async function runTurn(text, { reask = false } = {}) {
// which aborts the same fetch — from saving the partial twice. // which aborts the same fetch — from saving the partial twice.
if (wrap && acc && !persistedOnLeave) { if (wrap && acc && !persistedOnLeave) {
closeThinkingBlock(wrap); // settle the block closed, like `done` closeThinkingBlock(wrap); // settle the block closed, like `done`
closeToolCalls(wrap); // the turn was stopped — fold the record (phase 117, D3)
persistedOnLeave = true; persistedOnLeave = true;
appendTuneButton(wrap); // admin-only; parity with the restore path appendTuneButton(wrap); // admin-only; parity with the restore path
appendStoppedNote(wrap); appendStoppedNote(wrap);
+22 -2
View File
@@ -163,15 +163,32 @@ function addThinkingBlock(wrap, thinking) {
* page shows the truncation pixel-identically to the chat page (the * page shows the truncation pixel-identically to the chat page (the
* phase-50 restore contract). A record saved before phase 95 (no * phase-50 restore contract). A record saved before phase 95 (no
* fields) renders exactly as before (no marker, no migration). * fields) renders exactly as before (no marker, no migration).
* createElement + textContent only — nothing HTML-shaped from storage. */ * createElement + textContent only — nothing HTML-shaped from storage.
*
* Phase 117 (D2/D3): the lines ride the SAME native <details>
* disclosure as the chat page (details.tool-calls-disclosure + a
* plain-text "Tool call(s) (N)" summary) — but a pure render has no
* live turn, so it is created CLOSED: the shared page shows one
* compact line, never an auto-expanded record. The line bytes, the
* four label literals, and the <code> textContent arguments are
* untouched (the parity pins stay green). */
function addToolLines(wrap, tools) { function addToolLines(wrap, tools) {
if (!Array.isArray(tools) || !tools.length) return; if (!Array.isArray(tools) || !tools.length) return;
const body = wrap.querySelector(".msg-body"); const body = wrap.querySelector(".msg-body");
if (!body) return; if (!body) return;
// Phase 117 (D3): the SAME disclosure as the chat page — created
// CLOSED here (pure render, always folded; no live turn to open it).
const disclosure = document.createElement("details");
disclosure.className = "tool-calls-disclosure";
disclosure.open = false; // D3: closed on the shared page (pure render)
const summary = document.createElement("summary");
summary.className = "tool-calls-summary";
disclosure.appendChild(summary);
const container = document.createElement("div"); const container = document.createElement("div");
container.className = "tool-calls"; container.className = "tool-calls";
container.setAttribute("role", "list"); container.setAttribute("role", "list");
container.setAttribute("aria-label", "Tool calls"); container.setAttribute("aria-label", "Tool calls");
disclosure.appendChild(container);
for (const t of tools) { for (const t of tools) {
if (!t || typeof t.name !== "string") continue; if (!t || typeof t.name !== "string") continue;
const line = document.createElement("span"); const line = document.createElement("span");
@@ -213,7 +230,10 @@ function addToolLines(wrap, tools) {
line.appendChild(note); line.appendChild(note);
} }
} }
body.insertBefore(container, body.querySelector(".bubble")); // Phase 117 (D5): the count once, after every line is in.
const n = container.children.length;
summary.textContent = `Tool call${n === 1 ? "" : "s"} (${n})`;
body.insertBefore(disclosure, body.querySelector(".bubble"));
} }
/* "Maybe try:" chips under a deflected bubble (honesty gate, phase /* "Maybe try:" chips under a deflected bubble (honesty gate, phase
+55 -21
View File
@@ -615,42 +615,74 @@ details.thinking .thinking-text {
details.thinking .thinking-text p, details.thinking .thinking-text p,
details.thinking .thinking-text ul { margin: 0 0 0.5rem; } details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* Agent tool-call lines (phase 37): one visible "calling tool" row per /* Phase 117: the tool-call record folds into a single disclosure line,
`tool` SSE frame — in the same wrap as the Thinking block, above the reusing the Thinking block's native <details> convention. Open while
answer, below the Thinking summary. Deliberately distinct from the the turn is live (phase 87's elapsed suffix stays visible), folded at
scratchpad: accent palette (--accent-ink) vs the brand-ink summary, rest / on restore. The summary is a native focusable toggle — it
own icon, own accent left border. Contrast: --accent-ink on the row's inherits the house 3px :focus-visible ring (see details.thinking
--surface ≈10.4:1 (11.6:1 on the page bg), and --ink on --brand-soft summary). Accent rides the TEXT (var(--accent-ink), ≈10.4:1 on the
in the path `code` ≈11.5:1 — all comfortably AA in the (single dark) surface) — text + color, never color alone (B5). No new literal. The
theme. Inline rows only: appending lines never shifts the chat chevron matches details.thinking summary::before so the two
column (72rem — the .container width at every viewport; no new disclosures read as one family — the count text is the accessible
container), and the rows are not interactive — no focus targets. */ label either way. */
.tool-calls-disclosure { margin: 0.25rem 0; }
.tool-calls-summary {
cursor: pointer;
color: var(--accent-ink);
font-size: 0.8rem;
line-height: 1.4;
padding: 0.15rem 0;
list-style: none; /* the native marker is redundant with the count text */
}
.tool-calls-summary::-webkit-details-marker { display: none; }
/* CSS chevron: ▸ rotates 90° when open — the same family treatment as
details.thinking summary::before (the transition stills under
prefers-reduced-motion — see the reduced-motion block below). */
.tool-calls-summary::before {
content: "▸";
display: inline-block;
margin-right: 0.5rem;
transition: transform 0.15s ease;
}
.tool-calls-disclosure[open] .tool-calls-summary::before {
transform: rotate(90deg);
}
/* Agent tool-call lines (phase 37, deboxed in phase 117): one compact
inline-flow row per `tool` SSE frame — in the same wrap as the
Thinking block, above the answer, below the Thinking summary, inside
the phase-117 disclosure. Deliberately distinct from the scratchpad:
accent text (--accent-ink) vs the brand-ink summary. Contrast:
--accent-ink on the surface ≈10.4:1 (11.6:1 on the page bg), and
--ink on the surface in the path `code` ≈11.5:1 — all comfortably AA
in the (single dark) theme. Inline rows only: appending lines never
shifts the chat column (72rem — the .container width at every
viewport; no new container), and the rows are not interactive — no
focus targets (the fold is the disclosure's native summary). */
.tool-calls { .tool-calls {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
} }
/* Phase 117 deboxed the line (owner visual-glitch report 2026-09-15):
no card, no flex — the label + the inline `code` flow as ONE
continuous run, so a long path wraps to the left edge like a normal
sentence and the label no longer breaks mid-word ("Rea/ding" is
gone). The accent now rides the TEXT color (var(--accent-ink))
instead of a border. */
.tool-call { .tool-call {
display: flex;
align-items: baseline;
gap: 0.45rem;
background: var(--surface);
border: 1px solid var(--line);
border-left: 3px solid var(--accent-line);
border-radius: var(--radius-sm);
padding: 0.3rem 0.75rem;
color: var(--accent-ink); color: var(--accent-ink);
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1.4; line-height: 1.4;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
/* Phase 117 dechipped the path: it keeps the mono + ink treatment
(var(--ink) on the surface ≈11.5:1 — AA) but loses the chip
background — it is inline text in the line's run now. */
.tool-call code { .tool-call code {
font-family: var(--mono); font-family: var(--mono);
font-size: 0.95em; font-size: 0.95em;
background: var(--brand-soft);
color: var(--ink); color: var(--ink);
padding: 0.05em 0.35em;
border-radius: 5px;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
/* Phase 87 (TODO.md L5): the latest tool line's visible "processing" /* Phase 87 (TODO.md L5): the latest tool line's visible "processing"
@@ -1253,6 +1285,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.typing span.typing-elapsed { opacity: 1; } .typing span.typing-elapsed { opacity: 1; }
/* Phase 17 thinking block: the chevron stills (no rotation motion). */ /* Phase 17 thinking block: the chevron stills (no rotation motion). */
details.thinking summary::before { transition: none; } details.thinking summary::before { transition: none; }
/* Phase 117 tool-calls disclosure: the same chevron, the same still. */
.tool-calls-summary::before { transition: none; }
} }
/* Phase 117 (owner request 2026-09-15, live-mockup-confirmed — the /* Phase 117 (owner request 2026-09-15, live-mockup-confirmed — the
+424
View File
@@ -0,0 +1,424 @@
"""Phase 117 E2E (Playwright, mock-only): compact, well-wrapped tool-call lines.
Source: owner visual-glitch report (2026-09-15, mobile viewport,
``https://brain.reeseapps.com``) — "how much space the tool calls take
up, and the tool call text is spit and wrapped poorly": one completed
answer stacked 6+ full-width bordered tool-call cards (one per ``tool``
SSE frame), each a complete card (accent left border + surface
background + radius + a mono path chip inside), with the label flex
item breaking mid-word (``Rea``/``ding``) and the path wrapping to
three lines indented to the right of that broken label.
Phase 117's fix (frontend-only, D1–D6 in ``00_phase.md``): the
per-call lines ride in ONE native ``<details>`` disclosure — a compact
"Tool calls (N)" summary, open while the turn is live and FOLDED when
the answer begins (the first ``delta``), on ``done``, on stop/abort,
and on restore — and the lines themselves are deboxed inline-flow
text (no card, no flex: the label + ``<code>`` run as one continuous
sentence and the path wraps to the left edge).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_tool_call_compact.py -v --no-cov
MOCK-ONLY suite: the marker question drives the SAME deterministic
3-call flow ``test_agent_document_tools.py`` pins — ``ls`` →
``ls(scoped)`` → ``read`` (``tests/e2e/mock_llm.py``: the user message
contains ``use your tools`` and the system prompt carries the
``<tools>`` section of the HIGH prompt) — over the byte-identical
two-document KB fixture (mirrored from that suite so the flow is
deterministic). No slow proxy: the fold is about a COMPLETED turn (it
folds on the first delta), so the fast mock is the deterministic
driver.
Completion marker: the mock's single-read flow answers
``Read <source/path>. <first 80 chars of the read document>`` — the
deterministic quote below (``MOCK_ANSWER_MARKER``), NOT the plain
grounded answer's "Deterministic mock answer for E2E" tail (the tool
flow never reaches that branch). Its presence in the bubble proves the
first delta has landed — the turn is COMPLETE and the disclosure has
already folded (phase 117, D3: the fold rides the first delta).
Test → phase mapping (Playwright Mapping Rule):
1. ``test_tool_calls_fold_to_one_line_after_turn`` — pin 1: a
completed 3-call turn renders ONE visible "Tool calls (3)" summary;
the disclosure is CLOSED (no ``open`` attribute); the three
``.tool-call`` lines are present in the DOM but folded (the first
line is not visible); the answer is present.
2. ``test_summary_click_expands_the_calls`` — pin 2: tapping the
native ``<summary>`` (a real focusable toggle, AA) opens the
disclosure; the three lines become visible, in order, with the
phase-37/94 pinned text.
3. ``test_expanded_line_is_deboxed_inline_flow`` — pin 3: the
expanded "Reading" line's computed ``display`` is NOT ``flex``
(the label + path are one inline run, not two flex items — the
debox), and the label runs directly into the path in the same run.
4. ``test_restored_turn_renders_folded`` — pin 4: a same-context
RELOAD (the phase-14/50 persisted conversation restores) renders
the turn FOLDED — closed disclosure, "Tool calls (3)" summary,
three lines in the DOM, first line hidden — no auto-expand on load.
"""
from __future__ import annotations
import hashlib
from collections.abc import Callable
from datetime import UTC, datetime
from playwright.sync_api import 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
# --------------------------------------------------------------------------
# KB fixture — byte-identical to the phase-37 suite (the mirror)
# --------------------------------------------------------------------------
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.69 cosine against the mock's embeddings (E2E threshold 0.30)
#: plus FTS hits, so the turn is solidly grounded (HIGH prompt →
#: ``<tools>``).
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, seeded WITHOUT chunks
#: (indexed + catalogued + readable, but never a retrieval candidate).
#: Its ``(source, path)`` sorts FIRST in the catalog — the line the
#: mock's drill + read land on.
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 SAME marker that drives the mock's 3-tool flow (mirrored from
#: ``test_agent_document_tools.py`` — deterministic ``ls`` →
#: ``ls(scoped)`` → ``read``).
MARKER_QUESTION = (
"Use your tools: what is the exact JSON shape of reeselink.json "
"for my aws route53 hosted zone?"
)
#: The mock's deterministic single-read answer —
#: "Read <source/path>. <first 80 chars of the read document>"
#: (the phase-37 shape). This suite's completion marker: its presence
#: in the bubble proves the FIRST DELTA has landed — the turn is
#: COMPLETE and the disclosure has already folded (phase 117, D3).
MOCK_ANSWER_MARKER = f"Read {READ_SP}."
#: The phase-117 selectors (scoped to the conversation column).
SUMMARY = "#messages .msg.brain .tool-calls-summary"
DISCLOSURE = "#messages .msg.brain .tool-calls-disclosure"
LINES = "#messages .msg.brain .tool-call"
# --------------------------------------------------------------------------
# DB seeding (TRUNCATE-then-seed, cf. test_agent_document_tools.py)
# --------------------------------------------------------------------------
def _seed(db: Session) -> None:
"""The phase-37 two-document pair, byte-identical (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.
db.add(
Document(
source=READ_SOURCE,
path=READ_PATH,
full_path=f"/tmp/{READ_PATH}",
title="Example Record File",
content=RECORD_FILE_CONTENT,
content_hash=hashlib.sha256(RECORD_FILE_CONTENT.encode()).hexdigest(),
indexed_at=datetime.now(UTC),
created_at=datetime(2024, 6, 15, tzinfo=UTC),
)
)
def _reset_db(seed: Callable[[Session], None] | None = None) -> None:
"""Truncate the KB (plus the prompt-shaping tables and the
phase-55 auto-save rows), then re-seed — the E2E isolation
pattern. ``steering_notes`` / ``kb_overview`` are truncated too, so
the HIGH prompt is exactly ``<relevance>`` + ``<documents>`` +
``<tools>`` regardless of leftovers — byte-stable prompts,
byte-stable answers."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, "
"steering_notes, kb_overview, saved_chats, git_sources"
)
)
db.commit()
if seed is not None:
seed(db)
db.commit()
# --------------------------------------------------------------------------
# Page helpers
# --------------------------------------------------------------------------
def _disclosure_open(page: Page) -> bool:
"""The disclosure's open state: a closed ``<details>`` has NO
``open`` attribute (``get_attribute`` → ``None``); an open one
carries it (any value — the native boolean attribute is empty)."""
return page.get_attribute(DISCLOSURE, "open") is not None
def _submit_tools_turn(page: Page, app_url: str) -> None:
"""Log in as admin, submit the marker question, and wait for the
answer bubble — i.e. the turn is COMPLETE (the disclosure has
already folded on the first delta).
Auth: the phase-37 agent-tools E2E's pattern (the mock flow + the
house pattern for this flow win)."""
login(page, app_url, next="/")
page.fill("#message-input", MARKER_QUESTION)
page.click("#send-btn")
# The user bubble lands synchronously with the submit handler.
expect(page.locator(".msg.user .bubble").last).to_contain_text(MARKER_QUESTION)
# The answer's deterministic quote — its presence proves the first
# delta has landed (the fold rides that frame, phase 117 D3)…
expect(
page.locator(".msg.brain .bubble").last
).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
# …and the turn settled: button recovered (phase-48 — the in-flight
# button is the enabled Stop control, so the label assertion carries
# the settle wait).
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
def _expand_disclosure(page: Page) -> None:
"""Tap the summary — the native ``<summary>`` is a real focusable
toggle (AA), so a plain click is the user contract."""
page.locator(SUMMARY).click()
# --------------------------------------------------------------------------
# 1. A completed 3-call turn folds to one "Tool calls (3)" line
# --------------------------------------------------------------------------
def test_tool_calls_fold_to_one_line_after_turn(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Pin 1 (the space fix): after the deterministic ``ls`` →
``ls(scoped)`` → ``read`` turn COMPLETES, the record is ONE visible
"Tool calls (3)" summary — the disclosure is CLOSED (no ``open``
attribute: the first answer delta folded it, D3), the three
``.tool-call`` lines are still in the DOM (the permanent record)
but hidden (the first line is not visible), and the answer bubble
is present."""
page.set_default_timeout(30_000)
_reset_db(_seed)
_submit_tools_turn(page, app_url)
# One compact summary line, visible, with the live count.
summary = page.locator(SUMMARY)
expect(summary).to_be_visible()
expect(summary).to_contain_text("Tool calls (3)")
# Folded at rest: a closed <details> carries no `open` attribute.
assert page.get_attribute(DISCLOSURE, "open") is None
# The lines are present in the DOM (the permanent record) but
# folded: the FIRST line is not rendered.
expect(page.locator(LINES)).to_have_count(3)
expect(page.locator(LINES).first).not_to_be_visible()
# The answer is present (the mock's deterministic single-read quote).
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
MOCK_ANSWER_MARKER
)
# --------------------------------------------------------------------------
# 2. Tapping the summary expands the calls
# --------------------------------------------------------------------------
def test_summary_click_expands_the_calls(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Pin 2 (the expand contract): clicking the native ``<summary>``
opens the disclosure (the ``open`` attribute appears) and all
three ``.tool-call`` lines become visible, in order, with the
phase-37/94 pinned text (the drill line is "Listing documents in
<source>" — the top level lists sources only)."""
page.set_default_timeout(30_000)
_reset_db(_seed)
_submit_tools_turn(page, app_url)
assert not _disclosure_open(page) # folded after the turn (pin 1)
_expand_disclosure(page)
# The native boolean attribute is now present (any value — it is
# empty in the DOM; "true" is the attribute-present shorthand).
assert page.get_attribute(DISCLOSURE, "open") is not None
# All three lines, in order, visible with their pinned text.
lines = page.locator(LINES)
expect(lines).to_have_count(3)
expect(lines.first).to_be_visible()
expect(lines.nth(0)).to_be_visible()
expect(lines.nth(0)).to_contain_text("Listing documents")
expect(lines.nth(1)).to_be_visible()
expect(lines.nth(1)).to_contain_text("Listing documents in")
expect(lines.nth(1)).to_contain_text(READ_SOURCE)
expect(lines.nth(2)).to_be_visible()
expect(lines.nth(2)).to_contain_text("Reading ")
expect(lines.nth(2)).to_contain_text(READ_SP)
# --------------------------------------------------------------------------
# 3. An expanded line is deboxed inline-flow text (not two flex items)
# --------------------------------------------------------------------------
def test_expanded_line_is_deboxed_inline_flow(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Pin 3 (the wrapping fix): on the expanded "Reading" line the
computed ``display`` is NOT ``flex`` — the pre-phase-117 card had
``display: flex; align-items: baseline``, which made the label text
node and the ``<code>`` path two separate flex items (the label
broke mid-word, the path wrapped indented). Deboxed (D4), the label
+ inline ``<code>`` flow as one continuous run: the path wraps to
the left edge like a normal sentence."""
page.set_default_timeout(30_000)
_reset_db(_seed)
_submit_tools_turn(page, app_url)
_expand_disclosure(page)
# The visible "Reading" line (the third of the three).
line = page.locator(LINES).nth(2)
expect(line).to_be_visible()
expect(line).to_contain_text("Reading ")
# The debox: NOT a flex item pair — one inline run. (As a flex item
# of the .tool-calls column its used display blockifies to "block";
# the pre-phase-117 card computed "flex" — the discriminator.)
display = page.evaluate(
"""() => {
const lines = document.querySelectorAll('#messages .msg.brain .tool-call');
const el = Array.from(lines).find(
(l) => l.textContent.includes('Reading '));
return el ? getComputedStyle(el).display : null;
}"""
)
assert display is not None and display != "flex", (
"the expanded .tool-call line must be deboxed (label + path one "
f"inline run, not two flex items) — computed display: {display!r}"
)
# --------------------------------------------------------------------------
# 4. A reload restores the turn FOLDED (no auto-expand on load)
# --------------------------------------------------------------------------
def test_restored_turn_renders_folded(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""Pin 4 (the restore path): after the turn completes (the
phase-50/55 auto-save persisted it — the local ``bor.chat.v1``
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
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)."""
page.set_default_timeout(30_000)
_reset_db(_seed)
_submit_tools_turn(page, app_url)
page.reload()
expect(page.locator("#empty-state")).to_be_hidden(timeout=30_000)
# The restored brain message's answer (the restore is synchronous at
# boot — the persisted record is byte-stable for this fixture).
expect(
page.locator(".msg.brain .bubble").last
).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
# Folded on load: one compact summary line, disclosure closed.
expect(page.locator(SUMMARY)).to_be_visible()
expect(page.locator(SUMMARY)).to_contain_text("Tool calls (3)")
assert page.get_attribute(DISCLOSURE, "open") is None
# The three lines are in the DOM (the permanent record) but folded:
# the first line is not rendered — no auto-expand.
expect(page.locator(LINES)).to_have_count(3)
expect(page.locator(LINES).first).not_to_be_visible()
+6 -5
View File
@@ -248,20 +248,21 @@ def test_thinking_block_stays_on_top_of_tool_lines() -> None:
def test_tool_call_style_is_accent_and_contrast_safe() -> None: def test_tool_call_style_is_accent_and_contrast_safe() -> None:
"""styles.css: .tool-call is an inline row with the accent palette """styles.css (phase 117 debox): .tool-call is a deboxed
(distinct from the brand-ink Thinking block) and mono `code` styling inline-flow row — the label + the mono `code` path are ONE
for the path; the wrapper stacks lines without shifting the column.""" continuous wrapping run (no flex, no card), the accent rides the
TEXT color (distinct from the brand-ink Thinking block), and the
`code` is inline mono text with no chip; the wrapper stacks lines
without shifting the column."""
css = _css() css = _css()
assert ".tool-calls" in css assert ".tool-calls" in css
assert ".tool-call" in css assert ".tool-call" in css
m = re.search(r"\.tool-call \{([^}]*)\}", css) m = re.search(r"\.tool-call \{([^}]*)\}", css)
assert m, "the .tool-call rule must exist" assert m, "the .tool-call rule must exist"
row = m.group(1) row = m.group(1)
assert "display: flex" in row, "inline row: icon + text"
assert "var(--accent-ink)" in row, ( assert "var(--accent-ink)" in row, (
"accent color distinguishes it from the thinking block (≈10.4:1 on surface)" "accent color distinguishes it from the thinking block (≈10.4:1 on surface)"
) )
assert "var(--accent-line)" in row, "accent left border"
code = re.search(r"\.tool-call code \{([^}]*)\}", css) code = re.search(r"\.tool-call code \{([^}]*)\}", css)
assert code, "the path `code` must be styled" assert code, "the path `code` must be styled"
assert "var(--mono)" in code.group(1) assert "var(--mono)" in code.group(1)
+249
View File
@@ -0,0 +1,249 @@
"""Unit: the phase-117 tool-calls DISCLOSURE contract (task 01).
The owner's visual-glitch report (2026-09-15): one per-call tool "card"
per ``tool`` frame — 6+ stacked full-width bordered cards on a phone —
swamped a completed answer, and the flex row broke the label
mid-word. The fix (phase 117, owner decisions D1–D6) wraps the
existing ``.tool-calls`` list in a native ``details`` disclosure with a
plain-text "Tool call(s) (N)" summary — created OPEN on the first live
``tool`` frame, folded at rest (delta/done/stop via ``closeToolCalls``),
and rendered CLOSED on the restore path and the shared page (D3). The
line bytes, the four label literals, and the ``<code>`` textContent
arguments stay byte-identical (D6), so the phase-37/70/87/95 pins
stay green.
No ``app/`` logic (frontend-only, D1) — like
``test_frontend_tool_states.py``, this module pins the JS/CSS markers
at source level: the disclosure/summary build (task 01) and the
debox/dechip + disclosure CSS contract (task 02). The E2E story suite
(task 03) completes the phase.
"""
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"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
#: The four pinned tool-line label literals (D6) — the emoji guard and
#: the phase-37/70 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 _shared_js() -> str:
return SHARED_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.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 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
# 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"
)
# 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"
)
assert "innerHTML" not in body, (
"no HTML injection surface on tool lines — textContent only"
)
def test_append_tool_line_keeps_the_four_pinned_labels() -> None:
"""D6: the four ``line.textContent`` label literals and the
``<code>`` textContent arguments stay byte-identical — the emoji
guard, the phase-37/70 unit pins, and the E2E text assertions all
key off these exact bytes."""
body = _fn(_js(), "appendToolLine")
for label in PINNED_LABELS:
assert label in body, f"pinned label literal changed: {label!r}"
assert "code.textContent = argument" in body, (
"the path/pattern/scope is data — textContent, never innerHTML"
)
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
handler (the turn ended), the stop/abort settle (the turn was
stopped), and the restore path — so the definition + ≥4 calls."""
js = _js()
body = _fn(js, "closeToolCalls")
assert 'querySelector?.(".tool-calls-disclosure")' in body, (
"targets the disclosure of the given wrap only"
)
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"
)
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)."""
js = _js()
fn = js.find("function renderStoredMessage")
assert fn != -1
body = js[fn : js.find("function restoreConversation", fn)]
assert "appendToolLine(wrap, t.name, arg)" in body
assert "closeToolCalls(wrap)" in body
assert (
body.find("appendToolLine(wrap, t.name, arg)")
< body.find("closeToolCalls(wrap)")
), "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_shared_page_add_tool_lines_builds_a_closed_disclosure() -> None:
"""shared.js addToolLines (D3 parity): the SAME
``details.tool-calls-disclosure`` + summary + ``.tool-calls`` list —
but created CLOSED (a pure render has no live turn to open it),
with the count set once at the end. The four label literals, the
three textContent-only argument lines, and the phase-95 marker stay
byte-identical (no innerHTML anywhere)."""
body = _fn(_shared_js(), "addToolLines")
assert "document.createElement(\"details\")" in body
assert "className = \"tool-calls-disclosure\"" in body
assert "document.createElement(\"summary\")" in body
assert "open = false" in body, (
"D3: the shared page renders the disclosure CLOSED — never an "
"auto-expanded record"
)
assert "className = \"tool-calls\"" in body
for label in PINNED_LABELS:
assert label in body, f"pinned label literal changed: {label!r}"
assert "Tool call${n === 1 ? \"\" : \"s\"} (${n})" in body, (
"the same plain-text count template (D5), set once at the end"
)
assert body.count("code.textContent = argument") == 3, (
"all three argument-bearing lines (read / grep / ls) stay "
"textContent-only"
)
assert "innerHTML" not in body, (
"no HTML injection surface on shared tool lines — textContent only"
)
def test_tool_call_rule_is_deboxed_inline_flow() -> None:
"""D4: the ``.tool-call`` rule is DEBOXED — no ``display: flex``,
no card (no background / border / radius / padding), and no
``var(--accent-line)`` left border. The accent now rides the TEXT
(``var(--accent-ink)``, ≈10.4:1 on the surface), and the line keeps
the compact status metrics + ``overflow-wrap: anywhere`` so the
label + inline ``code`` flow as ONE continuous run — a long path
wraps to the left edge and the label never breaks mid-word (the
"Rea/ding" glitch is gone)."""
m = re.search(r"\.tool-call \{([^}]*)\}", _css())
assert m, "the .tool-call rule must exist"
row = m.group(1)
assert "display: flex" not in row, (
"deboxed: no flex — the label + code are one inline run"
)
assert "var(--accent-line)" not in row, "deboxed: no accent left border"
assert "border" not in row, "deboxed: no card borders at all"
assert "background" not in row, "deboxed: no card background"
assert "padding" not in row, "deboxed: no card padding"
assert "var(--accent-ink)" in row, (
"the accent now rides the text color (≈10.4:1 on the surface)"
)
assert "font-size: 0.8rem" in row, "the compact status metrics stay"
assert "overflow-wrap: anywhere" in row, "the path wraps inside the run"
def test_tool_call_code_rule_is_dechipped() -> None:
"""D4: the ``.tool-call code`` rule KEEPS ``var(--mono)`` +
``var(--ink)`` (≈11.5:1 on the surface — AA) but LOSES the chip
(no background, no padding, no radius) — the path is inline text
in the line's run now."""
m = re.search(r"\.tool-call code \{([^}]*)\}", _css())
assert m, "the .tool-call code rule must exist"
code = m.group(1)
assert "var(--mono)" in code
assert "var(--ink)" in code
assert "background" not in code, "dechipped: no chip background"
assert "padding" not in code, "dechipped: no chip padding"
assert "border-radius" not in code, "dechipped: no chip radius"
assert "overflow-wrap: anywhere" in code
def test_tool_calls_disclosure_and_summary_rules_exist() -> None:
"""Phase 117 (D2/D4): the NEW ``.tool-calls-disclosure`` and
``.tool-calls-summary`` rules exist — the summary is a native
focusable toggle carrying the accent TEXT (``var(--accent-ink)``,
≈10.4:1 on the surface; text + color, never color alone, B5),
modeled on the details.thinking family, with no new color literal
(the phase-92 zero-literal invariant)."""
css = _css()
assert re.search(r"\.tool-calls-disclosure \{", css), (
"the .tool-calls-disclosure rule must exist"
)
m = re.search(r"\.tool-calls-summary \{([^}]*)\}", css)
assert m, "the .tool-calls-summary rule must exist"
summary = m.group(1)
assert "var(--accent-ink)" in summary, (
"the accent rides the summary text (≈10.4:1 on the surface)"
)
assert "cursor: pointer" in summary, "a toggle affordance"