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
+89
View File
@@ -618,6 +618,15 @@ let lastUserWrap = null;
const RETRY_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>';
/* Phase 160 (task 04, TODO L4): the "Continue researching" button's
* forward glyph (aria-hidden decoration — the visible "Continue
* researching" label carries the accessible name). Same stroke family
* as RETRY_ICON: fill="none", currentColor stroke (the CSS themes it
* — ink-soft → ink on hover, the phase-08 palette), 24×24 viewBox,
* stroke-width 1.7. No icon font, no CDN (A11). */
const CONTINUE_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m5 6 6 6-6 6"/><path d="m13 6 6 6-6 6"/></svg>';
/* Phase 159 (task 01, TODO.md L3): the Edit button's pencil glyph
* (aria-hidden decoration — the visible "Edit" label carries the
* accessible name). Same stroke family as RETRY_ICON: fill="none",
@@ -648,6 +657,34 @@ function appendRetryButton(wrap) {
meta.appendChild(btn);
}
/* Phase 160 (task 04, TODO L4): the "Continue researching" button —
* the one-click continuation of a turn that spent its whole tool
* budget (the `done` frame's additive `budget_exhausted` key, task
* 03 — ABSENT on every non-cap turn, so the button never appears
* elsewhere). The house pattern through getActionsRow (phase 130
* task 02 — the role=list row joins the button as a listitem so ARIA
* stays valid); one button per bubble. NOT admin-gated, like Retry —
* chat is public, so every visitor can keep a long research session
* going. On a capped answer the row order is Tune → Retry → Continue
* → Save-as-doc (the insertion order phase 130 relies on). Click →
* continueTurn() — a plain next turn: the budget is per-turn, so the
* new turn has a whole new round budget, and nothing server-side
* knows or cares that this is a continuation (A10). */
const CONTINUE_QUESTION = "continue";
function appendContinueButton(wrap) {
const meta = getActionsRow(wrap);
if (!meta) return;
if (meta.querySelector(".continue-btn")) return; // one per bubble
const btn = document.createElement("button");
btn.type = "button";
btn.className = "continue-btn";
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
btn.innerHTML = CONTINUE_ICON + "<span>Continue researching</span>";
btn.addEventListener("click", () => continueTurn());
meta.appendChild(btn);
}
/* "Edit" button in the last USER bubble's actions row (phase 159,
* task 01, TODO.md L3 — the trim-and-re-ask remedy: a "Question too
* long — trim it and re-ask" turn, or any mistyped / half-formed
@@ -2688,6 +2725,14 @@ function renderStoredMessage(m) {
// precedes the failed record, unchanged; a mid-conversation failed
// bubble gets no button — the phase-49 last-bubble-only rule).
if (m.failed && m.error) appendFailedNote(wrap, m.error);
// Phase 160 (task 04, TODO L4): a cap-reached answer keeps its
// Continue affordance on a page refresh — a restored `history` is
// exactly what the continuation turn needs (runTurn's plain send
// carries conversation.slice(0, -1)). The marker is a client
// restore hint only (the schema's budget_exhausted key, never a
// server behavior), so pre-phase / non-cap records restore
// byte-identically (no key → no button).
if (m.budget_exhausted) appendContinueButton(wrap);
lastBrainWrap = wrap; // phase 49: the LAST restored brain bubble wins
}
@@ -3304,6 +3349,30 @@ function retryLastTurn(wrap) {
return runTurn(text, { reask: true });
}
/* Phase 160 (task 04, TODO L4): the "Continue researching" click —
* the retryLastTurn discipline, one turn at a time. No-op while a
* turn is in flight (thinking / streaming — the runTurn guard; the
* button is never double-fired into two turns), then clear EVERY
* rendered .continue-btn FIRST (the markLastRetryable remove-first
* pattern: the moment the new turn's answer lands, a stale button on
* any earlier bubble is gone by construction; the one-per-bubble
* guard + the clear make a double-send impossible), then a PLAIN send
* (runTurn's `reask` defaults to false): the "continue" user bubble
* appears, and the record is pushed + saved through the ordinary save
* points (conversation.push + saveConversation + persistConversation).
* The request carries the FULL history (conversation.slice(0, -1)) —
* the model resumes with everything it already read and gets a fresh
* agent_max_rounds. ASSUMPTION: the continuation question is the
* literal word "continue" — what the model is taught to ask the user
* for (ROUND_CAP_WRAPUP_INSTRUCTION / the tool-budget-spent trailer,
* phase 160 tasks 01–02), so the visible bubble, the typed
* equivalent, and the model's own suggestion all agree. */
function continueTurn() {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
messagesEl.querySelectorAll(".continue-btn").forEach((b) => b.remove());
return runTurn(CONTINUE_QUESTION);
}
/* Phase 123 (task 02, TODO L6): the send flow's UPLOAD STEP (locked
* A8) — the attached File goes to POST /api/chat-images as multipart
* (the session cookie rides the browser; the endpoint is user-gated
@@ -3933,6 +4002,12 @@ async 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
@@ -3943,6 +4018,20 @@ async 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.