Skip to content

fix(character): re-anchor the Format button on its custom element, not a tag that moved - #729

Merged
ffroliva merged 11 commits into
developfrom
bugfix/727-format-button-anchor
Sep 7, 2026
Merged

ffroliva merged 11 commits into
developfrom
bugfix/727-format-button-anchor

Conversation

@ffroliva

@ffroliva ffroliva commented Sep 7, 2026 •

Copy link
Copy Markdown
Owner

Summary

Both faults are fixed and live-verified

--format-prompt had two independent faults. The anchor had drifted (the button was never found), and — once found — gflow clicked it and submitted before Flow's server-side rewrite arrived, so the reshaped prompt was discarded on every run behind a green exit code.

Both are fixed here. Live e2e: 1 passed in 406.20s on denon82, asserting the observed rewrite (prompt_formatted, now emitted only after the composer text actually changes) plus format_button_clicked, and rejecting format_not_observed, format_click_failed and format_button_not_found.

Measured cost of making it work: 406s vs a 210s control on the same account and face prompt. Partly the ~4-5s rewrite; mostly Flow generating from a 608-character elaboration instead of the 102 characters typed. That is the feature's actual price — it was free only while it did nothing — and it is why _CREATE_TIMEOUT_S moved 300 → 480.

Lifecycle

  • Issue triaged — reproduces on CLI only; character create is an explicit _MCP_EXEMPT in tests/mcp/test_cli_parity.py, so there is no MCP twin to drift
  • spike run before any claim — skills/spike/SKILL.md, finding in docs/superpowers/spikes/2026-09-07-character-format-button-anchor.md
  • predict — a selector re-anchor backed by a live DOM capture, not a transport/auth/schema change
  • scenario + plan — single-constant fix with an existing drift canary already in place
  • check green, including the step 1b mirror sweep (all six axes below)
  • MCP twin: reasoned exemption already recorded for character create; zero hits for format_prompt under src/gflow_cli/mcp/
  • E2E evidence — tests/e2e/test_character_create_e2e.py::test_character_create_format_prompt_clicks_format_button, 1 passed in 219.90s on denon82, real generation, zero credits. This test was RED before the fix and is green by a live run — the assertion was never relaxed.
  • Live-verified against real Flow; what could NOT be verified is stated below
  • Council review — running next

Validation

  • Two new unit tests pin the contract that was missing, both watched red against the old constant first:
    • test_cascade_covers_both_ligature_carriers — AssertionError: cascade must carry the migrated-host <mat-icon> carrier
    • test_cascade_carries_no_display_label_anchor — AssertionError: "button:has(span:text-is('"'"'Format'"'"'))" anchors on a localised display label
    • plus test_clicks_ligature_selector_first, updated to the new primary anchor
  • uv run python scripts/ci/check_repo_hygiene.py — 1017 files, no violations
  • uv run python scripts/ci/check_doc_links.py — all links resolved across 29 files
  • uv run python scripts/ci/check_website_docs_pii.py — no private identifiers, 25 files
  • uv run python scripts/ci/generate_website_docs.py --check — mirror in sync
  • uv run python scripts/ci/check_council_memory.py — 48 memory files, all cited
  • uv run ruff check src tests / ruff format --check src tests
  • uv run pyright src — 0 errors
  • uv run python -m pytest -q --cov=gflow_cli — 4009 passed, 7 skipped, 92% coverage

Step 1b blast radius — six axes

Grepped format-prompt / format_prompt / personal_recommendations across src/gflow_cli/mcp/, docs/, website/docs/, README.md, KNOWN_ISSUES.md, skills/.

Axis Verdict
A. Execution paths (CLI / MCP direct / MCP queued) N/A — no option, param or payload key changed; a selector constant only
B. MCP surface truth N/A — 0 hits in src/gflow_cli/mcp/; character create exempt
C. Agent-facing surfaces N/A — 0 hits in skills/, README.md, docs/INDEX.md, AGENTS.md command surface
D. Error / exit-code surface N/A — behaviour stays best-effort; no raise site, no EXIT_CODE_MAP entry
E. Declarative / template surfaces N/A
F. Docs + published mirror CHANGELOG entry added; mirror verified in sync. Remaining hits are dated historical records (LIVE_VERIFICATION_v0.45.0.md, PROJECT_STATUS.md, the 2026-07-26 PLAN) that correctly describe what was true then

What this does NOT prove

  • The labs frontend. No unmoved account exists here, so the two <i> entries are carried forward on the 2026-07-27 capture alone. They were not re-verified today and are not asserted to still work.
  • The content of Flow'"'"'s rewrite. The e2e asserts the button was found, enabled and clicked. Whether Flow'"'"'s reshaped prompt is better stays a human read.
  • That this was the last missed constant. fix: drive character create on the migrated Flow host #703'"'"'s carrier sweep demonstrably skipped one; this PR sweeps one more.

Open question, deliberately not acted on here

--format-prompt stays best-effort, which is what its help text promises and what #727 says is correct. But that is why this went unnoticed for an unknown period: a paid run silently skipped the step the user asked for behind exit 0, and only an opt-in e2e (GFLOW_CLI_E2E_RUN_CHARACTER=1, not in the nightly canary) could catch it. Whether a missed button should be loud — or fatal under an explicit --format-prompt — is a real design change and belongs in its own issue.

