From 7fd250c49186fb855610b727690f9a1d936db39f Mon Sep 17 00:00:00 2001 From: Denys Kuchma Date: Sun, 28 Jun 2026 16:13:40 +0200 Subject: [PATCH 1/3] reuse successful test experience during execution --- src/ai/conversation.ts | 14 +++- src/ai/historian/experience.ts | 46 ++++++++++--- src/ai/pilot.ts | 13 ++++ src/ai/provider.ts | 20 ++++-- src/ai/tester.ts | 7 ++ src/experience-tracker.ts | 28 ++++++-- src/explorbot.ts | 8 ++- tests/unit/conversation.test.ts | 30 +++++++++ tests/unit/experience-tracker.test.ts | 30 +++++++-- tests/unit/historian-experience.test.ts | 87 +++++++++++++++++++++++++ tests/unit/pilot-state-context.test.ts | 27 +++++++- tests/unit/provider.test.ts | 28 ++++++++ tests/unit/tester-focus-scope.test.ts | 38 ++++++++++- 13 files changed, 345 insertions(+), 31 deletions(-) create mode 100644 tests/unit/historian-experience.test.ts diff --git a/src/ai/conversation.ts b/src/ai/conversation.ts index e3b1f80..b5e2a3b 100644 --- a/src/ai/conversation.ts +++ b/src/ai/conversation.ts @@ -20,6 +20,7 @@ export class Conversation { model: any; telemetryFunctionId?: string; protectedPrefixCount = 0; + private toolExecutions: ToolExecution[] = []; private autoTrimRules: Map; constructor(messages: ModelMessage[] = [], model?: any, telemetryFunctionId?: string) { @@ -83,7 +84,9 @@ export class Conversation { } clone(): Conversation { - return new Conversation([...this.messages], this.model, this.telemetryFunctionId); + const clone = new Conversation([...this.messages], this.model, this.telemetryFunctionId); + clone.toolExecutions = [...this.toolExecutions]; + return clone; } cleanupTag(tagName: string, replacement: string, keepLast = 0): void { @@ -227,6 +230,10 @@ export class Conversation { } getToolExecutions(): ToolExecution[] { + if (this.toolExecutions.length > 0) { + return [...this.toolExecutions]; + } + const toolCalls = new Map(); for (const message of this.messages) { if (message.role !== 'assistant') continue; @@ -256,4 +263,9 @@ export class Conversation { return executions; } + + addToolExecutions(executions: ToolExecution[]): void { + if (executions.length === 0) return; + this.toolExecutions.push(...executions); + } } diff --git a/src/ai/historian/experience.ts b/src/ai/historian/experience.ts index 71a1ccd..2767bb6 100644 --- a/src/ai/historian/experience.ts +++ b/src/ai/historian/experience.ts @@ -217,6 +217,8 @@ export function WithExperience(Base: T) { if (!CODECEPT_TOOLS.includes(exec.toolName as any)) continue; if (!exec.output?.code) continue; + this.saveFallbackAttempts(exec, initialState); + if (!exec.wasSuccessful) { const bucket = failedByTool.get(exec.toolName) || []; bucket.push(exec); @@ -280,16 +282,7 @@ export function WithExperience(Base: T) { const candidate = candidates[pattern.candidateIndex]; if (!candidate) continue; - const url = candidate.success.output?.pageDiff?.currentUrl; - let state: ActionResult = initialState; - - if (url && url !== initialState.url) { - const transition = this.stateManager.getLastVisitToPath(url); - if (transition) { - state = ActionResult.fromState(transition.toState); - } - } - + const state = this.resolveActionState(candidate.success, initialState); if (isNonReusableCode(candidate.success.output.code)) continue; this.experienceTracker.writeAction(state, { title: pattern.intent, code: candidate.success.output.code, explanation: pattern.explanation }); } @@ -300,6 +293,39 @@ export function WithExperience(Base: T) { } } + private saveFallbackAttempts(exec: ToolExecution, initialState: ActionResult): void { + if (!exec.wasSuccessful) return; + const attempts = exec.output?.attempts; + if (!Array.isArray(attempts)) return; + if (attempts.length < 2) return; + + const successfulAttempt = attempts.find((attempt) => attempt.success && attempt.command === exec.output.code) || attempts.find((attempt) => attempt.success); + if (!successfulAttempt?.command) return; + + const failedAttempts = attempts.filter((attempt) => !attempt.success); + if (failedAttempts.length === 0) return; + if (isNonReusableCode(successfulAttempt.command)) return; + + const state = this.resolveActionState(exec, initialState); + const title = getExecutionLabel(exec, `${exec.toolName} target element`); + const failedCommands = failedAttempts.map((attempt) => attempt.command).filter(Boolean).join(', '); + const explanation = failedCommands ? `Use this locator after these alternatives failed: ${failedCommands}` : 'Use this locator after fallback attempts failed.'; + this.experienceTracker.writeAction(state, { + title, + code: successfulAttempt.command, + explanation, + }); + } + + private resolveActionState(exec: ToolExecution, initialState: ActionResult): ActionResult { + const url = exec.output?.url || exec.output?.pageDiff?.previousUrl; + if (!url || url === initialState.url) return initialState; + + const transition = this.stateManager.getLastVisitToPath(url); + if (!transition) return initialState; + return ActionResult.fromState(transition.toState); + } + private async analyzeDiscoveries(stepsWithDiffs: Array<{ step: SessionStep; ariaDiff: string | null }>): Promise { if (!stepsWithDiffs.some((s) => s.ariaDiff)) return; diff --git a/src/ai/pilot.ts b/src/ai/pilot.ts index 2f8bad1..edefd81 100644 --- a/src/ai/pilot.ts +++ b/src/ai/pilot.ts @@ -607,6 +607,19 @@ export class Pilot implements Agent { const state = this.explorer.getStateManager().getCurrentState(); if (!state) return ''; const actionResult = ActionResult.fromState(state); + const successful = this.experienceTracker.getSuccessfulExperience(actionResult); + if (successful.length > 0) { + return dedent` + + Past successful recipes recorded from prior runs for this page. + Prefer these solutions first when they match the current scenario. Use the exact code blocks as the first attempt before trying alternative locators. + If a saved locator misses, then fall back to ARIA/UI-map. + + ${successful.join('\n\n')} + + `; + } + const toc = this.experienceTracker.getExperienceTableOfContents(actionResult); return renderExperienceToc(toc); } diff --git a/src/ai/provider.ts b/src/ai/provider.ts index c1917ae..325eeb2 100644 --- a/src/ai/provider.ts +++ b/src/ai/provider.ts @@ -253,16 +253,24 @@ export class Provider { const toolCalls = response.toolCalls || []; const toolResults = response.toolResults || []; - const toolExecutions = toolCalls.map((call: any, index: number) => ({ - toolName: call.toolName || '', - input: call.input, - output: toolResults[index]?.output, - wasSuccessful: toolResults[index]?.output?.success || false, - })); + const toolExecutions = toolCalls.map((call: any, index: number) => { + const output = this.unwrapToolOutput(toolResults[index]?.output); + return { + toolName: call.toolName || '', + input: call.input, + output, + wasSuccessful: Boolean(output) && output.success !== false, + }; + }); + conversation.addToolExecutions(toolExecutions); return { conversation, response, toolExecutions }; } + private unwrapToolOutput(output: any): any { + return output?.type === 'json' && output?.value ? output.value : output; + } + async chat(messages: ModelMessage[], model: any, options: any = {}): Promise { const modelName = this.getModelName(model); setActivity(`🤖 Asking ${modelName}`, 'ai'); diff --git a/src/ai/tester.ts b/src/ai/tester.ts index 1d884eb..0ee0099 100644 --- a/src/ai/tester.ts +++ b/src/ai/tester.ts @@ -581,6 +581,7 @@ export class Tester extends TaskAgent implements Agent { } if (isNewUrl) { + const experience = this.getExperience(currentState); const alreadySeenUiMap = this.seenUiMapUrls.has(currentUrl); let research = ''; if (!alreadySeenUiMap) { @@ -625,6 +626,8 @@ export class Tester extends TaskAgent implements Agent { However, is not always up to date, use and to understand the ACTUAL state of the page Do not interact with elements that are not listed in and Refer to information on page sections in and use container CSS locators to interact with elements inside sections + + ${experience} `; return context; } @@ -784,6 +787,7 @@ export class Tester extends TaskAgent implements Agent { - Use tool input schemas exactly as documented. Do not invent parameter names or add fields not listed by the tool schema. - Use click() for buttons, links, and clickable elements ONLY - do NOT include I.fillField() or I.type() commands in click() tool - click() commands array is for FALLBACK LOCATORS of the SAME element, NOT for clicking different elements in sequence. If you need to click two different elements, make two separate click() calls. + - If contains an ACTION/FLOW code block that matches the current step, put that saved command FIRST in the relevant tool's commands array. Add new fallback locators only after the saved command. - Use form() for text input (I.fillField, I.type), dropdown selection (I.selectOption), file uploads (I.attachFile), and multi-step form interactions - Use pressKey() for pressing special keys (Enter, Escape, Tab, Arrow keys) or key combinations with modifiers (Ctrl+A, Shift+Delete, etc.) - Use container CSS locators from to interact with elements inside sections @@ -853,6 +857,7 @@ export class Tester extends TaskAgent implements Agent { private buildScenarioBlock(task: Test, actionResult: ActionResult): string { const knowledge = this.getKnowledge(actionResult); + const experience = this.getExperience(actionResult); return dedent` @@ -883,6 +888,8 @@ export class Tester extends TaskAgent implements Agent { ${this.buildAvailableFiles()} ${knowledge} + + ${experience} `; } diff --git a/src/experience-tracker.ts b/src/experience-tracker.ts index 82ccb89..2492b20 100644 --- a/src/experience-tracker.ts +++ b/src/experience-tracker.ts @@ -99,6 +99,10 @@ export class ExperienceTracker { readExperienceFile(stateHash: string): { content: string; data: any } { const filePath = this.getExperienceFilePath(stateHash); + return this.readExperienceFileByPath(filePath); + } + + private readExperienceFileByPath(filePath: string): { content: string; data: any } { const fileContent = readFileSync(filePath, 'utf8'); const { content, data } = matter(fileContent); return { content, data }; @@ -269,9 +273,23 @@ export class ExperienceTracker { return this.getAllExperience() .filter((experience) => { const experienceState = experience.data as WebPageState; - return state.isRelevantExperienceRecord(experienceState, { - includeDescendantExperience: options?.includeDescendantExperience, - }); + if ( + state.isRelevantExperienceRecord(experienceState, { + includeDescendantExperience: options?.includeDescendantExperience, + }) + ) { + return true; + } + + const related = Array.isArray(experience.data.related) ? experience.data.related : []; + return related.some((url) => + state.isRelevantExperienceRecord( + { url }, + { + includeDescendantExperience: options?.includeDescendantExperience, + } + ) + ); }) .map((experience) => { const lines = experience.content.split('\n'); @@ -350,7 +368,7 @@ export class ExperienceTracker { const filePath = this.findExperienceFileByHash(entry.fileHash); if (!filePath) return null; - const { content } = this.readExperienceFile(entry.fileHash); + const { content } = this.readExperienceFileByPath(filePath); const extracted = extractHeadingSection(content, sectionIndex); if (!extracted) return null; @@ -424,7 +442,7 @@ export class ExperienceTracker { const filePath = this.findExperienceFileByHash(entry.fileHash); if (!filePath) return null; - const { content } = this.readExperienceFile(entry.fileHash); + const { content } = this.readExperienceFileByPath(filePath); const extracted = extractHeadingSection(content, sectionIndex); if (!extracted) return null; diff --git a/src/explorbot.ts b/src/explorbot.ts index 3847d9a..1ce136d 100644 --- a/src/explorbot.ts +++ b/src/explorbot.ts @@ -221,7 +221,13 @@ export class ExplorBot { this.agents.tester = this.createAgent(({ ai, explorer }) => { const researcher = this.agentResearcher(); const navigator = this.agentNavigator(); - const tools = createAgentTools({ explorer, researcher, navigator }); + const stateManager = explorer.getStateManager(); + const experienceTracker = stateManager.getExperienceTracker(); + const getState = () => { + const state = stateManager.getCurrentState(); + return state ? ActionResult.fromState(state) : null; + }; + const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState }); return new Tester(explorer, ai, researcher, navigator, tools); }); diff --git a/tests/unit/conversation.test.ts b/tests/unit/conversation.test.ts index d4e403b..92d1866 100644 --- a/tests/unit/conversation.test.ts +++ b/tests/unit/conversation.test.ts @@ -405,4 +405,34 @@ describe('Conversation', () => { expect(output.pageDiff.htmlParts).toBeDefined(); }); }); + + it('preserves recorded tool executions after compacting prompt tool results', () => { + const conversation = new Conversation(); + conversation.addToolExecutions([ + { + toolName: 'click', + input: { explanation: 'Open run' }, + wasSuccessful: true, + output: { + success: true, + code: 'I.click("Run")', + attempts: [ + { command: 'I.click("Missing")', success: false }, + { command: 'I.click("Run")', success: true }, + ], + pageDiff: { + htmlParts: [{ subtree: '
large
' }], + ariaChanges: 'x'.repeat(600), + }, + }, + }, + ]); + + conversation.compactToolResults(0); + + const [execution] = conversation.getToolExecutions(); + expect(execution.output.attempts).toHaveLength(2); + expect(execution.output.pageDiff.htmlParts).toHaveLength(1); + expect(execution.output.pageDiff.ariaChanges).toHaveLength(600); + }); }); diff --git a/tests/unit/experience-tracker.test.ts b/tests/unit/experience-tracker.test.ts index e0a5979..4af75bb 100644 --- a/tests/unit/experience-tracker.test.ts +++ b/tests/unit/experience-tracker.test.ts @@ -1,12 +1,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; import { ActionResult } from '../../src/action-result'; import { ConfigParser } from '../../src/config'; import { ExperienceTracker } from '../../src/experience-tracker'; describe('ExperienceTracker', () => { let experienceTracker: ExperienceTracker; - const testDir = '/tmp/experience'; + const testRoot = join(process.cwd(), 'tmp', 'experience-tracker-test'); + const testDir = join(testRoot, 'experience'); beforeEach(() => { if (existsSync(testDir)) { @@ -24,7 +26,7 @@ describe('ExperienceTracker', () => { const configParser = ConfigParser.getInstance(); (configParser as any).config = mockConfig; - (configParser as any).configPath = '/tmp/config.js'; + (configParser as any).configPath = join(testRoot, 'config.js'); experienceTracker = new ExperienceTracker(); }); @@ -123,6 +125,26 @@ describe('ExperienceTracker', () => { const withDesc = experienceTracker.getRelevantExperience(parent, { includeDescendantExperience: true }); expect(withDesc).toHaveLength(2); }); + + it('includes experience when current state matches related URL', () => { + const list = new ActionResult({ + html: 'List', + url: 'https://example.com/projects/demo', + title: 'List', + }); + const suite = new ActionResult({ + html: 'Suite', + url: 'https://example.com/projects/demo/suite/123', + title: 'Suite', + }); + + experienceTracker.writeFlow(list, '## FLOW: open suite\n\n* Open suite\n\n```js\nI.click("Suite")\n```\n\n---\n', ['/projects/demo/suite/123']); + + const relevant = experienceTracker.getRelevantExperience(suite); + + expect(relevant).toHaveLength(1); + expect(relevant[0].content).toContain('## FLOW: open suite'); + }); }); describe('readExperienceFile', () => { @@ -238,7 +260,7 @@ describe('ExperienceTracker', () => { experienceTracker.writeFlow(state, ''); experienceTracker.writeFlow(state, ' \n '); const stateHash = state.getStateHash(); - const filePath = `/tmp/experience/${stateHash}.md`; + const filePath = join(testDir, `${stateHash}.md`); if (existsSync(filePath)) { const { content } = experienceTracker.readExperienceFile(stateHash); expect(content.trim()).toBe(''); @@ -266,7 +288,7 @@ describe('ExperienceTracker', () => { const state = makeState(); disabledTracker.writeFlow(state, sampleBody); const stateHash = state.getStateHash(); - const filePath = `/tmp/experience/${stateHash}.md`; + const filePath = join(testDir, `${stateHash}.md`); expect(existsSync(filePath)).toBe(false); }); }); diff --git a/tests/unit/historian-experience.test.ts b/tests/unit/historian-experience.test.ts new file mode 100644 index 0000000..138de76 --- /dev/null +++ b/tests/unit/historian-experience.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'bun:test'; +import { ActionResult } from '../../src/action-result.ts'; +import type { ToolExecution } from '../../src/ai/conversation.ts'; +import { Historian } from '../../src/ai/historian.ts'; + +function makeHistorian(writes: any[]) { + const fakeExperienceTracker = { + getRelevantExperience: () => [], + writeFlow: () => {}, + writeAction: (state: ActionResult, action: any) => writes.push({ state, action }), + } as any; + const fakeStateManager = { + getLastVisitToPath: () => null, + } as any; + const fakeRecorder = { + exportChunk: async () => new Map(), + drainVerifications: () => [], + } as any; + return new Historian({} as any, fakeExperienceTracker, undefined, fakeStateManager, undefined, { + recorder: fakeRecorder, + helper: undefined, + }); +} + +describe('Historian experience retry learning', () => { + it('saves successful fallback locator attempts as reusable action experience', async () => { + const writes: any[] = []; + const historian = makeHistorian(writes); + const initialState = new ActionResult({ + html: 'Runs', + url: 'https://example.com/projects/demo/runs', + title: 'Runs', + }); + const exec: ToolExecution = { + toolName: 'click', + input: { explanation: 'Open first visible run' }, + wasSuccessful: true, + output: { + code: 'I.click(\'a[href*="d0820b1a"]\')', + url: '/projects/demo/runs', + pageDiff: { + currentUrl: '/projects/demo/runs/d0820b1a', + }, + attempts: [ + { command: 'I.click("Star Test Run", ".tree")', success: false, error: 'not found' }, + { command: 'I.click({"role":"link","text":"Star Test Run"}, ".tree")', success: false, error: 'not found' }, + { command: 'I.click(\'a[href*="d0820b1a"]\')', success: true }, + ], + }, + }; + + await (historian as any).detectRetryPatterns([exec], initialState); + + expect(writes).toHaveLength(1); + expect(writes[0].state.url).toBe('/projects/demo/runs'); + expect(writes[0].action.title).toBe('Open first visible run'); + expect(writes[0].action.code).toBe('I.click(\'a[href*="d0820b1a"]\')'); + expect(writes[0].action.explanation).toContain('I.click("Star Test Run", ".tree")'); + }); + + it('does not save non-reusable fallback locators', async () => { + const writes: any[] = []; + const historian = makeHistorian(writes); + const initialState = new ActionResult({ + html: 'Runs', + url: 'https://example.com/projects/demo/runs', + title: 'Runs', + }); + const exec: ToolExecution = { + toolName: 'click', + input: { explanation: 'Open visually' }, + wasSuccessful: true, + output: { + code: 'I.clickXY(100, 200)', + url: '/projects/demo/runs', + attempts: [ + { command: 'I.click("Run")', success: false, error: 'not found' }, + { command: 'I.clickXY(100, 200)', success: true }, + ], + }, + }; + + await (historian as any).detectRetryPatterns([exec], initialState); + + expect(writes).toHaveLength(0); + }); +}); diff --git a/tests/unit/pilot-state-context.test.ts b/tests/unit/pilot-state-context.test.ts index 64bedc5..e3db714 100644 --- a/tests/unit/pilot-state-context.test.ts +++ b/tests/unit/pilot-state-context.test.ts @@ -14,15 +14,18 @@ function buildActionResult(browserLogs: any[] = [], ariaSnapshot = ''): ActionRe }); } -function buildPilotWithStore(store: RequestStore | null, hasOtherTabs = false): Pilot { +function buildPilotWithStore(store: RequestStore | null, hasOtherTabs = false, experienceTracker?: any): Pilot { const explorer: any = { getRequestStore: () => store, hasOtherTabs: () => hasOtherTabs, getOtherTabsInfo: () => [], + getStateManager: () => ({ + getCurrentState: () => buildActionResult(), + }), }; const provider: any = {}; const researcher: any = {}; - return new Pilot(provider, {}, researcher, explorer); + return new Pilot(provider, {}, researcher, explorer, experienceTracker); } function makeFailure(method: string, path: string, status: number, counter: number): RequestResult { @@ -100,4 +103,24 @@ describe('Pilot buildStateContext — error signals', () => { const context = (pilot as any).buildStateContext(buildActionResult()); expect(context).toContain('network errors: none'); }); + + it('renders full successful experience for planning before TOC fallback', () => { + const experienceTracker = { + getSuccessfulExperience: () => ['## ACTION: open run\n\n```js\nI.click("Star Test Run")\n```'], + getExperienceTableOfContents: () => [ + { + fileTag: 'A', + fileHash: 'page', + url: '/page', + sections: [{ index: 1, level: 2, title: 'ACTION: open run' }], + }, + ], + }; + const pilot = buildPilotWithStore(null, false, experienceTracker); + const context = (pilot as any).getExperienceToc(); + + expect(context).toContain('I.click("Star Test Run")'); + expect(context).toContain('Prefer these solutions first'); + expect(context).not.toContain('Call learnExperience'); + }); }); diff --git a/tests/unit/provider.test.ts b/tests/unit/provider.test.ts index c3e7a34..376d932 100644 --- a/tests/unit/provider.test.ts +++ b/tests/unit/provider.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import type { ModelMessage } from 'ai'; +import { Conversation } from '../../src/ai/conversation.js'; import { AiError, Provider } from '../../src/ai/provider.js'; import { ConfigParser } from '../../src/config.js'; import type { AIConfig } from '../../src/config.js'; @@ -98,6 +99,33 @@ describe('Provider', () => { }); }); + describe('invokeConversation', () => { + it('should unwrap json tool outputs before storing executions', async () => { + const conversation = new Conversation([{ role: 'user', content: 'click the run' }], mockAI.getModel()); + const output = { + success: true, + code: 'I.click("Star Test Run")', + attempts: [ + { command: 'I.click("Missing")', success: false }, + { command: 'I.click("Star Test Run")', success: true }, + ], + }; + + (provider as any).generateWithTools = async () => ({ + text: '', + response: { messages: [] }, + toolCalls: [{ toolName: 'click', input: { locator: 'Star Test Run' } }], + toolResults: [{ output: { type: 'json', value: output } }], + }); + + const result = await provider.invokeConversation(conversation, { click: {} }); + + expect(result?.toolExecutions?.[0]?.output).toEqual(output); + expect(result?.toolExecutions?.[0]?.wasSuccessful).toBe(true); + expect(conversation.getToolExecutions()[0]?.output?.attempts).toHaveLength(2); + }); + }); + describe('retry functionality', () => { it('should retry on API errors', async () => { const messages = [{ role: 'user', content: 'Hello' }]; diff --git a/tests/unit/tester-focus-scope.test.ts b/tests/unit/tester-focus-scope.test.ts index 8e833fc..2baecd8 100644 --- a/tests/unit/tester-focus-scope.test.ts +++ b/tests/unit/tester-focus-scope.test.ts @@ -2,15 +2,23 @@ import { describe, expect, it } from 'bun:test'; import { ActionResult } from '../../src/action-result.ts'; import { Tester } from '../../src/ai/tester.ts'; -function buildTester(): Tester { +function buildTester(experienceToc: any[] = []): Tester { const explorer: any = { getConfig: () => ({}), getCurrentIframeInfo: () => null, hasOtherTabs: () => false, getOtherTabsInfo: () => [], clearOtherTabsInfo: () => {}, + getStateManager: () => ({ + getExperienceTracker: () => ({ + getExperienceTableOfContents: () => experienceToc, + }), + getCurrentState: () => buildState('- main:', '/page'), + }), + }; + const provider: any = { + getSystemPromptForAgent: () => '', }; - const provider: any = {}; const researcher: any = { research: async () => '', researchOverlay: async () => null, @@ -29,6 +37,14 @@ function buildState(ariaSnapshot: string, url = '/page'): ActionResult { } describe('Tester reinjectContextIfNeeded — focus scope hint', () => { + it('instructs tester to put matching experience commands first', () => { + const tester = buildTester(); + const system = (tester as any).getSystemMessage(); + + expect(system).toContain('put that saved command FIRST'); + expect(system).toContain('Add new fallback locators only after the saved command'); + }); + it('emits when ARIA snapshot contains a dialog', async () => { const tester = buildTester(); const state = buildState('- dialog "Create Requirement":\n - tablist:\n - tab "Text"\n - tab "File"'); @@ -68,4 +84,22 @@ describe('Tester reinjectContextIfNeeded — focus scope hint', () => { expect(context).toContain(''); expect(context).toContain('A dialog "New Form"'); }); + + it('emits experience TOC on URL change', async () => { + const tester = buildTester([ + { + fileTag: 'A', + fileHash: 'page', + url: '/page', + sections: [{ index: 1, level: 2, title: 'FLOW: create item' }], + }, + ]); + const state = buildState('- main:\n - button "Create"', '/with-experience'); + + const context = await (tester as any).reinjectContextIfNeeded(2, state); + + expect(context).toContain(''); + expect(context).toContain('A.1 ## FLOW: create item'); + expect(context).toContain('Call learnExperience'); + }); }); From ec8f2722feb5ae22db0bd5419da26c5f040ef05d Mon Sep 17 00:00:00 2001 From: Denys Kuchma Date: Sun, 28 Jun 2026 16:18:05 +0200 Subject: [PATCH 2/3] fix --- src/ai/historian/experience.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ai/historian/experience.ts b/src/ai/historian/experience.ts index 2767bb6..bd6d16d 100644 --- a/src/ai/historian/experience.ts +++ b/src/ai/historian/experience.ts @@ -308,7 +308,10 @@ export function WithExperience(Base: T) { const state = this.resolveActionState(exec, initialState); const title = getExecutionLabel(exec, `${exec.toolName} target element`); - const failedCommands = failedAttempts.map((attempt) => attempt.command).filter(Boolean).join(', '); + const failedCommands = failedAttempts + .map((attempt) => attempt.command) + .filter(Boolean) + .join(', '); const explanation = failedCommands ? `Use this locator after these alternatives failed: ${failedCommands}` : 'Use this locator after fallback attempts failed.'; this.experienceTracker.writeAction(state, { title, From 413588e5fa02f01de95844fd7470c2d30a00d57e Mon Sep 17 00:00:00 2001 From: Denys Kuchma Date: Sun, 28 Jun 2026 16:23:21 +0200 Subject: [PATCH 3/3] fix test --- tests/unit/tester-error-page.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/unit/tester-error-page.test.ts b/tests/unit/tester-error-page.test.ts index 880ec90..cd086fd 100644 --- a/tests/unit/tester-error-page.test.ts +++ b/tests/unit/tester-error-page.test.ts @@ -43,7 +43,9 @@ describe('Tester error page handling', () => { getStateManager: () => ({ getCurrentState: () => currentState, clearHistory: () => {}, - getExperienceTracker: () => ({}), + getExperienceTracker: () => ({ + getExperienceTableOfContents: () => [], + }), }), getKnowledgeTracker: () => ({ getRelevantKnowledge: () => [], @@ -97,7 +99,9 @@ describe('Tester error page handling', () => { getStateManager: () => ({ getCurrentState: () => currentState, clearHistory: () => {}, - getExperienceTracker: () => ({}), + getExperienceTracker: () => ({ + getExperienceTableOfContents: () => [], + }), }), getKnowledgeTracker: () => ({ getRelevantKnowledge: () => [],