@kkeve/suite 0.0.12 → 0.0.15

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.
@@ -230,7 +230,10 @@ async function run(options) {
230
230
  console.log(chalk_1.default.blue(` ▶ Round ${round} — Executing Playwright tests...\n`));
231
231
  await ensurePlaywrightBrowsers();
232
232
  if (!env.KEVE_CDP_ENDPOINT && !env.KEVE_CDP_WS_ENDPOINT) {
233
- env.KEVE_CDP_ENDPOINT = 'http://127.0.0.1:9222';
233
+ // 不再盲目设置 KEVE_CDP_ENDPOINT fallback — 如果 browserSetup() 未成功启动 Chrome,
234
+ // 这个 fallback 会让 isCdpMode() 返回 true → getCdpBrowser() 去连一个不存在的 CDP → throw
235
+ // 现在由 getCdpBrowser() 内部处理 graceful fallback 到非 CDP 模式
236
+ console.log(chalk_1.default.gray(' Note: No CDP endpoint configured — Playwright will launch its own browser'));
234
237
  }
235
238
  const exitCode = await new Promise((resolve) => {
236
239
  const child = (0, node_child_process_1.spawn)('npx', pwArgs, {
@@ -73,9 +73,12 @@ function resolveOptions(opts) {
73
73
  // 2. ktest 原始的 TCDPQA100195 / TCDPQA100189 等:「大写字母+数字」连续无分隔
74
74
  // (ktest 命名规则:TC=TestCase DPQA=数据平台QA 100195=序列号)
75
75
  function parseCaseIdFromTitle(title) {
76
- const colonMatch = title.match(/^([A-Z]+(?:-[A-Z]+)*-\d+|[A-Z]+\d+):/);
77
- const bracketMatch = title.match(/\[([A-Z]+(?:-[A-Z]+)*-\d+|[A-Z]+\d+)\]/);
78
- const spaceMatch = title.match(/^([A-Z]+(?:-[A-Z]+)*-\d+|[A-Z]+\d+)\s/);
76
+ // Case ID patterns: "SKILL_SHARE-01:", "AG-01:", "ABC123:", "[REG-02]"
77
+ // Supports: underscores (SKILL_SHARE), hyphens (REG-01), pure alphanumeric (ABC123)
78
+ const caseIdPattern = '[A-Z][A-Z0-9]*(?:[_-][A-Z0-9]+)*-\\d+|[A-Z]+\\d+';
79
+ const colonMatch = title.match(new RegExp(`^(${caseIdPattern}):`));
80
+ const bracketMatch = title.match(new RegExp(`\\[(${caseIdPattern})\\]`));
81
+ const spaceMatch = title.match(new RegExp(`^(${caseIdPattern})\\s`));
79
82
  return colonMatch?.[1] || bracketMatch?.[1] || spaceMatch?.[1] || null;
80
83
  }
81
84
  /**
@@ -1,6 +1,6 @@
1
1
  declare global {
2
2
  var __keve_cdp_browser: import('@playwright/test').Browser | undefined;
3
3
  }
4
- export declare function getCdpBrowser(): Promise<import('@playwright/test').Browser>;
4
+ export declare function getCdpBrowser(): Promise<import('@playwright/test').Browser | null>;
5
5
  export declare function isCdpMode(): boolean;
6
6
  export default function (): Promise<void>;
@@ -50,21 +50,30 @@ async function getCdpBrowser() {
50
50
  if (!wsUrl && process.env.KEVE_CDP_ENDPOINT) {
51
51
  const httpUrl = process.env.KEVE_CDP_ENDPOINT.replace(/\/$/, '');
52
52
  try {
53
- const resp = await fetch(`${httpUrl}/json/version`);
53
+ const resp = await fetch(`${httpUrl}/json/version`, { signal: AbortSignal.timeout(5000) });
54
54
  const data = await resp.json();
55
55
  wsUrl = data.webSocketDebuggerUrl;
56
56
  console.log(`[keve CDP] Resolved WS endpoint: ${wsUrl}`);
57
57
  }
58
58
  catch (e) {
59
- throw new Error(`Failed to resolve CDP WebSocket URL from ${httpUrl}: ${e.message}`);
59
+ console.warn(`[keve CDP] Failed to resolve CDP WebSocket URL from ${httpUrl}: ${e.message} — falling back to non-CDP mode`);
60
+ return null;
60
61
  }
61
62
  }
62
- if (!wsUrl)
63
- throw new Error('No CDP endpoint configured');
64
- const browser = await chromium.connectOverCDP(wsUrl, { timeout: 15000 });
65
- console.log(`[keve CDP] Connected via ${wsUrl}`);
66
- globalThis.__keve_cdp_browser = browser;
67
- return browser;
63
+ if (!wsUrl) {
64
+ console.warn(`[keve CDP] No CDP endpoint configured — falling back to non-CDP mode`);
65
+ return null;
66
+ }
67
+ try {
68
+ const browser = await chromium.connectOverCDP(wsUrl, { timeout: 15000 });
69
+ console.log(`[keve CDP] Connected via ${wsUrl}`);
70
+ globalThis.__keve_cdp_browser = browser;
71
+ return browser;
72
+ }
73
+ catch (connectErr) {
74
+ console.warn(`[keve CDP] connectOverCDP failed: ${connectErr.message} — falling back to non-CDP mode`);
75
+ return null;
76
+ }
68
77
  }
69
78
  function isCdpMode() {
70
79
  return !!process.env.KEVE_CDP_WS_ENDPOINT || !!process.env.KEVE_CDP_ENDPOINT;
@@ -76,8 +85,9 @@ async function default_1() {
76
85
  if (cdpEndpoint) {
77
86
  console.log(`[keve global-setup] CDP endpoint detected: ${cdpEndpoint}`);
78
87
  // CDP 模式:在 global-setup 中提前连接一次,缓存 Browser 对象
79
- try {
80
- const browser = await getCdpBrowser();
88
+ // getCdpBrowser() 失败时返回 null 并清除 CDP 环境变量 → 后续 fixture 自动 fallback
89
+ const browser = await getCdpBrowser();
90
+ if (browser) {
81
91
  const contexts = browser.contexts();
82
92
  const pages = contexts.length > 0 ? contexts[0].pages() : [];
83
93
  console.log(`[keve global-setup] CDP connected. contexts=${contexts.length}, pages=${pages.length}`);
@@ -85,8 +95,8 @@ async function default_1() {
85
95
  console.log(`[keve global-setup] Existing tabs: ${pages.map(p => p.url()).join(', ')}`);
86
96
  }
87
97
  }
88
- catch (e) {
89
- console.warn(`[keve global-setup] CDP connection failed: ${e.message}`);
98
+ else {
99
+ console.warn(`[keve global-setup] CDP connection failed — tests will use non-CDP fallback (chromium.launch)`);
90
100
  }
91
101
  if (!fs.existsSync(storageStatePath)) {
92
102
  const authDir = path.dirname(storageStatePath);
@@ -87,9 +87,10 @@ keve_aspect_1.keveAspect.register({
87
87
  phase: 'after',
88
88
  order: 20,
89
89
  async execute(ctx, result) {
90
- if (!result?.actions?.length || !result.success)
90
+ if (!result?.actions?.length)
91
91
  return;
92
- learned_actions_1.learnedActions.add(ctx.step, result.actions);
92
+ const url = ctx.page.url();
93
+ learned_actions_1.learnedActions.add(ctx.step, result.actions, url, result.success);
93
94
  },
94
95
  });
95
96
  // ─── (diagnostic extraction moved to page-agent/diagnostic.ts, called via onAfterTask hook) ──
@@ -103,8 +104,17 @@ exports.test = test_1.test.extend({
103
104
  browser: async ({}, use, testInfo) => {
104
105
  if ((0, global_setup_1.isCdpMode)()) {
105
106
  const browser = await (0, global_setup_1.getCdpBrowser)(); // 复用全局共享的 CDP browser
106
- await use(browser);
107
- // CDP 模式下不关闭浏览器(共享连接,关闭会影响后续用例)
107
+ if (browser) {
108
+ await use(browser);
109
+ // CDP 模式下不关闭浏览器(共享连接,关闭会影响后续用例)
110
+ }
111
+ else {
112
+ // CDP 不可达 → graceful fallback 到独立启动浏览器
113
+ console.log(`[keve-test] CDP unavailable, falling back to chromium.launch()`);
114
+ const fallback = await test_1.chromium.launch(testInfo.project?.use?.launchOptions || {});
115
+ await use(fallback);
116
+ await fallback.close();
117
+ }
108
118
  }
109
119
  else {
110
120
  const browser = await test_1.chromium.launch(testInfo.project?.use?.launchOptions || {});
@@ -113,7 +123,8 @@ exports.test = test_1.test.extend({
113
123
  }
114
124
  },
115
125
  page: async ({ browser }, use) => {
116
- if ((0, global_setup_1.isCdpMode)()) {
126
+ const isCdp = (0, global_setup_1.isCdpMode)() && browser.isConnected();
127
+ if (isCdp) {
117
128
  const contexts = browser.contexts();
118
129
  const cdpContext = contexts.length > 0 ? contexts[0] : await browser.newContext();
119
130
  // CDP 模式:优先复用已有 page(保留登录状态),仅在无可用 page 时新开
@@ -202,7 +213,8 @@ exports.test = test_1.test.extend({
202
213
  catch { /* non-critical */ }
203
214
  }
204
215
  // ── Hand off to agent: agent does Re-Act (OR shortcut to blocked if fnBlocked) ──
205
- const learnedHint = learned_actions_1.learnedActions.getHint(options.step);
216
+ const currentUrl = page.url();
217
+ const learnedHint = learned_actions_1.learnedActions.getHint(options.step, currentUrl);
206
218
  let reactResult;
207
219
  let reactTimedOut = false;
208
220
  if (fnBlocked) {
@@ -222,43 +234,69 @@ exports.test = test_1.test.extend({
222
234
  };
223
235
  }
224
236
  else {
225
- // ── Per-goal timeout: prevent one slow goal from exhausting the test timeout ──
226
- // Default 300s per goal; total test timeout should be ≥ (goals × 300s + overhead)
227
- const GOAL_TIMEOUT_MS = 300_000;
228
- const goalTimeoutController = new AbortController();
229
- const goalTimeout = setTimeout(() => {
230
- goalTimeoutController.abort();
231
- console.log(`[keveGoal] ⏰ "${options.step}" timed out after ${GOAL_TIMEOUT_MS}ms — stopping agent`);
232
- }, GOAL_TIMEOUT_MS);
237
+ // ── Page liveness check: if fn killed the page, skip Agent Re-Act ──
238
+ // When fn error involves page/browser close, the page is dead —
239
+ // Agent cannot take a11y snapshots or interact. Skip immediately.
240
+ let pageAlive = true;
233
241
  try {
234
- reactResult = await (0, agent_1.reactLoop)(page, options.step, options.expected, {
235
- learnedActionsHint: learnedHint,
236
- specFilePath: ctx.specFilePath,
237
- fnSource,
238
- fnResult: fnError ? { error: fnError } : { success: true },
239
- signal: goalTimeoutController.signal,
240
- goalScreenshotBefore: goalScreenshotBefore || undefined,
241
- fnAfterScreenshot: fnAfterScreenshot || undefined,
242
- });
242
+ await page.url();
243
+ }
244
+ catch {
245
+ pageAlive = false;
243
246
  }
244
- catch (reactErr) {
245
- reactTimedOut = true;
246
- const isGoalTimeout = goalTimeoutController.signal.aborted;
247
- const msg = isGoalTimeout
248
- ? `Goal timed out (${GOAL_TIMEOUT_MS}ms)`
249
- : (reactErr?.message || String(reactErr));
250
- console.log(`[keveGoal] reactLoop interrupted: ${msg.slice(0, 200)}`);
247
+ if (!pageAlive) {
248
+ console.log(`[keveGoal] 🚫 "${options.step}" BLOCKED — page is dead after fn error, skipping agent Re-Act`);
251
249
  reactResult = {
252
250
  expectedMet: false,
253
- actions: [],
251
+ actions: [{
252
+ action: { tool: 'done', verdict: 'blocked', text: `Page closed after fn error: ${fnError}` },
253
+ toolOutput: '',
254
+ result: 'ok',
255
+ }],
254
256
  finalSnapshot: '',
255
257
  agentScreenshots: [],
256
- conclusion: isGoalTimeout ? 'blocked' : 'fail',
258
+ conclusion: 'blocked',
257
259
  };
258
260
  }
259
- finally {
260
- clearTimeout(goalTimeout);
261
- }
261
+ else {
262
+ // ── Per-goal timeout: prevent one slow goal from exhausting the test timeout ──
263
+ // Default 300s per goal; total test timeout should be ≥ (goals × 300s + overhead)
264
+ const GOAL_TIMEOUT_MS = 300_000;
265
+ const goalTimeoutController = new AbortController();
266
+ const goalTimeout = setTimeout(() => {
267
+ goalTimeoutController.abort();
268
+ console.log(`[keveGoal] ⏰ "${options.step}" timed out after ${GOAL_TIMEOUT_MS}ms — stopping agent`);
269
+ }, GOAL_TIMEOUT_MS);
270
+ try {
271
+ reactResult = await (0, agent_1.reactLoop)(page, options.step, options.expected, {
272
+ learnedActionsHint: learnedHint,
273
+ specFilePath: ctx.specFilePath,
274
+ fnSource,
275
+ fnResult: fnError ? { error: fnError } : { success: true },
276
+ signal: goalTimeoutController.signal,
277
+ goalScreenshotBefore: goalScreenshotBefore || undefined,
278
+ fnAfterScreenshot: fnAfterScreenshot || undefined,
279
+ });
280
+ }
281
+ catch (reactErr) {
282
+ reactTimedOut = true;
283
+ const isGoalTimeout = goalTimeoutController.signal.aborted;
284
+ const msg = isGoalTimeout
285
+ ? `Goal timed out (${GOAL_TIMEOUT_MS}ms)`
286
+ : (reactErr?.message || String(reactErr));
287
+ console.log(`[keveGoal] reactLoop interrupted: ${msg.slice(0, 200)}`);
288
+ reactResult = {
289
+ expectedMet: false,
290
+ actions: [],
291
+ finalSnapshot: '',
292
+ agentScreenshots: [],
293
+ conclusion: isGoalTimeout ? 'blocked' : 'fail',
294
+ };
295
+ }
296
+ finally {
297
+ clearTimeout(goalTimeout);
298
+ }
299
+ } // end of page-alive else (agent Re-Act path)
262
300
  } // end of else (non-blocked path)
263
301
  // ── Deterministic override: fn assertion failure cannot be overridden by Agent "pass" ──
264
302
  // When fn has a hard assertion failure (Expected X, Received Y), the value mismatch
@@ -464,6 +502,16 @@ function isEnvironmentBlockedError(err) {
464
502
  || msg.includes('err_too_many_redirects')) {
465
503
  return true;
466
504
  }
505
+ // Page/context/browser closed — dead page, Agent cannot explore
506
+ // e.g. "page.waitForLoadState: Target page, context or browser has been closed"
507
+ // e.g. "page.ariaSnapshot: Target page, context or browser has been closed"
508
+ // When the CDP connection drops or the user closes the tab, any page API throws this.
509
+ // Agent Re-Act on a dead page is a waste of time → BLOCKED immediately.
510
+ if (msg.includes('target page, context or browser has been closed')
511
+ || msg.includes('page has been closed') || msg.includes('browser has been closed')
512
+ || msg.includes('context has been closed')) {
513
+ return true;
514
+ }
467
515
  return false;
468
516
  }
469
517
  /**
@@ -4,21 +4,50 @@
4
4
  * When one keveGoal's Re-Act loop discovers a working action sequence,
5
5
  * other goals in the same round can use those discoveries as hints.
6
6
  *
7
- * Key format: "${step}" — the step description
8
- * Value: ActionLogEntry[] — the successful action sequence
7
+ * v2: Fuzzy matching by token Jaccard similarity + page URL pattern.
8
+ * Exact match is tried first; if no hit, fuzzy search across all entries.
9
9
  */
10
+ interface CacheEntry {
11
+ step: string;
12
+ actions: any[];
13
+ url?: string;
14
+ success: boolean;
15
+ }
10
16
  export declare class LearnedActions {
11
17
  private cache;
12
- /** Store discovered actions for a step */
13
- add(step: string, actions: any[]): void;
14
- /** Retrieve discovered actions for a step */
18
+ /** Store discovered actions for a step (with optional page URL for context matching)
19
+ * @param success Whether the step succeeded — failed entries are stored with warnings only
20
+ */
21
+ add(step: string, actions: any[], url?: string, success?: boolean): void;
22
+ /** Retrieve discovered actions for a step (exact match) */
15
23
  get(step: string): any[] | undefined;
16
- /** Get a JSON hint string for injecting into Re-Act prompt */
17
- getHint(step: string): string | undefined;
24
+ /** Tokenize a Chinese/mixed step description for fuzzy matching.
25
+ * Uses bigram (character pair) extraction for Chinese — more granular than
26
+ * whitespace splitting, captures shared sub-phrases like "指标标准名称".
27
+ */
28
+ private tokenize;
29
+ /** Compute Jaccard similarity between two token sets */
30
+ private jaccard;
31
+ /** Find the best matching cached entry for a given step description */
32
+ private findBestMatch;
33
+ /** Get a hint string for injecting into Re-Act prompt.
34
+ * 1. Exact match → direct reuse
35
+ * 2. Fuzzy match by token similarity → similar step reuse
36
+ * 3. Same-page fallback → if same URL path, use the most recent successful entry
37
+ */
38
+ getHint(step: string, currentUrl?: string): string | undefined;
39
+ /** Extract path portion of URL for same-page matching (ignore query/hash) */
40
+ private extractPath;
41
+ /** Format a hint from a cache entry's actions.
42
+ * Success: action sequence summary + key discoveries
43
+ * Failure: warnings/blockers only — avoid misleading Agent into reusing failed paths
44
+ */
45
+ private formatHint;
18
46
  /** Get all cached entries */
19
- getAll(): Map<string, any[]>;
47
+ getAll(): Map<string, CacheEntry>;
20
48
  /** Clear all cached entries (typically at round start) */
21
49
  clear(): void;
22
50
  }
23
51
  /** Global singleton for same-round sharing */
24
52
  export declare const learnedActions: LearnedActions;
53
+ export {};
@@ -5,51 +5,243 @@
5
5
  * When one keveGoal's Re-Act loop discovers a working action sequence,
6
6
  * other goals in the same round can use those discoveries as hints.
7
7
  *
8
- * Key format: "${step}" — the step description
9
- * Value: ActionLogEntry[] — the successful action sequence
8
+ * v2: Fuzzy matching by token Jaccard similarity + page URL pattern.
9
+ * Exact match is tried first; if no hit, fuzzy search across all entries.
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.learnedActions = exports.LearnedActions = void 0;
13
13
  class LearnedActions {
14
14
  cache = new Map();
15
- /** Store discovered actions for a step */
16
- add(step, actions) {
17
- // Only store successful action sequences (ending with expected met)
15
+ /** Store discovered actions for a step (with optional page URL for context matching)
16
+ * @param success Whether the step succeeded — failed entries are stored with warnings only
17
+ */
18
+ add(step, actions, url, success = true) {
18
19
  if (actions.length > 0) {
19
- this.cache.set(step, actions);
20
+ this.cache.set(step, { step, actions, url, success });
20
21
  }
21
22
  }
22
- /** Retrieve discovered actions for a step */
23
+ /** Retrieve discovered actions for a step (exact match) */
23
24
  get(step) {
24
- return this.cache.get(step);
25
+ return this.cache.get(step)?.actions;
25
26
  }
26
- /** Get a JSON hint string for injecting into Re-Act prompt */
27
- getHint(step) {
28
- const actions = this.cache.get(step);
29
- if (!actions || actions.length === 0)
30
- return undefined;
31
- // Summarize: only include action type + role/name, not full snapshot
27
+ /** Tokenize a Chinese/mixed step description for fuzzy matching.
28
+ * Uses bigram (character pair) extraction for Chinese — more granular than
29
+ * whitespace splitting, captures shared sub-phrases like "指标标准名称".
30
+ */
31
+ tokenize(text) {
32
+ // 1. Split by delimiters to get chunks
33
+ const chunks = text
34
+ .replace(/[,。、;:""''()【】《》\s,.;:!?/\\|]+/g, ' ')
35
+ .split(' ')
36
+ .filter(t => t.length > 0);
37
+ // 2. For each chunk, extract character bigrams (for Chinese sub-phrase matching)
38
+ // AND keep the original chunk (for exact chunk matches)
39
+ const tokens = [];
40
+ for (const chunk of chunks) {
41
+ tokens.push(chunk.toLowerCase());
42
+ // Generate bigrams: sliding window of 2 chars
43
+ for (let i = 0; i < chunk.length - 1; i++) {
44
+ tokens.push(chunk.substring(i, i + 2).toLowerCase());
45
+ }
46
+ }
47
+ return tokens;
48
+ }
49
+ /** Compute Jaccard similarity between two token sets */
50
+ jaccard(a, b) {
51
+ const setA = new Set(a.map(t => t.toLowerCase()));
52
+ const setB = new Set(b.map(t => t.toLowerCase()));
53
+ const intersection = [...setA].filter(t => setB.has(t)).length;
54
+ const union = new Set([...setA, ...setB]).size;
55
+ return union === 0 ? 0 : intersection / union;
56
+ }
57
+ /** Find the best matching cached entry for a given step description */
58
+ findBestMatch(step) {
59
+ const tokens = this.tokenize(step);
60
+ let bestEntry;
61
+ let bestScore = 0;
62
+ // Threshold: 18% token overlap — low enough to catch "填写其他必填项" vs "必填项填入合法值",
63
+ // high enough to avoid completely unrelated steps matching
64
+ const THRESHOLD = 0.18;
65
+ for (const [, entry] of this.cache) {
66
+ const entryTokens = this.tokenize(entry.step);
67
+ const score = this.jaccard(tokens, entryTokens);
68
+ if (score > bestScore && score >= THRESHOLD) {
69
+ bestScore = score;
70
+ bestEntry = entry;
71
+ }
72
+ }
73
+ return bestEntry;
74
+ }
75
+ /** Get a hint string for injecting into Re-Act prompt.
76
+ * 1. Exact match → direct reuse
77
+ * 2. Fuzzy match by token similarity → similar step reuse
78
+ * 3. Same-page fallback → if same URL path, use the most recent successful entry
79
+ */
80
+ getHint(step, currentUrl) {
81
+ // 1. Try exact match first (prefer success entries over failures)
82
+ const exact = this.cache.get(step);
83
+ if (exact?.actions.length) {
84
+ const hint = this.formatHint(exact.step, exact.actions, false, exact.success);
85
+ if (hint)
86
+ return hint;
87
+ }
88
+ // 2. Fuzzy match by step description similarity (prefer success, but include failures)
89
+ const best = this.findBestMatch(step);
90
+ if (best?.actions.length) {
91
+ const hint = this.formatHint(best.step, best.actions, true, best.success);
92
+ if (hint)
93
+ return hint;
94
+ }
95
+ // 3. Same-page fallback: if on the same URL path as a cached entry, use the most
96
+ // relevant one (highest Jaccard, even if below fuzzy threshold).
97
+ // Minimum Jaccard = 0.08 to avoid completely unrelated steps on the same page.
98
+ if (currentUrl) {
99
+ const urlPath = this.extractPath(currentUrl);
100
+ let bestSamePage;
101
+ let bestSamePageScore = 0;
102
+ const SAME_PAGE_MIN = 0.08;
103
+ const stepTokens = this.tokenize(step);
104
+ for (const [, entry] of this.cache) {
105
+ if (entry.url && this.extractPath(entry.url) === urlPath) {
106
+ const entryTokens = this.tokenize(entry.step);
107
+ const score = this.jaccard(stepTokens, entryTokens);
108
+ if (score > bestSamePageScore && score >= SAME_PAGE_MIN) {
109
+ bestSamePageScore = score;
110
+ bestSamePage = entry;
111
+ }
112
+ }
113
+ }
114
+ if (bestSamePage?.actions.length) {
115
+ const hint = this.formatHint(bestSamePage.step, bestSamePage.actions, true, bestSamePage.success);
116
+ if (hint)
117
+ return hint;
118
+ }
119
+ }
120
+ return undefined;
121
+ }
122
+ /** Extract path portion of URL for same-page matching (ignore query/hash) */
123
+ extractPath(url) {
124
+ try {
125
+ const u = new URL(url);
126
+ return u.pathname;
127
+ }
128
+ catch {
129
+ return url;
130
+ }
131
+ }
132
+ /** Format a hint from a cache entry's actions.
133
+ * Success: action sequence summary + key discoveries
134
+ * Failure: warnings/blockers only — avoid misleading Agent into reusing failed paths
135
+ */
136
+ formatHint(sourceStep, actions, isFuzzy, success) {
137
+ if (!success) {
138
+ // Failed step — surface warnings/blockers AND combobox conflict info
139
+ const discoveries = [];
140
+ for (const a of actions) {
141
+ const out = a.toolOutput || '';
142
+ if (out.startsWith('⚠️'))
143
+ discoveries.push(out.slice(0, 120));
144
+ // Also capture combobox "重复" conflicts — valuable for later goals to avoid same data
145
+ if (out.includes('重复') || out.includes('duplicate') || out.includes('conflict')) {
146
+ discoveries.push(`DATA CONFLICT: ${out.slice(0, 120)}`);
147
+ }
148
+ }
149
+ if (discoveries.length === 0)
150
+ return undefined;
151
+ const prefix = isFuzzy
152
+ ? `Previous FAILED step SIMILAR to "${sourceStep}"`
153
+ : `Previous FAILED step "${sourceStep}"`;
154
+ return `${prefix} — avoid these approaches. Blocked by: ${discoveries.slice(-3).map(d => `[${d}]`).join('; ')}`;
155
+ }
156
+ // Success — full action sequence + discoveries (existing logic)
157
+ // Extract stable element identity from toolOutput (not from action fields which lack role/name)
32
158
  const summary = actions
33
- .filter(a => a.action.tool !== 'done')
159
+ .filter(a => a.action?.tool !== 'done' && a.tool !== 'done')
34
160
  .map(a => {
35
- const act = a.action;
161
+ const act = a.action || a;
162
+ const out = a.toolOutput || '';
36
163
  if (act.tool === 'navigate')
37
164
  return `navigate(${act.url})`;
38
- if (act.tool === 'click')
39
- return `click(${act.role} "${act.name}")`;
40
- if (act.tool === 'type')
41
- return `type(${act.role} "${act.name}", "${act.text}"${act.submit ? ', Enter' : ''})`;
165
+ if (act.tool === 'click') {
166
+ // Parse toolOutput: "✅ Clicked [ref=e688] button \"新增指标\"" → "button 新增指标"
167
+ // or: "⚠️ Clicked [ref=e2843] button \"确定\" — dialog still open" → "button 确定"
168
+ const descMatch = out.match(/Clicked \[ref=\w+\]\s+(\S+\s+"[^"]*"|\S+)/);
169
+ if (descMatch) {
170
+ // descMatch[1] = 'button "新增指标"' or 'button "确定"' or 'element'
171
+ const clean = descMatch[1].replace(/"/g, '').trim();
172
+ return `click(${clean})`;
173
+ }
174
+ return `click(?)`;
175
+ }
176
+ if (act.tool === 'type') {
177
+ // Parse toolOutput for stable type info
178
+ const typeMatch = out.match(/✅ (replace|append) "([^"]*)" into element \[ref=\w+\]/);
179
+ if (typeMatch)
180
+ return `type("${typeMatch[2].slice(0, 30)}"${typeMatch[1] === 'append' ? ', append' : ''})`;
181
+ // Combobox selection
182
+ const cbMatch = out.match(/✅ Selected "([^"]*)" in combobox/);
183
+ if (cbMatch)
184
+ return `select("${cbMatch[1].slice(0, 30)}")`;
185
+ return `type(?)`;
186
+ }
187
+ if (act.tool === 'fill_form') {
188
+ // Extract filled fields from multi-line toolOutput
189
+ // Lines look like: "✅ textbox [e2755] = "测试指标标准名称"" or "✅ combobox [e2800] = "KwaiBI官方数据集DEMO" (custom)"
190
+ const fields = [];
191
+ for (const line of out.split('\n')) {
192
+ const fieldMatch = line.match(/✅ (\w+) \[\w+\] = "?([^"]*?)"?\s*(\(custom\))?$/);
193
+ if (fieldMatch && fieldMatch[2]) {
194
+ const fType = fieldMatch[1];
195
+ const fVal = fieldMatch[2].slice(0, 25);
196
+ fields.push(`${fType}="${fVal}"`);
197
+ }
198
+ }
199
+ return fields.length ? `fill_form(${fields.join(', ')})` : 'fill_form(...)';
200
+ }
201
+ if (act.tool === 'select_option') {
202
+ const selMatch = out.match(/✅ Selected "([^"]*)"/);
203
+ if (selMatch)
204
+ return `select_option("${selMatch[1].slice(0, 30)}")`;
205
+ return `select_option(?)`;
206
+ }
42
207
  if (act.tool === 'hover')
43
- return `hover(${act.role} "${act.name}")`;
208
+ return `hover(${act.role || ''} "${act.name || ''}")`;
44
209
  if (act.tool === 'pressKey')
45
210
  return `pressKey(${act.key})`;
46
211
  if (act.tool === 'scroll')
47
212
  return `scroll(${act.direction})`;
48
213
  if (act.tool === 'wait')
49
214
  return `wait(${act.time}ms)`;
215
+ if (act.tool === 'execute_javascript') {
216
+ const jsResult = out.match(/✅ JS executed\. Result: (.+)/);
217
+ if (jsResult)
218
+ return `js(→ ${jsResult[1].slice(0, 40)})`;
219
+ return 'js(?)';
220
+ }
50
221
  return act.tool;
51
222
  });
52
- return `Previous successful actions for "${step}": ${summary.join(' → ')}`;
223
+ // Extract key toolOutput lines — discoveries that other goals need to know
224
+ // Focus on: combobox option results (✅/❌), dialog warnings (⚠️), validation messages
225
+ const discoveries = [];
226
+ for (const a of actions) {
227
+ const out = a.toolOutput || '';
228
+ // Combobox option selection results (most valuable for form reuse)
229
+ if (out.includes('combobox') && (out.includes('(custom)') || out.includes('could not find option'))) {
230
+ discoveries.push(out.slice(0, 120));
231
+ }
232
+ // Dialog warnings that blocked submission
233
+ if (out.startsWith('⚠️')) {
234
+ discoveries.push(out.slice(0, 120));
235
+ }
236
+ }
237
+ // Limit to 5 most important discoveries
238
+ const discoveryLines = discoveries.length > 0
239
+ ? `\nKey discoveries: ${discoveries.slice(-5).map(d => `[${d}]`).join('; ')}`
240
+ : '';
241
+ const prefix = isFuzzy
242
+ ? `Previous successful actions for SIMILAR step "${sourceStep}"`
243
+ : `Previous successful actions for "${sourceStep}"`;
244
+ return `${prefix}: ${summary.join(' → ')}${discoveryLines}`;
53
245
  }
54
246
  /** Get all cached entries */
55
247
  getAll() {
@@ -85,6 +85,19 @@ export declare class KevePageAgent {
85
85
  /** Current execute options (set at start of execute(), accessible from hooks) */
86
86
  options?: AgentOptions;
87
87
  private hooks?;
88
+ _dialogHandler: {
89
+ accept: boolean;
90
+ promptText?: string;
91
+ } | null;
92
+ _lastDialogInfo: {
93
+ type: string;
94
+ message: string;
95
+ accepted: boolean;
96
+ } | null;
97
+ _pendingFileChooser: any;
98
+ _consoleMessages: any[];
99
+ _networkRequests: any[];
100
+ _pageListenersSetup: boolean;
88
101
  constructor(page: Page, options?: {
89
102
  maxSteps?: number;
90
103
  customSystemPrompt?: string;
@@ -92,6 +105,9 @@ export declare class KevePageAgent {
92
105
  });
93
106
  /** Stop the current execution */
94
107
  stop(): void;
108
+ /** Set up page event listeners for dialog, console, network, and file chooser capture.
109
+ * Called once per page; subsequent calls are no-ops. */
110
+ private setupPageListeners;
95
111
  /** Main Re-Act execute loop */
96
112
  execute(step: string, expected: string, options?: AgentOptions): Promise<AgentResult>;
97
113
  private pushObservation;
@@ -110,6 +126,11 @@ export declare class KevePageAgent {
110
126
  bbox?: number[];
111
127
  analysis?: string;
112
128
  }>;
129
+ /** Visual assertion: check a visual condition without interacting with the page */
130
+ visualAssertElement(screenshotBase64: string, assertion: string): Promise<{
131
+ passed: boolean;
132
+ reasoning: string;
133
+ }>;
113
134
  /** Build final result */
114
135
  private buildResult;
115
136
  }