feat: report new URL after actions that trigger navigation (#1853)

## Summary

- Input tools (`click`, `fill`, `press_key`, `hover`, `drag`,
`type_text`, `fill_form`, `click_at`) and `evaluate_script` now append a
`Page navigated to <url>.` line to the response when the action triggers
a cross-document navigation.
- `WaitForHelper.waitForEventsAfterAction` returns `{navigated:
boolean}` instead of `void`, surfacing the navigation signal that was
already being detected internally.
- No change to `navigate_page` or `new_page` since they already report
the URL explicitly.

Fixes #243

## Why

Today, if a `click` causes a page navigation, the response says
*"Successfully clicked on the element"* with no indication that the page
URL changed. The agent has to make an extra `list_pages` call to
discover where it landed. This saves that round-trip for every
navigation-triggering action.

## Design

The existing `waitForNavigationStarted` in `WaitForHelper` already knows
whether a cross-document navigation started. We propagate that signal as
`{navigated: boolean}` through the return value of
`waitForEventsAfterAction` → `McpPage` → `ContextPage` interface, and
let each handler append the URL line when `navigated` is true.

Same-document (history API) navigations remain filtered out by the
existing `waitForNavigationStarted` logic, matching current behavior.
Click-opens-new-tab is a separate concern (#367).

## Test plan

- [x] New test: click on a link that causes navigation → response
includes `Page navigated to <url>.`
- [x] New test: click on a button that doesn't navigate → no navigation
line in response
- [x] Full test suite (563 tests) passes
- [x] TypeScript typecheck clean
- [x] ESLint + Prettier clean
This commit is contained in:
Roger
2026-05-13 01:06:21 +10:00
committed by GitHub
parent 85f935b2be
commit b5948582a0
6 changed files with 125 additions and 19 deletions
+2 -1
View File
@@ -28,6 +28,7 @@ import type {
import {
getNetworkMultiplierFromString,
WaitForHelper,
type WaitForEventsResult,
} from './WaitForHelper.js';
/**
@@ -132,7 +133,7 @@ export class McpPage implements ContextPage {
waitForEventsAfterAction(
action: () => Promise<unknown>,
options?: {timeout?: number; handleDialog?: 'accept' | 'dismiss' | string},
): Promise<void> {
): Promise<WaitForEventsResult> {
const helper = this.createWaitForHelper(
this.cpuThrottlingRate,
getNetworkMultiplierFromString(this.networkConditions),
+27 -2
View File
@@ -127,7 +127,7 @@ export class WaitForHelper {
async waitForEventsAfterAction(
action: () => Promise<unknown>,
options?: {timeout?: number; handleDialog?: 'accept' | 'dismiss' | string},
): Promise<void> {
): Promise<WaitForEventsResult> {
let dialogOpened = false;
if (options?.handleDialog) {
const dialogHandler = (dialog: Pick<Dialog, 'accept' | 'dismiss'>) => {
@@ -146,6 +146,7 @@ export class WaitForHelper {
});
}
const urlBeforeAction = this.#page.url();
const navigationFinished = this.waitForNavigationStarted()
.then(navigationStated => {
if (navigationStated) {
@@ -170,7 +171,7 @@ export class WaitForHelper {
await navigationFinished;
if (dialogOpened) {
return;
return {};
}
// Wait for stable dom after navigation so we execute in
@@ -181,6 +182,30 @@ export class WaitForHelper {
} finally {
this.#abortController.abort();
}
const urlAfterAction = this.#page.url();
return {
...(urlAfterAction !== urlBeforeAction
? {navigatedToUrl: urlAfterAction}
: {}),
};
}
}
export interface WaitForEventsResult {
/**
* The URL the page navigated to during the action, if a navigation
* occurred.
*/
navigatedToUrl?: string;
}
export function appendWaitForResult(
response: {appendResponseLine(value: string): void},
result: WaitForEventsResult,
): void {
if (result.navigatedToUrl) {
response.appendResponseLine(`Page navigated to ${result.navigatedToUrl}.`);
}
}
+2 -1
View File
@@ -24,6 +24,7 @@ import type {
ExtensionServiceWorker,
} from '../types.js';
import type {PaginationOptions} from '../utils/types.js';
import type {WaitForEventsResult} from '../WaitForHelper.js';
import type {ToolCategory} from './categories.js';
import type {
@@ -260,7 +261,7 @@ export type ContextPage = Readonly<{
waitForEventsAfterAction(
action: () => Promise<unknown>,
options?: {timeout?: number; handleDialog?: 'accept' | 'dismiss' | string},
): Promise<void>;
): Promise<WaitForEventsResult>;
getThirdPartyDeveloperTools():
| ToolGroup<ThirdPartyDeveloperToolDefinition>
| undefined;
+21 -8
View File
@@ -10,6 +10,10 @@ import {zod} from '../third_party/index.js';
import type {ElementHandle, KeyInput} from '../third_party/index.js';
import type {TextSnapshotNode} from '../types.js';
import {parseKey} from '../utils/keyboard.js';
import {
appendWaitForResult,
type WaitForEventsResult,
} from '../WaitForHelper.js';
import {ToolCategory} from './categories.js';
import type {ContextPage} from './ToolDefinition.js';
@@ -109,7 +113,7 @@ export const click = definePageTool({
const shouldSelectNativeOption =
!request.params.dblClick && aXNode?.role === 'option';
try {
await request.page.waitForEventsAfterAction(async () => {
const result = await request.page.waitForEventsAfterAction(async () => {
if (
shouldSelectNativeOption &&
(await selectNativeSelectOption(handle))
@@ -126,6 +130,7 @@ export const click = definePageTool({
? `Successfully double clicked on the element`
: `Successfully clicked on the element`,
);
appendWaitForResult(response, result);
if (request.params.includeSnapshot) {
response.includeSnapshot();
}
@@ -154,7 +159,7 @@ export const clickAt = definePageTool({
blockedByDialog: true,
handler: async (request, response) => {
const page = request.page;
await page.waitForEventsAfterAction(async () => {
const result = await page.waitForEventsAfterAction(async () => {
await page.pptrPage.mouse.click(request.params.x, request.params.y, {
clickCount: request.params.dblClick ? 2 : 1,
});
@@ -164,6 +169,7 @@ export const clickAt = definePageTool({
? `Successfully double clicked at the coordinates`
: `Successfully clicked at the coordinates`,
);
appendWaitForResult(response, result);
if (request.params.includeSnapshot) {
response.includeSnapshot();
}
@@ -190,10 +196,11 @@ export const hover = definePageTool({
const uid = request.params.uid;
const handle = await request.page.getElementByUid(uid);
try {
await request.page.waitForEventsAfterAction(async () => {
const result = await request.page.waitForEventsAfterAction(async () => {
await handle.asLocator().hover();
});
response.appendResponseLine(`Successfully hovered over the element`);
appendWaitForResult(response, result);
if (request.params.includeSnapshot) {
response.includeSnapshot();
}
@@ -314,7 +321,7 @@ export const fill = definePageTool({
blockedByDialog: true,
handler: async (request, response, context) => {
const page = request.page;
await page.waitForEventsAfterAction(async () => {
const result = await page.waitForEventsAfterAction(async () => {
await fillFormElement(
request.params.uid,
request.params.value,
@@ -323,6 +330,7 @@ export const fill = definePageTool({
);
});
response.appendResponseLine(`Successfully filled out the element`);
appendWaitForResult(response, result);
if (request.params.includeSnapshot) {
response.includeSnapshot();
}
@@ -343,7 +351,7 @@ export const typeText = definePageTool({
blockedByDialog: true,
handler: async (request, response) => {
const page = request.page;
await page.waitForEventsAfterAction(async () => {
const result = await page.waitForEventsAfterAction(async () => {
await page.pptrPage.keyboard.type(request.params.text);
if (request.params.submitKey) {
await page.pptrPage.keyboard.press(
@@ -354,6 +362,7 @@ export const typeText = definePageTool({
response.appendResponseLine(
`Typed text "${request.params.text}${request.params.submitKey ? ` + ${request.params.submitKey}` : ''}"`,
);
appendWaitForResult(response, result);
},
});
@@ -376,12 +385,13 @@ export const drag = definePageTool({
);
const toHandle = await request.page.getElementByUid(request.params.to_uid);
try {
await request.page.waitForEventsAfterAction(async () => {
const result = await request.page.waitForEventsAfterAction(async () => {
await fromHandle.drag(toHandle);
await new Promise(resolve => setTimeout(resolve, 50));
await toHandle.drop(fromHandle);
});
response.appendResponseLine(`Successfully dragged an element`);
appendWaitForResult(response, result);
if (request.params.includeSnapshot) {
response.includeSnapshot();
}
@@ -418,8 +428,9 @@ export const fillForm = definePageTool({
blockedByDialog: true,
handler: async (request, response, context) => {
const page = request.page;
let lastResult: WaitForEventsResult = {};
for (const element of request.params.elements) {
await page.waitForEventsAfterAction(async () => {
lastResult = await page.waitForEventsAfterAction(async () => {
await fillFormElement(
element.uid,
element.value,
@@ -429,6 +440,7 @@ export const fillForm = definePageTool({
});
}
response.appendResponseLine(`Successfully filled out the form`);
appendWaitForResult(response, lastResult);
if (request.params.includeSnapshot) {
response.includeSnapshot();
}
@@ -508,7 +520,7 @@ export const pressKey = definePageTool({
const tokens = parseKey(request.params.key);
const [key, ...modifiers] = tokens;
await page.waitForEventsAfterAction(async () => {
const result = await page.waitForEventsAfterAction(async () => {
for (const modifier of modifiers) {
await page.pptrPage.keyboard.down(modifier);
}
@@ -521,6 +533,7 @@ export const pressKey = definePageTool({
response.appendResponseLine(
`Successfully pressed key: ${request.params.key}`,
);
appendWaitForResult(response, result);
if (request.params.includeSnapshot) {
response.includeSnapshot();
}
+12 -7
View File
@@ -7,6 +7,7 @@
import {zod} from '../third_party/index.js';
import type {Frame, JSHandle, Page, WebWorker} from '../third_party/index.js';
import type {ExtensionServiceWorker} from '../types.js';
import {appendWaitForResult} from '../WaitForHelper.js';
import {ToolCategory} from './categories.js';
import type {Context, Response} from './ToolDefinition.js';
@@ -85,12 +86,15 @@ Example with arguments: \`(el) => {
}
const worker = await getWebWorker(context, serviceWorkerId);
await context.getSelectedMcpPage().waitForEventsAfterAction(
async () => {
await performEvaluation(worker, fnString, [], response);
},
{handleDialog: dialogAction ?? 'accept'},
);
const result = await context
.getSelectedMcpPage()
.waitForEventsAfterAction(
async () => {
await performEvaluation(worker, fnString, [], response);
},
{handleDialog: dialogAction ?? 'accept'},
);
appendWaitForResult(response, result);
return;
}
@@ -110,12 +114,13 @@ Example with arguments: \`(el) => {
const evaluatable = await getPageOrFrame(page, frames);
await mcpPage.waitForEventsAfterAction(
const result = await mcpPage.waitForEventsAfterAction(
async () => {
await performEvaluation(evaluatable, fnString, args, response);
},
{handleDialog: dialogAction ?? 'accept'},
);
appendWaitForResult(response, result);
} finally {
void Promise.allSettled(args.map(arg => arg.dispose()));
}
+61
View File
@@ -130,6 +130,67 @@ describe('input', () => {
});
});
it('reports the new URL when click triggers a navigation', async () => {
server.addHtmlRoute(
'/start',
html`<a href="/after-click">Navigate page</a>`,
);
server.addHtmlRoute('/after-click', html`<main>arrived</main>`);
await withMcpContext(async (response, context) => {
const page = context.getSelectedPptrPage();
await page.goto(server.getRoute('/start'));
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
context.getSelectedMcpPage(),
);
await click.handler(
{
params: {
uid: '1_1',
},
page: context.getSelectedMcpPage(),
},
response,
context,
);
const expectedUrl = server.getRoute('/after-click');
assert.ok(
response.responseLines.some(
line => line === `Page navigated to ${expectedUrl}.`,
),
`Expected response to mention navigation to ${expectedUrl}, got: ${response.responseLines.join(' | ')}`,
);
});
});
it('does not report navigation when click does not navigate', async () => {
await withMcpContext(async (response, context) => {
const page = context.getSelectedPptrPage();
await page.setContent(
html`<button onclick="this.innerText = 'clicked';">test</button>`,
);
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
context.getSelectedMcpPage(),
);
await click.handler(
{
params: {
uid: '1_1',
},
page: context.getSelectedMcpPage(),
},
response,
context,
);
assert.ok(
!response.responseLines.some(line =>
line.startsWith('Page navigated to '),
),
`Did not expect a navigation line, got: ${response.responseLines.join(' | ')}`,
);
});
});
it('waits for stable DOM', async () => {
server.addHtmlRoute(
'/unstable',