From c479cd7a7f0264bc74de30321d144c79520547d8 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 21 Aug 2026 11:14:09 +0300 Subject: [PATCH] Give the AI the reason a click failed, not Playwright's retry log A failed browser command handed the model Playwright's entire call log: every wait and retry line, the same "element is not enabled" once per attempt, a class attribute broken across six lines, and terminal color codes around all of it. The one fact that decides what to do next -- the element resolved but was disabled -- sat buried in ~850 characters of bookkeeping. compactErrorMessage() strips ANSI, runs the text through the strip strategies and truncates. One strategy today: the call log loses its waiting/retrying/ attempting lines, the rest is deduped, leaving the element that was resolved and why it could not be clicked. The locator is not repeated -- the tool result already carries the command. Errors with no call log pass through untouched. The seven raw lastError.toString()/String(lastError) sites in click, hover, pressKey, form, back and reset now route through errorText(), so that stringification exists once. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 10 ++++++++++ src/ai/tester.ts | 3 ++- src/ai/tools.ts | 17 +++++++++-------- src/utils/strings.ts | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c99363c6..3a0b5131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 2026-08-21 + +### Changes + +- A browser command that fails now reports a short error instead of Playwright's full retry log. + Click, hover, key press, form and back/reset failures used to carry every wait and retry line, + the same reason repeated once per attempt, and terminal color codes — around 850 characters for + one disabled button. What is left is the element the locator resolved to and the reason it could + not be acted on, so the AI reads the blocker instead of scrolling past bookkeeping. + ## 2026-08-18 ### Changes diff --git a/src/ai/tester.ts b/src/ai/tester.ts index 76802e18..a85387ca 100644 --- a/src/ai/tester.ts +++ b/src/ai/tester.ts @@ -15,6 +15,7 @@ import { compactAriaSnapshot, detectFocusArea } from '../utils/aria.ts'; import { ErrorPageError, isErrorPage } from '../utils/error-page.ts'; import { createDebug, tag } from '../utils/logger.ts'; import { loop } from '../utils/loop.ts'; +import { compactErrorMessage } from '../utils/strings.ts'; import type { Agent, AgentDeps } from './agent.ts'; import type { Captain } from './captain.ts'; import type { Conversation } from './conversation.ts'; @@ -947,7 +948,7 @@ export class Tester extends TaskAgent implements Agent { }; if (resetAction.lastError) { - result.error = resetAction.lastError.toString(); + result.error = compactErrorMessage(resetAction.lastError); } return result; diff --git a/src/ai/tools.ts b/src/ai/tools.ts index 9911c1eb..bcf368c0 100644 --- a/src/ai/tools.ts +++ b/src/ai/tools.ts @@ -8,6 +8,7 @@ import { type Task, TestResult } from '../test-plan.js'; import { LARGE_ARIA_CHANGE_THRESHOLD } from '../utils/aria.ts'; import { isFatalBrowserError } from '../utils/browser-errors.ts'; import { createDebug, tag } from '../utils/logger.js'; +import { compactErrorMessage } from '../utils/strings.ts'; import { pause } from '../utils/loop.js'; import { WebElement } from '../utils/web-element.ts'; import type { ToolDeps } from './agent.ts'; @@ -98,7 +99,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps, const success = await action.attempt(command, explanation); const attempt: { command: string; success: boolean; error?: string } = { command, success }; - if (action.lastError) attempt.error = action.lastError.toString(); + if (action.lastError) attempt.error = errorText(action.lastError); attempts.push(attempt); if (success) { @@ -124,7 +125,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps, for (const retryCmd of retryCommands) { if (!(await action.attempt(retryCmd, explanation))) { - attempts.push({ command: retryCmd, success: false, error: action.lastError?.toString() }); + attempts.push({ command: retryCmd, success: false, error: errorText(action.lastError) }); continue; } const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, retryCmd); @@ -237,7 +238,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps, for (const command of commands) { const success = await action.attempt(command, explanation); const attempt: { command: string; success: boolean; error?: string } = { command, success }; - if (action.lastError) attempt.error = action.lastError.toString(); + if (action.lastError) attempt.error = errorText(action.lastError); attempts.push(attempt); if (!success) continue; @@ -320,7 +321,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps, ); } - const errorMsg = `pressKey fallback to type() failed: ${action.lastError?.toString()}`; + const errorMsg = `pressKey fallback to type() failed: ${errorText(action.lastError)}`; await commitNote(activeNote, TestResult.FAILED, toolResult, action); return failedToolResult('pressKey', errorMsg, { ...toolResult, @@ -366,7 +367,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps, ); } - const errorMsg = `pressKey() failed: ${action.lastError?.toString()}`; + const errorMsg = `pressKey() failed: ${errorText(action.lastError)}`; await commitNote(activeNote, TestResult.FAILED, toolResult, action); return failedToolResult('pressKey', errorMsg, { ...toolResult, @@ -448,7 +449,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps, const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, formLocator); if (action.lastError) { - const message = action.lastError ? String(action.lastError) : 'Unknown error'; + const message = errorText(action.lastError); await commitNote(activeNote, TestResult.FAILED, toolResult, action); let formSuggestion = 'Look into error message and identify which commands passed and which failed. Continue execution using step-by-step approach using click() and form() tools.'; @@ -952,7 +953,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig } const failData: Record = { suggestion: 'Try reset() to return to the starting page.' }; - if (action.lastError) failData.error = action.lastError.toString(); + if (action.lastError) failData.error = errorText(action.lastError); return failedToolResult('back', `Failed to navigate back to ${targetUrl}`, failData); }, }), @@ -1154,7 +1155,7 @@ function transformContainsCommand(command: string): string { } function errorText(error: unknown): string { - if (error instanceof Error) return error.toString(); + if (error instanceof Error) return compactErrorMessage(error); return 'Unknown error occurred'; } diff --git a/src/utils/strings.ts b/src/utils/strings.ts index 4803e45d..da9c1897 100644 --- a/src/utils/strings.ts +++ b/src/utils/strings.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto'; +import stripAnsi from 'strip-ansi'; export function truncateJson(input: any): string { if (!input) return ''; @@ -40,3 +41,38 @@ export function safeFilename(name: string, ext = '', maxBytes = 240): string { } return truncated + suffix + ext; } + +export function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return `${text.slice(0, max - 3)}...`; +} + +const MAX_COMPACT_ERROR = 400; + +export function compactErrorMessage(error: unknown): string { + let text = stripAnsi(String(error)); + for (const strip of STRIP_STRATEGIES) { + text = strip(text); + } + return truncate(text, MAX_COMPACT_ERROR); +} + +function stripCallLog(text: string): string { + const CALL_LOG = 'Call log:'; + const NOISE = ['attempting', 'retrying', 'waiting']; + + const [headline, ...log] = text.split(CALL_LOG); + if (!log.length) return text; + + const lines = new Set(); + for (const line of log.join(CALL_LOG).split('\n')) { + const cleaned = normalizeInlineText(line); + if (!cleaned) continue; + if (NOISE.some((noise) => cleaned.includes(noise))) continue; + lines.add(cleaned); + } + + return [headline.trim(), ...lines].join(' '); +} + +const STRIP_STRATEGIES = [stripCallLog];