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
+572 -235
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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=",
+4 -1
View File
@@ -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",
}
+7 -2
View File
@@ -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,
}
+39 -10
View File
@@ -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
+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"
)
@@ -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) ---
+6 -2
View File
@@ -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))]
+17 -3
View File
@@ -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).