explorbot 0.1.25 → 0.1.27

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 (54) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/ai/historian/codeceptjs.js +1 -1
  3. package/dist/src/ai/historian/experience.js +1 -1
  4. package/dist/src/ai/historian/playwright.js +1 -1
  5. package/dist/src/ai/historian/screencast.js +3 -3
  6. package/dist/src/ai/historian.js +1 -1
  7. package/dist/src/ai/navigator.js +4 -4
  8. package/dist/src/ai/pilot.js +8 -5
  9. package/dist/src/ai/planner.js +2 -4
  10. package/dist/src/ai/provider.js +5 -3
  11. package/dist/src/ai/researcher/cache.js +8 -0
  12. package/dist/src/ai/researcher/deep-analysis.js +146 -61
  13. package/dist/src/ai/researcher/locators.js +1 -1
  14. package/dist/src/ai/researcher.js +4 -4
  15. package/dist/src/ai/session-analyst.js +4 -1
  16. package/dist/src/ai/task-agent.js +2 -2
  17. package/dist/src/commands/context-aria-command.js +1 -1
  18. package/dist/src/commands/explore-command.js +67 -25
  19. package/dist/src/commands/test-command.js +2 -1
  20. package/dist/src/components/App.js +1 -33
  21. package/dist/src/components/LogPane.js +9 -3
  22. package/dist/src/experience-tracker.js +2 -2
  23. package/dist/src/explorbot.js +1 -1
  24. package/dist/src/reporter.js +24 -5
  25. package/dist/src/utils/log-filters.js +27 -0
  26. package/dist/src/utils/logger.js +28 -1
  27. package/dist/src/utils/next-steps.js +1 -7
  28. package/package.json +1 -1
  29. package/src/ai/historian/codeceptjs.ts +1 -1
  30. package/src/ai/historian/experience.ts +1 -1
  31. package/src/ai/historian/playwright.ts +1 -1
  32. package/src/ai/historian/screencast.ts +3 -3
  33. package/src/ai/historian.ts +1 -1
  34. package/src/ai/navigator.ts +4 -4
  35. package/src/ai/pilot.ts +8 -5
  36. package/src/ai/planner.ts +1 -3
  37. package/src/ai/provider.ts +5 -3
  38. package/src/ai/researcher/cache.ts +7 -0
  39. package/src/ai/researcher/deep-analysis.ts +166 -68
  40. package/src/ai/researcher/locators.ts +1 -1
  41. package/src/ai/researcher.ts +4 -4
  42. package/src/ai/session-analyst.ts +4 -1
  43. package/src/ai/task-agent.ts +2 -2
  44. package/src/commands/context-aria-command.ts +1 -1
  45. package/src/commands/explore-command.ts +68 -23
  46. package/src/commands/test-command.ts +2 -1
  47. package/src/components/App.tsx +0 -33
  48. package/src/components/LogPane.tsx +18 -3
  49. package/src/experience-tracker.ts +2 -2
  50. package/src/explorbot.ts +1 -1
  51. package/src/reporter.ts +24 -6
  52. package/src/utils/log-filters.ts +26 -0
  53. package/src/utils/logger.ts +24 -2
  54. package/src/utils/next-steps.ts +1 -6
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -82,7 +82,7 @@ export function WithCodeceptJS(Base) {
82
82
  const filePath = join(testsDir, safeFilename(plan.title, '.js'));
83
83
  writeFileSync(filePath, lines.join('\n'));
84
84
  this.savedFiles.add(filePath);
85
- tag('substep').log(`Saved plan tests to: ${relativeToCwd(filePath)}`);
85
+ tag('operation').log(`Saved plan tests to: ${relativeToCwd(filePath)}`);
86
86
  return filePath;
87
87
  }
88
88
  getKnowledgeLines(url, indent = ' ') {
@@ -30,7 +30,7 @@ export function WithExperience(Base) {
30
30
  await this.reportSession(task, steps);
31
31
  }
32
32
  await this.stopScreencast();
33
- tag('substep').log(`Historian saved session for: ${task.description}`);
33
+ tag('operation').log(`Historian saved session for: ${task.description}`);
34
34
  }
