mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(record): record switch_tab and keep Trace v3 states per tab
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,7 +15,12 @@ import {
|
||||
const AGENT_WINDOW_ID = 100;
|
||||
const TAB_ID = 4;
|
||||
const START_URL = "https://example.com/";
|
||||
const RECORD_START_V3 = { session_id: "abcd", url: START_URL, trace_version: 3 as const };
|
||||
const RECORD_START_V3 = {
|
||||
session_id: "abcd",
|
||||
url: START_URL,
|
||||
trace_version: 3 as const,
|
||||
supports_tab_switch_steps: true,
|
||||
};
|
||||
const stepSequenceByProducer = new Map<string, number>();
|
||||
|
||||
function asTraceV3(trace: RecordedTrace): TraceV3 {
|
||||
@@ -48,6 +53,7 @@ function chromeEvent<T extends (...args: never[]) => unknown>() {
|
||||
|
||||
function installChrome() {
|
||||
const runtimeOnMessage = chromeEvent<RuntimeListener>();
|
||||
const tabsOnActivated = chromeEvent<(activeInfo: chrome.tabs.TabActiveInfo) => unknown>();
|
||||
const webNavigationOnCompleted =
|
||||
chromeEvent<(details: chrome.webNavigation.WebNavigationFramedCallbackDetails) => unknown>();
|
||||
const webNavigationOnCommitted =
|
||||
@@ -57,7 +63,7 @@ function installChrome() {
|
||||
vi.stubGlobal("chrome", {
|
||||
runtime: { onMessage: runtimeOnMessage },
|
||||
tabs: {
|
||||
onActivated: chromeEvent(),
|
||||
onActivated: tabsOnActivated,
|
||||
onCreated: chromeEvent(),
|
||||
onUpdated: chromeEvent(),
|
||||
},
|
||||
@@ -66,7 +72,7 @@ function installChrome() {
|
||||
onCommitted: webNavigationOnCommitted,
|
||||
},
|
||||
});
|
||||
return { runtimeOnMessage, webNavigationOnCompleted, webNavigationOnCommitted };
|
||||
return { runtimeOnMessage, tabsOnActivated, webNavigationOnCompleted, webNavigationOnCommitted };
|
||||
}
|
||||
|
||||
function fakeManager() {
|
||||
@@ -118,7 +124,10 @@ type FakeCdp = CdpRunner & {
|
||||
const LONG_IDLE_MS = 10_000;
|
||||
|
||||
/** AX-only page: DOMSnapshot calls throw so capture falls back to the AX tree. */
|
||||
function makeFakeCdp(trees?: unknown[], options?: { failCaptures?: boolean }): FakeCdp {
|
||||
function makeFakeCdp(
|
||||
trees?: unknown[],
|
||||
options?: { failCaptures?: boolean; treesByTab?: Record<number, unknown> },
|
||||
): FakeCdp {
|
||||
type EventListener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => void;
|
||||
const events: EventListener[] = [];
|
||||
const script = [...(trees ?? [])];
|
||||
@@ -153,6 +162,8 @@ function makeFakeCdp(trees?: unknown[], options?: { failCaptures?: boolean }): F
|
||||
"Accessibility.enable": () => ({}),
|
||||
"Accessibility.getFullAXTree": (_params, _tabId) => {
|
||||
if (failing) throw new Error("Execution context was destroyed");
|
||||
const tabTree = options?.treesByTab?.[_tabId];
|
||||
if (tabTree) return tabTree;
|
||||
if (script.length === 0) return axTree("Example Domain", ["Submit"]);
|
||||
return script.length === 1 ? script[0] : script.shift();
|
||||
},
|
||||
@@ -202,6 +213,31 @@ function makeTabsApi() {
|
||||
};
|
||||
}
|
||||
|
||||
function makeMultiTabsApi(tabs: chrome.tabs.Tab[]) {
|
||||
const byId = new Map(tabs.flatMap((tab) => (typeof tab.id === "number" ? [[tab.id, tab]] : [])));
|
||||
return {
|
||||
get: async (tabId: number) => {
|
||||
const tab = byId.get(tabId);
|
||||
if (!tab) throw new Error(`tab ${tabId} closed`);
|
||||
return tab;
|
||||
},
|
||||
query: async (query: chrome.tabs.QueryInfo) =>
|
||||
[...byId.values()].filter(
|
||||
(tab) =>
|
||||
(query.windowId === undefined || tab.windowId === query.windowId) &&
|
||||
(query.active === undefined || tab.active === query.active),
|
||||
),
|
||||
activate(tabId: number) {
|
||||
for (const tab of byId.values()) tab.active = tab.id === tabId;
|
||||
},
|
||||
goTo(tabId: number, url: string, title: string) {
|
||||
const tab = byId.get(tabId);
|
||||
if (!tab) throw new Error(`tab ${tabId} closed`);
|
||||
Object.assign(tab, { url, title });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Outlast a settle on a quiet page plus the per-tab observation cooldown. */
|
||||
function settleWait(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 800));
|
||||
@@ -212,16 +248,19 @@ function runtimeOnMessageEmit(
|
||||
requestId: string,
|
||||
step: unknown,
|
||||
tabId = TAB_ID,
|
||||
): void {
|
||||
active = true,
|
||||
): ReturnType<typeof vi.fn> {
|
||||
const producerId = `test-producer-${tabId}`;
|
||||
const producerKey = `${requestId}:${producerId}`;
|
||||
const sequence = (stepSequenceByProducer.get(producerKey) ?? 0) + 1;
|
||||
stepSequenceByProducer.set(producerKey, sequence);
|
||||
const sendResponse = vi.fn();
|
||||
chromeApi.runtimeOnMessage.emit(
|
||||
{ type: RECORD_STEP, requestId, producerId, sequence, step },
|
||||
{ tab: { id: tabId } } as chrome.runtime.MessageSender,
|
||||
() => {},
|
||||
{ tab: { id: tabId, active } } as chrome.runtime.MessageSender,
|
||||
sendResponse,
|
||||
);
|
||||
return sendResponse;
|
||||
}
|
||||
|
||||
describe("recorded user steps reach the exported trace", () => {
|
||||
@@ -1173,4 +1212,335 @@ describe("recorded user steps reach the exported trace", () => {
|
||||
);
|
||||
expect(result).toMatchObject({ code: "invalid_params" });
|
||||
});
|
||||
|
||||
it("records tab transitions and binds actions to each tab's observation", async () => {
|
||||
const chromeApi = installChrome();
|
||||
const manager = fakeManager();
|
||||
const secondUrl = "https://example.com/second";
|
||||
const tabsApi = makeMultiTabsApi([
|
||||
{
|
||||
id: TAB_ID,
|
||||
windowId: AGENT_WINDOW_ID,
|
||||
active: true,
|
||||
status: "complete",
|
||||
url: START_URL,
|
||||
title: "First tab",
|
||||
} as chrome.tabs.Tab,
|
||||
{
|
||||
id: 5,
|
||||
windowId: AGENT_WINDOW_ID,
|
||||
active: false,
|
||||
status: "complete",
|
||||
url: secondUrl,
|
||||
title: "Second tab",
|
||||
} as chrome.tabs.Tab,
|
||||
]);
|
||||
const cdp = makeFakeCdp(undefined, {
|
||||
treesByTab: {
|
||||
[TAB_ID]: axTree("First tab", ["First action"]),
|
||||
5: axTree("Second tab", ["Second action"]),
|
||||
},
|
||||
});
|
||||
let requestId = "";
|
||||
const sendToTab = vi.fn(async (_tabId: number, message: unknown) => {
|
||||
const typed = message as { type?: string; requestId?: string };
|
||||
if (typed.type === RECORD_START && typed.requestId) requestId = typed.requestId;
|
||||
return { ok: true };
|
||||
});
|
||||
const deps = { tabsApi, sendToTab, cdp };
|
||||
|
||||
await handleRecordStart(manager, RECORD_START_V3, deps);
|
||||
attachRecordStepListener(deps);
|
||||
tabsApi.activate(5);
|
||||
chromeApi.tabsOnActivated.emit({ tabId: 5, windowId: AGENT_WINDOW_ID });
|
||||
await settleWait();
|
||||
runtimeOnMessageEmit(
|
||||
chromeApi,
|
||||
requestId,
|
||||
{
|
||||
op: "click",
|
||||
page_url: secondUrl,
|
||||
target: { role: "button", name: "Second action", tag: "button" },
|
||||
},
|
||||
5,
|
||||
);
|
||||
await settleWait();
|
||||
|
||||
tabsApi.activate(TAB_ID);
|
||||
chromeApi.tabsOnActivated.emit({ tabId: TAB_ID, windowId: AGENT_WINDOW_ID });
|
||||
await settleWait();
|
||||
runtimeOnMessageEmit(chromeApi, requestId, {
|
||||
op: "click",
|
||||
page_url: START_URL,
|
||||
target: { role: "button", name: "First action", tag: "button" },
|
||||
});
|
||||
await settleWait();
|
||||
|
||||
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, deps);
|
||||
const trace = asTraceV3((stopped as RecordStopResult).trace);
|
||||
const stateUrl = (stateId: string) => trace.states.find((state) => state.id === stateId)?.url;
|
||||
const switches = trace.steps.filter((step) => step.op === "switch_tab");
|
||||
const clicks = trace.steps.filter((step) => step.op === "click");
|
||||
|
||||
expect(switches).toHaveLength(2);
|
||||
expect(clicks).toHaveLength(2);
|
||||
expect(stateUrl(switches[0]!.state)).toBe(START_URL);
|
||||
expect(stateUrl(switches[0]!.result.state)).toBe(secondUrl);
|
||||
expect(stateUrl(clicks[0]!.state)).toBe(secondUrl);
|
||||
expect(stateUrl(switches[1]!.result.state)).toBe(START_URL);
|
||||
expect(stateUrl(clicks[1]!.state)).toBe(START_URL);
|
||||
});
|
||||
|
||||
it("keeps switch_tab internal when the v3 caller did not advertise support", async () => {
|
||||
const chromeApi = installChrome();
|
||||
const manager = fakeManager();
|
||||
const tabsApi = makeMultiTabsApi([
|
||||
{
|
||||
id: TAB_ID,
|
||||
windowId: AGENT_WINDOW_ID,
|
||||
active: true,
|
||||
status: "complete",
|
||||
url: START_URL,
|
||||
} as chrome.tabs.Tab,
|
||||
{
|
||||
id: 5,
|
||||
windowId: AGENT_WINDOW_ID,
|
||||
active: false,
|
||||
status: "complete",
|
||||
url: "https://example.com/second",
|
||||
} as chrome.tabs.Tab,
|
||||
]);
|
||||
const deps = { tabsApi, sendToTab: vi.fn(async () => ({ ok: true })), cdp: makeFakeCdp() };
|
||||
|
||||
await handleRecordStart(
|
||||
manager,
|
||||
{ session_id: "abcd", url: START_URL, trace_version: 3 },
|
||||
deps,
|
||||
);
|
||||
tabsApi.activate(5);
|
||||
chromeApi.tabsOnActivated.emit({ tabId: 5, windowId: AGENT_WINDOW_ID });
|
||||
await settleWait();
|
||||
|
||||
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, deps);
|
||||
const trace = asTraceV3((stopped as RecordStopResult).trace);
|
||||
expect(trace.steps.some((step) => step.op === "switch_tab")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the latest rapid tab activation when an earlier rearm acknowledges late", async () => {
|
||||
const chromeApi = installChrome();
|
||||
const manager = fakeManager();
|
||||
const tabsApi = makeMultiTabsApi(
|
||||
[TAB_ID, 5, 6].map(
|
||||
(id) =>
|
||||
({
|
||||
id,
|
||||
windowId: AGENT_WINDOW_ID,
|
||||
active: id === TAB_ID,
|
||||
status: "complete",
|
||||
url: id === TAB_ID ? START_URL : `https://example.com/tab-${id}`,
|
||||
}) as chrome.tabs.Tab,
|
||||
),
|
||||
);
|
||||
let releaseSecond!: () => void;
|
||||
const secondGate = new Promise<void>((resolve) => {
|
||||
releaseSecond = resolve;
|
||||
});
|
||||
let announceSecond!: () => void;
|
||||
const secondStarted = new Promise<void>((resolve) => {
|
||||
announceSecond = resolve;
|
||||
});
|
||||
const sendToTab = vi.fn(async (tabId: number, message: unknown) => {
|
||||
if ((message as { type?: string }).type === RECORD_START && tabId === 5) {
|
||||
announceSecond();
|
||||
await secondGate;
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
const deps = { tabsApi, sendToTab, cdp: makeFakeCdp() };
|
||||
|
||||
await handleRecordStart(manager, RECORD_START_V3, deps);
|
||||
tabsApi.activate(5);
|
||||
chromeApi.tabsOnActivated.emit({ tabId: 5, windowId: AGENT_WINDOW_ID });
|
||||
await secondStarted;
|
||||
tabsApi.activate(6);
|
||||
chromeApi.tabsOnActivated.emit({ tabId: 6, windowId: AGENT_WINDOW_ID });
|
||||
await settleWait();
|
||||
releaseSecond();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, deps);
|
||||
const trace = asTraceV3((stopped as RecordStopResult).trace);
|
||||
const switches = trace.steps.filter((step) => step.op === "switch_tab");
|
||||
const stateUrl = (stateId: string) => trace.states.find((state) => state.id === stateId)?.url;
|
||||
expect(switches).toHaveLength(1);
|
||||
expect(stateUrl(switches[0]!.result.state)).toBe("https://example.com/tab-6");
|
||||
});
|
||||
|
||||
it("keeps pending action settles scoped to their tab during an immediate switch", async () => {
|
||||
const chromeApi = installChrome();
|
||||
const manager = fakeManager();
|
||||
const secondUrl = "https://example.com/second";
|
||||
const tabsApi = makeMultiTabsApi([
|
||||
{
|
||||
id: TAB_ID,
|
||||
windowId: AGENT_WINDOW_ID,
|
||||
active: true,
|
||||
status: "complete",
|
||||
url: START_URL,
|
||||
} as chrome.tabs.Tab,
|
||||
{
|
||||
id: 5,
|
||||
windowId: AGENT_WINDOW_ID,
|
||||
active: false,
|
||||
status: "complete",
|
||||
url: secondUrl,
|
||||
} as chrome.tabs.Tab,
|
||||
]);
|
||||
let requestId = "";
|
||||
const sendToTab = vi.fn(async (_tabId: number, message: unknown) => {
|
||||
const typed = message as { type?: string; requestId?: string };
|
||||
if (typed.type === RECORD_START && typed.requestId) requestId = typed.requestId;
|
||||
return { ok: true };
|
||||
});
|
||||
const deps = { tabsApi, sendToTab, cdp: makeFakeCdp() };
|
||||
|
||||
await handleRecordStart(manager, RECORD_START_V3, deps);
|
||||
attachRecordStepListener(deps);
|
||||
runtimeOnMessageEmit(chromeApi, requestId, {
|
||||
op: "click",
|
||||
page_url: START_URL,
|
||||
target: { role: "button", name: "First action", tag: "button" },
|
||||
});
|
||||
tabsApi.activate(5);
|
||||
chromeApi.tabsOnActivated.emit({ tabId: 5, windowId: AGENT_WINDOW_ID });
|
||||
runtimeOnMessageEmit(
|
||||
chromeApi,
|
||||
requestId,
|
||||
{
|
||||
op: "click",
|
||||
page_url: secondUrl,
|
||||
target: { role: "button", name: "Second action", tag: "button" },
|
||||
},
|
||||
5,
|
||||
);
|
||||
await settleWait();
|
||||
|
||||
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, deps);
|
||||
const trace = asTraceV3((stopped as RecordStopResult).trace);
|
||||
const stateUrl = (stateId: string) => trace.states.find((state) => state.id === stateId)?.url;
|
||||
|
||||
expect(trace.steps.map((step) => step.op)).toEqual(["click", "switch_tab", "click"]);
|
||||
expect(stateUrl(trace.steps[0]!.result.state)).toBe(START_URL);
|
||||
expect(stateUrl(trace.steps[1]!.result.state)).toBe(secondUrl);
|
||||
expect(stateUrl(trace.steps[2]!.state)).toBe(secondUrl);
|
||||
});
|
||||
|
||||
it("tracks the same navigation URL independently for each tab", async () => {
|
||||
const chromeApi = installChrome();
|
||||
const manager = fakeManager();
|
||||
const tabsApi = makeMultiTabsApi([
|
||||
{
|
||||
id: TAB_ID,
|
||||
windowId: AGENT_WINDOW_ID,
|
||||
active: true,
|
||||
status: "complete",
|
||||
url: START_URL,
|
||||
} as chrome.tabs.Tab,
|
||||
{
|
||||
id: 5,
|
||||
windowId: AGENT_WINDOW_ID,
|
||||
active: false,
|
||||
status: "complete",
|
||||
url: "https://example.com/second",
|
||||
} as chrome.tabs.Tab,
|
||||
]);
|
||||
const deps = { tabsApi, sendToTab: vi.fn(async () => ({ ok: true })), cdp: makeFakeCdp() };
|
||||
|
||||
await handleRecordStart(manager, RECORD_START_V3, deps);
|
||||
tabsApi.activate(5);
|
||||
chromeApi.tabsOnActivated.emit({ tabId: 5, windowId: AGENT_WINDOW_ID });
|
||||
await settleWait();
|
||||
tabsApi.goTo(5, START_URL, "Same URL in second tab");
|
||||
chromeApi.webNavigationOnCommitted.emit({
|
||||
tabId: 5,
|
||||
frameId: 0,
|
||||
url: START_URL,
|
||||
transitionType: "typed",
|
||||
transitionQualifiers: ["from_address_bar"],
|
||||
} as unknown as chrome.webNavigation.WebNavigationTransitionCallbackDetails);
|
||||
await settleWait();
|
||||
|
||||
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, deps);
|
||||
const trace = asTraceV3((stopped as RecordStopResult).trace);
|
||||
expect(
|
||||
trace.steps.filter((step) => step.op === "navigate" && step.to === START_URL),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drops late input and frame navigation from an inactive tab", async () => {
|
||||
const chromeApi = installChrome();
|
||||
const manager = fakeManager();
|
||||
const secondUrl = "https://example.com/second";
|
||||
const tabsApi = makeMultiTabsApi([
|
||||
{
|
||||
id: TAB_ID,
|
||||
windowId: AGENT_WINDOW_ID,
|
||||
active: true,
|
||||
status: "complete",
|
||||
url: START_URL,
|
||||
} as chrome.tabs.Tab,
|
||||
{
|
||||
id: 5,
|
||||
windowId: AGENT_WINDOW_ID,
|
||||
active: false,
|
||||
status: "complete",
|
||||
url: secondUrl,
|
||||
} as chrome.tabs.Tab,
|
||||
]);
|
||||
let requestId = "";
|
||||
const sendToTab = vi.fn(async (_tabId: number, message: unknown) => {
|
||||
const typed = message as { type?: string; requestId?: string };
|
||||
if (typed.type === RECORD_START && typed.requestId) requestId = typed.requestId;
|
||||
return { ok: true };
|
||||
});
|
||||
const deps = { tabsApi, sendToTab, cdp: makeFakeCdp() };
|
||||
|
||||
await handleRecordStart(manager, RECORD_START_V3, deps);
|
||||
attachRecordStepListener(deps);
|
||||
tabsApi.activate(5);
|
||||
chromeApi.tabsOnActivated.emit({ tabId: 5, windowId: AGENT_WINDOW_ID });
|
||||
await settleWait();
|
||||
const lateAck = runtimeOnMessageEmit(
|
||||
chromeApi,
|
||||
requestId,
|
||||
{
|
||||
op: "fill",
|
||||
page_url: START_URL,
|
||||
target: { role: "textbox", name: "Draft", tag: "input" },
|
||||
value: "late",
|
||||
},
|
||||
TAB_ID,
|
||||
false,
|
||||
);
|
||||
expect(lateAck).toHaveBeenCalledWith({ ok: true, sequence: 1 });
|
||||
chromeApi.webNavigationOnCommitted.emit({
|
||||
tabId: TAB_ID,
|
||||
frameId: 0,
|
||||
url: "https://example.com/late",
|
||||
transitionType: "link",
|
||||
transitionQualifiers: [],
|
||||
} as unknown as chrome.webNavigation.WebNavigationTransitionCallbackDetails);
|
||||
chromeApi.webNavigationOnCommitted.emit({
|
||||
tabId: 5,
|
||||
frameId: 7,
|
||||
url: "https://frame.example/late",
|
||||
transitionType: "link",
|
||||
transitionQualifiers: [],
|
||||
} as unknown as chrome.webNavigation.WebNavigationTransitionCallbackDetails);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, deps);
|
||||
const trace = asTraceV3((stopped as RecordStopResult).trace);
|
||||
expect(trace.steps.map((step) => step.op)).toEqual(["switch_tab"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
type RecordStopMessage,
|
||||
} from "@/lib/record-bridge";
|
||||
import { RecordingObservationRuntime } from "@/lib/recording/recording-runtime";
|
||||
import { appendRecordedPayload, observeRecordedNavigation } from "@/lib/recording/step-buffer";
|
||||
import {
|
||||
appendRecordedPayload,
|
||||
observeRecordedNavigation,
|
||||
type RecordingStepBuffer,
|
||||
} from "@/lib/recording/step-buffer";
|
||||
import { RecordingTabCoordinator, type TabActivation } from "@/lib/recording/tab-coordinator";
|
||||
import { buildTraceV2 } from "@/lib/recording/trace-reducer-v2";
|
||||
import type { RecordingDraftStep } from "@/lib/recording/types";
|
||||
import type { SessionManager } from "@/session-manager/manager";
|
||||
@@ -48,7 +53,7 @@ import {
|
||||
|
||||
interface ActiveRecording {
|
||||
requestId: string;
|
||||
tabId: number;
|
||||
tabs: RecordingTabCoordinator;
|
||||
agentWindowId: number;
|
||||
startUrl?: string;
|
||||
purpose?: string;
|
||||
@@ -56,14 +61,12 @@ interface ActiveRecording {
|
||||
startedAt: string;
|
||||
startedAtMs: number;
|
||||
traceVersion: 2 | 3;
|
||||
supportsTabSwitchSteps: boolean;
|
||||
finishPromise: Promise<RecordedTrace>;
|
||||
resolveFinish: (trace: RecordedTrace) => void;
|
||||
rejectFinish: (err: Error) => void;
|
||||
settled: boolean;
|
||||
finishAttempt: Promise<RecordedTrace | null> | null;
|
||||
currentUrl?: string;
|
||||
pendingNavigation: boolean;
|
||||
pendingNavigationDeadline?: number;
|
||||
observation: RecordingObservationRuntime | null;
|
||||
stoppedBy: StopReason;
|
||||
/** Navigation callbacks tracked from event receipt through action enqueue. */
|
||||
@@ -79,8 +82,13 @@ interface ActiveRecording {
|
||||
lastStepSequenceByProducer: Map<string, number>;
|
||||
}
|
||||
|
||||
function enqueueRecordingAction(recording: ActiveRecording, task: () => Promise<void>): void {
|
||||
recording.actionQueue = recording.actionQueue.then(task, task).catch(() => {});
|
||||
function enqueueRecordingAction(
|
||||
recording: ActiveRecording,
|
||||
task: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const queued = recording.actionQueue.then(task, task);
|
||||
recording.actionQueue = queued.catch(() => {});
|
||||
return queued;
|
||||
}
|
||||
|
||||
const recordings = new Map<string, ActiveRecording>();
|
||||
@@ -198,6 +206,7 @@ function buildTrace(recording: ActiveRecording): RecordedTrace {
|
||||
startUrl: recording.startUrl,
|
||||
stoppedBy: recording.stoppedBy,
|
||||
bskVersion: BSK_TRACE_VERSION,
|
||||
includeTabSwitches: recording.supportsTabSwitchSteps,
|
||||
});
|
||||
}
|
||||
if (recording.traceVersion === 3) {
|
||||
@@ -211,6 +220,38 @@ function buildTrace(recording: ActiveRecording): RecordedTrace {
|
||||
});
|
||||
}
|
||||
|
||||
function stepBufferFor(
|
||||
recording: ActiveRecording,
|
||||
tabId: number,
|
||||
fallbackUrl?: string,
|
||||
): RecordingStepBuffer {
|
||||
return {
|
||||
steps: recording.steps,
|
||||
navigation: recording.tabs.navigation(tabId, fallbackUrl),
|
||||
};
|
||||
}
|
||||
|
||||
async function activateRecordingTab(
|
||||
recording: ActiveRecording,
|
||||
targetTabId: number,
|
||||
): Promise<void> {
|
||||
const previousTabId = recording.tabs.currentTabId;
|
||||
if (targetTabId === previousTabId) return;
|
||||
|
||||
const transition = recording.observation
|
||||
? await recording.observation.captureTabTransition(previousTabId, targetTabId)
|
||||
: {};
|
||||
const { targetUrl, ...stateLinks } = transition;
|
||||
const draft: Extract<RecordingDraftStep, { op: "switch_tab" }> = {
|
||||
op: "switch_tab",
|
||||
...stateLinks,
|
||||
};
|
||||
recording.steps.push(draft);
|
||||
recording.observation?.bindTabTransition(draft, recording.steps.length);
|
||||
recording.tabs.navigation(targetTabId, targetUrl);
|
||||
recording.tabs.commit(targetTabId);
|
||||
}
|
||||
|
||||
async function processRecordedStep(
|
||||
recording: ActiveRecording,
|
||||
draftIndex: number,
|
||||
@@ -307,7 +348,8 @@ export function attachRecordStepListener(deps: RecordDeps = getDefaultDeps()): (
|
||||
if (!isRecordStepMessage(message)) return false;
|
||||
for (const recording of recordings.values()) {
|
||||
if (recording.requestId !== message.requestId) continue;
|
||||
const sourceTabId = sender.tab?.id ?? recording.tabId;
|
||||
const sourceTabId = sender.tab?.id ?? recording.tabs.currentTabId;
|
||||
const sourceWasActive = sender.tab?.active ?? sourceTabId === recording.tabs.activeTabId;
|
||||
const producerKey = `${sourceTabId}:${message.producerId}`;
|
||||
const expectedSequence = (recording.lastStepSequenceByProducer.get(producerKey) ?? 0) + 1;
|
||||
if (message.sequence < expectedSequence) {
|
||||
@@ -325,9 +367,19 @@ export function attachRecordStepListener(deps: RecordDeps = getDefaultDeps()): (
|
||||
|
||||
recording.lastStepSequenceByProducer.set(producerKey, message.sequence);
|
||||
enqueueRecordingAction(recording, async () => {
|
||||
await recording.observation?.flushRedirects();
|
||||
if (sourceTabId !== recording.tabs.currentTabId) {
|
||||
if (!sourceWasActive) return;
|
||||
if (sourceTabId !== recording.tabs.activeTabId)
|
||||
recording.tabs.noteActivation(sourceTabId);
|
||||
await activateRecordingTab(recording, sourceTabId);
|
||||
}
|
||||
await recording.observation?.flushRedirects(sourceTabId);
|
||||
const targetHint = message.step.geometry ? { geometry: message.step.geometry } : undefined;
|
||||
const draftIndex = appendRecordedPayload(recording, message.step, targetHint);
|
||||
const draftIndex = appendRecordedPayload(
|
||||
stepBufferFor(recording, sourceTabId, message.step.page_url),
|
||||
message.step,
|
||||
targetHint,
|
||||
);
|
||||
if (draftIndex !== null) {
|
||||
await processRecordedStep(recording, draftIndex, sourceTabId);
|
||||
}
|
||||
@@ -359,7 +411,7 @@ export function attachRecordFinishListener(deps: RecordDeps = getDefaultDeps()):
|
||||
|
||||
function findRecordingByTabId(tabId: number): ActiveRecording | null {
|
||||
for (const recording of recordings.values()) {
|
||||
if (recording.tabId === tabId && !recording.settled) return recording;
|
||||
if (recording.tabs.currentTabId === tabId && !recording.settled) return recording;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -396,7 +448,7 @@ async function clearRearmTimersForRecording(
|
||||
recording: ActiveRecording,
|
||||
deps: RecordDeps,
|
||||
): Promise<void> {
|
||||
clearRearmTimer(recording.tabId);
|
||||
clearRearmTimer(recording.tabs.currentTabId);
|
||||
try {
|
||||
const tabs = await deps.tabsApi.query({ windowId: recording.agentWindowId });
|
||||
for (const tab of tabs) {
|
||||
@@ -412,12 +464,12 @@ async function stopRecordingOnAllAgentTabs(
|
||||
deps: RecordDeps,
|
||||
): Promise<void> {
|
||||
const stopMsg: RecordStopMessage = { type: RECORD_STOP, requestId: recording.requestId };
|
||||
let tabIds = [recording.tabId];
|
||||
let tabIds = [recording.tabs.currentTabId];
|
||||
try {
|
||||
const tabs = await deps.tabsApi.query({ windowId: recording.agentWindowId });
|
||||
tabIds = [
|
||||
...new Set([
|
||||
recording.tabId,
|
||||
recording.tabs.currentTabId,
|
||||
...tabs.flatMap((tab) => (typeof tab.id === "number" ? [tab.id] : [])),
|
||||
]),
|
||||
];
|
||||
@@ -428,11 +480,11 @@ async function stopRecordingOnAllAgentTabs(
|
||||
for (const tabId of tabIds) {
|
||||
try {
|
||||
const response = await deps.sendToTab(tabId, stopMsg);
|
||||
if (tabId === recording.tabId && !isRecordStartAck(response)) {
|
||||
if (tabId === recording.tabs.currentTabId && !isRecordStartAck(response)) {
|
||||
throw new Error("content script did not confirm recorded steps");
|
||||
}
|
||||
} catch {
|
||||
if (tabId === recording.tabId) {
|
||||
if (tabId === recording.tabs.currentTabId) {
|
||||
throw new Error("failed to flush recorded steps");
|
||||
}
|
||||
}
|
||||
@@ -450,6 +502,7 @@ async function rearmRecording(
|
||||
recording: ActiveRecording,
|
||||
targetTabId: number,
|
||||
deps: RecordDeps,
|
||||
activation?: TabActivation,
|
||||
): Promise<boolean> {
|
||||
// Do NOT toggle automation-bypass here: each retry used to increment the
|
||||
// content-script counter, and a single stop decrement left the ControlOverlay
|
||||
@@ -463,7 +516,10 @@ async function rearmRecording(
|
||||
};
|
||||
try {
|
||||
await sendRecordStartWithAck(targetTabId, startMsg, deps.sendToTab);
|
||||
recording.tabId = targetTabId;
|
||||
if (activation) {
|
||||
if (!recording.tabs.isLatest(activation)) return true;
|
||||
await enqueueRecordingAction(recording, () => activateRecordingTab(recording, targetTabId));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
if (attempt + 1 < RECORD_REARM_MAX_ATTEMPTS) {
|
||||
@@ -474,7 +530,7 @@ async function rearmRecording(
|
||||
return false;
|
||||
}
|
||||
|
||||
function scheduleRearmForTab(tabId: number, deps: RecordDeps): void {
|
||||
function scheduleRearmForTab(tabId: number, deps: RecordDeps, activation?: TabActivation): void {
|
||||
const existing = rearmTimers.get(tabId);
|
||||
if (existing) clearTimeout(existing);
|
||||
rearmTimers.set(
|
||||
@@ -483,7 +539,7 @@ function scheduleRearmForTab(tabId: number, deps: RecordDeps): void {
|
||||
rearmTimers.delete(tabId);
|
||||
void (async () => {
|
||||
const current = await findRecordingForTab(tabId, deps);
|
||||
if (current) await rearmRecording(current, tabId, deps);
|
||||
if (current) await rearmRecording(current, tabId, deps, activation);
|
||||
})();
|
||||
}, RECORD_REARM_DEBOUNCE_MS),
|
||||
);
|
||||
@@ -502,7 +558,12 @@ export function attachRecordTabListener(deps: RecordDeps = getDefaultDeps()): ()
|
||||
};
|
||||
|
||||
const onActivated = (activeInfo: chrome.tabs.TabActiveInfo) => {
|
||||
scheduleRearmForTab(activeInfo.tabId, deps);
|
||||
for (const recording of recordings.values()) {
|
||||
if (recording.settled || recording.agentWindowId !== activeInfo.windowId) continue;
|
||||
const activation = recording.tabs.noteActivation(activeInfo.tabId);
|
||||
scheduleRearmForTab(activeInfo.tabId, deps, activation);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
chrome.tabs.onCreated.addListener(onCreated);
|
||||
@@ -522,54 +583,39 @@ export function attachRecordNavigationListener(deps: RecordDeps = getDefaultDeps
|
||||
transitionQualifiers?: string[],
|
||||
) => {
|
||||
if (!url) return;
|
||||
const direct = findRecordingByTabId(tabId);
|
||||
const candidates = direct
|
||||
? direct.acceptingNavigation
|
||||
? [direct]
|
||||
: []
|
||||
: [...recordings.values()].filter(
|
||||
(recording) => !recording.settled && recording.acceptingNavigation,
|
||||
const candidates = [...recordings.values()].filter(
|
||||
(recording) =>
|
||||
!recording.settled && recording.acceptingNavigation && recording.tabs.activeTabId === tabId,
|
||||
);
|
||||
for (const recording of candidates) {
|
||||
const queued = enqueueRecordingAction(recording, async () => {
|
||||
if (tabId !== recording.tabs.currentTabId) {
|
||||
await activateRecordingTab(recording, tabId);
|
||||
}
|
||||
const result = observeRecordedNavigation(
|
||||
stepBufferFor(recording, tabId, url),
|
||||
url,
|
||||
causedByAction,
|
||||
transitionType,
|
||||
transitionQualifiers,
|
||||
);
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
let resolveTracked!: () => void;
|
||||
const tracked = new Promise<void>((resolve) => {
|
||||
resolveTracked = resolve;
|
||||
});
|
||||
for (const candidate of candidates) candidate.navigationCallbacks.add(tracked);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const recording = await findRecordingForTab(tabId, deps);
|
||||
if (!recording || !recording.acceptingNavigation || !candidates.includes(recording)) {
|
||||
if (result.kind === "coalesce_redirect") {
|
||||
recording.observation?.scheduleRedirect(tabId, recording.steps, result.url);
|
||||
return;
|
||||
}
|
||||
enqueueRecordingAction(recording, async () => {
|
||||
const result = observeRecordedNavigation(
|
||||
recording,
|
||||
url,
|
||||
causedByAction,
|
||||
transitionType,
|
||||
transitionQualifiers,
|
||||
);
|
||||
if (result.kind === "coalesce_redirect") {
|
||||
recording.observation?.scheduleRedirect(tabId, recording.steps, result.url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.kind === "noop") return;
|
||||
recording.observation?.clearRedirect(tabId);
|
||||
if (result.kind === "appended") {
|
||||
await processRecordedStep(recording, result.index, tabId);
|
||||
} else {
|
||||
recording.observation?.scheduleSettle(tabId, recording.steps, result.index);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
for (const candidate of candidates) candidate.navigationCallbacks.delete(tracked);
|
||||
resolveTracked();
|
||||
}
|
||||
})();
|
||||
if (result.kind === "noop") return;
|
||||
recording.observation?.clearRedirect(tabId);
|
||||
if (result.kind === "appended") {
|
||||
await processRecordedStep(recording, result.index, tabId);
|
||||
} else {
|
||||
recording.observation?.scheduleSettle(tabId, recording.steps, result.index);
|
||||
}
|
||||
});
|
||||
const tracked = queued.catch(() => {});
|
||||
recording.navigationCallbacks.add(tracked);
|
||||
void tracked.finally(() => recording.navigationCallbacks.delete(tracked));
|
||||
}
|
||||
};
|
||||
const onMainFrameComplete = (tabId: number, url?: string) => {
|
||||
void (async () => {
|
||||
@@ -651,7 +697,8 @@ export function attachRecordQueryListener(deps: RecordDeps = getDefaultDeps()):
|
||||
sendResponse({ active: false });
|
||||
return;
|
||||
}
|
||||
await rearmRecording(recording, tabId, deps);
|
||||
const activation = sender.tab?.active ? recording.tabs.noteActivation(tabId) : undefined;
|
||||
await rearmRecording(recording, tabId, deps, activation);
|
||||
sendResponse({
|
||||
active: true,
|
||||
requestId: recording.requestId,
|
||||
@@ -716,7 +763,7 @@ async function finishRecordingAttempt(
|
||||
if (!(await drainRecordingToStability(recording))) {
|
||||
return null;
|
||||
}
|
||||
await recording.observation?.settleTrailing(recording.tabId, recording.steps);
|
||||
await recording.observation?.settleTrailing(recording.tabs.currentTabId, recording.steps);
|
||||
|
||||
recording.settled = true;
|
||||
recordings.delete(sessionId);
|
||||
@@ -768,7 +815,7 @@ export async function handleRecordStart(
|
||||
const redactValues = params.redact_values ?? false;
|
||||
recordings.set(params.session_id, {
|
||||
requestId,
|
||||
tabId: target.tabId,
|
||||
tabs: new RecordingTabCoordinator(target.tabId, navigateUrl),
|
||||
agentWindowId: ctx.agentWindowId,
|
||||
startUrl: navigateUrl,
|
||||
...(params.purpose ? { purpose: params.purpose } : {}),
|
||||
@@ -776,14 +823,12 @@ export async function handleRecordStart(
|
||||
startedAt: new Date(startedAtMs).toISOString(),
|
||||
startedAtMs,
|
||||
traceVersion: traceVersionOrErr,
|
||||
supportsTabSwitchSteps: params.supports_tab_switch_steps === true,
|
||||
finishPromise,
|
||||
resolveFinish,
|
||||
rejectFinish,
|
||||
settled: false,
|
||||
finishAttempt: null,
|
||||
currentUrl: navigateUrl,
|
||||
pendingNavigation: false,
|
||||
pendingNavigationDeadline: undefined,
|
||||
observation:
|
||||
traceVersionOrErr === 3 && deps.cdp
|
||||
? new RecordingObservationRuntime({
|
||||
@@ -886,7 +931,7 @@ export async function handleRecordStart(
|
||||
return cancelledError();
|
||||
}
|
||||
active.startUrl = startUrl;
|
||||
active.currentUrl = startUrl;
|
||||
active.tabs.navigation(target.tabId, startUrl).currentUrl = startUrl;
|
||||
|
||||
if (isContentScriptRestrictedUrl(startUrl)) {
|
||||
await abortPending(false);
|
||||
|
||||
Reference in New Issue
Block a user