From 349d8da86079a35a9a5dee42808ec90b97126c2b Mon Sep 17 00:00:00 2001 From: Anderson Martinez Date: Mon, 6 Jul 2026 14:09:46 -0500 Subject: [PATCH 1/8] feat: implement snapshot diffing --- src/@types/types.d.ts | 7 ++ src/lib/agent-format.ts | 190 +++++++++++++++++++++++++++++ src/skills/system-prompt.ts | 1 + src/tools/agent.ts | 37 +++++- src/tools/schemas.ts | 9 ++ test/lib/agent-format.diff.spec.ts | 107 ++++++++++++++++ 6 files changed, 347 insertions(+), 4 deletions(-) create mode 100644 test/lib/agent-format.diff.spec.ts diff --git a/src/@types/types.d.ts b/src/@types/types.d.ts index 663d74d..f53c180 100644 --- a/src/@types/types.d.ts +++ b/src/@types/types.d.ts @@ -188,6 +188,13 @@ export interface ActiveSession { skillState: SkillFireState; lastUsedAt: number; lastUrl?: string; + // Previous snapshot's elements keyed by selector, for snapshot diffing — + // subsequent snapshots return only what changed. Re-indexed each snapshot; + // the diff is skipped after a cross-origin nav since prior refs are invalid. + lastElements?: Map; + // Previous snapshot's elements in DOM order, for the positional diff — order + // survives an in-place SPA re-render even when ids/selectors churn. + lastSnapshotElements?: SnapshotElement[]; } /* ------------------------------------------------------------------ */ diff --git a/src/lib/agent-format.ts b/src/lib/agent-format.ts index 2e7229d..2a6501e 100644 --- a/src/lib/agent-format.ts +++ b/src/lib/agent-format.ts @@ -204,3 +204,193 @@ export const formatSnapshot = (snapshot: SnapshotResult): string => { lines.push('--- END SNAPSHOT ---'); return lines.join('\n'); }; + +// Fields that define whether an element "changed" between snapshots. `ref` is +// excluded (it's positional/cosmetic — the agent acts by selector); `selector` +// is excluded because it's the identity key itself. +const elementSignature = (el: SnapshotElement): string => + JSON.stringify([ + el.role, + el.name, + el.text, + el.value, + el.type, + el.placeholder, + el.href, + el.disabled, + el.checked, + el.focused, + el.required, + el.ariaLabel, + el.tag, + el.frameId, + ]); + +// Framework-generated ids churn on every render (Radix `radix-«r…»`/`:r0:`, +// React useId `«r…»`/`:r…:`, Headless UI, MUI). A selector/id built from one is +// NOT a stable cross-render identity, so we exclude it from the diff key. +const FRAMEWORK_ID = /(radix-|headlessui-|mui-[a-z]|«r|:r[0-9a-z]|_r_)/i; + +// Cross-snapshot identity for an element. Numeric [ref] is positional (churns), +// so we never key on it. Prefer a clean id, then a clean CSS selector, then a +// semantic composite — this keeps precision when the selector is stable but +// degrades gracefully on SPAs where the selector embeds a framework id. +export const elementKey = (el: SnapshotElement): string => { + if (el.id && !FRAMEWORK_ID.test(el.id)) return `#${el.id}`; + if (el.selector && !FRAMEWORK_ID.test(el.selector)) return `sel:${el.selector}`; + // Prefer aria-label over name: name often carries the volatile value (a stat + // tile's number), so keying on it churns the element on every value change; + // aria-label is the stable descriptor. Value change then shows as `changed`. + const label = el.ariaLabel || el.name; + return `sem:${el.tag}|${el.role}|${label}|${el.href ?? ''}`; +}; + +// Index a snapshot's elements by their stable identity key. +// ponytail: last-wins on key collisions (e.g. two unlabeled buttons that fall +// back to the same semantic key). Rare; tighten with a positional tiebreak only +// if it shows up in practice. +export const indexByIdentity = ( + snapshot: SnapshotResult, +): Map => + new Map(snapshot.elements.map((el) => [elementKey(el), el])); + +// Render a snapshot as a delta against the previous snapshot's elements: only +// new/changed elements print in full, removed ones as a selector list, and +// unchanged ones collapse to a count. Callers fall back to formatSnapshot() for +// the first snapshot in a session or after a cross-origin reset (prior refs +// invalid). Assumes the previous full/diff snapshot is still in the model's +// transcript so omitted elements remain referenceable. +export const formatSnapshotDiff = ( + snapshot: SnapshotResult, + prev: Map, +): string => { + const frameLabels = new Map(); + snapshot.frames?.forEach((frame, i) => + frameLabels.set(frame.frameId, `frame#${i + 1}`), + ); + + const seen = new Set(); + const added: SnapshotElement[] = []; + const changed: SnapshotElement[] = []; + for (const el of snapshot.elements) { + const key = elementKey(el); + seen.add(key); + const before = prev.get(key); + if (!before) added.push(el); + else if (elementSignature(before) !== elementSignature(el)) + changed.push(el); + } + // Print removed elements by their selector (the actionable handle), not the + // internal identity key. + const removed = [...prev.entries()] + .filter(([key]) => !seen.has(key)) + .map(([, el]) => el.selector); + const unchanged = snapshot.elements.length - added.length - changed.length; + + const lines: string[] = [ + '--- PAGE SNAPSHOT (diff vs previous; unchanged elements omitted) ---', + `${snapshot.url} | ${snapshot.title}`, + `Changes: ${added.length} new, ${changed.length} changed, ${removed.length} removed, ${unchanged} unchanged (${snapshot.elements.length} total)`, + ]; + + lines.push( + ...(snapshot.detectedChallenges ?? []).map( + (type) => `! Detected challenge: ${type}`, + ), + ); + + if (!added.length && !changed.length && !removed.length) { + lines.push('', 'No changes since last snapshot.', '--- END SNAPSHOT ---'); + return lines.join('\n'); + } + + lines.push(''); + for (const el of added) lines.push(`+ ${formatElement(el, frameLabels)}`); + for (const el of changed) lines.push(`~ ${formatElement(el, frameLabels)}`); + for (const sel of removed) lines.push(`- ref=${sel} (removed)`); + if (unchanged > 0) { + lines.push( + `… ${unchanged} unchanged elements omitted (still valid from the previous snapshot).`, + ); + } + + lines.push('--- END SNAPSHOT ---'); + return lines.join('\n'); +}; + +// Fields whose old→new transition is worth surfacing on a changed element. +const CHANGE_FIELDS: Array = [ + 'name', + 'value', + 'text', + 'checked', + 'disabled', + 'focused', +]; + +const formatChange = ( + before: SnapshotElement, + after: SnapshotElement, + frameLabels?: Map, +): string => { + const deltas = CHANGE_FIELDS.filter((f) => before[f] !== after[f]).map( + (f) => `${f}: ${JSON.stringify(before[f] ?? '')}→${JSON.stringify(after[f] ?? '')}`, + ); + const suffix = deltas.length ? ` (${deltas.join(', ')})` : ''; + return `~ ${formatElement(after, frameLabels)}${suffix}`; +}; + +// Diff two snapshots of equal length by PAIRING ELEMENTS BY POSITION. DOM order +// survives an in-place SPA re-render even when ids/selectors churn, so when the +// element count is unchanged this pairs each slot old↔new and surfaces only the +// value/state that moved (e.g. a stat card 0→1,158) — the case identity-keying +// can't catch. Only meaningful when prev.length === snapshot.elements.length; +// the caller gates on that and takes the shortest of {full, identity, positional} +// so a bad positional guess (a reorder) is simply discarded, never emitted. +export const formatSnapshotDiffPositional = ( + prev: SnapshotElement[], + snapshot: SnapshotResult, +): string => { + const frameLabels = new Map(); + snapshot.frames?.forEach((frame, i) => + frameLabels.set(frame.frameId, `frame#${i + 1}`), + ); + + const changed: Array<{ before: SnapshotElement; after: SnapshotElement }> = []; + snapshot.elements.forEach((after, i) => { + const before = prev[i]; + if (before && elementSignature(before) !== elementSignature(after)) + changed.push({ before, after }); + }); + const unchanged = snapshot.elements.length - changed.length; + + const lines: string[] = [ + '--- PAGE SNAPSHOT (diff vs previous; unchanged elements omitted) ---', + `${snapshot.url} | ${snapshot.title}`, + `Changes: ${changed.length} changed, ${unchanged} unchanged (${snapshot.elements.length} total)`, + ]; + + lines.push( + ...(snapshot.detectedChallenges ?? []).map( + (type) => `! Detected challenge: ${type}`, + ), + ); + + if (!changed.length) { + lines.push('', 'No changes since last snapshot.', '--- END SNAPSHOT ---'); + return lines.join('\n'); + } + + lines.push(''); + for (const { before, after } of changed) { + lines.push(formatChange(before, after, frameLabels)); + } + if (unchanged > 0) { + lines.push( + `… ${unchanged} unchanged elements omitted (still valid from the previous snapshot).`, + ); + } + + lines.push('--- END SNAPSHOT ---'); + return lines.join('\n'); +}; diff --git a/src/skills/system-prompt.ts b/src/skills/system-prompt.ts index 8e69007..35f5f46 100644 --- a/src/skills/system-prompt.ts +++ b/src/skills/system-prompt.ts @@ -46,6 +46,7 @@ Load manually via **browserless_skill** if suspected but not injected: - Snapshot VALID after: type, hover, scroll, evaluate - Expect new content? → re-snapshot - Element roles in snapshot (link, button, textbox, combobox, checkbox, heading) tell you what each does +- Snapshots after the first return a **diff** vs. your previous snapshot: only \`+\` new / \`~\` changed / \`-\` removed elements, plus a count of unchanged ones omitted. Unchanged elements stay valid — keep using their refs from the earlier snapshot. If that earlier snapshot is no longer in your context (summarized/trimmed away), request \`snapshot { full: true }\` to get the complete element list again. ## Selectors - Use **ref=** (CSS) or **deep-ref=** (starts \`< \`) exactly as shown in snapshot diff --git a/src/tools/agent.ts b/src/tools/agent.ts index aad0c5c..2951be0 100644 --- a/src/tools/agent.ts +++ b/src/tools/agent.ts @@ -44,6 +44,9 @@ import { formatConnectError, formatErrorMessage, formatSnapshot, + formatSnapshotDiff, + formatSnapshotDiffPositional, + indexByIdentity, } from '../lib/agent-format.js'; // export schemas, system prompt, and formatters @@ -703,14 +706,40 @@ export function registerAgentTools( ); const noticeBlock = notice ? `${notice}\n\n` : ''; if (lastSnapshot.url) agentSession.lastUrl = lastSnapshot.url; + // Diff against the prior snapshot to save tokens. Send full instead + // when: first snapshot this session; cross-origin nav (notice set — + // prior refs invalid); or the prior snapshot was captured mid-hydration + // (SPA baseline had far fewer elements, so a diff would read as + // all-new). And even when we diff, never emit more than a full snapshot + // — on SPA re-mounts the diff can churn larger, so take the shorter. + const prevElements = agentSession.lastElements; + const prevArr = agentSession.lastSnapshotElements; + const hydrating = + !!prevElements && + lastSnapshot.elements.length > 0 && + prevElements.size < lastSnapshot.elements.length * 0.25; + const forceFull = + (lastCmd?.params as { full?: boolean } | undefined)?.full === true; + const full = formatSnapshot(lastSnapshot); + let snapshotText = full; + if (prevElements && !notice && !hydrating && !forceFull) { + // Take the shortest of full / identity-diff / positional-diff. The + // positional candidate (only when element count is unchanged) catches + // in-place SPA value updates that identity-keying churns on; a bad + // positional guess just loses to a shorter candidate and is dropped. + const candidates = [full, formatSnapshotDiff(lastSnapshot, prevElements)]; + if (prevArr && prevArr.length === lastSnapshot.elements.length) { + candidates.push(formatSnapshotDiffPositional(prevArr, lastSnapshot)); + } + snapshotText = candidates.reduce((a, b) => (b.length < a.length ? b : a)); + } + agentSession.lastElements = indexByIdentity(lastSnapshot); + agentSession.lastSnapshotElements = lastSnapshot.elements; baseContent = [ { type: 'text' as const, text: appendSkills( - batchPrefix + - noticeBlock + - formatSnapshot(lastSnapshot) + - closedSuffix, + batchPrefix + noticeBlock + snapshotText + closedSuffix, triggered, ), }, diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts index 75bfc3c..bb7de62 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -60,6 +60,15 @@ const SnapshotCommandSchema = z.object({ 'Optional tab targetId to peek at without switching the active tab. ' + 'Obtain via getTabs or a prior snapshot response. Omit to snapshot the active tab.', ), + full: z + .boolean() + .optional() + .describe( + 'Force a complete snapshot instead of a diff. Snapshots normally return ' + + 'only what changed since your previous one; set full:true when you no ' + + 'longer have that previous snapshot in context (e.g. it was summarized ' + + 'away) and need the entire element list again.', + ), }) .optional() .default({}), diff --git a/test/lib/agent-format.diff.spec.ts b/test/lib/agent-format.diff.spec.ts new file mode 100644 index 0000000..645aa16 --- /dev/null +++ b/test/lib/agent-format.diff.spec.ts @@ -0,0 +1,107 @@ +import { expect } from 'chai'; + +import { + formatSnapshotDiff, + formatSnapshotDiffPositional, + indexByIdentity, +} from '../../src/lib/agent-format.js'; +import type { SnapshotElement, SnapshotResult } from '../../src/@types/types.js'; + +const el = (over: Partial): SnapshotElement => ({ + ref: 0, + role: 'button', + name: '', + selector: '', + tag: 'button', + ...over, +}); + +const snap = (elements: SnapshotElement[]): SnapshotResult => ({ + url: 'https://example.com', + title: 'Example', + elements, + time: 1, +}); + +describe('formatSnapshotDiff', () => { + it('omits unchanged elements and reports counts', () => { + const prev = indexByIdentity( + snap([ + el({ ref: 1, name: 'Home', selector: 'a#home' }), + el({ ref: 2, name: 'Login', selector: 'a#login' }), + ]), + ); + // refs renumber, one unchanged, one new; identity is the selector. + const out = formatSnapshotDiff( + snap([ + el({ ref: 5, name: 'Home', selector: 'a#home' }), + el({ ref: 6, name: 'Sign up', selector: 'a#signup' }), + ]), + prev, + ); + expect(out).to.match(/1 new, 0 changed, 1 removed, 1 unchanged/); + expect(out).to.include('+ [6] button button "Sign up" ref=a#signup'); + expect(out).to.include('- ref=a#login (removed)'); + expect(out).to.include('1 unchanged elements omitted'); + // Unchanged element must NOT be re-listed — that's the whole point. + expect(out).to.not.include('"Home"'); + }); + + it('flags an element whose state changed, ignoring ref renumbering', () => { + const prev = indexByIdentity( + snap([el({ ref: 1, selector: '#agree', name: 'Agree', checked: false })]), + ); + const out = formatSnapshotDiff( + snap([el({ ref: 9, selector: '#agree', name: 'Agree', checked: true })]), + prev, + ); + expect(out).to.match(/0 new, 1 changed, 0 removed, 0 unchanged/); + expect(out).to.include('~ [9]'); + expect(out).to.include('(checked)'); + }); + + it('reports no changes when nothing moved', () => { + const prev = indexByIdentity(snap([el({ ref: 1, selector: '#x', name: 'X' })])); + const out = formatSnapshotDiff( + snap([el({ ref: 2, selector: '#x', name: 'X' })]), + prev, + ); + expect(out).to.include('No changes since last snapshot.'); + }); + + it('positional diff surfaces value changes when ids churn but order holds', () => { + // In-place SPA re-render: stat tiles keep DOM order, ids churn, numbers move. + const prev = [ + el({ ref: 1, role: 'text', name: '0', selector: 'div#radix-«r1»' }), + el({ ref: 2, role: 'text', name: '5', selector: 'div#radix-«r2»' }), + ]; + const out = formatSnapshotDiffPositional( + prev, + snap([ + el({ ref: 8, role: 'text', name: '1,158', selector: 'div#radix-«r7»' }), + el({ ref: 9, role: 'text', name: '5', selector: 'div#radix-«r8»' }), + ]), + ); + expect(out).to.match(/1 changed, 1 unchanged/); + expect(out).to.include('name: "0"→"1,158"'); + // Unchanged slot (value 5) must not be listed. + expect(out).to.not.include('"5"'); + }); + + it('treats a churned framework-id selector as the same element via semantic key', () => { + // Radix/useId selectors regenerate per render on SPAs — semantic identity + // (role+name+tag) must still match so it is not counted as new+removed. + const prev = indexByIdentity( + snap([ + el({ ref: 1, role: 'menuitem', name: 'Profile', selector: 'div#radix-«r1»' }), + ]), + ); + const out = formatSnapshotDiff( + snap([ + el({ ref: 7, role: 'menuitem', name: 'Profile', selector: 'div#radix-«r9»' }), + ]), + prev, + ); + expect(out).to.include('No changes since last snapshot.'); + }); +}); From 025a8abc7a3664780868ddce40d5acce0aa8bd64 Mon Sep 17 00:00:00 2001 From: Anderson Martinez Date: Mon, 6 Jul 2026 14:09:46 -0500 Subject: [PATCH 2/8] chore: prettier --- src/lib/agent-format.ts | 9 ++++++--- src/tools/agent.ts | 13 ++++++++++--- test/lib/agent-format.diff.spec.ts | 23 +++++++++++++++++++---- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/lib/agent-format.ts b/src/lib/agent-format.ts index 2a6501e..bed0e08 100644 --- a/src/lib/agent-format.ts +++ b/src/lib/agent-format.ts @@ -237,7 +237,8 @@ const FRAMEWORK_ID = /(radix-|headlessui-|mui-[a-z]|«r|:r[0-9a-z]|_r_)/i; // degrades gracefully on SPAs where the selector embeds a framework id. export const elementKey = (el: SnapshotElement): string => { if (el.id && !FRAMEWORK_ID.test(el.id)) return `#${el.id}`; - if (el.selector && !FRAMEWORK_ID.test(el.selector)) return `sel:${el.selector}`; + if (el.selector && !FRAMEWORK_ID.test(el.selector)) + return `sel:${el.selector}`; // Prefer aria-label over name: name often carries the volatile value (a stat // tile's number), so keying on it churns the element on every value change; // aria-label is the stable descriptor. Value change then shows as `changed`. @@ -334,7 +335,8 @@ const formatChange = ( frameLabels?: Map, ): string => { const deltas = CHANGE_FIELDS.filter((f) => before[f] !== after[f]).map( - (f) => `${f}: ${JSON.stringify(before[f] ?? '')}→${JSON.stringify(after[f] ?? '')}`, + (f) => + `${f}: ${JSON.stringify(before[f] ?? '')}→${JSON.stringify(after[f] ?? '')}`, ); const suffix = deltas.length ? ` (${deltas.join(', ')})` : ''; return `~ ${formatElement(after, frameLabels)}${suffix}`; @@ -356,7 +358,8 @@ export const formatSnapshotDiffPositional = ( frameLabels.set(frame.frameId, `frame#${i + 1}`), ); - const changed: Array<{ before: SnapshotElement; after: SnapshotElement }> = []; + const changed: Array<{ before: SnapshotElement; after: SnapshotElement }> = + []; snapshot.elements.forEach((after, i) => { const before = prev[i]; if (before && elementSignature(before) !== elementSignature(after)) diff --git a/src/tools/agent.ts b/src/tools/agent.ts index 2951be0..0f1aa3d 100644 --- a/src/tools/agent.ts +++ b/src/tools/agent.ts @@ -727,11 +727,18 @@ export function registerAgentTools( // positional candidate (only when element count is unchanged) catches // in-place SPA value updates that identity-keying churns on; a bad // positional guess just loses to a shorter candidate and is dropped. - const candidates = [full, formatSnapshotDiff(lastSnapshot, prevElements)]; + const candidates = [ + full, + formatSnapshotDiff(lastSnapshot, prevElements), + ]; if (prevArr && prevArr.length === lastSnapshot.elements.length) { - candidates.push(formatSnapshotDiffPositional(prevArr, lastSnapshot)); + candidates.push( + formatSnapshotDiffPositional(prevArr, lastSnapshot), + ); } - snapshotText = candidates.reduce((a, b) => (b.length < a.length ? b : a)); + snapshotText = candidates.reduce((a, b) => + b.length < a.length ? b : a, + ); } agentSession.lastElements = indexByIdentity(lastSnapshot); agentSession.lastSnapshotElements = lastSnapshot.elements; diff --git a/test/lib/agent-format.diff.spec.ts b/test/lib/agent-format.diff.spec.ts index 645aa16..5038e3b 100644 --- a/test/lib/agent-format.diff.spec.ts +++ b/test/lib/agent-format.diff.spec.ts @@ -5,7 +5,10 @@ import { formatSnapshotDiffPositional, indexByIdentity, } from '../../src/lib/agent-format.js'; -import type { SnapshotElement, SnapshotResult } from '../../src/@types/types.js'; +import type { + SnapshotElement, + SnapshotResult, +} from '../../src/@types/types.js'; const el = (over: Partial): SnapshotElement => ({ ref: 0, @@ -61,7 +64,9 @@ describe('formatSnapshotDiff', () => { }); it('reports no changes when nothing moved', () => { - const prev = indexByIdentity(snap([el({ ref: 1, selector: '#x', name: 'X' })])); + const prev = indexByIdentity( + snap([el({ ref: 1, selector: '#x', name: 'X' })]), + ); const out = formatSnapshotDiff( snap([el({ ref: 2, selector: '#x', name: 'X' })]), prev, @@ -93,12 +98,22 @@ describe('formatSnapshotDiff', () => { // (role+name+tag) must still match so it is not counted as new+removed. const prev = indexByIdentity( snap([ - el({ ref: 1, role: 'menuitem', name: 'Profile', selector: 'div#radix-«r1»' }), + el({ + ref: 1, + role: 'menuitem', + name: 'Profile', + selector: 'div#radix-«r1»', + }), ]), ); const out = formatSnapshotDiff( snap([ - el({ ref: 7, role: 'menuitem', name: 'Profile', selector: 'div#radix-«r9»' }), + el({ + ref: 7, + role: 'menuitem', + name: 'Profile', + selector: 'div#radix-«r9»', + }), ]), prev, ); From 3032a96c97e37f2a6cf86ca258b51912e6751d23 Mon Sep 17 00:00:00 2001 From: Anderson Martinez Date: Mon, 6 Jul 2026 14:15:35 -0500 Subject: [PATCH 3/8] refactor: streamline snapshot diffing logic and improve readability --- src/lib/agent-format.ts | 169 +++++++++++++++------------------------- 1 file changed, 61 insertions(+), 108 deletions(-) diff --git a/src/lib/agent-format.ts b/src/lib/agent-format.ts index bed0e08..3633612 100644 --- a/src/lib/agent-format.ts +++ b/src/lib/agent-format.ts @@ -178,11 +178,8 @@ export const formatSnapshot = (snapshot: SnapshotResult): string => { // Label cross-origin iframes (frame#1, …) and list them so the agent knows // which elements live in a frame and that their deep-ref selectors pierce it. - const frameLabels = new Map(); + const frameLabels = frameLabelsOf(snapshot); if (snapshot.frames?.length) { - snapshot.frames.forEach((frame, i) => - frameLabels.set(frame.frameId, `frame#${i + 1}`), - ); lines.push(`Frames (${snapshot.frames.length} iframes):`); for (const frame of snapshot.frames) { const origin = frame.crossOrigin ? 'cross-origin' : 'same-origin'; @@ -205,9 +202,8 @@ export const formatSnapshot = (snapshot: SnapshotResult): string => { return lines.join('\n'); }; -// Fields that define whether an element "changed" between snapshots. `ref` is -// excluded (it's positional/cosmetic — the agent acts by selector); `selector` -// is excluded because it's the identity key itself. +// Fields whose change marks an element "changed". Excludes ref (positional) and +// selector (the identity key itself). const elementSignature = (el: SnapshotElement): string => JSON.stringify([ el.role, @@ -226,50 +222,68 @@ const elementSignature = (el: SnapshotElement): string => el.frameId, ]); -// Framework-generated ids churn on every render (Radix `radix-«r…»`/`:r0:`, -// React useId `«r…»`/`:r…:`, Headless UI, MUI). A selector/id built from one is -// NOT a stable cross-render identity, so we exclude it from the diff key. +// Framework-generated ids (Radix/useId/Headless/MUI) churn every render, so a +// selector or id built from one is not a stable diff key — excluded below. const FRAMEWORK_ID = /(radix-|headlessui-|mui-[a-z]|«r|:r[0-9a-z]|_r_)/i; -// Cross-snapshot identity for an element. Numeric [ref] is positional (churns), -// so we never key on it. Prefer a clean id, then a clean CSS selector, then a -// semantic composite — this keeps precision when the selector is stable but -// degrades gracefully on SPAs where the selector embeds a framework id. +// Stable cross-snapshot identity: clean id → clean selector → semantic composite +// (never the positional ref). Degrades gracefully when selectors churn on SPAs. export const elementKey = (el: SnapshotElement): string => { if (el.id && !FRAMEWORK_ID.test(el.id)) return `#${el.id}`; if (el.selector && !FRAMEWORK_ID.test(el.selector)) return `sel:${el.selector}`; - // Prefer aria-label over name: name often carries the volatile value (a stat - // tile's number), so keying on it churns the element on every value change; - // aria-label is the stable descriptor. Value change then shows as `changed`. + // name often holds the volatile value (a stat number) and would churn the key; + // aria-label is the stable descriptor, so the value shows as `changed` instead. const label = el.ariaLabel || el.name; return `sem:${el.tag}|${el.role}|${label}|${el.href ?? ''}`; }; -// Index a snapshot's elements by their stable identity key. -// ponytail: last-wins on key collisions (e.g. two unlabeled buttons that fall -// back to the same semantic key). Rare; tighten with a positional tiebreak only -// if it shows up in practice. +// ponytail: last-wins on key collisions (two unlabeled buttons → same semantic +// key); add a tiebreak only if it bites. export const indexByIdentity = ( snapshot: SnapshotResult, ): Map => new Map(snapshot.elements.map((el) => [elementKey(el), el])); -// Render a snapshot as a delta against the previous snapshot's elements: only -// new/changed elements print in full, removed ones as a selector list, and -// unchanged ones collapse to a count. Callers fall back to formatSnapshot() for -// the first snapshot in a session or after a cross-origin reset (prior refs -// invalid). Assumes the previous full/diff snapshot is still in the model's -// transcript so omitted elements remain referenceable. +const frameLabelsOf = (snapshot: SnapshotResult): Map => { + const labels = new Map(); + snapshot.frames?.forEach((f, i) => labels.set(f.frameId, `frame#${i + 1}`)); + return labels; +}; + +// Shared header/footer for both diffs so the rendered shape stays one thing. +const assembleDiff = ( + snapshot: SnapshotResult, + countsLine: string, + changeLines: string[], + unchanged: number, +): string => { + const lines = [ + '--- PAGE SNAPSHOT (diff vs previous; unchanged elements omitted) ---', + `${snapshot.url} | ${snapshot.title}`, + countsLine, + ...(snapshot.detectedChallenges ?? []).map((t) => `! Detected challenge: ${t}`), + ]; + if (changeLines.length === 0) { + lines.push('', 'No changes since last snapshot.'); + } else { + lines.push('', ...changeLines); + if (unchanged > 0) + lines.push( + `… ${unchanged} unchanged elements omitted (still valid from the previous snapshot).`, + ); + } + lines.push('--- END SNAPSHOT ---'); + return lines.join('\n'); +}; + +// Delta vs the previous snapshot: +new / ~changed / -removed, unchanged collapsed +// to a count. Assumes the prior snapshot is still in context (caller sends full otherwise). export const formatSnapshotDiff = ( snapshot: SnapshotResult, prev: Map, ): string => { - const frameLabels = new Map(); - snapshot.frames?.forEach((frame, i) => - frameLabels.set(frame.frameId, `frame#${i + 1}`), - ); - + const frameLabels = frameLabelsOf(snapshot); const seen = new Set(); const added: SnapshotElement[] = []; const changed: SnapshotElement[] = []; @@ -281,42 +295,19 @@ export const formatSnapshotDiff = ( else if (elementSignature(before) !== elementSignature(el)) changed.push(el); } - // Print removed elements by their selector (the actionable handle), not the - // internal identity key. + // Removed elements print by selector (the actionable handle), not the key. const removed = [...prev.entries()] .filter(([key]) => !seen.has(key)) .map(([, el]) => el.selector); const unchanged = snapshot.elements.length - added.length - changed.length; - const lines: string[] = [ - '--- PAGE SNAPSHOT (diff vs previous; unchanged elements omitted) ---', - `${snapshot.url} | ${snapshot.title}`, - `Changes: ${added.length} new, ${changed.length} changed, ${removed.length} removed, ${unchanged} unchanged (${snapshot.elements.length} total)`, + const changeLines = [ + ...added.map((el) => `+ ${formatElement(el, frameLabels)}`), + ...changed.map((el) => `~ ${formatElement(el, frameLabels)}`), + ...removed.map((sel) => `- ref=${sel} (removed)`), ]; - - lines.push( - ...(snapshot.detectedChallenges ?? []).map( - (type) => `! Detected challenge: ${type}`, - ), - ); - - if (!added.length && !changed.length && !removed.length) { - lines.push('', 'No changes since last snapshot.', '--- END SNAPSHOT ---'); - return lines.join('\n'); - } - - lines.push(''); - for (const el of added) lines.push(`+ ${formatElement(el, frameLabels)}`); - for (const el of changed) lines.push(`~ ${formatElement(el, frameLabels)}`); - for (const sel of removed) lines.push(`- ref=${sel} (removed)`); - if (unchanged > 0) { - lines.push( - `… ${unchanged} unchanged elements omitted (still valid from the previous snapshot).`, - ); - } - - lines.push('--- END SNAPSHOT ---'); - return lines.join('\n'); + const counts = `Changes: ${added.length} new, ${changed.length} changed, ${removed.length} removed, ${unchanged} unchanged (${snapshot.elements.length} total)`; + return assembleDiff(snapshot, counts, changeLines, unchanged); }; // Fields whose old→new transition is worth surfacing on a changed element. @@ -342,58 +333,20 @@ const formatChange = ( return `~ ${formatElement(after, frameLabels)}${suffix}`; }; -// Diff two snapshots of equal length by PAIRING ELEMENTS BY POSITION. DOM order -// survives an in-place SPA re-render even when ids/selectors churn, so when the -// element count is unchanged this pairs each slot old↔new and surfaces only the -// value/state that moved (e.g. a stat card 0→1,158) — the case identity-keying -// can't catch. Only meaningful when prev.length === snapshot.elements.length; -// the caller gates on that and takes the shortest of {full, identity, positional} -// so a bad positional guess (a reorder) is simply discarded, never emitted. +// Diff equal-length snapshots by DOM position — order survives an in-place SPA +// re-render when ids churn, surfacing moved values. Caller gates on equal length + shortest-wins. export const formatSnapshotDiffPositional = ( prev: SnapshotElement[], snapshot: SnapshotResult, ): string => { - const frameLabels = new Map(); - snapshot.frames?.forEach((frame, i) => - frameLabels.set(frame.frameId, `frame#${i + 1}`), - ); - - const changed: Array<{ before: SnapshotElement; after: SnapshotElement }> = - []; + const frameLabels = frameLabelsOf(snapshot); + const changeLines: string[] = []; snapshot.elements.forEach((after, i) => { const before = prev[i]; if (before && elementSignature(before) !== elementSignature(after)) - changed.push({ before, after }); + changeLines.push(formatChange(before, after, frameLabels)); }); - const unchanged = snapshot.elements.length - changed.length; - - const lines: string[] = [ - '--- PAGE SNAPSHOT (diff vs previous; unchanged elements omitted) ---', - `${snapshot.url} | ${snapshot.title}`, - `Changes: ${changed.length} changed, ${unchanged} unchanged (${snapshot.elements.length} total)`, - ]; - - lines.push( - ...(snapshot.detectedChallenges ?? []).map( - (type) => `! Detected challenge: ${type}`, - ), - ); - - if (!changed.length) { - lines.push('', 'No changes since last snapshot.', '--- END SNAPSHOT ---'); - return lines.join('\n'); - } - - lines.push(''); - for (const { before, after } of changed) { - lines.push(formatChange(before, after, frameLabels)); - } - if (unchanged > 0) { - lines.push( - `… ${unchanged} unchanged elements omitted (still valid from the previous snapshot).`, - ); - } - - lines.push('--- END SNAPSHOT ---'); - return lines.join('\n'); + const unchanged = snapshot.elements.length - changeLines.length; + const counts = `Changes: ${changeLines.length} changed, ${unchanged} unchanged (${snapshot.elements.length} total)`; + return assembleDiff(snapshot, counts, changeLines, unchanged); }; From cb291c6ee9d9d5f6658dc9187dff5afa282d4afb Mon Sep 17 00:00:00 2001 From: Anderson Martinez Date: Mon, 6 Jul 2026 14:15:35 -0500 Subject: [PATCH 4/8] chore: prettier --- src/lib/agent-format.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/agent-format.ts b/src/lib/agent-format.ts index 3633612..f4471e3 100644 --- a/src/lib/agent-format.ts +++ b/src/lib/agent-format.ts @@ -262,7 +262,9 @@ const assembleDiff = ( '--- PAGE SNAPSHOT (diff vs previous; unchanged elements omitted) ---', `${snapshot.url} | ${snapshot.title}`, countsLine, - ...(snapshot.detectedChallenges ?? []).map((t) => `! Detected challenge: ${t}`), + ...(snapshot.detectedChallenges ?? []).map( + (t) => `! Detected challenge: ${t}`, + ), ]; if (changeLines.length === 0) { lines.push('', 'No changes since last snapshot.'); From ab50ff981de5443caa553ccffa0de40bd50f1adb Mon Sep 17 00:00:00 2001 From: Anderson Martinez Date: Mon, 6 Jul 2026 14:19:48 -0500 Subject: [PATCH 5/8] refactor: simplify comments related to snapshot diffing logic --- src/@types/types.d.ts | 5 ----- src/tools/agent.ts | 14 ++++---------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/src/@types/types.d.ts b/src/@types/types.d.ts index f53c180..3720c00 100644 --- a/src/@types/types.d.ts +++ b/src/@types/types.d.ts @@ -188,12 +188,7 @@ export interface ActiveSession { skillState: SkillFireState; lastUsedAt: number; lastUrl?: string; - // Previous snapshot's elements keyed by selector, for snapshot diffing — - // subsequent snapshots return only what changed. Re-indexed each snapshot; - // the diff is skipped after a cross-origin nav since prior refs are invalid. lastElements?: Map; - // Previous snapshot's elements in DOM order, for the positional diff — order - // survives an in-place SPA re-render even when ids/selectors churn. lastSnapshotElements?: SnapshotElement[]; } diff --git a/src/tools/agent.ts b/src/tools/agent.ts index 209a024..0974b82 100644 --- a/src/tools/agent.ts +++ b/src/tools/agent.ts @@ -710,12 +710,8 @@ export function registerAgentTools( ); const noticeBlock = notice ? `${notice}\n\n` : ''; if (lastSnapshot.url) agentSession.lastUrl = lastSnapshot.url; - // Diff against the prior snapshot to save tokens. Send full instead - // when: first snapshot this session; cross-origin nav (notice set — - // prior refs invalid); or the prior snapshot was captured mid-hydration - // (SPA baseline had far fewer elements, so a diff would read as - // all-new). And even when we diff, never emit more than a full snapshot - // — on SPA re-mounts the diff can churn larger, so take the shorter. + // Send full on first snapshot / cross-origin nav / mid-hydration + // baseline / explicit full; otherwise diff (shortest-wins below). const prevElements = agentSession.lastElements; const prevArr = agentSession.lastSnapshotElements; const hydrating = @@ -727,10 +723,8 @@ export function registerAgentTools( const full = formatSnapshot(lastSnapshot); let snapshotText = full; if (prevElements && !notice && !hydrating && !forceFull) { - // Take the shortest of full / identity-diff / positional-diff. The - // positional candidate (only when element count is unchanged) catches - // in-place SPA value updates that identity-keying churns on; a bad - // positional guess just loses to a shorter candidate and is dropped. + // Shortest of full / identity-diff / positional-diff wins, so a bad + // positional guess (only tried at equal count) can never cost more. const candidates = [ full, formatSnapshotDiff(lastSnapshot, prevElements), From de8024c5641970f1026b5bbd3382c158264ea7d0 Mon Sep 17 00:00:00 2001 From: Anderson Martinez Date: Mon, 6 Jul 2026 14:45:12 -0500 Subject: [PATCH 6/8] refactor: enhance snapshot diffing logic to preserve duplicate identity keys and include frame legend --- src/lib/agent-format.ts | 56 +++++++++++++++++++----------- test/lib/agent-format.diff.spec.ts | 33 +++++++++++++++++- 2 files changed, 68 insertions(+), 21 deletions(-) diff --git a/src/lib/agent-format.ts b/src/lib/agent-format.ts index f4471e3..d05c2bb 100644 --- a/src/lib/agent-format.ts +++ b/src/lib/agent-format.ts @@ -176,21 +176,8 @@ export const formatSnapshot = (snapshot: SnapshotResult): string => { } } - // Label cross-origin iframes (frame#1, …) and list them so the agent knows - // which elements live in a frame and that their deep-ref selectors pierce it. const frameLabels = frameLabelsOf(snapshot); - if (snapshot.frames?.length) { - lines.push(`Frames (${snapshot.frames.length} iframes):`); - for (const frame of snapshot.frames) { - const origin = frame.crossOrigin ? 'cross-origin' : 'same-origin'; - lines.push( - ` ${frameLabels.get(frame.frameId)} ${frame.url} (${origin})`, - ); - } - lines.push( - 'Elements tagged [frame#N] live in that iframe; their deep-ref selectors pierce it — pass as-is to click/type/hover.', - ); - } + lines.push(...frameLegend(snapshot, frameLabels)); lines.push(''); @@ -238,12 +225,23 @@ export const elementKey = (el: SnapshotElement): string => { return `sem:${el.tag}|${el.role}|${label}|${el.href ?? ''}`; }; -// ponytail: last-wins on key collisions (two unlabeled buttons → same semantic -// key); add a tiebreak only if it bites. +// Occurrence-suffix the identity key so duplicate-keyed elements (two unlabeled +// buttons, repeated links) are each kept instead of collapsing to last-wins. +const identityEntries = ( + elements: SnapshotElement[], +): Array<[string, SnapshotElement]> => { + const seen = new Map(); + return elements.map((el) => { + const base = elementKey(el); + const n = seen.get(base) ?? 0; + seen.set(base, n + 1); + return [`${base}${n}`, el]; + }); +}; + export const indexByIdentity = ( snapshot: SnapshotResult, -): Map => - new Map(snapshot.elements.map((el) => [elementKey(el), el])); +): Map => new Map(identityEntries(snapshot.elements)); const frameLabelsOf = (snapshot: SnapshotResult): Map => { const labels = new Map(); @@ -251,6 +249,24 @@ const frameLabelsOf = (snapshot: SnapshotResult): Map => { return labels; }; +// Frame legend + deep-ref guidance, shared by full and diff snapshots so framed +// elements never arrive without their [frame#N] → url mapping. +const frameLegend = ( + snapshot: SnapshotResult, + frameLabels: Map, +): string[] => { + if (!snapshot.frames?.length) return []; + const lines = [`Frames (${snapshot.frames.length} iframes):`]; + for (const frame of snapshot.frames) { + const origin = frame.crossOrigin ? 'cross-origin' : 'same-origin'; + lines.push(` ${frameLabels.get(frame.frameId)} ${frame.url} (${origin})`); + } + lines.push( + 'Elements tagged [frame#N] live in that iframe; their deep-ref selectors pierce it — pass as-is to click/type/hover.', + ); + return lines; +}; + // Shared header/footer for both diffs so the rendered shape stays one thing. const assembleDiff = ( snapshot: SnapshotResult, @@ -265,6 +281,7 @@ const assembleDiff = ( ...(snapshot.detectedChallenges ?? []).map( (t) => `! Detected challenge: ${t}`, ), + ...frameLegend(snapshot, frameLabelsOf(snapshot)), ]; if (changeLines.length === 0) { lines.push('', 'No changes since last snapshot.'); @@ -289,8 +306,7 @@ export const formatSnapshotDiff = ( const seen = new Set(); const added: SnapshotElement[] = []; const changed: SnapshotElement[] = []; - for (const el of snapshot.elements) { - const key = elementKey(el); + for (const [key, el] of identityEntries(snapshot.elements)) { seen.add(key); const before = prev.get(key); if (!before) added.push(el); diff --git a/test/lib/agent-format.diff.spec.ts b/test/lib/agent-format.diff.spec.ts index 5038e3b..774e8bc 100644 --- a/test/lib/agent-format.diff.spec.ts +++ b/test/lib/agent-format.diff.spec.ts @@ -19,11 +19,15 @@ const el = (over: Partial): SnapshotElement => ({ ...over, }); -const snap = (elements: SnapshotElement[]): SnapshotResult => ({ +const snap = ( + elements: SnapshotElement[], + extra: Partial = {}, +): SnapshotResult => ({ url: 'https://example.com', title: 'Example', elements, time: 1, + ...extra, }); describe('formatSnapshotDiff', () => { @@ -50,6 +54,33 @@ describe('formatSnapshotDiff', () => { expect(out).to.not.include('"Home"'); }); + it('preserves duplicate identity keys (removal of one is not hidden)', () => { + // Two elements collapse to the same semantic key; removing one must show. + const dup = { role: 'button', tag: 'button', name: 'Go', selector: '' }; + const prev = indexByIdentity(snap([el(dup), el(dup)])); + const out = formatSnapshotDiff(snap([el(dup)]), prev); + expect(out).to.match(/0 new, 0 changed, 1 removed/); + }); + + it('includes the frame legend in a diff when frames are present', () => { + const frames = [ + { frameId: 'f1', url: 'https://ad.example', crossOrigin: true }, + ]; + const prev = indexByIdentity(snap([el({ selector: '#a', name: 'A' })])); + const out = formatSnapshotDiff( + snap( + [ + el({ selector: '#a', name: 'A' }), + el({ selector: '#b', name: 'B', frameId: 'f1' }), + ], + { frames }, + ), + prev, + ); + expect(out).to.include('Frames (1 iframes):'); + expect(out).to.include('frame#1 https://ad.example (cross-origin)'); + }); + it('flags an element whose state changed, ignoring ref renumbering', () => { const prev = indexByIdentity( snap([el({ ref: 1, selector: '#agree', name: 'Agree', checked: false })]), From 918f3c1a3812cc033a10362798b1ddfc408201a9 Mon Sep 17 00:00:00 2001 From: Anderson Martinez Date: Mon, 6 Jul 2026 15:10:11 -0500 Subject: [PATCH 7/8] refactor: enhance snapshot diffing to handle active tab changes and provide detailed change reasons --- src/@types/types.d.ts | 2 ++ src/lib/agent-format.ts | 58 ++++++++++++++---------------- src/tools/agent.ts | 30 +++++++++++++--- test/lib/agent-format.diff.spec.ts | 12 +++++++ 4 files changed, 66 insertions(+), 36 deletions(-) diff --git a/src/@types/types.d.ts b/src/@types/types.d.ts index 3720c00..2d621c4 100644 --- a/src/@types/types.d.ts +++ b/src/@types/types.d.ts @@ -190,6 +190,8 @@ export interface ActiveSession { lastUrl?: string; lastElements?: Map; lastSnapshotElements?: SnapshotElement[]; + // Active tab the cache above belongs to; a change means re-baseline (new tab). + lastActiveTargetId?: string | null; } /* ------------------------------------------------------------------ */ diff --git a/src/lib/agent-format.ts b/src/lib/agent-format.ts index d05c2bb..ced4dba 100644 --- a/src/lib/agent-format.ts +++ b/src/lib/agent-format.ts @@ -189,25 +189,25 @@ export const formatSnapshot = (snapshot: SnapshotResult): string => { return lines.join('\n'); }; -// Fields whose change marks an element "changed". Excludes ref (positional) and -// selector (the identity key itself). +const SIGNATURE_FIELDS: Array = [ + 'role', + 'name', + 'text', + 'value', + 'type', + 'placeholder', + 'href', + 'disabled', + 'checked', + 'focused', + 'required', + 'ariaLabel', + 'tag', + 'frameId', +]; + const elementSignature = (el: SnapshotElement): string => - JSON.stringify([ - el.role, - el.name, - el.text, - el.value, - el.type, - el.placeholder, - el.href, - el.disabled, - el.checked, - el.focused, - el.required, - el.ariaLabel, - el.tag, - el.frameId, - ]); + JSON.stringify(SIGNATURE_FIELDS.map((f) => el[f])); // Framework-generated ids (Radix/useId/Headless/MUI) churn every render, so a // selector or id built from one is not a stable diff key — excluded below. @@ -305,13 +305,13 @@ export const formatSnapshotDiff = ( const frameLabels = frameLabelsOf(snapshot); const seen = new Set(); const added: SnapshotElement[] = []; - const changed: SnapshotElement[] = []; + const changed: Array<{ before: SnapshotElement; after: SnapshotElement }> = []; for (const [key, el] of identityEntries(snapshot.elements)) { seen.add(key); const before = prev.get(key); if (!before) added.push(el); else if (elementSignature(before) !== elementSignature(el)) - changed.push(el); + changed.push({ before, after: el }); } // Removed elements print by selector (the actionable handle), not the key. const removed = [...prev.entries()] @@ -321,29 +321,23 @@ export const formatSnapshotDiff = ( const changeLines = [ ...added.map((el) => `+ ${formatElement(el, frameLabels)}`), - ...changed.map((el) => `~ ${formatElement(el, frameLabels)}`), + ...changed.map(({ before, after }) => + formatChange(before, after, frameLabels), + ), ...removed.map((sel) => `- ref=${sel} (removed)`), ]; const counts = `Changes: ${added.length} new, ${changed.length} changed, ${removed.length} removed, ${unchanged} unchanged (${snapshot.elements.length} total)`; return assembleDiff(snapshot, counts, changeLines, unchanged); }; -// Fields whose old→new transition is worth surfacing on a changed element. -const CHANGE_FIELDS: Array = [ - 'name', - 'value', - 'text', - 'checked', - 'disabled', - 'focused', -]; - +// `~` line with the field-level old→new reason, so a detected change is always +// visible. Reads SIGNATURE_FIELDS — the same fields change detection keys on. const formatChange = ( before: SnapshotElement, after: SnapshotElement, frameLabels?: Map, ): string => { - const deltas = CHANGE_FIELDS.filter((f) => before[f] !== after[f]).map( + const deltas = SIGNATURE_FIELDS.filter((f) => before[f] !== after[f]).map( (f) => `${f}: ${JSON.stringify(before[f] ?? '')}→${JSON.stringify(after[f] ?? '')}`, ); diff --git a/src/tools/agent.ts b/src/tools/agent.ts index 0974b82..0d7ec43 100644 --- a/src/tools/agent.ts +++ b/src/tools/agent.ts @@ -710,8 +710,19 @@ export function registerAgentTools( ); const noticeBlock = notice ? `${notice}\n\n` : ''; if (lastSnapshot.url) agentSession.lastUrl = lastSnapshot.url; + // A peek (targetId ≠ active tab) reads a different tab; it must not + // diff against or overwrite the active tab's cache. + const targetId = (lastCmd?.params as { targetId?: string } | undefined) + ?.targetId; + const peeking = !!targetId && targetId !== lastSnapshot.activeTargetId; + // Active tab changed (switch/create/close) → prior cache is a different + // tab; re-baseline with a full snapshot. + const switchedTab = + lastSnapshot.activeTargetId != null && + agentSession.lastActiveTargetId != null && + lastSnapshot.activeTargetId !== agentSession.lastActiveTargetId; // Send full on first snapshot / cross-origin nav / mid-hydration - // baseline / explicit full; otherwise diff (shortest-wins below). + // baseline / explicit full / peek / tab switch; else diff (shortest-wins). const prevElements = agentSession.lastElements; const prevArr = agentSession.lastSnapshotElements; const hydrating = @@ -722,7 +733,14 @@ export function registerAgentTools( (lastCmd?.params as { full?: boolean } | undefined)?.full === true; const full = formatSnapshot(lastSnapshot); let snapshotText = full; - if (prevElements && !notice && !hydrating && !forceFull) { + if ( + prevElements && + !notice && + !hydrating && + !forceFull && + !peeking && + !switchedTab + ) { // Shortest of full / identity-diff / positional-diff wins, so a bad // positional guess (only tried at equal count) can never cost more. const candidates = [ @@ -738,8 +756,12 @@ export function registerAgentTools( b.length < a.length ? b : a, ); } - agentSession.lastElements = indexByIdentity(lastSnapshot); - agentSession.lastSnapshotElements = lastSnapshot.elements; + // Never let a peek's other-tab elements become the active baseline. + if (!peeking) { + agentSession.lastElements = indexByIdentity(lastSnapshot); + agentSession.lastSnapshotElements = lastSnapshot.elements; + agentSession.lastActiveTargetId = lastSnapshot.activeTargetId; + } baseContent = [ { type: 'text' as const, diff --git a/test/lib/agent-format.diff.spec.ts b/test/lib/agent-format.diff.spec.ts index 774e8bc..55fa483 100644 --- a/test/lib/agent-format.diff.spec.ts +++ b/test/lib/agent-format.diff.spec.ts @@ -94,6 +94,18 @@ describe('formatSnapshotDiff', () => { expect(out).to.include('(checked)'); }); + it('shows a reason for changes formatElement does not render (e.g. placeholder)', () => { + const prev = indexByIdentity( + snap([el({ selector: '#q', name: 'Search', placeholder: 'old' })]), + ); + const out = formatSnapshotDiff( + snap([el({ selector: '#q', name: 'Search', placeholder: 'new' })]), + prev, + ); + expect(out).to.match(/1 changed/); + expect(out).to.include('placeholder: "old"→"new"'); + }); + it('reports no changes when nothing moved', () => { const prev = indexByIdentity( snap([el({ ref: 1, selector: '#x', name: 'X' })]), From 14264855d5e58f53deccdb00e99844d6bd4e7453 Mon Sep 17 00:00:00 2001 From: Anderson Martinez Date: Mon, 6 Jul 2026 15:10:11 -0500 Subject: [PATCH 8/8] chore: prettier --- src/lib/agent-format.ts | 3 ++- src/tools/agent.ts | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/lib/agent-format.ts b/src/lib/agent-format.ts index ced4dba..2157b50 100644 --- a/src/lib/agent-format.ts +++ b/src/lib/agent-format.ts @@ -305,7 +305,8 @@ export const formatSnapshotDiff = ( const frameLabels = frameLabelsOf(snapshot); const seen = new Set(); const added: SnapshotElement[] = []; - const changed: Array<{ before: SnapshotElement; after: SnapshotElement }> = []; + const changed: Array<{ before: SnapshotElement; after: SnapshotElement }> = + []; for (const [key, el] of identityEntries(snapshot.elements)) { seen.add(key); const before = prev.get(key); diff --git a/src/tools/agent.ts b/src/tools/agent.ts index 0d7ec43..5888bb7 100644 --- a/src/tools/agent.ts +++ b/src/tools/agent.ts @@ -712,9 +712,11 @@ export function registerAgentTools( if (lastSnapshot.url) agentSession.lastUrl = lastSnapshot.url; // A peek (targetId ≠ active tab) reads a different tab; it must not // diff against or overwrite the active tab's cache. - const targetId = (lastCmd?.params as { targetId?: string } | undefined) - ?.targetId; - const peeking = !!targetId && targetId !== lastSnapshot.activeTargetId; + const targetId = ( + lastCmd?.params as { targetId?: string } | undefined + )?.targetId; + const peeking = + !!targetId && targetId !== lastSnapshot.activeTargetId; // Active tab changed (switch/create/close) → prior cache is a different // tab; re-baseline with a full snapshot. const switchedTab =