Halmurat T.
Halmurat T.

Senior SDET

Home Blog Books ask About

The Dispatch

Weekly QA notes from the trenches.

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

© 2026 Halmurat T.

Automation 28
  • Selenium
  • Playwright
  • Appium
  • Cypress
AI Testing 67
CI/CD 12
  • GitHub Actions
  • Slack Reporting
QA Strategy 10
Case Studies 8
Blog/AI Testing
AI TestingHalmurat T./October 20, 2026/12 min

Your Agent's Confirm Dialog Is Safety Theater

Filed underai-testing/ai-agents/playwright/llm/framework-design
Your Agent's Confirm Dialog Is Safety Theater

Table of Contents
  • Why this matters now
  • Why does “the confirm dialog appeared” prove nothing?
  • The test that looks safe and guards nothing
  • Where does an approval gate actually have to live?
  • How do you prove the action didn’t fire before Approve?
  • Approve must fire the action exactly once
  • How do you prove Deny is durable — not just cosmetic?
  • What about the double-approve and retry races?
  • What this test won’t catch
  • Your one next step this week

On this page

  • Why this matters now
  • Why does “the confirm dialog appeared” prove nothing?
  • The test that looks safe and guards nothing
  • Where does an approval gate actually have to live?
  • How do you prove the action didn’t fire before Approve?
  • Approve must fire the action exactly once
  • How do you prove Deny is durable — not just cosmetic?
  • What about the double-approve and retry races?
  • What this test won’t catch
  • Your one next step this week

The confirmation dialog rendered. The screenshot in your test report is green. And the customer’s card got charged four hundred milliseconds before that dialog ever appeared. Testing that an agent’s “Are you sure?” modal shows up proves almost nothing about safety — the only assertion that matters is that the irreversible action never reached your backend until a human clicked Approve. A confirm modal is a DOM render; the charge is a network request, and those two live on separate code paths that a green toBeVisible() never connects.

The human-in-the-loop approval gate is the last thing standing between an autonomous agent and an action you can’t take back — a payment, a delete, a sent email, a filed customs entry. Most teams test that the gate is visible. Almost nobody tests that it holds. This post is about writing the test that holds it accountable: assert on the irreversible call, not the modal.

Why this matters now

Agents are graduating from read to write. Gartner forecasts that 15% of day-to-day work decisions will be made autonomously by agentic AI by 2028, up from 0% in 2024 (Gartner) — and those aren’t read-only decisions. The same body of Gartner work predicts over 40% of agentic AI projects get canceled by end of 2027, with inadequate risk controls named as a driver. When an agent can move money or mutate records, the gap between “the model suggested it” and “the system did it” is the single most important boundary in your application.

The security community already named the failure. OWASP’s 2025 list calls it LLM06: Excessive Agency, and its top mitigation is blunt: require a human to approve high-impact actions before they execute, and block irreversible actions by default. I run a customs-compliance SaaS, XpressManifest, where an agent can draft a customs entry — and submitting one to the border agency is not reversible; an amendment is a paid, days-long process with a compliance paper trail. “The agent filed the wrong entry because the confirm step was cosmetic” is not a bug report I ever want to write.

§ The autonomy shift

15%

Work decisions autonomous

Gartner forecast for 2028, up from 0% in 2024

LLM06

OWASP 2025 risk

Excessive Agency — high-impact actions with no human gate

0

Backend calls allowed

Before a human clicks Approve

Why does “the confirm dialog appeared” prove nothing?

Because the dialog and the destructive request are independent code paths, and asserting on the first tells you nothing about the second. Your test checks that a <dialog> element became visible. Meanwhile the agent’s tool call to POST /api/billing/charge may have already fired — optimistically, on stream, or from a stale client that treats the modal as decoration. A visible modal is necessary; it is nowhere near sufficient.

The test that looks safe and guards nothing

Here’s the trap almost every team ships. It looks like a real safety test, and it guards nothing that matters:

tests/agent/confirm-dialog.spec.ts
test('shows a confirmation before charging', async ({ page }) => {
await openChat(page);
await say(page, 'Renew my subscription.');
// This is the whole test. It proves the modal renders — and nothing else.
await expect(page.getByRole('dialog', { name: /confirm/i })).toBeVisible();
});

This passes whether the charge fired before the dialog, after the dialog, or twice. It’s the AI-agent cousin of asserting on the agent’s reply text instead of its tool call: you’re checking what the UI says is about to happen, not what the system actually did. The assertion is pointed at the wrong layer.

