diff --git a/apps/extension/src/lib/__tests__/tab-coordinator.test.ts b/apps/extension/src/lib/__tests__/tab-coordinator.test.ts index 037c850..42499d3 100644 --- a/apps/extension/src/lib/__tests__/tab-coordinator.test.ts +++ b/apps/extension/src/lib/__tests__/tab-coordinator.test.ts @@ -23,7 +23,7 @@ describe("RecordingTabCoordinator", () => { expect(tabs.isLatest(second)).toBe(false); expect(tabs.isLatest(third)).toBe(true); - expect(tabs.commit(6)).toBe(4); + tabs.commit(6); expect(tabs.currentTabId).toBe(6); }); }); diff --git a/apps/extension/src/lib/__tests__/trace-reducer-v3.test.ts b/apps/extension/src/lib/__tests__/trace-reducer-v3.test.ts index 5437085..1018aec 100644 --- a/apps/extension/src/lib/__tests__/trace-reducer-v3.test.ts +++ b/apps/extension/src/lib/__tests__/trace-reducer-v3.test.ts @@ -48,6 +48,15 @@ describe("trace reducer v3", () => { ]); }); + it("does not export a tab transition without both observation endpoints", () => { + const sourceOnly: RecordingDraftStep = { op: "switch_tab", preStateId: "s1" }; + const targetOnly: RecordingDraftStep = { op: "switch_tab", postStateId: "s2" }; + + expect( + reduceTraceStepsV3([sourceOnly, targetOnly], { includeTabSwitches: true }).steps, + ).toEqual([]); + }); + it("collapses redirect hops while retaining draft-to-step identity", () => { const drafts: RecordingDraftStep[] = [ { op: "navigate", url: "https://example.com/start", preStateId: "s1", postStateId: "s2" }, diff --git a/apps/extension/src/lib/recording/recording-runtime.ts b/apps/extension/src/lib/recording/recording-runtime.ts index 42a0261..e27577f 100644 --- a/apps/extension/src/lib/recording/recording-runtime.ts +++ b/apps/extension/src/lib/recording/recording-runtime.ts @@ -165,12 +165,8 @@ export class RecordingObservationRuntime { await context.settle.flush(); } - async flushRedirects(tabId?: number): Promise { - if (tabId !== undefined) { - await this.#contexts.get(tabId)?.settle.flushRedirects(); - return; - } - for (const context of this.#contexts.values()) await context.settle.flushRedirects(); + async flushRedirects(tabId: number): Promise { + await this.#contexts.get(tabId)?.settle.flushRedirects(); } async flush(): Promise { diff --git a/apps/extension/src/lib/recording/tab-coordinator.ts b/apps/extension/src/lib/recording/tab-coordinator.ts index 0dbe185..2782b6f 100644 --- a/apps/extension/src/lib/recording/tab-coordinator.ts +++ b/apps/extension/src/lib/recording/tab-coordinator.ts @@ -40,10 +40,8 @@ export class RecordingTabCoordinator { ); } - commit(tabId: number): number { - const previousTabId = this.#currentTabId; + commit(tabId: number): void { this.#currentTabId = tabId; - return previousTabId; } navigation(tabId: number, fallbackUrl?: string): RecordingNavigationCursor { diff --git a/apps/extension/src/lib/recording/trace-reducer-v3.ts b/apps/extension/src/lib/recording/trace-reducer-v3.ts index a28ad80..aded63b 100644 --- a/apps/extension/src/lib/recording/trace-reducer-v3.ts +++ b/apps/extension/src/lib/recording/trace-reducer-v3.ts @@ -69,6 +69,15 @@ function reduceDraft( options: ReduceDraftOptions, ): StepV3 | null { if (!shouldIncludeDraft(draft)) return null; + if (draft.op === "switch_tab") { + if (!options.includeTabSwitches || !draft.preStateId || !draft.postStateId) return null; + return { + op: "switch_tab", + id, + state: draft.preStateId, + result: { state: draft.postStateId }, + }; + } const state = draft.preStateId ?? draft.postStateId; const resultState = draft.postStateId ?? draft.preStateId; if (!state || !resultState) return null; @@ -77,8 +86,6 @@ function reduceDraft( switch (draft.op) { case "navigate": return { op: "navigate", ...common, to: draft.url, cause: navigationCause(draft) }; - case "switch_tab": - return options.includeTabSwitches ? { op: "switch_tab", ...common } : null; case "click": return { op: "click", diff --git a/apps/extension/src/tools/__tests__/record-steps.test.ts b/apps/extension/src/tools/__tests__/record-steps.test.ts index 4b6a02d..83d108b 100644 --- a/apps/extension/src/tools/__tests__/record-steps.test.ts +++ b/apps/extension/src/tools/__tests__/record-steps.test.ts @@ -1376,6 +1376,62 @@ describe("recorded user steps reach the exported trace", () => { expect(stateUrl(switches[0]!.result.state)).toBe("https://example.com/tab-6"); }); + it("does not retry an in-flight tab rearm after recording stops", 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, + ]); + let failRearm!: () => void; + const rearmGate = new Promise((_resolve, reject) => { + failRearm = () => reject(new Error("rearm failed after stop")); + }); + let announceRearm!: () => void; + const rearmStarted = new Promise((resolve) => { + announceRearm = resolve; + }); + const sendToTab = vi.fn(async (tabId: number, message: unknown) => { + if ((message as { type?: string }).type === RECORD_START && tabId === 5) { + announceRearm(); + await rearmGate; + } + 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 rearmStarted; + + const stoppedPromise = handleRecordStop(manager, { session_id: "abcd" }, deps); + await vi.waitFor(() => { + expect(sendToTab).toHaveBeenCalledWith(5, expect.objectContaining({ type: RECORD_STOP })); + }); + failRearm(); + const stopped = await stoppedPromise; + await new Promise((resolve) => setTimeout(resolve, 600)); + + const startsForSecondTab = sendToTab.mock.calls.filter( + ([tabId, message]) => tabId === 5 && (message as { type?: string }).type === RECORD_START, + ); + expect(startsForSecondTab).toHaveLength(1); + expect(asTraceV3((stopped as RecordStopResult).trace).steps).toHaveLength(0); + }); + it("keeps pending action settles scoped to their tab during an immediate switch", async () => { const chromeApi = installChrome(); const manager = fakeManager(); diff --git a/apps/extension/src/tools/record.ts b/apps/extension/src/tools/record.ts index 8ebceca..80dde14 100644 --- a/apps/extension/src/tools/record.ts +++ b/apps/extension/src/tools/record.ts @@ -91,6 +91,10 @@ function enqueueRecordingAction( return queued; } +function isRecordingFinishing(recording: ActiveRecording): boolean { + return recording.settled || recording.finishAttempt !== null; +} + const recordings = new Map(); const RECORD_START_RETRIES = 3; @@ -171,9 +175,11 @@ async function sendRecordStartWithAck( tabId: number, msg: RecordStartMessage, sendToTab: RecordDeps["sendToTab"], + cancelled?: () => boolean, ): Promise { let lastError: unknown; for (let attempt = 0; attempt < RECORD_START_RETRIES; attempt += 1) { + if (cancelled?.()) throw new Error("record start cancelled"); try { const response = await sendToTab(tabId, msg); if (isRecordStartAck(response)) return; @@ -181,6 +187,7 @@ async function sendRecordStartWithAck( } catch (err) { lastError = err; } + if (cancelled?.()) throw new Error("record start cancelled"); if (attempt + 1 < RECORD_START_RETRIES) { await sleep(RECORD_START_RETRY_DELAY_MS); } @@ -508,20 +515,29 @@ async function rearmRecording( // content-script counter, and a single stop decrement left the ControlOverlay // stuck with pointer-events:none (page usable, Interrupt dead). RecordOverlay // already hides the control chrome while activeRecord is set. + const isFinishing = () => isRecordingFinishing(recording); for (let attempt = 0; attempt < RECORD_REARM_MAX_ATTEMPTS; attempt += 1) { + if (isFinishing()) return false; const startMsg: RecordStartMessage = { type: RECORD_START, requestId: recording.requestId, startedAtMs: recording.startedAtMs, }; try { - await sendRecordStartWithAck(targetTabId, startMsg, deps.sendToTab); + await sendRecordStartWithAck(targetTabId, startMsg, deps.sendToTab, isFinishing); + if (isFinishing()) return false; if (activation) { if (!recording.tabs.isLatest(activation)) return true; - await enqueueRecordingAction(recording, () => activateRecordingTab(recording, targetTabId)); + await enqueueRecordingAction(recording, async () => { + if (isFinishing() || !recording.tabs.isLatest(activation)) { + return; + } + await activateRecordingTab(recording, targetTabId); + }); } return true; } catch { + if (isFinishing()) return false; if (attempt + 1 < RECORD_REARM_MAX_ATTEMPTS) { await sleep(RECORD_REARM_RETRY_DELAY_MS); } @@ -551,7 +567,7 @@ export function attachRecordTabListener(deps: RecordDeps = getDefaultDeps()): () const windowId = tab.windowId; if (tabId === undefined || windowId === undefined) return; for (const recording of recordings.values()) { - if (recording.settled || recording.agentWindowId !== windowId) continue; + if (isRecordingFinishing(recording) || recording.agentWindowId !== windowId) continue; scheduleRearmForTab(tabId, deps); return; } @@ -559,7 +575,8 @@ export function attachRecordTabListener(deps: RecordDeps = getDefaultDeps()): () const onActivated = (activeInfo: chrome.tabs.TabActiveInfo) => { for (const recording of recordings.values()) { - if (recording.settled || recording.agentWindowId !== activeInfo.windowId) continue; + if (isRecordingFinishing(recording) || recording.agentWindowId !== activeInfo.windowId) + continue; const activation = recording.tabs.noteActivation(activeInfo.tabId); scheduleRearmForTab(activeInfo.tabId, deps, activation); return;