35
35
  async reportSession(test, steps) {
36
36
  if (!this.reporter)
@@ -119,7 +119,7 @@ export function WithPlaywright(Base) {
119
119
  const filePath = join(testsDir, safeFilename(plan.title, '.spec.ts'));
120
120
  writeFileSync(filePath, lines.join('\n'));
121
121
  this.savedFiles.add(filePath);
122
- tag('substep').log(`Saved plan tests to: ${relativeToCwd(filePath)}`);
122
+ tag('operation').log(`Saved plan tests to: ${relativeToCwd(filePath)}`);
123
123
  return filePath;
124
124
  }
125
125
  getPlaywrightKnowledgeLines(url, indent = ' ') {
@@ -76,7 +76,7 @@ export function WithScreencast(Base) {
76
76
  this.screencastLastChapter = null;
77
77
  }
78
78
  catch (err) {
79
- tag('substep').log(`Screencast start failed: ${err.message}`);
79
+ tag('operation').log(`Screencast start failed: ${err.message}`);
80
80
  }
81
81
  }
82
82
  async emitChapter(_step) {
@@ -104,7 +104,7 @@ export function WithScreencast(Base) {
104
104
  await this.screencastPage.screencast.stop();
105
105
  }
106
106
  catch (err) {
107
- tag('substep').log(`Screencast stop failed: ${err.message}`);
107
+ tag('operation').log(`Screencast stop failed: ${err.message}`);
108
108
  }
109
109
  this.screencastActive = false;
110
110
  this.screencastPage = null;
@@ -114,7 +114,7 @@ export function WithScreencast(Base) {
114
114
  if (path) {
115
115
  this.savedFiles.add(path);
116
116
  task?.addArtifact?.(path);
117
- tag('substep').log(`Saved screencast: ${relativeToCwd(path)}`);
117
+ tag('operation').log(`Saved screencast: ${relativeToCwd(path)}`);
118
118
  }
119
119
  }
120
120
  };
@@ -38,6 +38,6 @@ export class Historian extends HistorianBase {
38
38
  }
39
39
  writeFileSync(filePath, content);
40
40
  this.savedFiles.add(filePath);
41
- tag('substep').log(`Updated test file with healed steps: ${relativeToCwd(filePath)}`);
41
+ tag('operation').log(`Updated test file with healed steps: ${relativeToCwd(filePath)}`);
42
42
  }
43
43
  }
@@ -182,7 +182,7 @@ class Navigator {
182
182
  if (!actionResult.isInsideIframe) {
183
183
  const successful = this.experienceTracker.getSuccessfulExperience(actionResult);
184
184
  if (successful.length > 0) {
185
- tag('substep').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${actionResult.url}`);
185
+ tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${actionResult.url}`);
186
186
  experience = `<experience>\nPast successful recipes recorded from prior runs for this page. Prefer these solutions first if they match the goal.\n\n${successful.join('\n\n')}\n</experience>`;
187
187
  }
188
188
  }
@@ -276,7 +276,7 @@ class Navigator {
276
276
  stop();
277
277
  return;
278
278
  }
279
- tag('substep').log('Feeding failures back to AI for a new batch...');
279
+ tag('operation').log('Feeding failures back to AI for a new batch...');
280
280
  let contextMsg = 'Previous solutions did not work. Analyze the failures and try DIFFERENT strategies (not syntactic variants of the same locator).\n\n';
281
281
  if (batchFailures.length > 0) {
282
282
  const lines = batchFailures
@@ -573,7 +573,7 @@ class Navigator {
573
573
  debugLog('Verification message:', message);
574
574
  const cachedVerification = actionResult.getVerification(message);
575
575
  if (cachedVerification !== null) {
576
- tag('substep').log(`Reusing cached verification: ${cachedVerification ? 'PASS' : 'FAIL'}`);
576
+ tag('operation').log(`Reusing cached verification: ${cachedVerification ? 'PASS' : 'FAIL'}`);
577
577
  return { verified: cachedVerification, successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
578
578
  }
579
579
  let knowledge = '';
@@ -591,7 +591,7 @@ class Navigator {
591
591
  const toc = this.experienceTracker.getExperienceTableOfContents(actionResult);
592
592
  if (toc.length > 0) {
593
593
  const totalSections = toc.reduce((sum, entry) => sum + entry.sections.length, 0);
594
- tag('substep').log(`Found ${toc.length} experience ${pluralize(toc.length, 'file')} (${totalSections} sections) for: ${actionResult.url}`);
594
+ tag('operation').log(`Found ${toc.length} experience ${pluralize(toc.length, 'file')} (${totalSections} sections) for: ${actionResult.url}`);
595
595
  experience = renderExperienceToc(toc);
596
596
  }
597
597
  }
@@ -84,7 +84,7 @@ export class Pilot {
84
84
  }
85
85
  const schema = z.object({
86
86
  decision: z.enum(['pass', 'fail', 'continue', 'skipped']).describe('pass = test succeeded, fail = test failed, continue = tester should keep going, skipped = scenario is irrelevant OR systematic execution failures prevented testing'),
87
- reason: z.string().describe('What happened and why (1-2 sentences). Do NOT repeat the decision status (e.g. "scenario goal achieved/not achieved") — just explain the evidence. For continue: explain why rejected and suggest alternatives.'),
87
+ reason: z.string().describe('Concise user-facing reason, maximum 1 short sentence and 120 characters. Do NOT repeat the decision status; explain only the evidence. For continue: explain why rejected and suggest alternatives.'),
88
88
  guidance: z.string().nullable().describe('Required for "continue": specific actionable instruction for the tester — what exactly to verify, retry differently, or complete next. Be concrete.'),
89
89
  requestVerification: z
90
90
  .string()
@@ -149,7 +149,7 @@ export class Pilot {
149
149
  this.explorer.getPlaywrightRecorder().recordVerification(verifyResult.assertionSteps);
150
150
  }
151
151
  }
152
- tag('info').log(`Pilot: ${result.decision} ${result.reason}`);
152
+ tag('info').log(`Pilot: ${result.decision} - ${result.reason}`);
153
153
  task.summary = result.reason;
154
154
  const verdictState = screenshotState || currentState;
155
155
  if (result.decision === 'pass') {
@@ -187,7 +187,7 @@ export class Pilot {
187
187
  const notes = task.notesToString() || 'No notes recorded.';
188
188
  const schema = z.object({
189
189
  decision: z.enum(['allow', 'fail', 'continue', 'skipped']).describe('allow = reset proceeds, fail = test failed (stop looping), continue = veto reset, tester should act on current page instead, skipped = scenario is irrelevant or cannot be executed'),
190
- reason: z.string().describe('What evidence justifies this decision (1-2 sentences). Do not restate the decision.'),
190
+ reason: z.string().describe('Concise evidence-only reason, maximum 1 short sentence and 120 characters. Do not restate the decision.'),
191
191
  guidance: z.string().nullable().describe('Required for "continue": concrete instruction for what the tester should do instead of resetting (e.g. which tool to call, what to verify).'),
192
192
  });
193
193
  const userContent = dedent `
@@ -343,8 +343,9 @@ export class Pilot {
343
343
  - "continue": tester hasn't completed the goal; provide concrete guidance (which tool, what to check).
344
344
  If a verify() asserted a state that was ALREADY TRUE before the test, it proves nothing — reject.
345
345
 
346
- reason field: do NOT restate the decision ("scenario goal achieved/not achieved"). State what happened —
347
- what was verified, what failed, what evidence was found.
346
+ reason field: one short sentence, maximum 120 characters. Do NOT restate the decision
347
+ ("scenario goal achieved/not achieved"). State what happened: what was verified, what failed,
348
+ or what evidence was found.
348
349
  `;
349
350
  }
350
351
  async planTest(task, currentState) {
@@ -919,6 +920,8 @@ export class Pilot {
919
920
  Response format:
920
921
  PROGRESS: <1 sentence assessment>
921
922
  NEXT: <specific actionable instruction for Tester>
923
+
924
+ Keep user-facing reasons concise: one short sentence, maximum 120 characters, evidence only, no repeated verdict wording.
922
925
  `;
923
926
  }
924
927
  }
@@ -14,7 +14,7 @@ import { mdq } from '../utils/markdown-query.js';
14
14
  import { planToCompactAiContext } from "../utils/test-plan-markdown.js";
15
15
  import { Conversation } from "./conversation.js";
16
16
  import { WithSessionDedup } from "./planner/session-dedup.js";
17
- import { getActiveStyle, getStyles } from "./planner/styles.js";
17
+ import { getActiveStyle } from "./planner/styles.js";
18
18
  import { WithSubPages, getPlannedByStateHash, getRegisteredPlan, registerPlan } from "./planner/subpages.js";
19
19
  import { POSSIBLE_SECTIONS, Researcher } from "./researcher.js";
20
20
  import { findSimilarStateHash } from "./researcher/cache.js";
@@ -28,7 +28,7 @@ const TasksSchema = z.object({
28
28
  scenario: z.string().describe('A single sentence describing what to test'),
29
29
  priority: z.enum(['critical', 'important', 'high', 'normal', 'low']).describe('Priority of the task based on business importance'),
30
30
  startUrl: z.string().nullable().describe('Start URL for the test if different from plan URL (only for tests on visited subpages)'),
31
- steps: z.array(z.string()).describe('List of steps to perform for this scenario. Each step should be a specific action (e.g., "Click on Login button", "Enter username in email field", "Submit the form"). Keep steps atomic and actionable.'),
31
+ steps: z.array(z.string()).describe('List of steps to perform for this scenario. Each step should be a specific action (e.g., "Open the form", "Enter required data", "Submit the form"). Keep steps atomic and actionable.'),
32
32
  expectedOutcomes: z
33
33
  .array(z.string())
34
34
  .describe('List of expected outcomes that can be verified. Each outcome should be simple, specific, and easy to check (e.g., "Success message appears", "URL changes to /dashboard", "Form field shows error"). Keep outcomes atomic - do not combine multiple checks into one.'),
@@ -199,9 +199,7 @@ export class Planner extends PlannerBase {
199
199
  tag('multiline').log(summary);
200
200
  }
201
201
  }
202
- const availableStyles = Object.keys(getStyles()).join(', ');
203
202
  tag('success').log(`Planning complete! ${this.currentPlan.tests.length} tests in plan: ${this.currentPlan.title}`);
204
- tag('info').log(`Planning style: ${this.lastStyleName} (available: ${availableStyles})`);
205
203
  if (state.url)
206
204
  registerPlan(state.url, this.currentPlan, feature, state.hash);
207
205
  this.registerPlanInSession(this.currentPlan);
@@ -259,8 +259,11 @@ export class Provider {
259
259
  }
260
260
  throw new ContextLengthError(error.message || error.toString());
261
261
  }
262
- tag('error').log(error.message || error.toString());
263
- throw new AiError(error.message || error.toString());
262
+ const message = error.message || error.toString();
263
+ if (message !== 'No response text from AI') {
264
+ tag('error').log(message);
265
+ }
266
+ throw new AiError(message);
264
267
  }
265
268
  }
266
269
  async generateWithTools(messages, model, tools, options = {}) {
@@ -333,7 +336,6 @@ export class Provider {
333
336
  catch (error) {
334
337
  clearActivity();
335
338
  if (error?.message?.includes('Tool choice is required')) {
336
- tag('warning').log('Model completed without calling a tool, returning empty result');
337
339
  return { text: '', toolCalls: [], toolResults: [], response: { messages: [] }, usage: null };
338
340
  }
339
341
  if (error?.name === 'AbortError')
@@ -46,6 +46,14 @@ export function getCachedResearch(hash) {
46
46
  memoryCacheTimestamps[hash] = now;
47
47
  return cached;
48
48
  }
49
+ export function getPreviousResearch(hash) {
50
+ if (!hash)
51
+ return '';
52
+ const researchFile = outputPath('research', `${hash}.md`);
53
+ if (!existsSync(researchFile))
54
+ return '';
55
+ return readFileSync(researchFile, 'utf8');
56
+ }
49
57
  export function saveResearch(hash, text, combinedHtml) {
50
58
  const researchDir = outputPath('research');
51
59
  const researchFile = join(researchDir, `${hash}.md`);
@@ -2,9 +2,10 @@ import dedent from 'dedent';
2
2
  import { ActionResult } from '../../action-result.js';
3
3
  import { executionController } from "../../execution-controller.js";
4
4
  import { detectFocusArea, diffAriaSnapshots } from "../../utils/aria.js";
5
+ import { extractCodeBlocks } from "../../utils/code-extractor.js";
5
6
  import { tag } from '../../utils/logger.js';
6
7
  import { mdq } from "../../utils/markdown-query.js";
7
- import { getCachedResearch, saveResearch } from "./cache.js";
8
+ import { getCachedResearch, getPreviousResearch, saveResearch } from "./cache.js";
8
9
  import { debugLog } from "./mixin.js";
9
10
  import { parseResearchSections } from "./parser.js";
10
11
  const DEFAULT_MAX_EXPANDABLE_CLICKS = 10;
@@ -13,13 +14,26 @@ export function WithDeepAnalysis(Base) {
13
14
  async performDeepAnalysis(state, result) {
14
15
  tag('info').log('Starting deep analysis of expandable elements');
15
16
  await this.navigateTo(state.fullUrl || state.url);
16
- let expandables = await this._discoverExpandables(result.text);
17
- if (expandables.length === 0) {
18
- tag('info').log('No expandable elements identified by AI');
19
- return;
20
- }
21
- tag('substep').log(`Identified ${expandables.length} expandable elements`);
22
17
  const maxClicks = this.explorer.getConfig().ai?.agents?.researcher?.maxExpandableClicks ?? DEFAULT_MAX_EXPANDABLE_CLICKS;
18
+ const expandedSections = [];
19
+ const navigationLinks = [];
20
+ let verifiedCodes = [];
21
+ let missing = [];
22
+ const previousSections = this._loadPreviousExtendedSections(state.hash || '');
23
+ if (previousSections.length > 0) {
24
+ tag('substep').log(`Replaying ${previousSections.length} previously discovered sections`);
25
+ const replay = await this._replayPreviousSections(state, previousSections, maxClicks);
26
+ expandedSections.push(...replay.verified);
27
+ verifiedCodes = replay.verifiedCodes;
28
+ missing = replay.missing;
29
+ tag('info').log(`Reused ${replay.verified.length}/${previousSections.length} previous sections, ${missing.length} to re-discover`);
30
+ if (missing.length === 0 && replay.verified.length >= maxClicks) {
31
+ tag('info').log('Page appears unchanged, reusing previous sections and skipping discovery');
32
+ this._appendExtendedResearch(result, expandedSections, navigationLinks);
33
+ return;
34
+ }
35
+ }
36
+ let expandables = await this._discoverExpandables(result.text, missing, verifiedCodes);
23
37
  if (expandables.length > maxClicks) {
24
38
  expandables = await this._selectExpandables(expandables, state.fullUrl || state.url, maxClicks);
25
39
  tag('substep').log(`Selected ${expandables.length} expandables to click (max: ${maxClicks})`);
@@ -29,9 +43,11 @@ export function WithDeepAnalysis(Base) {
29
43
  commands: this._buildClickCommands(el),
30
44
  description: el.name,
31
45
  }))
32
- .filter((el) => el.commands.length > 0);
46
+ .filter((el) => el.commands.length > 0)
47
+ .filter((el) => !el.commands.some((cmd) => verifiedCodes.includes(cmd)));
33
48
  if (elements.length === 0) {
34
- tag('info').log('No expandables with valid locators');
49
+ tag('info').log('No new expandable elements to click');
50
+ this._appendExtendedResearch(result, expandedSections, navigationLinks);
35
51
  return;
36
52
  }
37
53
  const expandableRows = elements.map((el) => `| ${el.description} | \`${el.commands[0]}\` |`).join('\n');
@@ -39,21 +55,9 @@ export function WithDeepAnalysis(Base) {
39
55
  for (const el of elements)
40
56
  debugLog(`Expandable: ${el.description} → ${el.commands[0]}`);
41
57
  tag('substep').log(`Clicking ${elements.length} expandable elements`);
42
- const expandedSections = [];
43
- const navigationLinks = [];
44
58
  await this._clickExpandableElements(elements, state, expandedSections, navigationLinks);
45
59
  tag('info').log(`Deep analysis complete. Sections: ${expandedSections.length}, navigation links: ${navigationLinks.length}`);
46
- const dedupedSections = this._deduplicateExpandedSections(expandedSections);
47
- if (dedupedSections.length !== expandedSections.length) {
48
- tag('substep').log(`Deduplicated ${expandedSections.length} → ${dedupedSections.length} extended sections`);
49
- }
50
- if (dedupedSections.length > 0) {
51
- result.text += `\n\n# Extended Research\n\n${dedupedSections.join('\n\n---\n\n')}`;
52
- }
53
- if (navigationLinks.length > 0) {
54
- const links = navigationLinks.map((l) => `- \`${l.code}\` opens ${l.url}`).join('\n');
55
- result.text += `\n\n## Navigation Links\n\n${links}`;
56
- }
60
+ this._appendExtendedResearch(result, expandedSections, navigationLinks);
57
61
  }
58
62
  async researchOverlay(current, previous, pageStateHash) {
59
63
  const focusArea = detectFocusArea(current.ariaSnapshot);
@@ -97,7 +101,66 @@ export function WithDeepAnalysis(Base) {
97
101
  tag('substep').log(`Overlay research appended: ${focusArea.name}`);
98
102
  return sectionMarkdown;
99
103
  }
100
- async _discoverExpandables(researchText) {
104
+ _loadPreviousExtendedSections(hash) {
105
+ if (!hash)
106
+ return [];
107
+ const previous = getPreviousResearch(hash);
108
+ if (!previous)
109
+ return [];
110
+ const sections = [];
111
+ for (const section of parseResearchSections(previous)) {
112
+ if (!section.isExtended)
113
+ continue;
114
+ const code = extractCodeBlocks(section.rawMarkdown)[0];
115
+ if (!code)
116
+ continue;
117
+ sections.push({ name: section.name, code });
118
+ }
119
+ return sections;
120
+ }
121
+ async _replayPreviousSections(state, prevSections, maxClicks) {
122
+ const originalAria = state.ariaSnapshot || '';
123
+ const verified = [];
124
+ const verifiedCodes = [];
125
+ const missing = [];
126
+ for (const section of prevSections.slice(0, maxClicks)) {
127
+ if (executionController.isInterrupted())
128
+ break;
129
+ let outcome;
130
+ try {
131
+ outcome = await this._executeAndAnalyze([section.code], section.name, state, originalAria, this._summarizeExpanded(verified));
132
+ }
133
+ catch (err) {
134
+ tag('warning').log(`Replay failed for "${section.name}": ${err instanceof Error ? err.message : err}`);
135
+ await this._restorePageState(state.url, originalAria).catch(() => { });
136
+ missing.push(section);
137
+ continue;
138
+ }
139
+ if (outcome.status === 'revealed') {
140
+ verified.push(outcome.sectionMarkdown);
141
+ verifiedCodes.push(section.code);
142
+ debugLog(`Replayed and verified section: ${section.name}`);
143
+ continue;
144
+ }
145
+ debugLog(`Could not replay previous section: ${section.name}`);
146
+ missing.push(section);
147
+ }
148
+ return { verified, verifiedCodes, missing };
149
+ }
150
+ _appendExtendedResearch(result, expandedSections, navigationLinks) {
151
+ const dedupedSections = this._deduplicateExpandedSections(expandedSections);
152
+ if (dedupedSections.length !== expandedSections.length) {
153
+ tag('substep').log(`Deduplicated ${expandedSections.length} → ${dedupedSections.length} extended sections`);
154
+ }
155
+ if (dedupedSections.length > 0) {
156
+ result.text += `\n\n# Extended Research\n\n${dedupedSections.join('\n\n---\n\n')}`;
157
+ }
158
+ if (navigationLinks.length > 0) {
159
+ const links = navigationLinks.map((l) => `- \`${l.code}\` opens ${l.url}`).join('\n');
160
+ result.text += `\n\n## Navigation Links\n\n${links}`;
161
+ }
162
+ }
163
+ async _discoverExpandables(researchText, missing = [], verifiedCodes = []) {
101
164
  const allElements = new Map();
102
165
  for (const section of parseResearchSections(researchText)) {
103
166
  for (const el of section.elements) {
@@ -108,6 +171,15 @@ export function WithDeepAnalysis(Base) {
108
171
  if (allElements.size === 0)
109
172
  return [];
110
173
  const eidxList = [...allElements.keys()].join(', ');
174
+ let missingHint = '';
175
+ if (missing.length > 0) {
176
+ const list = missing.map((s) => `- "${s.name}" (previously revealed via ${s.code})`).join('\n');
177
+ missingHint = dedent `
178
+
179
+ These sections were present on a previous visit but their trigger could not be replayed now — the element may have moved or been renamed. Prioritize finding the element that now reveals each:
180
+ ${list}
181
+ `;
182
+ }
111
183
  const textPrompt = dedent `
112
184
  From this UI research, identify elements that could reveal hidden UI when clicked
113
185
  (dropdown menus, popups, expandable panels, accordion sections, overflow menus, tab switches).
@@ -115,6 +187,7 @@ export function WithDeepAnalysis(Base) {
115
187
  Available eidx refs: ${eidxList}
116
188
 
117
189
  ${researchText}
190
+ ${missingHint}
118
191
 
119
192
  Rules:
120
193
  - Only pick elements that HIDE content until clicked (menus, dropdowns, accordions, tabs)
@@ -136,6 +209,7 @@ export function WithDeepAnalysis(Base) {
136
209
 
137
210
  Look for: overflow/ellipsis menus, chevron dropdowns, hamburger menus,
138
211
  gear/settings buttons, accordion toggles, tab switches, filter buttons.
212
+ ${missingHint}
139
213
 
140
214
  Rules:
141
215
  - For repeated icons (same icon on every list row), pick only the FIRST one
@@ -258,48 +332,15 @@ export function WithDeepAnalysis(Base) {
258
332
  await this._restorePageState(state.url, originalAria);
259
333
  }
260
334
  }
261
- let clickCode = null;
262
- const action = this.explorer.createAction();
263
- for (const cmd of el.commands) {
264
- if (await action.attempt(cmd, undefined, false)) {
265
- clickCode = cmd;
266
- break;
267
- }
268
- }
269
- if (!clickCode) {
270
- debugLog(`Click failed: ${el.description.slice(0, 80)}`);
335
+ const outcome = await this._executeAndAnalyze(el.commands, el.description, state, originalAria, this._summarizeExpanded(expandedSections));
336
+ if (outcome.status === 'navigated') {
337
+ navigationLinks.push({ code: outcome.code, url: outcome.url });
271
338
  continue;
272
339
  }
273
- await new Promise((r) => setTimeout(r, 500));
274
- let diff;
275
- try {
276
- await this.explorer.createAction().capturePageState();
277
- const currAR = ActionResult.fromState(this.stateManager.getCurrentState());
278
- diff = await currAR.diff(previousState);
279
- await diff.calculate();
280
- }
281
- catch (err) {
282
- tag('warning').log(`State capture failed after click: ${err instanceof Error ? err.message : err}`);
283
- await this._restorePageState(state.url, originalAria);
284
- continue;
285
- }
286
- if (diff.urlHasChanged()) {
287
- debugLog(`Click navigated to ${this.stateManager.getCurrentState()?.url}`);
288
- navigationLinks.push({ code: clickCode, url: this.stateManager.getCurrentState()?.url || '' });
289
- await this.navigateTo(state.url);
290
- continue;
291
- }
292
- const clickHtmlSize = diff.htmlParts.reduce((sum, p) => sum + p.subtree.length, 0);
293
- if (!diff.ariaChanged && clickHtmlSize <= 150) {
294
- debugLog(`No changes from: ${el.description.slice(0, 80)}`);
295
- continue;
296
- }
297
- const sectionMarkdown = await this._analyzeExpandedAction(clickCode, el.description, diff, this._summarizeExpanded(expandedSections));
298
- if (sectionMarkdown) {
299
- expandedSections.push(sectionMarkdown);
340
+ if (outcome.status === 'revealed') {
341
+ expandedSections.push(outcome.sectionMarkdown);
300
342
  debugLog(`Captured section from: ${el.description.slice(0, 80)}`);
301
343
  }
302
- await this._restorePageState(state.url, originalAria);
303
344
  }
304
345
  catch (err) {
305
346
  tag('warning').log(`Expandable click failed for "${el.description.slice(0, 80)}": ${err instanceof Error ? err.message : err}`);
@@ -310,6 +351,50 @@ export function WithDeepAnalysis(Base) {
310
351
  }
311
352
  }
312
353
  }
354
+ async _executeAndAnalyze(commands, description, state, originalAria, alreadyExpanded) {
355
+ const previousState = ActionResult.fromState(this.stateManager.getCurrentState());
356
+ let clickCode = null;
357
+ const action = this.explorer.createAction();
358
+ for (const cmd of commands) {
359
+ if (await action.attempt(cmd, undefined, false)) {
360
+ clickCode = cmd;
361
+ break;
362
+ }
363
+ }
364
+ if (!clickCode) {
365
+ debugLog(`Click failed: ${description.slice(0, 80)}`);
366
+ return { status: 'failed' };
367
+ }
368
+ await new Promise((r) => setTimeout(r, 500));
369
+ let diff;
370
+ try {
371
+ await this.explorer.createAction().capturePageState();
372
+ const currAR = ActionResult.fromState(this.stateManager.getCurrentState());
373
+ diff = await currAR.diff(previousState);
374
+ await diff.calculate();
375
+ }
376
+ catch (err) {
377
+ tag('warning').log(`State capture failed after click: ${err instanceof Error ? err.message : err}`);
378
+ await this._restorePageState(state.url, originalAria);
379
+ return { status: 'failed' };
380
+ }
381
+ if (diff.urlHasChanged()) {
382
+ const url = this.stateManager.getCurrentState()?.url || '';
383
+ debugLog(`Click navigated to ${url}`);
384
+ await this.navigateTo(state.url);
385
+ return { status: 'navigated', code: clickCode, url };
386
+ }
387
+ const clickHtmlSize = diff.htmlParts.reduce((sum, p) => sum + p.subtree.length, 0);
388
+ if (!diff.ariaChanged && clickHtmlSize <= 150) {
389
+ debugLog(`No changes from: ${description.slice(0, 80)}`);
390
+ return { status: 'none', code: clickCode };
391
+ }
392
+ const sectionMarkdown = await this._analyzeExpandedAction(clickCode, description, diff, alreadyExpanded);
393
+ await this._restorePageState(state.url, originalAria);
394
+ if (!sectionMarkdown)
395
+ return { status: 'none', code: clickCode };
396
+ return { status: 'revealed', code: clickCode, sectionMarkdown };
397
+ }
313
398
  async _restorePageState(url, originalAria) {
314
399
  try {
315
400
  await this.cancelInUi();
@@ -70,7 +70,7 @@ export function WithLocators(Base) {
70
70
  broken++;
71
71
  }
72
72
  }
73
- tag('substep').log(`Validated ${locators.length} locators: ${locators.length - broken} valid, ${broken} broken`);
73
+ tag('operation').log(`Validated ${locators.length} locators: ${locators.length - broken} valid, ${broken} broken`);
74
74
  }
75
75
  async fixBrokenSections(result, conversation) {
76
76
  const broken = result.locators.filter((l) => l.valid === false);
@@ -114,7 +114,7 @@ export class Researcher extends ResearcherBase {
114
114
  if (!deep && !force) {
115
115
  const similar = await findSimilarResearch(combinedHtml);
116
116
  if (similar) {
117
- tag('substep').log('Similar research found, reusing cached result');
117
+ tag('operation').log('Similar research found, reusing cached result');
118
118
  if (stateHash)
119
119
  saveResearch(stateHash, similar, combinedHtml);
120
120
  tag('multiline').log(formatResearchSummary(similar));
@@ -264,10 +264,10 @@ export class Researcher extends ResearcherBase {
264
264
  tag('multiline').log(formatResearchSummary(result.text, { visionUsed: this.hasScreenshotToAnalyze }));
265
265
  tag('success').log('Research complete');
266
266
  if (researchFile)
267
- tag('substep').log(`Research file saved to: ${researchFile}`);
267
+ tag('operation').log(`Research file saved to: ${researchFile}`);
268
268
  if (this.actionResult?.screenshotFile) {
269
269
  const screenshotPath = outputPath('states', this.actionResult.screenshotFile);
270
- tag('substep').log(`UI screenshot: file://${screenshotPath}`);
270
+ tag('operation').log(`UI screenshot: file://${screenshotPath}`);
271
271
  }
272
272
  await this.hooksRunner.runAfterHook('researcher', state.url);
273
273
  return result.text;
@@ -402,7 +402,7 @@ export class Researcher extends ResearcherBase {
402
402
  .map((k) => k.content)
403
403
  .filter((k) => !!k)
404
404
  .join('\n\n');
405
- tag('substep').log(`Found ${knowledgeFiles.length} relevant knowledge ${pluralize(knowledgeFiles.length, 'file')} for: ${this.actionResult.url}`);
405
+ tag('operation').log(`Found ${knowledgeFiles.length} relevant knowledge ${pluralize(knowledgeFiles.length, 'file')} for: ${this.actionResult.url}`);
406
406
  knowledge = `
407
407
  <hint>
408
408
  Here is relevant knowledge for this page:
@@ -34,6 +34,9 @@ export class SessionAnalyst {
34
34
 
35
35
  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.
36
36
 
37
+ The action log is more authoritative than the scenario title. If the actual submitted data, page state, or action sequence does not match the scenario title, classify it as Execution issue and do not list that scenario under What works. Do NOT infer a product Defect or UX issue from behavior caused by incorrect test data or an automation mismatch.
38
+ Negative test data is valid when it matches a negative scenario. Do not call intentionally invalid input wrong data when the scenario expects rejection or validation feedback.
39
+
37
40
  A solitary failure where adjacent tests on the same feature passed → Execution, not Defect.
38
41
 
39
42
  ## Severity (defects only)
@@ -69,7 +72,7 @@ export class SessionAnalyst {
69
72
 
70
73
  ## Brevity rules
71
74
 
72
- - 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".
75
+ - Headline: 2 sentences MAX. About the FEATURE, not the run. No counts, no "N tests", no "this session". Never use these words: "exercised", "comprehensive", "notably", "this session", "module", "targeted", "covered creation".
73
76
  - What works: feature name + test refs. NO parentheticals, NO caveats. If there's a caveat, the entry doesn't belong here.
74
77
  - Defect title is the BUG ("Search returns non-matching results"), never the scenario name.
75
78
  - Reproduce steps are imperative one-liners drawn from the log.
@@ -24,7 +24,7 @@ export class TaskAgent {
24
24
  .map((k) => k.content)
25
25
  .filter((k) => !!k)
26
26
  .join('\n\n');
27
- tag('substep').log(`Found ${knowledgeFiles.length} relevant knowledge ${pluralize(knowledgeFiles.length, 'file')}`);
27
+ tag('operation').log(`Found ${knowledgeFiles.length} relevant knowledge ${pluralize(knowledgeFiles.length, 'file')}`);
28
28
  return dedent `
29
29
  <knowledge>
30
30
  Here is relevant knowledge for this page:
@@ -40,7 +40,7 @@ export class TaskAgent {
40
40
  return '';
41
41
  const totalSections = toc.reduce((sum, entry) => sum + entry.sections.length, 0);
42
42
  debugLog(`injecting experience TOC (${toc.length} files, ${totalSections} sections)`);
43
- tag('substep').log(`Found ${toc.length} experience ${pluralize(toc.length, 'file')} (${totalSections} sections)`);
43
+ tag('operation').log(`Found ${toc.length} experience ${pluralize(toc.length, 'file')} (${totalSections} sections)`);
44
44
  return renderExperienceToc(toc);
45
45
  }
46
46
  setHistorian(historian) {
@@ -12,6 +12,6 @@ export class ContextAriaCommand extends BaseCommand {
12
12
  if (!ariaSnapshot) {
13
13
  throw new Error('No ARIA snapshot available for current page');
14
14
  }
15
- tag('multiline').log(`ARIA Snapshot:\n\n${ariaSnapshot}`);
15
+ tag('multiline').log(`ARIA Snapshot:\n\n${ariaSnapshot}`, { maxLines: 10 });
16
16
  }
17
17
  }