phase: 160_tool_budget_telemetry
Build and Push Containers / build-and-push-app (push) Successful in 2m27s
Build and Push Containers / build-and-push-db (push) Failing after 2m8s

All green. Final report:

**Phase 160 completion pass — verified (final verification; all 5 tasks already in `complete/`)**

- **Verified, no code defects found:** budget trailers (`TOOL_BUDGET_REMAINING/LAST/SPENT`), cap-path rejection + `ROUND_CAP_FALLBACK_ANSWER` + `ROUND_CAP_WRAPUP_INSTRUCTION`, `done.budget_exhausted` (ABSENT-when-false), `rounds=/cap_hit=` log fields, `agent_max_rounds=10` default, frontend Continue button + restore + shared-page exclusion, mock markers.
- **Tests/lint:** `uv run pytest` 153/153 green (EXIT 0); `uv run pytest --cov=app --cov-report=term-missing` TOTAL **99%** (>90%); `uv run ruff check .` clean; `uv run pyright` 0 errors/0 warnings.
- **Criteria:** ① decreasing trailer pinned by unit tests (9→…→spent) ✅ ② cap path ends readable, no tool lines after cap (E2E) ✅ ③ `budget_exhausted` true-only-on-cap, absent otherwise (integration) ✅ ④ Continue button → one fresh `continue` turn, fresh budget, record intact (E2E) ✅ ⑤ flag survives refresh; shared chat never renders it (`shared.js` has no `budget_exhausted` path) ✅ ⑥ `max_rounds=0` byte-identical single `tools=None` request; default still 10 ✅ ⑦ full suite/coverage/lint green ✅ ⑧ `test_tool_budget_telemetry.py` **7/7** in isolation; all 6 regression E2E suites green in isolation ✅ ⑨ commit + phase move = harness (I make no git history/index changes).
- **Docs:** README.md — fixed: the "Continue researching" button and cap behavior were undocumented (added a Chat-section paragraph + an Agent-tools-section paragraph, incl. the phase-71 recorded-revision note); env-var table, commands, and architecture all verified consistent otherwise. AGENTS.md — verified consistent, no changes needed.
- **Decisions:** no code changes required — phase was fully implemented; only README docs added. No live-infrastructure touched.
- **Next pending phase:** `161_reasoning_bubble`.
This commit is contained in:
2026-10-06 01:42:44 -04:00
parent cb8767512a
commit 8172956c15
55 changed files with 4124 additions and 344 deletions
+546
View File
@@ -0,0 +1,546 @@
"""Unit: the phase-160 (task 04, TODO L4) "Continue researching" button.
When a turn ends because its tool budget was spent (the ``done``
frame's additive ``budget_exhausted`` key, task 03 — ABSENT on every
non-cap turn), the answer bubble offers **Continue researching**: one
click sends the next turn (a plain send — the budget is per-turn, so
the fresh turn has a whole new round budget) and the marker persists
with the record so a page refresh restores the affordance. The shared
page never shows the button (read-only by construction).
Pins (the house source-pin pattern — no browser):
* ``CONTINUE_ICON`` / ``CONTINUE_QUESTION`` — the inline aria-hidden
SVG in the RETRY_ICON stroke family (no icon font, no CDN — A11)
and the literal continuation word (what the model is taught to ask
the user for);
* ``appendContinueButton`` — the house pattern through
``getActionsRow`` (the role=list row joins the button as a
listitem), the one-per-bubble guard, click → ``continueTurn()``;
* ``continueTurn`` — the ``retryLastTurn`` discipline: no-op while a
turn is in flight, every ``.continue-btn`` removed FIRST, then a
PLAIN ``runTurn(CONTINUE_QUESTION)`` (no ``reask``);
* the ``done`` settle — ``budget_exhausted`` rides
``rememberBrainTurn``'s meta (``undefined`` drops the key, so every
non-cap record stays byte-identical) and the button is appended
ONLY when the frame is flagged, in the row order Tune → Retry →
Continue → Save-as-doc (the insertion order phase 130 relies on);
* the restore branch — keys on ``m.budget_exhausted``;
* ``startNewChat`` needs nothing — its list reset drops the buttons;
* ``retryLastTurn`` / ``markLastRetryable`` / ``rememberBrainTurn``
are byte-unchanged (the phase-120 shared pin for the first);
* the shared page (work item 8) — ``shared.html`` never loads
``app.js`` and ``shared.js`` builds no buttons / actions rows, so a
flagged record renders without any Continue control there.
The browser-level assertions (the live click running a fresh turn, the
reload restoring the button, the shared page's read-only render) are
E2E-gated by tests/e2e/test_tool_budget_telemetry.py (task 05).
"""
from __future__ import annotations
from pathlib import Path
from tests.unit.test_frontend_failed_turn import PINNED_RETRY_LAST_TURN
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
SHARED_HTML = FRONTEND / "shared.html"
SHARED_JS = FRONTEND / "assets" / "shared.js"
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _fn_body(js: str, header: str) -> str:
"""The full source of the function whose header is *header* —
brace-matched from the first ``{`` OUTSIDE the parameter list
(paren depth 0, the house convention), so destructured parameters
are skipped (the pinned functions carry no string literal with a
stray brace)."""
start = js.index(header)
i = start
paren = 0
while i < len(js):
c = js[i]
if c == "(":
paren += 1
elif c == ")":
paren -= 1
elif c == "{" and paren == 0:
break
i += 1
depth = 0
for j in range(i, len(js)):
c = js[j]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return js[start : j + 1]
raise AssertionError(f"unbalanced braces in {header!r}")
def _done_branch(js: str) -> str:
"""The SSE ``done`` branch — from the branch test to the next
``else if`` (flat block, the slice is exact)."""
start = js.index('ev.type === "done"')
return js[start : js.index('ev.type === "tool_result"', start)]
# ---------------------------------------------------------------------------
# the glyph + the continuation word
# ---------------------------------------------------------------------------
def test_continue_icon_is_an_inline_svg_in_the_stroke_family() -> None:
"""``CONTINUE_ICON`` is the inline ``aria-hidden`` SVG in the
RETRY_ICON stroke family (task 01: 24×24, ``fill="none"``,
``stroke="currentColor"``, ``stroke-width="1.7"`` — the CSS themes
the stroke) — no icon font, no CDN (A11)."""
js = _js()
start = js.index("const CONTINUE_ICON =")
seg = js[start :]
q1 = seg.index("'")
q2 = seg.index("'", q1 + 1)
literal = seg[q1 + 1 : q2] # the first string literal = the inline SVG
for attr in (
'aria-hidden="true"',
'viewBox="0 0 24 24"',
'fill="none"',
'stroke="currentColor"',
'stroke-width="1.7"',
):
assert attr in literal, f"the glyph keeps the shared family: {attr!r}"
assert literal.startswith("<svg") and literal.endswith("</svg>")
def test_continue_question_is_the_literal_word_continue() -> None:
"""``CONTINUE_QUESTION`` is the literal word ``continue`` — the
word the model is taught to ask the user for (task 02's
ROUND_CAP_WRAPUP_INSTRUCTION / the spent-budget trailer), so the
visible bubble, the typed equivalent, and the model's own
suggestion all agree."""
js = _js()
assert 'const CONTINUE_QUESTION = "continue";' in js
def test_continue_icon_is_defined() -> None:
"""EXACTLY ONE ``CONTINUE_ICON`` constant (the glyph is shared by
the button, never duplicated)."""
js = _js()
assert js.count("const CONTINUE_ICON =") == 1
assert js.count("const CONTINUE_QUESTION =") == 1
# ---------------------------------------------------------------------------
# appendContinueButton — the house pattern
# ---------------------------------------------------------------------------
def test_append_continue_button_joins_the_actions_row() -> None:
"""``appendContinueButton(wrap)`` keeps the house pattern (the
appendRetryButton shape, task 02): the getActionsRow lookup (the
dedicated actions row, NEVER the chips row), the null no-op, the
one-per-bubble guard (``.continue-btn``), the role=list →
listitem ARIA join, the icon + the visible "Continue researching"
label (the label carries the accessible name), and the click →
``continueTurn()`` binding."""
js = _js()
body = _fn_body(js, "function appendContinueButton(wrap) {")
assert "getActionsRow(wrap)" in body, "the shared get-or-create helper"
assert "if (!meta) return" in body, "no-op without a .msg-body"
assert 'querySelector(".continue-btn")' in body, (
"the one-per-bubble guard"
)
assert 'meta.getAttribute("role") === "list"' in body, (
"the role=list check (the row is always role=list)"
)
assert 'setAttribute("role", "listitem")' in body, "the listitem join"
assert 'btn.className = "continue-btn"' in body
assert 'btn.innerHTML = CONTINUE_ICON + "<span>Continue researching</span>"' in body, (
"icon + the visible label (the accessible name)"
)
assert "btn.addEventListener(\"click\", () => continueTurn())" in body, (
"the click handler — a plain continueTurn() (no wrap, no args)"
)
assert "meta.appendChild(btn)" in body
# NOT admin-gated, like Retry (chat is public).
assert "isAdmin" not in body
def test_continue_button_lands_after_the_retry_button() -> None:
"""``appendContinueButton`` is defined DIRECTLY AFTER
``appendRetryButton`` (task 02's placement — the sibling in the
actions-row family, before the Edit button's block)."""
js = _js()
retry = js.index("function appendRetryButton(wrap) {")
cont = js.index("function appendContinueButton(wrap) {")
edit = js.index("function appendEditButton(wrap) {")
assert retry < cont < edit
# ---------------------------------------------------------------------------
# continueTurn — the retryLastTurn discipline
# ---------------------------------------------------------------------------
def test_continue_turn_bails_and_clears_and_sends_plain() -> None:
"""``continueTurn()`` (task 03): (1) no-op while a turn is in
flight (the ``retryLastTurn`` guard — one turn at a time, no
double-send); (2) clears EVERY rendered ``.continue-btn`` FIRST
(the markLastRetryable remove-first pattern — a stale button on an
earlier bubble is gone the moment the new turn's answer lands);
(3) a PLAIN send — ``runTurn(CONTINUE_QUESTION)`` with NO
``reask`` argument (the default ``false``): the user bubble
appears, the record is pushed + saved at the ordinary save
points, and the request carries the full history."""
js = _js()
body = _fn_body(js, "function continueTurn() {")
assert (
"if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;"
in body
), "the in-flight no-op (the retryLastTurn guard)"
assert (
'messagesEl.querySelectorAll(".continue-btn").forEach((b) => b.remove());'
in body
), "every continue button is cleared before the send"
assert "return runTurn(CONTINUE_QUESTION)" in body, (
"a PLAIN send — the returned promise (the retryLastTurn "
"convention)"
)
assert "reask" not in body, (
"NO reask argument — a plain send (runTurn's default false)"
)
order = [
body.index("UI_STATE.thinking"),
body.index(".remove()"),
body.index("runTurn(CONTINUE_QUESTION)"),
]
assert order == sorted(order), (
"guard → clear-every-button → send, in that order"
)
def test_continue_turn_sends_one_plain_turn_with_full_history() -> None:
"""The plain-send contract on the ``runTurn`` side (task 03's
save-point list): with ``reask`` defaulting to false, the turn
appends the user bubble, pushes ``{ who: "user", text }`` (no
image — a text-only record), runs the ordinary save points
(``saveConversation`` + ``persistConversation``), and the request's
``history`` is the FULL ``conversation.slice(0, -1)`` — the model
resumes with everything it already read."""
js = _js()
rt = _fn_body(js, "function runTurn(text, { reask = false, image = null } = {}) {")
assert "if (!reask) {" in rt, "the default is a plain send"
assert 'conversation.push(' in rt
assert 'saveConversation();' in rt
assert "persistConversation();" in rt
assert "const history = conversation.slice(0, -1).map(" in rt, (
"the request carries the full history (everything except the "
"just-pushed question)"
)
# ---------------------------------------------------------------------------
# the done settle — the flag rides the record + the button is gated
# ---------------------------------------------------------------------------
def test_done_handler_persists_the_flag_and_appends_the_button() -> None:
"""The ``done`` settle (task 04): ``budget_exhausted`` rides
``rememberBrainTurn``'s meta as
``ev.budget_exhausted || undefined`` (the ``answer_image``
precedent — ``undefined`` drops the key from the JSON, so every
non-cap record stays byte-identical), every earlier bubble's
continue button is cleared FIRST (the completion criterion —
"neither does any earlier answer once a newer turn lands" — the
markLastRetryable remove-first pattern), and the button is
appended ONLY when the frame is flagged."""
js = _js()
branch = _done_branch(js)
assert "budget_exhausted: ev.budget_exhausted || undefined" in branch, (
"the flag persists with the turn (undefined drops the key)"
)
assert (
'messagesEl.querySelectorAll(".continue-btn").forEach((b) => b.remove());'
in branch
), "every earlier bubble's continue button is cleared on the settle"
assert "if (ev.budget_exhausted) appendContinueButton(wrap);" in branch, (
"the button is appended ONLY on a flagged done"
)
order = [
branch.index('querySelectorAll(".continue-btn")'),
branch.index("appendContinueButton(wrap)"),
]
assert order == sorted(order), "clear-every-button → gated append"
def test_done_branch_row_order_is_tune_retry_continue_save() -> None:
"""The row order on a capped answer (task 04): Tune → Retry (the
markLastRetryable re-append) → Continue (flag-gated) → Save-as-doc
— the INSERTION order phase 130 relies on (the DOM order inside
the row is the visual order)."""
js = _js()
branch = _done_branch(js)
order = [
branch.index("appendTuneButton(wrap);"),
branch.index("markLastRetryable();"),
branch.index("appendContinueButton(wrap);"),
branch.index('appendSaveAsDocButton(wrap, finalText || acc || "…");'),
]
assert order == sorted(order), (
"done: Tune → Retry → Continue → Save-as-doc (the insertion "
"order phase 130 relies on)"
)
def test_continue_button_is_not_added_on_the_other_settle_paths() -> None:
"""The Continue button is a CAP-REACHED-DONE affordance only: the
zero-frame fallback, the stop finalize, and the failed-turn funnel
(none of them sees a ``budget_exhausted`` frame — a failed or
stopped turn never hit the cap) append NO continue button, and the
``done`` branch's flag-gate is the single gate."""
js = _js()
assert js.count("appendContinueButton(wrap);") == 2, (
"exactly TWO call sites (with the terminating semicolon — the "
"function header ends in a brace, not a semicolon): the done "
"settle + the restore path"
)
zero = js.index("if (!aborted && !wrap) {")
block = js[zero : js.index("} catch (err) {", zero)]
assert "appendContinueButton" not in block, (
"the zero-frame fallback (a failed turn) adds no continue button"
)
funnel = _fn_body(
js,
"function finalizeFailedTurn(\n"
" detail,\n"
" { acc, thinking, tools, segments, wrap, leavePartialIndex }\n"
") {",
)
assert "appendContinueButton" not in funnel, (
"the failed-turn funnel adds no continue button"
)
# ---------------------------------------------------------------------------
# the restore path + startNewChat
# ---------------------------------------------------------------------------
def test_restore_branch_keys_on_the_persisted_marker() -> None:
"""The phase-14 restore (``renderStoredMessage``'s brain branch,
task 05): the Continue affordance restores from the persisted
``m.budget_exhausted`` marker — a reload keeps the button on a
flagged answer (a restored ``history`` is exactly what the
continuation turn needs), and a pre-phase / non-cap record (no
key) restores byte-identically (no button). The append lands
BEFORE the ``lastBrainWrap`` assignment, after the per-message
appends (the actions row is the bubble's LAST row, as on the
live done path)."""
js = _js()
body = _fn_body(js, "function renderStoredMessage(m) {")
assert "if (m.budget_exhausted) appendContinueButton(wrap);" in body, (
"the restore keys on the persisted marker"
)
order = [
body.index("appendFailedNote(wrap, m.error);"),
body.index("if (m.budget_exhausted) appendContinueButton(wrap);"),
body.index("lastBrainWrap = wrap"),
]
assert order == sorted(order), (
"restore: the per-message appends → the continue button → "
"lastBrainWrap (the after-the-loop markLastRetryable lands the "
"Retry in the same actions row, as on the live path)"
)
def test_start_new_chat_unchanged_drops_the_buttons_with_the_list() -> None:
"""``startNewChat`` (task 05: needs nothing) — its list reset
removes every ``.msg`` bubble, so the continue buttons die with
the list (the phase-49 reasoning). The reset + the in-flight
guard stay byte-pinned."""
js = _js()
body = _fn_body(js, "function startNewChat() {")
assert (
"if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;"
in body
), "the in-flight guard stays"
assert 'messagesEl.querySelectorAll(".msg").forEach((el) => el.remove());' in body, (
"the list reset drops the buttons along with the list"
)
assert "appendContinueButton" not in body, (
"no continue-button wiring was added to startNewChat"
)
# ---------------------------------------------------------------------------
# NOT touched — the phase's explicit contract (byte-pinned sources)
# ---------------------------------------------------------------------------
#: The FULL source of ``markLastRetryable`` as of pre-phase-160 task
#: 04 — the remove-then-append shape (remove every rendered
#: .retry-btn FIRST, then re-append on the last brain bubble) is
#: UNCHANGED: the continue button has its OWN clear pass in
#: continueTurn, and markLastRetryable's targeting is untouched.
PINNED_MARK_LAST_RETRYABLE = """function markLastRetryable() {
messagesEl.querySelectorAll(".retry-btn").forEach((b) => b.remove());
if (!lastBrainWrap) return;
let lastIdx = -1;
for (let i = conversation.length - 1; i >= 0; i -= 1) {
if (conversation[i].who === "brain") {
lastIdx = i;
break;
}
}
const prev = lastIdx > 0 ? conversation[lastIdx - 1] : null;
if (!prev || prev.who !== "user") return;
appendRetryButton(lastBrainWrap);
}"""
#: The FULL source of ``rememberBrainTurn`` as of pre-phase-160 task
#: 04 — the ``...meta`` spread means the new ``budget_exhausted``
#: optional key rides the record with ZERO change to this helper
#: (``undefined`` drops the key from the JSON, the house
#: optional-meta pattern).
PINNED_REMEMBER_BRAIN_TURN = """function rememberBrainTurn(rawText, meta, replaceIndex = -1) {
const rec = { who: "brain", text: rawText || "…", ...meta };
if (replaceIndex >= 0 && conversation[replaceIndex]?.who === "brain") {
conversation[replaceIndex] = rec;
} else {
conversation.push(rec);
}
saveConversation();
// Phase 55 (A2): the auto-save rides the brain save point — the row
// updates with the (possibly replaced) brain turn + metadata. The
// pagehide partial reuses this helper, so it rides the same path (no
// extra wiring).
persistConversation();
}"""
def test_mark_last_retryable_is_byte_unchanged() -> None:
"""``markLastRetryable`` is byte-unchanged by phase 160 (task 04
— the redo-in-place targeting is REUSED, not extended): the full
source must match the pin (a diff is a contract violation)."""
assert _fn_body(_js(), "function markLastRetryable() {") == PINNED_MARK_LAST_RETRYABLE
def test_remember_brain_turn_is_byte_unchanged() -> None:
"""``rememberBrainTurn`` is byte-unchanged by phase 160 (task 04 —
the ``budget_exhausted`` meta key rides the existing ``...meta``
spread, so the save point is consumed as-is): the full source must
match the pin."""
assert _fn_body(_js(), "function rememberBrainTurn(rawText, meta, replaceIndex = -1) {") == (
PINNED_REMEMBER_BRAIN_TURN
)
def test_retry_last_turn_is_byte_unchanged() -> None:
"""``retryLastTurn`` is byte-unchanged by phase 160 (task 04 — the
continue button REUSES its discipline, the function is consumed
as-is): the full source must match the phase-120 pin (the shared
constant — one pin, no drift)."""
assert _fn_body(_js(), "function retryLastTurn(wrap) {") == PINNED_RETRY_LAST_TURN
# ---------------------------------------------------------------------------
# the shared page (work item 8) — the Continue control never renders there
# ---------------------------------------------------------------------------
def test_shared_page_never_shows_the_continue_control() -> None:
"""Work item 8 (no code change expected): the shared page
(``/shared/<token>``, served server-side — ``shared.html``)
renders the conversation through ``shared.js``'s OWN read-only
renderer, never through ``app.js`` — so the ``budget_exhausted``
key is IGNORED there by construction: the page loads no
``app.js`` (no ``appendContinueButton`` in its script graph),
and ``shared.js`` builds NO buttons, no forms, and no
``.msg-actions`` row (owner-locked: zero interactive controls —
the "no buttons, no forms, no links, no click handlers"
contract), so a flagged record renders without any Continue
affordance."""
html = SHARED_HTML.read_text(encoding="utf-8")
assert "app.js" not in html, (
"the shared page never loads app.js — its action buttons "
"(incl. Continue) are not in its script graph"
)
shared = SHARED_JS.read_text(encoding="utf-8")
assert "continue-btn" not in shared
assert "msg-actions" not in shared, (
"shared.js never builds the actions row — zero controls"
)
assert 'createElement("button")' not in shared, (
"owner-locked: NO buttons are ever created on the shared page"
)
assert 'addEventListener("click"' not in shared, (
"owner-locked: NO click handlers on the shared page (the img "
"load-failure degradation handler is not a control — the "
"zero-interactive-controls contract)"
)
# ---------------------------------------------------------------------------
# styles.css — the .continue-btn family
# ---------------------------------------------------------------------------
def test_continue_btn_css_is_the_retry_family() -> None:
"""``.continue-btn`` is the exact visual family of ``.retry-btn``
(task 07): the same pill size/spacing (``min-height: 44px``,
``gap: 0.35rem``, ``padding: 0.35rem 0.8rem``,
``border-radius: 999px``, ``border: 1px solid var(--line)``),
the same neutral color (``--ink-soft`` → ``--ink`` on hover over
``--brand-soft``), the 14px currentColor glyph, and the shared
GLOBAL ``:focus-visible`` ring (styles.css L162 — the ring rule is
not per-class, so no selector-list extension is needed). No
animation of its own → the ``prefers-reduced-motion`` blocks need
nothing for it (the family's parity — .retry-btn / .edit-btn have
none either)."""
css = _css()
start = css.index(".continue-btn {")
end = css.index("}", start)
block = css[start : end + 1]
for decl in (
"display: inline-flex;",
"gap: 0.35rem;",
"min-height: 44px;",
"padding: 0.35rem 0.8rem;",
"border-radius: 999px;",
"border: 1px solid var(--line);",
"background: transparent;",
"color: var(--ink-soft);",
"font-weight: 600;",
"font-size: 0.82rem;",
"white-space: nowrap;",
"cursor: pointer;",
):
assert decl in block, f"the pill family keeps {decl!r}"
assert ".continue-btn svg { width: 14px; height: 14px; display: block; }" in css
assert ".continue-btn:hover { background: var(--brand-soft); color: var(--ink); }" in css
def test_continue_btn_mobile_block_min_height() -> None:
"""The mobile block (task 07): ``.continue-btn`` joins the
``.tune-btn`` / ``.retry-btn`` / ``.edit-btn`` 44px min-height
lockstep next to ``.retry-btn`` — EXACTLY ONE such single-line
rule (the base rule is a multi-line block, so the one-line form
occurs only in the mobile media block)."""
css = _css()
assert css.count(".continue-btn { min-height: 44px; }") == 1
idx = css.index(".continue-btn { min-height: 44px; }")
# It sits beside the family's other lockstep lines (near them in
# the same mobile block).
retry = css.index(".retry-btn { min-height: 44px; }")
assert abs(idx - retry) < 200, (
"the continue line is beside .retry-btn in the mobile block"
)