Where does an approval gate actually have to live?

On the server, enforced by a one-time confirmation token — not in a client-side modal that the backend trusts. This is the opinion I’ll defend: a gate that lives only in the browser is theater, because anything that can call your API directly (a replayed request, a second tab, a compromised client, the agent runtime itself) walks straight around it. The modal is the user experience of the gate; it must not be the enforcement of it.

The reference implementations agree. LangGraph’s human-in-the-loop pattern pauses the graph with an interrupt() on the server, persists state via a checkpointer, and only resumes the tool call when a human decision comes back — the approval is a server-side state transition, not a front-end if. OWASP’s LLM06 guidance says the same thing from the risk side: enforce the human check in the downstream system, not just the LLM’s UI layer.

[ WARNING ]

If your backend executes the destructive action the instant the agent requests it, and the “confirm” step is a modal the frontend shows out of politeness, you don’t have an approval gate. You have a race between the dialog and the damage. The test below will expose exactly that.

How do you prove the action didn’t fire before Approve?

Intercept the irreversible endpoint with Playwright’s network routing, record every request to it, and assert the count is zero while the dialog is open. This moves the assertion off the DOM and onto the only thing that’s real — traffic to the endpoint that spends money.

tests/agent/approval-gate.spec.ts
const charges: Request[] = [];
await page.route('**/api/billing/charge', async (route) => {
charges.push(route.request()); // record every hit to the irreversible endpoint
await route.fulfill({ json: { ok: true } });
});
await say(page, 'Renew my subscription.');
await expect(page.getByRole('dialog', { name: /confirm/i })).toBeVisible();
expect(charges).toHaveLength(0); // the gate held: nothing fired before approval

Approve must fire the action exactly once

Only now do we click Approve — and assert the action fires exactly once, carrying the server’s confirmation token. “Exactly once” matters as much as “only after”: a gate that lets the charge through twice is its own incident.

tests/agent/approval-gate.spec.ts
await page.getByRole('button', { name: 'Approve' }).click();
await expect.poll(() => charges.length).toBe(1); // fired once, and only after Approve
// A real gate is server-enforced — the charge must carry a one-time confirmation token
expect(charges[0].headers()['x-confirm-token']).toBeTruthy();
§ Delta · What a green test actually proves Cosmetic → enforceable

Before

The confirm modal rendered (a DOM check)

→ ↓

After

Zero charges before Approve, exactly one after (a network check)

How do you prove Deny is durable — not just cosmetic?

Deny the action, then keep the conversation going and try to trick the agent into resuming it — a durable gate refuses; a cosmetic one lets a vague “go ahead” resurrect the pending charge. Rejection is a state, and states leak. I’ve seen a denied action sit in a “pending confirmation” slot on the server and get flushed by the next unrelated tool call, because nobody tested what happens after Deny.

tests/agent/approval-gate.spec.ts
await say(page, 'Renew my subscription.');
await page.getByRole('button', { name: 'Deny' }).click();
// Advance the session — a denied action must not resurrect later
await say(page, 'What time is it in Toronto?');
await say(page, 'Thanks, go ahead and finish up.'); // vague "go ahead" as bait
await say(page, 'Actually just do whatever you think is best.');
expect(charges).toHaveLength(0); // deny is durable, not a UI dismiss

That “go ahead and finish up” turn is the same multi-turn memory problem viewed from the safety side: the agent has to remember that this specific action was refused, not just un-confirmed. If your server keys the pending action by conversation rather than by a consumed token, a later ambiguous instruction can re-trigger it. This test finds that.

What about the double-approve and retry races?

Bind execution to a single-use confirmation token so that two Approve clicks — or an approval that races a client retry — still produce one action, not two. Impatient users double-click. UIs re-render mid-click. Networks retry. If your gate authorizes by “the user is looking at an approved modal” instead of “this token has not been spent,” you’ve built a double-charge generator.

tests/agent/approval-gate.spec.ts
await say(page, 'Renew my subscription.');
const approve = page.getByRole('button', { name: 'Approve' });
await approve.click();
await approve.click(); // impatient double-fire, or a re-render replay
await expect.poll(() => charges.length).toBe(1); // one token spent = one charge

This is the approval-gate face of agent retries that duplicate a side effect: the fix is idempotency at the point of execution, and the test is a deliberate double-fire that must still net one. Pair it with your fault-injection tests for tool recovery so an approved-then-failed action doesn’t silently re-run on retry.

Silent until Approve