Contribution Checklist

  • Targets develop
  • Real Git identity
  • No secrets, cookies, tokens, signed URLs or raw captures — the capture stays gitignored under scripts/dev/_spike_out/
  • Reviewed before submitting

…t a tag that moved

`--format-prompt` had degraded to a silent no-op on the migrated host: all three
entries of PROMPT_FORMAT_SELECTORS missed, `format_button_not_found` fired, the
prompt submitted as typed and the run exited 0 having spent image quota on a
prompt-engineering step that never happened.

Nothing was absent. Measured live on denon82 ($0 — navigation, one free
createEntity, DOM reads), with a control anchor so a flat zero could not be read
as absence:

  control_check  count=1   (prompt box resolved — the probe was in the editor)
  personal_recommendations via i.google-symbols   -> 0
  personal_recommendations via i                  -> 0
  span:text-is('Format')                          -> 0
  the button itself, visible the whole time       -> 1

  <flow-format-prompt-button>
    <button aria-label="Formatar">
      <mat-icon class="... google-symbols ...">personal_recommendations</mat-icon>

Two independent misses, either one fatal:

  a. Angular renders the ligature in <mat-icon>, not <i>. The ligature never
     changed and mat-icon even carries the google-symbols class, so the class
     was never the problem — the `i` tag was. This is the same carrier split
     already fixed for add_2 / arrow_drop_down / accessibility_new in #703;
     this constant was simply not swept with them, and nothing proves it was
     the last one that was missed.
  b. The button now has an aria-label, but Flow localises it ("Formatar" on a
     pt account), as it does the <span>. The EN text fallback is deleted rather
     than translated — display labels are banned as anchors (AGENTS.md).

The cascade now leads with the custom element <flow-format-prompt-button>: a
component boundary rather than a layout accident, unique among the six composer
buttons, and the strongest anchor class this frontend offers.

Two unit tests pin the contract that was missing: the cascade must carry BOTH
ligature carriers, and it must carry no display-label anchor. Both were watched
red against the old constant before the fix.

Verified by a live run, not by relaxing the assertion:
  tests/e2e/test_character_create_e2e.py::test_character_create_format_prompt_clicks_format_button
  1 passed in 219.90s — denon82, real generation, zero credits

Not measured: the labs frontend. No unmoved account exists here, so the two <i>
entries ride on the 2026-07-27 capture alone and were not re-verified today.

Closes #727
Nine-dimension council on 7c1d4c6 returned 4 GREEN / 5 YELLOW. Every finding
below was raised by a reviewer, not by the author.

D1/D14 - the retained 2026-07-27 comment still said, present tense, that the EN
  `span:text-is('Format')` fallback "has to be" the structural anchor, describing
  an entry the same commit deletes. The block also told the spike doc's story a
  second time, in the copy that will drift. Cut to the two DOM captures (the
  evidence) plus four lines, and a link to the finding.

D4/D6 - `test_clicks_ligature_selector_first` kept its name and docstring while
  only its assertion moved to the custom element; the e2e's failure message still
  told a future drift victim the cascade is "anchored on the personal_recommendations
  ligature". A label that lies outlives the bug it describes. Renamed, reworded,
  and the e2e message now names the probe to run. Assertions themselves untouched -
  only the string a failure prints.

D4 - the fall-through test drove only `PROMPT_FORMAT_SELECTORS[-1]`, so the new
  `<mat-icon>` entry at index 1 was never exercised. Parametrized over the whole
  cascade.

D6 - `is_visible(timeout=1000)` is inert: Playwright documents the argument as
  ignored, and the call answers from the current DOM without waiting. Dropped, with
  the reason, so nobody re-derives "a 4-entry cascade costs 4s before the submit".

D9 - the CHANGELOG called the capture "DOM read only" while the method it cites
  does a free `createEntity`, which is a write. Two files, one method, disagreeing.

D5 - `flow-locale-leak-icon-ligatures.md` still prescribed the `<i>`-only pattern
  that caused this: #703 fixed three constants for the carrier split and never
  updated the memory that teaches it, so the fourth was written the old way on
  purpose. Corrected, and the split promoted to its own entry.

D13/D2 - the spike put its guard in FRONT of its probe: a control miss raised
  before the dump, the screenshots and the report, deleting the evidence that
  would correct it. That is the #701 failure this file cites in its own docstring.
  Both controls now raise from a `finally` that has already written everything.
  A second control was missing entirely - nothing checked that the typed text
  landed, so a click intercepted by a consent overlay would have recorded a false
  absence indistinguishable from a real measurement. The scratch entity is deleted
  again, and the output path is root-anchored: `.gitignore`'s entry is slash-anchored,
  so running the spike from `scripts/dev/` wrote an UNIGNORED capture carrying the
  profile name, live URL, entity ids and authenticated outer_html.

D14 - the occlusion probe measured the prompt box, not the button under
  investigation, and the spike doc filed that under "confirmed, not assumed".
  Deleted; the live e2e click landing is stronger evidence anyway.

Re-verified live on denon82 after the rewrite ($0, 14s, clean exit):
  control_check    count=1
  typed_control    chars=47
  flow-format-prompt-button button                          -> 1
  button:has(mat-icon:text-is('personal_recommendations'))  -> 1
  button:has(i.google-symbols:text-is(...))                 -> 0
  button:has(i:text-is(...))                                -> 0

