Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 3 additions & 9 deletions src/ai/tester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -602,7 +602,7 @@ export class Tester extends TaskAgent implements Agent {
</page>

<page_aria>
${await this.interactiveAriaWithRefs(currentState)}
${await interactiveAriaWithRefs(this.explorer, currentState)}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing refs to Tester here, but clickRef is still missing from ACTION_TOOLS. This means even a successful clickRef won’t count as an action, so Tester may stop the test as stalled despite making real progress. We should either add clickRef to all action/progress tracking or keep refs limited to Prima as in #130

</page_aria>
${uiMapSection}

Expand Down Expand Up @@ -639,17 +639,11 @@ export class Tester extends TaskAgent implements Agent {
</page>

<page_aria>
${await this.interactiveAriaWithRefs(currentState)}
${await interactiveAriaWithRefs(this.explorer, currentState)}
</page_aria>
`;
}

private async interactiveAriaWithRefs(state: ActionResult): Promise<string> {
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);
Expand Down
26 changes: 16 additions & 10 deletions src/ai/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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.');
}
Expand Down
6 changes: 6 additions & 0 deletions src/utils/aria.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>) => Promise<string> } | undefined, state: { getInteractiveARIA: () => string }): Promise<string> => {
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);
Expand Down
17 changes: 16 additions & 1 deletion tests/unit/aria.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<string>) => 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');

Expand Down
Loading