From a7026ac8f8a9cf7ff3521cdd23f3f4addbf51e30 Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Sun, 13 Sep 2026 21:15:47 +0100 Subject: [PATCH 1/3] fix(799): name the agent-only composer instead of calling it selector drift Google has put some accounts on a flow.google.com composer with NO classic arm: the only prompt box is the agent panel, and aspect/model/count are Agent-settings defaults rather than per-request controls. Every control the migrated driver reaches for is structurally absent, so gflow waited out the 30 s readiness gate and raised UiSelectorDriftError -- exit 23, which reads as OUR frontend bug and invites a retry that cannot work. @Cstanish127 reported it with the DOM capture that made it diagnosable. It now raises FlowAgentUiError: exit 25, retryable=False, $0, pre-submit. The class was written for exactly this ("replaces the classic generation controls with a chat interface... fail cleanly instead of raising drift errors") -- it just had no way to be reached from the migrated host. THE DISCRIMINATOR IS THE CHIP. The DOM here is identical to #749's recoverable agent mode: the settings trigger present under a bare `hidden`. That is why it read as drift. What separates them is that a recoverable account still has a button.agent-mode-chip to turn off and this one has none anywhere -- so AGENT_MODE_CHIP_ANY exists alongside AGENT_MODE_CHIP, which matches only a PRESSED chip and therefore cannot tell "no agent/classic split" from "the split exists and is off". A trigger that has LEFT the DOM is untouched: that is a renamed selector, our bug, and stays drift. An un-pressed chip is deliberately excluded from the claim rather than guessed at: that state was never observed and contradicts the measured mechanism (pressed => hidden), so it falls through to drift. `retryable` moved from FlowAppError to GFlowError, under the condition FlowAppError's own comment set: "move it up if, and only if, a second class needs it." This is the second. FlowAgentUiError is in RETRYABLE_ERRORS because the labs A/B cohort flaps; which composer a migrated account gets is server-assigned and does not. test_only_flow_app_error_carries_the_override said it should fail if anyone widened this back without a second producer -- it fired, and is rewritten to record why rather than deleted. VERIFICATION. tests/api/transports/test_agent_only_composer.py drives real headless Chromium against the reporter's own captured markup, because the fix rests on one piece of third-party behaviour a fake cannot settle: whether Playwright reports + + +""" + +#: #749's recoverable arm: same hidden trigger, but a chip exists to turn off. The +#: recovery owns this, and the guard must keep its hands off it. +AGENT_MODE_RECOVERABLE = """ + + + + +""" + +#: Ordinary drift: the anchor is gone from the DOM entirely. Our bug, not a cohort. +TRIGGER_GONE = """ + +""" + +#: A healthy classic composer — the trigger is right there and visible. +HEALTHY = """ + + + +""" + + +@pytest.fixture +async def page() -> AsyncIterator[Page]: + """A real headless Chromium page, or skip if the browser isn't installed.""" + playwright_api = pytest.importorskip("playwright.async_api") + try: + async with playwright_api.async_playwright() as pw: + try: + browser = await pw.chromium.launch() + except Exception as exc: # pragma: no cover — environment-dependent + pytest.skip(f"chromium unavailable: {type(exc).__name__}: {exc}") + ctx = await browser.new_context() + new_page = await ctx.new_page() + try: + yield new_page + finally: + await ctx.close() + await browser.close() + except NotImplementedError as exc: # pragma: no cover — no subprocess loop + pytest.skip(f"playwright cannot start here: {exc}") + + +async def _verdict(page: Page, markup: str) -> bool: + from gflow_cli.api.transports.migrated_composer import READY_ANCHOR + + await page.set_content(markup) + trigger = page.locator(READY_ANCHOR).first + return await MigratedComposer._is_agent_only_composer(page, trigger) # pyright: ignore[reportPrivateUsage] + + +@pytest.mark.asyncio +async def test_a_bare_hidden_attribute_really_does_read_as_present_but_invisible( + page: Page, +) -> None: + """The load-bearing third-party assumption, measured rather than assumed.""" + from gflow_cli.api.transports.migrated_composer import READY_ANCHOR + + await page.set_content(AGENT_ONLY) + trigger = page.locator(READY_ANCHOR).first + + assert await trigger.count() == 1, "the reporter's class list must still match" + assert await trigger.is_visible() is False, "a bare `hidden` must not read as visible" + + +@pytest.mark.asyncio +async def test_the_reporters_captured_composer_is_recognised(page: Page) -> None: + assert await _verdict(page, AGENT_ONLY) is True + + +@pytest.mark.asyncio +async def test_the_recoverable_agent_mode_is_left_to_its_recovery(page: Page) -> None: + """A chip in the page means there IS a classic arm — #749 owns this, not #799.""" + assert await _verdict(page, AGENT_MODE_RECOVERABLE) is False + + +@pytest.mark.asyncio +async def test_a_vanished_trigger_stays_selector_drift(page: Page) -> None: + assert await _verdict(page, TRIGGER_GONE) is False + + +@pytest.mark.asyncio +async def test_a_healthy_composer_is_never_called_a_cohort(page: Page) -> None: + assert await _verdict(page, HEALTHY) is False diff --git a/tests/api/transports/test_migrated_composer.py b/tests/api/transports/test_migrated_composer.py index 76a5a1bbd..76799851c 100644 --- a/tests/api/transports/test_migrated_composer.py +++ b/tests/api/transports/test_migrated_composer.py @@ -32,6 +32,7 @@ from gflow_cli.errors import ( EXIT_CODE_MAP, ConfigurationError, + FlowAgentUiError, FlowHostMigratedError, InsufficientCreditsError, MediaUploadRejectedError, @@ -39,6 +40,7 @@ TransportTimeoutError, UiSelectorDriftError, WireFormatError, + is_retryable, ) # --- a tiny DOM ------------------------------------------------------------- @@ -155,8 +157,10 @@ class Dom: # to tell apart; `migrated_composer.AGENT_MODE_CHIP` carries the selector rationale # and points at the spike that measured all of this. agent_mode: bool = False - #: The mode is on but the chip is not in the DOM — nothing to click, so the driver - #: must fall back to the ordinary drift message rather than promise a recovery. + #: The chip is not in the DOM at all. With the trigger still present-but-hidden this + #: is #799's agent-only cohort — no classic arm to return to, so nothing to click and + #: nothing to promise; the driver names the cohort (exit 25) instead of calling it + #: drift. With the trigger GONE too it stays ordinary drift, a renamed selector. agent_chip_present: bool = True # A chip that will not toggle off. NOT observed on either of our accounts — kept # because the reporter's might pin the mode, and a fix that cannot say "I tried and @@ -585,6 +589,10 @@ def locator(self, css: str, *, scope: FakeLocator | None = None) -> FakeLocator: if css == migrated_composer.AGENT_MODE_CHIP: pressed = dom.agent_mode and dom.agent_chip_present return FakeLocator(self, "agent_chip", ["chip"] if pressed else []) + if css == migrated_composer.AGENT_MODE_CHIP_ANY: + # Presence alone, independent of `agent_mode` — that is the whole point of + # the second selector: #799's cohort renders no chip in any state. + return FakeLocator(self, "agent_chip_any", ["chip"] if dom.agent_chip_present else []) if css == "flow-agent-panel button": buttons = [Radio("close", "Close")] if dom.agent_panel_expanded else [] return FakeLocator(self, "agent_close", buttons) @@ -2372,19 +2380,59 @@ async def test_ensure_editor_still_names_agent_mode_when_the_chip_click_fails() assert "migrated.agent_mode_exit_failed" in [e["event"] for e in logs] -async def test_ensure_editor_falls_back_to_drift_when_agent_mode_renders_no_chip() -> None: - """Mode on, chip absent — a cohort that hides it, or a renamed class. There is - nothing to click, so nothing may be promised: the ordinary drift message is the - honest one, and the `agent_chip_present` knob exists for exactly this state.""" +async def test_ensure_editor_names_the_agent_only_cohort_instead_of_drift( + install_log_capture, +) -> None: + """#799: the trigger is in the DOM under a bare `hidden` and there is NO chip. + + This used to be ordinary selector drift, on the reasoning that with nothing to + click nothing may be promised. That was right about the recovery and wrong about + the diagnosis: #799 supplied the ground truth this state had been missing — a + third cohort whose composer is agent-only, with no classic arm to return to and + therefore no chip to un-press. The DOM is identical to a pressed chip's (#749), + which is why it read as drift, and why the chip's ABSENCE is what separates them. + + Exit 25, not 23, and not retryable: a driver that does not exist will not appear + on a re-run. + """ from gflow_cli.api.transports.migrated_composer import MigratedComposer page = FakePage() page.dom.agent_mode = True page.dom.agent_chip_present = False - with pytest.raises(UiSelectorDriftError) as exc: + + with pytest.raises(FlowAgentUiError) as exc: await MigratedComposer().ensure_editor(page, "p1", timeout_s=0.2) + assert page.dom.agent_chip_clicks == 0 - assert "agent mode" not in str(exc.value).casefold() + assert EXIT_CODE_MAP[FlowAgentUiError] == 25 + assert is_retryable(exc.value) is False + assert "agent-only" in str(exc.value).casefold() + # The class default talks about an A/B cohort and suggests another Chrome profile; + # neither applies when the composer is a property of the Google account. + hint = exc.value.remediation_hint + assert "#799" in hint + assert "A/B" not in hint + events = [e["event"] for e in install_log_capture.entries] + assert "migrated.agent_only_composer" in events + + +async def test_ensure_editor_still_reports_drift_when_the_trigger_is_gone_entirely() -> None: + """The discriminator is present-but-hidden, not merely "not visible". + + A trigger that has left the DOM is a renamed or restructured selector — our bug, + filed against us — and must not be dressed up as somebody else's cohort. + """ + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + page = FakePage() + page.dom.agent_mode = True + page.dom.agent_chip_present = False + page.dom.trigger_present = False + + with pytest.raises(UiSelectorDriftError) as exc: + await MigratedComposer().ensure_editor(page, "p1", timeout_s=0.2) + assert "agent-only" not in str(exc.value).casefold() async def test_ensure_editor_reports_drift_once_agent_mode_is_actually_off() -> None: diff --git a/tests/test_errors_classification.py b/tests/test_errors_classification.py index 8eebe6682..e9b117557 100644 --- a/tests/test_errors_classification.py +++ b/tests/test_errors_classification.py @@ -159,14 +159,18 @@ def test_classify_content_safety_handles_multiple_details() -> None: class TestPerInstanceRetryability: - """`FlowAppError.retryable` overrides the class answer for one raise site. - - It exists because `FlowAppError` (exit 31) now covers two shapes with different - retry semantics: Flow's client-side crash page, where a retry genuinely works, - and its `/about` redirect (#756), where retryability is UNMEASURED — the redirect - stopped reproducing on `ci-probe` between 2026-09-08 and 2026-09-10 - (docs/superpowers/spikes/2026-09-10-about-redirect-stability.md). One flag for - both would have made the class answer an assertion nobody checked. + """`GFlowError.retryable` overrides the class answer at a raise site. + + It began on `FlowAppError` (exit 31), which covers two shapes with different retry + semantics: Flow's client-side crash page, where a retry genuinely works, and its + `/about` redirect (#756), where retryability is UNMEASURED — the redirect stopped + reproducing on `ci-probe` between 2026-09-08 and 2026-09-10 + (docs/superpowers/spikes/2026-09-10-about-redirect-stability.md). One flag for both + would have made the class answer an assertion nobody checked. + + It now lives on the base, under the condition its own comment set: *"move it up if, + and only if, a second class needs it."* #799 is that second class — see + `test_the_override_moved_up_because_a_second_class_needed_it`. """ def test_class_answer_is_unchanged_when_no_override(self) -> None: @@ -179,19 +183,28 @@ def test_instance_override_wins(self) -> None: assert is_retryable(FlowAppError(detail="/about", retryable=False)) is False - def test_only_flow_app_error_carries_the_override(self) -> None: - """Scoped to the one class that needs it (council D14). + def test_the_override_moved_up_because_a_second_class_needed_it(self) -> None: + """This test used to assert the opposite, and that is the point. - A base-class field would sit on every error in the project to serve one - raise site. `is_retryable` reads it by `getattr`, so narrowing costs nothing - — and this test is what makes the narrowing visible if someone widens it back - without a second producer to justify it. + It pinned the override to `FlowAppError` alone, and said it should fail if + anyone widened it back "without a second producer to justify it". #799 is the + second producer: a migrated account whose composer is agent-only raises + `FlowAgentUiError`, which is in `RETRYABLE_ERRORS` because the labs A/B cohort + flaps — while which composer a migrated account gets is server-assigned and + does not. So the flag moved to the base, exactly as `FlowAppError`'s own + comment specified, and the guard is rewritten rather than deleted. """ - from gflow_cli.errors import UiSelectorDriftError, is_retryable + from gflow_cli.errors import FlowAgentUiError, UiSelectorDriftError, is_retryable + # The class answer still rules when no raise site overrode it. assert is_retryable(UiSelectorDriftError(detail="drift")) is False - with pytest.raises(TypeError): - UiSelectorDriftError(detail="drift", retryable=True) # type: ignore[call-arg] + assert is_retryable(FlowAgentUiError(detail="labs A/B cohort")) is True + + # Producer two: the #799 raise site turns its class answer off. + assert is_retryable(FlowAgentUiError(detail="agent-only", retryable=False)) is False + + # Available to any error now, not a TypeError as it was before. + assert is_retryable(UiSelectorDriftError(detail="drift", retryable=True)) is True def test_the_signin_landing_raises_auth_expired(self) -> None: """The `"signin"` arm, which only the e2e reached before — and `addopts` diff --git a/website/docs/KNOWN_ISSUES.md b/website/docs/KNOWN_ISSUES.md index 649bf9a03..875a7bb9e 100644 --- a/website/docs/KNOWN_ISSUES.md +++ b/website/docs/KNOWN_ISSUES.md @@ -1247,6 +1247,35 @@ continue as a seeded I2V generation — tracked as backlog. ## Mitigated +### Migrated accounts can have an agent-only composer, which gflow cannot drive + +Some accounts on `flow.google.com` are served a composer with **no classic arm at all**: +the only prompt box is the agent panel, there is no `agent-mode-chip` to turn off, and +aspect / model / count are Agent-settings **defaults** rather than per-request controls. +Every control the migrated driver reaches for is structurally absent, so `gflow video` +and `gflow image` cannot run there at all. + +**Mitigation (v0.74.0):** the state is named before submit — `FlowAgentUiError`, exit 25, +`retryable: false`, $0 spent — instead of the generic `UiSelectorDriftError` (exit 23) +that reads as a gflow frontend bug and invites a doomed retry. The discriminator is the +chip, because the DOM is otherwise identical to the *recoverable* agent mode of +[#749](https://github.com/ffroliva/gflow-cli/issues/749): a hidden settings trigger **with** +a chip is recoverable and gflow turns it back itself; a hidden trigger with **no chip +anywhere** is this cohort. + +**No workaround inside gflow.** No flag, `--ui-mode`, or profile change reaches it — the +composer is a property of the Google account. Generating from the Flow web UI still works. +A driver for the agent panel is not implemented; +[#799](https://github.com/ffroliva/gflow-cli/issues/799) stays open for it. + +**Prevalence is unmeasured.** One reporter, one account (Windows 11, ru locale), whose DOM +capture is what made this diagnosable. No account available to the maintainers is in this +cohort, so the guard is verified against the reporter's captured markup driven by a real +Chromium (`tests/api/transports/test_agent_only_composer.py`) and **not** against live +Flow — that needs an account in the cohort and is the named blocker on #799. + +--- + ### Flow can pin the agentic cohort server-side for hours The classic↔agentic editor arm is server-assigned per page load and normally diff --git a/website/docs/MCP.md b/website/docs/MCP.md index 3b6390fda..6868b5c93 100644 --- a/website/docs/MCP.md +++ b/website/docs/MCP.md @@ -362,11 +362,19 @@ aisandbox REST reads fail. > believe `auth status`.** Through v0.73.2 the second site carried the class-default > SAPISID advice instead — see the CHANGELOG for why. -The migrated composer itself also comes in more than one shape. If `gflow_generate_video` -fails pre-submit with a selector-drift envelope naming `.settings-trigger-button` as -present-but-hidden, and no `agent-mode-chip` exists on the page, the account is on the -**agent-only composer**, which has no classic composer at all — there is nothing to drive -yet ([#799](https://github.com/ffroliva/gflow-cli/issues/799)). +The migrated composer itself also comes in more than one shape. Since v0.74.0 an account +whose composer is **agent-only** — no classic composer at all — is named as such before +submit: `FlowAgentUiError`, **exit 25**, `retryable: false`, saying the settings trigger is +present but hidden and that no `agent-mode-chip` exists to turn off. Through v0.73.2 that +same account got a generic selector-drift envelope (exit 23), which reads as *our* bug and +invites a retry that cannot work ([#799](https://github.com/ffroliva/gflow-cli/issues/799)). + +The distinction that matters, because the DOM is identical either way: a **pressed chip** +means the classic arm exists and gflow turns it back on itself +([#749](https://github.com/ffroliva/gflow-cli/issues/749)); **no chip at all** means there +is no classic arm to return to, aspect / model / count are Agent-settings defaults rather +than per-request controls, and no flag or profile change helps. Generating from the Flow +web UI still works. ### 6.2 Envelope → cause → what to do diff --git a/website/docs/USAGE.md b/website/docs/USAGE.md index 41761bc6d..0af7d7e34 100644 --- a/website/docs/USAGE.md +++ b/website/docs/USAGE.md @@ -1789,7 +1789,7 @@ shell scripts can branch on the failure mode without parsing stderr. | `22` | `UpscaleUnavailableError` | 4K upscale is gated to Flow **Ultra** accounts (HTTP 403) | Use `--scale 2k`, or upgrade the Flow plan | | `23` | `UiSelectorDriftError` | A Flow editor control could not be located — Google changed the frontend (issues #183, #493) — **or** a blocking announcement overlay survived dismissal, so no control below it can be clicked (probe `overlay_close_button`, #593). On the migrated host a missing submit control is checked against the wallet first and reported as **37** when Flow swapped in its insufficient-credits warning, so a short balance no longer arrives here | Update gflow-cli; file a bug with the probe name + the diagnostics JSON / debug screenshot referenced in the error message, plus the incident bundle's `report.md` when one was written. For `overlay_close_button`: open the project once in Chrome and dismiss the announcement — the dismissal persists on your account | | `24` | `BrowserEngineUnavailableError` | `GFLOW_CLI_BROWSER_ENGINE=patchright` but the engine is not installed | `pip install 'gflow-cli[patchright]'`, or unset `GFLOW_CLI_BROWSER_ENGINE` | -| `25` | `FlowAgentUiError` | The profile is on Flow's Agentic UI cohort and the classic media panel is unrecoverable for this operation | Rare since v0.38.0 (#332): the mode controller reliably recovers agentic→classic, so first retry with `--ui-mode classic`; if it persists, see KNOWN_ISSUES on the agentic cohort | +| `25` | `FlowAgentUiError` | The profile is on Flow's Agentic UI cohort and the classic media panel is unrecoverable for this operation — **or**, on `flow.google.com`, the account's composer is agent-only and there is no classic arm at all ([#799](https://github.com/ffroliva/gflow-cli/issues/799)) | On labs this is rare since v0.38.0 (#332): the mode controller reliably recovers agentic→classic, so first retry with `--ui-mode classic`. The migrated agent-only cohort reports `retryable: false` — no flag or profile change helps; use the Flow web UI. See KNOWN_ISSUES | | `26` | `MediaAttributionError` | Generated media could not be reliably attributed to this request (issue #281) | Re-run; a dedicated project with fewer pre-existing assets avoids the ambiguity | | `27` | `MediaUploadRejectedError` | Flow's upload endpoint refused the input file (`uploadImage` 4xx, issue #287) | Re-encode the image (`ffmpeg -q:v 2 -map_metadata -1`), or reference the asset by its media UUID | | `28` | `UiModeUnavailableError` | The Flow UI arm this command required (`--ui-mode`/`GFLOW_CLI_UI_MODE`; `-i` forces agentic for images; **video always requires classic** — no agentic video driver exists) couldn't be reached after a switch attempt; aborted before submitting — no credits spent (issue #299) | Retry (the cohort flaps per load); try another `--profile`; for images you can also relax `GFLOW_CLI_UI_MODE` — for video there is nothing to relax | From 883bf47321b3e6576c2a2759c630b178b104f8bc Mon Sep 17 00:00:00 2001 From: Flavio Oliva Date: Sun, 13 Sep 2026 22:21:42 +0100 Subject: [PATCH 2/3] fix(799): close the council's findings, and measure the live controls The substantive one is D6: a $0 live negative control WAS available and I had not run it, while the PR named a blocker broad enough to imply nothing live could be measured. That is the failure this project treats as equal to omitting the item. Run now, on real Flow: healthy migrated composer (ffroliva, real project page) trigger present + VISIBLE, button.agent-mode-chip present and UN-PRESSED -> _is_agent_only_composer = False live /about landing (denon82, #756) trigger absent -> False, i.e. the TRIGGER_GONE arm on a real page The first is load-bearing and it corrects a claim in my own comment. I had written that an un-pressed chip "was never observed". It is in fact the NORMAL state of a healthy migrated composer -- which is the point: a working account on this host HAS a chip, so having none is genuinely anomalous rather than merely unmatched. The state actually never observed is an un-pressed chip beside a HIDDEN trigger, and the comment now says that instead. D4: the fail-closed branch of _is_agent_only_composer was UNCOVERED -- the fake's raise guard keys on kind "agent_chip" and the new selector builds "agent_chip_any", so the docstring's "an unreadable page is not evidence of a cohort" was untested and new-code coverage sat near 73%, under Sonar's floor. Guard widened, test added (pressed probe answers, ANY probe goes dark -> drift, not exit 25). migrated_composer.py coverage 80% -> 93%. D9 found two prose contradictions that survived the clean rebase, exactly as [[prose-conflicts-hide-in-disjoint-files]] predicts, and both were in regions I had not re-read: docs/MCP.md still said only ONE raise site overrides its retryable class (this PR adds the second), and still said generation "still works" in every migrated row twelve lines above the cohort where it cannot. D15: exit-code moves belong where script owners look, so ### Changed now carries the 23 -> 25 migration note; MCP.md says exit-25-EQUIVALENT, since that file's own rule is that MCP returns envelopes, never exit codes; FlowAgentUiError's class docstring described the labs A/B cohort as its only producer. D14: the Chromium page fixture was a byte-for-byte third copy -- moved to the transports conftest that already existed for it, and the two older copies deleted with it. Kept the name `page` so no test signature changed. D4 asked that the reason a real browser is required travel with it; it does. D1: FlowApiError's legacy positional branch pops named kwargs one at a time, so it silently ate `retryable=` once the base accepted it. Forwarded. Also: skills/gflow-cli/SKILL.md gains the row that stops an agent advising a retry or --ui-mode classic on exit 25 -- the only Codex/Cursor-facing surface where the labs-vs-migrated distinction matters. Refs #799 --- CHANGELOG.md | 18 +++++-- KNOWN_ISSUES.md | 11 +++-- docs/MCP.md | 15 ++++-- skills/gflow-cli/SKILL.md | 1 + .../api/transports/migrated_composer.py | 10 ++-- src/gflow_cli/errors.py | 24 +++++++--- tests/api/transports/conftest.py | 37 ++++++++++++++ .../transports/test_agent_only_composer.py | 48 ++++++++----------- tests/api/transports/test_ligature_carrier.py | 21 -------- .../api/transports/test_migrated_composer.py | 31 +++++++++++- .../transports/test_overlay_precondition.py | 23 --------- website/docs/KNOWN_ISSUES.md | 11 +++-- website/docs/MCP.md | 15 ++++-- 13 files changed, 162 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46bf2e186..cd89d70c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **On `flow.google.com`, an agent-only composer now exits `25` (`FlowAgentUiError`) where + it previously exited `23` (`UiSelectorDriftError`)** — and reports `retryable: false` + rather than inheriting that class's retryable default. Scripts branching on `23` for + this failure must add `25`. Nothing else moved: a trigger missing from the DOM is still + exit 23. See the `### Fixed` entry below for why + ([#799](https://github.com/ffroliva/gflow-cli/issues/799)). + ### Fixed - **Migrated-host i2v: the Frames picker is confirmed when it does not commit on the @@ -76,13 +85,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `button.agent-mode-chip` to turn off while this one has none. A trigger that has left the DOM entirely is unchanged — that is a renamed selector, our bug, and still drift. + `retryable` moved from `FlowAppError` to the error base to make that possible — its own + comment set the condition, *"move it up if, and only if, a second class needs it"*, and + this is the second. No existing error's retryability changed. + Reported with the DOM evidence that made it diagnosable by **@Cstanish127**. gflow-cli still has no driver for that composer; #799 stays open for it. -- **`retryable` is now a per-raise override on every error, not just `FlowAppError`.** - Its own comment set the condition — *"move it up if, and only if, a second class needs - it"* — and #799 is the second: `FlowAgentUiError` is in `RETRYABLE_ERRORS` because the - labs A/B cohort flaps, while which composer a migrated account gets does not. - ## [0.73.2] — 2026-09-12 ### Fixed diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 067227d16..af130cbf8 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -1270,9 +1270,14 @@ A driver for the agent panel is not implemented; **Prevalence is unmeasured.** One reporter, one account (Windows 11, ru locale), whose DOM capture is what made this diagnosable. No account available to the maintainers is in this -cohort, so the guard is verified against the reporter's captured markup driven by a real -Chromium (`tests/api/transports/test_agent_only_composer.py`) and **not** against live -Flow — that needs an account in the cohort and is the named blocker on #799. +cohort, so the **positive** case is verified against the reporter's captured markup driven +by a real Chromium (`tests/api/transports/test_agent_only_composer.py`) and **not** against +live Flow — that needs an account in the cohort and is the named blocker on #799. + +The **negative** controls were measured live at $0 on 2026-09-13, which is what makes the +discriminator more than a guess: a healthy migrated composer carries +`button.agent-mode-chip` **present and un-pressed** with its settings trigger visible. So a +migrated account normally has a chip, and having none is the anomaly this keys on. --- diff --git a/docs/MCP.md b/docs/MCP.md index 6868b5c93..ac4b5b982 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -141,15 +141,19 @@ marks a transient failure a scheduler can re-run without operator intervention WAF/reCAPTCHA bounce (`WafRejectionError`), rate-limit (`RateLimitError`), transport timeout (`TransportTimeoutError`), network blip (`NetworkError`), a dropped browser session (`BrowserSessionClosedError`), a Flow web-app crash -(`FlowAppError`), an agentic-cohort flap (`FlowAgentUiError`), an unreachable +(`FlowAppError`), an agentic-cohort flap (`FlowAgentUiError` — but see the +agent-only exception in § 6.1, which overrides it to `false`), an unreachable UI arm (`UiModeUnavailableError`), and a partially-completed sync (`SyncPartialError`). That list is `errors.RETRYABLE_ERRORS`, but it is no longer the whole answer: `errors.is_retryable` consults the **instance** first, so a raise -site can override its class. One does today — Flow's `/about` redirect raises +site can override its class. Two do today — Flow's `/about` redirect raises `FlowAppError` with `retryable: false`, and since 2026-09-11 that is a **measurement**, not a preserved default: caught during a live occurrence, 5/5 consecutive attempts over ~3 minutes landed on `/about` again, on the account's own project with a healthy session — so a retry is doomed and costs ~35 s each ([#756](https://github.com/ffroliva/gflow-cli/issues/756)). The +second is the migrated **agent-only composer**, which raises `FlowAgentUiError` with +`retryable: false` because which composer an account gets is server-assigned and does not +flap ([#799](https://github.com/ffroliva/gflow-cli/issues/799)). The *cause*, and whether it ever clears, remain unmeasured. Read the flag off the envelope; never re-derive it from the class list. Everything @@ -350,8 +354,9 @@ gflow credits user # does the Bearer path still work for this account? | verified | fails, `"credits endpoint returned 401"` | migrated; a token exists but aisandbox-pa rejects it | Same outcome, different raise site. Since v0.74.0 this one names the cause too ([#795](https://github.com/ffroliva/gflow-cli/issues/795)); through v0.73.2 it read `"aisandbox-pa returned 401 after token refresh"`. Measured on a migrated account, 2026-09-13 | | "Signed in to Google, but not to the Flow app" **forever** | — | migrated; labs no longer mints a Flow session at all | Login cannot complete on the released build. Tracked in [#791](https://github.com/ffroliva/gflow-cli/issues/791) | -In every migrated row, **generation over the migrated composer still works** — only the -aisandbox REST reads fail. +In every migrated row above, **generation over the migrated composer still works** — only +the aisandbox REST reads fail. The one exception is the agent-only composer described +below, where there is no composer for gflow to drive at all. > **A `credits` failure does not mean your cookies are stale.** Two raise sites produce it > — labs answering with no token, and aisandbox-pa rejecting the token labs did issue — @@ -364,7 +369,7 @@ aisandbox REST reads fail. The migrated composer itself also comes in more than one shape. Since v0.74.0 an account whose composer is **agent-only** — no classic composer at all — is named as such before -submit: `FlowAgentUiError`, **exit 25**, `retryable: false`, saying the settings trigger is +submit: `FlowAgentUiError`, **exit-25-equivalent**, `retryable: false`, saying the settings trigger is present but hidden and that no `agent-mode-chip` exists to turn off. Through v0.73.2 that same account got a generic selector-drift envelope (exit 23), which reads as *our* bug and invites a retry that cannot work ([#799](https://github.com/ffroliva/gflow-cli/issues/799)). diff --git a/skills/gflow-cli/SKILL.md b/skills/gflow-cli/SKILL.md index c23ee72f0..67f1168fe 100644 --- a/skills/gflow-cli/SKILL.md +++ b/skills/gflow-cli/SKILL.md @@ -280,6 +280,7 @@ Documented errors agents commonly make — negative examples for the SkillOpt tr | Python: `client = FlowApiClient(...)` then method calls | Must use `async with FlowApiClient(...) as client:` — it's an async context manager | | Python: `from gflow_cli import FlowApiClient` | `from gflow_cli.api.client import FlowApiClient` | | `gflow video t2v`/`i2v`/`r2v` without `--project` on an account Google moved to `flow.google.com` (exit 11), or an unported UUID/entity reference or model (exit 36) | Pass `--project ` — migrated hosts support video t2v, local-file i2v/r2v, and image t2i/i2i (local refs) only; exit 36 is non-retryable, `GFLOW_CLI_FLOW_HOST=labs.google` is the kill switch (see USAGE § gflow video t2v / i2v / r2v and image sections) | +| Telling a user on `flow.google.com` whose run exits **25** to retry, switch profile or pass `--ui-mode classic` | Their account's composer is **agent-only** — no classic arm exists, so aspect/model/count are Agent-settings defaults and gflow has no driver for it (`retryable: false`, $0, pre-submit). Nothing in gflow reaches it; the Flow web UI still works ([#799](https://github.com/ffroliva/gflow-cli/issues/799)). Exit 25 on `labs.google` IS the retryable A/B cohort — check the host before advising | | Suggesting a native `batch` subcommand under `gflow video` | It doesn't exist — that stub never worked and was removed. Loop `gflow video t2v`/`i2v` from the shell for multi-clip runs (`gflow image batch manifest.tsv\|json` is the real, working batch command, but it's image-only) | ## Disclaimer diff --git a/src/gflow_cli/api/transports/migrated_composer.py b/src/gflow_cli/api/transports/migrated_composer.py index a0c652007..088a87cde 100644 --- a/src/gflow_cli/api/transports/migrated_composer.py +++ b/src/gflow_cli/api/transports/migrated_composer.py @@ -862,9 +862,13 @@ async def _is_agent_only_composer(page: Page, trigger: Any) -> bool: the chip is what makes a hidden trigger recoverable (#749), so its absence is what says this account has no classic arm to go back to. - Reading the chip un-pressed is deliberately excluded from the claim. That state - was never observed and contradicts the measured mechanism (pressed ⇒ hidden), so - it falls through to drift rather than being asserted as a cohort. + An un-pressed chip is deliberately excluded from the claim, and measuring it is + what makes the rest of this worth doing: on a live healthy migrated composer + (ffroliva, 2026-09-13, $0) the chip IS present and un-pressed while the trigger + is visible — so a normal account on this host has a chip, and having none is the + anomaly. An un-pressed chip beside a HIDDEN trigger is the state never observed; + it contradicts the measured mechanism (pressed ⇒ hidden) and falls through to + drift rather than being asserted as a cohort. Fail-closed: an unreadable page is not evidence of a cohort. """ diff --git a/src/gflow_cli/errors.py b/src/gflow_cli/errors.py index fb77b03fa..d9afa97fa 100644 --- a/src/gflow_cli/errors.py +++ b/src/gflow_cli/errors.py @@ -181,6 +181,12 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: instance=kwargs.pop("instance", None), route=route_kw, remediation_hint=kwargs.pop("remediation_hint", None), + # Forwarded like its siblings above. This branch pops named kwargs one + # by one, so an unlisted one is dropped in silence — and since + # `retryable` moved onto the base it now LOOKS accepted here while + # doing nothing (council D1). No caller passes it today; the cost of + # it being wrong later is a doomed retry nobody can explain. + retryable=kwargs.pop("retryable", None), ) self.body = body else: @@ -698,11 +704,17 @@ class InsufficientCreditsError(GFlowError): class FlowAgentUiError(GFlowError): - """Raised when Google Flow's new Agentic UI cohort is detected at runtime. - - This cohort replaces the classic generation controls with a chat interface - that is not supported by gflow-cli. Raising this error allows the CLI to - fail cleanly with exit code 25 instead of timing out or raising drift errors. + """Raised when a Flow composer replaces the classic generation controls with a + chat interface gflow-cli cannot drive. Exit 25 instead of a timeout or a drift + error. + + **Two producers, and they differ on retry.** On labs.google this is the Agentic + UI A/B cohort, which is server-assigned per page load and flaps — hence class + membership in ``RETRYABLE_ERRORS``, and hence the default remediation's advice to + try another profile. On the migrated ``flow.google.com`` host it is also raised + for an account whose composer is **agent-only** (#799): no classic arm exists at + all, so that raise site passes ``retryable=False`` and its own + ``remediation_hint``, because no retry or profile change can reach it. """ problem_type = "https://gflow-cli.dev/errors/flow-agent-ui" @@ -1378,7 +1390,7 @@ def is_retryable(exc: GFlowError) -> bool: """Shared retry classification consumed by every machine-readable error surface. The class answer (``RETRYABLE_ERRORS``) unless the raise site overrode it — see - ``FlowAppError.retryable``. + ``GFlowError.retryable``. ``isinstance(..., bool)`` rather than a truthiness test, deliberately: a ``MagicMock`` answers every ``getattr`` with a truthy child mock, so diff --git a/tests/api/transports/conftest.py b/tests/api/transports/conftest.py index b74144d99..6bc8fc1fa 100644 --- a/tests/api/transports/conftest.py +++ b/tests/api/transports/conftest.py @@ -11,12 +11,49 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import pytest from gflow_cli.api.transports.drivers import factory +if TYPE_CHECKING: # pragma: no cover + from collections.abc import AsyncIterator + + from playwright.async_api import Page + @pytest.fixture(autouse=True) def _fast_ui_detection(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(factory, "_DETECT_TIMEOUT_S", 0.0) monkeypatch.setattr(factory, "_DETECT_POLL_INTERVAL_S", 0.0) + + +@pytest.fixture +async def page() -> AsyncIterator[Page]: + """A real headless Chromium page, or skip if the browser isn't installed. + + Shared because three files drive production selectors against captured markup and + each had grown its own byte-identical copy (council D14). Named `page` so no test + signature changes; a file needing different launch options can still shadow it. + + Why a REAL browser and not a fake: a fake answers whatever it was written to + answer, so it cannot settle what a CSS engine actually does with the markup — see + `test_agent_only_composer.py`'s module docstring for the worked example. + """ + playwright_api = pytest.importorskip("playwright.async_api") + try: + async with playwright_api.async_playwright() as pw: + try: + browser = await pw.chromium.launch() + except Exception as exc: # pragma: no cover — environment-dependent + pytest.skip(f"chromium unavailable: {type(exc).__name__}: {exc}") + ctx = await browser.new_context() + new_page = await ctx.new_page() + try: + yield new_page + finally: + await ctx.close() + await browser.close() + except NotImplementedError as exc: # pragma: no cover — no subprocess loop + pytest.skip(f"playwright cannot start here: {exc}") diff --git a/tests/api/transports/test_agent_only_composer.py b/tests/api/transports/test_agent_only_composer.py index 3ad0faf68..cd785a9b6 100644 --- a/tests/api/transports/test_agent_only_composer.py +++ b/tests/api/transports/test_agent_only_composer.py @@ -8,11 +8,26 @@ real browser can, and AGENTS.md asks for exactly this before wiring in an assumption about someone else's runtime. -The markup is the reporter's own, from the issue body — the `hidden` attribute, the -`settings-trigger-button` class among others, the `aria-label`. What this does NOT -prove is that Flow still serves it; that needs an account in that cohort, which is a -named external blocker on #799. It proves the discriminator is sound on the DOM we -were given, and that its three negative controls do not fire. +Provenance, precisely: the reporter's issue posts an elided `