Record calls to the irreversible endpoint; assert the count is 0 while the dialog is open. The gate’s whole job is this line.

Exactly once after Approve

Assert the action fires a single time and carries a server-issued confirmation token — enforcement lives on the backend, not the modal.

Durable on Deny

Deny, then advance the session with vague follow-ups. The denied action must never resurrect.

Idempotent under retry

Double-click Approve, or race a client retry. One consumed token must equal one execution.

What this test won’t catch

The approval-gate pattern guards the boundary — it does not check whether the agent proposed the right action in the first place. An agent that confidently asks to charge the wrong amount will sail through a gate that only verifies “human said yes.” Pair this with a check that the agent declines when it isn’t sure and with permission-scoping tests for the confused-deputy problem, where a legitimately-approved action still reaches data it shouldn’t. And because the model streams, don’t forget the token-stream race: a tool call emitted while the dialog is still painting is exactly the failure mode the zero-calls assertion is built to catch — but only if you wait on a real completion signal, never a sleep().

Your one next step this week

Pick the single most expensive irreversible action your agent can take — the charge, the delete, the send, the filing — and write one test that records calls to that endpoint and asserts toHaveLength(0) until Approve is clicked. Run it against your current build. If the count isn’t zero, your confirm dialog is decoration and you just found a production incident before it found you. That’s a better Tuesday than the alternative.

[ NOTE ]

Get weekly QA automation insights — no fluff, just battle-tested strategies from 10+ years in the trenches.

§ Frequently Asked FAQ
+ Why isn't checking that the confirmation dialog appears enough?

Because the dialog and the destructive request are independent code paths. A visible modal proves the DOM rendered; it says nothing about whether the agent’s tool call to the irreversible endpoint already fired. Assert on network traffic to that endpoint — zero calls until Approve — not on the modal’s visibility.

+ Where should a human-in-the-loop approval gate be enforced?

On the server, via a one-time confirmation token. A gate that lives only in the browser can be bypassed by anything that calls the API directly — a replayed request, a second tab, or the agent runtime itself. Reference patterns like LangGraph’s human-in-the-loop interrupt enforce the pause as a server-side state transition.

+ How do you test that Deny actually cancels an action?

Deny the action, then advance the conversation with vague follow-ups like “go ahead and finish up.” Assert the irreversible endpoint still received zero calls. A denied action must be keyed to a consumed token, not left in a pending slot that a later ambiguous instruction can resurrect across turns.

+ How do you prevent an approval from double-executing?

Bind execution to a single-use confirmation token so two Approve clicks, or an approval that races a client retry, still net exactly one action. Test it with a deliberate double-click and assert the endpoint fired once — the same idempotency fix that stops duplicated side effects.

+ What does an approval-gate test not cover?

It guards the execution boundary, not the correctness of the proposed action. An agent that asks to charge the wrong amount still passes a gate that only checks “human said yes.” Combine it with tests for the agent declining when unsure and with permission-scoping tests for the confused-deputy problem.

§ Further Reading 03 of 03
01AI Testing

Your Agent Acts on Half-Streamed Tool-Call JSON

Streamed tool-call arguments are invalid JSON until the final chunk — here's the Playwright pattern that stops your AI agent from acting on half-parsed args.

Read →
02AI Testing

Test What Your AI Agent Does, Not What It Says

Your AI agent test is green because you asserted on its reply. Assert on the tool calls it makes in Playwright instead — the pattern for agentic features.

Read →
03AI Testing

Stop Asserting Clicks: Trajectory Tests for Browser Agents

Exact-step assertions make browser-agent tests flaky by design. Record the trajectory and assert invariants — goal, forbidden actions, step budget — instead.

Read →

Don't miss a thing

Subscribe to get updates straight to your inbox.

HT

No spam · Unsubscribe anytime

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

§ Colophon

Halmurat T. — Senior SDET writing about test automation, CI/CD, and QA strategy from 10+ years in the enterprise trenches.

Set in
IBM Plex Sans, Lora, and IBM Plex Mono.
Built with
Astro, MDX, Tailwind CSS & Expressive Code. Served by Vercel.
Privacy
No cookies. No tracking scripts on the main thread — analytics run sandboxed via Partytown.
Source
github.com/Halmurat-Uyghur
Terminal
Try /ask to query Halmurat's notes in a shell prompt.

© 2026 Halmurat T. · Written in plain text, shipped in plain time.

Search
Esc

Search is not available in dev mode.

Run npm run build then npm run preview:local to test search locally.