Skip to content

Gemini Enterprise accumulates one A2UI action dispatcher > per agent response — one click dispatches N duplicate message:send calls; > mutating actions execute N times #1631

@mscdw

Description

@mscdw

Root cause (isolated with a minimal probe, four controlled variants)

Probe variant (the per-turn-unique element) sends per click
surfaceId 1 → 2 → 3 → 4 (+1 per turn)
surfaceId and every component id same +1 ramp
nothing (fully static ids, the pattern of this repo's GE samples) same +1 ramp
surfaceId, component ids, and the action name string same +1 ramp

The ramp is identical across all four strategies, so no agent-side
identifier — surfaceId, component id, or action name — affects the dispatch
count.
GE accumulates one dispatch subscription per A2UI render in the
conversation and never releases them; a single physical click fires all of
them.

The action-name variant pins the mechanism precisely. With a unique action
name per turn (probe_click_t1, probe_click_t2, …), a click that produced N
duplicates returned N copies of the currently displayed card's action
name
— never a spread of the historical cards' names:

click on the turn-4 card → 3× userAction name=probe_click_t4   (not t2+t3+t4)

So the duplicates are N re-dispatches of the one clicked button, not N
historical cards each firing their own listener. They share the clicked card's
surfaceId and the client click timestamp; only the A2A messageId differs. N
grows by one per A2UI render and compounds (each duplicate's task is itself a
new turn) — we measured 50+ from one click in a long conversation.

The agent cannot reap the subscription. We tested the protocol's own
teardown: each turn emits deleteSurface for the prior surface before
rendering the new one (verified firing in the agent logs every turn). GE
ignores it cross-turn — the old card stays in the transcript and its
dispatch subscription keeps firing, so the ramp is unchanged. No agent-side
mechanism — no identifier strategy, not even deleteSurface — releases the
accumulated dispatchers. A client-side fix (reap a surface's dispatcher when
its turn scrolls out of the active set, or when deleteSurface is received)
is the only remedy.

Summary

Clicking a single A2UI button in a Gemini Enterprise conversation causes GE's
client to send the same userAction as N independent A2A message:send
requests
, where N grows roughly one per turn in the conversation. We observed
N=11 for one click in an 11-turn conversation. Each duplicate creates its own
task and executes the agent independently.

Minimal reproduction (no application code in the loop)

We reproduced this with a ~150-line probe agent — no LLM, no tools, no
templates, no deduplication
: a static v0.8 card with a turn counter and one
button (probe_a2ui/ in our repo; happy to share verbatim). Deployed via
vertexai…A2aAgent on Agent Engine, registered in Gemini Enterprise, fresh
conversation, four button clicks:

Click Sends received by the agent Turns executed
1 1 turn 2
2 2 turns 3, 4
3 3 turns 5, 6, 7
4 4 turns 8–11

A perfect +1 ramp per click. The user-visible effect on click 3: the card
flashed "Probe turn 5", "6", "7" in succession, then GE settled the display on
turn 4's card — an earlier task's slot. Because each duplicate's task adds
a conversation turn, N compounds: in a production conversation we measured a
single click fanning out to 50+ message:send calls within one afternoon.

Evidence (agent-side logs, Agent Engine A2A runtime, 2026-06-12)

One physical click produced 11 requests within ~700 ms. All carry the same
surfaceId
(the clicked card) and the same client click timestamp, but
a fresh A2A messageId each:

userAction received: name=view_quote surfaceId=quote-list-7df27db0-… ts=2026-06-12T00:50:51.315Z messageId=0cff3dd2-…
userAction received: name=view_quote surfaceId=quote-list-7df27db0-… ts=2026-06-12T00:50:51.315Z messageId=85dc907e-…
… (9 more, identical except messageId)

In a fresh conversation the duplication count ramps with turn count: first
click ×1, then ×2, ×3, … — consistent with an accumulating per-turn handler in
the GE client, all dispatching the same click event.

Reproduced with Google's own published GE sample (agent code verbatim)

Deployed samples/agent/adk/gemini_enterprise/agent_engine (the Contact Lookup
sample) from this repo, agent code byte-for-byte unchanged, its own
uv.lock runtime, registered to the same GE app. Clicking "View" on a contact
fanned out identically:

one "View" click  ->  4x  Received a2ui ClientEvent:
    {surfaceId: 'contact-list', sourceComponentId: 'view-button:contact1', name: 'view_profile'}
  (per-click receipts over three clicks: 4, 4, 1; 14 message:send, of which 2 were 500s)

Same shape as every other agent: N identical dispatches of the one clicked
button (same surfaceId + source component), each a separate message:send.
Two incidental findings from the run, both worth fixing in the sample:

  • The sample does not deploy on the current Agent Engine runtime as
    published — deploy.py pins a2a-sdk==0.3.25 without the [http-server]
    extra, so set_up() dies with ImportError: starlette/sse-starlette required. One-line dependency fix (a2a-sdk[http-server]) was the only
    change made.
  • The duplicate dispatches surface as 500 Internal Server Errors (2 of 14):
    the sample's executor lets a per-duplicate model failure escape execute()
    as an A2A InternalError instead of degrading to text. A boundary guard around
    execute() would contain it.

Same-engine control: the open A2UI client does NOT fan out (measured)

The defect is in the Gemini Enterprise client, not the A2UI protocol or
the agent. We drove the same deployed engine with the upstream
@a2ui/angular renderer (our out-of-GE web tier, headless Chromium),
clicking card buttons across eight turns, and counted dispatches both at the
browser (network) and at the proxy (server logs):

per-click /api/a2a dispatches (browser):  [1, 1, 1, 1, 1, 1, 1, 1]
proxy turns: 4× view_quote + 4× list_quotes, each ONE message:send,
             streamed, TASK_STATE_COMPLETED in ~0.4–0.6s, zero duplicates

Flat one-per-click through turn 8 — where GE, fed identical surfaceUpdate /
beginRendering frames from the same engine, ramps 1, 2, 3, 4…. The open
renderer holds one live surface per card kind (in-place updates) and
dispatches each resolved action through a single app-level handler; there is
no per-rendered-card listener to accumulate. The variable is isolated to the
GE client.

Impact

  1. Non-idempotent actions execute N times. A single "add item" click in our
    quoting agent executed its add_line_item MCP tool five times (quantities
    merged ×5, silently). Any agent whose buttons mutate state is affected; the
    GE samples in this repo don't surface it because their click actions are
    read-only lookups.
  2. Visible flashing: N tasks → N card renders per click. (Agents using
    per-turn-unique surfaceIds see N cards appear in sequence; GE appears to
    display the last-completing task's response.)

Additional display observations (from working around this)

While building a server-side defense we verified two GE display behaviors that
constrain any agent-side workaround:

  • GE shows the last-completing task's response slot for the click: if a
    duplicate completes with no parts after the real turn, the visible turn is
    blank ("User action triggered." with no card below it).
  • A surfaceId renders at the slot that first painted it; re-sending the
    same id from a later task updates the old slot in place and leaves the new
    (displayed) slot empty.

The workaround that renders correctly: dedupe server-side on the click event's
identity (surfaceId + client timestamp + action + context — never messageId,
which is fresh per duplicate), run the action exactly once, and replay the
first execution's parts VERBATIM to every duplicate task while using a stable
per-card-kind surfaceId — identical re-renders of the same surface are
visually invisible, and whichever task slot GE displays carries the full
response. Agent authors shouldn't need any of this.

What we'd expect

One click → one message:send. Concretely, a client-side fix (this is a GE
client defect — see the same-engine control above):

  • Dispatch a click only to the surface that originated it. The duplicates
    are N re-dispatches of the one clicked button to every live surface's
    accumulated handler; scoping dispatch to the originating surface yields one.
  • And/or reap a surface's action dispatcher when its turn leaves the active
    set, or when deleteSurface is received.
    Today GE retains every rendered
    surface's dispatcher for the life of the conversation and honors no teardown.

If at-least-once dispatch is intentional, please document it — agent authors
could then implement idempotent handling deliberately. But note no agent-side
mechanism prevents it (we tested unique surfaceIds, unique component ids,
unique action names, and deleteSurface — all still ramp), so absent a client
fix every author must carry a server-side deduper.

Agent-side workarounds we found necessary (until a client fix lands)

For others hitting this before it's fixed, two mitigations made our agent
robust — both arguably things the SDK/template should provide:

  1. Idempotency / dedupe. Key on the click event's identity
    (surfaceId + client timestamp + action + context — never messageId,
    which is fresh per duplicate); run the action once; replay the first result
    to the rest.
  2. A boundary guard around execute(). The duplicate storm makes
    model-driven renders fail intermittently, and an escaping exception becomes
    an A2A 500 (we saw this in the published Contact Lookup sample — 2 of 14
    message:send were 500s). Degrade to text instead.

Environment

  • Agent: custom A2A AgentExecutor on Vertex AI Agent Engine (A2aAgent
    template), A2UI v0.8 negotiated via the agent card extension.
  • Client: Gemini Enterprise web app, agent registered via
    a2aAgentDefinition.jsonAgentCard.
  • Reproduction: any A2UI agent whose card hosts buttons; click once in a
    multi-turn conversation and count message:send requests server-side.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

Status
Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions