explorbot 0.1.13 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/package.json +3 -2
  2. package/dist/src/action.js +3 -2
  3. package/dist/src/ai/conversation.js +20 -4
  4. package/dist/src/ai/historian/utils.js +8 -1
  5. package/dist/src/ai/pilot.js +198 -260
  6. package/dist/src/ai/provider.js +25 -12
  7. package/dist/src/ai/quartermaster.js +2 -2
  8. package/dist/src/ai/researcher/focus.js +51 -10
  9. package/dist/src/ai/researcher/sections.js +8 -4
  10. package/dist/src/ai/researcher.js +9 -24
  11. package/dist/src/ai/rules.js +2 -0
  12. package/dist/src/ai/session-analyst.js +46 -41
  13. package/dist/src/ai/tester.js +63 -22
  14. package/dist/src/ai/tools.js +19 -4
  15. package/dist/src/commands/explore-command.js +8 -2
  16. package/dist/src/components/StatusPane.js +6 -1
  17. package/dist/src/experience-tracker.js +9 -0
  18. package/dist/src/explorer.js +2 -5
  19. package/dist/src/reporter.js +41 -1
  20. package/dist/src/stats.js +2 -1
  21. package/dist/src/test-plan.js +47 -3
  22. package/package.json +3 -2
  23. package/src/action.ts +3 -2
  24. package/src/ai/conversation.ts +21 -4
  25. package/src/ai/historian/utils.ts +8 -1
  26. package/src/ai/pilot.ts +199 -259
  27. package/src/ai/provider.ts +24 -12
  28. package/src/ai/quartermaster.ts +2 -2
  29. package/src/ai/researcher/focus.ts +57 -8
  30. package/src/ai/researcher/sections.ts +7 -3
  31. package/src/ai/researcher.ts +8 -23
  32. package/src/ai/rules.ts +2 -0
  33. package/src/ai/session-analyst.ts +47 -41
  34. package/src/ai/tester.ts +55 -20
  35. package/src/ai/tools.ts +18 -4
  36. package/src/commands/explore-command.ts +9 -2
  37. package/src/components/StatusPane.tsx +6 -3
  38. package/src/experience-tracker.ts +9 -0
  39. package/src/explorer.ts +1 -4
  40. package/src/reporter.ts +44 -1
  41. package/src/stats.ts +3 -1
  42. package/src/test-plan.ts +62 -3
@@ -19,6 +19,15 @@ const responseLog = createDebug('explorbot:provider:in');
19
19
  class AiError extends Error {}
20
20
  export class ContextLengthError extends Error {}
21
21
 
