Files
brain-of-reese/.agents/phases/complete/117_tool_call_compact/00_phase.md
T
ducoterra c851d1a1c5
Build and Push Containers / build-and-push-app (push) Successful in 2m2s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 117_tool_call_compact
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
2026-09-15 18:26:38 -04:00

19 KiB
Raw Blame History

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.