Declined, with reason: dropping `button:has(i.google-symbols:...)` as redundant
with the broader `button:has(i:...)`. The superset argument is correct, but those
are the labs anchors and no unmoved account exists here to re-verify against -
narrowing an untested surface to save one line is the wrong trade.

Refs #727, #730, #731
`FlowApiClient._checkout_page()` is documented as blocking forever - "Waits
indefinitely if the pool is exhausted (no Pages available). Callers that need a
deadline must wrap the call themselves" (api/client.py) - over an
`asyncio.Queue(maxsize=n)` that holds ONE page by default. A script that takes
that page and never returns it has not leaked a resource; it has armed a deadlock
for the next caller that needs one.

Nothing usually does, which is why this sat latent in seven scripts. Then
spike_character_prompt_format.py added a `client.delete_characters(...)` cleanup
call in its `finally`, obeying skills/spike/SKILL.md's "delete anything the spike
created". Every `client.<verb>()` reaches Flow through `_post_json`, which checks
out a page of its own - so the cleanup blocked on a queue the script itself had
emptied.

It failed in the worst available shape. All work completed: the capture and all
three screenshots were on disk. No error, no traceback, no output. The process
simply never exited, holding the denon82 profile lease. `contextlib.suppress`
cannot rescue it, because a block is not an exception. And a wedged process
holding a lease is exactly what sends an operator to the process list - the
sequence that cost nine browsers belonging to a running e2e suite the same day,
and the other half of what test_spike_profile_lease.py exists to prevent.

Measured before fixing: 7 of 24 scripts under scripts/dev/ never check in. Of
those, only spike_character_prompt_format.py can deadlock TODAY - the other six
make no client call after taking the page. They are fixed anyway: "latent" is
precisely what the seventh was until this afternoon.

The 17 scripts that already pair the calls are the reference, and `_post_json`'s
own attempt() is the canonical shape - checkout, try, finally checkin.

tests/scripts/test_spike_page_pool.py pins the invariant offline and text-level,
in milliseconds, with no browser, mirroring test_spike_profile_lease.py including
its scan-still-resolves guard so a renamed pool API cannot empty the check
silently. Watched failing against all seven scripts by name before the fix.

The six sibling diffs are almost entirely re-indentation - `git diff -w` collapses
each to its added try/finally. Every script re-verified: ast parses, --help runs.

Fix verified by a run, not by inspection: the spike now exits 0 in 14 seconds and
logs `deleted_scratch_entity`, where it previously hung indefinitely.
Phase 0 for the question #727 deliberately left open: should a missed Format
button be loud? That cannot be answered until we know what a HIT looks like.

`format_character_prompt` clicks, waits ~500ms of jitter, logs
`ui_automation.prompt_formatted`, and returns - and `_send_prompt` submits
immediately after. So the event proves a CLICK, never a REWRITE
([[playwright-click-no-downstream-event-signature]]). If Flow reshapes the prompt
via a server round trip, 500ms may be short, and the submit could carry the text
the user typed while the rewritten text was still in flight: the flag doing
nothing, behind a green exit code AND a green e2e, which asserts the click event.

