mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8c5c4a7c4 | ||
|
|
4549ed0892 |
@@ -104,6 +104,27 @@ describe("reduceTraceSteps", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps committed empty fill steps", () => {
|
||||||
|
const { steps } = reduceTraceSteps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
op: "fill",
|
||||||
|
target: { tag: "input", role: "textbox", name: "Search query" },
|
||||||
|
value: "",
|
||||||
|
page_url: "https://example.com/search",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"https://example.com/search",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(steps).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
op: "fill",
|
||||||
|
value: "",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("maps select navigated_to onto effect.navigated_to (page id)", () => {
|
it("maps select navigated_to onto effect.navigated_to (page id)", () => {
|
||||||
const { pages, steps } = reduceTraceSteps(
|
const { pages, steps } = reduceTraceSteps(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export function shouldRecordPress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldIncludeDraft(step: DraftTraceStep): boolean {
|
function shouldIncludeDraft(step: DraftTraceStep): boolean {
|
||||||
if (step.op === "fill" && !(step.value ?? "").trim() && !step.redacted) return false;
|
|
||||||
if (step.op === "press" && !shouldRecordPress(step.key, step.modifiers)) return false;
|
if (step.op === "press" && !shouldRecordPress(step.key, step.modifiers)) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,52 @@ describe("SessionManager", () => {
|
|||||||
await expect(sm.start("aa11")).rejects.toThrow(/already exists/);
|
await expect(sm.start("aa11")).rejects.toThrow(/already exists/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes a newly created Agent Window when startup is aborted", async () => {
|
||||||
|
const aw = fakeAgentWindow();
|
||||||
|
let resolveCreate: (windowId: number) => void = () => {};
|
||||||
|
aw.createMock.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<number>((resolve) => {
|
||||||
|
resolveCreate = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
const controller = new AbortController();
|
||||||
|
const pending = sm.start("aa11", { signal: controller.signal });
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
resolveCreate(777);
|
||||||
|
|
||||||
|
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
expect(aw.removeMock).toHaveBeenCalledWith(777);
|
||||||
|
expect(sm.has("aa11")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes an incomplete Agent Window when active-tab setup fails", async () => {
|
||||||
|
const aw = fakeAgentWindow();
|
||||||
|
aw.ensureActiveTabMock.mockRejectedValueOnce(new Error("tab setup failed"));
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
|
||||||
|
await expect(sm.start("aa11")).rejects.toThrow("tab setup failed");
|
||||||
|
|
||||||
|
expect(aw.removeMock).toHaveBeenCalledWith(100);
|
||||||
|
expect(sm.has("aa11")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces the orphan Agent Window id when startup cleanup fails", async () => {
|
||||||
|
const aw = fakeAgentWindow();
|
||||||
|
aw.ensureActiveTabMock.mockRejectedValueOnce(new Error("tab setup failed"));
|
||||||
|
aw.removeMock.mockRejectedValueOnce(new Error("window removal denied"));
|
||||||
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
|
||||||
|
await expect(sm.start("aa11")).rejects.toMatchObject({
|
||||||
|
name: "SessionStartCleanupError",
|
||||||
|
windowId: 100,
|
||||||
|
message: expect.stringMatching(/cleanup of Agent Window 100 failed.*window removal denied/),
|
||||||
|
});
|
||||||
|
expect(sm.has("aa11")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("stop() closes the Agent Window and forgets the session", async () => {
|
it("stop() closes the Agent Window and forgets the session", async () => {
|
||||||
const aw = fakeAgentWindow();
|
const aw = fakeAgentWindow();
|
||||||
const sm = new SessionManager({ agentWindow: aw });
|
const sm = new SessionManager({ agentWindow: aw });
|
||||||
|
|||||||
@@ -43,12 +43,11 @@ export const chromeAgentWindowApi: AgentWindowApi = {
|
|||||||
return win.id;
|
return win.id;
|
||||||
},
|
},
|
||||||
async remove(windowId: number): Promise<void> {
|
async remove(windowId: number): Promise<void> {
|
||||||
try {
|
// Callers decide whether a missing/failed removal is benign. In
|
||||||
await chrome.windows.remove(windowId);
|
// particular, transactional session-start cleanup must be able to
|
||||||
} catch (err) {
|
// surface a window it could not remove instead of reporting a false
|
||||||
// Window may have been closed by the user already; ignore.
|
// cancellation success while the Agent Window remains open.
|
||||||
console.debug("[bh] chrome.windows.remove failed", err);
|
await chrome.windows.remove(windowId);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
async ensureActiveTab(windowId: number, url: string): Promise<void> {
|
async ensureActiveTab(windowId: number, url: string): Promise<void> {
|
||||||
const tabs = await chrome.tabs.query({ windowId });
|
const tabs = await chrome.tabs.query({ windowId });
|
||||||
|
|||||||
@@ -31,6 +31,38 @@ export interface SessionStartOptions {
|
|||||||
size?: { width: number; height: number };
|
size?: { width: number; height: number };
|
||||||
/** Defaults to true so existing clients keep visible Agent Windows. */
|
/** Defaults to true so existing clients keep visible Agent Windows. */
|
||||||
focused?: boolean;
|
focused?: boolean;
|
||||||
|
/** Cancellation for the transactional Agent Window startup sequence. */
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionStartCleanupError extends Error {
|
||||||
|
readonly windowId: number;
|
||||||
|
readonly startupError: unknown;
|
||||||
|
readonly cleanupError: unknown;
|
||||||
|
|
||||||
|
constructor(windowId: number, startupError: unknown, cleanupError: unknown) {
|
||||||
|
const startupMessage =
|
||||||
|
startupError instanceof Error ? startupError.message : String(startupError);
|
||||||
|
const cleanupMessage =
|
||||||
|
cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
||||||
|
super(
|
||||||
|
`session_start failed (${startupMessage}) and cleanup of Agent Window ${windowId} failed: ${cleanupMessage}`,
|
||||||
|
);
|
||||||
|
this.name = "SessionStartCleanupError";
|
||||||
|
this.windowId = windowId;
|
||||||
|
this.startupError = startupError;
|
||||||
|
this.cleanupError = cleanupError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionStartAbortError(): Error {
|
||||||
|
const error = new Error("session_start aborted");
|
||||||
|
error.name = "AbortError";
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwIfSessionStartAborted(signal: AbortSignal | undefined): void {
|
||||||
|
if (signal?.aborted) throw sessionStartAbortError();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,18 +172,36 @@ export class SessionManager {
|
|||||||
if (this.sessions.has(sessionId)) {
|
if (this.sessions.has(sessionId)) {
|
||||||
throw new Error(`[bh] session ${sessionId} already exists`);
|
throw new Error(`[bh] session ${sessionId} already exists`);
|
||||||
}
|
}
|
||||||
const windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, opts);
|
throwIfSessionStartAborted(opts.signal);
|
||||||
await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
|
|
||||||
const ctx: SessionContext = {
|
let windowId: number | null = null;
|
||||||
sessionId,
|
try {
|
||||||
agentWindowId: windowId,
|
const { signal: _signal, ...createOptions } = opts;
|
||||||
refStore: new RefStore(),
|
windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, createOptions);
|
||||||
borrowedTabs: new Map(),
|
throwIfSessionStartAborted(opts.signal);
|
||||||
createdAtMs: this.now(),
|
await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
|
||||||
};
|
throwIfSessionStartAborted(opts.signal);
|
||||||
this.sessions.set(sessionId, ctx);
|
|
||||||
this.windowIndex.set(windowId, sessionId);
|
const ctx: SessionContext = {
|
||||||
return ctx;
|
sessionId,
|
||||||
|
agentWindowId: windowId,
|
||||||
|
refStore: new RefStore(),
|
||||||
|
borrowedTabs: new Map(),
|
||||||
|
createdAtMs: this.now(),
|
||||||
|
};
|
||||||
|
this.sessions.set(sessionId, ctx);
|
||||||
|
this.windowIndex.set(windowId, sessionId);
|
||||||
|
return ctx;
|
||||||
|
} catch (startupError) {
|
||||||
|
if (windowId !== null) {
|
||||||
|
try {
|
||||||
|
await this.agentWindow.remove(windowId);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
throw new SessionStartCleanupError(windowId, startupError, cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw startupError;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -677,23 +677,31 @@ describe("ToolDispatcher", () => {
|
|||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
expect(ac?.signal.aborted).toBe(true);
|
expect(ac?.signal.aborted).toBe(true);
|
||||||
|
|
||||||
// Cancel ack arrived synchronously; the slow tool replies with
|
// Cancel ack arrives synchronously, but the original RPC must not reply
|
||||||
// `cancelled` once the dispatcher's race observes the abort.
|
// until the in-progress window creation has completed and been rolled back.
|
||||||
const ack = sent.find(
|
const ack = sent.find(
|
||||||
(m) =>
|
(m) =>
|
||||||
typeof (m as { id?: string }).id === "string" && (m as { id: string }).id === "cancel-1",
|
typeof (m as { id?: string }).id === "string" && (m as { id: string }).id === "cancel-1",
|
||||||
);
|
);
|
||||||
expect(ack).toEqual({ id: "cancel-1", result: { cancelled: true } });
|
expect(ack).toEqual({ id: "cancel-1", result: { cancelled: true } });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
sent.find(
|
||||||
|
(m) => typeof (m as { id?: string }).id === "string" && (m as { id: string }).id === "r-1",
|
||||||
|
),
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(dispatcher.inflightAbortControllers.has("r-1")).toBe(true);
|
||||||
|
|
||||||
|
resolveCreate(9999);
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
const slow = sent.find(
|
const slow = sent.find(
|
||||||
(m) => typeof (m as { id?: string }).id === "string" && (m as { id: string }).id === "r-1",
|
(m) => typeof (m as { id?: string }).id === "string" && (m as { id: string }).id === "r-1",
|
||||||
);
|
);
|
||||||
expect(slow).toMatchObject({ id: "r-1", error: { code: "cancelled" } });
|
expect(slow).toMatchObject({ id: "r-1", error: { code: "cancelled" } });
|
||||||
expect(dispatcher.inflightAbortControllers.has("r-1")).toBe(false);
|
expect(dispatcher.inflightAbortControllers.has("r-1")).toBe(false);
|
||||||
|
expect(sessions.has("aa44")).toBe(false);
|
||||||
// Drain the dangling create promise so vitest does not warn.
|
expect(sessions.list()).toEqual([]);
|
||||||
resolveCreate(9999);
|
|
||||||
await flushMicrotasks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cancel for an unknown rpc_id replies with cancelled=false", async () => {
|
it("cancel for an unknown rpc_id replies with cancelled=false", async () => {
|
||||||
|
|||||||
@@ -2457,6 +2457,64 @@ describe("handleSnapshot", () => {
|
|||||||
expect(ctx.refStore.resolve("e1")).toBeNull();
|
expect(ctx.refStore.resolve("e1")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the previous RefStore when cancellation lands during DOM capture", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.refStore.set("e1", 999, { tabId: 4 });
|
||||||
|
const controller = new AbortController();
|
||||||
|
let resolveCapture: (value: unknown) => void = () => {};
|
||||||
|
const send = vi.fn(async (_tabId: number, method: string) => {
|
||||||
|
if (method === "Accessibility.enable") return {};
|
||||||
|
if (method === "Accessibility.getFullAXTree") {
|
||||||
|
return {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
nodeId: "new",
|
||||||
|
role: { type: "role", value: "button" },
|
||||||
|
name: { type: "computedString", value: "New" },
|
||||||
|
backendDOMNodeId: 123,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (method === "Page.getLayoutMetrics") {
|
||||||
|
return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } };
|
||||||
|
}
|
||||||
|
if (method === "DOMSnapshot.enable") return {};
|
||||||
|
if (method === "DOMSnapshot.captureSnapshot") {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolveCapture = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected CDP method ${method}`);
|
||||||
|
});
|
||||||
|
const deps = {
|
||||||
|
cdp: {
|
||||||
|
send: send as unknown as <T = unknown>(
|
||||||
|
tabId: number,
|
||||||
|
method: string,
|
||||||
|
params?: object,
|
||||||
|
) => Promise<T>,
|
||||||
|
trackSessionTab: vi.fn(),
|
||||||
|
},
|
||||||
|
tabsApi: {
|
||||||
|
get: vi.fn(),
|
||||||
|
query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const pending = handleSnapshot(sm, { session_id: "aa11" }, deps, controller.signal);
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(send).toHaveBeenCalledWith(4, "DOMSnapshot.captureSnapshot", expect.any(Object)),
|
||||||
|
);
|
||||||
|
controller.abort();
|
||||||
|
resolveCapture({ strings: [], documents: [] });
|
||||||
|
|
||||||
|
await expect(pending).resolves.toMatchObject({ code: "cancelled" });
|
||||||
|
expect(ctx.refStore.resolve("e1", { tabId: 4 })).toBe(999);
|
||||||
|
expect(ctx.refStore.resolve("e2")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("surfaces CDP failures as cdp_failed", async () => {
|
it("surfaces CDP failures as cdp_failed", async () => {
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
await sm.start("aa11");
|
await sm.start("aa11");
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ function makeApis(
|
|||||||
}),
|
}),
|
||||||
getLastFocused: vi.fn(async () => ({ id: 500 }) as chrome.windows.Window),
|
getLastFocused: vi.fn(async () => ({ id: 500 }) as chrome.windows.Window),
|
||||||
create: vi.fn(async () => ({ id: 999 }) as chrome.windows.Window),
|
create: vi.fn(async () => ({ id: 999 }) as chrome.windows.Window),
|
||||||
|
remove: vi.fn(async () => {}),
|
||||||
};
|
};
|
||||||
return { tabs, windows };
|
return { tabs, windows };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ function makeWindowsApi(
|
|||||||
get: ReturnType<typeof vi.fn>;
|
get: ReturnType<typeof vi.fn>;
|
||||||
lastFocused: ReturnType<typeof vi.fn>;
|
lastFocused: ReturnType<typeof vi.fn>;
|
||||||
create: ReturnType<typeof vi.fn>;
|
create: ReturnType<typeof vi.fn>;
|
||||||
|
remove: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
} {
|
} {
|
||||||
const get = vi.fn(async (windowId: number) => {
|
const get = vi.fn(async (windowId: number) => {
|
||||||
@@ -197,7 +198,13 @@ function makeWindowsApi(
|
|||||||
const id = opts?.createWindowId ?? 999;
|
const id = opts?.createWindowId ?? 999;
|
||||||
return { id } as chrome.windows.Window;
|
return { id } as chrome.windows.Window;
|
||||||
});
|
});
|
||||||
return { api: { get, getLastFocused: lastFocused, create }, spies: { get, lastFocused, create } };
|
const remove = vi.fn(async (windowId: number) => {
|
||||||
|
state.windowsClosed.add(windowId);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
api: { get, getLastFocused: lastFocused, create, remove },
|
||||||
|
spies: { get, lastFocused, create, remove },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("handleTabCreate", () => {
|
describe("handleTabCreate", () => {
|
||||||
@@ -601,6 +608,86 @@ describe("handleTabReturn", () => {
|
|||||||
expect(winSpies.create).toHaveBeenCalledOnce();
|
expect(winSpies.create).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("closes a newly created fallback window when cancellation wins before the move", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
||||||
|
const state: FakeTabState = {
|
||||||
|
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
|
||||||
|
nextTabId: 50,
|
||||||
|
windowsClosed: new Set([200]),
|
||||||
|
};
|
||||||
|
const { api, spies } = makeTabMutationApi(state);
|
||||||
|
const { api: windowsApi, spies: winSpies } = makeWindowsApi(state, {
|
||||||
|
lastFocused: 100,
|
||||||
|
createWindowId: 777,
|
||||||
|
});
|
||||||
|
let resolveCreate: (window: chrome.windows.Window) => void = () => {};
|
||||||
|
winSpies.create.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveCreate = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const pending = handleTabReturn(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11", tab_id: 7 },
|
||||||
|
{ tabs: api, windows: windowsApi, signal: controller.signal },
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => expect(winSpies.create).toHaveBeenCalledOnce());
|
||||||
|
controller.abort();
|
||||||
|
resolveCreate({ id: 777 } as chrome.windows.Window);
|
||||||
|
|
||||||
|
await expect(pending).resolves.toMatchObject({ code: "cancelled" });
|
||||||
|
expect(winSpies.remove).toHaveBeenCalledWith(777);
|
||||||
|
expect(spies.move).not.toHaveBeenCalled();
|
||||||
|
expect(ctx.borrowedTabs.has(7)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces the fallback window id when cancellation cleanup fails", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
||||||
|
const state: FakeTabState = {
|
||||||
|
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
|
||||||
|
nextTabId: 50,
|
||||||
|
windowsClosed: new Set([200]),
|
||||||
|
};
|
||||||
|
const { api, spies } = makeTabMutationApi(state);
|
||||||
|
const { api: windowsApi, spies: winSpies } = makeWindowsApi(state, {
|
||||||
|
lastFocused: 100,
|
||||||
|
createWindowId: 777,
|
||||||
|
});
|
||||||
|
let resolveCreate: (window: chrome.windows.Window) => void = () => {};
|
||||||
|
winSpies.create.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveCreate = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
winSpies.remove.mockRejectedValueOnce(new Error("window removal denied"));
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const pending = handleTabReturn(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11", tab_id: 7 },
|
||||||
|
{ tabs: api, windows: windowsApi, signal: controller.signal },
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => expect(winSpies.create).toHaveBeenCalledOnce());
|
||||||
|
controller.abort();
|
||||||
|
resolveCreate({ id: 777 } as chrome.windows.Window);
|
||||||
|
|
||||||
|
await expect(pending).resolves.toMatchObject({
|
||||||
|
code: "protocol_error",
|
||||||
|
data: { reason: "cleanup_failed", resource_type: "window", resource_id: 777 },
|
||||||
|
message: expect.stringMatching(/fallback window 777.*window removal denied/),
|
||||||
|
});
|
||||||
|
expect(spies.move).not.toHaveBeenCalled();
|
||||||
|
expect(ctx.borrowedTabs.has(7)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to a new window when getLastFocused only returns the Agent Window", async () => {
|
it("falls back to a new window when getLastFocused only returns the Agent Window", async () => {
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
const ctx = await sm.start("aa11");
|
const ctx = await sm.start("aa11");
|
||||||
|
|||||||
@@ -27,19 +27,23 @@ export async function handleConsole(
|
|||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
params: ConsoleParams,
|
params: ConsoleParams,
|
||||||
deps: ConsoleDeps = defaultConsoleDeps(),
|
deps: ConsoleDeps = defaultConsoleDeps(),
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<ConsoleResult | RpcError> {
|
): Promise<ConsoleResult | RpcError> {
|
||||||
|
if (signal?.aborted) return { code: "cancelled", message: "console aborted" };
|
||||||
const ctxOrErr = lookupSession(manager, params, "console");
|
const ctxOrErr = lookupSession(manager, params, "console");
|
||||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||||
const bounds = parseBufferedReadBounds(params);
|
const bounds = parseBufferedReadBounds(params);
|
||||||
if (isRpcError(bounds)) return bounds;
|
if (isRpcError(bounds)) return bounds;
|
||||||
const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi);
|
const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi);
|
||||||
if (isRpcError(target)) return target;
|
if (isRpcError(target)) return target;
|
||||||
|
if (signal?.aborted) return { code: "cancelled", message: "console aborted" };
|
||||||
if (!deps.cdp.ensureConsoleCapture || !deps.cdp.consoleEntriesSince) {
|
if (!deps.cdp.ensureConsoleCapture || !deps.cdp.consoleEntriesSince) {
|
||||||
return { code: "cdp_failed", message: "console capture requires CDP console support" };
|
return { code: "cdp_failed", message: "console capture requires CDP console support" };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deps.cdp.ensureConsoleCapture(target.tabId);
|
await deps.cdp.ensureConsoleCapture(target.tabId);
|
||||||
|
if (signal?.aborted) return { code: "cancelled", message: "console aborted" };
|
||||||
deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId);
|
deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId);
|
||||||
return deps.cdp.consoleEntriesSince(
|
return deps.cdp.consoleEntriesSince(
|
||||||
target.tabId,
|
target.tabId,
|
||||||
|
|||||||
@@ -125,11 +125,10 @@ export interface DispatcherDeps {
|
|||||||
* `AbortController` keyed by its wire `id` in
|
* `AbortController` keyed by its wire `id` in
|
||||||
* [`inflightAbortControllers`]. When the daemon pushes a `cancel`
|
* [`inflightAbortControllers`]. When the daemon pushes a `cancel`
|
||||||
* request the dispatcher trips the matching controller; tool
|
* request the dispatcher trips the matching controller; tool
|
||||||
* handlers that already accept a `signal` (waits, navigation,
|
* handlers observe that signal between awaited operations. The
|
||||||
* interaction, evaluate, tabs) react in line, and the dispatcher
|
* original RPC remains pending until its handler has stopped or
|
||||||
* additionally races the in-flight invocation against the abort
|
* completed compensation; only the separate cancel acknowledgement
|
||||||
* promise so handlers without explicit signal plumbing still respond
|
* takes the fast path.
|
||||||
* promptly with `cancelled`.
|
|
||||||
*/
|
*/
|
||||||
export class ToolDispatcher {
|
export class ToolDispatcher {
|
||||||
private readonly transport: Transport;
|
private readonly transport: Transport;
|
||||||
@@ -221,7 +220,7 @@ export class ToolDispatcher {
|
|||||||
try {
|
try {
|
||||||
const sessionId = sessionIdForBrowserControlMethod(req);
|
const sessionId = sessionIdForBrowserControlMethod(req);
|
||||||
if (sessionId) this.onBrowserControlResumed?.(sessionId);
|
if (sessionId) this.onBrowserControlResumed?.(sessionId);
|
||||||
const result = await Promise.race([this.invoke(req, ac.signal), abortPromise(ac.signal)]);
|
const result = await this.invoke(req, ac.signal);
|
||||||
if (isRpcError(result)) {
|
if (isRpcError(result)) {
|
||||||
body = { id: req.id, error: result };
|
body = { id: req.id, error: result };
|
||||||
} else {
|
} else {
|
||||||
@@ -286,7 +285,7 @@ export class ToolDispatcher {
|
|||||||
private async invoke(req: RequestFrame, signal: AbortSignal): Promise<unknown | RpcError> {
|
private async invoke(req: RequestFrame, signal: AbortSignal): Promise<unknown | RpcError> {
|
||||||
switch (req.method) {
|
switch (req.method) {
|
||||||
case "tool.session_start":
|
case "tool.session_start":
|
||||||
return handleSessionStart(this.sessions, req.params as SessionStartParams);
|
return handleSessionStart(this.sessions, req.params as SessionStartParams, { signal });
|
||||||
case "tool.session_stop": {
|
case "tool.session_stop": {
|
||||||
await this.releaseHoverLatch((req.params as SessionStopParams).session_id);
|
await this.releaseHoverLatch((req.params as SessionStopParams).session_id);
|
||||||
return handleSessionStop(this.sessions, req.params as SessionStopParams, {
|
return handleSessionStop(this.sessions, req.params as SessionStopParams, {
|
||||||
@@ -294,29 +293,36 @@ export class ToolDispatcher {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
case "tool.tab_list":
|
case "tool.tab_list":
|
||||||
return handleTabList(this.sessions, req.params as TabListParams);
|
return handleTabList(this.sessions, req.params as TabListParams, chromeTabsApi, signal);
|
||||||
case "tool.tab_create":
|
case "tool.tab_create":
|
||||||
return handleTabCreate(this.sessions, req.params as TabCreateParams);
|
return handleTabCreate(this.sessions, req.params as TabCreateParams, { signal });
|
||||||
case "tool.tab_close":
|
case "tool.tab_close":
|
||||||
return this.withHoverReleaseForRequest(req.params as TabCloseParams, () =>
|
return this.withHoverReleaseForRequest(
|
||||||
handleTabClose(this.sessions, req.params as TabCloseParams),
|
req.params as TabCloseParams,
|
||||||
|
() => handleTabClose(this.sessions, req.params as TabCloseParams, { signal }),
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
case "tool.tab_select":
|
case "tool.tab_select":
|
||||||
return handleTabSelect(this.sessions, req.params as TabSelectParams);
|
return handleTabSelect(this.sessions, req.params as TabSelectParams, { signal });
|
||||||
case "tool.tab_borrow":
|
case "tool.tab_borrow":
|
||||||
return handleTabBorrow(this.sessions, req.params as TabBorrowParams, {
|
return handleTabBorrow(this.sessions, req.params as TabBorrowParams, {
|
||||||
signal,
|
signal,
|
||||||
approveBorrow: this.approveBorrow,
|
approveBorrow: this.approveBorrow,
|
||||||
});
|
});
|
||||||
case "tool.tab_return":
|
case "tool.tab_return":
|
||||||
return handleTabReturn(this.sessions, req.params as TabReturnParams);
|
return handleTabReturn(this.sessions, req.params as TabReturnParams, { signal });
|
||||||
case "tool.window_resize":
|
case "tool.window_resize":
|
||||||
return handleWindowResize(this.sessions, req.params as WindowResizeParams);
|
return handleWindowResize(
|
||||||
|
this.sessions,
|
||||||
|
req.params as WindowResizeParams,
|
||||||
|
undefined,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
case "tool.emulate":
|
case "tool.emulate":
|
||||||
return handleEmulate(
|
return handleEmulate(
|
||||||
this.sessions,
|
this.sessions,
|
||||||
req.params as EmulateParams,
|
req.params as EmulateParams,
|
||||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
|
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
|
||||||
);
|
);
|
||||||
case "tool.screenshot":
|
case "tool.screenshot":
|
||||||
return handleScreenshot(
|
return handleScreenshot(
|
||||||
@@ -325,43 +331,57 @@ export class ToolDispatcher {
|
|||||||
this.cdp
|
this.cdp
|
||||||
? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi, captureApi: chromeTabsCaptureApi }
|
? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi, captureApi: chromeTabsCaptureApi }
|
||||||
: undefined,
|
: undefined,
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
case "tool.console":
|
case "tool.console":
|
||||||
return handleConsole(
|
return handleConsole(
|
||||||
this.sessions,
|
this.sessions,
|
||||||
req.params as ConsoleParams,
|
req.params as ConsoleParams,
|
||||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
|
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
case "tool.network":
|
case "tool.network":
|
||||||
return handleNetwork(
|
return handleNetwork(
|
||||||
this.sessions,
|
this.sessions,
|
||||||
req.params as NetworkParams,
|
req.params as NetworkParams,
|
||||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
|
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
case "tool.snapshot":
|
case "tool.snapshot":
|
||||||
return this.withHoverReassert(req.params as SnapshotParams, () =>
|
return this.withHoverReassert(
|
||||||
handleSnapshot(
|
req.params as SnapshotParams,
|
||||||
this.sessions,
|
() =>
|
||||||
req.params as SnapshotParams,
|
handleSnapshot(
|
||||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi } : undefined,
|
this.sessions,
|
||||||
),
|
req.params as SnapshotParams,
|
||||||
|
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi } : undefined,
|
||||||
|
signal,
|
||||||
|
),
|
||||||
|
{},
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
case "tool.observe": {
|
case "tool.observe": {
|
||||||
const params = req.params as ObserveParams;
|
const params = req.params as ObserveParams;
|
||||||
const hoverScope = await this.resolveHoverLatchScope(params);
|
const hoverScope = await this.resolveHoverLatchScope(params);
|
||||||
return this.withHoverReassert(params, () =>
|
throwIfDispatchAborted(signal);
|
||||||
handleObserve(
|
return this.withHoverReassert(
|
||||||
this.sessions,
|
params,
|
||||||
params,
|
() =>
|
||||||
this.cdp
|
handleObserve(
|
||||||
? {
|
this.sessions,
|
||||||
cdp: this.cdp,
|
params,
|
||||||
tabsApi: chromeTabsCaptureApi,
|
this.cdp
|
||||||
conditionalSurfaceProbe: !this.hasHoverLatchForScope(hoverScope),
|
? {
|
||||||
hoverProbeBypassOverlay: bypassOverlay,
|
cdp: this.cdp,
|
||||||
}
|
tabsApi: chromeTabsCaptureApi,
|
||||||
: undefined,
|
conditionalSurfaceProbe: !this.hasHoverLatchForScope(hoverScope),
|
||||||
),
|
hoverProbeBypassOverlay: bypassOverlay,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
signal,
|
||||||
|
),
|
||||||
|
{},
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "tool.get_html":
|
case "tool.get_html":
|
||||||
@@ -369,38 +389,51 @@ export class ToolDispatcher {
|
|||||||
this.sessions,
|
this.sessions,
|
||||||
req.params as GetHtmlParams,
|
req.params as GetHtmlParams,
|
||||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi } : undefined,
|
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi } : undefined,
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
case "tool.navigate":
|
case "tool.navigate":
|
||||||
return this.withHoverReleaseForRequest(req.params as NavigateParams, () =>
|
return this.withHoverReleaseForRequest(
|
||||||
handleNavigate(
|
req.params as NavigateParams,
|
||||||
this.sessions,
|
() =>
|
||||||
req.params as NavigateParams,
|
handleNavigate(
|
||||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
|
this.sessions,
|
||||||
),
|
req.params as NavigateParams,
|
||||||
|
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
|
||||||
|
),
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
case "tool.navigate_back":
|
case "tool.navigate_back":
|
||||||
return this.withHoverReleaseForRequest(req.params as NavigateBackParams, () =>
|
return this.withHoverReleaseForRequest(
|
||||||
handleNavigateBack(
|
req.params as NavigateBackParams,
|
||||||
this.sessions,
|
() =>
|
||||||
req.params as NavigateBackParams,
|
handleNavigateBack(
|
||||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
|
this.sessions,
|
||||||
),
|
req.params as NavigateBackParams,
|
||||||
|
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
|
||||||
|
),
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
case "tool.navigate_forward":
|
case "tool.navigate_forward":
|
||||||
return this.withHoverReleaseForRequest(req.params as NavigateForwardParams, () =>
|
return this.withHoverReleaseForRequest(
|
||||||
handleNavigateForward(
|
req.params as NavigateForwardParams,
|
||||||
this.sessions,
|
() =>
|
||||||
req.params as NavigateForwardParams,
|
handleNavigateForward(
|
||||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
|
this.sessions,
|
||||||
),
|
req.params as NavigateForwardParams,
|
||||||
|
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
|
||||||
|
),
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
case "tool.reload":
|
case "tool.reload":
|
||||||
return this.withHoverReleaseForRequest(req.params as ReloadParams, () =>
|
return this.withHoverReleaseForRequest(
|
||||||
handleReload(
|
req.params as ReloadParams,
|
||||||
this.sessions,
|
() =>
|
||||||
req.params as ReloadParams,
|
handleReload(
|
||||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
|
this.sessions,
|
||||||
),
|
req.params as ReloadParams,
|
||||||
|
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
|
||||||
|
),
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
case "tool.click":
|
case "tool.click":
|
||||||
return this.withHoverReassert(
|
return this.withHoverReassert(
|
||||||
@@ -419,6 +452,7 @@ export class ToolDispatcher {
|
|||||||
: undefined,
|
: undefined,
|
||||||
),
|
),
|
||||||
{ releaseAfter: true },
|
{ releaseAfter: true },
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
case "tool.hover": {
|
case "tool.hover": {
|
||||||
const result = await handleHover(
|
const result = await handleHover(
|
||||||
@@ -438,28 +472,37 @@ export class ToolDispatcher {
|
|||||||
return this.rememberHover((req.params as HoverParams).session_id, result);
|
return this.rememberHover((req.params as HoverParams).session_id, result);
|
||||||
}
|
}
|
||||||
case "tool.fill":
|
case "tool.fill":
|
||||||
return this.withHoverReleaseForRequest(req.params as FillParams, () =>
|
return this.withHoverReleaseForRequest(
|
||||||
handleFill(
|
req.params as FillParams,
|
||||||
this.sessions,
|
() =>
|
||||||
req.params as FillParams,
|
handleFill(
|
||||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
|
this.sessions,
|
||||||
),
|
req.params as FillParams,
|
||||||
|
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
|
||||||
|
),
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
case "tool.press":
|
case "tool.press":
|
||||||
return this.withHoverReleaseForRequest(req.params as PressParams, () =>
|
return this.withHoverReleaseForRequest(
|
||||||
handlePress(
|
req.params as PressParams,
|
||||||
this.sessions,
|
() =>
|
||||||
req.params as PressParams,
|
handlePress(
|
||||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
|
this.sessions,
|
||||||
),
|
req.params as PressParams,
|
||||||
|
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
|
||||||
|
),
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
case "tool.select":
|
case "tool.select":
|
||||||
return this.withHoverReleaseForRequest(req.params as SelectParams, () =>
|
return this.withHoverReleaseForRequest(
|
||||||
handleSelect(
|
req.params as SelectParams,
|
||||||
this.sessions,
|
() =>
|
||||||
req.params as SelectParams,
|
handleSelect(
|
||||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
|
this.sessions,
|
||||||
),
|
req.params as SelectParams,
|
||||||
|
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
|
||||||
|
),
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
case "tool.evaluate":
|
case "tool.evaluate":
|
||||||
return handleEvaluate(
|
return handleEvaluate(
|
||||||
@@ -566,9 +609,13 @@ export class ToolDispatcher {
|
|||||||
params: { session_id: string; tab_id?: number },
|
params: { session_id: string; tab_id?: number },
|
||||||
work: () => Promise<T>,
|
work: () => Promise<T>,
|
||||||
options: { releaseAfter?: boolean } = {},
|
options: { releaseAfter?: boolean } = {},
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
throwIfDispatchAborted(signal);
|
||||||
const scope = await this.resolveHoverLatchScope(params);
|
const scope = await this.resolveHoverLatchScope(params);
|
||||||
|
throwIfDispatchAborted(signal);
|
||||||
await this.reassertHover(scope);
|
await this.reassertHover(scope);
|
||||||
|
throwIfDispatchAborted(signal);
|
||||||
try {
|
try {
|
||||||
return await work();
|
return await work();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -581,9 +628,13 @@ export class ToolDispatcher {
|
|||||||
private async withHoverReleaseForRequest<T>(
|
private async withHoverReleaseForRequest<T>(
|
||||||
params: { session_id: string; tab_id?: number },
|
params: { session_id: string; tab_id?: number },
|
||||||
work: () => Promise<T>,
|
work: () => Promise<T>,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
throwIfDispatchAborted(signal);
|
||||||
const scope = await this.resolveHoverLatchScope(params);
|
const scope = await this.resolveHoverLatchScope(params);
|
||||||
|
throwIfDispatchAborted(signal);
|
||||||
await this.releaseHoverLatch(scope.session_id, scope.tab_id);
|
await this.releaseHoverLatch(scope.session_id, scope.tab_id);
|
||||||
|
throwIfDispatchAborted(signal);
|
||||||
return work();
|
return work();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,41 +744,14 @@ async function bypassOverlay(tabId: number, enabled: boolean): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function throwIfDispatchAborted(signal: AbortSignal | undefined): void {
|
||||||
* Resolves never; rejects with `AbortLikeError` as soon as the signal
|
if (!signal?.aborted) return;
|
||||||
* fires (or immediately if it is already aborted). Used by the
|
const error = new Error("rpc aborted by daemon cancel");
|
||||||
* dispatcher to race the tool invocation so handlers without explicit
|
error.name = "AbortError";
|
||||||
* signal plumbing still surface a `cancelled` reply promptly.
|
throw error;
|
||||||
*/
|
|
||||||
function abortPromise(signal: AbortSignal): Promise<never> {
|
|
||||||
return new Promise<never>((_, reject) => {
|
|
||||||
if (signal.aborted) {
|
|
||||||
reject(new AbortLikeError());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
signal.addEventListener(
|
|
||||||
"abort",
|
|
||||||
() => {
|
|
||||||
reject(new AbortLikeError());
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sentinel error class so [`isAbortLikeError`] can recognise our own
|
|
||||||
* race-rejection without confusing it with a real CDP failure.
|
|
||||||
*/
|
|
||||||
class AbortLikeError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("rpc aborted by daemon cancel");
|
|
||||||
this.name = "BhAbortError";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAbortLikeError(err: unknown): boolean {
|
function isAbortLikeError(err: unknown): boolean {
|
||||||
if (err instanceof AbortLikeError) return true;
|
|
||||||
if (err instanceof DOMException && err.name === "AbortError") return true;
|
if (err instanceof DOMException && err.name === "AbortError") return true;
|
||||||
if (typeof err === "object" && err !== null && (err as { name?: string }).name === "AbortError") {
|
if (typeof err === "object" && err !== null && (err as { name?: string }).name === "AbortError") {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export interface EmulateCdpRunner {
|
|||||||
export interface EmulateDeps {
|
export interface EmulateDeps {
|
||||||
cdp: EmulateCdpRunner;
|
cdp: EmulateCdpRunner;
|
||||||
tabsApi: ChromeTabsApi;
|
tabsApi: ChromeTabsApi;
|
||||||
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultEmulateDeps(): EmulateDeps {
|
function defaultEmulateDeps(): EmulateDeps {
|
||||||
@@ -210,11 +211,13 @@ export async function handleEmulate(
|
|||||||
params: EmulateParams,
|
params: EmulateParams,
|
||||||
deps: EmulateDeps = defaultEmulateDeps(),
|
deps: EmulateDeps = defaultEmulateDeps(),
|
||||||
): Promise<EmulateResult | RpcError> {
|
): Promise<EmulateResult | RpcError> {
|
||||||
|
if (deps.signal?.aborted) return { code: "cancelled", message: "emulate aborted" };
|
||||||
const ctxOrErr = lookupSession(manager, params, "emulate");
|
const ctxOrErr = lookupSession(manager, params, "emulate");
|
||||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||||
const ctx = ctxOrErr;
|
const ctx = ctxOrErr;
|
||||||
const target = await resolveTargetTab(manager, ctx, params?.tab_id, deps.tabsApi);
|
const target = await resolveTargetTab(manager, ctx, params?.tab_id, deps.tabsApi);
|
||||||
if (isRpcError(target)) return target;
|
if (isRpcError(target)) return target;
|
||||||
|
if (deps.signal?.aborted) return { code: "cancelled", message: "emulate aborted" };
|
||||||
const denied = enforceAgentWindow(ctx, target, "emulate");
|
const denied = enforceAgentWindow(ctx, target, "emulate");
|
||||||
if (denied) return denied;
|
if (denied) return denied;
|
||||||
|
|
||||||
@@ -223,9 +226,16 @@ export async function handleEmulate(
|
|||||||
return invalidParams("emulate off cannot be combined with overrides");
|
return invalidParams("emulate off cannot be combined with overrides");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
if (deps.signal?.aborted) return { code: "cancelled", message: "emulate aborted" };
|
||||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
await deps.cdp.clearDeviceMetricsOverride(target.tabId);
|
await deps.cdp.clearDeviceMetricsOverride(target.tabId);
|
||||||
|
if (deps.signal?.aborted) {
|
||||||
|
return { code: "cancelled", message: "emulate aborted after clearing device metrics" };
|
||||||
|
}
|
||||||
await deps.cdp.setTouchEmulationEnabled(target.tabId, false);
|
await deps.cdp.setTouchEmulationEnabled(target.tabId, false);
|
||||||
|
if (deps.signal?.aborted) {
|
||||||
|
return { code: "cancelled", message: "emulate aborted after clearing touch emulation" };
|
||||||
|
}
|
||||||
// An empty userAgent string clears the override (CDP convention).
|
// An empty userAgent string clears the override (CDP convention).
|
||||||
await deps.cdp.setUserAgentOverride(target.tabId, { userAgent: "" });
|
await deps.cdp.setUserAgentOverride(target.tabId, { userAgent: "" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -246,6 +256,7 @@ export async function handleEmulate(
|
|||||||
// values; the merged state is what gets applied (and echoed back).
|
// values; the merged state is what gets applied (and echoed back).
|
||||||
const merged = mergeEmulateOverrides(tabEmulationStates.get(target.tabId), overrides);
|
const merged = mergeEmulateOverrides(tabEmulationStates.get(target.tabId), overrides);
|
||||||
try {
|
try {
|
||||||
|
if (deps.signal?.aborted) return { code: "cancelled", message: "emulate aborted" };
|
||||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
if (merged.width !== undefined && merged.height !== undefined) {
|
if (merged.width !== undefined && merged.height !== undefined) {
|
||||||
await deps.cdp.setDeviceMetricsOverride(target.tabId, {
|
await deps.cdp.setDeviceMetricsOverride(target.tabId, {
|
||||||
@@ -254,6 +265,9 @@ export async function handleEmulate(
|
|||||||
deviceScaleFactor: merged.device_scale_factor ?? 0,
|
deviceScaleFactor: merged.device_scale_factor ?? 0,
|
||||||
mobile: merged.mobile ?? false,
|
mobile: merged.mobile ?? false,
|
||||||
});
|
});
|
||||||
|
if (deps.signal?.aborted) {
|
||||||
|
return { code: "cancelled", message: "emulate aborted after applying device metrics" };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (merged.user_agent !== undefined) {
|
if (merged.user_agent !== undefined) {
|
||||||
await deps.cdp.setUserAgentOverride(target.tabId, {
|
await deps.cdp.setUserAgentOverride(target.tabId, {
|
||||||
@@ -263,6 +277,9 @@ export async function handleEmulate(
|
|||||||
? { userAgentMetadata: toCdpUserAgentMetadata(merged.user_agent_metadata) }
|
? { userAgentMetadata: toCdpUserAgentMetadata(merged.user_agent_metadata) }
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
|
if (deps.signal?.aborted) {
|
||||||
|
return { code: "cancelled", message: "emulate aborted after applying user agent" };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (merged.touch !== undefined || merged.max_touch_points !== undefined) {
|
if (merged.touch !== undefined || merged.max_touch_points !== undefined) {
|
||||||
await deps.cdp.setTouchEmulationEnabled(
|
await deps.cdp.setTouchEmulationEnabled(
|
||||||
|
|||||||
@@ -37,17 +37,22 @@ export async function handleNetwork(
|
|||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
params: NetworkParams,
|
params: NetworkParams,
|
||||||
deps: NetworkDeps = defaultNetworkDeps(),
|
deps: NetworkDeps = defaultNetworkDeps(),
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<NetworkResult | RpcError> {
|
): Promise<NetworkResult | RpcError> {
|
||||||
|
if (signal?.aborted) return { code: "cancelled", message: "network aborted" };
|
||||||
const ctxOrErr = lookupSession(manager, params, "network");
|
const ctxOrErr = lookupSession(manager, params, "network");
|
||||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||||
const bounds = parseBufferedReadBounds(params);
|
const bounds = parseBufferedReadBounds(params);
|
||||||
if (isRpcError(bounds)) return bounds;
|
if (isRpcError(bounds)) return bounds;
|
||||||
const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi);
|
const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi);
|
||||||
if (isRpcError(target)) return target;
|
if (isRpcError(target)) return target;
|
||||||
|
if (signal?.aborted) return { code: "cancelled", message: "network aborted" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (signal?.aborted) return { code: "cancelled", message: "network aborted" };
|
||||||
deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId);
|
deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId);
|
||||||
await deps.cdp.ensureNetworkCapture(target.tabId);
|
await deps.cdp.ensureNetworkCapture(target.tabId);
|
||||||
|
if (signal?.aborted) return { code: "cancelled", message: "network aborted" };
|
||||||
return deps.cdp.networkEntriesSince(
|
return deps.cdp.networkEntriesSince(
|
||||||
target.tabId,
|
target.tabId,
|
||||||
bounds.since,
|
bounds.since,
|
||||||
|
|||||||
@@ -137,16 +137,42 @@ function defaultScreenshotDeps(): ScreenshotDeps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelled(tool: string): RpcError {
|
||||||
|
return { code: "cancelled", message: `${tool} aborted` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortError(tool: string): Error {
|
||||||
|
const error = new Error(`${tool} aborted`);
|
||||||
|
error.name = "AbortError";
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbortError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
(error as { name?: string }).name === "AbortError"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwIfAborted(signal: AbortSignal | undefined, tool: string): void {
|
||||||
|
if (signal?.aborted) throw abortError(tool);
|
||||||
|
}
|
||||||
|
|
||||||
async function captureElementScreenshot(
|
async function captureElementScreenshot(
|
||||||
cdp: SharedCdpRunner,
|
cdp: SharedCdpRunner,
|
||||||
tabId: number,
|
tabId: number,
|
||||||
backendNodeId: number,
|
backendNodeId: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<{ image_base64: string; width: number; height: number } | RpcError> {
|
): Promise<{ image_base64: string; width: number; height: number } | RpcError> {
|
||||||
|
if (signal?.aborted) return cancelled("screenshot");
|
||||||
const scrollErr = await scrollNodeIntoView(cdp, tabId, backendNodeId);
|
const scrollErr = await scrollNodeIntoView(cdp, tabId, backendNodeId);
|
||||||
if (scrollErr) return scrollErr;
|
if (scrollErr) return scrollErr;
|
||||||
|
if (signal?.aborted) return cancelled("screenshot");
|
||||||
|
|
||||||
const rectOrErr = await nodeBoundingRect(cdp, tabId, backendNodeId);
|
const rectOrErr = await nodeBoundingRect(cdp, tabId, backendNodeId);
|
||||||
if (isRpcError(rectOrErr)) return rectOrErr;
|
if (isRpcError(rectOrErr)) return rectOrErr;
|
||||||
|
if (signal?.aborted) return cancelled("screenshot");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shot = await cdp.send<{ data?: string }>(tabId, "Page.captureScreenshot", {
|
const shot = await cdp.send<{ data?: string }>(tabId, "Page.captureScreenshot", {
|
||||||
@@ -159,6 +185,7 @@ async function captureElementScreenshot(
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (signal?.aborted) return cancelled("screenshot");
|
||||||
const image_base64 = shot.data ?? "";
|
const image_base64 = shot.data ?? "";
|
||||||
if (!image_base64) {
|
if (!image_base64) {
|
||||||
return { code: "cdp_failed", message: "Page.captureScreenshot returned no data" };
|
return { code: "cdp_failed", message: "Page.captureScreenshot returned no data" };
|
||||||
@@ -193,11 +220,15 @@ async function captureFullTabPng(
|
|||||||
deps: ScreenshotDeps,
|
deps: ScreenshotDeps,
|
||||||
ctx: SessionContext,
|
ctx: SessionContext,
|
||||||
target: ResolvedTargetTab,
|
target: ResolvedTargetTab,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<string | RpcError> {
|
): Promise<string | RpcError> {
|
||||||
|
if (signal?.aborted) return cancelled("screenshot");
|
||||||
try {
|
try {
|
||||||
const dataUrl = await deps.captureApi.captureVisibleTab(target.windowId, { format: "png" });
|
const dataUrl = await deps.captureApi.captureVisibleTab(target.windowId, { format: "png" });
|
||||||
|
if (signal?.aborted) return cancelled("screenshot");
|
||||||
return stripDataUrlPrefix(dataUrl);
|
return stripDataUrlPrefix(dataUrl);
|
||||||
} catch (primaryErr) {
|
} catch (primaryErr) {
|
||||||
|
if (signal?.aborted) return cancelled("screenshot");
|
||||||
const cdp = deps.cdp;
|
const cdp = deps.cdp;
|
||||||
if (!cdp) {
|
if (!cdp) {
|
||||||
return {
|
return {
|
||||||
@@ -207,12 +238,15 @@ async function captureFullTabPng(
|
|||||||
}
|
}
|
||||||
let fallbackMsg: string;
|
let fallbackMsg: string;
|
||||||
try {
|
try {
|
||||||
|
if (signal?.aborted) return cancelled("screenshot");
|
||||||
cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
await cdp.ensureAttachedToUrl?.(target.tabId, target.url);
|
await cdp.ensureAttachedToUrl?.(target.tabId, target.url);
|
||||||
|
if (signal?.aborted) return cancelled("screenshot");
|
||||||
const shot = await cdp.send<{ data?: string }>(target.tabId, "Page.captureScreenshot", {
|
const shot = await cdp.send<{ data?: string }>(target.tabId, "Page.captureScreenshot", {
|
||||||
format: "png",
|
format: "png",
|
||||||
fromSurface: true,
|
fromSurface: true,
|
||||||
});
|
});
|
||||||
|
if (signal?.aborted) return cancelled("screenshot");
|
||||||
if (shot.data) return shot.data;
|
if (shot.data) return shot.data;
|
||||||
fallbackMsg = "Page.captureScreenshot returned no data";
|
fallbackMsg = "Page.captureScreenshot returned no data";
|
||||||
} catch (fallbackErr) {
|
} catch (fallbackErr) {
|
||||||
@@ -231,7 +265,9 @@ export async function handleScreenshot(
|
|||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
params: ScreenshotParams,
|
params: ScreenshotParams,
|
||||||
deps: ScreenshotDeps = defaultScreenshotDeps(),
|
deps: ScreenshotDeps = defaultScreenshotDeps(),
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<ScreenshotResult | RpcError> {
|
): Promise<ScreenshotResult | RpcError> {
|
||||||
|
if (signal?.aborted) return cancelled("screenshot");
|
||||||
const ctxOrErr = lookupSession(manager, params, "screenshot");
|
const ctxOrErr = lookupSession(manager, params, "screenshot");
|
||||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||||
const ctx = ctxOrErr;
|
const ctx = ctxOrErr;
|
||||||
@@ -243,6 +279,7 @@ export async function handleScreenshot(
|
|||||||
"screenshot",
|
"screenshot",
|
||||||
);
|
);
|
||||||
if (isRpcError(target)) return target;
|
if (isRpcError(target)) return target;
|
||||||
|
if (signal?.aborted) return cancelled("screenshot");
|
||||||
const dialogCursor = deps.cdp ? markDialogCursor(deps.cdp, target.tabId) : 0;
|
const dialogCursor = deps.cdp ? markDialogCursor(deps.cdp, target.tabId) : 0;
|
||||||
const withShotDialogs = <T extends object>(result: T) =>
|
const withShotDialogs = <T extends object>(result: T) =>
|
||||||
deps.cdp ? attachDialogs(deps.cdp, target.tabId, dialogCursor, result) : result;
|
deps.cdp ? attachDialogs(deps.cdp, target.tabId, dialogCursor, result) : result;
|
||||||
@@ -254,15 +291,18 @@ export async function handleScreenshot(
|
|||||||
}
|
}
|
||||||
const node = resolveSnapshotRef(ctx, ref, target.tabId);
|
const node = resolveSnapshotRef(ctx, ref, target.tabId);
|
||||||
if (isRpcError(node)) return node;
|
if (isRpcError(node)) return node;
|
||||||
|
if (signal?.aborted) return cancelled("screenshot");
|
||||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url);
|
await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url);
|
||||||
|
if (signal?.aborted) return cancelled("screenshot");
|
||||||
const cdp = deps.cdp;
|
const cdp = deps.cdp;
|
||||||
const captured = await withOverlaysHiddenForCapture(
|
const captured = await withOverlaysHiddenForCapture(
|
||||||
target.tabId,
|
target.tabId,
|
||||||
() => captureElementScreenshot(cdp, target.tabId, node.backendNodeId),
|
() => captureElementScreenshot(cdp, target.tabId, node.backendNodeId, signal),
|
||||||
deps.sendToTab,
|
deps.sendToTab,
|
||||||
);
|
);
|
||||||
if (isRpcError(captured)) return captured;
|
if (isRpcError(captured)) return captured;
|
||||||
|
if (signal?.aborted) return cancelled("screenshot");
|
||||||
return withShotDialogs({
|
return withShotDialogs({
|
||||||
image_base64: captured.image_base64,
|
image_base64: captured.image_base64,
|
||||||
width: captured.width,
|
width: captured.width,
|
||||||
@@ -282,10 +322,11 @@ export async function handleScreenshot(
|
|||||||
|
|
||||||
const captured = await withOverlaysHiddenForCapture(
|
const captured = await withOverlaysHiddenForCapture(
|
||||||
target.tabId,
|
target.tabId,
|
||||||
() => captureFullTabPng(deps, ctx, target),
|
() => captureFullTabPng(deps, ctx, target, signal),
|
||||||
deps.sendToTab,
|
deps.sendToTab,
|
||||||
);
|
);
|
||||||
if (isRpcError(captured)) return captured;
|
if (isRpcError(captured)) return captured;
|
||||||
|
if (signal?.aborted) return cancelled("screenshot");
|
||||||
const image_base64 = captured;
|
const image_base64 = captured;
|
||||||
const dims = parsePngDimensions(image_base64) ?? { width: 0, height: 0 };
|
const dims = parsePngDimensions(image_base64) ?? { width: 0, height: 0 };
|
||||||
return withShotDialogs({
|
return withShotDialogs({
|
||||||
@@ -1252,7 +1293,9 @@ export async function handleGetHtml(
|
|||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
params: GetHtmlParams,
|
params: GetHtmlParams,
|
||||||
deps: SnapshotDeps = getDefaultDeps(),
|
deps: SnapshotDeps = getDefaultDeps(),
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<GetHtmlResult | RpcError> {
|
): Promise<GetHtmlResult | RpcError> {
|
||||||
|
if (signal?.aborted) return cancelled("get_html");
|
||||||
const ctxOrErr = lookupSession(manager, params, "get_html");
|
const ctxOrErr = lookupSession(manager, params, "get_html");
|
||||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||||
const ctx = ctxOrErr;
|
const ctx = ctxOrErr;
|
||||||
@@ -1264,14 +1307,17 @@ export async function handleGetHtml(
|
|||||||
"get_html",
|
"get_html",
|
||||||
);
|
);
|
||||||
if (isRpcError(target)) return target;
|
if (isRpcError(target)) return target;
|
||||||
|
if (signal?.aborted) return cancelled("get_html");
|
||||||
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
||||||
|
|
||||||
const maxBytes =
|
const maxBytes =
|
||||||
params.max_bytes && params.max_bytes > 0 ? params.max_bytes : DEFAULT_GET_HTML_MAX_BYTES;
|
params.max_bytes && params.max_bytes > 0 ? params.max_bytes : DEFAULT_GET_HTML_MAX_BYTES;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
throwIfAborted(signal, "get_html");
|
||||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url);
|
await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url);
|
||||||
|
throwIfAborted(signal, "get_html");
|
||||||
let html: string;
|
let html: string;
|
||||||
if (params.ref) {
|
if (params.ref) {
|
||||||
const resolved = resolveSnapshotRef(ctx, params.ref, target.tabId);
|
const resolved = resolveSnapshotRef(ctx, params.ref, target.tabId);
|
||||||
@@ -1279,6 +1325,7 @@ export async function handleGetHtml(
|
|||||||
const resp = await deps.cdp.send<{ outerHTML?: string }>(target.tabId, "DOM.getOuterHTML", {
|
const resp = await deps.cdp.send<{ outerHTML?: string }>(target.tabId, "DOM.getOuterHTML", {
|
||||||
backendNodeId: resolved.backendNodeId,
|
backendNodeId: resolved.backendNodeId,
|
||||||
});
|
});
|
||||||
|
throwIfAborted(signal, "get_html");
|
||||||
html = resp.outerHTML ?? "";
|
html = resp.outerHTML ?? "";
|
||||||
} else {
|
} else {
|
||||||
const doc = await deps.cdp.send<{ root?: { nodeId?: number } }>(
|
const doc = await deps.cdp.send<{ root?: { nodeId?: number } }>(
|
||||||
@@ -1286,6 +1333,7 @@ export async function handleGetHtml(
|
|||||||
"DOM.getDocument",
|
"DOM.getDocument",
|
||||||
{ depth: 0 },
|
{ depth: 0 },
|
||||||
);
|
);
|
||||||
|
throwIfAborted(signal, "get_html");
|
||||||
const nodeId = doc.root?.nodeId;
|
const nodeId = doc.root?.nodeId;
|
||||||
if (typeof nodeId !== "number") {
|
if (typeof nodeId !== "number") {
|
||||||
return {
|
return {
|
||||||
@@ -1296,6 +1344,7 @@ export async function handleGetHtml(
|
|||||||
const resp = await deps.cdp.send<{ outerHTML?: string }>(target.tabId, "DOM.getOuterHTML", {
|
const resp = await deps.cdp.send<{ outerHTML?: string }>(target.tabId, "DOM.getOuterHTML", {
|
||||||
nodeId,
|
nodeId,
|
||||||
});
|
});
|
||||||
|
throwIfAborted(signal, "get_html");
|
||||||
html = resp.outerHTML ?? "";
|
html = resp.outerHTML ?? "";
|
||||||
}
|
}
|
||||||
const originalBytes = utf8ByteLength(html);
|
const originalBytes = utf8ByteLength(html);
|
||||||
@@ -1307,6 +1356,7 @@ export async function handleGetHtml(
|
|||||||
tab_id: target.tabId,
|
tab_id: target.tabId,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isAbortError(err)) return cancelled("get_html");
|
||||||
return {
|
return {
|
||||||
code: "cdp_failed",
|
code: "cdp_failed",
|
||||||
message: err instanceof Error ? err.message : String(err),
|
message: err instanceof Error ? err.message : String(err),
|
||||||
@@ -1326,19 +1376,24 @@ interface LayoutMetricsViewportReply {
|
|||||||
async function fallbackCapturedViewModel(
|
async function fallbackCapturedViewModel(
|
||||||
cdp: CdpRunner,
|
cdp: CdpRunner,
|
||||||
tabId: number,
|
tabId: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<CapturedViewModel> {
|
): Promise<CapturedViewModel> {
|
||||||
|
throwIfAborted(signal, "observation");
|
||||||
let viewport = { width: 0, height: 0 };
|
let viewport = { width: 0, height: 0 };
|
||||||
try {
|
try {
|
||||||
const metrics = await cdp.send<LayoutMetricsViewportReply>(tabId, "Page.getLayoutMetrics", {});
|
const metrics = await cdp.send<LayoutMetricsViewportReply>(tabId, "Page.getLayoutMetrics", {});
|
||||||
|
throwIfAborted(signal, "observation");
|
||||||
const vpSrc = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
|
const vpSrc = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
|
||||||
viewport = {
|
viewport = {
|
||||||
width: vpSrc.clientWidth ?? 0,
|
width: vpSrc.clientWidth ?? 0,
|
||||||
height: vpSrc.clientHeight ?? 0,
|
height: vpSrc.clientHeight ?? 0,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
if (isAbortError(err)) throw err;
|
||||||
// viewport stays zero-sized
|
// viewport stays zero-sized
|
||||||
}
|
}
|
||||||
const excludedBackendNodeIds = await collectOverlayExcludedBackendIds(cdp, tabId);
|
const excludedBackendNodeIds = await collectOverlayExcludedBackendIds(cdp, tabId, signal);
|
||||||
|
throwIfAborted(signal, "observation");
|
||||||
return { ...emptyCapturedViewModel(viewport), excludedBackendNodeIds };
|
return { ...emptyCapturedViewModel(viewport), excludedBackendNodeIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1347,14 +1402,17 @@ async function captureForVom(
|
|||||||
tabId: number,
|
tabId: number,
|
||||||
conditionalSurfaceProbe: boolean,
|
conditionalSurfaceProbe: boolean,
|
||||||
hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>,
|
hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<CapturedViewModel> {
|
): Promise<CapturedViewModel> {
|
||||||
try {
|
try {
|
||||||
return await captureViewModel(cdp, tabId, {
|
return await captureViewModel(cdp, tabId, {
|
||||||
conditionalSurfaceProbe,
|
conditionalSurfaceProbe,
|
||||||
hoverProbeBypassOverlay,
|
hoverProbeBypassOverlay,
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
return fallbackCapturedViewModel(cdp, tabId);
|
if (isAbortError(err)) throw err;
|
||||||
|
return fallbackCapturedViewModel(cdp, tabId, signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1365,7 +1423,9 @@ async function handleVomObservation(
|
|||||||
effect: ToolEffect,
|
effect: ToolEffect,
|
||||||
conditionalSurfaceProbe: boolean,
|
conditionalSurfaceProbe: boolean,
|
||||||
deps: SnapshotDeps = getDefaultDeps(),
|
deps: SnapshotDeps = getDefaultDeps(),
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<SnapshotResult | ObserveResult | RpcError> {
|
): Promise<SnapshotResult | ObserveResult | RpcError> {
|
||||||
|
if (signal?.aborted) return cancelled(toolName);
|
||||||
const ctxOrErr = lookupSession(manager, params, toolName);
|
const ctxOrErr = lookupSession(manager, params, toolName);
|
||||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||||
const ctx = ctxOrErr;
|
const ctx = ctxOrErr;
|
||||||
@@ -1377,19 +1437,24 @@ async function handleVomObservation(
|
|||||||
toolName,
|
toolName,
|
||||||
);
|
);
|
||||||
if (isRpcError(target)) return target;
|
if (isRpcError(target)) return target;
|
||||||
|
if (signal?.aborted) return cancelled(toolName);
|
||||||
const denied = enforceToolTargetScope(ctx, target, effect, toolName);
|
const denied = enforceToolTargetScope(ctx, target, effect, toolName);
|
||||||
if (denied) return denied;
|
if (denied) return denied;
|
||||||
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
throwIfAborted(signal, toolName);
|
||||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url);
|
await deps.cdp.ensureAttachedToUrl?.(target.tabId, target.url);
|
||||||
|
throwIfAborted(signal, toolName);
|
||||||
await deps.cdp.send<unknown>(target.tabId, "Accessibility.enable", {});
|
await deps.cdp.send<unknown>(target.tabId, "Accessibility.enable", {});
|
||||||
|
throwIfAborted(signal, toolName);
|
||||||
const result = await deps.cdp.send<{ nodes: CdpAxNode[] }>(
|
const result = await deps.cdp.send<{ nodes: CdpAxNode[] }>(
|
||||||
target.tabId,
|
target.tabId,
|
||||||
"Accessibility.getFullAXTree",
|
"Accessibility.getFullAXTree",
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
throwIfAborted(signal, toolName);
|
||||||
const axNodes = result.nodes ?? [];
|
const axNodes = result.nodes ?? [];
|
||||||
const effectiveConditionalSurfaceProbe =
|
const effectiveConditionalSurfaceProbe =
|
||||||
deps.conditionalSurfaceProbe ?? conditionalSurfaceProbe;
|
deps.conditionalSurfaceProbe ?? conditionalSurfaceProbe;
|
||||||
@@ -1398,13 +1463,16 @@ async function handleVomObservation(
|
|||||||
target.tabId,
|
target.tabId,
|
||||||
effectiveConditionalSurfaceProbe,
|
effectiveConditionalSurfaceProbe,
|
||||||
deps.hoverProbeBypassOverlay,
|
deps.hoverProbeBypassOverlay,
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
|
throwIfAborted(signal, toolName);
|
||||||
const scene = buildVomScene(axNodes, captured, { pageUrl: target.url });
|
const scene = buildVomScene(axNodes, captured, { pageUrl: target.url });
|
||||||
const rendered = renderVom(scene, {
|
const rendered = renderVom(scene, {
|
||||||
maxDepth: params.max_depth,
|
maxDepth: params.max_depth,
|
||||||
maxTokens: params.max_tokens,
|
maxTokens: params.max_tokens,
|
||||||
activeRegionPolicy: true,
|
activeRegionPolicy: true,
|
||||||
});
|
});
|
||||||
|
throwIfAborted(signal, toolName);
|
||||||
ctx.refStore.replace(
|
ctx.refStore.replace(
|
||||||
rendered.refs.map(
|
rendered.refs.map(
|
||||||
(r) => [r.ref, { backendNodeId: r.backendNodeId, tabId: target.tabId }] as const,
|
(r) => [r.ref, { backendNodeId: r.backendNodeId, tabId: target.tabId }] as const,
|
||||||
@@ -1430,6 +1498,7 @@ async function handleVomObservation(
|
|||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isAbortError(err)) return cancelled(toolName);
|
||||||
return {
|
return {
|
||||||
code: "cdp_failed",
|
code: "cdp_failed",
|
||||||
message: err instanceof Error ? err.message : String(err),
|
message: err instanceof Error ? err.message : String(err),
|
||||||
@@ -1441,14 +1510,16 @@ export async function handleSnapshot(
|
|||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
params: SnapshotParams,
|
params: SnapshotParams,
|
||||||
deps: SnapshotDeps = getDefaultDeps(),
|
deps: SnapshotDeps = getDefaultDeps(),
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<SnapshotResult | RpcError> {
|
): Promise<SnapshotResult | RpcError> {
|
||||||
return handleVomObservation(manager, params, "snapshot", "passive_read", false, deps);
|
return handleVomObservation(manager, params, "snapshot", "passive_read", false, deps, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleObserve(
|
export async function handleObserve(
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
params: ObserveParams,
|
params: ObserveParams,
|
||||||
deps: SnapshotDeps = getDefaultDeps(),
|
deps: SnapshotDeps = getDefaultDeps(),
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<ObserveResult | RpcError> {
|
): Promise<ObserveResult | RpcError> {
|
||||||
return handleVomObservation(manager, params, "observe", "transient_input", true, deps);
|
return handleVomObservation(manager, params, "observe", "transient_input", true, deps, signal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { SessionManager } from "@/session-manager/manager";
|
import { type SessionManager, SessionStartCleanupError } from "@/session-manager/manager";
|
||||||
import type { RpcError } from "@/transport/types";
|
import type { RpcError } from "@/transport/types";
|
||||||
|
import { rpcError } from "./errors";
|
||||||
import { clearRecordingForSession } from "./record";
|
import { clearRecordingForSession } from "./record";
|
||||||
import { isRpcError } from "./shared";
|
import { isRpcError } from "./shared";
|
||||||
import { returnBorrowedTab, type TabManagementDeps } from "./tabs";
|
import { returnBorrowedTab, type TabManagementDeps } from "./tabs";
|
||||||
@@ -58,6 +59,10 @@ export interface SessionStartResult {
|
|||||||
agent_window_id?: number;
|
agent_window_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionStartDeps {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionStopParams {
|
export interface SessionStopParams {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
}
|
}
|
||||||
@@ -91,6 +96,7 @@ export interface SessionStopDeps {
|
|||||||
export async function handleSessionStart(
|
export async function handleSessionStart(
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
params: SessionStartParams,
|
params: SessionStartParams,
|
||||||
|
deps: SessionStartDeps = {},
|
||||||
): Promise<SessionStartResult | RpcError> {
|
): Promise<SessionStartResult | RpcError> {
|
||||||
if (!params?.session_id) {
|
if (!params?.session_id) {
|
||||||
return {
|
return {
|
||||||
@@ -104,9 +110,23 @@ export async function handleSessionStart(
|
|||||||
const ctx = await manager.start(params.session_id, {
|
const ctx = await manager.start(params.session_id, {
|
||||||
size: sizeOrErr,
|
size: sizeOrErr,
|
||||||
focused: params.focused,
|
focused: params.focused,
|
||||||
|
signal: deps.signal,
|
||||||
});
|
});
|
||||||
return { agent_window_id: ctx.agentWindowId };
|
return { agent_window_id: ctx.agentWindowId };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof SessionStartCleanupError) {
|
||||||
|
return rpcError("protocol_error", "cleanup_failed", err.message, {
|
||||||
|
resource_type: "agent_window",
|
||||||
|
resource_id: err.windowId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof err === "object" &&
|
||||||
|
err !== null &&
|
||||||
|
(err as { name?: string }).name === "AbortError"
|
||||||
|
) {
|
||||||
|
return { code: "cancelled", message: "session_start aborted" };
|
||||||
|
}
|
||||||
// chrome.windows.create / SessionManager failures are not CDP
|
// chrome.windows.create / SessionManager failures are not CDP
|
||||||
// failures (§4.5 reserves cdp_failed for raw CDP errors). Surface
|
// failures (§4.5 reserves cdp_failed for raw CDP errors). Surface
|
||||||
// them as protocol_error so the CLI maps to the right exit code
|
// them as protocol_error so the CLI maps to the right exit code
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export interface ChromeWindowsApi {
|
|||||||
get(windowId: number): Promise<chrome.windows.Window>;
|
get(windowId: number): Promise<chrome.windows.Window>;
|
||||||
getLastFocused(filters?: chrome.windows.QueryOptions): Promise<chrome.windows.Window>;
|
getLastFocused(filters?: chrome.windows.QueryOptions): Promise<chrome.windows.Window>;
|
||||||
create(props: chrome.windows.CreateData): Promise<chrome.windows.Window | undefined>;
|
create(props: chrome.windows.CreateData): Promise<chrome.windows.Window | undefined>;
|
||||||
|
remove(windowId: number): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chromeWindowsApi: ChromeWindowsApi = {
|
export const chromeWindowsApi: ChromeWindowsApi = {
|
||||||
@@ -164,6 +165,7 @@ export const chromeWindowsApi: ChromeWindowsApi = {
|
|||||||
getLastFocused: (filters) =>
|
getLastFocused: (filters) =>
|
||||||
filters ? chrome.windows.getLastFocused(filters) : chrome.windows.getLastFocused(),
|
filters ? chrome.windows.getLastFocused(filters) : chrome.windows.getLastFocused(),
|
||||||
create: (p) => chrome.windows.create(p),
|
create: (p) => chrome.windows.create(p),
|
||||||
|
remove: (id) => chrome.windows.remove(id),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AgentOverlayResetApi {
|
export interface AgentOverlayResetApi {
|
||||||
@@ -215,7 +217,9 @@ export async function handleTabList(
|
|||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
params: TabListParams,
|
params: TabListParams,
|
||||||
api: ChromeTabsApi = chromeTabsApi,
|
api: ChromeTabsApi = chromeTabsApi,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<TabListResult | RpcError> {
|
): Promise<TabListResult | RpcError> {
|
||||||
|
if (signal?.aborted) return { code: "cancelled", message: "tab_list aborted" };
|
||||||
if (!params || typeof params.session_id !== "string" || params.session_id.length === 0) {
|
if (!params || typeof params.session_id !== "string" || params.session_id.length === 0) {
|
||||||
return {
|
return {
|
||||||
code: "invalid_params",
|
code: "invalid_params",
|
||||||
@@ -249,6 +253,7 @@ export async function handleTabList(
|
|||||||
const myAgentWindowId = ctx.agentWindowId;
|
const myAgentWindowId = ctx.agentWindowId;
|
||||||
|
|
||||||
const allTabs = await api.query({});
|
const allTabs = await api.query({});
|
||||||
|
if (signal?.aborted) return { code: "cancelled", message: "tab_list aborted" };
|
||||||
const tabs: TabInfo[] = [];
|
const tabs: TabInfo[] = [];
|
||||||
for (const t of allTabs) {
|
for (const t of allTabs) {
|
||||||
if (typeof t.id !== "number") continue;
|
if (typeof t.id !== "number") continue;
|
||||||
@@ -382,7 +387,12 @@ async function createTabAndCleanup(
|
|||||||
try {
|
try {
|
||||||
await getTabsApi(deps).remove(tab.id);
|
await getTabsApi(deps).remove(tab.id);
|
||||||
} catch (cleanupErr) {
|
} catch (cleanupErr) {
|
||||||
console.debug("[bsk tab_create] cleanup after abort failed", cleanupErr);
|
return rpcError(
|
||||||
|
"protocol_error",
|
||||||
|
"cleanup_failed",
|
||||||
|
`tab_create aborted but cleanup of tab ${tab.id} failed: ${describeError(cleanupErr)}`,
|
||||||
|
{ resource_type: "tab", resource_id: tab.id },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { code: "cancelled", message: "tab_create aborted" };
|
return { code: "cancelled", message: "tab_create aborted" };
|
||||||
@@ -693,7 +703,17 @@ async function moveTabForBorrow(
|
|||||||
try {
|
try {
|
||||||
await tabsApi.move(tabId, { windowId: originalWindowId, index: originalIndex });
|
await tabsApi.move(tabId, { windowId: originalWindowId, index: originalIndex });
|
||||||
} catch (rollbackErr) {
|
} catch (rollbackErr) {
|
||||||
console.debug("[bsk tab_borrow] rollback move failed", rollbackErr);
|
return rpcError(
|
||||||
|
"protocol_error",
|
||||||
|
"cleanup_failed",
|
||||||
|
`tab_borrow aborted but rollback of tab ${tabId} to window ${originalWindowId} failed: ${describeError(rollbackErr)}`,
|
||||||
|
{
|
||||||
|
resource_type: "tab",
|
||||||
|
resource_id: tabId,
|
||||||
|
original_window_id: originalWindowId,
|
||||||
|
original_index: originalIndex,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return { code: "cancelled", message: "tab_borrow aborted" };
|
return { code: "cancelled", message: "tab_borrow aborted" };
|
||||||
}
|
}
|
||||||
@@ -825,6 +845,12 @@ export interface ReturnOutcome {
|
|||||||
fallback: boolean;
|
fallback: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FallbackWindowTarget {
|
||||||
|
windowId: number;
|
||||||
|
index: number;
|
||||||
|
created: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function describeError(err: unknown): string {
|
function describeError(err: unknown): string {
|
||||||
return err instanceof Error ? err.message : String(err);
|
return err instanceof Error ? err.message : String(err);
|
||||||
}
|
}
|
||||||
@@ -833,10 +859,13 @@ async function chooseFallbackWindow(
|
|||||||
ctx: SessionContext,
|
ctx: SessionContext,
|
||||||
windowsApi: ChromeWindowsApi,
|
windowsApi: ChromeWindowsApi,
|
||||||
isAgentWindowId: (windowId: number) => boolean,
|
isAgentWindowId: (windowId: number) => boolean,
|
||||||
): Promise<{ windowId: number; index: number } | RpcError> {
|
signal?: AbortSignal,
|
||||||
|
): Promise<FallbackWindowTarget | RpcError> {
|
||||||
let lastFocusedError: unknown;
|
let lastFocusedError: unknown;
|
||||||
try {
|
try {
|
||||||
const last = await windowsApi.getLastFocused({ windowTypes: ["normal"] });
|
const last = await windowsApi.getLastFocused({ windowTypes: ["normal"] });
|
||||||
|
const cancelled = aborted(signal, "tab_return");
|
||||||
|
if (cancelled) return cancelled;
|
||||||
const lastId = typeof last?.id === "number" ? last.id : null;
|
const lastId = typeof last?.id === "number" ? last.id : null;
|
||||||
// Never relocate a user tab into *any* session's Agent Window: this
|
// Never relocate a user tab into *any* session's Agent Window: this
|
||||||
// session's is excluded explicitly, and other sessions' are excluded
|
// session's is excluded explicitly, and other sessions' are excluded
|
||||||
@@ -846,12 +875,15 @@ async function chooseFallbackWindow(
|
|||||||
// session write to it and, worse, see it destroyed when that session
|
// session write to it and, worse, see it destroyed when that session
|
||||||
// stops and closes its window.
|
// stops and closes its window.
|
||||||
if (lastId !== null && lastId !== ctx.agentWindowId && !isAgentWindowId(lastId)) {
|
if (lastId !== null && lastId !== ctx.agentWindowId && !isAgentWindowId(lastId)) {
|
||||||
return { windowId: lastId, index: -1 };
|
return { windowId: lastId, index: -1, created: false };
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastFocusedError = err;
|
lastFocusedError = err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cancelled = aborted(signal, "tab_return");
|
||||||
|
if (cancelled) return cancelled;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await windowsApi.create({
|
const created = await windowsApi.create({
|
||||||
url: NEW_TAB_DEFAULT_URL,
|
url: NEW_TAB_DEFAULT_URL,
|
||||||
@@ -863,7 +895,20 @@ async function chooseFallbackWindow(
|
|||||||
message: "tab_return: failed to create fallback window",
|
message: "tab_return: failed to create fallback window",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { windowId: created.id, index: 0 };
|
if (aborted(signal, "tab_return")) {
|
||||||
|
try {
|
||||||
|
await windowsApi.remove(created.id);
|
||||||
|
} catch (cleanupErr) {
|
||||||
|
return rpcError(
|
||||||
|
"protocol_error",
|
||||||
|
"cleanup_failed",
|
||||||
|
`tab_return aborted but cleanup of fallback window ${created.id} failed: ${describeError(cleanupErr)}`,
|
||||||
|
{ resource_type: "window", resource_id: created.id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { code: "cancelled", message: "tab_return aborted" };
|
||||||
|
}
|
||||||
|
return { windowId: created.id, index: 0, created: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const suffix = lastFocusedError
|
const suffix = lastFocusedError
|
||||||
? ` (after getLastFocused failed: ${describeError(lastFocusedError)})`
|
? ` (after getLastFocused failed: ${describeError(lastFocusedError)})`
|
||||||
@@ -875,6 +920,25 @@ async function chooseFallbackWindow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cleanupUnusedFallbackWindow(
|
||||||
|
windowsApi: ChromeWindowsApi,
|
||||||
|
target: FallbackWindowTarget,
|
||||||
|
reason: string,
|
||||||
|
): Promise<RpcError | null> {
|
||||||
|
if (!target.created) return null;
|
||||||
|
try {
|
||||||
|
await windowsApi.remove(target.windowId);
|
||||||
|
return null;
|
||||||
|
} catch (cleanupErr) {
|
||||||
|
return rpcError(
|
||||||
|
"protocol_error",
|
||||||
|
"cleanup_failed",
|
||||||
|
`${reason}; cleanup of fallback window ${target.windowId} failed: ${describeError(cleanupErr)}`,
|
||||||
|
{ resource_type: "window", resource_id: target.windowId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resetAgentOverlaysInReturnedTab(
|
function resetAgentOverlaysInReturnedTab(
|
||||||
ctx: SessionContext,
|
ctx: SessionContext,
|
||||||
tabId: number,
|
tabId: number,
|
||||||
@@ -911,10 +975,13 @@ export async function returnBorrowedTab(
|
|||||||
const tabsApi = getTabsApi(deps);
|
const tabsApi = getTabsApi(deps);
|
||||||
const windowsApi = getWindowsApi(deps);
|
const windowsApi = getWindowsApi(deps);
|
||||||
const isAgentWindowId = getIsAgentWindowId(deps);
|
const isAgentWindowId = getIsAgentWindowId(deps);
|
||||||
|
const alreadyCancelled = aborted(deps.signal, "tab_return");
|
||||||
|
if (alreadyCancelled) return alreadyCancelled;
|
||||||
|
|
||||||
let targetWindowId = entry.originalWindowId;
|
let targetWindowId = entry.originalWindowId;
|
||||||
let targetIndex = entry.originalIndex;
|
let targetIndex = entry.originalIndex;
|
||||||
let fallback = false;
|
let fallback = false;
|
||||||
|
let fallbackTarget: FallbackWindowTarget | null = null;
|
||||||
|
|
||||||
// Check the original window is still around.
|
// Check the original window is still around.
|
||||||
let originalAlive = true;
|
let originalAlive = true;
|
||||||
@@ -924,14 +991,30 @@ export async function returnBorrowedTab(
|
|||||||
console.debug("[bsk tab_return] original window gone, falling back", err);
|
console.debug("[bsk tab_return] original window gone, falling back", err);
|
||||||
originalAlive = false;
|
originalAlive = false;
|
||||||
}
|
}
|
||||||
|
const cancelledAfterLookup = aborted(deps.signal, "tab_return");
|
||||||
|
if (cancelledAfterLookup) return cancelledAfterLookup;
|
||||||
if (!originalAlive) {
|
if (!originalAlive) {
|
||||||
fallback = true;
|
fallback = true;
|
||||||
const target = await chooseFallbackWindow(ctx, windowsApi, isAgentWindowId);
|
const target = await chooseFallbackWindow(ctx, windowsApi, isAgentWindowId, deps.signal);
|
||||||
if ("code" in target) return target;
|
if ("code" in target) return target;
|
||||||
|
fallbackTarget = target;
|
||||||
targetWindowId = target.windowId;
|
targetWindowId = target.windowId;
|
||||||
targetIndex = target.index;
|
targetIndex = target.index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cancelledBeforeMove = aborted(deps.signal, "tab_return");
|
||||||
|
if (cancelledBeforeMove) {
|
||||||
|
if (fallbackTarget) {
|
||||||
|
const cleanupError = await cleanupUnusedFallbackWindow(
|
||||||
|
windowsApi,
|
||||||
|
fallbackTarget,
|
||||||
|
"tab_return aborted before moving the borrowed tab",
|
||||||
|
);
|
||||||
|
if (cleanupError) return cleanupError;
|
||||||
|
}
|
||||||
|
return cancelledBeforeMove;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const moved = await tabsApi.move(tabId, {
|
const moved = await tabsApi.move(tabId, {
|
||||||
windowId: targetWindowId,
|
windowId: targetWindowId,
|
||||||
@@ -947,14 +1030,33 @@ export async function returnBorrowedTab(
|
|||||||
fallback,
|
fallback,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (fallbackTarget) {
|
||||||
|
const cleanupError = await cleanupUnusedFallbackWindow(
|
||||||
|
windowsApi,
|
||||||
|
fallbackTarget,
|
||||||
|
`tab_return could not move tab ${tabId}`,
|
||||||
|
);
|
||||||
|
if (cleanupError) return cleanupError;
|
||||||
|
}
|
||||||
|
const cancelledAfterMoveFailure = aborted(deps.signal, "tab_return");
|
||||||
|
if (cancelledAfterMoveFailure) return cancelledAfterMoveFailure;
|
||||||
if (!fallback) {
|
if (!fallback) {
|
||||||
const target = await chooseFallbackWindow(ctx, windowsApi, isAgentWindowId);
|
const target = await chooseFallbackWindow(ctx, windowsApi, isAgentWindowId, deps.signal);
|
||||||
if ("code" in target) {
|
if ("code" in target) {
|
||||||
return {
|
return {
|
||||||
code: "cdp_failed",
|
code: "cdp_failed",
|
||||||
message: `tab_return: chrome.tabs.move failed: ${describeError(err)}; fallback failed: ${target.message}`,
|
message: `tab_return: chrome.tabs.move failed: ${describeError(err)}; fallback failed: ${target.message}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const cancelledBeforeFallbackMove = aborted(deps.signal, "tab_return");
|
||||||
|
if (cancelledBeforeFallbackMove) {
|
||||||
|
const cleanupError = await cleanupUnusedFallbackWindow(
|
||||||
|
windowsApi,
|
||||||
|
target,
|
||||||
|
"tab_return aborted before fallback move",
|
||||||
|
);
|
||||||
|
return cleanupError ?? cancelledBeforeFallbackMove;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const moved = await tabsApi.move(tabId, {
|
const moved = await tabsApi.move(tabId, {
|
||||||
windowId: target.windowId,
|
windowId: target.windowId,
|
||||||
@@ -970,6 +1072,12 @@ export async function returnBorrowedTab(
|
|||||||
fallback: true,
|
fallback: true,
|
||||||
};
|
};
|
||||||
} catch (fallbackErr) {
|
} catch (fallbackErr) {
|
||||||
|
const cleanupError = await cleanupUnusedFallbackWindow(
|
||||||
|
windowsApi,
|
||||||
|
target,
|
||||||
|
`tab_return fallback move for tab ${tabId} failed`,
|
||||||
|
);
|
||||||
|
if (cleanupError) return cleanupError;
|
||||||
return {
|
return {
|
||||||
code: "cdp_failed",
|
code: "cdp_failed",
|
||||||
message: `tab_return: chrome.tabs.move failed: ${describeError(err)}; fallback move failed: ${describeError(fallbackErr)}`,
|
message: `tab_return: chrome.tabs.move failed: ${describeError(err)}; fallback move failed: ${describeError(fallbackErr)}`,
|
||||||
|
|||||||
@@ -65,6 +65,25 @@ export interface CapturedViewModel {
|
|||||||
export interface CaptureViewModelOptions {
|
export interface CaptureViewModelOptions {
|
||||||
conditionalSurfaceProbe?: boolean;
|
conditionalSurfaceProbe?: boolean;
|
||||||
hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
|
hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureAbortError(): Error {
|
||||||
|
const error = new Error("observation aborted");
|
||||||
|
error.name = "AbortError";
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbortError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
(error as { name?: string }).name === "AbortError"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwIfAborted(signal: AbortSignal | undefined): void {
|
||||||
|
if (signal?.aborted) throw captureAbortError();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sparse array format Chrome uses for infrequently-set per-node fields. */
|
/** Sparse array format Chrome uses for infrequently-set per-node fields. */
|
||||||
@@ -203,21 +222,25 @@ async function enrichFormControlStates(
|
|||||||
cdp: CdpRunner,
|
cdp: CdpRunner,
|
||||||
tabId: number,
|
tabId: number,
|
||||||
frameNodeGroups: CapturedNode[][],
|
frameNodeGroups: CapturedNode[][],
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const hasControls = frameNodeGroups.some((nodes) =>
|
const hasControls = frameNodeGroups.some((nodes) =>
|
||||||
nodes.some((node) => isFormControlTag(node.tag)),
|
nodes.some((node) => isFormControlTag(node.tag)),
|
||||||
);
|
);
|
||||||
if (!hasControls) return;
|
if (!hasControls) return;
|
||||||
try {
|
try {
|
||||||
|
throwIfAborted(signal);
|
||||||
const result = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
const result = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
||||||
expression: formStateBatchExpression(MAX_FORM_ENRICH_CONTROLS),
|
expression: formStateBatchExpression(MAX_FORM_ENRICH_CONTROLS),
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
});
|
});
|
||||||
|
throwIfAborted(signal);
|
||||||
const frameStates = flattenFrameFormStates(runtimeValue<CapturedFrameFormState>(result));
|
const frameStates = flattenFrameFormStates(runtimeValue<CapturedFrameFormState>(result));
|
||||||
for (let i = 0; i < frameNodeGroups.length; i += 1) {
|
for (let i = 0; i < frameNodeGroups.length; i += 1) {
|
||||||
applyFormStates(frameNodeGroups[i], frameStates[i] ?? []);
|
applyFormStates(frameNodeGroups[i], frameStates[i] ?? []);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) throw error;
|
||||||
// Best-effort enrichment. DOMSnapshot/AX data still carries the nodes.
|
// Best-effort enrichment. DOMSnapshot/AX data still carries the nodes.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -425,8 +448,20 @@ function hoverStateExpression(): string {
|
|||||||
})()`;
|
})()`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function wait(ms: number): Promise<void> {
|
async function wait(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
throwIfAborted(signal);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
reject(captureAbortError());
|
||||||
|
};
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearHover(cdp: CdpRunner, tabId: number): Promise<void> {
|
async function clearHover(cdp: CdpRunner, tabId: number): Promise<void> {
|
||||||
@@ -598,41 +633,51 @@ async function probeHoverSurfaces(
|
|||||||
): Promise<CapturedSurfaceProbe[]> {
|
): Promise<CapturedSurfaceProbe[]> {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
|
throwIfAborted(options.signal);
|
||||||
const cssScan = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
const cssScan = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
||||||
expression: hoverCssTriggerScanExpression(),
|
expression: hoverCssTriggerScanExpression(),
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
});
|
});
|
||||||
|
throwIfAborted(options.signal);
|
||||||
const cssHoverPoints = runtimeValue<Array<{ x: number; y: number }>>(cssScan) ?? [];
|
const cssHoverPoints = runtimeValue<Array<{ x: number; y: number }>>(cssScan) ?? [];
|
||||||
const candidates = buildHoverCandidates(nodes, cssHoverPoints);
|
const candidates = buildHoverCandidates(nodes, cssHoverPoints);
|
||||||
if (candidates.length === 0) return [];
|
if (candidates.length === 0) return [];
|
||||||
|
|
||||||
const results: CapturedSurfaceProbe[] = [];
|
const results: CapturedSurfaceProbe[] = [];
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
|
throwIfAborted(options.signal);
|
||||||
await options.hoverProbeBypassOverlay?.(tabId, true).catch(() => undefined);
|
await options.hoverProbeBypassOverlay?.(tabId, true).catch(() => undefined);
|
||||||
try {
|
try {
|
||||||
|
throwIfAborted(options.signal);
|
||||||
for (const candidate of candidates.slice(0, MAX_HOVER_TRIGGERS)) {
|
for (const candidate of candidates.slice(0, MAX_HOVER_TRIGGERS)) {
|
||||||
|
throwIfAborted(options.signal);
|
||||||
if (Date.now() - started > MAX_HOVER_PROBE_MS) break;
|
if (Date.now() - started > MAX_HOVER_PROBE_MS) break;
|
||||||
if (results.length >= MAX_HOVER_SURFACES) break;
|
if (results.length >= MAX_HOVER_SURFACES) break;
|
||||||
if (seen.has(candidate.backendNodeId)) continue;
|
if (seen.has(candidate.backendNodeId)) continue;
|
||||||
try {
|
try {
|
||||||
await clearHover(cdp, tabId);
|
await clearHover(cdp, tabId);
|
||||||
await wait(HOVER_SETTLE_MS);
|
throwIfAborted(options.signal);
|
||||||
|
await wait(HOVER_SETTLE_MS, options.signal);
|
||||||
const baselineReply = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
const baselineReply = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
||||||
expression: hoverStateExpression(),
|
expression: hoverStateExpression(),
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
});
|
});
|
||||||
|
throwIfAborted(options.signal);
|
||||||
const baselineItems = runtimeValue<HoverRuntimeItem[]>(baselineReply) ?? [];
|
const baselineItems = runtimeValue<HoverRuntimeItem[]>(baselineReply) ?? [];
|
||||||
|
|
||||||
|
throwIfAborted(options.signal);
|
||||||
await cdp.send(tabId, "Input.dispatchMouseEvent", {
|
await cdp.send(tabId, "Input.dispatchMouseEvent", {
|
||||||
type: "mouseMoved",
|
type: "mouseMoved",
|
||||||
x: candidate.x,
|
x: candidate.x,
|
||||||
y: candidate.y,
|
y: candidate.y,
|
||||||
});
|
});
|
||||||
await wait(HOVER_SETTLE_MS);
|
throwIfAborted(options.signal);
|
||||||
|
await wait(HOVER_SETTLE_MS, options.signal);
|
||||||
const collected = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
const collected = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
|
||||||
expression: hoverStateExpression(),
|
expression: hoverStateExpression(),
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
});
|
});
|
||||||
|
throwIfAborted(options.signal);
|
||||||
const subItems = diffHoverItems(
|
const subItems = diffHoverItems(
|
||||||
baselineItems,
|
baselineItems,
|
||||||
runtimeValue<HoverRuntimeItem[]>(collected) ?? [],
|
runtimeValue<HoverRuntimeItem[]>(collected) ?? [],
|
||||||
@@ -646,7 +691,8 @@ async function probeHoverSurfaces(
|
|||||||
subItems,
|
subItems,
|
||||||
confidence: confidenceForHover(candidate, subItems),
|
confidence: confidenceForHover(candidate, subItems),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) throw error;
|
||||||
continue;
|
continue;
|
||||||
} finally {
|
} finally {
|
||||||
await clearHover(cdp, tabId);
|
await clearHover(cdp, tabId);
|
||||||
@@ -657,6 +703,7 @@ async function probeHoverSurfaces(
|
|||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isAbortError(err)) throw err;
|
||||||
console.debug("[bsk capture] hover surface probe failed", err);
|
console.debug("[bsk capture] hover surface probe failed", err);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -669,13 +716,16 @@ async function probeHoverSurfaces(
|
|||||||
export async function collectOverlayExcludedBackendIds(
|
export async function collectOverlayExcludedBackendIds(
|
||||||
cdp: CdpRunner,
|
cdp: CdpRunner,
|
||||||
tabId: number,
|
tabId: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<Set<number>> {
|
): Promise<Set<number>> {
|
||||||
const excluded = new Set<number>();
|
const excluded = new Set<number>();
|
||||||
try {
|
try {
|
||||||
|
throwIfAborted(signal);
|
||||||
const doc = await cdp.send<{ root?: { nodeId?: number } }>(tabId, "DOM.getDocument", {
|
const doc = await cdp.send<{ root?: { nodeId?: number } }>(tabId, "DOM.getDocument", {
|
||||||
depth: 0,
|
depth: 0,
|
||||||
pierce: true,
|
pierce: true,
|
||||||
});
|
});
|
||||||
|
throwIfAborted(signal);
|
||||||
const rootNodeId = doc.root?.nodeId;
|
const rootNodeId = doc.root?.nodeId;
|
||||||
if (typeof rootNodeId !== "number") return excluded;
|
if (typeof rootNodeId !== "number") return excluded;
|
||||||
|
|
||||||
@@ -683,6 +733,7 @@ export async function collectOverlayExcludedBackendIds(
|
|||||||
nodeId: rootNodeId,
|
nodeId: rootNodeId,
|
||||||
selector: OVERLAY_HOST_SELECTOR,
|
selector: OVERLAY_HOST_SELECTOR,
|
||||||
});
|
});
|
||||||
|
throwIfAborted(signal);
|
||||||
if (typeof found.nodeId !== "number" || found.nodeId === 0) return excluded;
|
if (typeof found.nodeId !== "number" || found.nodeId === 0) return excluded;
|
||||||
|
|
||||||
const described = await cdp.send<{ node?: CdpDomNode }>(tabId, "DOM.describeNode", {
|
const described = await cdp.send<{ node?: CdpDomNode }>(tabId, "DOM.describeNode", {
|
||||||
@@ -690,8 +741,10 @@ export async function collectOverlayExcludedBackendIds(
|
|||||||
depth: -1,
|
depth: -1,
|
||||||
pierce: true,
|
pierce: true,
|
||||||
});
|
});
|
||||||
|
throwIfAborted(signal);
|
||||||
collectBackendIdsFromDomNode(described.node, excluded);
|
collectBackendIdsFromDomNode(described.node, excluded);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isAbortError(err)) throw err;
|
||||||
console.debug("[bsk capture] overlay exclusion fallback failed", err);
|
console.debug("[bsk capture] overlay exclusion fallback failed", err);
|
||||||
}
|
}
|
||||||
return excluded;
|
return excluded;
|
||||||
@@ -910,7 +963,9 @@ export async function captureViewModel(
|
|||||||
tabId: number,
|
tabId: number,
|
||||||
options: CaptureViewModelOptions = {},
|
options: CaptureViewModelOptions = {},
|
||||||
): Promise<CapturedViewModel> {
|
): Promise<CapturedViewModel> {
|
||||||
|
throwIfAborted(options.signal);
|
||||||
const metrics = await cdp.send<LayoutMetricsReply>(tabId, "Page.getLayoutMetrics", {});
|
const metrics = await cdp.send<LayoutMetricsReply>(tabId, "Page.getLayoutMetrics", {});
|
||||||
|
throwIfAborted(options.signal);
|
||||||
const dpr = devicePixelRatio(metrics);
|
const dpr = devicePixelRatio(metrics);
|
||||||
const vpSrc = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
|
const vpSrc = metrics.cssLayoutViewport ?? metrics.layoutViewport ?? {};
|
||||||
const viewport: Viewport = {
|
const viewport: Viewport = {
|
||||||
@@ -921,11 +976,13 @@ export async function captureViewModel(
|
|||||||
const scrollY = vpSrc.pageY ?? 0;
|
const scrollY = vpSrc.pageY ?? 0;
|
||||||
|
|
||||||
await cdp.send(tabId, "DOMSnapshot.enable", {});
|
await cdp.send(tabId, "DOMSnapshot.enable", {});
|
||||||
|
throwIfAborted(options.signal);
|
||||||
const snap = await cdp.send<SnapshotReply>(tabId, "DOMSnapshot.captureSnapshot", {
|
const snap = await cdp.send<SnapshotReply>(tabId, "DOMSnapshot.captureSnapshot", {
|
||||||
computedStyles: REQUESTED_STYLES,
|
computedStyles: REQUESTED_STYLES,
|
||||||
includePaintOrder: true,
|
includePaintOrder: true,
|
||||||
includeDOMRects: true,
|
includeDOMRects: true,
|
||||||
});
|
});
|
||||||
|
throwIfAborted(options.signal);
|
||||||
|
|
||||||
const strings = snap.strings ?? [];
|
const strings = snap.strings ?? [];
|
||||||
const documents = snap.documents ?? [];
|
const documents = snap.documents ?? [];
|
||||||
@@ -957,11 +1014,13 @@ export async function captureViewModel(
|
|||||||
excludedBackendNodeIds.add(id);
|
excludedBackendNodeIds.add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await enrichFormControlStates(cdp, tabId, [nodes, ...iframeNodes.values()]);
|
await enrichFormControlStates(cdp, tabId, [nodes, ...iframeNodes.values()], options.signal);
|
||||||
|
throwIfAborted(options.signal);
|
||||||
|
|
||||||
const surfaceProbes = options.conditionalSurfaceProbe
|
const surfaceProbes = options.conditionalSurfaceProbe
|
||||||
? await probeHoverSurfaces(cdp, tabId, nodes, options)
|
? await probeHoverSurfaces(cdp, tabId, nodes, options)
|
||||||
: [];
|
: [];
|
||||||
|
throwIfAborted(options.signal);
|
||||||
|
|
||||||
return { nodes, viewport, iframeNodes, surfaceProbes, excludedBackendNodeIds };
|
return { nodes, viewport, iframeNodes, surfaceProbes, excludedBackendNodeIds };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ export async function handleWindowResize(
|
|||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
params: WindowResizeParams,
|
params: WindowResizeParams,
|
||||||
api: WindowResizeApi = chromeWindowResizeApi,
|
api: WindowResizeApi = chromeWindowResizeApi,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<WindowResizeResult | RpcError> {
|
): Promise<WindowResizeResult | RpcError> {
|
||||||
|
if (signal?.aborted) return { code: "cancelled", message: "window_resize aborted" };
|
||||||
const ctxOrErr = lookupSession(manager, params, "window_resize");
|
const ctxOrErr = lookupSession(manager, params, "window_resize");
|
||||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||||
const ctx = ctxOrErr;
|
const ctx = ctxOrErr;
|
||||||
@@ -64,6 +66,7 @@ export async function handleWindowResize(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (signal?.aborted) return { code: "cancelled", message: "window_resize aborted" };
|
||||||
await api.update(ctx.agentWindowId, {
|
await api.update(ctx.agentWindowId, {
|
||||||
width: sizeOrErr.width,
|
width: sizeOrErr.width,
|
||||||
height: sizeOrErr.height,
|
height: sizeOrErr.height,
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ export type RpcErrorReason =
|
|||||||
| "tab_not_active"
|
| "tab_not_active"
|
||||||
| "restricted_tab_url"
|
| "restricted_tab_url"
|
||||||
| "borrow_conflict"
|
| "borrow_conflict"
|
||||||
| "screenshot_capture_failed";
|
| "screenshot_capture_failed"
|
||||||
|
| "cleanup_failed";
|
||||||
|
|
||||||
export interface RpcErrorData {
|
export interface RpcErrorData {
|
||||||
reason?: RpcErrorReason;
|
reason?: RpcErrorReason;
|
||||||
|
|||||||
@@ -443,10 +443,10 @@ async fn handle_wait_ms(
|
|||||||
/// * **Queued** — the worker's pre-flight observes the cancelled
|
/// * **Queued** — the worker's pre-flight observes the cancelled
|
||||||
/// token and short-circuits with `cancelled` before any WS
|
/// token and short-circuits with `cancelled` before any WS
|
||||||
/// frame leaves the daemon (review C2 fix).
|
/// frame leaves the daemon (review C2 fix).
|
||||||
/// * **Forwarded** — the worker's `tokio::select!` returns
|
/// * **Forwarded** — we push a WS-side `cancel { rpc_id: ws_rpc_id }`
|
||||||
/// `cancelled` immediately, AND we additionally push a WS-side
|
/// frame so the extension's dispatcher can trip its
|
||||||
/// `cancel { rpc_id: ws_rpc_id }` frame so the extension's
|
/// `AbortController`; the worker keeps the session busy until the
|
||||||
/// dispatcher can trip its `AbortController`.
|
/// extension returns its final result or the cleanup timeout expires.
|
||||||
///
|
///
|
||||||
/// Returns `{ cancelled }` reflecting whether either surface
|
/// Returns `{ cancelled }` reflecting whether either surface
|
||||||
/// matched. The RPC itself never errors — a cancelled tool surfaces
|
/// matched. The RPC itself never errors — a cancelled tool surfaces
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ pub const QUEUE_CAPACITY: usize = 64;
|
|||||||
/// so tests can default it.
|
/// so tests can default it.
|
||||||
pub const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(30);
|
pub const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Once a forwarded request is cancelled, keep the session queue busy while
|
||||||
|
/// the extension finishes handler-side compensation. This is deliberately
|
||||||
|
/// bounded so a wedged extension cannot pin the session forever.
|
||||||
|
pub const CANCEL_CLEANUP_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
/// Job submitted into a session queue. Carries everything the worker
|
/// Job submitted into a session queue. Carries everything the worker
|
||||||
/// needs to forward one RPC and one oneshot to deliver the answer.
|
/// needs to forward one RPC and one oneshot to deliver the answer.
|
||||||
pub struct ToolJob {
|
pub struct ToolJob {
|
||||||
@@ -70,9 +75,9 @@ pub struct ToolJob {
|
|||||||
/// the queued/forwarded state machine; the worker checks it on
|
/// the queued/forwarded state machine; the worker checks it on
|
||||||
/// pre-flight (so a cancel arriving while the job was still
|
/// pre-flight (so a cancel arriving while the job was still
|
||||||
/// queued short-circuits without ever touching the extension)
|
/// queued short-circuits without ever touching the extension)
|
||||||
/// and selects on `cancel.cancelled()` while awaiting the WS
|
/// and observes `cancel.cancelled()` while awaiting the WS response.
|
||||||
/// response (so a cancel arriving mid-flight unblocks the worker
|
/// A forwarded cancel keeps the worker busy until the extension's
|
||||||
/// the same way the WS-side cancel frame already did).
|
/// final response arrives or the bounded cleanup timeout expires.
|
||||||
///
|
///
|
||||||
/// `None` for daemon-internal callers that do not flow through an
|
/// `None` for daemon-internal callers that do not flow through an
|
||||||
/// IPC request id (e.g. `session.stop`'s queued teardown call —
|
/// IPC request id (e.g. `session.stop`'s queued teardown call —
|
||||||
@@ -571,20 +576,41 @@ async fn forward_one(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let waited = await_with_optional_cancel(job.timeout, waiter, cancel_token.as_ref()).await;
|
let waited = await_with_optional_cancel(
|
||||||
|
job.timeout,
|
||||||
|
CANCEL_CLEANUP_TIMEOUT,
|
||||||
|
waiter,
|
||||||
|
cancel_token.as_ref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let response = match waited {
|
let response = match waited {
|
||||||
WaitOutcome::Response(resp) => resp,
|
WaitOutcome::Response(resp) => resp,
|
||||||
WaitOutcome::Cancelled => {
|
WaitOutcome::CancelledAfterResponse(resp) => {
|
||||||
// Drop the WS waiter so a late extension reply is dropped
|
// Cancellation wins the external verdict, but only after the
|
||||||
// cleanly (otherwise pending.resolve would log a stale
|
// extension's original RPC has settled. Preserve any non-cancel
|
||||||
// entry). The CLI caller will get a synthesised
|
// error so compensation failures remain explicit instead of
|
||||||
// `cancelled` here even if the extension never answers.
|
// being hidden behind a generic cancelled result.
|
||||||
client.pending.lock().unwrap().cancel(&rpc_id);
|
if let ResponseBody::Err(err) = resp.body
|
||||||
|
&& !matches!(err.code, ErrorCode::Cancelled | ErrorCode::UserAborted)
|
||||||
|
{
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
return Err(cancelled_error(
|
return Err(cancelled_error(
|
||||||
job.inflight.as_deref(),
|
job.inflight.as_deref(),
|
||||||
"tool dispatch cancelled mid-flight",
|
"tool dispatch cancelled after extension cleanup",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
WaitOutcome::CleanupTimeout => {
|
||||||
|
client.pending.lock().unwrap().cancel(&rpc_id);
|
||||||
|
return Err(RpcError {
|
||||||
|
code: ErrorCode::Timeout,
|
||||||
|
message: format!(
|
||||||
|
"cancelled tool did not finish cleanup within {:?}",
|
||||||
|
CANCEL_CLEANUP_TIMEOUT
|
||||||
|
),
|
||||||
|
data: Some(serde_json::json!({ "reason": "cancel_cleanup_timeout" })),
|
||||||
|
});
|
||||||
|
}
|
||||||
WaitOutcome::WaiterClosed => {
|
WaitOutcome::WaiterClosed => {
|
||||||
client.pending.lock().unwrap().cancel(&rpc_id);
|
client.pending.lock().unwrap().cancel(&rpc_id);
|
||||||
return Err(RpcError {
|
return Err(RpcError {
|
||||||
@@ -611,47 +637,42 @@ async fn forward_one(
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum WaitOutcome {
|
enum WaitOutcome {
|
||||||
Response(bsk_protocol::ResponseFrame),
|
Response(bsk_protocol::ResponseFrame),
|
||||||
Cancelled,
|
CancelledAfterResponse(bsk_protocol::ResponseFrame),
|
||||||
|
CleanupTimeout,
|
||||||
WaiterClosed,
|
WaiterClosed,
|
||||||
Timeout,
|
Timeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn await_with_optional_cancel(
|
async fn await_with_optional_cancel(
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
waiter: oneshot::Receiver<bsk_protocol::ResponseFrame>,
|
cleanup_timeout: Duration,
|
||||||
|
mut waiter: oneshot::Receiver<bsk_protocol::ResponseFrame>,
|
||||||
cancel: Option<&super::abort::AbortToken>,
|
cancel: Option<&super::abort::AbortToken>,
|
||||||
) -> WaitOutcome {
|
) -> WaitOutcome {
|
||||||
match cancel {
|
match cancel {
|
||||||
// Cancel wins if both ready: when a cancel notification and
|
Some(token) => {
|
||||||
// the extension's response are both observable on the same
|
let deadline = tokio::time::sleep(timeout);
|
||||||
// tokio tick, `biased;` polls `token.cancelled()` first and
|
tokio::pin!(deadline);
|
||||||
// resolves the whole `select!` to `WaitOutcome::Cancelled`.
|
tokio::select! {
|
||||||
// The already-arrived tool result is intentionally dropped.
|
// Cancel keeps same-tick priority, but it no longer drops the
|
||||||
//
|
// waiter. The worker remains busy until the extension replies
|
||||||
// This extends design §4.6 — which only specifies "CLI sends
|
// after compensation or the cleanup deadline expires.
|
||||||
// `cancel` on SIGINT and waits for the extension's
|
biased;
|
||||||
// `cancelled` reply (≤ 2s before forced exit)" — by pinning
|
_ = token.cancelled() => {
|
||||||
// the same-tick race to a single, observable verdict.
|
match tokio::time::timeout(cleanup_timeout, &mut waiter).await {
|
||||||
// Without `biased;`, an extension racing a fast successful
|
Ok(Ok(resp)) => WaitOutcome::CancelledAfterResponse(resp),
|
||||||
// reply against a cancel notification could occasionally
|
Ok(Err(_)) => WaitOutcome::WaiterClosed,
|
||||||
// resolve as `ok` even though the caller had already moved on
|
Err(_) => WaitOutcome::CleanupTimeout,
|
||||||
// to compensation logic, leaving the agent trusting state the
|
}
|
||||||
// daemon had just been asked to roll back. Picking
|
},
|
||||||
// cancel-wins keeps the external observation rule simple
|
outcome = &mut waiter => match outcome {
|
||||||
// ("once a cancel is in flight, the in-flight RPC's verdict
|
Ok(resp) => WaitOutcome::Response(resp),
|
||||||
// is `cancelled`") and matches what the CLI / agent already
|
Err(_) => WaitOutcome::WaiterClosed,
|
||||||
// assumes after firing SIGINT. Round 3 M1 / round 4 M2
|
},
|
||||||
// nail-down.
|
_ = &mut deadline => WaitOutcome::Timeout,
|
||||||
Some(token) => tokio::select! {
|
}
|
||||||
biased;
|
}
|
||||||
_ = token.cancelled() => WaitOutcome::Cancelled,
|
None => match tokio::time::timeout(timeout, &mut waiter).await {
|
||||||
outcome = tokio::time::timeout(timeout, waiter) => match outcome {
|
|
||||||
Ok(Ok(resp)) => WaitOutcome::Response(resp),
|
|
||||||
Ok(Err(_)) => WaitOutcome::WaiterClosed,
|
|
||||||
Err(_) => WaitOutcome::Timeout,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
None => match tokio::time::timeout(timeout, waiter).await {
|
|
||||||
Ok(Ok(resp)) => WaitOutcome::Response(resp),
|
Ok(Ok(resp)) => WaitOutcome::Response(resp),
|
||||||
Ok(Err(_)) => WaitOutcome::WaiterClosed,
|
Ok(Err(_)) => WaitOutcome::WaiterClosed,
|
||||||
Err(_) => WaitOutcome::Timeout,
|
Err(_) => WaitOutcome::Timeout,
|
||||||
@@ -733,10 +754,16 @@ mod await_with_optional_cancel_tests {
|
|||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
tx.send(dummy_response()).unwrap();
|
tx.send(dummy_response()).unwrap();
|
||||||
|
|
||||||
let outcome = await_with_optional_cancel(Duration::from_secs(10), rx, Some(&token)).await;
|
let outcome = await_with_optional_cancel(
|
||||||
|
Duration::from_secs(10),
|
||||||
|
Duration::from_secs(1),
|
||||||
|
rx,
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert!(
|
assert!(
|
||||||
matches!(outcome, WaitOutcome::Cancelled),
|
matches!(outcome, WaitOutcome::CancelledAfterResponse(_)),
|
||||||
"expected Cancelled when both arms are ready under biased; (got something else)"
|
"expected cancellation to win while retaining the ready response"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,7 +776,13 @@ mod await_with_optional_cancel_tests {
|
|||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
tx.send(dummy_response()).unwrap();
|
tx.send(dummy_response()).unwrap();
|
||||||
|
|
||||||
let outcome = await_with_optional_cancel(Duration::from_secs(10), rx, Some(&token)).await;
|
let outcome = await_with_optional_cancel(
|
||||||
|
Duration::from_secs(10),
|
||||||
|
Duration::from_secs(1),
|
||||||
|
rx,
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
match outcome {
|
match outcome {
|
||||||
WaitOutcome::Response(frame) => {
|
WaitOutcome::Response(frame) => {
|
||||||
assert!(matches!(frame.body, ResponseBody::Ok(_)));
|
assert!(matches!(frame.body, ResponseBody::Ok(_)));
|
||||||
@@ -762,9 +795,50 @@ mod await_with_optional_cancel_tests {
|
|||||||
async fn no_cancel_token_still_returns_response() {
|
async fn no_cancel_token_still_returns_response() {
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
tx.send(dummy_response()).unwrap();
|
tx.send(dummy_response()).unwrap();
|
||||||
let outcome = await_with_optional_cancel(Duration::from_secs(10), rx, None).await;
|
let outcome =
|
||||||
|
await_with_optional_cancel(Duration::from_secs(10), Duration::from_secs(1), rx, None)
|
||||||
|
.await;
|
||||||
assert!(matches!(outcome, WaitOutcome::Response(_)));
|
assert!(matches!(outcome, WaitOutcome::Response(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancelled_waiter_stays_pending_until_cleanup_response_arrives() {
|
||||||
|
let token = AbortToken::new();
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
token.cancel();
|
||||||
|
let pending = tokio::spawn(async move {
|
||||||
|
await_with_optional_cancel(
|
||||||
|
Duration::from_secs(10),
|
||||||
|
Duration::from_secs(1),
|
||||||
|
rx,
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
assert!(!pending.is_finished());
|
||||||
|
tx.send(dummy_response()).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
pending.await.unwrap(),
|
||||||
|
WaitOutcome::CancelledAfterResponse(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancelled_waiter_releases_after_cleanup_timeout() {
|
||||||
|
let token = AbortToken::new();
|
||||||
|
let (_tx, rx) = oneshot::channel();
|
||||||
|
token.cancel();
|
||||||
|
let outcome = await_with_optional_cancel(
|
||||||
|
Duration::from_secs(10),
|
||||||
|
Duration::from_millis(10),
|
||||||
|
rx,
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(matches!(outcome, WaitOutcome::CleanupTimeout));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -583,11 +583,11 @@ async fn cancel_for_unknown_rpc_id_returns_false() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn concurrent_tool_call_returns_session_busy_while_slow_rpc_inflight() {
|
async fn cancel_keeps_session_busy_until_delayed_extension_cleanup_finishes() {
|
||||||
// With session busy fast-fail, a second tool RPC submitted while
|
// A forwarded cancel is acknowledged immediately, but the extension
|
||||||
// the worker is occupied returns `session_busy` immediately
|
// deliberately delays the original RPC's final response to model
|
||||||
// instead of queuing behind the slow call. Cancel still unblocks
|
// handler-side compensation. The daemon must keep the session busy
|
||||||
// the in-flight RPC through the existing forwarded-cancel path.
|
// throughout that delay and release it only after cleanup settles.
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering as AOrdering};
|
use std::sync::atomic::{AtomicUsize, Ordering as AOrdering};
|
||||||
let (handle, sock) = spawn_daemon().await;
|
let (handle, sock) = spawn_daemon().await;
|
||||||
let mut ws = connect_ext(handle.ws_addr()).await;
|
let mut ws = connect_ext(handle.ws_addr()).await;
|
||||||
@@ -596,6 +596,10 @@ async fn concurrent_tool_call_returns_session_busy_while_slow_rpc_inflight() {
|
|||||||
let ws = Arc::new(tokio::sync::Mutex::new(ws));
|
let ws = Arc::new(tokio::sync::Mutex::new(ws));
|
||||||
let snapshots_seen = Arc::new(AtomicUsize::new(0));
|
let snapshots_seen = Arc::new(AtomicUsize::new(0));
|
||||||
let snapshots_seen_clone = Arc::clone(&snapshots_seen);
|
let snapshots_seen_clone = Arc::clone(&snapshots_seen);
|
||||||
|
let cleanup_started = Arc::new(tokio::sync::Notify::new());
|
||||||
|
let cleanup_started_clone = Arc::clone(&cleanup_started);
|
||||||
|
let cleanup_release = Arc::new(tokio::sync::Notify::new());
|
||||||
|
let cleanup_release_clone = Arc::clone(&cleanup_release);
|
||||||
let ws_clone = Arc::clone(&ws);
|
let ws_clone = Arc::clone(&ws);
|
||||||
let responder = tokio::spawn(async move {
|
let responder = tokio::spawn(async move {
|
||||||
let mut pending_snapshot: Option<String> = None;
|
let mut pending_snapshot: Option<String> = None;
|
||||||
@@ -636,6 +640,16 @@ async fn concurrent_tool_call_returns_session_busy_while_slow_rpc_inflight() {
|
|||||||
snapshots_seen_clone.fetch_add(1, AOrdering::SeqCst);
|
snapshots_seen_clone.fetch_add(1, AOrdering::SeqCst);
|
||||||
pending_snapshot = Some(req.id);
|
pending_snapshot = Some(req.id);
|
||||||
}
|
}
|
||||||
|
Method::ToolTabList => {
|
||||||
|
let reply = ResponseFrame {
|
||||||
|
id: req.id,
|
||||||
|
body: ResponseBody::Ok(json!({ "tabs": [] })),
|
||||||
|
};
|
||||||
|
let mut g = ws_clone.lock().await;
|
||||||
|
g.send(Message::Text(serde_json::to_string(&reply).unwrap()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
Method::Cancel => {
|
Method::Cancel => {
|
||||||
let target = req
|
let target = req
|
||||||
.params
|
.params
|
||||||
@@ -647,6 +661,8 @@ async fn concurrent_tool_call_returns_session_busy_while_slow_rpc_inflight() {
|
|||||||
if let (Some(target), Some(snap)) = (target, snapshot_id)
|
if let (Some(target), Some(snap)) = (target, snapshot_id)
|
||||||
&& target == snap
|
&& target == snap
|
||||||
{
|
{
|
||||||
|
cleanup_started_clone.notify_one();
|
||||||
|
cleanup_release_clone.notified().await;
|
||||||
let reply = ResponseFrame {
|
let reply = ResponseFrame {
|
||||||
id: snap,
|
id: snap,
|
||||||
body: ResponseBody::Err(RpcError {
|
body: ResponseBody::Err(RpcError {
|
||||||
@@ -753,6 +769,33 @@ async fn concurrent_tool_call_returns_session_busy_while_slow_rpc_inflight() {
|
|||||||
.expect("cancel slow rpc succeeds");
|
.expect("cancel slow rpc succeeds");
|
||||||
assert!(cancel_slow.cancelled);
|
assert!(cancel_slow.cancelled);
|
||||||
|
|
||||||
|
cleanup_started.notified().await;
|
||||||
|
let busy_after_cancel = tokio::time::timeout(
|
||||||
|
Duration::from_millis(200),
|
||||||
|
busy_ipc.call_with_id::<_, serde_json::Value>(
|
||||||
|
"snap-busy-after-cancel".into(),
|
||||||
|
Method::ToolSnapshot,
|
||||||
|
Some(json!({"session_id": session_id.clone()})),
|
||||||
|
Duration::from_secs(10),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("post-cancel RPC should fast-fail while cleanup is pending")
|
||||||
|
.unwrap();
|
||||||
|
let busy_after_cancel_err =
|
||||||
|
busy_after_cancel.expect_err("session must remain busy during delayed compensation");
|
||||||
|
assert_eq!(busy_after_cancel_err.code, ErrorCode::Timeout);
|
||||||
|
assert_eq!(
|
||||||
|
busy_after_cancel_err
|
||||||
|
.data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|d| d.get("reason"))
|
||||||
|
.and_then(|v| v.as_str()),
|
||||||
|
Some(bsk::rpc_reason::SESSION_BUSY)
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanup_release.notify_one();
|
||||||
|
|
||||||
let slow_outcome = tokio::time::timeout(Duration::from_secs(5), slow_handle)
|
let slow_outcome = tokio::time::timeout(Duration::from_secs(5), slow_handle)
|
||||||
.await
|
.await
|
||||||
.expect("slow snapshot did not resolve")
|
.expect("slow snapshot did not resolve")
|
||||||
@@ -761,6 +804,18 @@ async fn concurrent_tool_call_returns_session_busy_while_slow_rpc_inflight() {
|
|||||||
let slow_err = slow_outcome.expect_err("slow snapshot must surface cancelled");
|
let slow_err = slow_outcome.expect_err("slow snapshot must surface cancelled");
|
||||||
assert_eq!(slow_err.code, ErrorCode::Cancelled);
|
assert_eq!(slow_err.code, ErrorCode::Cancelled);
|
||||||
|
|
||||||
|
let after_cleanup: serde_json::Value = busy_ipc
|
||||||
|
.call(
|
||||||
|
"tabs-after-cleanup",
|
||||||
|
Method::ToolTabList,
|
||||||
|
Some(json!({"session_id": session_id})),
|
||||||
|
Duration::from_secs(3),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("session queue should reopen after extension cleanup");
|
||||||
|
assert_eq!(after_cleanup, json!({ "tabs": [] }));
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
snapshots_seen.load(AOrdering::SeqCst),
|
snapshots_seen.load(AOrdering::SeqCst),
|
||||||
1,
|
1,
|
||||||
|
|||||||
Reference in New Issue
Block a user