Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/extension/responseHandleTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export class ResponseHandleNotFoundError extends Error {
* server-side Response object.
*/
export class ResponseHandleTable {

@ziruihao ziruihao Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is that SDK client can operate over many tabs, which means the chromium-supplied request identifier isn't unique can can collide across tabs. So we made this ResponseHandleTable to act as a global lookup.

Might be just me, but I did find our this custom-created responseId a bit confusing as a first time developer. At first, I thought it was referring to the chromium-supplied identifiers. It's not a big deal, but I do wonder if we could avoid creating custom IDs and instead use a compound CDP_SESSION_ID-CDP_NETWORK_REQUEST_ID, which would be globally identifiable and unique across tabs.

private readonly handlesByResponse = new WeakMap<Response, string>();
private readonly entries = new Map<string, ResponseHandleEntry>();
private readonly maxHandles: number;
private readonly createHandle: () => string;
Expand All @@ -43,13 +44,16 @@ export class ResponseHandleTable {
}

register(pageId: string, response: Response): string {
const existing = this.handlesByResponse.get(response);
if (existing && this.entries.get(existing)?.pageId === pageId) return existing;
const responseId = this.nextHandle();
while (this.entries.size >= this.maxHandles) {
const oldestResponseId = this.entries.keys().next().value as string | undefined;
if (oldestResponseId === undefined) break;
this.delete(oldestResponseId);
}
this.entries.set(responseId, { pageId, response });
this.handlesByResponse.set(response, responseId);
return responseId;
}

Expand All @@ -73,6 +77,7 @@ export class ResponseHandleTable {
const entry = this.entries.get(responseId);
if (!entry) return;
this.entries.delete(responseId);
this.handlesByResponse.delete(entry.response);
entry.response.dispose(new ResponseHandleNotFoundError(responseId));
}

Expand Down
41 changes: 29 additions & 12 deletions packages/extension/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ export type UnderstudyRuntimePage = {
listener: (event: PageCDPEvent) => void,
signal?: AbortSignal,
): Promise<() => void>;
subscribeResponse(
listener: (response: Response) => void,
signal?: AbortSignal,
): Promise<() => void>;
subscribeWebMCPToolsChanged(
listener: (event: WebMCPToolsEvent) => void,
signal?: AbortSignal,
Expand Down Expand Up @@ -866,6 +870,19 @@ export class StagehandRuntime {
subscription.controller.signal,
);
break;
case "response":
subscription.dispose = await page.subscribeResponse((response) => {
if (!isActive()) return;
this.adapters.emitPageEvent({
subscriptionId: params.subscriptionId,
event: "response",
pageId: params.pageId,
sessionId: response.session.id ?? params.pageId,
targetId: response.page.conn.targetIdForSession(response.session.id) ?? params.pageId,
response: this.responseDescriptor(params.pageId, response),
});
}, subscription.controller.signal);
break;
case "toolsadded":
case "toolsremoved":
subscription.dispose = await page.subscribeWebMCPToolsChanged((event) => {
Expand Down Expand Up @@ -1179,6 +1196,17 @@ export class StagehandRuntime {
return pageId;
}

private responseDescriptor(pageId: string, response: Response) {
return {
responseId: this.responseHandles.register(pageId, response),
url: response.url(),
status: response.status(),
statusText: response.statusText(),
headers: response.headers(),
fromServiceWorker: response.fromServiceWorker(),
};
}

private pageNavigationResult(
pageId: string,
page: UnderstudyRuntimePage,
Expand All @@ -1187,18 +1215,7 @@ export class StagehandRuntime {
const pageRef = pageRefFromUnderstudyPage(page);
if (!(response instanceof Response)) return { page: pageRef, response: null };

const responseId = this.responseHandles.register(pageId, response);
return {
page: pageRef,
response: {
responseId,
url: response.url(),
status: response.status(),
statusText: response.statusText(),
headers: response.headers(),
fromServiceWorker: response.fromServiceWorker(),
},
};
return { page: pageRef, response: this.responseDescriptor(pageId, response) };
}

requireBrowserSession(): StagehandBrowserSession {
Expand Down
8 changes: 5 additions & 3 deletions packages/extension/types/private/network.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Protocol } from "devtools-protocol";
import type { Response } from "../../understudy/response.js";

/** Metadata tracked for each network request currently in-flight. */
export type NetworkRequestInfo = {
Expand All @@ -15,9 +16,10 @@ export type NetworkRequestInfo = {

/** Callback hooks consumers can implement to observe network transitions. */
export interface NetworkObserver {
onRequestStarted(info: NetworkRequestInfo): void;
onRequestFinished(info: NetworkRequestInfo): void;
onRequestFailed(info: NetworkRequestInfo): void;
onRequestStarted?(info: NetworkRequestInfo): void;
onRequestFinished?(info: NetworkRequestInfo): void;
onRequestFailed?(info: NetworkRequestInfo): void;
onResponse?(response: Response): void;

@ziruihao ziruihao Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like a lot of optionals, but better than breaking out NetworkObserver into different types.

}

/** Options for the idle waiter helper. */
Expand Down
50 changes: 47 additions & 3 deletions packages/extension/understudy/networkManager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Protocol } from "devtools-protocol";
import type { CDPSessionLike } from "./cdp.js";
import type { Page } from "./page.js";
import { Response } from "./response.js";
import {
DEFAULT_IDLE_WAIT,
IGNORED_RESOURCE_TYPES,
Expand All @@ -23,6 +25,10 @@ import {
* Aggregates network information for all CDP sessions owned by a Page.
*/
export class NetworkManager {
private readonly pendingResponses = new Set<Response>();

constructor(private readonly page: Page) {}

readonly sessions = new Map<
string,
{
Expand Down Expand Up @@ -114,6 +120,7 @@ export class NetworkManager {

const onResponse = (evt: Protocol.Network.ResponseReceivedEvent) => {
if (!evt?.requestId) return;
this.emitResponse(evt, session);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: For data: responses, the later synthetic finish() completes only network bookkeeping; the emitted Response.finished() still waits for loading events, so finished() and body() can hang. Mark the response finished on this synthetic completion.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/extension/understudy/networkManager.ts, line 123:

<comment>For `data:` responses, the later synthetic `finish()` completes only network bookkeeping; the emitted `Response.finished()` still waits for loading events, so `finished()` and `body()` can hang. Mark the response finished on this synthetic completion.</comment>

<file context>
@@ -114,6 +120,7 @@ export class NetworkManager {
 
     const onResponse = (evt: Protocol.Network.ResponseReceivedEvent) => {
       if (!evt?.requestId) return;
+      this.emitResponse(evt, session);
       const url = evt.response?.url ?? "";
       if (url.startsWith("data:")) finish(evt.requestId);
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetch("data:text/plain,Hello"); still triggers Network.loadingFinished event.

const url = evt.response?.url ?? "";
if (url.startsWith("data:")) finish(evt.requestId);
};
Expand Down Expand Up @@ -163,6 +170,7 @@ export class NetworkManager {
const entry = this.sessions.get(sid);
if (!entry) return;
entry.detach();
this.disposeResponsesForSession(entry.session);
this.sessions.delete(sid);

for (const key of this.requests.keys()) {
Expand Down Expand Up @@ -302,30 +310,66 @@ export class NetworkManager {
for (const { detach } of this.sessions.values()) {
detach();
}
for (const response of this.pendingResponses) response.dispose();
this.pendingResponses.clear();
this.sessions.clear();
this.observers.clear();
this.requests.clear();
this.documentRequestsByFrame.clear();
}

public subscribeResponse(listener: (response: Response) => void): () => void {
return this.addObserver({ onResponse: listener });
}

/** Materialize one response for all interested observers; body retrieval stays lazy. */
private emitResponse(
event: Protocol.Network.ResponseReceivedEvent,
session: CDPSessionLike,
): void {
if (![...this.observers].some((observer) => typeof observer.onResponse === "function")) return;
const response = new Response({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: responseReceivedExtraInfo can arrive before responseReceived, but this Response is created afterward and has no replay buffer. Cache early extra-info events and apply them so allHeaders() and headersArray() retain headers such as set-cookie.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/extension/understudy/networkManager.ts, line 331:

<comment>`responseReceivedExtraInfo` can arrive before `responseReceived`, but this `Response` is created afterward and has no replay buffer. Cache early extra-info events and apply them so `allHeaders()` and `headersArray()` retain headers such as `set-cookie`.</comment>

<file context>
@@ -302,30 +310,66 @@ export class NetworkManager {
+    session: CDPSessionLike,
+  ): void {
+    if (![...this.observers].some((observer) => typeof observer.onResponse === "function")) return;
+    const response = new Response({
+      page: this.page,
+      session,
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking we omit Network. responseReceivedExtraInfo as a limitation for now.

page: this.page,
session,
requestId: event.requestId,
frameId: event.frameId,
loaderId: event.loaderId,
response: event.response,
fromServiceWorker: Boolean(event.response.fromServiceWorker),
});
this.pendingResponses.add(response);
void response.finished().then(() => this.pendingResponses.delete(response));
for (const observer of this.observers) {
observer.onResponse?.(response);
}
}

private disposeResponsesForSession(session: CDPSessionLike): void {
for (const response of this.pendingResponses) {
if (response.session !== session) continue;
response.dispose(new Error("Response session was detached"));
this.pendingResponses.delete(response);
}
}

/** Fan-out helper when a tracked request starts. */
emitStart(info: NetworkRequestInfo): void {
for (const obs of this.observers) {
obs.onRequestStarted(info);
obs.onRequestStarted?.(info);
}
}

/** Fan-out helper when a tracked request completes successfully. */
emitFinish(info: NetworkRequestInfo): void {
for (const obs of this.observers) {
obs.onRequestFinished(info);
obs.onRequestFinished?.(info);
}
}

/** Fan-out helper when a tracked request fails mid-flight. */
emitFailure(info: NetworkRequestInfo): void {
for (const obs of this.observers) {
obs.onRequestFailed(info);
obs.onRequestFailed?.(info);
}
}

Expand Down
13 changes: 12 additions & 1 deletion packages/extension/understudy/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ export class Page {
this.logger,
);

this.networkManager = new NetworkManager();
this.networkManager = new NetworkManager(this);
this.networkManager.trackSession(this.mainSession);
}

Expand Down Expand Up @@ -605,6 +605,17 @@ export class Page {
);
}

public async subscribeResponse(
listener: (response: Response) => void,
signal?: AbortSignal,
): Promise<() => void> {
return this.subscribeWhenReady(
undefined,
() => this.networkManager.subscribeResponse(listener),
signal,
);
}

/** Activate against future shared-state changes, without replaying initial tools. */
public async subscribeWebMCPToolsChanged(
listener: (event: WebMCPToolsEvent) => void,
Expand Down
11 changes: 10 additions & 1 deletion packages/protocol/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1285,7 +1285,7 @@ export const ResponseFinishedResultSchema = z
export const PageEventNameSchema = z.enum(["console"]).meta({ id: "PageEventName" });

export const PageSubscriptionEventNameSchema = z
.enum(["console", "toolsadded", "toolsremoved"])
.enum(["console", "toolsadded", "toolsremoved", "response"])
.meta({ id: "PageSubscriptionEventName" });

export const PageCDPEventParamsSchema = z
Expand Down Expand Up @@ -1360,10 +1360,19 @@ export const PageToolsRemovedNotificationSchema = z
})
.meta({ id: "PageToolsRemovedNotification" });

export const PageResponseNotificationSchema = z
.strictObject({
...pageEventRoutingShape,
event: z.literal("response"),
response: NavigationResponseDescriptorSchema,
})
.meta({ id: "PageResponseNotification" });

export const PageEventNotificationSchema = z
.discriminatedUnion("event", [
PageToolsAddedNotificationSchema,
PageToolsRemovedNotificationSchema,
PageResponseNotificationSchema,
])
.meta({ id: "PageEventNotification" });

Expand Down
35 changes: 34 additions & 1 deletion packages/protocol/stagehand.v4.json
Original file line number Diff line number Diff line change
Expand Up @@ -3774,7 +3774,7 @@
},
"PageSubscriptionEventName": {
"type": "string",
"enum": ["console", "toolsadded", "toolsremoved"]
"enum": ["console", "toolsadded", "toolsremoved", "response"]
},
"PageOffParams": {
"type": "object",
Expand Down Expand Up @@ -5352,6 +5352,9 @@
},
{
"$ref": "#/$defs/PageToolsRemovedNotification"
},
{
"$ref": "#/$defs/PageResponseNotification"
}
]
},
Expand Down Expand Up @@ -5436,6 +5439,36 @@
"required": ["frame_id", "name"],
"additionalProperties": false
},
"PageResponseNotification": {
"type": "object",
"properties": {
"subscription_id": {
"type": "string",
"minLength": 1
},
"page_id": {
"type": "string",
"minLength": 1
},
"session_id": {
"type": "string",
"minLength": 1
},
"target_id": {
"type": "string",
"minLength": 1
},
"event": {
"type": "string",
"const": "response"
},
"response": {
"$ref": "#/$defs/NavigationResponseDescriptor"
}
},
"required": ["subscription_id", "page_id", "session_id", "target_id", "event", "response"],
"additionalProperties": false
},
"BrowserbaseSessionCreateParams": {
"type": "object",
"properties": {
Expand Down
2 changes: 2 additions & 0 deletions packages/protocol/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ import type {
PageEventNotificationSchema,
PageToolsAddedNotificationSchema,
PageToolsRemovedNotificationSchema,
PageResponseNotificationSchema,
WebMCPToolIdentitySchema,
PageLocatorSchema,
PageNavigationResultSchema,
Expand Down Expand Up @@ -327,6 +328,7 @@ export type PageEventName = z.infer<typeof PageEventNameSchema>;
export type PageSubscriptionEventName = z.infer<typeof PageSubscriptionEventNameSchema>;
export type PageEventNotification = z.infer<typeof PageEventNotificationSchema>;
export type PageToolsAddedNotification = z.infer<typeof PageToolsAddedNotificationSchema>;
export type PageResponseNotification = z.infer<typeof PageResponseNotificationSchema>;
export type PageToolsRemovedNotification = z.infer<typeof PageToolsRemovedNotificationSchema>;
export type WebMCPToolIdentity = z.infer<typeof WebMCPToolIdentitySchema>;
export type PageCDPEventParams = z.infer<typeof PageCDPEventParamsSchema>;
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk-python/src/stagehand/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
CDPSubscription,
Page,
PageEventListener,
ResponseListener,
ToolsAddedListener,
ToolsRemovedListener,
WebMCPToolIdentity,
Expand Down Expand Up @@ -147,6 +148,7 @@
"PageCDPEvent",
"PageDragAndDropRoutePoint",
"PageEventListener",
"ResponseListener",
"ToolsAddedListener",
"ToolsRemovedListener",
"WebMCPToolIdentity",
Expand Down
Loading
Loading