fix: report when the selected page was auto-replaced by the fallback (#2308)

Follow-up to #2304 (the "note" part discussed there).

When the selected page disappears from the page list,
`createPagesSnapshot()` silently re-selects the first page. The agent
gets no signal: if its next call is `list_pages` (which is what the
closed-page error message recommends), the listing already shows the new
selection and every subsequent tool call runs against a page the agent
never picked.

This PR records the automatic fallback and surfaces it as a one-line
note in the pages section of the same response:

```
## Pages
Note: the previously selected page was closed. Page 1 is now selected.
1: about:blank [selected]
```

For a selected page that is missing from the list without being closed,
the note reads "is no longer listed" instead. If the expectation from
#2304 holds (a page stays listed as long as it is not closed), that
wording never renders; if the transient case discussed there does occur
in the wild, the note will make it visible.

The fallback behavior itself is unchanged (as discussed in #2304, closed
tabs keep the browser-like auto-selection). No note is emitted on first
connect, when nothing was selected before.

Tests: two unit tests for the fallback bookkeeping (closed page, regular
selection), one for the missing-but-open case via a stubbed page list,
and the one affected snapshot updated (`close_page` now includes the
note).

Refs: #2304
This commit is contained in:
Thomas Bachem
2026-07-08 08:08:26 +01:00
committed by GitHub
parent ffc6060acf
commit 2c16ac311b
4 changed files with 94 additions and 1 deletions
+19
View File
@@ -88,6 +88,7 @@ export class McpContext implements Context {
#mcpPages = new Map<Page, McpPage>();
#selectedPage?: McpPage;
#selectedPageFallback?: {wasClosed: boolean};
#networkCollector: NetworkCollector;
#consoleCollector: ConsoleCollector;
#devtoolsUniverseManager: UniverseManager;
@@ -571,6 +572,16 @@ export class McpContext implements Context {
this.#updateSelectedPageTimeouts();
}
/**
* Returns details about the last page snapshot automatically replacing the
* selection because the selected page disappeared from the page list, or
* `undefined` if the snapshot left the selection intact. Recomputed on every
* createPagesSnapshot() call.
*/
getSelectedPageFallback(): {wasClosed: boolean} | undefined {
return this.#selectedPageFallback;
}
#updateSelectedPageTimeouts() {
const page = this.#getSelectedMcpPage();
// For waiters 5sec timeout should be sufficient.
@@ -674,11 +685,19 @@ export class McpContext implements Context {
);
});
this.#selectedPageFallback = undefined;
if (
(!this.#selectedPage ||
this.#pages.indexOf(this.#selectedPage.pptrPage) === -1) &&
this.#pages[0]
) {
// Record the automatic change so the response can surface it. Skipped on
// first connect, when there was no prior selection to replace.
if (this.#selectedPage) {
this.#selectedPageFallback = {
wasClosed: this.#selectedPage.pptrPage.isClosed(),
};
}
this.selectPage(this.#getMcpPage(this.#pages[0]));
}
+12
View File
@@ -1006,6 +1006,18 @@ Call ${handleDialog.name} to handle it before continuing.`);
{regularPages: [], extensionPages: []},
);
const selectionFallback = context.getSelectedPageFallback();
if (selectionFallback) {
let selectedPageId: number | undefined;
try {
selectedPageId = context.getSelectedMcpPage().id;
} catch {
selectedPageId = undefined;
}
response.push(
`Note: the previously selected page ${selectionFallback.wasClosed ? 'was closed' : 'is no longer listed'}.${selectedPageId !== undefined ? ` Page ${selectedPageId} is now selected.` : ''}`,
);
}
if (regularPages.length) {
const parts = [`## Pages`];
const structuredPages = [];
+62
View File
@@ -135,6 +135,68 @@ describe('McpContext', () => {
});
});
it('reports the fallback when the selected page is closed', async () => {
await withMcpContext(async (_response, context) => {
const page = await context.newPage();
assert.ok(context.isPageSelected(page.pptrPage));
await page.pptrPage.close();
await context.createPagesSnapshot();
const [firstPage] = context.getPages();
assert.ok(firstPage);
assert.ok(context.isPageSelected(firstPage));
const fallback = context.getSelectedPageFallback();
assert.ok(fallback, 'fallback should be reported');
assert.strictEqual(fallback.wasClosed, true);
});
});
it('clears the fallback on the next snapshot with a valid selection', async () => {
await withMcpContext(async (_response, context) => {
const page = await context.newPage();
await page.pptrPage.close();
await context.createPagesSnapshot();
assert.ok(context.getSelectedPageFallback());
// A later snapshot keeps a valid selection (e.g. the one taken before the
// next response, or after an explicit select), so the note is not repeated.
await context.createPagesSnapshot();
assert.strictEqual(context.getSelectedPageFallback(), undefined);
});
});
it('does not report a fallback for a regular selection', async () => {
await withMcpContext(async (_response, context) => {
await context.newPage();
await context.createPagesSnapshot();
assert.strictEqual(context.getSelectedPageFallback(), undefined);
});
});
it('reports the fallback when the selected page is missing from the list', async () => {
await withMcpContext(async (_response, context) => {
const page = await context.newPage();
assert.ok(context.isPageSelected(page.pptrPage));
// A live page that is temporarily missing from the pages list.
const pages = await context.browser.pages();
const stub = sinon
.stub(context.browser, 'pages')
.resolves(pages.filter(otherPage => otherPage !== page.pptrPage));
try {
await context.createPagesSnapshot();
} finally {
stub.restore();
}
const fallback = context.getSelectedPageFallback();
assert.ok(fallback, 'fallback should be reported');
assert.strictEqual(fallback.wasClosed, false);
});
});
it('should include network requests in structured content', async t => {
await withMcpContext(async (response, context) => {
const mockRequest = getMockRequest({
+1 -1
View File
@@ -1,5 +1,5 @@
exports[`pages > close_page > when dialog is open 1`] = `
{"content":[{"type":"text","text":"## Pages\\n1: about:blank [selected]"}],"structuredContent":{"pages":[{"id":1,"url":"about:blank","title":"","selected":true}]}}
{"content":[{"type":"text","text":"Note: the previously selected page was closed. Page 1 is now selected.\\n## Pages\\n1: about:blank [selected]"}],"structuredContent":{"pages":[{"id":1,"url":"about:blank","title":"","selected":true}]}}
`;
exports[`pages > list_pages > list pages for extension pages with --category-extensions 1`] = `