22
+ function extractCachedTokens(usage: any): number {
23
+ if (!usage) return 0;
24
+ const direct = usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens;
25
+ if (typeof direct === 'number') return direct;
26
+ const raw = usage.raw;
27
+ const fromRaw = raw?.prompt_tokens_details?.cached_tokens ?? raw?.promptTokensDetails?.cachedTokens;
28
+ return typeof fromRaw === 'number' ? fromRaw : 0;
29
+ }
30
+
22
31
  function rejectAfterIdle(ms: number, signal: { cancelled: boolean }): Promise<never> {
23
32
  return new Promise((_, reject) => {
24
33
  const tick = () => {
@@ -265,9 +274,10 @@ export class Provider {
265
274
 
266
275
  if (response.usage) {
267
276
  Stats.recordTokens(options.agentName || 'unknown', modelName, {
268
- input: response.usage.promptTokens || 0,
269
- output: response.usage.completionTokens || 0,
270
- total: response.usage.totalTokens || 0,
277
+ input: response.usage.inputTokens ?? response.usage.promptTokens ?? 0,
278
+ output: response.usage.outputTokens ?? response.usage.completionTokens ?? 0,
279
+ total: response.usage.totalTokens ?? 0,
280
+ cached: extractCachedTokens(response.usage),
271
281
  });
272
282
  }
273
283
 
@@ -355,9 +365,10 @@ export class Provider {
355
365
 
356
366
  if (response.usage) {
357
367
  Stats.recordTokens(options.agentName || 'unknown', modelName, {
358
- input: response.usage.promptTokens || 0,
359
- output: response.usage.completionTokens || 0,
360
- total: response.usage.totalTokens || 0,
368
+ input: response.usage.inputTokens ?? response.usage.promptTokens ?? 0,
369
+ output: response.usage.outputTokens ?? response.usage.completionTokens ?? 0,
370
+ total: response.usage.totalTokens ?? 0,
371
+ cached: extractCachedTokens(response.usage),
361
372
  });
362
373
  }
363
374
 
@@ -428,9 +439,10 @@ export class Provider {
428
439
 
429
440
  if (response.usage) {
430
441
  Stats.recordTokens(options.agentName || 'unknown', modelName, {
431
- input: response.usage.promptTokens || 0,
432
- output: response.usage.completionTokens || 0,
433
- total: response.usage.totalTokens || 0,
442
+ input: response.usage.inputTokens ?? response.usage.promptTokens ?? 0,
443
+ output: response.usage.outputTokens ?? response.usage.completionTokens ?? 0,
444
+ total: response.usage.totalTokens ?? 0,
445
+ cached: extractCachedTokens(response.usage),
434
446
  });
435
447
  }
436
448
 
@@ -625,9 +637,9 @@ export class Provider {
625
637
 
626
638
  if (response.usage) {
627
639
  Stats.recordTokens('vision', this.getModelName(this.config.visionModel), {
628
- input: response.usage.promptTokens || 0,
629
- output: response.usage.completionTokens || 0,
630
- total: response.usage.totalTokens || 0,
640
+ input: response.usage.inputTokens ?? response.usage.promptTokens ?? 0,
641
+ output: response.usage.outputTokens ?? response.usage.completionTokens ?? 0,
642
+ total: response.usage.totalTokens ?? 0,
631
643
  });
632
644
  }
633
645
 
@@ -240,11 +240,11 @@ Focus on what would confuse a real user or caused the agent to make mistakes.`;
240
240
  const criticalViolations = report.axeViolations.filter((v) => v.impact === 'critical' || v.impact === 'serious');
241
241
  for (const v of criticalViolations.slice(0, 3)) {
242
242
  const nodeHtml = v.nodes[0]?.html.slice(0, 100) || '';
243
- task.addNote(`🔴 A11Y [${v.impact}] ${v.id}: ${v.description} — ${nodeHtml}`);
243
+ task.addVerificationDetail(`🔴 A11Y [${v.impact}] ${v.id}: ${v.description} — ${nodeHtml}`);
244
244
  }
245
245
 
246
246
  for (const issue of report.semanticIssues.slice(0, 3)) {
247
- task.addNote(`💡 UX [${issue.type}] ${issue.element}: ${issue.suggestion}`);
247
+ task.addVerificationDetail(`💡 UX [${issue.type}] ${issue.element}: ${issue.suggestion}`);
248
248
  }
249
249
  }
250
250
 
@@ -1,4 +1,4 @@
1
- import { detectFocusArea } from '../../utils/aria.ts';
1
+ import type { Page } from 'playwright';
2
2
  import { mdq } from '../../utils/markdown-query.ts';
3
3
  import type { ResearchSection } from './parser.ts';
4
4
  import type { ResearchResult } from './research-result.ts';
@@ -10,16 +10,65 @@ export function hasFocusedSection(text: string): boolean {
10
10
  return text.includes(FOCUSED_MARKER);
11
11
  }
12
12
 
13
- export function detectFocusFromAria(ariaSnapshot: string | null, sections: ResearchSection[]): string | null {
14
- const focusArea = detectFocusArea(ariaSnapshot);
15
- if (!focusArea.detected) return null;
13
+ interface FocusProbe {
14
+ name: string;
15
+ isDialog: boolean;
16
+ zIndex: number;
17
+ hasShadow: boolean;
18
+ }
19
+
20
+ export async function detectFocusedSection(page: Page, sections: ResearchSection[]): Promise<string | null> {
21
+ const candidates: FocusProbe[] = [];
22
+
23
+ for (const section of sections) {
24
+ if (!section.containerCss) continue;
25
+ const key = section.name.toLowerCase().replace(/^section:\s*/, '');
26
+ if (FOCUS_SKIP_SECTIONS.has(key)) continue;
27
+
28
+ try {
29
+ const locator = page.locator(section.containerCss).first();
30
+ if (!(await locator.isVisible())) continue;
31
+
32
+ const probe = await locator.evaluate((el) => {
33
+ const dialogSelector = '[role="dialog"], [role="alertdialog"], [aria-modal="true"]';
34
+ const isDialog = el.matches(dialogSelector) || !!el.querySelector(dialogSelector);
35
+
36
+ let cur: Element | null = el;
37
+ let maxZ = 0;
38
+ while (cur && cur !== document.body) {
39
+ const cs = window.getComputedStyle(cur);
40
+ if (cs.position !== 'static') {
41
+ const z = Number.parseInt(cs.zIndex, 10);
42
+ if (!Number.isNaN(z) && z > maxZ) maxZ = z;
43
+ }
44
+ cur = cur.parentElement;
45
+ }
16
46
 
17
- if (focusArea.type === 'dialog' || focusArea.type === 'modal') {
18
- const dialogSection = sections.find((s) => s.containerCss && (s.containerCss.includes('[role="dialog"]') || s.containerCss.includes('[role="alertdialog"]') || s.containerCss.includes('[aria-modal')));
19
- if (dialogSection) return dialogSection.name;
47
+ const shadow = window.getComputedStyle(el).boxShadow;
48
+ const hasShadow = !!shadow && shadow !== 'none';
49
+
50
+ return { isDialog, zIndex: maxZ, hasShadow };
51
+ });
52
+
53
+ candidates.push({ name: section.name, ...probe });
54
+ } catch {}
20
55
  }
21
56
 
22
- return null;
57
+ if (candidates.length === 0) return null;
58
+
59
+ const dialogs = candidates.filter((c) => c.isDialog);
60
+ const pool = dialogs.length > 0 ? dialogs : candidates;
61
+
62
+ const winner = pool.reduce<FocusProbe | null>((best, c) => {
63
+ if (!best) return c;
64
+ if (c.zIndex !== best.zIndex) return c.zIndex > best.zIndex ? c : best;
65
+ if (c.hasShadow !== best.hasShadow) return c.hasShadow ? c : best;
66
+ return best;
67
+ }, null);
68
+
69
+ if (!winner) return null;
70
+ if (dialogs.length === 0 && winner.zIndex === 0 && !winner.hasShadow) return null;
71
+ return winner.name;
23
72
  }
24
73
 
25
74
  export function markSectionAsFocused(result: ResearchResult, sectionName: string): void {
@@ -7,7 +7,9 @@ import { tag } from '../../utils/logger.js';
7
7
  import { RulesLoader } from '../../utils/rules-loader.ts';
8
8
  import type { Provider } from '../provider.js';
9
9
  import { locatorRule as generalLocatorRuleText } from '../rules.js';
10
+ import { markSectionAsFocused } from './focus.ts';
10
11
  import type { Constructor } from './mixin.ts';
12
+ import { ResearchResult } from './research-result.ts';
11
13
 
12
14
  export interface SectionMethods {
13
15
  researchBySections(): Promise<string>;
@@ -54,9 +56,11 @@ export function WithSections<T extends Constructor>(Base: T) {
54
56
  throw new Error('Per-section research produced no sections — AI responses all empty or NOT_PRESENT');
55
57
  }
56
58
 
57
- let merged = parts.join('\n\n');
58
- if (focusCss) merged += '\n\n> Focused: Focus';
59
- return merged;
59
+ const merged = parts.join('\n\n');
60
+ if (!focusCss) return merged;
61
+ const focused = new ResearchResult(merged, this.actionResult?.url || '');
62
+ markSectionAsFocused(focused, 'Focus');
63
+ return focused.text;
60
64
  }
61
65
 
62
66
  private async _detectFocusCss(): Promise<string | null> {
@@ -24,7 +24,7 @@ import { ContextLengthError, type Provider } from './provider.js';
24
24
  import { findSimilarResearch, getCachedResearch, saveResearch } from './researcher/cache.ts';
25
25
  import { type CoordinateMethods, WithCoordinates } from './researcher/coordinates.ts';
26
26
  import { type DeepAnalysisMethods, WithDeepAnalysis } from './researcher/deep-analysis.ts';
27
- import { detectFocusFromAria, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from './researcher/focus.ts';
27
+ import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from './researcher/focus.ts';
28
28
  import { type LocatorMethods, WithLocators } from './researcher/locators.ts';
29
29
  import { extractValidContainers, formatResearchSummary, parseResearchSections } from './researcher/parser.ts';
30
30
  import { ResearchResult } from './researcher/research-result.ts';
@@ -234,17 +234,12 @@ export class Researcher extends ResearcherBase implements Agent {
234
234
  await this.fixBrokenSections(result, activeConversation);
235
235
  }
236
236
 
237
- // Focused section: parse AI declaration, then ARIA fallback
238
- const focusMatch = result.text.match(/^>\s*Focused:\s*(.+)/m);
239
- if (focusMatch) {
240
- result.text = result.text.replace(focusMatch[0], '');
241
- markSectionAsFocused(result, focusMatch[1].trim());
242
- }
243
- if (!hasFocusedSection(result.text)) {
237
+ // Focused section: unified Playwright probe (HTML+CSS+visibility).
238
+ // Must run BEFORE visuallyAnnotateContainers — annotation overlays inject z-index 99998+ which would pollute the scoring.
239
+ if (!interrupted() && this.hasScreenshotToAnalyze) {
244
240
  const sections = parseResearchSections(result.text);
245
- const ariaSnapshot = this.actionResult?.getCompactARIA() || '';
246
- const focusedName = detectFocusFromAria(ariaSnapshot, sections);
247
- if (focusedName) markSectionAsFocused(result, focusedName);
241
+ const focused = await detectFocusedSection(this.explorer.playwrightHelper.page, sections);
242
+ if (focused) markSectionAsFocused(result, focused);
248
243
  }
249
244
 
250
245
  // Stage 4: Visual analysis
@@ -281,8 +276,8 @@ export class Researcher extends ResearcherBase implements Agent {
281
276
  await this.backfillBrokenLocators(result);
282
277
  }
283
278
 
284
- // Focused section: final fallback
285
- if (!hasFocusedSection(result.text)) {
279
+ // Focused section: final fallback (vision-only — without a screenshot we don't infer focus)
280
+ if (this.hasScreenshotToAnalyze && !hasFocusedSection(result.text)) {
286
281
  const sections = parseResearchSections(result.text);
287
282
  const fallback = pickDefaultFocusedSection(sections);
288
283
  if (fallback) markSectionAsFocused(result, fallback);
@@ -451,16 +446,6 @@ export class Researcher extends ResearcherBase implements Agent {
451
446
 
452
447
  | Element | ARIA | CSS | eidx |
453
448
  </section_format>
454
-
455
- <focused_section>
456
- At the end of your output, declare the primary focus area on a single line:
457
-
458
- > Focused: <exact section name>
459
-
460
- - If a dialog/modal/drawer/overlay exists, it is focused.
461
- - Otherwise pick the section where the main business action happens (list for catalog, detail for item page, content for article).
462
- - Navigation and menu/toolbar are never focused.
463
- </focused_section>
464
449
  `;
465
450
  }
466
451
 
package/src/ai/rules.ts CHANGED
@@ -241,6 +241,8 @@ export function multipleTabsRule(tabs: Array<{ url: string; title: string }>): s
241
241
 
242
242
  export const actionRule = dedent`
243
243
  <actions>
244
+ \`faker\` (from @faker-js/faker) is available inside I.* calls for generating data, e.g. I.fillField('Bio', faker.lorem.paragraphs(5)).
245
+
244
246
  ### I.click
245
247
 
246
248
  clicks on the element by its locator
@@ -19,69 +19,71 @@ export class SessionAnalyst implements Agent {
19
19
  const eligible = tests.filter((t) => t.startTime != null);
20
20
  if (eligible.length === 0) return '';
21
21
 
22
- const model = this.provider.getModelForAgent('analyst');
22
+ const model = this.provider.getAgenticModel('analyst');
23
23
  const customPrompt = this.provider.getSystemPromptForAgent('analyst', undefined);
24
24
 
25
25
  const systemPrompt = dedent`
26
- You write a brief end-of-session report after autonomous exploratory testing. Your reader is a developer who needs to know in seconds: what is broken, how to reproduce it, and which results were inconclusive.
26
+ You write a TERSE end-of-session report. Reader is a developer who wants to UNDERSTAND THE FEATURE what works, what is broken, what is unclear. Every word must earn its place.
27
27
 
28
- Output MARKDOWN. No JSON, no preamble, no closing remarks. Start with the heading.
28
+ Output MARKDOWN. No JSON, no preamble, no closing summary.
29
29
 
30
- ## Clustering
31
- Group by ROOT CAUSE, not by scenario. If three tests fail for the same dropdown, that is ONE defect listing all three test refs (#3, #5, #7). Do not produce one cluster per test.
30
+ NO EMOJI. No 🔴 🟡 🟢 ✅, no escape sequences like \\u2705. Use plain text severity tags: [High], [Medium], [Low] for defects.
32
31
 
33
- ## Bucketing
34
- Use the FINAL verdict (the test's \`result\` field) as the starting point. Mid-test errors that the automation recovered from do NOT make a passed test unreliable.
32
+ ## Reporting unit
35
33
 
36
- - **Defect** real product bug. \`result: failed\` AND the failure reflects the app misbehaving (not the automation). The automation completed its interactions, the app contradicted the expected outcome. Severity required.
37
- - **UX issue** — app works but the UI is ambiguous, controls are hidden, or labels are unclear. Worth flagging to design.
38
- - **Execution issue** — the FINAL verdict is unreliable. Only two cases:
39
- 1. \`result: failed\` AND the failure was automation, environment, or UI/UX (locator missing, timeout, AI loop, navigation stuck, modal trapped focus, no accessible label) — i.e. the test could not conclude whether the app works.
40
- 2. \`result: passed\` AND clear evidence in the log shows the user-visible goal was NOT achieved (no confirmation visible, no state change verified, the assertion was vacuous).
34
+ Report at the level of FEATURES / FLOWS / PAGES. Tests are evidence, not the unit. Several tests covering the same flow ONE entry citing all of them.
41
35
 
42
- A test that passed and shows no contrary evidence belongs in NO section. Do not list passed tests just because the log contains intermediate retries or recovered failures.
36
+ ## Walk every test
43
37
 
44
- ## Severity emoji (defects only)
45
- - 🔴 critical or high — core flow blocked, data loss, security
46
- - 🟡 medium — partial breakage with workaround
47
- - 🟢 low — cosmetic
38
+ PASSED test: did all steps run, was the goal actually verified, did the user-visible goal happen? All yes → contributes to What works. Any no → Execution issue (false positive).
48
39
 
49
- ## Required format
40
+ FAILED test, first match wins: (1) goal achieved but mis-verified → Execution. (2) automation failure (locator/timeout/loop/modal/a11y) → Execution. (3) bad preconditions or data → Execution. (4) wrong URL/environment → Execution. (5) app contradicted expected outcome → Defect.
41
+
42
+ Crucial distinction: "the app misbehaved" vs "the automation could not interact with the app". ONLY the first is a Defect. If the automation gives up before the app responds — timeout, retries exhausted, dead loop / loop detected, could not click or find an element — that is an Execution issue regardless of what the log calls it. Failure inside the automation ≠ failure inside the product.
43
+
44
+ A solitary failure where adjacent tests on the same feature passed → Execution, not Defect.
45
+
46
+ ## Severity (defects only)
47
+ [High] blocks a core flow · [Medium] degrades a flow but workaround exists · [Low] cosmetic / edge case
48
+
49
+ ## Format
50
50
 
51
51
  # Session Analysis
52
52
 
53
- <one sentence: total tests, defect count, headline finding>
53
+ <ONE or TWO sentences describing the FEATURE STATE — what was explored, whether the core flow holds, what the standout problem is. NO test counts, NO "N tests run". Talk about the product, not the run.>
54
+
55
+ ## Coverage
56
+ - Pages: <paths>
57
+ - Features: <capabilities>
58
+
59
+ ## What works
60
+ - **<feature>** — #2, #7, #8
54
61
 
55
62
  ## Defects
56
63
 
57
- ### 🔴 <plain-English title of the BUG, not the scenario name>
58
- Affects: #3, #5, #7
64
+ ### [Medium] <plain-English bug title>
65
+ Affects: #3, #5
59
66
  Reproduce:
60
- 1. <concrete UI step a person can replay>
61
- 2. <next step>
62
- Evidence: <one short observation from the test log>
63
-
64
- ### 🟡 <next defect>
65
- ...
67
+ 1. <concrete UI step>
68
+ 2. <next>
69
+ Evidence: <one short observation>
66
70
 
67
71
  ## UX issues
68
-
69
- - **<title>** — #4
70
- <one short evidence line>
72
+ - **<feature>** — <what's confusing> (#7)
71
73
 
72
74
  ## Execution Issues
75
+ - **#2 <scenario>** — <≤10 words, what was unreliable>
73
76
 
74
- - **<short test name or scenario phrase>** — <plain-English one-liner: what made the result unreliable>
75
- - **<…>** — <…>
77
+ ## Brevity rules
76
78
 
77
- ## Rules
78
- - Defects first, sorted by severity descending. Omit any section that has zero entries.
79
- - Defect title describes the BUG ("Run-type dropdown does not filter"), never the scenario name.
80
- - Reproduce steps are concrete UI actions derived from the log: URL + clicks + inputs. Imperative, one short line each.
81
- - Evidence is the smallest factual observation from notes/steps that supports the claim — what was OBSERVED in the page (HTML, message, missing element). Never quote the test's \`result\` field as evidence; that is a tautology.
82
- - **Execution Issues** entries must explain what actually went wrong in concrete terms a human understands: "could not find a Submit button after navigation", "page reloaded before the assertion ran", "passed without ever seeing a confirmation message", "marked failed but the new item appears in the list", "modal trapped focus and tests could not click outside", "ARIA tree had no labelled controls". Avoid jargon like "locator failed" without context. Never write category prefixes ("execution:", "false-positive:") the section header already says it. No emoji on these entries.
83
- - Do NOT include a passed test in any section unless evidence proves its goal was not achieved. Intermediate retries or recovered errors in the log are not grounds for listing a passed test.
84
- - No editorialising, no restating the scenario verbatim, no closing summary.
79
+ - Headline: 2 sentences MAX. About the FEATURE, not the run. No counts, no "N tests", no "this session". Banned words: "exercised", "comprehensive", "notably", "this session", "module", "targeted", "covered creation".
80
+ - What works: feature name + test refs. NO parentheticals, NO caveats. If there's a caveat, the entry doesn't belong here.
81
+ - Defect title is the BUG ("Search returns non-matching results"), never the scenario name.
82
+ - Reproduce steps are imperative one-liners drawn from the log.
83
+ - Evidence is one short factual observation. Never quote the \`result\` field.
84
+ - Execution Issues: ONE line per test, ≤10 words, plain. Examples: "passed vacuously, no list assertion", "no file upload step in log", "dead loop on Save click". No prefixes, no nested explanation.
85
+ - Omit any empty section.
86
+ - Section order: Coverage What works Defects (severity desc) → UX issues → Execution Issues.
85
87
 
86
88
  ${customPrompt || ''}
87
89
  `;
@@ -101,7 +103,7 @@ export class SessionAnalyst implements Agent {
101
103
  { agentName: 'analyst' }
102
104
  );
103
105
 
104
- return (response?.text || '').trim();
106
+ return decodeEscapes((response?.text || '').trim());
105
107
  }
106
108
 
107
109
  writeReport(markdown: string): string {
@@ -131,3 +133,7 @@ export class SessionAnalyst implements Agent {
131
133
  `;
132
134
  }
133
135
  }
136
+
137
+ function decodeEscapes(text: string): string {
138
+ return text.replace(/\\u\{([0-9a-fA-F]+)\}/g, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16))).replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)));
139
+ }
package/src/ai/tester.ts CHANGED
@@ -64,6 +64,8 @@ export class Tester extends TaskAgent implements Agent {
64
64
  private pageStateHash: string | null = null;
65
65
  private pageActionResult: ActionResult | null = null;
66
66
  private hooksRunner: HooksRunner;
67
+ private seenUiMapUrls = new Set<string>();
68
+ private lastAnalyzedStateHash: string | null = null;
67
69
 
68
70
  constructor(explorer: Explorer, provider: Provider, researcher: Researcher, navigator: Navigator, agentTools?: any) {
69
71
  super();
@@ -104,7 +106,7 @@ export class Tester extends TaskAgent implements Agent {
104
106
  }
105
107
 
106
108
  private get progressCheckInterval(): number {
107
- return (this.explorer.getConfig().ai?.agents?.tester as any)?.progressCheckInterval ?? 5;
109
+ return (this.explorer.getConfig().ai?.agents?.tester as any)?.progressCheckInterval ?? 3;
108
110
  }
109
111
 
110
112
  getConversation(): Conversation | null {
@@ -123,6 +125,8 @@ export class Tester extends TaskAgent implements Agent {
123
125
  this.previousStateHash = null;
124
126
  this.pageStateHash = null;
125
127
  this.pageActionResult = null;
128
+ this.seenUiMapUrls.clear();
129
+ this.lastAnalyzedStateHash = null;
126
130
  this.explorer.getStateManager().clearHistory();
127
131
  this.resetFailureCount();
128
132
  this.pilot?.reset();
@@ -147,14 +151,20 @@ export class Tester extends TaskAgent implements Agent {
147
151
  const initialState = ActionResult.fromState(state);
148
152
 
149
153
  const conversation = this.provider.startConversation(this.getSystemMessage(), 'tester');
154
+ conversation.markLastMessageCacheable();
150
155
  this.currentConversation = conversation;
151
156
 
152
157
  const outputDir = ConfigParser.getInstance().getOutputDir();
153
158
  this.executionLogFile = join(outputDir, `tester_${task.sessionName}.md`);
154
159
  // Note: Markdown saving functionality removed from Conversation class
155
160
 
156
- const initialPrompt = await this.buildTestPrompt(task, initialState);
157
- conversation.addUserText(initialPrompt);
161
+ const scenarioBlock = this.buildScenarioBlock(task, initialState);
162
+ conversation.addUserText(scenarioBlock);
163
+ conversation.markLastMessageCacheable();
164
+ conversation.protectPrefix(conversation.messages.length);
165
+
166
+ const pageContext = await this.reinjectContextIfNeeded(1, initialState);
167
+ if (pageContext) conversation.addUserText(pageContext);
158
168
 
159
169
  return await Observability.run(
160
170
  `test: ${task.scenario}`,
@@ -177,6 +187,12 @@ export class Tester extends TaskAgent implements Agent {
177
187
  if (this.pilot) {
178
188
  try {
179
189
  const plan = await this.pilot.planTest(task, initialState);
190
+ if (task.hasFinished) {
191
+ offFailedRequest?.();
192
+ page?.off('pageerror', onPageError);
193
+ page?.off('console', onConsoleMessage);
194
+ return { success: task.isSuccessful };
195
+ }
180
196
  if (plan) {
181
197
  conversation.addUserText(`Pilot's test plan:\n${plan}\n\nFollow this plan while executing the test.`);
182
198
  }
@@ -200,13 +216,15 @@ export class Tester extends TaskAgent implements Agent {
200
216
  debugLog(`Navigating to ${task.startUrl}`);
201
217
  await this.explorer.visit(task.startUrl!);
202
218
 
203
- const currentUrl = this.explorer.getStateManager().getCurrentState()?.url || task.startUrl || '';
219
+ const startState = this.explorer.getStateManager().getCurrentState();
220
+ if (startState) task.addUrlNote(startState);
221
+ const currentUrl = startState?.url || task.startUrl || '';
204
222
  await this.hooksRunner.runBeforeHook('tester', currentUrl);
205
223
 
206
224
  const offStateChange = this.explorer.getStateManager().onStateChange((event: StateTransition) => {
207
225
  if (task.hasFinished) return;
208
226
  if (event.toState?.url === event.fromState?.url) return;
209
- task.addNote(`Navigated to ${event.toState?.url}`, TestResult.PASSED);
227
+ if (event.toState) task.addUrlNote(event.toState, event.fromState || undefined);
210
228
  task.states.push(event.toState);
211
229
  });
212
230
 
@@ -253,13 +271,13 @@ export class Tester extends TaskAgent implements Agent {
253
271
  `);
254
272
  }
255
273
 
256
- conversation.cleanupTag('page_aria', '...cleaned aria snapshot...', 2);
274
+ conversation.cleanupTag('page_aria', '...cleaned aria snapshot...', 1);
257
275
  conversation.cleanupTag('page_html', '...cleaned HTML snapshot...', 1);
258
276
  conversation.cleanupTag('experience', '...cleaned experience...', 1);
259
277
  conversation.cleanupTag('applied_experience', '...cleaned past experience...', 1);
260
278
  conversation.cleanupTag('page_ui_map', '...cleaned UI map...', 1);
261
279
  conversation.cleanupTag('page_ui_map_overlay', '...cleaned UI overlay...', 1);
262
- conversation.compactToolResults(3);
280
+ conversation.compactToolResults(2);
263
281
 
264
282
  if (iteration > 1) {
265
283
  const isNewPage = this.previousUrl !== null && this.previousUrl !== currentState.url;
@@ -270,16 +288,17 @@ export class Tester extends TaskAgent implements Agent {
270
288
  if (isNewPage && this.pilot) {
271
289
  const guidance = await this.pilot.reviewNewPage(task, currentState, conversation);
272
290
  if (guidance) nextStep += `\n\n${guidance}`;
273
- } else if ((iteration % this.progressCheckInterval === 0 || this.consecutiveFailures >= 3 || this.consecutiveEmptyResults >= 2) && this.pilot) {
291
+ } else if (this.shouldAnalyzeProgress(iteration, currentState) && this.pilot) {
274
292
  const guidance = await this.pilot.analyzeProgress(task, currentState, conversation);
275
293
  if (guidance) nextStep += `\n\n${guidance}`;
276
294
  this.consecutiveFailures = 0;
295
+ this.lastAnalyzedStateHash = currentState.hash;
277
296
  }
278
297
  conversation.addUserText(nextStep);
279
298
  }
280
299
 
281
300
  const result = await this.provider.invokeConversation(conversation, tools, {
282
- maxToolRoundtrips: 5,
301
+ maxToolRoundtrips: 3,
283
302
  toolChoice: 'required',
284
303
  stopWhen: () => task.hasFinished,
285
304
  });
@@ -368,10 +387,15 @@ export class Tester extends TaskAgent implements Agent {
368
387
  : undefined,
369
388
  catch: async ({ error, stop }) => {
370
389
  tag('error').log(`Test execution error: ${error}`);
390
+ const message = error instanceof Error ? error.message : String(error);
371
391
  if (!task.hasFinished) {
372
- task.addNote(`Execution error: ${error instanceof Error ? error.message : String(error)}`);
392
+ task.addNote(`Execution error: ${message}`);
373
393
  }
374
- stop();
394
+ if (error instanceof Error && error.name === 'AbortError') {
395
+ stop();
396
+ return;
397
+ }
398
+ conversation.addUserText(`Previous AI call failed: ${message}. Take a different approach on the next step.`);
375
399
  },
376
400
  }
377
401
  );
@@ -421,6 +445,14 @@ export class Tester extends TaskAgent implements Agent {
421
445
  };
422
446
  }
423
447
 
448
+ private shouldAnalyzeProgress(iteration: number, currentState: ActionResult): boolean {
449
+ if (this.consecutiveFailures >= 3) return true;
450
+ if (this.consecutiveEmptyResults >= 2) return true;
451
+ if (iteration % this.progressCheckInterval !== 0) return false;
452
+ if (this.lastAnalyzedStateHash === currentState.hash) return false;
453
+ return true;
454
+ }
455
+
424
456
  private async prepareInstructionsForNextStep(task: Test): Promise<string> {
425
457
  let outcomeStatus = dedent`
426
458
  <task>
@@ -511,17 +543,21 @@ export class Tester extends TaskAgent implements Agent {
511
543
  }
512
544
 
513
545
  if (isNewUrl) {
546
+ const alreadySeenUiMap = this.seenUiMapUrls.has(currentUrl);
514
547
  let research = '';
515
- try {
516
- research = await this.researcher.research(currentState);
517
- } catch (err) {
518
- if (!(err instanceof ErrorPageError)) throw err;
519
- tag('warning').log(`Research skipped: ${err.message}`);
548
+ if (!alreadySeenUiMap) {
549
+ try {
550
+ research = await this.researcher.research(currentState);
551
+ } catch (err) {
552
+ if (!(err instanceof ErrorPageError)) throw err;
553
+ tag('warning').log(`Research skipped: ${err.message}`);
554
+ }
520
555
  }
521
556
  this.pageStateHash = currentStateHash;
522
557
  this.pageActionResult = currentState;
523
558
  let uiMapSection = '';
524
559
  if (research) {
560
+ this.seenUiMapUrls.add(currentUrl);
525
561
  uiMapSection = dedent`
526
562
 
527
563
  Page UI Map
@@ -530,6 +566,8 @@ export class Tester extends TaskAgent implements Agent {
530
566
  ${research}
531
567
  </page_ui_map>
532
568
  `;
569
+ } else if (alreadySeenUiMap) {
570
+ uiMapSection = `\n\n<page_ui_map>UI map for ${currentUrl} was shown earlier in this session — refer to it above.</page_ui_map>`;
533
571
  }
534
572
 
535
573
  context += dedent`
@@ -740,9 +778,8 @@ export class Tester extends TaskAgent implements Agent {
740
778
  `;
741
779
  }
742
780
 
743
- private async buildTestPrompt(task: Test, actionResult: ActionResult): Promise<string> {
781
+ private buildScenarioBlock(task: Test, actionResult: ActionResult): string {
744
782
  const knowledge = this.getKnowledge(actionResult);
745
- const pageContext = await this.reinjectContextIfNeeded(1, actionResult);
746
783
 
747
784
  return dedent`
748
785
  <task>
@@ -770,8 +807,6 @@ export class Tester extends TaskAgent implements Agent {
770
807
  ${this.buildAvailableFiles()}
771
808
 
772
809
  ${knowledge}
773
-
774
- ${pageContext}
775
810
  `;
776
811
  }
777
812
 
package/src/ai/tools.ts CHANGED
@@ -510,7 +510,7 @@ export function createAgentTools({
510
510
  }
511
511
 
512
512
  return successToolResult('see', {
513
- analysis: analysisResult,
513
+ analysis: cap(analysisResult, ANALYSIS_OUTPUT_CAP),
514
514
  message: `Successfully analyzed screenshot for: ${request}`,
515
515
  suggestion: 'Visual confirmation is valid evidence for test results. Use record() to note the visual findings.',
516
516
  });
@@ -559,8 +559,8 @@ export function createAgentTools({
559
559
  url: currentState.url,
560
560
  title: currentState.title,
561
561
  suggestion: 'If not enough context received, call see() to visually identify elements in page contents',
562
- aria,
563
- html,
562
+ aria: cap(aria, ARIA_OUTPUT_CAP),
563
+ html: cap(html, HTML_OUTPUT_CAP),
564
564
  reminder: 'Context provided. Do not call context() again until you perform actions or suspect page changed.',
565
565
  });
566
566
  } catch (error) {
@@ -657,7 +657,7 @@ export function createAgentTools({
657
657
 
658
658
  return successToolResult('research', {
659
659
  analysis: researchResult,
660
- aria: ActionResult.fromState(currentState).getInteractiveARIA(),
660
+ aria: cap(ActionResult.fromState(currentState).getInteractiveARIA(), ARIA_OUTPUT_CAP),
661
661
  message: `Successfully researched page: ${currentState.url}.`,
662
662
  suggestion: dedent`
663
663
  You received comprehensive UI map report. Use it to understand the page structure and navigate to the elements.
@@ -1001,6 +1001,16 @@ export function createAgentTools({
1001
1001
 
1002
1002
  const PAGE_DIFF_SUGGESTION = 'Analyze page diff. htmlParts shows what changed and WHERE — each part has a container selector. Use the container as context when clicking elements from the diff.';
1003
1003
 
1004
+ const ARIA_OUTPUT_CAP = 4000;
1005
+ const HTML_OUTPUT_CAP = 6000;
1006
+ const ANALYSIS_OUTPUT_CAP = 2000;
1007
+
1008
+ function cap(text: string | undefined | null, max: number): string {
1009
+ if (!text) return '';
1010
+ if (text.length <= max) return text;
1011
+ return `${text.slice(0, max)}\n[...truncated; ${text.length - max} chars omitted...]`;
1012
+ }
1013
+
1004
1014
  function transformContainsCommand(command: string): string {
1005
1015
  if (!command.includes(':contains(')) return command;
1006
1016
 
@@ -1044,8 +1054,12 @@ function successToolResult(action: string, data?: Record<string, any>, source?:
1044
1054
  if (data?.pageDiff) {
1045
1055
  let suggestion = PAGE_DIFF_SUGGESTION;
1046
1056
  const ariaChanges = data.pageDiff.ariaChanges || '';
1057
+ const urlChanged = data.pageDiff.urlChanged === true;
1058
+ const hasHtmlParts = Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
1047
1059
  if (countAriaChanges(ariaChanges) >= 50) {
1048
1060
  suggestion = `MAJOR PAGE CHANGE. Page entered a different mode. Check htmlParts and iframes in pageDiff before next action. ${suggestion}`;
1061
+ } else if (!urlChanged && !ariaChanges && !hasHtmlParts) {
1062
+ suggestion = 'Action ran without error but produced no observable change (URL, ARIA and HTML all unchanged). The locator likely matched a non-interactive ancestor or an element outside the intended control. Re-locate via xpathCheck() or verify with see() before treating this as success.';
1049
1063
  } else if (ariaChanges.includes('heading') && ariaChanges.includes('added')) {
1050
1064
  suggestion += ' WARNING: A new panel or modal may have appeared. If this was not the intended action, close it and try a different element.';
1051
1065
  }