This script measures it rather than reasoning about it: seed a deliberately terse
prompt, confirm it landed, click Format through the SHIPPED cascade (so every run
also re-tests the #727 anchor), then poll the box every 250ms for 20s recording
when the text changes, while capturing every request the page makes. A no-change
result is reported WITH its timeline, so "unchanged after 20s" is a positive
observation with a shape rather than a wait that expired.

Never submits: no image, no video, no credit. Not measured, and said so in the
docstring: whether the rewrite call itself draws on server-side quota.

Carries the discipline the council extracted this afternoon - lease via
FlowApiClient, page returned before any client verb, controls that raise only from
a finally that has already written the evidence, scratch entity deleted,
root-anchored output path.

Refs #727
@ffroliva

ffroliva commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

⚠️ Correction to this PR's claims — the fix is necessary but NOT sufficient

A Phase-0 spike run after the council (scripts/dev/spike_format_prompt_effect.py, $0, denon82) measured what happens after the click this PR fixed:

baseline        'girl, red hair, sad'                       19 chars
format_clicked  flow-format-prompt-button button
box_text  t=0.011   chars=19    'girl, red hair, sad'      <- unchanged
box_text  t=5.355   chars=651   'Medium studio shot of a young girl with…'
verdict   changed=True  changed_at_s=5.355  within_current_wait=False
          requests_after_click=1
t=1.191   POST .../batchexecute?rpcids=eAenfb    <- the rewrite, server-side

Flow's rewrite lands at ~5.4s. format_character_prompt waits _jitter_ms(500) and returns, and _send_prompt submits on the next line.

So --format-prompt still submits the prompt the user typed, discarding the reshaped one — on every run, including runs where the button is found, enabled, clicked, and ui_automation.prompt_formatted is emitted.

What that means for the green e2e in this PR

test_character_create_format_prompt_clicks_format_button asserts prompt_formatted present and the two failure events absent. All three genuinely hold — the run was real and I am not withdrawing it. But the assertion set proves the click, not the rewrite, so the test is green while the feature does not work. It asserts the click because at the time it was written the click was the only observable.

This PR's scope is unchanged and still correct: #727 is "the anchor has drifted", and the anchor is fixed, verified live three times. What I am correcting is the implication a reader would draw from "e2e green" — that --format-prompt now does something. It does not, for a second and independent reason.

Why the fix is not folded in here

The settle signal is available (the box text changes in one discrete step, so it is a condition to poll, not a duration to sleep), and anchoring on "the text differs from what we inserted" is locale-invariant by construction. But choosing a timeout needs a distribution and I have one observation; the failure modes of eAenfb were never provoked; and labs was not measured at all. That is a behaviour change on a paid path, so it goes through /gflow:predict first.

Evidence: docs/superpowers/spikes/2026-09-07-format-click-is-not-a-format.md (added in this PR).

…it 0.5s

Measured on denon82, $0, never submits. #727 fixed the anchor; this measures the
step after it, which the council (D6) flagged as unevidenced: the #727 spike
captured no network, so nothing established the click reached a backend, let
alone that its answer arrived before the submit.

  baseline        'girl, red hair, sad'                    19 chars
  format_clicked  flow-format-prompt-button button
  box_text  t=0.011  chars=19   'girl, red hair, sad'      <- unchanged
  box_text  t=5.355  chars=651  'Medium studio shot of a young girl with...'
  verdict   changed=True changed_at_s=5.355 within_current_wait=False
  t=1.191   POST .../batchexecute?rpcids=eAenfb            <- server round trip

gflow waits _jitter_ms(500) and submits on the next line, so --format-prompt
discards the reshaped prompt on every run - including runs where the button is
found, clicked, and ui_automation.prompt_formatted is emitted. The flag has never
worked on this host: before #727 it missed the button, after #727 it throws away
the answer.

The e2e does not catch it and is not wrong to be green - it asserts the click,
because when it was written the click was the only observable.

Fixable: the rewrite is one discrete DOM swap, so the settle signal is a condition
to poll (box text differs from what we inserted), not a duration to sleep - and
comparing against OUR OWN string is locale-invariant by construction.

Not measured, and recorded as such: how variable 5.4s is (n=1), what the box does
when eAenfb errors or rate-limits, the labs frontend at all, whether the rewrite
draws quota, and idempotency of a second click.

Refs #727
@ffroliva

ffroliva commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

/gflow:predict — wait for the observed rewrite in format_character_prompt

Verdict: 🟢 GO with conditions · confidence 8.2 / 10 · no STOP conditions.

Persona Score Verdict Headline
P1 Architect 8 GO Fits the existing settle-gate idiom; invent nothing
P2 Security 7 GO* No auth surface; the fingerprint moves toward human — but do not log the rewritten text
P3 Performance 6 CAUTION Design is right; the timeout must not be reverse-engineered from n=1
P4 CLI/MCP UX 8 GO No MCP surface to break; hazards are doc/telemetry edits, not design risk
P5 Devil's Advocate 7 CAUTION Withdrew its own duplication objection; wants #731 landed first

Average 7.2, +1 for unanimous agreement on the approach. The two CAUTIONs are conditions on the number and the ordering, not objections to the design.

The one measurement that decides the mechanism

The eAenfb response arrived at t=1.191s. The DOM settled at t=5.355s.

P1 and P3 independently rejected gating on page.expect_response(rpcids=eAenfb), and P3 gave the killer reason: the response precedes the DOM by 4.2s, so a wire-level gate re-creates the same early submit. It also hardcodes an undocumented batchexecute id into ui_automation, against the structural-anchor rule, on a host whose labs twin was never measured. Keep the rpcid as a logged diagnostic; never as the gate.

Conditions carried into the plan

Unanimous (3+ personas independently):

  1. Length delta, not !=. Slate/ProseMirror re-renders and normalises whitespace; raw inequality against the inserted string false-positives and fires prompt_formatted on a non-rewrite — today's bug with better telemetry. Observed signal was 19 → 651 chars.
  2. Match the existing settle-gate idiom — time.monotonic() deadline + page.wait_for_timeout(poll), as _locate_body_prompt_box and five siblings already do. No new helper, no wait_for_function (locators over evaluate), no expect() (appears nowhere in this file).
  3. Stay best-effort. No new exit code. Promoting the miss to an exception lands it in EXIT_CODE_MAP, the MCP schema and the docs at once — and the body-prompt call site runs after the face image spent quota, so failing there aborts a saga mid-spend.

P1 — Architect:
4. The baseline is a parameter, not instance state. Both callers already hold the string they inserted. Stashing it on self gives a page-reused transport hidden temporal coupling across generations.
5. Keep -> bool; change its meaning, do not widen it. Both callers discard it, so enriching it builds a channel with no consumer.
6. Module constants, no env var (_BODY_SLOT_MOUNT_TIMEOUT_S precedent). No config nobody sets.

P2 — Security:
7. Never log the rewritten text, and never a truncated excerpt. Flow elaborates a terse person description into a detailed physical one — the output is more PII-dense than the input, and GFLOW_CLI_HISTORY_PROMPTS governs the DB, not structlog. Log prompt_len_before / prompt_len_after + the 8-char _prompt_hash_stable, matching same-file precedent. A 70-char head is exactly where the physical description lands.
8. Jitter the poll interval — a fixed 250ms cadence is itself a deterministic signature, and _jitter_ms exists in this module for that reason.
9. Monotonic deadline inside the existing try/except, so an eAenfb hang cannot leave the page held.

P4 — CLI/MCP UX:
10. Redefine prompt_formatted to mean the observed rewrite (it already claims to); add format_button_clicked (old semantics) and format_not_observed (timeout). CHANGELOG it as a semantics change and fix docs/LIVE_VERIFICATION_v0.45.0.md:106, which asserts the old meaning.
11. The --help is currently false — the button is found and the prompt is still submitted as typed. Rewrite it to name both failure modes and disclose the ~5-15s cost.
12. Expose prompt_formatted: false in --json so agents detect the degrade without parsing stderr.
13. No MCP surface exists (mcp/tools.py, worker/codec.py, docs/MCP.md all clean; character create is _MCP_EXEMPT for GAP #691). The exemption's reason still holds and this strengthens it — a longer inline call is direct input to the worker-enqueue decision (#481). Record that in the #691 port note.
14. Strengthen the e2e in the same PR, or it re-certifies the click.

Unresolved conflict — surfaced, not suppressed

The timeout value. P1 says ~15s (labs is unmeasured; there a no-rewrite pays the full budget before submitting). P3 says ~30s (costs are asymmetric — too long is bounded latency on an opt-in flag, too short silently reintroduces the exact bug, invisibly). P4 says ~20s.

Resolution: take the generous end, and make the number stop mattering. P5's framing is the strongest: the fallback on expiry is today's behaviour, so a generous cap means n=1 is no longer load-bearing. P1's labs concern is real but bounded, and only on an opt-in flag. ~30s cap, 250ms jittered poll, and log elapsed_ms on every success so the distribution accrues in production rather than being demanded before shipping.

P3 also notes the evidence is weaker than n=1: the 250ms cadence bounds the true settle only to (5.105, 5.355].

Sequencing

P5 argues #731 first: docs/CHARACTER.md prints stale selectors marked VERIFIED, mirrored to the published website. A false claim marked verified actively misleads; this proposal fixes a flag with four months of zero reports. Then this change. Then #730 as its own predicted sweep — not another hand-fix.

Cheapest experiment, if a tighter number is ever wanted

scripts/dev/spike_format_prompt_effect.py already does one run at $0, never submitting. Loop it: 5 prompts of varying length, one already-formatted (idempotency), one under DevTools network throttling (the error/empty path). ~15 minutes, no quota. Labs remains a named blocker — no unmoved account exists here.

…ionale copied six times

Two review passes on `git diff 7c1d4c6..HEAD`, run because this repo has a
documented pattern of the fix getting less scrutiny than the original - and
because this session had already reproduced it once today.

Regression pass (GREEN, one must-fix): the try/finally re-indent pushed two lines
in capture_image_add_media_dom.py past 100 chars. Invisible to /gflow:check,
whose lint scope is `src tests` - `scripts/` has never been in it. Reflowed; all
eight files this PR touches now pass `ruff check --select E501` (the 64 remaining
in scripts/dev/ are pre-existing in files this PR never opened).

That pass also proved the six re-indents semantics-preserving rather than reading
them: an AST transformer spliced out every Try whose finalbody is exactly a
_checkin_page call, then compared ast.dump before/after. Four files identical;
two differ only in interior whitespace of JS string constants, with no template
literals, so JS behaviour is unchanged. It also replayed the new guard against
7c1d4c6 and got offenders=7 - the guard fails pre-fix, so it is not vacuous.

YAGNI pass (GREEN, three cuts):
  - the 4-line rationale for the checkin was copy-pasted into all six scripts.
    That is the "do not copy the table around" failure, committed inside the fix
    for a bug caused by duplication. Collapsed to one line pointing at the test
    that owns the explanation.
  - test_spike_page_pool.py re-told the nine-browsers incident that is the entire
    subject of test_spike_profile_lease.py's docstring one file over. Now a
    pointer.
  - the spike doc pinned a literal count of single-carrier selectors, which is a
    number that rots. Deferred to the memory entry and #730.

Verified: ruff check/format src tests clean, 295 passed
(tests/scripts + test_ui_character_editor), doc links resolve, 49 memory files
all cited, and all six re-indented scripts still import and --help.

Known and out of scope: spike_image_upscale_drive.py has three bare
_checkin_page calls with no try/finally, so an exception between checkout and
them still leaks. The new guard is a text-presence check and passes it.
Pre-existing.
…d of a third copy

ponytail-review pass over the full PR diff, run because the previous passes were
scoped to a delta and the last commit had been reviewed by nobody.

The finding is mine and it is the same shape as the bug this PR fixes. The
character-editor ready selector was written out THREE times: once canonically as
UiAutomationTransport._CHARACTER_EDITOR_READY_SELECTOR, and copied verbatim into
two spike scripts. D13 flagged the first copy as a nice-to-have; I did not act on
it, then added a second copy in the new spike. A probe that asserts a different
anchor than the code it is probing measures the copy, not the code - which is
exactly how a selector and its documentation drift apart, and #727 is what that
costs. Both scripts already imported the class, so this needs no new import.

Also, in spike_format_prompt_effect.py: two monotonic bases (t_zero for requests,
t_click for polls) forced `r["t"] >= t_click - t_zero` in the verdict line. One
clock now, with click_at recorded in the report - the request and poll timelines
become directly comparable, which matters because the gap between them (response
at 1.191s, DOM at 5.355s) IS the finding. And `timeline[-1] if timeline else None`
was dead: the poll loop always runs at least once.

net: -8 lines. Verified: ruff clean on both, and both modules import with
identical resolved selectors.
/code-review at high effort, run on the full branch - the first review to see
anything past 7c1d4c6. Seven findings; these are the four that are claims rather
than behaviour, and a claim is what outlives the code.

CHANGELOG (finding 3) sat under "### Fixed" describing the bug as "the image
quota spent on a run whose requested prompt-engineering step never happened",
which implies that step now happens. It does not. The entry is scoped to what was
measured - the anchor - and a new "### Known issues" section states plainly that
--format-prompt still submits the prompt as typed.

--help (finding 2) promised Flow would rewrite the prompt and named "button not
found" as the only failure. That is the one case it gets wrong: the prompt is
submitted as typed even when the button IS found and clicked. Now names the real
limitation and keeps the best-effort promise it can actually honour.

The framing is deliberate and comes from a peer session reviewing the same class
of defect. The bug is NOT that the wait is too short - that reads as tuning. It is
that gflow reports success without observing it: prompt_formatted is emitted on
the click, so the absence of a completion inside a window we chose is recorded as
a completion. Same class as a 20s selector timeout read as "the feature is absent"
(#701) and a bundle photographed after teardown read as "the DOM was gone" (#722).
This one is the worst of the three because it fails toward SUCCESS - a premature
green looks exactly like the feature working, and nobody investigates a pass.

test_cascade_carries_no_display_label_anchor (finding 6) did not enforce the rule
its own docstring stated. It banned the byte sequence 'Format' in single quotes,
so it passed for "Format", for [aria-label*="Format"], and for 'Formatar' - the
exact string the spike observed on the pt account. Banning spellings loses to
translation by construction. Replaced with a whitelist: every text argument in the
cascade must BE a known ligature. Proven non-vacuous - accepts all four shipped
selectors, rejects all four bad spellings including 'Formatar'.

The is_visible comment (finding 7) claimed "a miss is one round trip". True for a
miss, false for a click timeout: entries 0 and 1 resolve to the SAME element on
migrated, 2 and 3 to the same element on labs, so a timeout is retried on an
identical element and the 5s budget is paid twice. Comment now states the real
worst case. Not de-duplicating the resolved elements here - that belongs with the
observed-rewrite rewrite, not in front of it.

NOT fixed here, recorded instead:
  - finding 1/5, the ~5.4s wait itself. /gflow:predict returned GO with conditions
    (8.2/10); it is a behaviour change on a paid path and gets its own PR.
  - finding 4, format_character_prompt resolves .first page-globally while
    _submit_body_prompt deliberately targets the LAST mounted box. On a two-box
    cohort the format click lands on the PORTRAIT composer. Real, and I have no
    two-box cohort to verify a fix against - naming it rather than blind-fixing it.

Verified: ruff + format clean, pyright 0 errors, 295 passed, doc links, hygiene
(1022 files), website mirror, 49 memory files, PII scan, and the corrected --help
renders.
Fixes the second half of #727, and the half that actually made the flag useless.
The anchor fix made the button findable; this makes the click mean something.

Flow rewrites SERVER-SIDE. Measured 2026-09-07 on denon82: the click fires a
batchexecute round trip and the reshaped text reaches the composer at ~5.4s. The
old code waited _jitter_ms(500), logged ui_automation.prompt_formatted and
returned True; _send_prompt submits on the very next line. So --format-prompt
shipped the prompt the user typed and threw the rewrite away, on every run, with
a success event in the log and exit 0.

THE DEFECT WAS NOT THE DURATION. It was reporting a success we had not observed:
the absence of a completion inside a window we chose, recorded as a completion.
Same class as a 20s selector timeout read as "the feature is absent" (#701) and a
bundle captured after teardown read as "the DOM was gone" (#722) - a measurement
that could not be taken, recorded as a measurement. This one is the worst of the
three because it fails toward SUCCESS: a premature green looks exactly like the
feature working, so nobody investigates it. Framing owed to the peer session that
named the class while reviewing the same shape in its own judge.

format_character_prompt now polls the bound composer until its text actually
changes, and True means observed-rewrite, not button-pressed.

Design per /gflow:predict (GO with conditions, 8.2/10, five personas):
  - Gate on the DOM, NOT on the wire. eAenfb's response arrives 4.2s BEFORE the
    text settles, so awaiting it reproduces the same early submit - and an
    undocumented batchexecute rpcid is not an anchor worth depending on. Comparing
    against the string we inserted never reads a display label, so it is
    locale-invariant by construction.
  - Length delta, not `!=`: Slate/ProseMirror normalise whitespace and bare
    inequality would fire on churn, re-reporting the false success with better
    telemetry. Normalisation moves a handful of chars; the observed rewrite moved
    632 (19 -> 651). Threshold 16.
  - Baseline is a PARAMETER, not instance state - holding it on self would couple
    it across generations on a reused page. Both callers already had the string.
  - Existing settle-gate idiom (monotonic deadline + jittered poll), not a new
    helper; module constants, no env var nobody would set.
  - Still best-effort, still `-> bool`: promoting a miss to an exception would land
    in EXIT_CODE_MAP, the MCP schema and the docs at once, and the body-prompt call
    site runs AFTER the face image already spent quota, so failing there aborts a
    saga mid-spend.
  - 30s budget, generous on purpose. Costs are asymmetric: too long is bounded
    latency on an opt-in flag whose expiry falls back to exactly the old behaviour;
    too short silently reintroduces the bug. elapsed_ms is logged on every success
    so a real distribution accrues instead of being fitted to n=1.
  - Lengths and a stable hash only, never the text. Flow ELABORATES a terse
    description into a detailed physical one, so the rewrite is more PII-dense than
    the input, and structlog is not governed by GFLOW_CLI_HISTORY_PROMPTS.
    Pinned by test_never_logs_the_prompt_text.

Also fixes /code-review finding 4, which no other reviewer caught: the button was
resolved page-globally with .first while _locate_body_prompt_box documents "the
LAST mounted box is the target - never the portrait's". On a two-box cohort the
format click landed on the PORTRAIT composer, reshaping the wrong prompt or
finding a disabled button and giving up. _locate_format_button now mirrors that
existing convention instead of inventing a second one. And a disabled match
`continue`s rather than `return False` - one stale anchor used to kill every
selector behind it, including the one that would have worked.

Events: prompt_formatted now means the observed rewrite (it always claimed to);
format_button_clicked carries the old semantics; format_not_observed reports a
timeout without claiming Flow failed - an unchanged box also covers Flow
declining, erroring, or judging the prompt already formatted, none of which were
provoked, so none are claimed.

Five tests written first and watched failing:
  TypeError: format_character_prompt() got an unexpected keyword argument 'prompt_box'
Then 912 passed in tests/api/transports/, pyright 0 errors, ruff clean.
An autouse fixture shrinks the 30s budget for the offline suite - wait_for_timeout
is mocked, so the loop would otherwise spin on wall-clock for a full minute across
two never-lands cases.

Still owed and not claimed: a live run proving the gate on real Flow.
Two defects, found by my own change overrunning the budget.

1. The budget was fitted to the old behaviour. A create drives up to two prompts
   and --format-prompt now waits for Flow's server-side rewrite on each, so the
   worst case grew by up to 60s on a run that already took ~220s. 300 -> 480.

2. A timeout HUNG instead of failing. subprocess.run(timeout=) kills the child
   then re-enters communicate() to drain the pipes, but a browser-driving child
   leaves Chrome grandchildren holding the inherited write handles, so the drain
   never returns. Observed: 32 minutes at near-zero CPU, indistinguishable from
   slow progress, until it was stopped by hand. An unbounded wait after a timeout
   defeats the timeout.

My first fix for (2) bounded the drain. It stopped the hang and then threw the
output away, so the very next failure reported a command and a duration and not
one transport event - I had traded a hang for a blind failure. Redirecting to
files removes the class instead: nothing blocks on a reader, and whatever the run
logged before it died is on disk and readable afterwards, including on the
timeout path.

Five sibling e2e modules carry their own pipe-based _run_gflow. Named in the
docstring rather than changed: only this one drives a browser long enough today
for the difference to show, and I cannot exercise the timeout path in the others.

The e2e assertions also now distinguish the two failure modes the gate created -
format_button_not_found (the anchor drifted) vs format_not_observed (clicked, no
rewrite) - and name the probe to run for each. prompt_formatted became
load-bearing for free: it is emitted only on an observed rewrite now, so the
assertion that passed for a year while the feature was broken finally means
what it says.
The live e2e passed - 1 passed in 406.20s, denon82, asserting the OBSERVED
rewrite plus format_button_clicked and rejecting format_not_observed,
format_click_failed and format_button_not_found. So --format-prompt works end to
end, and every claim I wrote an hour ago saying it does not is now false.

I wrote those claims, in four places, and they are exactly the kind that survive
because nobody thinks to look: a CHANGELOG "Known issues" block, a --help string
users read, a spike doc title, and that doc's conclusion. Swept by grep, not by
recall.

  - CHANGELOG: the Known-issues block is gone; the behaviour change is recorded
    under Changed, including what prompt_formatted now means and the two new
    events beside it.
  - --help: no longer advertises a KNOWN LIMITATION that no longer exists. It now
    names the real cost, because there is one.
  - the spike doc keeps its title in the past tense with a RESOLVED banner. It is
    a dated measurement, not a standing limitation, and deleting it would remove
    the evidence that makes the fix defensible.

MEASURED COST OF THE FIX, which the docs now state instead of implying:
--format-prompt takes 406s against a 210s control on the same account and face
prompt. Roughly double, and only partly the ~4-5s rewrite - the rest is Flow
generating from a 608-character elaboration instead of the 102 characters the
user typed. That is not a regression. It is what the feature costs, and it was
free only while it was doing nothing. It is also why _CREATE_TIMEOUT_S moved from
300 to 480: the old budget was fitted to a flag that did not work.

Verified: ruff + format clean, pyright 0 errors, 319 passed across the CLI,
transport and scripts suites, doc links, hygiene, website mirror, memory round
trip.
@ffroliva

ffroliva commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Live e2e green — both faults fixed

tests/e2e/test_character_create_e2e.py::test_character_create_format_prompt_clicks_format_button
1 passed in 406.20s (0:06:46)      profile denon82, migrated host

Transport events from a direct run of the same path:

16:52:21.960  ui_automation.format_button_clicked   selector='flow-format-prompt-button button'
16:52:26.138  ui_automation.prompt_formatted        elapsed_ms=4178
                                                    prompt_len_before=102  prompt_len_after=608
                                                    prompt_hash=f20df23c
16:52:30.495  ui_automation.prompt_submitted

Click → observed rewrite → submit, in that order. The inversion of those last two is the entire bug. Telemetry carries lengths and a stable hash and no prompt text, per the predict's security condition.

The control, because a result without one is a coincidence with formatting

run flag outcome
e2e, before the gate --format-prompt passed in 219.90s — but asserted only the click
e2e, after the gate --format-prompt TimeoutExpired at 480s
direct diagnostic --format-prompt OOM-killed at ~5min mid-poll — not evidence of a stall
control none completed in 210s, image on disk
e2e, re-run --format-prompt passed in 406.20s

Generation on this account was never broken. The overrun tracks the flag, and only ~9s of it is the gate — the rest is Flow generating from a 608-character elaboration instead of 102 characters. 406s vs 210s, roughly double.

That is not a regression. It is what the feature costs, and it was free only while it was doing nothing. It is also why the old 300s budget failed: that budget was fitted to a flag that did not work.

Claims I had to retract

An hour ago I wrote, in four places, that this flag does not work. All four are now false and all four were mine — a CHANGELOG "Known issues" block, the --help string, a spike-doc title, and that doc's conclusion. Swept by grep rather than recall, since the one that matters is always the one nobody thought to look at.

The spike doc keeps its past-tense title under a RESOLVED banner rather than being deleted: it is a dated measurement, and it is the evidence that makes the fix defensible.

Also fixed, found by the overrun

_run_gflow timed out and then hung — subprocess.run(timeout=) kills the child, then re-enters communicate() to drain pipes that surviving Chrome grandchildren still hold open. 32 minutes at near-zero CPU, indistinguishable from slow progress. My first fix bounded the drain, stopped the hang, and discarded the output — trading a hang for a blind failure that reported a command and a duration and not one transport event. Redirecting to files removes the class: nothing blocks on a reader, and the evidence survives the timeout path.

Five sibling e2e modules carry their own pipe-based _run_gflow. Named in the docstring, not changed — I cannot exercise the timeout path in the others.

@ffroliva
ffroliva merged commit 8ae50fb into develop Sep 7, 2026
18 checks passed
@ffroliva
ffroliva deleted the bugfix/727-format-button-anchor branch September 7, 2026 17:53
tranzmatt pushed a commit to tranzmatt/gflow-cli-unofficial that referenced this pull request Sep 9, 2026
… the false VERIFIED

Fixes ffroliva#731. docs/CHARACTER.md section 12 marked rows **VERIFIED** that stopped
being true at ffroliva#703, and the file is mirrored to website/docs, so the false claims
are published.

A row marked verified that is not is worse than no row: it is what a reader
trusts INSTEAD of reading the code. That is how ffroliva#727 got written - the carrier
split was documented nowhere a selector author would look, and the table
confidently said otherwise.

Every row re-derived by reading the constant, not edited from memory. What that
turned up, beyond what ffroliva#731 reported:

  - Model picker: the documented selector carried `aria-haspopup='menu'`, which
    appears in NONE of the four shipped entries. Not merely stale - it never
    matched the shipped tuple.
  - Generate / submit: SUBMIT_BUTTON_SELECTORS is single-carrier, and on the
    migrated host BOTH `<i>` entries miss. Only the third, `has-text('arrow_forward')`,
    fires - matching the mat-icon's text rather than its tag. Observed live
    2026-09-07: `prompt_submitted via="button:has-text('arrow_forward')"`. It works
    by luck, not by design; noted in the row and tracked in ffroliva#730.
  - Prompt box: the row claimed PROMPT_INPUT_SELECTORS[0]. On the migrated host [0]
    misses and [1] `div[contenteditable="true"]` is what matches (live, same run).
  - Editor-ready anchor and body-mode chip: both gained mat-icon / custom-element
    alternatives at ffroliva#703 that the rows never picked up.

The undated **VERIFIED** marker is replaced by a per-row date naming what the row
was checked against - code, live run, or both - so a row cannot silently outlive
its evidence again. The carrier split is stated at the top of the section, where
someone writing a selector will actually meet it.

Deliberately NOT included: the Format-button row and the docs/USAGE.md
`--format-prompt` entry. Both describe behaviour that is still changing in ffroliva#729,
and documenting a flag from this branch would describe the broken version. They
ship with that PR.

Also removed before commit: a [[ligature-carrier-differs-by-host]] wikilink I had
added here. That memory file lands with ffroliva#729, not develop - a pointer to something
that does not exist yet is the same defect this PR is fixing.

Verified: website mirror regenerated and in sync, doc links resolve, PII scan
clean, hygiene 1023 files. Three pre-existing dangling wikilinks remain in this
file (private-store-only memories); left alone, out of scope.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant