diff --git a/CHANGELOG.md b/CHANGELOG.md index c99363c6..4200f8b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 2026-08-20 + +### Changes + +- The `context()` tool now reads the page again instead of re-printing the snapshot the AI was + already given. It used to answer from the last stored state, so an AI that asked for fresh + context after the page moved on got the stale one back. +- The refreshed context carries element refs, the same ones the page context carries when a test + starts. Refs previously disappeared the moment the AI refreshed its context, so it kept clicking + refs that no longer existed and the click failed. +- `xpathCheck` matches XPath against the page as it is now, so its match list and its + "element is visible" answer describe the same page. +- The clickRef tool no longer suggests refs have a fixed short form — a ref is an opaque id that + varies with the page and with the frame the element sits in, and must be copied exactly. + ## 2026-08-18 ### Changes diff --git a/src/ai/tester.ts b/src/ai/tester.ts index 76802e18..03ab6264 100644 --- a/src/ai/tester.ts +++ b/src/ai/tester.ts @@ -11,7 +11,7 @@ import { Observability } from '../observability.ts'; import type { StateTransition } from '../state-manager.ts'; import { Stats } from '../stats.ts'; import { type Test, TestResult, type TestResultType } from '../test-plan.ts'; -import { compactAriaSnapshot, detectFocusArea } from '../utils/aria.ts'; +import { detectFocusArea, interactiveAriaWithRefs } from '../utils/aria.ts'; import { ErrorPageError, isErrorPage } from '../utils/error-page.ts'; import { createDebug, tag } from '../utils/logger.ts'; import { loop } from '../utils/loop.ts'; @@ -602,7 +602,7 @@ export class Tester extends TaskAgent implements Agent { - ${await this.interactiveAriaWithRefs(currentState)} + ${await interactiveAriaWithRefs(this.explorer, currentState)} ${uiMapSection} @@ -639,17 +639,11 @@ export class Tester extends TaskAgent implements Agent { - ${await this.interactiveAriaWithRefs(currentState)} + ${await interactiveAriaWithRefs(this.explorer, currentState)} `; } - private async interactiveAriaWithRefs(state: ActionResult): Promise { - const withRefs = await Promise.resolve(this.explorer?.withPage?.((page: any) => page.locator('body').ariaSnapshot({ mode: 'ai' }))).catch(() => null); - if (!withRefs) return state.getInteractiveARIA(); - return compactAriaSnapshot(withRefs, false); - } - private finishTest(task: Test): void { if (!task.result) { if (task.hasAchievedAll()) task.finish(TestResult.PASSED); diff --git a/src/ai/tools.ts b/src/ai/tools.ts index 9911c1eb..182798fd 100644 --- a/src/ai/tools.ts +++ b/src/ai/tools.ts @@ -5,7 +5,7 @@ import { ActionResult, type PageDiff, type ToolResultMetadata } from '../action- import type { ExperienceTracker } from '../experience-tracker.ts'; import { Stats } from '../stats.ts'; import { type Task, TestResult } from '../test-plan.js'; -import { LARGE_ARIA_CHANGE_THRESHOLD } from '../utils/aria.ts'; +import { LARGE_ARIA_CHANGE_THRESHOLD, interactiveAriaWithRefs } from '../utils/aria.ts'; import { isFatalBrowserError } from '../utils/browser-errors.ts'; import { createDebug, tag } from '../utils/logger.js'; import { pause } from '../utils/loop.js'; @@ -34,7 +34,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps, description: dedent` Click an element by trying multiple CodeceptJS commands in order until one succeeds. - Use this only for elements the page context gives you no ref for. When the element shows a ref such as [ref=e14], + Use this only for elements the page context gives you no ref for. When the element is followed by [ref=...], call clickRef with that ref instead — composing a locator for an element that already has a ref is wasted work, and a locator can match several elements where a ref cannot. @@ -153,15 +153,16 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps, clickRef: tool({ description: dedent` - Click an element by the ref the page context gave it, e.g. [ref=e14]. + Click an element by the ref the page context gave it, printed after the element as [ref=...]. Prefer this over click() whenever the element you want carries a ref. A ref names one exact element, so it cannot match several by mistake and never needs disambiguating — it is the fastest way to click. - Only pass a ref that appears in the page context you were given. Never invent or guess one. + A ref is an opaque id whose form varies with the page and with the frame the element lives in. + Copy it character for character from the page context you were given. Never invent, guess, shorten or rebuild one. If it reports the ref is gone, the page has been rebuilt: get fresh context and use the new ref. `, inputSchema: z.object({ - ref: z.string().describe('The ref exactly as it appears in the page context, e.g. "e14"'), + ref: z.string().describe('The ref copied character for character from the page context, as it appears inside [ref=...]'), element: z.string().describe('Role and name of the element you are clicking, for the record'), }), execute: async ({ ref, element }) => { @@ -646,19 +647,24 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig return failedToolResult('context', 'No current page state available.'); } - const actionResult = ActionResult.fromState(currentState); + const actionResult = await explorer.capture(); + if (actionResult.error) { + return failedToolResult('context', `Page state could not be captured: ${actionResult.error}`); + } + const html = await actionResult.simplifiedHtml(); - const aria = actionResult.getInteractiveARIA(); + const aria = await interactiveAriaWithRefs(explorer, actionResult); return successToolResult('context', { - url: currentState.url, - title: currentState.title, + url: actionResult.url, + title: actionResult.title, suggestion: 'If not enough context received, call see() to visually identify elements in page contents', aria: cap(aria, ARIA_OUTPUT_CAP), html: cap(html, HTML_OUTPUT_CAP), reminder: 'Context provided. Do not call context() again until you perform actions or suspect page changed.', }); } catch (error) { + throwIfFatalBrowserError(error); const errorMessage = errorText(error); return failedToolResult('context', `Context tool failed: ${errorMessage}`); } @@ -1000,7 +1006,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig return failedToolResult('xpathCheck', 'No current page state available.'); } - const html = ActionResult.fromState(currentState).html; + const html = (await explorer.capture()).html; if (!html) { return failedToolResult('xpathCheck', 'No HTML available for current page state.'); } diff --git a/src/utils/aria.ts b/src/utils/aria.ts index 90f10305..ef25f60b 100644 --- a/src/utils/aria.ts +++ b/src/utils/aria.ts @@ -532,6 +532,12 @@ export const compactAriaSnapshot = (snapshot: string | null, keepNamed = false, return renderTree(tree, 0, offload); }; +export const interactiveAriaWithRefs = async (explorer: { withPage?: (fn: (page: any) => Promise) => Promise } | undefined, state: { getInteractiveARIA: () => string }): Promise => { + const withRefs = await Promise.resolve(explorer?.withPage?.((page: any) => page.locator('body').ariaSnapshot({ mode: 'ai' }))).catch(() => null); + if (!withRefs) return state.getInteractiveARIA(); + return compactAriaSnapshot(withRefs, false); +}; + export const diffAriaSnapshots = (previous: string | null, current: string | null): AriaDiff => { const flat = (snap: string | null): FlatEntry[] => { let tree = parseSnapshot(snap); diff --git a/tests/unit/aria.test.ts b/tests/unit/aria.test.ts index da6e00f5..65f8ae24 100644 --- a/tests/unit/aria.test.ts +++ b/tests/unit/aria.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import { compactAriaSnapshot, diffAriaSnapshots } from '../../src/utils/aria.ts'; +import { compactAriaSnapshot, diffAriaSnapshots, interactiveAriaWithRefs } from '../../src/utils/aria.ts'; describe('aria', () => { it('returns null diff for identical snapshots', () => { @@ -245,6 +245,21 @@ describe('aria', () => { expect(compactAriaSnapshot('- textbox "Name": Bench probe', true)).toContain(': Bench probe'); }); + it('takes refs from the live snapshot when the page is reachable', async () => { + const explorer = { withPage: async (fn: (page: any) => Promise) => fn({ locator: () => ({ ariaSnapshot: async () => '- button "Save" [ref=e7]' }) }) }; + + const result = await interactiveAriaWithRefs(explorer, { getInteractiveARIA: () => '- button "Save"' }); + + expect(result).toContain('ref=e7'); + }); + + it('falls back to the stored snapshot when the live one is unavailable', async () => { + const explorer = { withPage: async () => Promise.reject(new Error('page closed')) }; + + expect(await interactiveAriaWithRefs(explorer, { getInteractiveARIA: () => '- button "Save"' })).toBe('- button "Save"'); + expect(await interactiveAriaWithRefs(undefined, { getInteractiveARIA: () => '- button "Save"' })).toBe('- button "Save"'); + }); + it('keeps refs on stateful nodes nested in a tree', () => { const snapshot = ['- navigation "Panel sections" [ref=e6]:', ' - button "Workspace" [ref=e10]', ' - button "Workflows" [active] [ref=e13]'].join('\n');