phase: 160_tool_budget_telemetry
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:
+572
-235
File diff suppressed because it is too large
Load Diff
@@ -1578,6 +1578,8 @@ def test_endpoint_log_line_records_suggested_after_summary_hits(
|
||||
"sources=",
|
||||
"thinking_chars=",
|
||||
"tool_calls=",
|
||||
"rounds=",
|
||||
"cap_hit=",
|
||||
"total_ms=",
|
||||
"retries=",
|
||||
"scaffold_stripped=",
|
||||
|
||||
@@ -491,7 +491,9 @@ def test_chat_message_image_defaults_to_none_and_is_omitted() -> None:
|
||||
assert m.image is None
|
||||
dumped = m.model_dump()
|
||||
assert "image" not in dumped # ABSENT, never ``null`` (the phase-50 contract)
|
||||
# and every pre-phase key set is untouched
|
||||
# and every pre-phase key set is untouched (the phase-160 additive
|
||||
# ``budget_exhausted`` marker joins as an explicit null — the
|
||||
# phase-120 ``failed`` / phase-48 ``stopped`` style).
|
||||
assert set(dumped) == {
|
||||
"who",
|
||||
"text",
|
||||
@@ -504,6 +506,7 @@ def test_chat_message_image_defaults_to_none_and_is_omitted() -> None:
|
||||
"stopped",
|
||||
"failed",
|
||||
"error",
|
||||
"budget_exhausted",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -173,10 +173,14 @@ def test_pre_phase_record_round_trips_byte_identical_on_existing_keys() -> None:
|
||||
# Every pre-phase key survives byte-identical…
|
||||
for key, value in pre_phase.items():
|
||||
assert dumped[key] == value, f"pre-phase key {key!r} changed: {dumped[key]!r}"
|
||||
# …and the ONLY additions are the two new keys as explicit nulls.
|
||||
assert set(dumped) == set(pre_phase) | {"failed", "error"}
|
||||
# …and the ONLY additions are the new keys as explicit nulls
|
||||
# (the phase-120/48 ``failed`` / ``error`` pair, and the phase-160
|
||||
# additive ``budget_exhausted`` marker — the client's restore
|
||||
# hint, explicit null where absent).
|
||||
assert set(dumped) == set(pre_phase) | {"failed", "error", "budget_exhausted"}
|
||||
assert dumped["failed"] is None
|
||||
assert dumped["error"] is None
|
||||
assert dumped["budget_exhausted"] is None
|
||||
# The stored shape re-validates (round-trip through the DB JSONB).
|
||||
assert ChatMessage.model_validate(dumped).model_dump() == dumped
|
||||
|
||||
@@ -197,4 +201,5 @@ def test_minimal_user_record_unchanged() -> None:
|
||||
"stopped": None,
|
||||
"failed": None,
|
||||
"error": None,
|
||||
"budget_exhausted": None,
|
||||
}
|
||||
|
||||
@@ -796,9 +796,15 @@ def test_retry_last_turn_is_byte_unchanged() -> None:
|
||||
#: task-01 idle-settle ``markLastEditable()`` mark pass (work item 3
|
||||
#: call sites — the in-flight guard's at-rest landing), and the
|
||||
#: task-03 aborted-settle mark pair (the 300s guard's ``aborted``
|
||||
#: catch branch — a timed-out turn must not strand the question).
|
||||
#: The ``reask`` path is consumed as-is. A diff here is a contract
|
||||
#: violation for the phase's remaining tasks.
|
||||
#: catch branch — a timed-out turn must not strand the question);
|
||||
#: plus the phase-160 (task 04, TODO L4) sanctioned additions:
|
||||
#: the done settle's ``budget_exhausted`` meta key (the additive
|
||||
#: done-frame marker — ``undefined`` drops the key, so every non-cap
|
||||
#: record stays byte-identical) and the flag-gated
|
||||
#: ``appendContinueButton`` (the "Continue researching" affordance —
|
||||
#: ABSENT on every other turn). The ``reask`` path is consumed as-is.
|
||||
#: A diff here is a contract violation for the phase's remaining
|
||||
#: tasks.
|
||||
PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}) {
|
||||
if (!reask) {
|
||||
// Phase 123 (task 02, TODO L6): the attached image rides the user
|
||||
@@ -1181,6 +1187,12 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
// row exactly as it looked live.
|
||||
related: ev.related?.length ? ev.related : undefined,
|
||||
suggestions: ev.suggestions,
|
||||
// Phase 160 (task 04, TODO L4): the cap-reached marker
|
||||
// persists with the turn — `undefined` drops the key from
|
||||
// the JSON (the house optional-meta pattern, the
|
||||
// answer_image line above is the precedent), so every
|
||||
// non-cap record stays byte-identical to pre-phase.
|
||||
budget_exhausted: ev.budget_exhausted || undefined,
|
||||
}, leavePartialIndex);
|
||||
lastBrainWrap = wrap; // this bubble is now the last brain answer
|
||||
// Phase 130 (task 02): the row order is the DOM order — the
|
||||
@@ -1191,6 +1203,20 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
appendTuneButton(wrap); // every completed brain bubble is tunable
|
||||
markLastRetryable(); // phase 49: the Retry button is last-bubble-only
|
||||
markLastEditable(); // phase 159: the done turn's question is editable (the in-flight guard no-ops here — the runTurn idle settle lands it)
|
||||
// Phase 160 (task 04, TODO L4): a cap-reached answer offers
|
||||
// the one-click continuation — first, every earlier bubble's
|
||||
// continue button is cleared (the markLastRetryable
|
||||
// remove-first pattern: the moment a newer turn lands, an old
|
||||
// bubble's stale affordance is gone — its "continue" would
|
||||
// append to a history the conversation already moved past),
|
||||
// then the button is appended ONLY when the done frame is
|
||||
// flagged (ABSENT on every other turn, so the button never
|
||||
// appears elsewhere); the row order stays Tune → Retry →
|
||||
// Continue → Save-as-doc (the insertion order phase 130
|
||||
// relies on — markLastEditable above acts on the user
|
||||
// bubble, so the brain bubble's row order is unchanged).
|
||||
messagesEl.querySelectorAll(".continue-btn").forEach((b) => b.remove());
|
||||
if (ev.budget_exhausted) appendContinueButton(wrap);
|
||||
// Phase 59: the RAW persisted markdown (never the rendered
|
||||
// HTML) — exactly the string rememberBrainTurn stores above,
|
||||
// so a reload (the restore path) offers the identical draft.
|
||||
@@ -1397,13 +1423,16 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
|
||||
|
||||
def test_run_turn_is_pinned_at_phase_159_state() -> None:
|
||||
"""``runTurn`` is pinned at exactly the state phase 159 left it
|
||||
(task 02 consumes its ``reask`` path AS-IS — the phase's "NOT
|
||||
touched" contract): the full source must match the pin — the
|
||||
``reask`` path, the turn-local resets, the SSE contract, the
|
||||
save points, and the sanctioned additions (the task-01
|
||||
``lastUserWrap`` assignment + the idle-settle mark pass, and the
|
||||
task-03 aborted-settle mark pair) included."""
|
||||
"""``runTurn`` is pinned at exactly the state phase 159 left it,
|
||||
plus phase 160 (task 04)'s sanctioned additions (task 02 consumes
|
||||
its ``reask`` path AS-IS — the phase's "NOT touched" contract;
|
||||
the phase-160 additions are the done settle's additive
|
||||
``budget_exhausted`` meta key + the flag-gated Continue button):
|
||||
the full source must match the pin — the ``reask`` path, the
|
||||
turn-local resets, the SSE contract, the save points, and the
|
||||
sanctioned additions (the task-01 ``lastUserWrap`` assignment +
|
||||
the idle-settle mark pass, the task-03 aborted-settle mark pair,
|
||||
and the phase-160 done-settle pair) included."""
|
||||
assert _fn_source(_js(), "runTurn") == PINNED_RUN_TURN
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -565,6 +565,88 @@ def test_done_event_answer_image_ref_drops_its_own_null_image_url() -> None:
|
||||
assert "image_url" not in dump["answer_image"]
|
||||
|
||||
|
||||
# --- the ``budget_exhausted`` done key (phase 160, task 03) ---
|
||||
|
||||
|
||||
def test_done_event_without_budget_exhausted_drops_the_key() -> None:
|
||||
"""``budget_exhausted=False`` (the default — every deflected /
|
||||
in-cap / pre-phase turn) → ``model_dump()`` has NO
|
||||
``budget_exhausted`` key — ABSENT, never ``false`` — and a full
|
||||
done frame (sources + related + suggestions, no flag) serializes
|
||||
byte-identically to the pre-phase expectation (the
|
||||
byte-identical-off contract, PLAN §13)."""
|
||||
event = ChatDoneEvent(
|
||||
deflected=False,
|
||||
sources=[SourceRef(source="docs", path="a.md", title="A")],
|
||||
related=[SourceRef(source="docs", path="b.md", title="B")],
|
||||
suggestions=["Maybe try X?"],
|
||||
)
|
||||
dump = event.model_dump()
|
||||
assert "budget_exhausted" not in dump
|
||||
# The pre-phase-160 key set, exactly (answer_image absent too).
|
||||
assert set(dump) == {"type", "deflected", "sources", "related", "suggestions"}
|
||||
assert _json_dumps(dump, sort_keys=True) == _json_dumps(
|
||||
{
|
||||
"type": "done",
|
||||
"deflected": False,
|
||||
"sources": [{"source": "docs", "path": "a.md", "title": "A"}],
|
||||
"related": [{"source": "docs", "path": "b.md", "title": "B"}],
|
||||
"suggestions": ["Maybe try X?"],
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
raw = _json_dumps(dump)
|
||||
assert "budget_exhausted" not in raw
|
||||
|
||||
|
||||
def test_done_event_with_budget_exhausted_carries_the_key() -> None:
|
||||
"""``budget_exhausted=True`` (a cap-reached turn) → the key is
|
||||
present and ``true``, the rest of the frame untouched; an
|
||||
explicit ``False`` behaves exactly like the default (absent)."""
|
||||
dump = ChatDoneEvent(deflected=False, sources=[], budget_exhausted=True).model_dump()
|
||||
assert dump["budget_exhausted"] is True
|
||||
assert dump["type"] == "done"
|
||||
assert dump["deflected"] is False
|
||||
assert dump["related"] == []
|
||||
assert dump["suggestions"] == []
|
||||
explicit_false = ChatDoneEvent(
|
||||
deflected=False, sources=[], budget_exhausted=False
|
||||
).model_dump()
|
||||
assert "budget_exhausted" not in explicit_false
|
||||
|
||||
|
||||
def test_done_event_budget_exhausted_and_answer_image_are_independent() -> None:
|
||||
"""Both optional keys shown on the same turn: the two omission
|
||||
rules are independent — a cap-reached turn with a shown answer
|
||||
image carries BOTH keys; the answer-image ref's own serializer
|
||||
still applies inside the frame."""
|
||||
dump = ChatDoneEvent(
|
||||
deflected=False,
|
||||
sources=[],
|
||||
answer_image=SourceRef(**_SHOWN_REF),
|
||||
budget_exhausted=True,
|
||||
).model_dump()
|
||||
assert dump["budget_exhausted"] is True
|
||||
assert dump["answer_image"] == _SHOWN_REF
|
||||
|
||||
|
||||
def test_done_event_without_budget_exhausted_parses_a_pre_phase_frame() -> None:
|
||||
"""Back-compat: a pre-phase-160 done frame (no
|
||||
``budget_exhausted`` key) parses with the default ``False`` and
|
||||
re-dumps byte-identically (PLAN §4 — the field is additive in
|
||||
both directions)."""
|
||||
old_payload = {
|
||||
"type": "done",
|
||||
"deflected": True,
|
||||
"sources": [],
|
||||
"related": [],
|
||||
"suggestions": [],
|
||||
}
|
||||
event = ChatDoneEvent(**old_payload)
|
||||
assert event.budget_exhausted is False
|
||||
assert event.model_dump() == old_payload
|
||||
|
||||
|
||||
# --- the ``ChatMessage`` boundary (the saved-chat record key) ---
|
||||
|
||||
|
||||
|
||||
@@ -259,7 +259,9 @@ def test_read_result_plain_carries_date_second_line(
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
||||
content = llm.requests[1][0][3]["content"]
|
||||
# Phase 160 (task 01): the budget-telemetry trailer rides the read
|
||||
# result — the raw result is the RAW part (the trailer stripped).
|
||||
content = llm.requests[1][0][3]["content"].removesuffix(agent._budget_note(9))
|
||||
lines = content.splitlines()
|
||||
assert lines[0] == "Document S/a.md:" # byte-identical first line
|
||||
assert lines[1] == f"date: {DATE}" # the D5 second line
|
||||
@@ -291,7 +293,9 @@ def test_read_result_truncated_carries_date_then_marker_and_notice(
|
||||
+ "\n"
|
||||
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=len(content_body))
|
||||
)
|
||||
assert llm.requests[1][0][3]["content"] == expected
|
||||
# Phase 160 (task 01): the trailer rides the truncated result too —
|
||||
# the truncation contract (phase 95) is the RAW part.
|
||||
assert llm.requests[1][0][3]["content"].startswith(expected)
|
||||
# The truncation still records on the holder (the ToolResultPiece
|
||||
# signal) — the date line adds nothing to the counters.
|
||||
assert holder.read_truncations == [("S/big.md", cap, len(content_body))]
|
||||
|
||||
@@ -386,6 +386,7 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
|
||||
"stopped": None,
|
||||
"failed": None,
|
||||
"error": None,
|
||||
"budget_exhausted": None,
|
||||
},
|
||||
{
|
||||
"who": "brain",
|
||||
@@ -434,6 +435,7 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
|
||||
"stopped": None,
|
||||
"failed": None,
|
||||
"error": None,
|
||||
"budget_exhausted": None,
|
||||
},
|
||||
{
|
||||
"who": "user",
|
||||
@@ -447,6 +449,7 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
|
||||
"stopped": None,
|
||||
"failed": None,
|
||||
"error": None,
|
||||
"budget_exhausted": None,
|
||||
},
|
||||
{
|
||||
"who": "brain",
|
||||
@@ -460,6 +463,7 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
|
||||
"stopped": True, # the owner stopped the generation mid-answer
|
||||
"failed": None,
|
||||
"error": None,
|
||||
"budget_exhausted": None,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -468,7 +472,10 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
|
||||
dumped = payload.model_dump()
|
||||
# the stored shape is byte-identical: every message dict — None-keys
|
||||
# and all — round-trips unchanged (and the top-level record keeps its
|
||||
# ``share`` default).
|
||||
# ``share`` default). The phase-160 additive ``budget_exhausted``
|
||||
# marker joins as an explicit null (the phase-120 ``failed`` /
|
||||
# phase-48 ``stopped`` style — ``None`` flows as null, the
|
||||
# phase-50 byte-identical contract covers the new key).
|
||||
assert dumped["messages"] == messages
|
||||
assert dumped["share"] is False
|
||||
|
||||
@@ -488,8 +495,11 @@ def test_realistic_payload_round_trips_through_update_model() -> None:
|
||||
"stopped": None,
|
||||
"failed": None,
|
||||
"error": None,
|
||||
"budget_exhausted": None,
|
||||
}
|
||||
payload = SavedChatUpdate.model_validate({"messages": [msg]})
|
||||
# Identical stored shape (the phase-160 additive ``budget_exhausted``
|
||||
# marker rides as an explicit null — the phase-120/48 style).
|
||||
assert payload.model_dump()["messages"] == [msg]
|
||||
|
||||
|
||||
@@ -708,8 +718,11 @@ def test_segments_absent_by_default_and_omitted_in_dump() -> None:
|
||||
dumped = msg.model_dump()
|
||||
assert "segments" not in dumped
|
||||
# Byte-identical to the pre-phase stored shape: every bor.chat.v1
|
||||
# optional key present (null), the three serializer-popped keys
|
||||
# (image / answer_image / segments) absent.
|
||||
# optional key present (null) — the phase-160 additive
|
||||
# ``budget_exhausted`` marker included (the phase-120 ``failed`` /
|
||||
# phase-48 ``stopped`` style — explicit null where absent) — and
|
||||
# the three serializer-popped keys (image / answer_image /
|
||||
# segments) absent.
|
||||
assert dumped == {
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
@@ -722,6 +735,7 @@ def test_segments_absent_by_default_and_omitted_in_dump() -> None:
|
||||
"stopped": None,
|
||||
"failed": None,
|
||||
"error": None,
|
||||
"budget_exhausted": None,
|
||||
}
|
||||
# An explicit empty list is a different thing: [] is a value, so it
|
||||
# round-trips as [] (present, empty — the pop is None-gated only).
|
||||
|
||||
Reference in New Issue
Block a user