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
- 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.
- 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:
- 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.
- 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.
Root cause (isolated with a minimal probe, four controlled variants)
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 Nduplicates returned N copies of the currently displayed card's action
name — never a spread of the historical cards' names:
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
deleteSurfacefor the prior surface beforerendering 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 theaccumulated dispatchers. A client-side fix (reap a surface's dispatcher when
its turn scrolls out of the active set, or when
deleteSurfaceis received)is the only remedy.
Summary
Clicking a single A2UI button in a Gemini Enterprise conversation causes GE's
client to send the same
userActionas N independent A2Amessage:sendrequests, 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 viavertexai…A2aAgenton Agent Engine, registered in Gemini Enterprise, freshconversation, four button clicks:
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:sendcalls 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 clicktimestamp, buta fresh A2A
messageIdeach: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 Lookupsample) from this repo, agent code byte-for-byte unchanged, its own
uv.lockruntime, registered to the same GE app. Clicking "View" on a contactfanned out identically:
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:
published —
deploy.pypinsa2a-sdk==0.3.25without the[http-server]extra, so
set_up()dies withImportError: starlette/sse-starlette required. One-line dependency fix (a2a-sdk[http-server]) was the onlychange made.
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/angularrenderer (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):
Flat one-per-click through turn 8 — where GE, fed identical
surfaceUpdate/beginRenderingframes from the same engine, ramps 1, 2, 3, 4…. The openrenderer 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
quoting agent executed its
add_line_itemMCP tool five times (quantitiesmerged ×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.
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:
duplicate completes with no parts after the real turn, the visible turn is
blank ("User action triggered." with no card below it).
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 GEclient defect — see the same-engine control above):
are N re-dispatches of the one clicked button to every live surface's
accumulated handler; scoping dispatch to the originating surface yields one.
set, or when
deleteSurfaceis received. Today GE retains every renderedsurface'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 clientfix 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:
(
surfaceId+ clienttimestamp+ action + context — nevermessageId,which is fresh per duplicate); run the action once; replay the first result
to the rest.
execute(). The duplicate storm makesmodel-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:sendwere 500s). Degrade to text instead.Environment
AgentExecutoron Vertex AI Agent Engine (A2aAgenttemplate), A2UI v0.8 negotiated via the agent card extension.
a2aAgentDefinition.jsonAgentCard.multi-turn conversation and count
message:sendrequests server-side.