@geometra/mcp 1.63.2 → 1.65.0
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.
- package/README.md +29 -7
- package/dist/index.js +2 -5
- package/dist/proxy-spawn.d.ts +29 -0
- package/dist/proxy-spawn.js +49 -17
- package/dist/server.js +1557 -526
- package/dist/session-state.js +262 -80
- package/dist/session.d.ts +189 -13
- package/dist/session.js +1600 -319
- package/dist/state-privacy.d.ts +23 -0
- package/dist/state-privacy.js +171 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.js +32 -0
- package/package.json +4 -4
package/dist/server.js
CHANGED
|
@@ -3,7 +3,9 @@ import { performance } from 'node:perf_hooks';
|
|
|
3
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
|
|
6
|
-
import {
|
|
6
|
+
import { REDACTED_STATE_URL, sanitizeUrlToOrigin } from './state-privacy.js';
|
|
7
|
+
import { SERVER_IMPLEMENTATION } from './version.js';
|
|
8
|
+
import { connect, connectThroughProxy, ensureSessionConnected, disconnect, pruneDisconnectedSessions, resolveSession, listSessions, getDefaultSessionId, prewarmProxy, sendClick, sendFillFields, sendFillOtp, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, sendScreenshot, sendPdfGenerate, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, formSchemaToFormGraph, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, parseSectionId, findNodeByPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
7
9
|
function checkedStateInput() {
|
|
8
10
|
return z
|
|
9
11
|
.union([z.boolean(), z.literal('mixed')])
|
|
@@ -188,7 +190,7 @@ const GEOMETRA_WAIT_FILTER_REQUIRED_MESSAGE = 'Provide at least one semantic fil
|
|
|
188
190
|
'(common for “Parsing…”, “Parsing your resume”, or similar). Passing only present/timeoutMs is not enough without a filter.';
|
|
189
191
|
/** Strict input so unknown keys (e.g. textGone) fail parse; empty-filter checks happen in handlers / waitForSemanticCondition. */
|
|
190
192
|
const sessionIdSchemaField = {
|
|
191
|
-
sessionId: z.string().optional().describe('Session identifier returned by geometra_connect. Omit
|
|
193
|
+
sessionId: z.string().optional().describe('Session identifier returned by geometra_connect or an auto-connected tool. Omit only when exactly one non-isolated session is active.'),
|
|
192
194
|
};
|
|
193
195
|
const geometraQueryInputSchema = z.object({
|
|
194
196
|
...nodeFilterShape(),
|
|
@@ -282,11 +284,87 @@ function capBatchActionTimeouts(action, capMs) {
|
|
|
282
284
|
return action;
|
|
283
285
|
}
|
|
284
286
|
}
|
|
287
|
+
const nonEmptyFieldIdSchema = z.string().trim().min(1, 'fieldId must not be empty');
|
|
288
|
+
const nonEmptyFieldLabelSchema = z.string().trim().min(1, 'fieldLabel must not be empty');
|
|
289
|
+
const nonEmptyToggleLabelSchema = z.string().trim().min(1, 'toggle label must not be empty');
|
|
290
|
+
function incompleteCoordinatePairError(input, first, second) {
|
|
291
|
+
const hasFirst = input[first] !== undefined;
|
|
292
|
+
const hasSecond = input[second] !== undefined;
|
|
293
|
+
return hasFirst === hasSecond
|
|
294
|
+
? undefined
|
|
295
|
+
: `${first} and ${second} must be provided together`;
|
|
296
|
+
}
|
|
297
|
+
function fileUploadContractError(input) {
|
|
298
|
+
const clickPairError = incompleteCoordinatePairError(input, 'x', 'y');
|
|
299
|
+
if (clickPairError)
|
|
300
|
+
return clickPairError;
|
|
301
|
+
const dropPairError = incompleteCoordinatePairError(input, 'dropX', 'dropY');
|
|
302
|
+
if (dropPairError)
|
|
303
|
+
return dropPairError;
|
|
304
|
+
const hasClick = input.x !== undefined && input.y !== undefined;
|
|
305
|
+
const hasDrop = input.dropX !== undefined && input.dropY !== undefined;
|
|
306
|
+
const hasFieldLabel = typeof input.fieldLabel === 'string' && input.fieldLabel.trim().length > 0;
|
|
307
|
+
const hasSemanticConstraint = hasFieldLabel || input.contextText !== undefined || input.sectionText !== undefined || input.exact !== undefined;
|
|
308
|
+
const strategy = input.strategy ?? 'auto';
|
|
309
|
+
if ((input.contextText !== undefined || input.sectionText !== undefined || input.exact !== undefined) && !hasFieldLabel) {
|
|
310
|
+
return 'contextText, sectionText, and exact require fieldLabel';
|
|
311
|
+
}
|
|
312
|
+
if (strategy === 'chooser') {
|
|
313
|
+
if (!hasClick)
|
|
314
|
+
return 'chooser strategy requires x and y';
|
|
315
|
+
if (hasDrop || hasSemanticConstraint)
|
|
316
|
+
return 'chooser strategy accepts only an x,y target; semantic and drop targets cannot be combined';
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
if (strategy === 'drop') {
|
|
320
|
+
if (!hasDrop)
|
|
321
|
+
return 'drop strategy requires dropX and dropY';
|
|
322
|
+
if (hasClick || hasSemanticConstraint)
|
|
323
|
+
return 'drop strategy accepts only a dropX,dropY target; chooser and semantic targets cannot be combined';
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
if (strategy === 'hidden') {
|
|
327
|
+
if (!hasFieldLabel)
|
|
328
|
+
return 'hidden strategy requires fieldLabel to prevent a global first-input fallback';
|
|
329
|
+
if (hasClick || hasDrop)
|
|
330
|
+
return 'hidden strategy does not accept chooser or drop coordinates';
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
if (hasDrop)
|
|
334
|
+
return 'dropX and dropY require strategy="drop"';
|
|
335
|
+
if (hasClick && hasFieldLabel)
|
|
336
|
+
return 'auto strategy requires exactly one target: either x,y or fieldLabel';
|
|
337
|
+
if (!hasClick && !hasFieldLabel)
|
|
338
|
+
return 'upload_files requires an explicit target: fieldLabel, x and y, or strategy="drop" with dropX and dropY';
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
function listboxPickContractError(input) {
|
|
342
|
+
if (typeof input.fieldLabel !== 'string' || input.fieldLabel.trim().length === 0) {
|
|
343
|
+
return 'pick_listbox_option requires fieldLabel so the committed field can be confirmed';
|
|
344
|
+
}
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
function selectOptionContractError(input) {
|
|
348
|
+
const coordinateError = incompleteCoordinatePairError(input, 'x', 'y');
|
|
349
|
+
if (coordinateError)
|
|
350
|
+
return coordinateError;
|
|
351
|
+
if (input.x === undefined || input.y === undefined)
|
|
352
|
+
return 'select_option requires x and y';
|
|
353
|
+
const selectorCount = Number(input.value !== undefined) + Number(input.label !== undefined) + Number(input.index !== undefined);
|
|
354
|
+
if (selectorCount === 0)
|
|
355
|
+
return 'select_option requires at least one of value, label, or index';
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
function addActionContractIssue(ctx, message) {
|
|
359
|
+
if (!message)
|
|
360
|
+
return;
|
|
361
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message });
|
|
362
|
+
}
|
|
285
363
|
const fillFieldSchema = z.union([
|
|
286
364
|
z.object({
|
|
287
365
|
kind: z.literal('text'),
|
|
288
|
-
fieldId:
|
|
289
|
-
fieldLabel:
|
|
366
|
+
fieldId: nonEmptyFieldIdSchema.optional().describe('Optional stable field id from geometra_form_schema'),
|
|
367
|
+
fieldLabel: nonEmptyFieldLabelSchema.describe('Visible field label / accessible name. Optional to duplicate when fieldId is present.'),
|
|
290
368
|
value: z.string().describe('Text value to set'),
|
|
291
369
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
292
370
|
typingDelayMs: z
|
|
@@ -301,11 +379,11 @@ const fillFieldSchema = z.union([
|
|
|
301
379
|
.optional()
|
|
302
380
|
.describe('Use composition-friendly events for IME-heavy controlled fields.'),
|
|
303
381
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
304
|
-
}),
|
|
382
|
+
}).strict(),
|
|
305
383
|
z.object({
|
|
306
384
|
kind: z.literal('text'),
|
|
307
|
-
fieldId:
|
|
308
|
-
fieldLabel:
|
|
385
|
+
fieldId: nonEmptyFieldIdSchema.describe('Stable field id from geometra_form_schema'),
|
|
386
|
+
fieldLabel: nonEmptyFieldLabelSchema.optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
|
|
309
387
|
value: z.string().describe('Text value to set'),
|
|
310
388
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
311
389
|
typingDelayMs: z
|
|
@@ -320,12 +398,13 @@ const fillFieldSchema = z.union([
|
|
|
320
398
|
.optional()
|
|
321
399
|
.describe('Use composition-friendly events for IME-heavy controlled fields.'),
|
|
322
400
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
323
|
-
}),
|
|
401
|
+
}).strict(),
|
|
324
402
|
z.object({
|
|
325
403
|
kind: z.literal('choice'),
|
|
326
|
-
fieldId:
|
|
327
|
-
fieldLabel:
|
|
404
|
+
fieldId: nonEmptyFieldIdSchema.optional().describe('Optional stable field id from geometra_form_schema'),
|
|
405
|
+
fieldLabel: nonEmptyFieldLabelSchema.describe('Visible field label / accessible name. Optional to duplicate when fieldId is present.'),
|
|
328
406
|
value: z.string().describe('Desired option value / answer label'),
|
|
407
|
+
optionIndex: z.number().int().min(0).optional().describe('Exact native <select> option index from optionDetails'),
|
|
329
408
|
query: z.string().optional().describe('Optional search text for searchable comboboxes'),
|
|
330
409
|
choiceType: z
|
|
331
410
|
.enum(['select', 'group', 'listbox'])
|
|
@@ -333,12 +412,13 @@ const fillFieldSchema = z.union([
|
|
|
333
412
|
.describe('Optional choice subtype hint. Use `group` for repeated radio/button answers, `select` for native selects, and `listbox` for searchable dropdowns.'),
|
|
334
413
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
335
414
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
336
|
-
}),
|
|
415
|
+
}).strict(),
|
|
337
416
|
z.object({
|
|
338
417
|
kind: z.literal('choice'),
|
|
339
|
-
fieldId:
|
|
340
|
-
fieldLabel:
|
|
418
|
+
fieldId: nonEmptyFieldIdSchema.describe('Stable field id from geometra_form_schema'),
|
|
419
|
+
fieldLabel: nonEmptyFieldLabelSchema.optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
|
|
341
420
|
value: z.string().describe('Desired option value / answer label'),
|
|
421
|
+
optionIndex: z.number().int().min(0).optional().describe('Exact native <select> option index from optionDetails'),
|
|
342
422
|
query: z.string().optional().describe('Optional search text for searchable comboboxes'),
|
|
343
423
|
choiceType: z
|
|
344
424
|
.enum(['select', 'group', 'listbox'])
|
|
@@ -346,42 +426,51 @@ const fillFieldSchema = z.union([
|
|
|
346
426
|
.describe('Optional choice subtype hint. Use `group` for repeated radio/button answers, `select` for native selects, and `listbox` for searchable dropdowns.'),
|
|
347
427
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
348
428
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
349
|
-
}),
|
|
429
|
+
}).strict(),
|
|
350
430
|
z.object({
|
|
351
431
|
kind: z.literal('toggle'),
|
|
352
|
-
fieldId:
|
|
353
|
-
label:
|
|
432
|
+
fieldId: nonEmptyFieldIdSchema.optional().describe('Optional stable field id from geometra_form_schema'),
|
|
433
|
+
label: nonEmptyToggleLabelSchema.describe('Visible checkbox/radio label to set. Optional to duplicate when fieldId is present.'),
|
|
354
434
|
checked: z.boolean().optional().default(true).describe('Desired checked state (default true)'),
|
|
355
435
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
356
436
|
controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
|
|
357
437
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
358
|
-
}),
|
|
438
|
+
}).strict(),
|
|
359
439
|
z.object({
|
|
360
440
|
kind: z.literal('toggle'),
|
|
361
|
-
fieldId:
|
|
362
|
-
label:
|
|
441
|
+
fieldId: nonEmptyFieldIdSchema.describe('Stable field id from geometra_form_schema'),
|
|
442
|
+
label: nonEmptyToggleLabelSchema.optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
|
|
363
443
|
checked: z.boolean().optional().default(true).describe('Desired checked state (default true)'),
|
|
364
444
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
365
445
|
controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
|
|
366
446
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
367
|
-
}),
|
|
447
|
+
}).strict(),
|
|
368
448
|
z.object({
|
|
369
449
|
kind: z.literal('file'),
|
|
370
|
-
fieldId:
|
|
371
|
-
fieldLabel:
|
|
450
|
+
fieldId: nonEmptyFieldIdSchema.optional().describe('Optional stable field id from geometra_form_schema'),
|
|
451
|
+
fieldLabel: nonEmptyFieldLabelSchema.describe('Visible file-field label / accessible name'),
|
|
372
452
|
paths: z.array(z.string()).min(1).describe('Absolute paths on the proxy machine'),
|
|
373
453
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
374
454
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
375
|
-
}),
|
|
455
|
+
}).strict(),
|
|
376
456
|
z.object({
|
|
377
457
|
kind: z.literal('file'),
|
|
378
|
-
fieldId:
|
|
379
|
-
fieldLabel:
|
|
458
|
+
fieldId: nonEmptyFieldIdSchema.describe('Stable field id from geometra_form_schema'),
|
|
459
|
+
fieldLabel: nonEmptyFieldLabelSchema.optional().describe('Optional when fieldId is present; MCP resolves the current label when file fields are exposed by geometra_form_schema'),
|
|
380
460
|
paths: z.array(z.string()).min(1).describe('Absolute paths on the proxy machine'),
|
|
381
461
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
382
462
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
383
|
-
}),
|
|
463
|
+
}).strict(),
|
|
384
464
|
]);
|
|
465
|
+
function toProxyFillFields(fields) {
|
|
466
|
+
return fields.map(field => {
|
|
467
|
+
const proxyField = { ...field };
|
|
468
|
+
delete proxyField.timeoutMs;
|
|
469
|
+
if (proxyField.kind === 'choice')
|
|
470
|
+
delete proxyField.expectedReadback;
|
|
471
|
+
return proxyField;
|
|
472
|
+
});
|
|
473
|
+
}
|
|
385
474
|
const formValueSchema = z.union([
|
|
386
475
|
z.string(),
|
|
387
476
|
z.boolean(),
|
|
@@ -398,14 +487,14 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
398
487
|
fullyVisible: z.boolean().optional().describe('When clicking by semantic target, require full visibility before clicking (default true)'),
|
|
399
488
|
maxRevealSteps: z.number().int().min(1).max(48).optional().describe('Maximum reveal attempts before clicking a semantic target. When omitted, Geometra auto-scales from scroll distance for tall forms.'),
|
|
400
489
|
revealTimeoutMs: timeoutMsInput.describe('Per-scroll wait timeout while revealing a semantic target'),
|
|
401
|
-
waitFor: z.object(waitConditionShape()).optional().describe('Optional semantic condition to wait for after the click'),
|
|
490
|
+
waitFor: z.object(waitConditionShape()).strict().optional().describe('Optional semantic condition to wait for after the click'),
|
|
402
491
|
timeoutMs: timeoutMsInput,
|
|
403
|
-
}),
|
|
492
|
+
}).strict(),
|
|
404
493
|
z.object({
|
|
405
494
|
type: z.literal('type'),
|
|
406
|
-
text: z.string(),
|
|
495
|
+
text: z.string().max(65_536),
|
|
407
496
|
timeoutMs: timeoutMsInput,
|
|
408
|
-
}),
|
|
497
|
+
}).strict(),
|
|
409
498
|
z.object({
|
|
410
499
|
type: z.literal('key'),
|
|
411
500
|
key: z.string(),
|
|
@@ -414,46 +503,51 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
414
503
|
meta: z.boolean().optional(),
|
|
415
504
|
alt: z.boolean().optional(),
|
|
416
505
|
timeoutMs: timeoutMsInput,
|
|
417
|
-
}),
|
|
506
|
+
}).strict(),
|
|
418
507
|
z.object({
|
|
419
508
|
type: z.literal('upload_files'),
|
|
420
|
-
paths: z.array(z.string()).min(1),
|
|
509
|
+
paths: z.array(z.string().trim().min(1)).min(1),
|
|
421
510
|
x: z.number().optional(),
|
|
422
511
|
y: z.number().optional(),
|
|
423
|
-
fieldLabel: z.string().optional(),
|
|
512
|
+
fieldLabel: z.string().trim().min(1).optional(),
|
|
424
513
|
exact: z.boolean().optional(),
|
|
425
514
|
strategy: z.enum(['auto', 'chooser', 'hidden', 'drop']).optional(),
|
|
426
515
|
dropX: z.number().optional(),
|
|
427
516
|
dropY: z.number().optional(),
|
|
517
|
+
contextText: z.string().trim().min(1).optional(),
|
|
518
|
+
sectionText: z.string().trim().min(1).optional(),
|
|
428
519
|
timeoutMs: timeoutMsInput,
|
|
429
|
-
}),
|
|
520
|
+
}).strict(),
|
|
430
521
|
z.object({
|
|
431
522
|
type: z.literal('pick_listbox_option'),
|
|
432
|
-
label: z.string(),
|
|
523
|
+
label: z.string().trim().min(1),
|
|
433
524
|
exact: z.boolean().optional(),
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
fieldLabel: z.string().
|
|
525
|
+
fieldId: z.string().trim().min(1).optional(),
|
|
526
|
+
fieldKey: z.string().trim().min(1).optional(),
|
|
527
|
+
fieldLabel: z.string().trim().min(1),
|
|
437
528
|
query: z.string().optional(),
|
|
438
529
|
timeoutMs: timeoutMsInput,
|
|
439
|
-
}),
|
|
530
|
+
}).strict(),
|
|
440
531
|
z.object({
|
|
441
532
|
type: z.literal('select_option'),
|
|
442
533
|
x: z.number(),
|
|
443
534
|
y: z.number(),
|
|
444
535
|
value: z.string().optional(),
|
|
445
|
-
label: z.string().optional(),
|
|
536
|
+
label: z.string().trim().min(1).optional(),
|
|
446
537
|
index: z.number().int().min(0).optional(),
|
|
447
538
|
timeoutMs: timeoutMsInput,
|
|
448
|
-
}),
|
|
539
|
+
}).strict(),
|
|
449
540
|
z.object({
|
|
450
541
|
type: z.literal('set_checked'),
|
|
451
|
-
label: z.string(),
|
|
542
|
+
label: z.string().trim().min(1, 'set_checked label must not be empty'),
|
|
452
543
|
checked: z.boolean().optional(),
|
|
453
544
|
exact: z.boolean().optional(),
|
|
454
545
|
controlType: z.enum(['checkbox', 'radio']).optional(),
|
|
546
|
+
fieldKey: z.string().trim().min(1).optional(),
|
|
547
|
+
contextText: z.string().trim().min(1).optional(),
|
|
548
|
+
sectionText: z.string().trim().min(1).optional(),
|
|
455
549
|
timeoutMs: timeoutMsInput,
|
|
456
|
-
}),
|
|
550
|
+
}).strict(),
|
|
457
551
|
z.object({
|
|
458
552
|
type: z.literal('wheel'),
|
|
459
553
|
deltaY: z.number(),
|
|
@@ -461,13 +555,13 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
461
555
|
x: z.number().optional(),
|
|
462
556
|
y: z.number().optional(),
|
|
463
557
|
timeoutMs: timeoutMsInput,
|
|
464
|
-
}),
|
|
558
|
+
}).strict(),
|
|
465
559
|
z.object({
|
|
466
560
|
type: z.literal('wait_for'),
|
|
467
561
|
...nodeFilterShape(),
|
|
468
562
|
present: z.boolean().optional(),
|
|
469
563
|
timeoutMs: timeoutMsInput,
|
|
470
|
-
}),
|
|
564
|
+
}).strict(),
|
|
471
565
|
z.object({
|
|
472
566
|
type: z.literal('fill_fields'),
|
|
473
567
|
fields: z.array(fillFieldSchema).min(1).max(80),
|
|
@@ -475,7 +569,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
475
569
|
.boolean()
|
|
476
570
|
.optional()
|
|
477
571
|
.describe('After filling, read each text/choice field back and flag mismatches (e.g. autocomplete rejected input, format transformed). Adds a `verification` entry to the step.'),
|
|
478
|
-
}),
|
|
572
|
+
}).strict(),
|
|
479
573
|
z.object({
|
|
480
574
|
type: z.literal('expand_section'),
|
|
481
575
|
id: z.string().describe('Stable section id from geometra_page_model (e.g. fm:1.0, ls:2.1).'),
|
|
@@ -492,11 +586,21 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
492
586
|
itemOffset: z.number().int().min(0).optional(),
|
|
493
587
|
maxTextPreview: z.number().int().min(0).max(20).optional(),
|
|
494
588
|
includeBounds: z.boolean().optional(),
|
|
495
|
-
}),
|
|
496
|
-
])
|
|
589
|
+
}).strict(),
|
|
590
|
+
]).superRefine((action, ctx) => {
|
|
591
|
+
if (action.type === 'upload_files') {
|
|
592
|
+
addActionContractIssue(ctx, fileUploadContractError(action));
|
|
593
|
+
}
|
|
594
|
+
else if (action.type === 'pick_listbox_option') {
|
|
595
|
+
addActionContractIssue(ctx, listboxPickContractError(action));
|
|
596
|
+
}
|
|
597
|
+
else if (action.type === 'select_option') {
|
|
598
|
+
addActionContractIssue(ctx, selectOptionContractError(action));
|
|
599
|
+
}
|
|
600
|
+
});
|
|
497
601
|
export function createServer() {
|
|
498
|
-
const server = new McpServer(
|
|
499
|
-
const sessionIdInput = z.string().optional().describe('Session identifier returned by geometra_connect. Omit
|
|
602
|
+
const server = new McpServer(SERVER_IMPLEMENTATION, { capabilities: { tools: {} } });
|
|
603
|
+
const sessionIdInput = z.string().optional().describe('Session identifier returned by geometra_connect or an auto-connected tool. Omit only when exactly one non-isolated session is active.');
|
|
500
604
|
// ── connect ──────────────────────────────────────────────────
|
|
501
605
|
server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
|
|
502
606
|
|
|
@@ -506,11 +610,16 @@ Use \`url\` (ws://…) only when a Geometra/native server or an already-running
|
|
|
506
610
|
|
|
507
611
|
Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth: true\` (or set \`GEOMETRA_STEALTH=1\`) to opt into CloakBrowser's Chromium for authorized testing instead of stock Playwright Chromium. File upload / wheel / native \`<select>\` need the proxy path (\`pageUrl\` or ws to proxy). Set \`returnForms: true\` and/or \`returnPageModel: true\` when you want a lower-turn startup response. When connect first-response latency matters more than inlining the page model, pair \`returnPageModel: true\` with \`pageModelMode: "deferred"\` and call \`geometra_page_model\` next.
|
|
508
612
|
|
|
509
|
-
**
|
|
613
|
+
**Session isolation:** browser sessions started from \`pageUrl\` (or an HTTP(S) \`url\`) are isolated by default: each gets a fresh Chromium that is destroyed on disconnect. Pass \`isolated: false\` explicitly only when you intentionally want sequential warm-browser reuse, including shared cookies, localStorage, and page state. Direct \`ws://\` connections attach to an externally managed endpoint and cannot create browser isolation.`, {
|
|
510
614
|
url: z
|
|
511
615
|
.string()
|
|
512
616
|
.optional()
|
|
513
617
|
.describe('WebSocket URL when a server is already running (e.g. ws://127.0.0.1:3200 or ws://localhost:3100). If you pass http(s) here by mistake, MCP will treat it as a page URL and start geometra-proxy.'),
|
|
618
|
+
authToken: z
|
|
619
|
+
.string()
|
|
620
|
+
.min(32)
|
|
621
|
+
.optional()
|
|
622
|
+
.describe('Bearer capability for an already-running authenticated geometra-proxy. Kept private and never returned in session metadata. Not needed when pageUrl starts the proxy automatically.'),
|
|
514
623
|
pageUrl: z
|
|
515
624
|
.string()
|
|
516
625
|
.url()
|
|
@@ -541,8 +650,7 @@ Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth
|
|
|
541
650
|
isolated: z
|
|
542
651
|
.boolean()
|
|
543
652
|
.optional()
|
|
544
|
-
.
|
|
545
|
-
.describe('When true, bypass the reusable proxy pool and spawn a brand-new Chromium for this session that is destroyed on disconnect. Required for safe parallel form submission — without this, two parallel sessions can land on the same pooled proxy and contaminate each other. Default false (use the pool for speed).'),
|
|
653
|
+
.describe('For pageUrl/HTTP(S) browser connects, omission uses a fresh Chromium that is destroyed on disconnect. Pass false explicitly to opt into sequential warm-browser reuse and shared browser state. Direct ws:// endpoints are externally managed.'),
|
|
546
654
|
proxy: z
|
|
547
655
|
.object({
|
|
548
656
|
server: z
|
|
@@ -576,6 +684,7 @@ Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth
|
|
|
576
684
|
onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields when returnForms=true'),
|
|
577
685
|
onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields when returnForms=true'),
|
|
578
686
|
includeOptions: z.boolean().optional().default(false).describe('Include explicit choice option labels in returned form schemas'),
|
|
687
|
+
includeFormGraph: z.boolean().optional().default(false).describe('Also include FormGraph 0.1-compatible graphs in returned form schemas. Use for paperwork/application workflows that need field paths, review gates, and source anchors.'),
|
|
579
688
|
includeContext: formSchemaContextInput(),
|
|
580
689
|
sinceSchemaId: z.string().optional().describe('If the current schema matches this id, return changed=false without resending forms'),
|
|
581
690
|
schemaFormat: formSchemaFormatInput(),
|
|
@@ -590,12 +699,16 @@ Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth
|
|
|
590
699
|
if (!browser.ok)
|
|
591
700
|
return err(browser.error);
|
|
592
701
|
const target = normalized.value;
|
|
702
|
+
if (target.kind === 'ws' && input.isolated === true) {
|
|
703
|
+
return err('isolated:true is unsupported for direct ws:// endpoints because MCP does not own that browser/runtime. Use pageUrl/HTTP(S) url for an isolated browser, or omit isolated for the externally managed endpoint.');
|
|
704
|
+
}
|
|
593
705
|
const formSchema = {
|
|
594
706
|
formId: input.formId,
|
|
595
707
|
maxFields: input.maxFields,
|
|
596
708
|
onlyRequiredFields: input.onlyRequiredFields,
|
|
597
709
|
onlyInvalidFields: input.onlyInvalidFields,
|
|
598
710
|
includeOptions: input.includeOptions,
|
|
711
|
+
includeFormGraph: input.includeFormGraph,
|
|
599
712
|
includeContext: input.includeContext,
|
|
600
713
|
sinceSchemaId: input.sinceSchemaId,
|
|
601
714
|
format: input.schemaFormat,
|
|
@@ -618,7 +731,7 @@ Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth
|
|
|
618
731
|
height: input.height,
|
|
619
732
|
slowMo: input.slowMo,
|
|
620
733
|
...(browser.stealth !== undefined && { stealth: browser.stealth }),
|
|
621
|
-
isolated: input.isolated,
|
|
734
|
+
isolated: input.isolated !== false,
|
|
622
735
|
proxy: input.proxy,
|
|
623
736
|
awaitInitialFrame: deferInlinePageModel ? false : undefined,
|
|
624
737
|
eagerInitialExtract: deferInlinePageModel ? true : undefined,
|
|
@@ -650,6 +763,7 @@ Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth
|
|
|
650
763
|
width: input.width,
|
|
651
764
|
height: input.height,
|
|
652
765
|
awaitInitialFrame: deferInlinePageModel ? false : undefined,
|
|
766
|
+
authToken: input.authToken,
|
|
653
767
|
});
|
|
654
768
|
if (input.returnForms) {
|
|
655
769
|
await stabilizeInlineFormSchemas(session, formSchema);
|
|
@@ -681,7 +795,7 @@ Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth
|
|
|
681
795
|
// ── prepare browser ──────────────────────────────────────────
|
|
682
796
|
server.tool('geometra_prepare_browser', `Pre-launch and pre-navigate a reusable geometra-proxy browser for a normal web page without creating an active MCP session.
|
|
683
797
|
|
|
684
|
-
Use this when you can prepare ahead of the user-facing task so the next \`geometra_connect\` or one-call \`geometra_run_actions\` on the same \`pageUrl\` / viewport / headless settings skips the cold browser launch.`, {
|
|
798
|
+
Use this when you can prepare ahead of the user-facing task so the next \`geometra_connect\` or one-call \`geometra_run_actions\` on the same \`pageUrl\` / viewport / headless settings skips the cold browser launch. The consuming call must pass \`isolated: false\` explicitly; isolated browser connects intentionally ignore the warm pool.`, {
|
|
685
799
|
pageUrl: z
|
|
686
800
|
.string()
|
|
687
801
|
.url()
|
|
@@ -984,7 +1098,7 @@ Captures the current URL, then polls until the URL changes and a stable UI tree
|
|
|
984
1098
|
});
|
|
985
1099
|
server.tool('geometra_fill_fields', `Fill several labeled form fields in one MCP call. This is the preferred high-level primitive for long forms.
|
|
986
1100
|
|
|
987
|
-
Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / comboboxes / radio-style questions addressed by field label + answer, \`"toggle"\` for individually labeled checkboxes or radios, and \`"file"\` for labeled uploads. When \`fieldId\` from \`geometra_form_schema\` is present, MCP can resolve the current label server-side so you do not need to duplicate \`fieldLabel\` / \`label\` for text, choice,
|
|
1101
|
+
Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / comboboxes / radio-style questions addressed by field label + answer, \`"toggle"\` for individually labeled checkboxes or radios, and \`"file"\` for labeled uploads. When \`fieldId\` from \`geometra_form_schema\` is present, MCP can resolve the current label server-side so you do not need to duplicate \`fieldLabel\` / \`label\` for text, choice, toggle, or file fields.`, {
|
|
988
1102
|
fields: z.array(fillFieldSchema).min(1).max(80).describe('Ordered field operations to apply. Use fieldId from geometra_form_schema to omit duplicate fieldLabel/label on schema-backed fields.'),
|
|
989
1103
|
stopOnError: z.boolean().optional().default(true).describe('Stop at the first failing field (default true)'),
|
|
990
1104
|
failOnInvalid: z
|
|
@@ -1032,12 +1146,21 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
|
|
|
1032
1146
|
fallbackFromBatch = { attempted: true, used: true, reason: 'batched-unavailable', attempts: 2 };
|
|
1033
1147
|
}
|
|
1034
1148
|
catch (e) {
|
|
1149
|
+
const ambiguity = ambiguousOutcomeDetails(e);
|
|
1150
|
+
if (ambiguity) {
|
|
1151
|
+
return err(JSON.stringify({
|
|
1152
|
+
completed: false,
|
|
1153
|
+
execution: 'batched',
|
|
1154
|
+
...ambiguousOutcomePayload(ambiguity),
|
|
1155
|
+
}, null, detail === 'verbose' ? 2 : undefined));
|
|
1156
|
+
}
|
|
1035
1157
|
const message = e instanceof Error ? e.message : String(e);
|
|
1036
1158
|
return err(message);
|
|
1037
1159
|
}
|
|
1038
1160
|
}
|
|
1039
1161
|
const steps = [];
|
|
1040
1162
|
let stoppedAt;
|
|
1163
|
+
let ambiguousAt;
|
|
1041
1164
|
for (let index = 0; index < resolvedFields.fields.length; index++) {
|
|
1042
1165
|
const field = resolvedFields.fields[index];
|
|
1043
1166
|
try {
|
|
@@ -1053,9 +1176,10 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
|
|
|
1053
1176
|
: { index, kind: field.kind, ok: true, ...result.compact });
|
|
1054
1177
|
}
|
|
1055
1178
|
catch (e) {
|
|
1056
|
-
|
|
1179
|
+
let handledError = e;
|
|
1180
|
+
let message = e instanceof Error ? e.message : String(e);
|
|
1057
1181
|
// Retry once for transient selection failures
|
|
1058
|
-
if (message.includes('selection_not_confirmed') || message.includes('still invalid after fill')) {
|
|
1182
|
+
if (!ambiguousOutcomeDetails(e) && (message.includes('selection_not_confirmed') || message.includes('still invalid after fill'))) {
|
|
1059
1183
|
try {
|
|
1060
1184
|
const retryResult = await executeFillField(session, field, detail);
|
|
1061
1185
|
steps.push(detail === 'verbose'
|
|
@@ -1063,12 +1187,25 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
|
|
|
1063
1187
|
: { index, kind: field.kind, ok: true, ...retryResult.compact, retried: true });
|
|
1064
1188
|
continue;
|
|
1065
1189
|
}
|
|
1066
|
-
catch {
|
|
1190
|
+
catch (retryError) {
|
|
1191
|
+
handledError = retryError;
|
|
1192
|
+
message = retryError instanceof Error ? retryError.message : String(retryError);
|
|
1193
|
+
}
|
|
1067
1194
|
}
|
|
1195
|
+
const ambiguity = ambiguousOutcomeDetails(handledError);
|
|
1068
1196
|
const suggestion = isResolvedFillFieldInput(field) ? suggestRecovery(field, message) : undefined;
|
|
1069
|
-
steps.push({
|
|
1070
|
-
|
|
1197
|
+
steps.push({
|
|
1198
|
+
index,
|
|
1199
|
+
kind: field.kind,
|
|
1200
|
+
ok: false,
|
|
1201
|
+
error: message,
|
|
1202
|
+
...(ambiguity ? ambiguousOutcomePayload(ambiguity) : {}),
|
|
1203
|
+
...(suggestion ? { suggestion } : {}),
|
|
1204
|
+
});
|
|
1205
|
+
if (ambiguity || stopOnError) {
|
|
1071
1206
|
stoppedAt = index;
|
|
1207
|
+
if (ambiguity)
|
|
1208
|
+
ambiguousAt = index;
|
|
1072
1209
|
break;
|
|
1073
1210
|
}
|
|
1074
1211
|
}
|
|
@@ -1079,12 +1216,19 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
|
|
|
1079
1216
|
const successCount = steps.filter(step => step.ok === true).length;
|
|
1080
1217
|
const errorCount = steps.length - successCount;
|
|
1081
1218
|
const payload = {
|
|
1082
|
-
completed: stoppedAt === undefined && steps.length === resolvedFields.fields.length,
|
|
1219
|
+
completed: errorCount === 0 && stoppedAt === undefined && steps.length === resolvedFields.fields.length,
|
|
1083
1220
|
fieldCount: resolvedFields.fields.length,
|
|
1084
1221
|
successCount,
|
|
1085
1222
|
errorCount,
|
|
1086
1223
|
...(includeSteps ? { steps } : {}),
|
|
1087
1224
|
...(stoppedAt !== undefined ? { stoppedAt } : {}),
|
|
1225
|
+
...(ambiguousAt !== undefined ? {
|
|
1226
|
+
ambiguous: true,
|
|
1227
|
+
ambiguousAt,
|
|
1228
|
+
resumeBlocked: true,
|
|
1229
|
+
resumeFromIndex: ambiguousAt,
|
|
1230
|
+
resumeInstruction: 'Inspect the current UI before resuming; do not skip the ambiguous field.',
|
|
1231
|
+
} : {}),
|
|
1088
1232
|
...(fallbackFromBatch ? { fallback: fallbackFromBatch } : {}),
|
|
1089
1233
|
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
1090
1234
|
};
|
|
@@ -1138,17 +1282,13 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1138
1282
|
isolated: z
|
|
1139
1283
|
.boolean()
|
|
1140
1284
|
.optional()
|
|
1141
|
-
.default
|
|
1142
|
-
.describe('When auto-connecting via pageUrl/url, request an isolated proxy (own brand-new Chromium, destroyed on disconnect). Required for safe parallel form submission. See geometra_connect for details. Ignored when reusing an existing sessionId — set isolated on the original geometra_connect for that case.'),
|
|
1285
|
+
.describe('When auto-connecting via pageUrl/HTTP(S) url, use a fresh isolated Chromium by default. Pass false explicitly for sequential warm reuse. Ignored when using an existing sessionId.'),
|
|
1143
1286
|
detail: detailInput(),
|
|
1144
1287
|
sessionId: sessionIdInput,
|
|
1145
1288
|
}, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, browserMode, formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, resumeFromIndex, verifyFills, skipPreFilled, isolated, detail, sessionId }) => {
|
|
1146
1289
|
const browser = resolveBrowserStealth({ stealth, browserMode });
|
|
1147
1290
|
if (!browser.ok)
|
|
1148
1291
|
return err(browser.error);
|
|
1149
|
-
const directFields = !includeSteps && !formId && Object.keys(valuesById ?? {}).length === 0
|
|
1150
|
-
? directLabelBatchFields(valuesByLabel)
|
|
1151
|
-
: null;
|
|
1152
1292
|
const resolved = await ensureToolSession({
|
|
1153
1293
|
sessionId,
|
|
1154
1294
|
url,
|
|
@@ -1160,7 +1300,6 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1160
1300
|
slowMo,
|
|
1161
1301
|
stealth: browser.stealth,
|
|
1162
1302
|
isolated,
|
|
1163
|
-
awaitInitialFrame: directFields ? false : undefined,
|
|
1164
1303
|
}, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_fill_form.');
|
|
1165
1304
|
if (!resolved.ok)
|
|
1166
1305
|
return err(resolved.error);
|
|
@@ -1170,50 +1309,6 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1170
1309
|
if (entryCount === 0) {
|
|
1171
1310
|
return err('Provide at least one value in valuesById or valuesByLabel');
|
|
1172
1311
|
}
|
|
1173
|
-
if (directFields) {
|
|
1174
|
-
try {
|
|
1175
|
-
const startRevision = session.updateRevision;
|
|
1176
|
-
const wait = await sendFillFields(session, directFields);
|
|
1177
|
-
const ackResult = parseProxyFillAckResult(wait.result);
|
|
1178
|
-
if (ackResult && ackResult.invalidCount === 0) {
|
|
1179
|
-
recordWorkflowFill(session, undefined, undefined, valuesById, valuesByLabel, 0, directFields.length);
|
|
1180
|
-
return ok(JSON.stringify({
|
|
1181
|
-
...connection,
|
|
1182
|
-
completed: true,
|
|
1183
|
-
execution: 'batched-direct',
|
|
1184
|
-
finalSource: 'proxy',
|
|
1185
|
-
requestedValueCount: entryCount,
|
|
1186
|
-
fieldCount: directFields.length,
|
|
1187
|
-
successCount: directFields.length,
|
|
1188
|
-
errorCount: 0,
|
|
1189
|
-
final: ackResult,
|
|
1190
|
-
}, null, detail === 'verbose' ? 2 : undefined));
|
|
1191
|
-
}
|
|
1192
|
-
await waitForDeferredBatchUpdate(session, startRevision, wait);
|
|
1193
|
-
const afterDirect = sessionA11y(session);
|
|
1194
|
-
const directSignals = afterDirect ? collectSessionSignals(afterDirect) : undefined;
|
|
1195
|
-
if (directSignals && directSignals.invalidFields.length === 0) {
|
|
1196
|
-
recordWorkflowFill(session, undefined, undefined, valuesById, valuesByLabel, 0, directFields.length);
|
|
1197
|
-
return ok(JSON.stringify({
|
|
1198
|
-
...connection,
|
|
1199
|
-
completed: true,
|
|
1200
|
-
execution: 'batched-direct',
|
|
1201
|
-
finalSource: 'session',
|
|
1202
|
-
requestedValueCount: entryCount,
|
|
1203
|
-
fieldCount: directFields.length,
|
|
1204
|
-
successCount: directFields.length,
|
|
1205
|
-
errorCount: 0,
|
|
1206
|
-
final: sessionSignalsPayload(directSignals, detail),
|
|
1207
|
-
}, null, detail === 'verbose' ? 2 : undefined));
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
catch (e) {
|
|
1211
|
-
if (!canFallbackToSequentialFill(e)) {
|
|
1212
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
1213
|
-
return err(message);
|
|
1214
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
1312
|
if (!session.tree || !session.layout) {
|
|
1218
1313
|
await waitForUiCondition(session, () => Boolean(session.tree && session.layout), 2_000);
|
|
1219
1314
|
}
|
|
@@ -1231,7 +1326,8 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1231
1326
|
if (!resolution.ok)
|
|
1232
1327
|
return err(resolution.error);
|
|
1233
1328
|
const schema = resolution.schema;
|
|
1234
|
-
const
|
|
1329
|
+
const targetFormPath = parseSectionId(schema.formId)?.path;
|
|
1330
|
+
const planned = planFormFill(schema, { valuesById, valuesByLabel }, schemas);
|
|
1235
1331
|
if (!planned.ok)
|
|
1236
1332
|
return err(planned.error);
|
|
1237
1333
|
let skippedCount = 0;
|
|
@@ -1265,14 +1361,24 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1265
1361
|
let fallbackFromBatch;
|
|
1266
1362
|
if (!includeSteps) {
|
|
1267
1363
|
let usedBatch = false;
|
|
1268
|
-
let
|
|
1364
|
+
let batchReadbackSnapshot;
|
|
1269
1365
|
try {
|
|
1270
1366
|
const startRevision = session.updateRevision;
|
|
1271
|
-
const wait = await sendFillFields(session, planned.fields);
|
|
1367
|
+
const wait = await sendFillFields(session, toProxyFillFields(planned.fields));
|
|
1368
|
+
assertActionWaitConfirmed('Batched form fill', wait);
|
|
1272
1369
|
const ackResult = parseProxyFillAckResult(wait.result);
|
|
1273
|
-
|
|
1274
|
-
if (
|
|
1275
|
-
|
|
1370
|
+
const batchIncludesFile = planned.fields.some(field => field.kind === 'file');
|
|
1371
|
+
if (batchIncludesFile) {
|
|
1372
|
+
// Legacy proxies do not return a structured fillFields result.
|
|
1373
|
+
// Because a file chooser may already have mutated, replaying the
|
|
1374
|
+
// batch or falling back sequentially would be unsafe. Current
|
|
1375
|
+
// proxies only emit this result after attachFiles confirms the
|
|
1376
|
+
// exact input retained the requested files.
|
|
1377
|
+
if (!ackResult) {
|
|
1378
|
+
throw new AmbiguousActionOutcomeError('Batched file fill', wait, 'Batched file fill was acknowledged without structured proxy confirmation; the upload may already have happened. Do not retry blindly.');
|
|
1379
|
+
}
|
|
1380
|
+
await waitForDeferredBatchUpdate(session, startRevision, wait);
|
|
1381
|
+
const verification = verifyFills ? verifyFormFills(session, planned.planned) : undefined;
|
|
1276
1382
|
const payload = {
|
|
1277
1383
|
...connection,
|
|
1278
1384
|
completed: true,
|
|
@@ -1283,15 +1389,57 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1283
1389
|
fieldCount: planned.fields.length,
|
|
1284
1390
|
successCount: planned.fields.length,
|
|
1285
1391
|
errorCount: 0,
|
|
1392
|
+
...(verification ? { verification } : {}),
|
|
1286
1393
|
final: ackResult,
|
|
1287
1394
|
};
|
|
1395
|
+
recordWorkflowFill(session, schema.formId, schema.name, valuesById, valuesByLabel, ackResult.invalidCount, planned.fields.length);
|
|
1396
|
+
if (failOnInvalid && ackResult.invalidCount > 0) {
|
|
1397
|
+
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
1398
|
+
}
|
|
1288
1399
|
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
1289
1400
|
}
|
|
1401
|
+
if (ackResult && ackResult.invalidCount === 0) {
|
|
1402
|
+
usedBatch = true;
|
|
1403
|
+
if (!verifyFills) {
|
|
1404
|
+
const payload = {
|
|
1405
|
+
...connection,
|
|
1406
|
+
completed: true,
|
|
1407
|
+
execution: 'batched',
|
|
1408
|
+
finalSource: 'proxy',
|
|
1409
|
+
formId: schema.formId,
|
|
1410
|
+
requestedValueCount: entryCount,
|
|
1411
|
+
fieldCount: planned.fields.length,
|
|
1412
|
+
successCount: planned.fields.length,
|
|
1413
|
+
errorCount: 0,
|
|
1414
|
+
final: ackResult,
|
|
1415
|
+
};
|
|
1416
|
+
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1290
1419
|
await waitForDeferredBatchUpdate(session, startRevision, wait);
|
|
1291
|
-
await waitForBatchFieldReadback(session, planned.fields);
|
|
1292
|
-
|
|
1420
|
+
batchReadbackSnapshot = await waitForBatchFieldReadback(session, planned.fields);
|
|
1421
|
+
if (!batchReadbackSnapshot) {
|
|
1422
|
+
if (!sessionA11y(session)) {
|
|
1423
|
+
throw new AmbiguousActionOutcomeError('Batched form fill', wait, 'Batched form fill was sent, but its read-back evidence became unavailable before confirmation. The fields may already have changed. Do not retry blindly.');
|
|
1424
|
+
}
|
|
1425
|
+
usedBatch = false;
|
|
1426
|
+
fallbackFromBatch = { attempted: true, used: true, reason: 'batched-invalid-readback', attempts: 2 };
|
|
1427
|
+
}
|
|
1428
|
+
else {
|
|
1429
|
+
usedBatch = true;
|
|
1430
|
+
}
|
|
1293
1431
|
}
|
|
1294
1432
|
catch (e) {
|
|
1433
|
+
const ambiguity = ambiguousOutcomeDetails(e);
|
|
1434
|
+
if (ambiguity) {
|
|
1435
|
+
return err(JSON.stringify({
|
|
1436
|
+
...connection,
|
|
1437
|
+
completed: false,
|
|
1438
|
+
execution: 'batched',
|
|
1439
|
+
formId: schema.formId,
|
|
1440
|
+
...ambiguousOutcomePayload(ambiguity),
|
|
1441
|
+
}, null, detail === 'verbose' ? 2 : undefined));
|
|
1442
|
+
}
|
|
1295
1443
|
if (!canFallbackToSequentialFill(e)) {
|
|
1296
1444
|
const message = e instanceof Error ? e.message : String(e);
|
|
1297
1445
|
return err(message);
|
|
@@ -1299,18 +1447,25 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1299
1447
|
fallbackFromBatch = { attempted: true, used: true, reason: 'batched-threw', attempts: 2 };
|
|
1300
1448
|
}
|
|
1301
1449
|
if (usedBatch) {
|
|
1302
|
-
const after =
|
|
1303
|
-
const signals = after
|
|
1450
|
+
const after = batchReadbackSnapshot;
|
|
1451
|
+
const signals = after
|
|
1452
|
+
? scopeSessionSignalsToForm(after, targetFormPath ? formNodeAtPath(after, targetFormPath) : undefined, targetFormPath !== undefined)
|
|
1453
|
+
: undefined;
|
|
1304
1454
|
const invalidRemaining = signals?.invalidFields.length ?? 0;
|
|
1305
|
-
if (
|
|
1455
|
+
if (invalidRemaining > 0) {
|
|
1306
1456
|
usedBatch = false;
|
|
1307
1457
|
fallbackFromBatch = { attempted: true, used: true, reason: 'batched-invalid-readback', attempts: 2 };
|
|
1308
1458
|
}
|
|
1309
1459
|
}
|
|
1310
1460
|
if (usedBatch) {
|
|
1311
|
-
const after =
|
|
1312
|
-
const signals = after
|
|
1461
|
+
const after = batchReadbackSnapshot;
|
|
1462
|
+
const signals = after
|
|
1463
|
+
? scopeSessionSignalsToForm(after, targetFormPath ? formNodeAtPath(after, targetFormPath) : undefined, targetFormPath !== undefined)
|
|
1464
|
+
: undefined;
|
|
1313
1465
|
const invalidRemaining = signals?.invalidFields.length ?? 0;
|
|
1466
|
+
const verification = verifyFills
|
|
1467
|
+
? verifyFormFills(session, planned.planned, batchReadbackSnapshot)
|
|
1468
|
+
: undefined;
|
|
1314
1469
|
const payload = {
|
|
1315
1470
|
...connection,
|
|
1316
1471
|
completed: true,
|
|
@@ -1324,6 +1479,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1324
1479
|
minConfidence: planned.planned.length > 0
|
|
1325
1480
|
? Number(Math.min(...planned.planned.map(p => p.confidence)).toFixed(2))
|
|
1326
1481
|
: undefined,
|
|
1482
|
+
...(verification ? { verification } : {}),
|
|
1327
1483
|
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
1328
1484
|
};
|
|
1329
1485
|
recordWorkflowFill(session, schema.formId, schema.name, valuesById, valuesByLabel, invalidRemaining, planned.fields.length);
|
|
@@ -1335,6 +1491,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1335
1491
|
}
|
|
1336
1492
|
const steps = [];
|
|
1337
1493
|
let stoppedAt;
|
|
1494
|
+
let ambiguousAt;
|
|
1338
1495
|
const startIndex = resumeFromIndex ?? 0;
|
|
1339
1496
|
for (let index = startIndex; index < planned.fields.length; index++) {
|
|
1340
1497
|
const field = planned.fields[index];
|
|
@@ -1349,23 +1506,36 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1349
1506
|
}
|
|
1350
1507
|
catch (e) {
|
|
1351
1508
|
const message = e instanceof Error ? e.message : String(e);
|
|
1509
|
+
const ambiguity = ambiguousOutcomeDetails(e);
|
|
1352
1510
|
const suggestion = suggestRecovery(field, message);
|
|
1353
|
-
steps.push({
|
|
1354
|
-
|
|
1511
|
+
steps.push({
|
|
1512
|
+
index,
|
|
1513
|
+
kind: field.kind,
|
|
1514
|
+
ok: false,
|
|
1515
|
+
...(confidence !== undefined ? { confidence, matchMethod } : {}),
|
|
1516
|
+
error: message,
|
|
1517
|
+
...(ambiguity ? ambiguousOutcomePayload(ambiguity) : {}),
|
|
1518
|
+
...(suggestion ? { suggestion } : {}),
|
|
1519
|
+
});
|
|
1520
|
+
if (ambiguity || stopOnError) {
|
|
1355
1521
|
stoppedAt = index;
|
|
1522
|
+
if (ambiguity)
|
|
1523
|
+
ambiguousAt = index;
|
|
1356
1524
|
break;
|
|
1357
1525
|
}
|
|
1358
1526
|
}
|
|
1359
1527
|
}
|
|
1360
1528
|
const after = sessionA11y(session);
|
|
1361
|
-
const signals = after
|
|
1529
|
+
const signals = after
|
|
1530
|
+
? scopeSessionSignalsToForm(after, targetFormPath ? formNodeAtPath(after, targetFormPath) : undefined, targetFormPath !== undefined)
|
|
1531
|
+
: undefined;
|
|
1362
1532
|
const invalidRemaining = signals?.invalidFields.length ?? 0;
|
|
1363
1533
|
const successCount = steps.filter(step => step.ok === true).length;
|
|
1364
1534
|
const errorCount = steps.length - successCount;
|
|
1365
1535
|
const verification = verifyFills ? verifyFormFills(session, planned.planned) : undefined;
|
|
1366
1536
|
const payload = {
|
|
1367
1537
|
...connection,
|
|
1368
|
-
completed: stoppedAt === undefined && (startIndex + steps.length) === planned.fields.length,
|
|
1538
|
+
completed: errorCount === 0 && stoppedAt === undefined && (startIndex + steps.length) === planned.fields.length,
|
|
1369
1539
|
execution: 'sequential',
|
|
1370
1540
|
formId: schema.formId,
|
|
1371
1541
|
requestedValueCount: entryCount,
|
|
@@ -1378,7 +1548,16 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1378
1548
|
: undefined,
|
|
1379
1549
|
...(startIndex > 0 ? { resumedFromIndex: startIndex } : {}),
|
|
1380
1550
|
...(includeSteps ? { steps } : {}),
|
|
1381
|
-
...(stoppedAt !== undefined ? {
|
|
1551
|
+
...(stoppedAt !== undefined ? {
|
|
1552
|
+
stoppedAt,
|
|
1553
|
+
resumeFromIndex: ambiguousAt ?? (stoppedAt + 1),
|
|
1554
|
+
} : {}),
|
|
1555
|
+
...(ambiguousAt !== undefined ? {
|
|
1556
|
+
ambiguous: true,
|
|
1557
|
+
ambiguousAt,
|
|
1558
|
+
resumeBlocked: true,
|
|
1559
|
+
resumeInstruction: 'Inspect the current UI before resuming; do not skip the ambiguous field.',
|
|
1560
|
+
} : {}),
|
|
1382
1561
|
...(fallbackFromBatch ? { fallback: fallbackFromBatch } : {}),
|
|
1383
1562
|
...(verification ? { verification } : {}),
|
|
1384
1563
|
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
@@ -1410,7 +1589,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1410
1589
|
|
|
1411
1590
|
Pass \`valuesById\` or \`valuesByLabel\` to populate fields, \`submit\` to target the submit button (default: semantic \`{ role: 'button', name: 'Submit' }\`), and \`waitFor\` to block on the post-submit state (success banner, navigation, submit button gone, etc.). Navigation is detected automatically and surfaced as \`navigated: true\` with \`afterUrl\`.
|
|
1412
1591
|
|
|
1413
|
-
Pass \`pageUrl\`/\`url\` to auto-connect in the same call
|
|
1592
|
+
Pass \`pageUrl\`/\`url\` to auto-connect in the same call. These browser connects are isolated by default; pass \`isolated: false\` only for intentional sequential warm reuse.`, {
|
|
1414
1593
|
url: z.string().optional().describe('Optional target URL. Use a ws:// Geometra server URL or an http(s) page URL to auto-connect before submitting.'),
|
|
1415
1594
|
pageUrl: z.string().optional().describe('Optional http(s) page URL to auto-connect before submitting. Prefer this over url for browser pages.'),
|
|
1416
1595
|
port: z.number().int().min(0).max(65535).optional().describe('Preferred local port for an auto-spawned proxy (default: ephemeral OS-assigned port).'),
|
|
@@ -1420,17 +1599,17 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1420
1599
|
slowMo: z.number().int().nonnegative().optional().describe('Playwright slowMo (ms) when auto-spawning a proxy.'),
|
|
1421
1600
|
stealth: stealthInput(),
|
|
1422
1601
|
browserMode: browserModeInput(),
|
|
1423
|
-
isolated: z.boolean().optional().
|
|
1602
|
+
isolated: z.boolean().optional().describe('When auto-connecting via pageUrl/HTTP(S) url, use a fresh isolated Chromium by default. Pass false explicitly for sequential warm reuse.'),
|
|
1424
1603
|
formId: z.string().optional().describe('Optional form id from geometra_form_schema or geometra_page_model'),
|
|
1425
1604
|
valuesById: formValuesRecordSchema.optional().describe('Form values keyed by stable field id from geometra_form_schema'),
|
|
1426
1605
|
valuesByLabel: formValuesRecordSchema.optional().describe('Form values keyed by schema field label'),
|
|
1427
|
-
submit: z.object(nodeFilterShape()).optional().describe('Semantic target for the submit button. Defaults to {role: "button", name: "Submit"}.'),
|
|
1606
|
+
submit: z.object(nodeFilterShape()).strict().optional().describe('Semantic target for the submit button. Defaults to {role: "button", name: "Submit"}.'),
|
|
1428
1607
|
submitIndex: z.number().int().min(0).optional().default(0).describe('Which matching submit target to click after sorting top-to-bottom (default 0)'),
|
|
1429
1608
|
submitTimeoutMs: z.number().int().min(50).max(60_000).optional().default(15_000).describe('Action wait timeout for the submit click (default 15000ms). Increase for slow backends.'),
|
|
1430
|
-
waitFor: z.object(waitConditionShape()).optional().describe('Optional semantic condition to wait for after the submit click (success banner,
|
|
1609
|
+
waitFor: z.object(waitConditionShape()).strict().optional().describe('Optional semantic condition to wait for after the submit click (success banner, submit gone, etc.)'),
|
|
1431
1610
|
skipFill: z.boolean().optional().default(false).describe('Skip the fill phase and go straight to submit+wait. Use when values have already been filled by a previous call.'),
|
|
1432
1611
|
softTimeoutMs: softTimeoutMsInput(),
|
|
1433
|
-
failOnInvalid: z.boolean().optional().default(false).describe('
|
|
1612
|
+
failOnInvalid: z.boolean().optional().default(false).describe('Control whether a validation_failed outcome is returned as an MCP error. Outcome facts are always reported.'),
|
|
1434
1613
|
detail: detailInput(),
|
|
1435
1614
|
sessionId: sessionIdInput,
|
|
1436
1615
|
}, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, browserMode, isolated, formId, valuesById, valuesByLabel, submit, submitIndex, submitTimeoutMs, waitFor, skipFill, softTimeoutMs, failOnInvalid, detail, sessionId }) => {
|
|
@@ -1445,28 +1624,37 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1445
1624
|
return err(resolved.error);
|
|
1446
1625
|
const session = resolved.session;
|
|
1447
1626
|
const connection = autoConnectionPayload(resolved);
|
|
1627
|
+
let targetFormId = formId;
|
|
1628
|
+
let targetFormIdentity;
|
|
1448
1629
|
let fillSummary;
|
|
1449
1630
|
let fillFallback;
|
|
1450
|
-
const pausedPayload = (phase, resumeHint, extra) =>
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1631
|
+
const pausedPayload = (phase, resumeHint, extra) => {
|
|
1632
|
+
const resumesSubmitForm = resumeHint?.tool === 'geometra_submit_form';
|
|
1633
|
+
const scopedResumeHint = {
|
|
1634
|
+
...(resumeHint ?? {}),
|
|
1635
|
+
...(resumesSubmitForm && targetFormId ? { formId: targetFormId } : {}),
|
|
1636
|
+
};
|
|
1637
|
+
return {
|
|
1638
|
+
...connection,
|
|
1639
|
+
completed: false,
|
|
1640
|
+
outcome: 'unconfirmed',
|
|
1641
|
+
paused: true,
|
|
1642
|
+
phase,
|
|
1643
|
+
pauseReason: 'soft-timeout',
|
|
1644
|
+
softTimeoutMs: effectiveSoftTimeoutMs,
|
|
1645
|
+
elapsedMs: Number((performance.now() - toolStartedAt).toFixed(1)),
|
|
1646
|
+
...(Object.keys(scopedResumeHint).length > 0 ? { resumeHint: scopedResumeHint } : {}),
|
|
1647
|
+
...(fillSummary ? { fill: fillSummary } : {}),
|
|
1648
|
+
...(fillFallback ? { fill_fallback: fillFallback } : {}),
|
|
1649
|
+
...(extra ?? {}),
|
|
1650
|
+
};
|
|
1651
|
+
};
|
|
1463
1652
|
if (!session.tree || !session.layout) {
|
|
1464
1653
|
await waitForUiCondition(session, () => Boolean(session.tree && session.layout), capTimeoutMs(2_000, timeoutCapFromDeadline(deadlineAt), 2_000));
|
|
1465
1654
|
}
|
|
1466
1655
|
const entryA11y = sessionA11y(session);
|
|
1467
1656
|
if (!entryA11y)
|
|
1468
1657
|
return err('No UI tree available for form submission');
|
|
1469
|
-
const entryUrl = entryA11y.meta?.pageUrl;
|
|
1470
1658
|
if (!hasSoftBudget(deadlineAt)) {
|
|
1471
1659
|
return ok(JSON.stringify(pausedPayload('before-fill', { retrySameCall: true }), null, detail === 'verbose' ? 2 : undefined));
|
|
1472
1660
|
}
|
|
@@ -1482,13 +1670,47 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1482
1670
|
if (!resolution.ok)
|
|
1483
1671
|
return err(resolution.error);
|
|
1484
1672
|
const schema = resolution.schema;
|
|
1485
|
-
|
|
1673
|
+
targetFormId = schema.formId;
|
|
1674
|
+
const initialFormPath = parseSectionId(schema.formId);
|
|
1675
|
+
const initialFormNode = initialFormPath?.kind === 'form'
|
|
1676
|
+
? formScopeNodeAtPath(entryA11y, initialFormPath.path)
|
|
1677
|
+
: undefined;
|
|
1678
|
+
if (!initialFormNode) {
|
|
1679
|
+
return err(`Target form ${schema.formId} does not resolve to a maintainable form scope before fill`);
|
|
1680
|
+
}
|
|
1681
|
+
const capturedIdentity = captureFormScopeIdentity(initialFormNode, schema);
|
|
1682
|
+
if (!capturedIdentity.ok)
|
|
1683
|
+
return err(capturedIdentity.error);
|
|
1684
|
+
targetFormIdentity = capturedIdentity.identity;
|
|
1685
|
+
const planned = planFormFill(schema, { valuesById, valuesByLabel }, schemas);
|
|
1486
1686
|
if (!planned.ok)
|
|
1487
1687
|
return err(planned.error);
|
|
1488
1688
|
let usedBatch = true;
|
|
1489
1689
|
try {
|
|
1490
1690
|
const startRevision = session.updateRevision;
|
|
1491
|
-
const wait = await sendFillFields(session, planned.fields
|
|
1691
|
+
const wait = await sendFillFields(session, toProxyFillFields(planned.fields), capTimeoutMs(undefined, timeoutCapFromDeadline(deadlineAt), HOST_SAFE_TOOL_TIMEOUT_MS));
|
|
1692
|
+
if (wait.status === 'timed_out') {
|
|
1693
|
+
fillSummary = {
|
|
1694
|
+
formId: schema.formId,
|
|
1695
|
+
execution: 'batched',
|
|
1696
|
+
fieldCount: planned.fields.length,
|
|
1697
|
+
...waitStatusPayload(wait),
|
|
1698
|
+
...(entryCount !== planned.fields.length ? { requestedValueCount: entryCount } : {}),
|
|
1699
|
+
};
|
|
1700
|
+
const guidance = ambiguousActionGuidance(wait);
|
|
1701
|
+
return ok(JSON.stringify(pausedPayload('fill', {
|
|
1702
|
+
tool: 'geometra_submit_form',
|
|
1703
|
+
retrySameCall: false,
|
|
1704
|
+
inspectCurrentUiFirst: true,
|
|
1705
|
+
skipFill: false,
|
|
1706
|
+
actionId: wait.actionId,
|
|
1707
|
+
requestId: wait.requestId,
|
|
1708
|
+
}, {
|
|
1709
|
+
pauseReason: 'action-outcome-ambiguous',
|
|
1710
|
+
retrySafety: 'inspect-first',
|
|
1711
|
+
guidance,
|
|
1712
|
+
}), null, detail === 'verbose' ? 2 : undefined));
|
|
1713
|
+
}
|
|
1492
1714
|
const ack = parseProxyFillAckResult(wait.result);
|
|
1493
1715
|
await waitForDeferredBatchUpdate(session, startRevision, wait);
|
|
1494
1716
|
fillSummary = {
|
|
@@ -1497,19 +1719,23 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1497
1719
|
fieldCount: planned.fields.length,
|
|
1498
1720
|
...waitStatusPayload(wait),
|
|
1499
1721
|
...(ack ? { invalidCount: ack.invalidCount, alertCount: ack.alertCount } : {}),
|
|
1722
|
+
...(ack?.invalidFields ? { invalidFields: ack.invalidFields } : {}),
|
|
1500
1723
|
...(entryCount !== planned.fields.length ? { requestedValueCount: entryCount } : {}),
|
|
1501
1724
|
};
|
|
1502
|
-
if (wait.status === 'timed_out') {
|
|
1503
|
-
return ok(JSON.stringify(pausedPayload('fill', {
|
|
1504
|
-
tool: 'geometra_submit_form',
|
|
1505
|
-
skipFill: true,
|
|
1506
|
-
submit: submit ?? { role: 'button', name: 'Submit' },
|
|
1507
|
-
submitIndex,
|
|
1508
|
-
...(waitFor ? { waitFor } : {}),
|
|
1509
|
-
}), null, detail === 'verbose' ? 2 : undefined));
|
|
1510
|
-
}
|
|
1511
1725
|
}
|
|
1512
1726
|
catch (e) {
|
|
1727
|
+
const ambiguity = ambiguousOutcomeDetails(e);
|
|
1728
|
+
if (ambiguity) {
|
|
1729
|
+
return err(JSON.stringify({
|
|
1730
|
+
...connection,
|
|
1731
|
+
completed: false,
|
|
1732
|
+
paused: true,
|
|
1733
|
+
phase: 'fill',
|
|
1734
|
+
pauseReason: 'action-outcome-ambiguous',
|
|
1735
|
+
...ambiguousOutcomePayload(ambiguity),
|
|
1736
|
+
submit: { attempted: false, reason: 'Fill outcome must be inspected before submission.' },
|
|
1737
|
+
}, null, detail === 'verbose' ? 2 : undefined));
|
|
1738
|
+
}
|
|
1513
1739
|
if (!canFallbackToSequentialFill(e)) {
|
|
1514
1740
|
const message = e instanceof Error ? e.message : String(e);
|
|
1515
1741
|
return err(`Failed to fill form before submit: ${message}`);
|
|
@@ -1520,6 +1746,7 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1520
1746
|
if (!usedBatch) {
|
|
1521
1747
|
let successCount = 0;
|
|
1522
1748
|
let firstErr;
|
|
1749
|
+
let firstAmbiguity;
|
|
1523
1750
|
for (const field of planned.fields) {
|
|
1524
1751
|
if (!hasSoftBudget(deadlineAt)) {
|
|
1525
1752
|
fillSummary = {
|
|
@@ -1531,10 +1758,8 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1531
1758
|
};
|
|
1532
1759
|
return ok(JSON.stringify(pausedPayload('fill', {
|
|
1533
1760
|
tool: 'geometra_submit_form',
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
submitIndex,
|
|
1537
|
-
...(waitFor ? { waitFor } : {}),
|
|
1761
|
+
retrySameCall: true,
|
|
1762
|
+
skipFill: false,
|
|
1538
1763
|
}), null, detail === 'verbose' ? 2 : undefined));
|
|
1539
1764
|
}
|
|
1540
1765
|
try {
|
|
@@ -1543,19 +1768,42 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1543
1768
|
}
|
|
1544
1769
|
catch (e) {
|
|
1545
1770
|
firstErr = e instanceof Error ? e.message : String(e);
|
|
1771
|
+
firstAmbiguity = ambiguousOutcomeDetails(e);
|
|
1546
1772
|
break;
|
|
1547
1773
|
}
|
|
1548
1774
|
}
|
|
1549
|
-
if (firstErr !== undefined && successCount === 0) {
|
|
1550
|
-
return err(`Failed to fill form before submit (sequential fallback): ${firstErr}`);
|
|
1551
|
-
}
|
|
1552
1775
|
fillSummary = {
|
|
1553
1776
|
formId: schema.formId,
|
|
1554
1777
|
execution: 'sequential',
|
|
1555
1778
|
fieldCount: planned.fields.length,
|
|
1556
1779
|
successCount,
|
|
1780
|
+
...(firstErr !== undefined ? { error: firstErr } : {}),
|
|
1557
1781
|
...(entryCount !== planned.fields.length ? { requestedValueCount: entryCount } : {}),
|
|
1558
1782
|
};
|
|
1783
|
+
if (firstErr !== undefined) {
|
|
1784
|
+
if (firstAmbiguity) {
|
|
1785
|
+
return err(JSON.stringify({
|
|
1786
|
+
...connection,
|
|
1787
|
+
completed: false,
|
|
1788
|
+
paused: true,
|
|
1789
|
+
phase: 'fill',
|
|
1790
|
+
pauseReason: 'action-outcome-ambiguous',
|
|
1791
|
+
fill: fillSummary,
|
|
1792
|
+
...ambiguousOutcomePayload(firstAmbiguity),
|
|
1793
|
+
submit: { attempted: false, reason: 'Fill outcome must be inspected before submission.' },
|
|
1794
|
+
}, null, detail === 'verbose' ? 2 : undefined));
|
|
1795
|
+
}
|
|
1796
|
+
return err(JSON.stringify({
|
|
1797
|
+
...connection,
|
|
1798
|
+
completed: false,
|
|
1799
|
+
execution: 'unconfirmed',
|
|
1800
|
+
outcome: 'unconfirmed',
|
|
1801
|
+
phase: 'fill',
|
|
1802
|
+
fill: fillSummary,
|
|
1803
|
+
...(fillFallback ? { fill_fallback: fillFallback } : {}),
|
|
1804
|
+
submit: { attempted: false, reason: 'Fill phase did not complete successfully.' },
|
|
1805
|
+
}, null, detail === 'verbose' ? 2 : undefined));
|
|
1806
|
+
}
|
|
1559
1807
|
}
|
|
1560
1808
|
}
|
|
1561
1809
|
const submitResumeHint = {
|
|
@@ -1569,110 +1817,217 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1569
1817
|
return ok(JSON.stringify(pausedPayload('submit', submitResumeHint), null, detail === 'verbose' ? 2 : undefined));
|
|
1570
1818
|
}
|
|
1571
1819
|
const submitFilter = submit ?? { role: 'button', name: 'Submit' };
|
|
1820
|
+
const submitTargetFailurePayload = (error, fallback) => ({
|
|
1821
|
+
...connection,
|
|
1822
|
+
completed: false,
|
|
1823
|
+
outcome: 'unconfirmed',
|
|
1824
|
+
...(fillSummary ? { fill: fillSummary } : {}),
|
|
1825
|
+
...(fillFallback ? { fill_fallback: fillFallback } : {}),
|
|
1826
|
+
submit: { ok: false, error: `Submit target not found: ${error}` },
|
|
1827
|
+
...(fallback ? { submit_fallback: fallback } : {}),
|
|
1828
|
+
});
|
|
1829
|
+
let submitFormNode;
|
|
1830
|
+
let submitFormPath;
|
|
1831
|
+
let formScope;
|
|
1832
|
+
if (targetFormId) {
|
|
1833
|
+
const currentA11y = sessionA11y(session);
|
|
1834
|
+
const parsedForm = parseSectionId(targetFormId);
|
|
1835
|
+
const currentSchema = currentA11y
|
|
1836
|
+
? getSessionFormSchemas(session, { includeOptions: false, includeContext: 'auto' })
|
|
1837
|
+
.find(schema => schema.formId === targetFormId)
|
|
1838
|
+
: undefined;
|
|
1839
|
+
const currentForm = currentA11y && parsedForm?.kind === 'form'
|
|
1840
|
+
? formScopeNodeAtPath(currentA11y, parsedForm.path)
|
|
1841
|
+
: undefined;
|
|
1842
|
+
if (!currentA11y || parsedForm?.kind !== 'form' || !currentSchema || !currentForm) {
|
|
1843
|
+
return err(JSON.stringify(submitTargetFailurePayload(`Target form ${targetFormId} has an invalid or stale form path`), null, detail === 'verbose' ? 2 : undefined));
|
|
1844
|
+
}
|
|
1845
|
+
if (!targetFormIdentity) {
|
|
1846
|
+
const capturedIdentity = captureFormScopeIdentity(currentForm, currentSchema);
|
|
1847
|
+
if (!capturedIdentity.ok) {
|
|
1848
|
+
return err(JSON.stringify(submitTargetFailurePayload(capturedIdentity.error), null, detail === 'verbose' ? 2 : undefined));
|
|
1849
|
+
}
|
|
1850
|
+
targetFormIdentity = capturedIdentity.identity;
|
|
1851
|
+
}
|
|
1852
|
+
submitFormPath = [...parsedForm.path];
|
|
1853
|
+
formScope = { formId: targetFormId, path: submitFormPath, identity: targetFormIdentity };
|
|
1854
|
+
const scoped = currentFormScope(session, currentA11y, formScope);
|
|
1855
|
+
if (!scoped.ok) {
|
|
1856
|
+
return err(JSON.stringify(submitTargetFailurePayload(scoped.error), null, detail === 'verbose' ? 2 : undefined));
|
|
1857
|
+
}
|
|
1858
|
+
submitFormNode = scoped.node;
|
|
1859
|
+
}
|
|
1572
1860
|
const resolvedClick = await resolveClickLocationWithFallback(session, {
|
|
1573
1861
|
filter: submitFilter,
|
|
1574
1862
|
index: submitIndex,
|
|
1575
1863
|
fullyVisible: true,
|
|
1864
|
+
formScope,
|
|
1576
1865
|
revealTimeoutMs: capTimeoutMs(2_500, timeoutCapFromDeadline(deadlineAt), 2_500),
|
|
1577
1866
|
});
|
|
1578
1867
|
if (!resolvedClick.ok) {
|
|
1868
|
+
const payload = submitTargetFailurePayload(resolvedClick.error, resolvedClick.fallback);
|
|
1869
|
+
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
1870
|
+
}
|
|
1871
|
+
const preSubmitA11y = sessionA11y(session);
|
|
1872
|
+
let submitPoint = { x: resolvedClick.value.x, y: resolvedClick.value.y };
|
|
1873
|
+
if (formScope) {
|
|
1874
|
+
if (!preSubmitA11y) {
|
|
1875
|
+
return err(JSON.stringify(submitTargetFailurePayload(`Target form ${formScope.formId} cannot be revalidated because the current UI tree is unavailable`), null, detail === 'verbose' ? 2 : undefined));
|
|
1876
|
+
}
|
|
1877
|
+
const scoped = currentFormScope(session, preSubmitA11y, formScope);
|
|
1878
|
+
const targetPath = resolvedClick.value.target?.path;
|
|
1879
|
+
const currentTarget = targetPath ? findNodeByPath(preSubmitA11y, targetPath) : undefined;
|
|
1880
|
+
const targetContext = currentTarget ? nodeContextForNode(preSubmitA11y, currentTarget) : undefined;
|
|
1881
|
+
if (!scoped.ok ||
|
|
1882
|
+
!currentTarget ||
|
|
1883
|
+
!targetPath ||
|
|
1884
|
+
!pathIsStrictDescendant(targetPath, formScope.path) ||
|
|
1885
|
+
!nodeMatchesFilter(currentTarget, submitFilter, targetContext)) {
|
|
1886
|
+
return err(JSON.stringify(submitTargetFailurePayload(`Target form ${formScope.formId} or its selected submit control became stale before click`), null, detail === 'verbose' ? 2 : undefined));
|
|
1887
|
+
}
|
|
1888
|
+
const currentCenter = currentVisibleClickCenter(preSubmitA11y, currentTarget);
|
|
1889
|
+
if (!currentCenter) {
|
|
1890
|
+
return err(JSON.stringify(submitTargetFailurePayload(`Target form ${formScope.formId} submit control is no longer visible at a usable click point`), null, detail === 'verbose' ? 2 : undefined));
|
|
1891
|
+
}
|
|
1892
|
+
submitPoint = currentCenter;
|
|
1893
|
+
submitFormNode = scoped.node;
|
|
1894
|
+
}
|
|
1895
|
+
else if (preSubmitA11y) {
|
|
1896
|
+
submitFormNode = resolveSubmitFormNode(preSubmitA11y, undefined, resolvedClick.value.target?.path);
|
|
1897
|
+
submitFormPath = submitFormNode?.path;
|
|
1898
|
+
}
|
|
1899
|
+
const preSubmitSignals = preSubmitA11y
|
|
1900
|
+
? scopeSessionSignalsToForm(preSubmitA11y, submitFormNode)
|
|
1901
|
+
: undefined;
|
|
1902
|
+
if (preSubmitSignals && preSubmitSignals.invalidFields.length > 0) {
|
|
1579
1903
|
const payload = {
|
|
1580
1904
|
...connection,
|
|
1581
1905
|
completed: false,
|
|
1906
|
+
execution: 'not_started',
|
|
1907
|
+
outcome: 'validation_failed',
|
|
1582
1908
|
...(fillSummary ? { fill: fillSummary } : {}),
|
|
1583
1909
|
...(fillFallback ? { fill_fallback: fillFallback } : {}),
|
|
1584
|
-
submit: {
|
|
1585
|
-
|
|
1910
|
+
submit: {
|
|
1911
|
+
attempted: false,
|
|
1912
|
+
reason: 'Invalid fields remain in the target form before submit; the submit control was not clicked.',
|
|
1913
|
+
},
|
|
1914
|
+
final: sessionSignalsPayload(preSubmitSignals, detail),
|
|
1586
1915
|
};
|
|
1587
|
-
|
|
1916
|
+
const serialized = JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined);
|
|
1917
|
+
return failOnInvalid ? err(serialized) : ok(serialized);
|
|
1588
1918
|
}
|
|
1589
1919
|
if (!hasSoftBudget(deadlineAt)) {
|
|
1590
1920
|
return ok(JSON.stringify(pausedPayload('submit', submitResumeHint), null, detail === 'verbose' ? 2 : undefined));
|
|
1591
1921
|
}
|
|
1592
1922
|
const beforeSubmit = sessionA11y(session);
|
|
1593
|
-
const
|
|
1923
|
+
const beforeClickUrl = beforeSubmit?.meta?.pageUrl;
|
|
1924
|
+
const waitFilter = {
|
|
1925
|
+
id: waitFor?.id,
|
|
1926
|
+
role: waitFor?.role,
|
|
1927
|
+
name: waitFor?.name,
|
|
1928
|
+
text: waitFor?.text,
|
|
1929
|
+
contextText: waitFor?.contextText,
|
|
1930
|
+
promptText: waitFor?.promptText,
|
|
1931
|
+
sectionText: waitFor?.sectionText,
|
|
1932
|
+
itemText: waitFor?.itemText,
|
|
1933
|
+
value: waitFor?.value,
|
|
1934
|
+
checked: waitFor?.checked,
|
|
1935
|
+
disabled: waitFor?.disabled,
|
|
1936
|
+
focused: waitFor?.focused,
|
|
1937
|
+
selected: waitFor?.selected,
|
|
1938
|
+
expanded: waitFor?.expanded,
|
|
1939
|
+
invalid: waitFor?.invalid,
|
|
1940
|
+
required: waitFor?.required,
|
|
1941
|
+
busy: waitFor?.busy,
|
|
1942
|
+
};
|
|
1943
|
+
const beforeWaitEvidence = waitFor && beforeSubmit
|
|
1944
|
+
? captureWaitEvidence(beforeSubmit, waitFilter)
|
|
1945
|
+
: undefined;
|
|
1946
|
+
const clickWait = await sendClick(session, submitPoint.x, submitPoint.y, capTimeoutMs(submitTimeoutMs, timeoutCapFromDeadline(deadlineAt), 15_000));
|
|
1947
|
+
if (clickWait.status === 'timed_out') {
|
|
1948
|
+
const guidance = ambiguousActionGuidance(clickWait);
|
|
1949
|
+
return ok(JSON.stringify(pausedPayload('submit', {
|
|
1950
|
+
...submitResumeHint,
|
|
1951
|
+
retrySameCall: false,
|
|
1952
|
+
inspectCurrentUiFirst: true,
|
|
1953
|
+
actionId: clickWait.actionId,
|
|
1954
|
+
requestId: clickWait.requestId,
|
|
1955
|
+
}, {
|
|
1956
|
+
pauseReason: 'action-outcome-ambiguous',
|
|
1957
|
+
retrySafety: 'inspect-first',
|
|
1958
|
+
guidance,
|
|
1959
|
+
submit: {
|
|
1960
|
+
at: submitPoint,
|
|
1961
|
+
...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target) } : {}),
|
|
1962
|
+
...waitStatusPayload(clickWait),
|
|
1963
|
+
},
|
|
1964
|
+
}), null, detail === 'verbose' ? 2 : undefined));
|
|
1965
|
+
}
|
|
1594
1966
|
let waitResult;
|
|
1967
|
+
let waitError;
|
|
1595
1968
|
if (waitFor) {
|
|
1596
1969
|
if (!hasSoftBudget(deadlineAt)) {
|
|
1597
1970
|
return ok(JSON.stringify(pausedPayload('wait_for', {
|
|
1598
1971
|
tool: 'geometra_wait_for',
|
|
1599
|
-
|
|
1600
|
-
id: waitFor.id,
|
|
1601
|
-
role: waitFor.role,
|
|
1602
|
-
name: waitFor.name,
|
|
1603
|
-
text: waitFor.text,
|
|
1604
|
-
contextText: waitFor.contextText,
|
|
1605
|
-
promptText: waitFor.promptText,
|
|
1606
|
-
sectionText: waitFor.sectionText,
|
|
1607
|
-
itemText: waitFor.itemText,
|
|
1608
|
-
value: waitFor.value,
|
|
1609
|
-
checked: waitFor.checked,
|
|
1610
|
-
disabled: waitFor.disabled,
|
|
1611
|
-
focused: waitFor.focused,
|
|
1612
|
-
selected: waitFor.selected,
|
|
1613
|
-
expanded: waitFor.expanded,
|
|
1614
|
-
invalid: waitFor.invalid,
|
|
1615
|
-
required: waitFor.required,
|
|
1616
|
-
busy: waitFor.busy,
|
|
1617
|
-
}),
|
|
1972
|
+
...compactFilterPayload(waitFilter),
|
|
1618
1973
|
present: waitFor.present ?? true,
|
|
1974
|
+
...(waitFor.timeoutMs !== undefined ? { timeoutMs: waitFor.timeoutMs } : {}),
|
|
1619
1975
|
}, {
|
|
1620
1976
|
submit: {
|
|
1621
|
-
at:
|
|
1977
|
+
at: submitPoint,
|
|
1622
1978
|
...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target) } : {}),
|
|
1623
1979
|
...waitStatusPayload(clickWait),
|
|
1624
1980
|
},
|
|
1625
1981
|
}), null, detail === 'verbose' ? 2 : undefined));
|
|
1626
1982
|
}
|
|
1627
1983
|
const postWait = await waitForSemanticCondition(session, {
|
|
1628
|
-
filter:
|
|
1629
|
-
id: waitFor.id,
|
|
1630
|
-
role: waitFor.role,
|
|
1631
|
-
name: waitFor.name,
|
|
1632
|
-
text: waitFor.text,
|
|
1633
|
-
contextText: waitFor.contextText,
|
|
1634
|
-
promptText: waitFor.promptText,
|
|
1635
|
-
sectionText: waitFor.sectionText,
|
|
1636
|
-
itemText: waitFor.itemText,
|
|
1637
|
-
value: waitFor.value,
|
|
1638
|
-
checked: waitFor.checked,
|
|
1639
|
-
disabled: waitFor.disabled,
|
|
1640
|
-
focused: waitFor.focused,
|
|
1641
|
-
selected: waitFor.selected,
|
|
1642
|
-
expanded: waitFor.expanded,
|
|
1643
|
-
invalid: waitFor.invalid,
|
|
1644
|
-
required: waitFor.required,
|
|
1645
|
-
busy: waitFor.busy,
|
|
1646
|
-
},
|
|
1984
|
+
filter: waitFilter,
|
|
1647
1985
|
present: waitFor.present ?? true,
|
|
1648
1986
|
timeoutMs: capTimeoutMs(waitFor.timeoutMs, timeoutCapFromDeadline(deadlineAt), 15_000),
|
|
1649
1987
|
});
|
|
1650
1988
|
if (!postWait.ok) {
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
preErrFallbacks.push({ phase: 'submit', ...resolvedClick.fallback });
|
|
1656
|
-
const payload = {
|
|
1657
|
-
...connection,
|
|
1658
|
-
completed: false,
|
|
1659
|
-
...(fillSummary ? { fill: fillSummary } : {}),
|
|
1660
|
-
submit: {
|
|
1661
|
-
at: { x: resolvedClick.value.x, y: resolvedClick.value.y },
|
|
1662
|
-
...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target) } : {}),
|
|
1663
|
-
...waitStatusPayload(clickWait),
|
|
1664
|
-
},
|
|
1665
|
-
...(preErrFallbacks.length > 0 ? { fallbacks: preErrFallbacks } : {}),
|
|
1666
|
-
waitFor: { ok: false, error: postWait.error },
|
|
1667
|
-
};
|
|
1668
|
-
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
1989
|
+
waitError = postWait.error;
|
|
1990
|
+
}
|
|
1991
|
+
else {
|
|
1992
|
+
waitResult = postWait.value;
|
|
1669
1993
|
}
|
|
1670
|
-
waitResult = postWait.value;
|
|
1671
1994
|
}
|
|
1672
1995
|
const after = sessionA11y(session);
|
|
1673
|
-
const signals = after
|
|
1674
|
-
|
|
1675
|
-
|
|
1996
|
+
const signals = after
|
|
1997
|
+
? scopeSessionSignalsToForm(after, submitFormPath ? formScopeNodeAtPath(after, submitFormPath) : undefined, submitFormPath !== undefined)
|
|
1998
|
+
: undefined;
|
|
1999
|
+
const clickResult = clickWait.result && typeof clickWait.result === 'object'
|
|
2000
|
+
? clickWait.result
|
|
2001
|
+
: undefined;
|
|
2002
|
+
const clickAfterUrl = typeof clickResult?.pageUrl === 'string' ? clickResult.pageUrl : undefined;
|
|
2003
|
+
const clickBeforeUrl = typeof clickResult?.urlBefore === 'string' ? clickResult.urlBefore : beforeClickUrl;
|
|
2004
|
+
const observedAfterUrl = after?.meta?.pageUrl;
|
|
2005
|
+
const navigatedByClick = Boolean(clickResult?.navigated === true &&
|
|
2006
|
+
clickAfterUrl &&
|
|
2007
|
+
clickBeforeUrl &&
|
|
2008
|
+
clickAfterUrl !== clickBeforeUrl);
|
|
2009
|
+
const navigatedByTree = Boolean(observedAfterUrl && beforeClickUrl && observedAfterUrl !== beforeClickUrl);
|
|
2010
|
+
const navigated = navigatedByClick || navigatedByTree;
|
|
2011
|
+
const afterUrl = navigatedByClick && clickAfterUrl
|
|
2012
|
+
? clickAfterUrl
|
|
2013
|
+
: observedAfterUrl ?? clickAfterUrl;
|
|
2014
|
+
const waitEvidenceFresh = waitResult
|
|
2015
|
+
? waitConditionHasFreshEvidence(beforeWaitEvidence, after, waitResult)
|
|
2016
|
+
: false;
|
|
2017
|
+
const execution = 'completed';
|
|
2018
|
+
const invalidCount = signals?.invalidFields.length ?? 0;
|
|
2019
|
+
const outcome = invalidCount > 0
|
|
2020
|
+
? 'validation_failed'
|
|
2021
|
+
: waitError
|
|
2022
|
+
? 'unconfirmed'
|
|
2023
|
+
: waitFor
|
|
2024
|
+
? waitEvidenceFresh
|
|
2025
|
+
? 'submitted'
|
|
2026
|
+
: 'unconfirmed'
|
|
2027
|
+
: navigated
|
|
2028
|
+
? 'submitted'
|
|
2029
|
+
: 'unconfirmed';
|
|
2030
|
+
const completed = outcome === 'submitted';
|
|
1676
2031
|
// Aggregate all fallback usage into a single top-level `fallbacks[]`
|
|
1677
2032
|
// array so this tool matches the shape emitted by `geometra_run_actions`
|
|
1678
2033
|
// and can be aggregated the same way by operators.
|
|
@@ -1685,16 +2040,22 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1685
2040
|
}
|
|
1686
2041
|
const payload = {
|
|
1687
2042
|
...connection,
|
|
1688
|
-
completed
|
|
2043
|
+
completed,
|
|
2044
|
+
execution,
|
|
2045
|
+
outcome,
|
|
1689
2046
|
...(fillSummary ? { fill: fillSummary } : {}),
|
|
1690
2047
|
submit: {
|
|
1691
|
-
at:
|
|
2048
|
+
at: submitPoint,
|
|
1692
2049
|
...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target), revealSteps: resolvedClick.value.revealAttempts ?? 0 } : {}),
|
|
1693
2050
|
...waitStatusPayload(clickWait),
|
|
1694
2051
|
},
|
|
1695
|
-
...(waitResult
|
|
2052
|
+
...(waitResult
|
|
2053
|
+
? { waitFor: { ...waitConditionCompact(waitResult), fresh: waitEvidenceFresh } }
|
|
2054
|
+
: waitError
|
|
2055
|
+
? { waitFor: { ok: false, error: waitError } }
|
|
2056
|
+
: {}),
|
|
1696
2057
|
...(fallbackRecords.length > 0 ? { fallbacks: fallbackRecords } : {}),
|
|
1697
|
-
...(navigated ? { navigated: true, afterUrl } : {}),
|
|
2058
|
+
...(navigated ? { navigated: true, ...(afterUrl ? { afterUrl } : {}) } : {}),
|
|
1698
2059
|
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
1699
2060
|
};
|
|
1700
2061
|
// Pull in page model hints on navigation to mirror fill_form behavior.
|
|
@@ -1705,15 +2066,12 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1705
2066
|
if (model.verification)
|
|
1706
2067
|
payload.verification = model.verification;
|
|
1707
2068
|
}
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
// for the actual comparison here).
|
|
1715
|
-
void beforeSubmit;
|
|
1716
|
-
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
2069
|
+
const serialized = JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined);
|
|
2070
|
+
if (waitError)
|
|
2071
|
+
return err(serialized);
|
|
2072
|
+
if (failOnInvalid && outcome === 'validation_failed')
|
|
2073
|
+
return err(serialized);
|
|
2074
|
+
return ok(serialized);
|
|
1717
2075
|
});
|
|
1718
2076
|
server.tool('geometra_run_actions', `Execute several Geometra actions in one MCP round trip and return one consolidated result. This is the preferred path for long, multi-step form fills where one-tool-per-field would otherwise create too much chatter.
|
|
1719
2077
|
|
|
@@ -1730,8 +2088,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
|
|
|
1730
2088
|
isolated: z
|
|
1731
2089
|
.boolean()
|
|
1732
2090
|
.optional()
|
|
1733
|
-
.default
|
|
1734
|
-
.describe('When auto-connecting via pageUrl/url, request an isolated proxy. See geometra_connect for details.'),
|
|
2091
|
+
.describe('When auto-connecting via pageUrl/HTTP(S) url, use a fresh isolated Chromium by default. Pass false explicitly for sequential warm reuse.'),
|
|
1735
2092
|
actions: z.array(batchActionSchema).min(1).max(80).describe('Ordered high-level action steps to run sequentially'),
|
|
1736
2093
|
resumeFromIndex: z
|
|
1737
2094
|
.number()
|
|
@@ -1781,6 +2138,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
|
|
|
1781
2138
|
const steps = [];
|
|
1782
2139
|
let stoppedAt;
|
|
1783
2140
|
let pausedAt;
|
|
2141
|
+
let ambiguousAt;
|
|
1784
2142
|
const batchStartedAt = performance.now();
|
|
1785
2143
|
// Collect transparent-fallback signals from each step so run_actions
|
|
1786
2144
|
// surfaces them at top level regardless of `includeSteps` — otherwise
|
|
@@ -1853,6 +2211,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
|
|
|
1853
2211
|
}
|
|
1854
2212
|
catch (e) {
|
|
1855
2213
|
const message = e instanceof Error ? e.message : String(e);
|
|
2214
|
+
const ambiguity = ambiguousOutcomeDetails(e);
|
|
1856
2215
|
const elapsedMs = Number((performance.now() - startedAt).toFixed(1));
|
|
1857
2216
|
const cumulativeMs = Number((performance.now() - batchStartedAt).toFixed(1));
|
|
1858
2217
|
steps.push({
|
|
@@ -1863,7 +2222,15 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
|
|
|
1863
2222
|
cumulativeMs,
|
|
1864
2223
|
...(uiTreeWaitMs > 0 ? { uiTreeWaitMs: Number(uiTreeWaitMs.toFixed(1)) } : {}),
|
|
1865
2224
|
error: message,
|
|
2225
|
+
...(ambiguity ? ambiguousOutcomePayload(ambiguity) : {}),
|
|
1866
2226
|
});
|
|
2227
|
+
if (ambiguity) {
|
|
2228
|
+
// Continuing would silently skip a mutation that may still land.
|
|
2229
|
+
// Stop regardless of stopOnError and pin any resume to this step.
|
|
2230
|
+
ambiguousAt = index;
|
|
2231
|
+
stoppedAt = index;
|
|
2232
|
+
break;
|
|
2233
|
+
}
|
|
1867
2234
|
if (stopOnError) {
|
|
1868
2235
|
stoppedAt = index;
|
|
1869
2236
|
break;
|
|
@@ -1874,9 +2241,18 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
|
|
|
1874
2241
|
const successCount = steps.filter(step => step.ok === true).length;
|
|
1875
2242
|
const errorCount = steps.length - successCount;
|
|
1876
2243
|
const elapsedMs = Number((performance.now() - toolStartedAt).toFixed(1));
|
|
1877
|
-
const completed = stoppedAt === undefined && pausedAt === undefined && startIndex + steps.length >= actions.length;
|
|
2244
|
+
const completed = errorCount === 0 && stoppedAt === undefined && pausedAt === undefined && startIndex + steps.length >= actions.length;
|
|
1878
2245
|
const resumePayload = {
|
|
1879
2246
|
...(startIndex > 0 ? { resumedFromIndex: startIndex } : {}),
|
|
2247
|
+
...(ambiguousAt !== undefined
|
|
2248
|
+
? {
|
|
2249
|
+
ambiguous: true,
|
|
2250
|
+
ambiguousAt,
|
|
2251
|
+
resumeBlocked: true,
|
|
2252
|
+
resumeFromIndex: ambiguousAt,
|
|
2253
|
+
resumeInstruction: 'Do not skip this ambiguous step. Inspect the current UI; an identical retry will reuse its action identity when deduplication is supported.',
|
|
2254
|
+
}
|
|
2255
|
+
: {}),
|
|
1880
2256
|
...(pausedAt !== undefined
|
|
1881
2257
|
? {
|
|
1882
2258
|
paused: true,
|
|
@@ -1892,6 +2268,8 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
|
|
|
1892
2268
|
? {
|
|
1893
2269
|
...connection,
|
|
1894
2270
|
completed,
|
|
2271
|
+
successCount,
|
|
2272
|
+
errorCount,
|
|
1895
2273
|
...resumePayload,
|
|
1896
2274
|
...(stoppedAt !== undefined ? { stoppedAt } : {}),
|
|
1897
2275
|
...(fallbackRecords.length > 0 ? { fallbacks: fallbackRecords } : {}),
|
|
@@ -1972,18 +2350,18 @@ Unlike geometra_expand_section, this collapses repeated radio/button groups into
|
|
|
1972
2350
|
isolated: z
|
|
1973
2351
|
.boolean()
|
|
1974
2352
|
.optional()
|
|
1975
|
-
.default
|
|
1976
|
-
.describe('When auto-connecting via pageUrl/url, request an isolated proxy. See geometra_connect for details.'),
|
|
2353
|
+
.describe('When auto-connecting via pageUrl/HTTP(S) url, use a fresh isolated Chromium by default. Pass false explicitly for sequential warm reuse.'),
|
|
1977
2354
|
formId: z.string().optional().describe('Optional form id from geometra_page_model. If omitted, returns every form schema on the page.'),
|
|
1978
2355
|
maxFields: z.number().int().min(1).max(120).optional().default(80).describe('Cap returned fields per form'),
|
|
1979
2356
|
onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
|
|
1980
2357
|
onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields'),
|
|
1981
2358
|
includeOptions: z.boolean().optional().default(false).describe('Include explicit choice option labels'),
|
|
2359
|
+
includeFormGraph: z.boolean().optional().default(false).describe('Also include FormGraph 0.1-compatible graphs for the returned forms'),
|
|
1982
2360
|
includeContext: formSchemaContextInput(),
|
|
1983
2361
|
sinceSchemaId: z.string().optional().describe('If the current schema matches this id, return changed=false without resending forms'),
|
|
1984
2362
|
format: formSchemaFormatInput(),
|
|
1985
2363
|
sessionId: sessionIdInput,
|
|
1986
|
-
}, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, browserMode, isolated, formId, maxFields, onlyRequiredFields, onlyInvalidFields, includeOptions, includeContext, sinceSchemaId, format, sessionId }) => {
|
|
2364
|
+
}, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, browserMode, isolated, formId, maxFields, onlyRequiredFields, onlyInvalidFields, includeOptions, includeFormGraph, includeContext, sinceSchemaId, format, sessionId }) => {
|
|
1987
2365
|
const browser = resolveBrowserStealth({ stealth, browserMode });
|
|
1988
2366
|
if (!browser.ok)
|
|
1989
2367
|
return err(browser.error);
|
|
@@ -2000,6 +2378,7 @@ Unlike geometra_expand_section, this collapses repeated radio/button groups into
|
|
|
2000
2378
|
onlyRequiredFields,
|
|
2001
2379
|
onlyInvalidFields,
|
|
2002
2380
|
includeOptions,
|
|
2381
|
+
includeFormGraph,
|
|
2003
2382
|
includeContext,
|
|
2004
2383
|
sinceSchemaId,
|
|
2005
2384
|
format,
|
|
@@ -2132,7 +2511,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
2132
2511
|
.optional()
|
|
2133
2512
|
.default(2_500)
|
|
2134
2513
|
.describe('Per-scroll wait timeout while revealing a semantic target (default 2500ms)'),
|
|
2135
|
-
waitFor: z.object(waitConditionShape()).optional().describe('Optional semantic condition to wait for after the click'),
|
|
2514
|
+
waitFor: z.object(waitConditionShape()).strict().optional().describe('Optional semantic condition to wait for after the click'),
|
|
2136
2515
|
timeoutMs: z
|
|
2137
2516
|
.number()
|
|
2138
2517
|
.int()
|
|
@@ -2177,6 +2556,29 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
2177
2556
|
});
|
|
2178
2557
|
if (!resolved.ok)
|
|
2179
2558
|
return err(clickFallbackErrorMessage(resolved));
|
|
2559
|
+
const waitFilter = waitFor ? {
|
|
2560
|
+
id: waitFor.id,
|
|
2561
|
+
role: waitFor.role,
|
|
2562
|
+
name: waitFor.name,
|
|
2563
|
+
text: waitFor.text,
|
|
2564
|
+
contextText: waitFor.contextText,
|
|
2565
|
+
promptText: waitFor.promptText,
|
|
2566
|
+
sectionText: waitFor.sectionText,
|
|
2567
|
+
itemText: waitFor.itemText,
|
|
2568
|
+
value: waitFor.value,
|
|
2569
|
+
checked: waitFor.checked,
|
|
2570
|
+
disabled: waitFor.disabled,
|
|
2571
|
+
focused: waitFor.focused,
|
|
2572
|
+
selected: waitFor.selected,
|
|
2573
|
+
expanded: waitFor.expanded,
|
|
2574
|
+
invalid: waitFor.invalid,
|
|
2575
|
+
required: waitFor.required,
|
|
2576
|
+
busy: waitFor.busy,
|
|
2577
|
+
} : undefined;
|
|
2578
|
+
const waitEvidenceRoot = sessionA11y(session) ?? before;
|
|
2579
|
+
const beforeWaitEvidence = waitFilter && waitEvidenceRoot
|
|
2580
|
+
? captureWaitEvidence(waitEvidenceRoot, waitFilter)
|
|
2581
|
+
: undefined;
|
|
2180
2582
|
const wait = await sendClick(session, resolved.value.x, resolved.value.y, timeoutMs);
|
|
2181
2583
|
const summary = postActionSummary(session, before, wait, detail);
|
|
2182
2584
|
const clickLine = !resolved.value.target
|
|
@@ -2185,37 +2587,23 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
2185
2587
|
const lines = [clickLine, summary];
|
|
2186
2588
|
if (waitFor) {
|
|
2187
2589
|
const postWait = await waitForSemanticCondition(session, {
|
|
2188
|
-
filter:
|
|
2189
|
-
id: waitFor.id,
|
|
2190
|
-
role: waitFor.role,
|
|
2191
|
-
name: waitFor.name,
|
|
2192
|
-
text: waitFor.text,
|
|
2193
|
-
contextText: waitFor.contextText,
|
|
2194
|
-
promptText: waitFor.promptText,
|
|
2195
|
-
sectionText: waitFor.sectionText,
|
|
2196
|
-
itemText: waitFor.itemText,
|
|
2197
|
-
value: waitFor.value,
|
|
2198
|
-
checked: waitFor.checked,
|
|
2199
|
-
disabled: waitFor.disabled,
|
|
2200
|
-
focused: waitFor.focused,
|
|
2201
|
-
selected: waitFor.selected,
|
|
2202
|
-
expanded: waitFor.expanded,
|
|
2203
|
-
invalid: waitFor.invalid,
|
|
2204
|
-
required: waitFor.required,
|
|
2205
|
-
busy: waitFor.busy,
|
|
2206
|
-
},
|
|
2590
|
+
filter: waitFilter,
|
|
2207
2591
|
present: waitFor.present ?? true,
|
|
2208
2592
|
timeoutMs: waitFor.timeoutMs ?? 10_000,
|
|
2209
2593
|
});
|
|
2210
2594
|
if (!postWait.ok)
|
|
2211
2595
|
return err([...lines, postWait.error].join('\n'));
|
|
2596
|
+
const fresh = waitConditionHasFreshEvidence(beforeWaitEvidence, sessionA11y(session), postWait.value);
|
|
2597
|
+
if (!fresh) {
|
|
2598
|
+
return err([...lines, 'Post-click condition matched only pre-existing evidence; the click outcome is unconfirmed.'].join('\n'));
|
|
2599
|
+
}
|
|
2212
2600
|
lines.push(`Post-click ${waitConditionSuccessLine(postWait.value)}`);
|
|
2213
2601
|
const compact = {
|
|
2214
2602
|
at: { x: resolved.value.x, y: resolved.value.y },
|
|
2215
2603
|
...(resolved.value.target ? { target: compactNodeReference(resolved.value.target), revealSteps: resolved.value.revealAttempts ?? 0 } : {}),
|
|
2216
2604
|
...waitStatusPayload(wait),
|
|
2217
2605
|
...(resolved.fallback ? { fallback: resolved.fallback } : {}),
|
|
2218
|
-
postWait: waitConditionCompact(postWait.value),
|
|
2606
|
+
postWait: { ...waitConditionCompact(postWait.value), fresh: true },
|
|
2219
2607
|
};
|
|
2220
2608
|
return ok(detailText(lines.filter(Boolean).join('\n'), compact, detail));
|
|
2221
2609
|
}
|
|
@@ -2225,13 +2613,23 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
2225
2613
|
...waitStatusPayload(wait),
|
|
2226
2614
|
...(resolved.fallback ? { fallback: resolved.fallback } : {}),
|
|
2227
2615
|
};
|
|
2616
|
+
if (wait.status === 'timed_out') {
|
|
2617
|
+
const guidance = ambiguousActionGuidance(wait);
|
|
2618
|
+
return err(detailText([...lines.filter(Boolean), guidance].join('\n'), {
|
|
2619
|
+
completed: false,
|
|
2620
|
+
outcome: 'unconfirmed',
|
|
2621
|
+
retrySafety: 'inspect-first',
|
|
2622
|
+
guidance,
|
|
2623
|
+
...compact,
|
|
2624
|
+
}, detail));
|
|
2625
|
+
}
|
|
2228
2626
|
return ok(detailText(lines.filter(Boolean).join('\n'), compact, detail));
|
|
2229
2627
|
});
|
|
2230
2628
|
// ── type ─────────────────────────────────────────────────────
|
|
2231
2629
|
server.tool('geometra_type', `Type text into the currently focused element. First click a textbox/input with geometra_click to focus it, then use this to type.
|
|
2232
2630
|
|
|
2233
2631
|
Each character is sent as a key event through the geometry protocol. Returns a compact semantic delta when possible, otherwise a short current-UI overview.`, {
|
|
2234
|
-
text: z.string().describe('Text to type into the focused element'),
|
|
2632
|
+
text: z.string().max(65_536).describe('Text to type into the focused element (maximum 65,536 characters)'),
|
|
2235
2633
|
timeoutMs: z
|
|
2236
2634
|
.number()
|
|
2237
2635
|
.int()
|
|
@@ -2249,10 +2647,10 @@ Each character is sent as a key event through the geometry protocol. Returns a c
|
|
|
2249
2647
|
const before = sessionA11y(session);
|
|
2250
2648
|
const wait = await sendType(session, text, timeoutMs);
|
|
2251
2649
|
const summary = postActionSummary(session, before, wait, detail);
|
|
2252
|
-
return
|
|
2650
|
+
return actionWaitResponse(wait, `Typed "${text}".\n${summary}`, {
|
|
2253
2651
|
...compactTextValue(text),
|
|
2254
2652
|
...waitStatusPayload(wait),
|
|
2255
|
-
}, detail)
|
|
2653
|
+
}, detail);
|
|
2256
2654
|
});
|
|
2257
2655
|
// ── key ──────────────────────────────────────────────────────
|
|
2258
2656
|
server.tool('geometra_key', `Send a special key press (Enter, Tab, Escape, ArrowDown, etc.) to the Geometra UI. Useful for form submission, focus navigation, and keyboard shortcuts.`, {
|
|
@@ -2278,10 +2676,10 @@ Each character is sent as a key event through the geometry protocol. Returns a c
|
|
|
2278
2676
|
const before = sessionA11y(session);
|
|
2279
2677
|
const wait = await sendKey(session, key, { shift, ctrl, meta, alt }, timeoutMs);
|
|
2280
2678
|
const summary = postActionSummary(session, before, wait, detail);
|
|
2281
|
-
return
|
|
2679
|
+
return actionWaitResponse(wait, `Pressed ${formatKeyCombo(key, { shift, ctrl, meta, alt })}.\n${summary}`, {
|
|
2282
2680
|
key: formatKeyCombo(key, { shift, ctrl, meta, alt }),
|
|
2283
2681
|
...waitStatusPayload(wait),
|
|
2284
|
-
}, detail)
|
|
2682
|
+
}, detail);
|
|
2285
2683
|
});
|
|
2286
2684
|
// ── fill OTP / verification-code box group ─────────────────────
|
|
2287
2685
|
server.tool('geometra_fill_otp', `Fill a multi-cell OTP / verification-code input group (e.g. Greenhouse's 8-box security code, generic 6-digit 2FA widgets, Auth0 / Clerk per-char inputs). Auto-detects a row of sibling <input maxlength="1"> elements at adjacent x-coordinates and types each character through a real keyboard event cycle so React's per-cell onKeyDown handler can auto-advance focus.
|
|
@@ -2311,45 +2709,53 @@ Detection is fully generic (no site branding). It refuses to run if the detected
|
|
|
2311
2709
|
const wait = await sendFillOtp(session, value, { fieldLabel, perCharDelayMs }, timeoutMs);
|
|
2312
2710
|
const summary = postActionSummary(session, before, wait, detail);
|
|
2313
2711
|
const result = wait.result;
|
|
2314
|
-
return
|
|
2712
|
+
return actionWaitResponse(wait, `Filled OTP code (${value.length} chars).\n${summary}`, {
|
|
2315
2713
|
...compactTextValue(value),
|
|
2316
2714
|
...(fieldLabel ? { fieldLabel } : {}),
|
|
2317
2715
|
...(result?.cellCount !== undefined ? { cellCount: result.cellCount } : {}),
|
|
2318
2716
|
...(result?.filledCount !== undefined ? { filledCount: result.filledCount } : {}),
|
|
2319
2717
|
...waitStatusPayload(wait),
|
|
2320
|
-
}, detail)
|
|
2718
|
+
}, detail);
|
|
2321
2719
|
}
|
|
2322
2720
|
catch (e) {
|
|
2323
2721
|
return err(e.message);
|
|
2324
2722
|
}
|
|
2325
2723
|
});
|
|
2326
2724
|
// ── upload files (proxy) ───────────────────────────────────────
|
|
2327
|
-
server.
|
|
2725
|
+
server.registerTool('geometra_upload_files', {
|
|
2726
|
+
description: `Attach local files to a file input. Requires \`@geometra/proxy\` (paths exist on the proxy host).
|
|
2328
2727
|
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2728
|
+
Every upload must name one explicit target. **auto** (default) accepts either a complete x,y chooser target or a labeled field. **hidden** requires fieldLabel. **chooser** requires x,y. **drop** requires dropX,dropY. Geometra rejects incomplete, conflicting, and global-first-input targets.`,
|
|
2729
|
+
inputSchema: z.object({
|
|
2730
|
+
paths: z.array(z.string().trim().min(1)).min(1).describe('Absolute paths on the proxy machine, e.g. /Users/you/resume.pdf'),
|
|
2731
|
+
x: z.number().optional().describe('Click X to trigger native file chooser; must be paired with y'),
|
|
2732
|
+
y: z.number().optional().describe('Click Y to trigger native file chooser; must be paired with x'),
|
|
2733
|
+
fieldLabel: z.string().trim().min(1).optional().describe('Specific labeled file field (for example "Resume" or "Cover letter")'),
|
|
2734
|
+
exact: z.boolean().optional().describe('Exact match when using fieldLabel'),
|
|
2735
|
+
strategy: z
|
|
2736
|
+
.enum(['auto', 'chooser', 'hidden', 'drop'])
|
|
2737
|
+
.optional()
|
|
2738
|
+
.describe('Upload strategy (default auto)'),
|
|
2739
|
+
dropX: z.number().optional().describe('Drop target X (viewport) for strategy drop; must be paired with dropY'),
|
|
2740
|
+
dropY: z.number().optional().describe('Drop target Y (viewport) for strategy drop; must be paired with dropX'),
|
|
2741
|
+
contextText: z.string().trim().min(1).optional().describe('Ancestor / prompt text to disambiguate repeated file inputs; requires fieldLabel'),
|
|
2742
|
+
sectionText: z.string().trim().min(1).optional().describe('Containing section text to disambiguate repeated file inputs; requires fieldLabel'),
|
|
2743
|
+
timeoutMs: z
|
|
2744
|
+
.number()
|
|
2745
|
+
.int()
|
|
2746
|
+
.min(50)
|
|
2747
|
+
.max(60_000)
|
|
2748
|
+
.optional()
|
|
2749
|
+
.describe('Optional action wait timeout (resume parsing / SPA upload flows often need longer than a normal click)'),
|
|
2750
|
+
detail: detailInput(),
|
|
2751
|
+
sessionId: sessionIdInput,
|
|
2752
|
+
}).strict().superRefine((input, ctx) => {
|
|
2753
|
+
addActionContractIssue(ctx, fileUploadContractError(input));
|
|
2754
|
+
}),
|
|
2755
|
+
}, async ({ paths, x, y, fieldLabel, exact, strategy, dropX, dropY, contextText, sectionText, timeoutMs, detail, sessionId }) => {
|
|
2756
|
+
const contractError = fileUploadContractError({ x, y, fieldLabel, exact, strategy, dropX, dropY, contextText, sectionText });
|
|
2757
|
+
if (contractError)
|
|
2758
|
+
return err(contractError);
|
|
2353
2759
|
const sessionResult = resolveToolSession(sessionId);
|
|
2354
2760
|
if ('error' in sessionResult)
|
|
2355
2761
|
return sessionResult.error;
|
|
@@ -2362,41 +2768,51 @@ Strategies: **auto** (default) tries chooser click if x,y given, else a labeled
|
|
|
2362
2768
|
exact,
|
|
2363
2769
|
strategy,
|
|
2364
2770
|
drop: dropX !== undefined && dropY !== undefined ? { x: dropX, y: dropY } : undefined,
|
|
2771
|
+
contextText,
|
|
2772
|
+
sectionText,
|
|
2365
2773
|
}, timeoutMs ?? 8_000);
|
|
2366
2774
|
const summary = postActionSummary(session, before, wait, detail);
|
|
2367
|
-
return
|
|
2775
|
+
return actionWaitResponse(wait, `Uploaded ${paths.length} file(s).\n${summary}`, {
|
|
2368
2776
|
fileCount: paths.length,
|
|
2369
2777
|
...(fieldLabel ? { fieldLabel } : {}),
|
|
2370
2778
|
...(strategy ? { strategy } : {}),
|
|
2779
|
+
...(contextText ? { contextText } : {}),
|
|
2780
|
+
...(sectionText ? { sectionText } : {}),
|
|
2371
2781
|
...waitStatusPayload(wait),
|
|
2372
2782
|
...(fieldLabel ? { readback: fieldStatePayload(session, fieldLabel) } : {}),
|
|
2373
|
-
}, detail)
|
|
2783
|
+
}, detail);
|
|
2374
2784
|
}
|
|
2375
2785
|
catch (e) {
|
|
2376
2786
|
return err(e.message);
|
|
2377
2787
|
}
|
|
2378
2788
|
});
|
|
2379
|
-
server.
|
|
2789
|
+
server.registerTool('geometra_pick_listbox_option', {
|
|
2790
|
+
description: `Pick an option from a custom dropdown / listbox / searchable combobox (Headless UI, React Select, Radix, Ashby-style custom selects, etc.). Requires \`@geometra/proxy\`.
|
|
2380
2791
|
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2792
|
+
fieldLabel is required so Geometra can confirm which control committed the selection. fieldId/fieldKey are supplemental exact identity and are only meaningful alongside fieldLabel. Coordinate-only opening is intentionally unsupported because it cannot provide field readback. If the opened control is editable, MCP types query (or the option label by default) before selecting.`,
|
|
2793
|
+
inputSchema: z.object({
|
|
2794
|
+
label: z.string().trim().min(1).describe('Accessible name of the option (visible text or aria-label)'),
|
|
2795
|
+
exact: z.boolean().optional().describe('Exact name match'),
|
|
2796
|
+
fieldId: z.string().trim().min(1).optional().describe('Stable field id from geometra_form_schema; used with fieldLabel'),
|
|
2797
|
+
fieldKey: z.string().trim().min(1).optional().describe('Authored field key from geometra_form_schema for exact resolution; used with fieldLabel'),
|
|
2798
|
+
fieldLabel: z.string().trim().min(1).describe('Field label of the dropdown/combobox to open and verify (e.g. "Location")'),
|
|
2799
|
+
query: z.string().optional().describe('Optional text to type into a searchable combobox before selecting'),
|
|
2800
|
+
timeoutMs: z
|
|
2801
|
+
.number()
|
|
2802
|
+
.int()
|
|
2803
|
+
.min(50)
|
|
2804
|
+
.max(60_000)
|
|
2805
|
+
.optional()
|
|
2806
|
+
.describe('Optional action wait timeout for slow dropdowns / remote search results'),
|
|
2807
|
+
detail: detailInput(),
|
|
2808
|
+
sessionId: sessionIdInput,
|
|
2809
|
+
}).strict().superRefine((input, ctx) => {
|
|
2810
|
+
addActionContractIssue(ctx, listboxPickContractError(input));
|
|
2811
|
+
}),
|
|
2812
|
+
}, async ({ label, exact, fieldId, fieldKey, fieldLabel, query, timeoutMs, detail, sessionId }) => {
|
|
2813
|
+
const contractError = listboxPickContractError({ fieldId, fieldKey, fieldLabel });
|
|
2814
|
+
if (contractError)
|
|
2815
|
+
return err(contractError);
|
|
2400
2816
|
const sessionResult = resolveToolSession(sessionId);
|
|
2401
2817
|
if ('error' in sessionResult)
|
|
2402
2818
|
return sessionResult.error;
|
|
@@ -2405,105 +2821,125 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
|
|
|
2405
2821
|
try {
|
|
2406
2822
|
const wait = await sendListboxPick(session, label, {
|
|
2407
2823
|
exact,
|
|
2408
|
-
|
|
2824
|
+
fieldId,
|
|
2825
|
+
fieldKey,
|
|
2409
2826
|
fieldLabel,
|
|
2410
2827
|
query,
|
|
2411
2828
|
}, timeoutMs);
|
|
2412
2829
|
const summary = postActionSummary(session, before, wait, detail);
|
|
2413
|
-
const fieldSummary = fieldLabel ? summarizeFieldLabelState(session, fieldLabel) : undefined;
|
|
2830
|
+
const fieldSummary = fieldLabel ? summarizeFieldLabelState(session, fieldLabel, fieldKey) : undefined;
|
|
2414
2831
|
const summaryText = [
|
|
2415
2832
|
`Picked listbox option "${label}".`,
|
|
2416
2833
|
fieldSummary,
|
|
2417
2834
|
summary,
|
|
2418
2835
|
].filter(Boolean).join('\n');
|
|
2419
|
-
return
|
|
2836
|
+
return actionWaitResponse(wait, summaryText, {
|
|
2420
2837
|
label,
|
|
2838
|
+
...(fieldId ? { fieldId } : {}),
|
|
2839
|
+
...(fieldKey ? { fieldKey } : {}),
|
|
2421
2840
|
...(fieldLabel ? { fieldLabel } : {}),
|
|
2422
2841
|
...waitStatusPayload(wait),
|
|
2423
|
-
...(fieldLabel ? { readback: fieldStatePayload(session, fieldLabel) } : {}),
|
|
2424
|
-
}, detail)
|
|
2842
|
+
...(fieldLabel ? { readback: fieldStatePayload(session, fieldLabel, fieldKey) } : {}),
|
|
2843
|
+
}, detail);
|
|
2425
2844
|
}
|
|
2426
2845
|
catch (e) {
|
|
2427
2846
|
return err(e.message);
|
|
2428
2847
|
}
|
|
2429
2848
|
});
|
|
2430
2849
|
// ── select option (proxy, native <select>) ─────────────────────
|
|
2431
|
-
server.
|
|
2850
|
+
server.registerTool('geometra_select_option', {
|
|
2851
|
+
description: `Set a native HTML \`<select>\` after clicking its center (x,y from geometra_query). Requires \`@geometra/proxy\`.
|
|
2432
2852
|
|
|
2433
|
-
Custom React/Vue dropdowns are not supported here — use \`geometra_pick_listbox_option\` for custom dropdowns / searchable comboboxes.`,
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2853
|
+
Provide one or more of value, label, and index. Every supplied selector is conjunctive identity and must describe the same enabled option. Custom React/Vue dropdowns are not supported here — use \`geometra_pick_listbox_option\` for custom dropdowns / searchable comboboxes.`,
|
|
2854
|
+
inputSchema: z.object({
|
|
2855
|
+
x: z.number().describe('X coordinate (e.g. center of the select from geometra_query)'),
|
|
2856
|
+
y: z.number().describe('Y coordinate'),
|
|
2857
|
+
value: z.string().optional().describe('Exact option value= attribute'),
|
|
2858
|
+
label: z.string().trim().min(1).optional().describe('Exact normalized visible option label'),
|
|
2859
|
+
index: z.number().int().min(0).optional().describe('Zero-based option index'),
|
|
2860
|
+
timeoutMs: z
|
|
2861
|
+
.number()
|
|
2862
|
+
.int()
|
|
2863
|
+
.min(50)
|
|
2864
|
+
.max(60_000)
|
|
2865
|
+
.optional()
|
|
2866
|
+
.describe('Optional action wait timeout'),
|
|
2867
|
+
detail: detailInput(),
|
|
2868
|
+
sessionId: sessionIdInput,
|
|
2869
|
+
}).strict().superRefine((input, ctx) => {
|
|
2870
|
+
addActionContractIssue(ctx, selectOptionContractError(input));
|
|
2871
|
+
}),
|
|
2872
|
+
}, async ({ x, y, value, label, index, timeoutMs, detail, sessionId }) => {
|
|
2873
|
+
const contractError = selectOptionContractError({ x, y, value, label, index });
|
|
2874
|
+
if (contractError)
|
|
2875
|
+
return err(contractError);
|
|
2451
2876
|
const sessionResult = resolveToolSession(sessionId);
|
|
2452
2877
|
if ('error' in sessionResult)
|
|
2453
2878
|
return sessionResult.error;
|
|
2454
2879
|
const session = sessionResult.session;
|
|
2455
|
-
if (value === undefined && label === undefined && index === undefined) {
|
|
2456
|
-
return err('Provide at least one of value, label, or index');
|
|
2457
|
-
}
|
|
2458
2880
|
const before = sessionA11y(session);
|
|
2459
2881
|
try {
|
|
2460
2882
|
const wait = await sendSelectOption(session, x, y, { value, label, index }, timeoutMs);
|
|
2461
2883
|
const summary = postActionSummary(session, before, wait, detail);
|
|
2462
|
-
return
|
|
2884
|
+
return actionWaitResponse(wait, `Selected option.\n${summary}`, {
|
|
2463
2885
|
at: { x, y },
|
|
2464
2886
|
...(value !== undefined ? { value } : {}),
|
|
2465
2887
|
...(label !== undefined ? { label } : {}),
|
|
2466
2888
|
...(index !== undefined ? { index } : {}),
|
|
2467
2889
|
...waitStatusPayload(wait),
|
|
2468
|
-
}, detail)
|
|
2890
|
+
}, detail);
|
|
2469
2891
|
}
|
|
2470
2892
|
catch (e) {
|
|
2471
2893
|
return err(e.message);
|
|
2472
2894
|
}
|
|
2473
2895
|
});
|
|
2474
|
-
server.
|
|
2896
|
+
server.registerTool('geometra_set_checked', {
|
|
2897
|
+
description: `Set a checkbox or radio by label. Requires \`@geometra/proxy\`.
|
|
2475
2898
|
|
|
2476
|
-
Prefer this over raw coordinate clicks for custom forms that keep the real input visually hidden (common on Ashby, Greenhouse custom widgets, and design-system checkboxes/radios). Uses substring label matching unless exact=true.`,
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
.
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2899
|
+
Prefer this over raw coordinate clicks for custom forms that keep the real input visually hidden (common on Ashby, Greenhouse custom widgets, and design-system checkboxes/radios). Uses substring label matching unless exact=true. Unknown parameter names are rejected.`,
|
|
2900
|
+
inputSchema: z.object({
|
|
2901
|
+
label: z.string().trim().min(1, 'label must not be empty').describe('Accessible label or visible option text to match'),
|
|
2902
|
+
checked: z.boolean().optional().default(true).describe('Desired checked state (radios only support true)'),
|
|
2903
|
+
exact: z.boolean().optional().describe('Exact label match'),
|
|
2904
|
+
controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
|
|
2905
|
+
fieldKey: z.string().trim().min(1).optional().describe('Authored field key from geometra_form_schema for exact resolution'),
|
|
2906
|
+
contextText: z.string().trim().min(1).optional().describe('Ancestor / prompt text to disambiguate repeated checkboxes/radios'),
|
|
2907
|
+
sectionText: z.string().trim().min(1).optional().describe('Containing section text to disambiguate repeated checkboxes/radios'),
|
|
2908
|
+
timeoutMs: z
|
|
2909
|
+
.number()
|
|
2910
|
+
.int()
|
|
2911
|
+
.min(50)
|
|
2912
|
+
.max(60_000)
|
|
2913
|
+
.optional()
|
|
2914
|
+
.describe('Optional action wait timeout'),
|
|
2915
|
+
detail: detailInput(),
|
|
2916
|
+
sessionId: sessionIdInput,
|
|
2917
|
+
}).strict(),
|
|
2918
|
+
}, async ({ label, checked, exact, controlType, fieldKey, contextText, sectionText, timeoutMs, detail, sessionId }) => {
|
|
2493
2919
|
const sessionResult = resolveToolSession(sessionId);
|
|
2494
2920
|
if ('error' in sessionResult)
|
|
2495
2921
|
return sessionResult.error;
|
|
2496
2922
|
const session = sessionResult.session;
|
|
2497
2923
|
const before = sessionA11y(session);
|
|
2498
2924
|
try {
|
|
2499
|
-
const wait = await sendSetChecked(session, label, {
|
|
2925
|
+
const wait = await sendSetChecked(session, label, {
|
|
2926
|
+
checked,
|
|
2927
|
+
exact,
|
|
2928
|
+
controlType,
|
|
2929
|
+
...(fieldKey ? { fieldKey } : {}),
|
|
2930
|
+
contextText,
|
|
2931
|
+
sectionText,
|
|
2932
|
+
}, timeoutMs);
|
|
2500
2933
|
const summary = postActionSummary(session, before, wait, detail);
|
|
2501
|
-
return
|
|
2934
|
+
return actionWaitResponse(wait, `Set ${controlType ?? 'checkbox/radio'} "${label}" to ${String(checked ?? true)}.\n${summary}`, {
|
|
2502
2935
|
label,
|
|
2503
2936
|
checked: checked ?? true,
|
|
2504
2937
|
...(controlType ? { controlType } : {}),
|
|
2938
|
+
...(fieldKey ? { fieldKey } : {}),
|
|
2939
|
+
...(contextText ? { contextText } : {}),
|
|
2940
|
+
...(sectionText ? { sectionText } : {}),
|
|
2505
2941
|
...waitStatusPayload(wait),
|
|
2506
|
-
}, detail)
|
|
2942
|
+
}, detail);
|
|
2507
2943
|
}
|
|
2508
2944
|
catch (e) {
|
|
2509
2945
|
return err(e.message);
|
|
@@ -2533,12 +2969,12 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
|
|
|
2533
2969
|
try {
|
|
2534
2970
|
const wait = await sendWheel(session, deltaY, { deltaX, x, y }, timeoutMs);
|
|
2535
2971
|
const summary = postActionSummary(session, before, wait, detail);
|
|
2536
|
-
return
|
|
2972
|
+
return actionWaitResponse(wait, `Wheel delta (${deltaX ?? 0}, ${deltaY}).\n${summary}`, {
|
|
2537
2973
|
deltaY,
|
|
2538
2974
|
...(deltaX !== undefined ? { deltaX } : {}),
|
|
2539
2975
|
...(x !== undefined && y !== undefined ? { at: { x, y } } : {}),
|
|
2540
2976
|
...waitStatusPayload(wait),
|
|
2541
|
-
}, detail)
|
|
2977
|
+
}, detail);
|
|
2542
2978
|
}
|
|
2543
2979
|
catch (e) {
|
|
2544
2980
|
return err(e.message);
|
|
@@ -2683,7 +3119,7 @@ For a token-efficient semantic view, use geometra_snapshot (default compact). Fo
|
|
|
2683
3119
|
return ok(JSON.stringify(session.layout, null, 2));
|
|
2684
3120
|
});
|
|
2685
3121
|
// ── workflow state ───────────────────────────────────────────
|
|
2686
|
-
server.tool('geometra_workflow_state', `Get the accumulated workflow state across page navigations. Shows which pages/forms have been filled,
|
|
3122
|
+
server.tool('geometra_workflow_state', `Get the accumulated workflow state across page navigations. Shows which pages/forms and field identities have been filled, with the fill status per page. Submitted values and local file paths are never stored or returned; every recorded field is represented by a redaction marker.
|
|
2687
3123
|
|
|
2688
3124
|
Use this after navigating to a new page in a multi-step flow (e.g. job applications) to understand what has been completed so far. Pass \`clear: true\` to reset the workflow state.`, {
|
|
2689
3125
|
clear: z.boolean().optional().default(false).describe('Reset the workflow state'),
|
|
@@ -2700,6 +3136,8 @@ Use this after navigating to a new page in a multi-step flow (e.g. job applicati
|
|
|
2700
3136
|
if (!session.workflowState || session.workflowState.pages.length === 0) {
|
|
2701
3137
|
return ok(JSON.stringify({
|
|
2702
3138
|
pageCount: 0,
|
|
3139
|
+
valuesRedacted: true,
|
|
3140
|
+
redactionNotice: 'Submitted values and local file paths are not stored.',
|
|
2703
3141
|
message: 'No workflow state recorded yet. Fill a form with geometra_fill_form to start tracking.',
|
|
2704
3142
|
}));
|
|
2705
3143
|
}
|
|
@@ -2711,13 +3149,18 @@ Use this after navigating to a new page in a multi-step flow (e.g. job applicati
|
|
|
2711
3149
|
totalFieldsFilled: totalFields,
|
|
2712
3150
|
totalInvalidRemaining: totalInvalid,
|
|
2713
3151
|
elapsedMs: Date.now() - state.startedAt,
|
|
3152
|
+
valuesRedacted: true,
|
|
3153
|
+
redactionNotice: 'Submitted values and local file paths are not stored; filledFields lists identities and filledValues contains redaction markers only.',
|
|
2714
3154
|
pages: state.pages.map(p => ({
|
|
2715
3155
|
pageUrl: p.pageUrl,
|
|
2716
3156
|
...(p.formId ? { formId: p.formId } : {}),
|
|
2717
3157
|
...(p.formName ? { formName: p.formName } : {}),
|
|
2718
3158
|
fieldCount: p.fieldCount,
|
|
2719
3159
|
invalidCount: p.invalidCount,
|
|
3160
|
+
filledFields: p.filledFields,
|
|
3161
|
+
// Preserve the established response key without retaining values.
|
|
2720
3162
|
filledValues: p.filledValues,
|
|
3163
|
+
valuesRedacted: true,
|
|
2721
3164
|
})),
|
|
2722
3165
|
}));
|
|
2723
3166
|
});
|
|
@@ -2786,12 +3229,19 @@ Returns \`{ pdf, pageUrl }\` where \`pdf\` is the base64-encoded PDF bytes.`, {
|
|
|
2786
3229
|
return err(`PDF generation failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
2787
3230
|
}
|
|
2788
3231
|
});
|
|
2789
|
-
server.tool('geometra_disconnect',
|
|
2790
|
-
closeBrowser: z.boolean().optional().default(false).describe(
|
|
3232
|
+
server.tool('geometra_disconnect', 'Disconnect one explicitly resolved Geometra session. Isolated browser sessions are always destroyed. A session created with isolated: false may return its browser to the warm pool; pass closeBrowser: true to destroy that session\'s browser only. This tool never closes another session\'s browser.', {
|
|
3233
|
+
closeBrowser: z.boolean().optional().default(false).describe("Destroy this session's spawned proxy/browser instead of returning an explicitly reusable browser to the warm pool"),
|
|
2791
3234
|
sessionId: sessionIdInput,
|
|
2792
3235
|
}, async ({ closeBrowser, sessionId }) => {
|
|
2793
|
-
|
|
2794
|
-
|
|
3236
|
+
const resolved = resolveToolSession(sessionId);
|
|
3237
|
+
if ('error' in resolved)
|
|
3238
|
+
return resolved.error;
|
|
3239
|
+
const target = resolved.session;
|
|
3240
|
+
disconnect({ closeProxy: closeBrowser, sessionId: target.id });
|
|
3241
|
+
const browserClosed = closeBrowser || target.isolated === true;
|
|
3242
|
+
return ok(browserClosed
|
|
3243
|
+
? `Disconnected session ${target.id} and closed its browser.`
|
|
3244
|
+
: `Disconnected session ${target.id}; its explicitly reusable browser remains warm.`);
|
|
2795
3245
|
});
|
|
2796
3246
|
server.tool('geometra_list_sessions', 'List all active Geometra sessions with their IDs and URLs. Use this to discover available sessions when operating on multiple pages in parallel.', {}, async () => {
|
|
2797
3247
|
const sessions = listSessions();
|
|
@@ -2822,8 +3272,17 @@ function connectPayload(session, opts) {
|
|
|
2822
3272
|
...(opts.detail === 'verbose' && a11y ? { currentUi: sessionOverviewFromA11y(a11y) } : {}),
|
|
2823
3273
|
};
|
|
2824
3274
|
}
|
|
3275
|
+
function sessionHasFreshUi(session) {
|
|
3276
|
+
// `ws` is required on real Sessions. The absence allowance keeps legacy
|
|
3277
|
+
// in-process test doubles compatible while production always verifies the
|
|
3278
|
+
// transport is OPEN, closing the CLOSING -> close-event stale-read window.
|
|
3279
|
+
const transportOpen = !session.ws || session.ws.readyState === session.ws.OPEN;
|
|
3280
|
+
return transportOpen
|
|
3281
|
+
&& session.hasFreshFrame !== false
|
|
3282
|
+
&& Boolean(session.tree && session.layout);
|
|
3283
|
+
}
|
|
2825
3284
|
function sessionA11y(session) {
|
|
2826
|
-
if (!session
|
|
3285
|
+
if (!sessionHasFreshUi(session))
|
|
2827
3286
|
return null;
|
|
2828
3287
|
if (session.cachedA11yRevision === session.updateRevision) {
|
|
2829
3288
|
return session.cachedA11y ?? null;
|
|
@@ -2834,9 +3293,17 @@ function sessionA11y(session) {
|
|
|
2834
3293
|
return a11y;
|
|
2835
3294
|
}
|
|
2836
3295
|
async function ensureSessionUiTree(session, timeoutMs = 4_000) {
|
|
2837
|
-
if (session
|
|
3296
|
+
if (sessionHasFreshUi(session))
|
|
3297
|
+
return true;
|
|
3298
|
+
try {
|
|
3299
|
+
await ensureSessionConnected(session);
|
|
3300
|
+
}
|
|
3301
|
+
catch {
|
|
3302
|
+
return false;
|
|
3303
|
+
}
|
|
3304
|
+
if (sessionHasFreshUi(session))
|
|
2838
3305
|
return true;
|
|
2839
|
-
return await waitForUiCondition(session, () =>
|
|
3306
|
+
return await waitForUiCondition(session, () => sessionHasFreshUi(session), timeoutMs);
|
|
2840
3307
|
}
|
|
2841
3308
|
async function sessionA11yWhenReady(session, timeoutMs = 4_000) {
|
|
2842
3309
|
const ready = await ensureSessionUiTree(session, timeoutMs);
|
|
@@ -2883,6 +3350,7 @@ function packedFormSchemas(forms) {
|
|
|
2883
3350
|
ic: form.invalidCount,
|
|
2884
3351
|
f: form.fields.map(field => ({
|
|
2885
3352
|
i: field.id,
|
|
3353
|
+
...(field.fieldKey ? { fk: field.fieldKey } : {}),
|
|
2886
3354
|
k: field.kind,
|
|
2887
3355
|
l: field.label,
|
|
2888
3356
|
...(field.required ? { r: 1 } : {}),
|
|
@@ -2891,6 +3359,8 @@ function packedFormSchemas(forms) {
|
|
|
2891
3359
|
...(field.booleanChoice ? { b: 1 } : {}),
|
|
2892
3360
|
...(field.controlType ? { t: field.controlType } : {}),
|
|
2893
3361
|
...(field.optionCount !== undefined ? { oc: field.optionCount } : {}),
|
|
3362
|
+
...(field.options ? { o: field.options } : {}),
|
|
3363
|
+
...(field.optionDetails ? { od: field.optionDetails } : {}),
|
|
2894
3364
|
...(field.value ? { v: field.value } : {}),
|
|
2895
3365
|
...(field.valueLength !== undefined ? { vl: field.valueLength } : {}),
|
|
2896
3366
|
...(field.checked !== undefined ? { c: field.checked ? 1 : 0 } : {}),
|
|
@@ -2904,7 +3374,10 @@ function packedFormSchemas(forms) {
|
|
|
2904
3374
|
}
|
|
2905
3375
|
function formSchemaResponsePayload(session, opts) {
|
|
2906
3376
|
const forms = getSessionFormSchemas(session, opts);
|
|
2907
|
-
const
|
|
3377
|
+
const graphForms = opts.includeFormGraph && !opts.includeOptions
|
|
3378
|
+
? getSessionFormSchemas(session, { ...opts, includeOptions: true })
|
|
3379
|
+
: forms;
|
|
3380
|
+
const schemaJson = JSON.stringify(forms) + (opts.includeFormGraph ? '|formgraph' : '');
|
|
2908
3381
|
const schemaId = `fs:${shortHash(schemaJson)}`;
|
|
2909
3382
|
if (opts.sinceSchemaId && opts.sinceSchemaId === schemaId) {
|
|
2910
3383
|
return {
|
|
@@ -2914,12 +3387,14 @@ function formSchemaResponsePayload(session, opts) {
|
|
|
2914
3387
|
format: opts.format ?? 'compact',
|
|
2915
3388
|
};
|
|
2916
3389
|
}
|
|
3390
|
+
const pageUrl = sessionA11y(session)?.meta?.pageUrl;
|
|
2917
3391
|
return {
|
|
2918
3392
|
schemaId,
|
|
2919
3393
|
changed: true,
|
|
2920
3394
|
formCount: forms.length,
|
|
2921
3395
|
format: opts.format ?? 'compact',
|
|
2922
3396
|
forms: (opts.format ?? 'compact') === 'packed' ? packedFormSchemas(forms) : forms,
|
|
3397
|
+
...(opts.includeFormGraph ? { formGraphs: graphForms.map(form => formSchemaToFormGraph(form, pageUrl)) } : {}),
|
|
2923
3398
|
};
|
|
2924
3399
|
}
|
|
2925
3400
|
function totalReturnedSchemaFields(forms) {
|
|
@@ -2986,7 +3461,7 @@ function connectResponsePayload(session, opts) {
|
|
|
2986
3461
|
function deferredPageModelConnectPayload(session, options) {
|
|
2987
3462
|
return {
|
|
2988
3463
|
deferred: true,
|
|
2989
|
-
ready:
|
|
3464
|
+
ready: sessionHasFreshUi(session),
|
|
2990
3465
|
tool: 'geometra_page_model',
|
|
2991
3466
|
options: {
|
|
2992
3467
|
maxPrimaryActions: options?.maxPrimaryActions ?? 6,
|
|
@@ -3029,6 +3504,12 @@ async function ensureToolSession(target, missingConnectionMessage = 'Not connect
|
|
|
3029
3504
|
if (!normalized.ok)
|
|
3030
3505
|
return { ok: false, error: normalized.error };
|
|
3031
3506
|
const resolvedTarget = normalized.value;
|
|
3507
|
+
if (resolvedTarget.kind === 'ws' && target.isolated === true) {
|
|
3508
|
+
return {
|
|
3509
|
+
ok: false,
|
|
3510
|
+
error: 'isolated:true is unsupported for direct ws:// endpoints because MCP does not own that browser/runtime. Use pageUrl/HTTP(S) url for an isolated browser, or omit isolated for the externally managed endpoint.',
|
|
3511
|
+
};
|
|
3512
|
+
}
|
|
3032
3513
|
try {
|
|
3033
3514
|
if (resolvedTarget.kind === 'proxy') {
|
|
3034
3515
|
const session = await connectThroughProxy({
|
|
@@ -3040,7 +3521,7 @@ async function ensureToolSession(target, missingConnectionMessage = 'Not connect
|
|
|
3040
3521
|
slowMo: target.slowMo,
|
|
3041
3522
|
...(target.stealth !== undefined && { stealth: target.stealth }),
|
|
3042
3523
|
awaitInitialFrame: target.awaitInitialFrame,
|
|
3043
|
-
isolated: target.isolated,
|
|
3524
|
+
isolated: target.isolated !== false,
|
|
3044
3525
|
});
|
|
3045
3526
|
return {
|
|
3046
3527
|
ok: true,
|
|
@@ -3073,6 +3554,7 @@ function autoConnectionPayload(target) {
|
|
|
3073
3554
|
return {};
|
|
3074
3555
|
return {
|
|
3075
3556
|
autoConnected: true,
|
|
3557
|
+
sessionId: target.session.id,
|
|
3076
3558
|
...(target.transport ? { transport: target.transport } : {}),
|
|
3077
3559
|
...(target.requestedPageUrl ? { pageUrl: target.requestedPageUrl } : {}),
|
|
3078
3560
|
...(target.requestedWsUrl ? { requestedWsUrl: target.requestedWsUrl } : {}),
|
|
@@ -3096,8 +3578,8 @@ function postActionSummary(session, before, wait, detail = 'minimal') {
|
|
|
3096
3578
|
}
|
|
3097
3579
|
if (wait?.status === 'timed_out') {
|
|
3098
3580
|
notes.push(detail === 'verbose'
|
|
3099
|
-
? `No
|
|
3100
|
-
: `No
|
|
3581
|
+
? `No correlated terminal response arrived within ${wait.timeoutMs}ms. The action may still be running or may already have succeeded. ${ambiguousActionGuidance(wait)}`
|
|
3582
|
+
: `No correlated response arrived within ${wait.timeoutMs}ms; the action may still have succeeded. Do not retry blindly.`);
|
|
3101
3583
|
}
|
|
3102
3584
|
if (!after)
|
|
3103
3585
|
return [...notes, 'No UI update received'].filter(Boolean).join('\n');
|
|
@@ -3131,6 +3613,123 @@ function summarizeCompactContext(context) {
|
|
|
3131
3613
|
}
|
|
3132
3614
|
return parts.length > 0 ? `Context: ${parts.join(' | ')}` : '';
|
|
3133
3615
|
}
|
|
3616
|
+
function pathIsStrictDescendant(path, ancestorPath) {
|
|
3617
|
+
return path.length > ancestorPath.length && ancestorPath.every((segment, index) => path[index] === segment);
|
|
3618
|
+
}
|
|
3619
|
+
function formNodeAtPath(root, path) {
|
|
3620
|
+
const node = findNodeByPath(root, path);
|
|
3621
|
+
return node?.role === 'form' ? node : undefined;
|
|
3622
|
+
}
|
|
3623
|
+
function formScopeNodeAtPath(root, path) {
|
|
3624
|
+
const node = findNodeByPath(root, path);
|
|
3625
|
+
return node && (node.role === 'form' || node.role === 'group' || node.role === 'region')
|
|
3626
|
+
? node
|
|
3627
|
+
: undefined;
|
|
3628
|
+
}
|
|
3629
|
+
function formScopeFieldAnchor(field) {
|
|
3630
|
+
if (field.fieldKey)
|
|
3631
|
+
return `key:${field.fieldKey}`;
|
|
3632
|
+
return [
|
|
3633
|
+
'field',
|
|
3634
|
+
field.kind,
|
|
3635
|
+
normalizeLookupKey(field.label),
|
|
3636
|
+
field.controlType ?? '',
|
|
3637
|
+
field.choiceType ?? '',
|
|
3638
|
+
field.booleanChoice ? 'boolean' : '',
|
|
3639
|
+
].join(':');
|
|
3640
|
+
}
|
|
3641
|
+
function captureFormScopeIdentity(node, schema) {
|
|
3642
|
+
const fieldAnchors = schema.fields.map(formScopeFieldAnchor).sort();
|
|
3643
|
+
const name = normalizeLookupKey(node.name || schema.name || '');
|
|
3644
|
+
if (fieldAnchors.length === 0 && !name) {
|
|
3645
|
+
return {
|
|
3646
|
+
ok: false,
|
|
3647
|
+
error: `Target form ${schema.formId} has no stable semantic or field identity; refusing to submit without a maintainable form scope`,
|
|
3648
|
+
};
|
|
3649
|
+
}
|
|
3650
|
+
return {
|
|
3651
|
+
ok: true,
|
|
3652
|
+
identity: {
|
|
3653
|
+
role: node.role,
|
|
3654
|
+
name,
|
|
3655
|
+
fieldAnchors,
|
|
3656
|
+
},
|
|
3657
|
+
};
|
|
3658
|
+
}
|
|
3659
|
+
function containsFormFieldAnchors(current, expected) {
|
|
3660
|
+
const counts = new Map();
|
|
3661
|
+
for (const anchor of current)
|
|
3662
|
+
counts.set(anchor, (counts.get(anchor) ?? 0) + 1);
|
|
3663
|
+
for (const anchor of expected) {
|
|
3664
|
+
const count = counts.get(anchor) ?? 0;
|
|
3665
|
+
if (count === 0)
|
|
3666
|
+
return false;
|
|
3667
|
+
counts.set(anchor, count - 1);
|
|
3668
|
+
}
|
|
3669
|
+
return true;
|
|
3670
|
+
}
|
|
3671
|
+
function currentFormScope(session, root, scope) {
|
|
3672
|
+
const node = formScopeNodeAtPath(root, scope.path);
|
|
3673
|
+
const schema = getSessionFormSchemas(session, {
|
|
3674
|
+
includeOptions: false,
|
|
3675
|
+
includeContext: 'auto',
|
|
3676
|
+
}).find(candidate => candidate.formId === scope.formId);
|
|
3677
|
+
if (!node || !schema) {
|
|
3678
|
+
return { ok: false, error: `Target form ${scope.formId} is stale or no longer resolves to a form in the current UI` };
|
|
3679
|
+
}
|
|
3680
|
+
const currentName = normalizeLookupKey(node.name || schema.name || '');
|
|
3681
|
+
const currentAnchors = schema.fields.map(formScopeFieldAnchor).sort();
|
|
3682
|
+
if (node.role !== scope.identity.role ||
|
|
3683
|
+
currentName !== scope.identity.name ||
|
|
3684
|
+
!containsFormFieldAnchors(currentAnchors, scope.identity.fieldAnchors)) {
|
|
3685
|
+
return {
|
|
3686
|
+
ok: false,
|
|
3687
|
+
error: `Target form ${scope.formId} was replaced at the same path; its semantic/schema identity no longer matches`,
|
|
3688
|
+
};
|
|
3689
|
+
}
|
|
3690
|
+
return { ok: true, node, schema };
|
|
3691
|
+
}
|
|
3692
|
+
function currentVisibleClickCenter(root, target) {
|
|
3693
|
+
const { x, y, width, height } = target.bounds;
|
|
3694
|
+
const viewport = root.bounds;
|
|
3695
|
+
if (![x, y, width, height, viewport.x, viewport.y, viewport.width, viewport.height].every(Number.isFinite))
|
|
3696
|
+
return undefined;
|
|
3697
|
+
if (width <= 0 || height <= 0 || viewport.width <= 0 || viewport.height <= 0)
|
|
3698
|
+
return undefined;
|
|
3699
|
+
const visibleLeft = Math.max(x, viewport.x);
|
|
3700
|
+
const visibleTop = Math.max(y, viewport.y);
|
|
3701
|
+
const visibleRight = Math.min(x + width, viewport.x + viewport.width);
|
|
3702
|
+
const visibleBottom = Math.min(y + height, viewport.y + viewport.height);
|
|
3703
|
+
if (visibleRight <= visibleLeft || visibleBottom <= visibleTop)
|
|
3704
|
+
return undefined;
|
|
3705
|
+
return {
|
|
3706
|
+
x: visibleLeft + (visibleRight - visibleLeft) / 2,
|
|
3707
|
+
y: visibleTop + (visibleBottom - visibleTop) / 2,
|
|
3708
|
+
};
|
|
3709
|
+
}
|
|
3710
|
+
function resolveSubmitFormNode(root, formId, submitPath) {
|
|
3711
|
+
const parsedForm = formId ? parseSectionId(formId) : null;
|
|
3712
|
+
if (parsedForm?.kind === 'form') {
|
|
3713
|
+
const explicit = formNodeAtPath(root, parsedForm.path);
|
|
3714
|
+
if (explicit)
|
|
3715
|
+
return explicit;
|
|
3716
|
+
}
|
|
3717
|
+
if (!submitPath)
|
|
3718
|
+
return undefined;
|
|
3719
|
+
for (let length = submitPath.length; length >= 0; length--) {
|
|
3720
|
+
const candidate = formNodeAtPath(root, submitPath.slice(0, length));
|
|
3721
|
+
if (candidate)
|
|
3722
|
+
return candidate;
|
|
3723
|
+
}
|
|
3724
|
+
return undefined;
|
|
3725
|
+
}
|
|
3726
|
+
function scopeSessionSignalsToForm(pageRoot, formNode, targetFormWasKnown = false) {
|
|
3727
|
+
const pageSignals = collectSessionSignals(pageRoot);
|
|
3728
|
+
if (formNode) {
|
|
3729
|
+
return { ...pageSignals, invalidFields: collectSessionSignals(formNode).invalidFields };
|
|
3730
|
+
}
|
|
3731
|
+
return targetFormWasKnown ? { ...pageSignals, invalidFields: [] } : pageSignals;
|
|
3732
|
+
}
|
|
3134
3733
|
function collectSessionSignals(root) {
|
|
3135
3734
|
const signals = {
|
|
3136
3735
|
...(root.meta?.pageUrl ? { pageUrl: root.meta.pageUrl } : {}),
|
|
@@ -3143,6 +3742,17 @@ function collectSessionSignals(root) {
|
|
|
3143
3742
|
};
|
|
3144
3743
|
const seenAlerts = new Set();
|
|
3145
3744
|
const seenInvalidIds = new Set();
|
|
3745
|
+
const formControlRoles = new Set([
|
|
3746
|
+
'textbox',
|
|
3747
|
+
'combobox',
|
|
3748
|
+
'checkbox',
|
|
3749
|
+
'radio',
|
|
3750
|
+
'spinbutton',
|
|
3751
|
+
'listbox',
|
|
3752
|
+
'searchbox',
|
|
3753
|
+
'slider',
|
|
3754
|
+
'switch',
|
|
3755
|
+
]);
|
|
3146
3756
|
const captchaPattern = /recaptcha|g-recaptcha|hcaptcha|h-captcha|turnstile|cf-turnstile|captcha/i;
|
|
3147
3757
|
const captchaTypes = {
|
|
3148
3758
|
recaptcha: 'recaptcha', 'g-recaptcha': 'recaptcha',
|
|
@@ -3169,7 +3779,11 @@ function collectSessionSignals(root) {
|
|
|
3169
3779
|
signals.alerts.push(text);
|
|
3170
3780
|
}
|
|
3171
3781
|
}
|
|
3172
|
-
|
|
3782
|
+
const isFormControl = formControlRoles.has(node.role) ||
|
|
3783
|
+
node.meta?.controlTag === 'input' ||
|
|
3784
|
+
node.meta?.controlTag === 'textarea' ||
|
|
3785
|
+
node.meta?.controlTag === 'select';
|
|
3786
|
+
if (isFormControl && (node.state?.invalid === true || Boolean(node.validation?.error))) {
|
|
3173
3787
|
const id = nodeIdForPath(node.path);
|
|
3174
3788
|
if (!seenInvalidIds.has(id)) {
|
|
3175
3789
|
seenInvalidIds.add(id);
|
|
@@ -3274,20 +3888,26 @@ function compactTextValue(value, inlineLimit = 48) {
|
|
|
3274
3888
|
? { value: normalized }
|
|
3275
3889
|
: { valueLength: value.length };
|
|
3276
3890
|
}
|
|
3277
|
-
function
|
|
3891
|
+
function fieldReadbackNodes(a11y, fieldLabel, roles, fieldKey) {
|
|
3892
|
+
if (fieldKey) {
|
|
3893
|
+
const matches = [];
|
|
3894
|
+
const allowedRoles = new Set(roles);
|
|
3895
|
+
const walk = (node) => {
|
|
3896
|
+
if (allowedRoles.has(node.role) && node.meta?.controlKey === fieldKey)
|
|
3897
|
+
matches.push(node);
|
|
3898
|
+
for (const child of node.children)
|
|
3899
|
+
walk(child);
|
|
3900
|
+
};
|
|
3901
|
+
walk(a11y);
|
|
3902
|
+
return matches;
|
|
3903
|
+
}
|
|
3904
|
+
return roles.flatMap(role => findNodes(a11y, { name: fieldLabel, role }));
|
|
3905
|
+
}
|
|
3906
|
+
function fieldStatePayload(session, fieldLabel, fieldKey) {
|
|
3278
3907
|
const a11y = sessionA11y(session);
|
|
3279
3908
|
if (!a11y)
|
|
3280
3909
|
return undefined;
|
|
3281
|
-
const matches =
|
|
3282
|
-
name: fieldLabel,
|
|
3283
|
-
role: 'combobox',
|
|
3284
|
-
});
|
|
3285
|
-
if (matches.length === 0) {
|
|
3286
|
-
matches.push(...findNodes(a11y, { name: fieldLabel, role: 'textbox' }));
|
|
3287
|
-
}
|
|
3288
|
-
if (matches.length === 0) {
|
|
3289
|
-
matches.push(...findNodes(a11y, { name: fieldLabel, role: 'button' }));
|
|
3290
|
-
}
|
|
3910
|
+
const matches = fieldReadbackNodes(a11y, fieldLabel, ['combobox', 'textbox', 'button'], fieldKey);
|
|
3291
3911
|
const match = matches[0];
|
|
3292
3912
|
if (!match)
|
|
3293
3913
|
return undefined;
|
|
@@ -3302,7 +3922,11 @@ function fieldStatePayload(session, fieldLabel) {
|
|
|
3302
3922
|
function waitStatusPayload(wait) {
|
|
3303
3923
|
if (!wait)
|
|
3304
3924
|
return {};
|
|
3305
|
-
const payload = {
|
|
3925
|
+
const payload = {
|
|
3926
|
+
wait: wait.status,
|
|
3927
|
+
requestId: wait.requestId,
|
|
3928
|
+
actionId: wait.actionId,
|
|
3929
|
+
};
|
|
3306
3930
|
// Surface navigation info from proxy click handlers so callers can tell
|
|
3307
3931
|
// when a click triggered a full-page nav (form submit → thank-you page).
|
|
3308
3932
|
// Without this, the proxy session may die on the next request and the
|
|
@@ -3372,6 +3996,45 @@ function waitConditionCompact(result) {
|
|
|
3372
3996
|
...(result.present ? { matchCount: result.matchCount } : {}),
|
|
3373
3997
|
};
|
|
3374
3998
|
}
|
|
3999
|
+
function waitEvidenceNodeSnapshot(node, filter) {
|
|
4000
|
+
const relevantState = {
|
|
4001
|
+
...(filter.checked !== undefined ? { checked: node.state?.checked } : {}),
|
|
4002
|
+
...(filter.disabled !== undefined ? { disabled: node.state?.disabled } : {}),
|
|
4003
|
+
...(filter.focused !== undefined ? { focused: node.state?.focused } : {}),
|
|
4004
|
+
...(filter.selected !== undefined ? { selected: node.state?.selected } : {}),
|
|
4005
|
+
...(filter.expanded !== undefined ? { expanded: node.state?.expanded } : {}),
|
|
4006
|
+
...(filter.invalid !== undefined ? { invalid: node.state?.invalid } : {}),
|
|
4007
|
+
...(filter.required !== undefined ? { required: node.state?.required } : {}),
|
|
4008
|
+
...(filter.busy !== undefined ? { busy: node.state?.busy } : {}),
|
|
4009
|
+
};
|
|
4010
|
+
return {
|
|
4011
|
+
role: node.role,
|
|
4012
|
+
name: node.name,
|
|
4013
|
+
value: node.value,
|
|
4014
|
+
validation: node.validation,
|
|
4015
|
+
...(Object.keys(relevantState).length > 0 ? { state: relevantState } : {}),
|
|
4016
|
+
};
|
|
4017
|
+
}
|
|
4018
|
+
function captureWaitEvidence(root, filter) {
|
|
4019
|
+
const matches = findNodes(root, filter);
|
|
4020
|
+
return {
|
|
4021
|
+
matchCount: matches.length,
|
|
4022
|
+
fingerprints: new Set(matches.map(node => JSON.stringify(waitEvidenceNodeSnapshot(node, filter)))),
|
|
4023
|
+
};
|
|
4024
|
+
}
|
|
4025
|
+
function waitConditionHasFreshEvidence(before, after, result) {
|
|
4026
|
+
if (!before || !after)
|
|
4027
|
+
return false;
|
|
4028
|
+
const current = captureWaitEvidence(after, result.filter);
|
|
4029
|
+
if (!result.present) {
|
|
4030
|
+
return before.matchCount > 0 && current.matchCount === 0;
|
|
4031
|
+
}
|
|
4032
|
+
if (current.matchCount === 0)
|
|
4033
|
+
return false;
|
|
4034
|
+
if (before.matchCount === 0)
|
|
4035
|
+
return true;
|
|
4036
|
+
return [...current.fingerprints].some(fingerprint => !before.fingerprints.has(fingerprint));
|
|
4037
|
+
}
|
|
3375
4038
|
function inferRevealStepBudget(target, viewport) {
|
|
3376
4039
|
const verticalSteps = Math.ceil(Math.abs(target.scrollHint.revealDeltaY) / Math.max(1, viewport.height * 0.75));
|
|
3377
4040
|
const horizontalSteps = Math.ceil(Math.abs(target.scrollHint.revealDeltaX) / Math.max(1, viewport.width * 0.7));
|
|
@@ -3396,9 +4059,19 @@ async function revealSemanticTarget(session, options) {
|
|
|
3396
4059
|
const a11y = sessionA11y(session);
|
|
3397
4060
|
if (!a11y)
|
|
3398
4061
|
return { ok: false, error: 'No UI tree available to reveal from' };
|
|
3399
|
-
|
|
4062
|
+
if (options.formScope) {
|
|
4063
|
+
const scoped = currentFormScope(session, a11y, options.formScope);
|
|
4064
|
+
if (!scoped.ok) {
|
|
4065
|
+
return { ok: false, error: scoped.error, staleFormScope: true };
|
|
4066
|
+
}
|
|
4067
|
+
}
|
|
4068
|
+
// Match against the page root first so prompt/section/item context from
|
|
4069
|
+
// ancestors outside the form remains available, then constrain the
|
|
4070
|
+
// actionable candidates to descendants of the selected current form.
|
|
4071
|
+
const matches = sortA11yNodes(findNodes(a11y, options.filter).filter(node => !options.formScope || pathIsStrictDescendant(node.path, options.formScope.path)));
|
|
3400
4072
|
if (matches.length === 0) {
|
|
3401
|
-
|
|
4073
|
+
const scopeSuffix = options.formScope ? ` within form ${options.formScope.formId}` : '';
|
|
4074
|
+
return { ok: false, error: `No elements found matching ${JSON.stringify(options.filter)}${scopeSuffix}` };
|
|
3402
4075
|
}
|
|
3403
4076
|
if (options.index >= matches.length) {
|
|
3404
4077
|
return {
|
|
@@ -3467,6 +4140,7 @@ async function resolveClickLocation(session, options) {
|
|
|
3467
4140
|
filter: options.filter,
|
|
3468
4141
|
index: options.index ?? 0,
|
|
3469
4142
|
fullyVisible: options.fullyVisible ?? true,
|
|
4143
|
+
formScope: options.formScope,
|
|
3470
4144
|
maxSteps: options.maxRevealSteps,
|
|
3471
4145
|
timeoutMs: options.revealTimeoutMs ?? 2_500,
|
|
3472
4146
|
});
|
|
@@ -3486,6 +4160,8 @@ async function resolveClickLocationWithFallback(session, options) {
|
|
|
3486
4160
|
const first = await resolveClickLocation(session, options);
|
|
3487
4161
|
if (first.ok)
|
|
3488
4162
|
return first;
|
|
4163
|
+
if (first.staleFormScope)
|
|
4164
|
+
return first;
|
|
3489
4165
|
// Fallback only applies to semantic resolves. Explicit coordinates never enter
|
|
3490
4166
|
// the reveal path, so there is nothing to retry.
|
|
3491
4167
|
const hasExplicitCoordinates = options.x !== undefined || options.y !== undefined;
|
|
@@ -3508,6 +4184,8 @@ async function resolveClickLocationWithFallback(session, options) {
|
|
|
3508
4184
|
fallback: { attempted: true, used: true, reason: 'revision-retry', attempts },
|
|
3509
4185
|
};
|
|
3510
4186
|
}
|
|
4187
|
+
if (retry.staleFormScope)
|
|
4188
|
+
return retry;
|
|
3511
4189
|
}
|
|
3512
4190
|
if (options.fullyVisible !== false) {
|
|
3513
4191
|
attempts += 1;
|
|
@@ -3524,6 +4202,8 @@ async function resolveClickLocationWithFallback(session, options) {
|
|
|
3524
4202
|
fallback: { attempted: true, used: true, reason: 'relaxed-visibility', attempts },
|
|
3525
4203
|
};
|
|
3526
4204
|
}
|
|
4205
|
+
if (relaxed.staleFormScope)
|
|
4206
|
+
return relaxed;
|
|
3527
4207
|
}
|
|
3528
4208
|
// All fallback phases tried and none recovered. Carry the trace of what we
|
|
3529
4209
|
// tried so operators see the attempted-but-failed signal alongside the
|
|
@@ -3570,6 +4250,24 @@ function compactFormattedNode(node) {
|
|
|
3570
4250
|
function detailText(summary, compact, detail) {
|
|
3571
4251
|
return detail === 'terse' ? JSON.stringify(compact) : summary;
|
|
3572
4252
|
}
|
|
4253
|
+
function ambiguousActionGuidance(wait) {
|
|
4254
|
+
return `Outcome is ambiguous for actionId ${wait.actionId} (requestId ${wait.requestId}). ` +
|
|
4255
|
+
'Do not retry blindly or skip this step; inspect the current UI first. ' +
|
|
4256
|
+
'An identical retry reuses the same action identity when the proxy supports deduplication.';
|
|
4257
|
+
}
|
|
4258
|
+
function actionWaitResponse(wait, summary, compact, detail) {
|
|
4259
|
+
if (wait.status === 'timed_out') {
|
|
4260
|
+
const guidance = ambiguousActionGuidance(wait);
|
|
4261
|
+
return err(detailText(`${summary}\n${guidance}`, {
|
|
4262
|
+
completed: false,
|
|
4263
|
+
outcome: 'unconfirmed',
|
|
4264
|
+
retrySafety: 'inspect-first',
|
|
4265
|
+
guidance,
|
|
4266
|
+
...compact,
|
|
4267
|
+
}, detail));
|
|
4268
|
+
}
|
|
4269
|
+
return ok(detailText(summary, compact, detail));
|
|
4270
|
+
}
|
|
3573
4271
|
function normalizeLookupKey(value) {
|
|
3574
4272
|
return value.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
3575
4273
|
}
|
|
@@ -3613,6 +4311,57 @@ function coerceChoiceValue(field, value) {
|
|
|
3613
4311
|
const option = field.options?.find(option => normalizeLookupKey(option) === desired);
|
|
3614
4312
|
return option ?? (value ? 'Yes' : 'No');
|
|
3615
4313
|
}
|
|
4314
|
+
function resolveNativeChoiceValue(field, value) {
|
|
4315
|
+
const options = field.optionDetails;
|
|
4316
|
+
if (!options || options.length === 0)
|
|
4317
|
+
return { ok: true, value };
|
|
4318
|
+
const resolved = (option) => ({
|
|
4319
|
+
ok: true,
|
|
4320
|
+
value: option.value,
|
|
4321
|
+
optionIndex: option.index,
|
|
4322
|
+
expectedReadback: option.label,
|
|
4323
|
+
});
|
|
4324
|
+
const enabled = options.filter(option => !option.disabled);
|
|
4325
|
+
const exactValue = enabled.filter(option => option.value === value);
|
|
4326
|
+
if (exactValue.length > 0)
|
|
4327
|
+
return resolved(exactValue[0]);
|
|
4328
|
+
const lookup = normalizeLookupKey(value);
|
|
4329
|
+
const normalizedValues = enabled.filter(option => normalizeLookupKey(option.value) === lookup);
|
|
4330
|
+
if (normalizedValues.length === 1)
|
|
4331
|
+
return resolved(normalizedValues[0]);
|
|
4332
|
+
if (normalizedValues.length > 1) {
|
|
4333
|
+
const distinct = new Set(normalizedValues.map(option => option.value));
|
|
4334
|
+
if (distinct.size === 1)
|
|
4335
|
+
return resolved(normalizedValues[0]);
|
|
4336
|
+
return {
|
|
4337
|
+
ok: false,
|
|
4338
|
+
error: `Option value "${value}" is ambiguous for field "${field.label}". Use an exact option value from optionDetails.`,
|
|
4339
|
+
};
|
|
4340
|
+
}
|
|
4341
|
+
const labelMatches = enabled.filter(option => normalizeLookupKey(option.label) === lookup);
|
|
4342
|
+
if (labelMatches.length === 1)
|
|
4343
|
+
return resolved(labelMatches[0]);
|
|
4344
|
+
if (labelMatches.length > 1) {
|
|
4345
|
+
const distinctValues = new Set(labelMatches.map(option => option.value));
|
|
4346
|
+
if (distinctValues.size === 1)
|
|
4347
|
+
return resolved(labelMatches[0]);
|
|
4348
|
+
return {
|
|
4349
|
+
ok: false,
|
|
4350
|
+
error: `Option label "${value}" maps to multiple submitted values for field "${field.label}". Use an exact option value from optionDetails.`,
|
|
4351
|
+
};
|
|
4352
|
+
}
|
|
4353
|
+
const disabledMatch = options.some(option => option.disabled && (option.value === value ||
|
|
4354
|
+
normalizeLookupKey(option.value) === lookup ||
|
|
4355
|
+
normalizeLookupKey(option.label) === lookup));
|
|
4356
|
+
if (disabledMatch) {
|
|
4357
|
+
return { ok: false, error: `Option "${value}" is disabled for field "${field.label}".` };
|
|
4358
|
+
}
|
|
4359
|
+
const available = enabled.slice(0, 12).map(option => `${option.label} (${option.value})`);
|
|
4360
|
+
return {
|
|
4361
|
+
ok: false,
|
|
4362
|
+
error: `Unknown option "${value}" for field "${field.label}".${available.length > 0 ? ` Available options: ${available.join(', ')}.` : ''}`,
|
|
4363
|
+
};
|
|
4364
|
+
}
|
|
3616
4365
|
/**
|
|
3617
4366
|
* Normalize a text value based on field format hints (placeholder, inputType, pattern).
|
|
3618
4367
|
* Handles common date and phone format conversions.
|
|
@@ -3688,28 +4437,69 @@ function pad2(n) {
|
|
|
3688
4437
|
return n < 10 ? `0${n}` : String(n);
|
|
3689
4438
|
}
|
|
3690
4439
|
function plannedFillInputsForField(field, value) {
|
|
4440
|
+
if (field.kind === 'file') {
|
|
4441
|
+
if (!Array.isArray(value)) {
|
|
4442
|
+
return { error: `Field "${field.label}" expects a non-empty string array of proxy-host file paths` };
|
|
4443
|
+
}
|
|
4444
|
+
const paths = value.map(path => path.trim()).filter(Boolean);
|
|
4445
|
+
if (paths.length === 0 || paths.length !== value.length) {
|
|
4446
|
+
return { error: `Field "${field.label}" expects a non-empty string array of proxy-host file paths` };
|
|
4447
|
+
}
|
|
4448
|
+
if (field.format?.multiple !== true && paths.length > 1) {
|
|
4449
|
+
return { error: `Field "${field.label}" accepts only one file` };
|
|
4450
|
+
}
|
|
4451
|
+
return [{
|
|
4452
|
+
kind: 'file',
|
|
4453
|
+
fieldId: field.id,
|
|
4454
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
4455
|
+
fieldLabel: field.label,
|
|
4456
|
+
paths,
|
|
4457
|
+
}];
|
|
4458
|
+
}
|
|
3691
4459
|
if (field.kind === 'text') {
|
|
3692
4460
|
if (typeof value !== 'string')
|
|
3693
4461
|
return { error: `Field "${field.label}" expects a string value` };
|
|
3694
4462
|
const normalized = normalizeFieldValue(value, field.format);
|
|
3695
|
-
return [{
|
|
4463
|
+
return [{
|
|
4464
|
+
kind: 'text',
|
|
4465
|
+
fieldId: field.id,
|
|
4466
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
4467
|
+
fieldLabel: field.label,
|
|
4468
|
+
value: normalized,
|
|
4469
|
+
}];
|
|
3696
4470
|
}
|
|
3697
4471
|
if (field.kind === 'choice') {
|
|
3698
4472
|
const coerced = coerceChoiceValue(field, value);
|
|
3699
|
-
if (
|
|
4473
|
+
if (coerced === null)
|
|
3700
4474
|
return { error: `Field "${field.label}" expects a string value` };
|
|
4475
|
+
const resolvedChoice = field.choiceType === 'select'
|
|
4476
|
+
? resolveNativeChoiceValue(field, coerced)
|
|
4477
|
+
: { ok: true, value: coerced };
|
|
4478
|
+
if (!resolvedChoice.ok)
|
|
4479
|
+
return { error: resolvedChoice.error };
|
|
3701
4480
|
return [{
|
|
3702
4481
|
kind: 'choice',
|
|
3703
4482
|
fieldId: field.id,
|
|
4483
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
3704
4484
|
fieldLabel: field.label,
|
|
3705
|
-
value:
|
|
4485
|
+
value: resolvedChoice.value,
|
|
4486
|
+
...(resolvedChoice.optionIndex !== undefined ? { optionIndex: resolvedChoice.optionIndex } : {}),
|
|
4487
|
+
...(resolvedChoice.expectedReadback !== undefined ? { expectedReadback: resolvedChoice.expectedReadback } : {}),
|
|
4488
|
+
...(field.choiceType === 'select' && field.optionDetails?.length ? { exact: true } : {}),
|
|
3706
4489
|
...(field.choiceType ? { choiceType: field.choiceType } : {}),
|
|
3707
4490
|
}];
|
|
3708
4491
|
}
|
|
3709
4492
|
if (field.kind === 'toggle') {
|
|
3710
4493
|
if (typeof value !== 'boolean')
|
|
3711
4494
|
return { error: `Field "${field.label}" expects a boolean value` };
|
|
3712
|
-
return [{
|
|
4495
|
+
return [{
|
|
4496
|
+
kind: 'toggle',
|
|
4497
|
+
fieldId: field.id,
|
|
4498
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
4499
|
+
label: field.label,
|
|
4500
|
+
checked: value,
|
|
4501
|
+
controlType: field.controlType,
|
|
4502
|
+
}];
|
|
3713
4503
|
}
|
|
3714
4504
|
const selected = Array.isArray(value) ? value : typeof value === 'string' ? [value] : null;
|
|
3715
4505
|
if (!selected || selected.length === 0)
|
|
@@ -3721,12 +4511,29 @@ function plannedFillInputsForField(field, value) {
|
|
|
3721
4511
|
return field.options.map(option => ({
|
|
3722
4512
|
kind: 'toggle',
|
|
3723
4513
|
fieldId: field.id,
|
|
4514
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
3724
4515
|
label: option,
|
|
3725
4516
|
checked: selectedKeys.has(normalizeLookupKey(option)),
|
|
3726
4517
|
controlType: 'checkbox',
|
|
3727
4518
|
}));
|
|
3728
4519
|
}
|
|
3729
|
-
function
|
|
4520
|
+
function unkeyedSchemaFieldIsAmbiguous(field, schemas) {
|
|
4521
|
+
if (field.fieldKey)
|
|
4522
|
+
return false;
|
|
4523
|
+
const matchingIds = new Set();
|
|
4524
|
+
const label = normalizeLookupKey(field.label);
|
|
4525
|
+
for (const schema of schemas) {
|
|
4526
|
+
for (const candidate of schema.fields) {
|
|
4527
|
+
if (normalizeLookupKey(candidate.label) === label)
|
|
4528
|
+
matchingIds.add(candidate.id);
|
|
4529
|
+
}
|
|
4530
|
+
}
|
|
4531
|
+
return matchingIds.size > 1;
|
|
4532
|
+
}
|
|
4533
|
+
function ambiguousFieldIdentityError(field) {
|
|
4534
|
+
return `Field "${field.label}" has no unique authored identity and shares its label with another control. Geometra refused to guess; refresh the schema or use a contextual recovery action.`;
|
|
4535
|
+
}
|
|
4536
|
+
function planFormFill(schema, opts, pageSchemas = [schema]) {
|
|
3730
4537
|
const fieldById = new Map(schema.fields.map(field => [field.id, field]));
|
|
3731
4538
|
const fieldsByLabel = new Map();
|
|
3732
4539
|
for (const field of schema.fields) {
|
|
@@ -3743,6 +4550,9 @@ function planFormFill(schema, opts) {
|
|
|
3743
4550
|
const field = fieldById.get(fieldId);
|
|
3744
4551
|
if (!field)
|
|
3745
4552
|
return { ok: false, error: `Unknown form field id ${fieldId}. Refresh geometra_form_schema and try again.` };
|
|
4553
|
+
if (unkeyedSchemaFieldIsAmbiguous(field, pageSchemas)) {
|
|
4554
|
+
return { ok: false, error: ambiguousFieldIdentityError(field) };
|
|
4555
|
+
}
|
|
3746
4556
|
const next = plannedFillInputsForField(field, value);
|
|
3747
4557
|
if ('error' in next)
|
|
3748
4558
|
return { ok: false, error: next.error };
|
|
@@ -3758,6 +4568,9 @@ function planFormFill(schema, opts) {
|
|
|
3758
4568
|
return { ok: false, error: `Label "${label}" is ambiguous in form ${schema.formId}. Use valuesById for this field.` };
|
|
3759
4569
|
}
|
|
3760
4570
|
const field = matches[0];
|
|
4571
|
+
if (unkeyedSchemaFieldIsAmbiguous(field, pageSchemas)) {
|
|
4572
|
+
return { ok: false, error: ambiguousFieldIdentityError(field) };
|
|
4573
|
+
}
|
|
3761
4574
|
if (seenFieldIds.has(field.id)) {
|
|
3762
4575
|
return { ok: false, error: `Field "${label}" was provided in both valuesById and valuesByLabel` };
|
|
3763
4576
|
}
|
|
@@ -3780,36 +4593,83 @@ function isResolvedFillFieldInput(field) {
|
|
|
3780
4593
|
}
|
|
3781
4594
|
function resolveFillFieldInputs(session, fields) {
|
|
3782
4595
|
const unresolved = fields.filter(field => !isResolvedFillFieldInput(field));
|
|
3783
|
-
|
|
4596
|
+
const requiresSchemaLookup = fields.some(field => typeof field.fieldId === 'string');
|
|
4597
|
+
const a11y = sessionA11y(session);
|
|
4598
|
+
if (unresolved.length === 0 && !requiresSchemaLookup && !a11y) {
|
|
3784
4599
|
return { ok: true, fields: fields };
|
|
3785
4600
|
}
|
|
3786
|
-
|
|
3787
|
-
if (!a11y)
|
|
4601
|
+
if (!a11y) {
|
|
3788
4602
|
return { ok: false, error: 'No UI tree available to resolve fieldId entries from geometra_form_schema' };
|
|
4603
|
+
}
|
|
4604
|
+
const schemas = buildFormSchemas(a11y, { includeOptions: true, includeContext: 'always' });
|
|
3789
4605
|
const fieldById = new Map();
|
|
3790
|
-
for (const schema of
|
|
4606
|
+
for (const schema of schemas) {
|
|
3791
4607
|
for (const field of schema.fields)
|
|
3792
4608
|
fieldById.set(field.id, field);
|
|
3793
4609
|
}
|
|
3794
4610
|
const resolved = [];
|
|
3795
4611
|
for (const field of fields) {
|
|
3796
4612
|
if (isResolvedFillFieldInput(field)) {
|
|
3797
|
-
|
|
3798
|
-
|
|
4613
|
+
const schemaField = field.fieldId ? fieldById.get(field.fieldId) : undefined;
|
|
4614
|
+
if (field.fieldId && !schemaField) {
|
|
4615
|
+
return { ok: false, error: `Unknown form field id ${field.fieldId}. Refresh geometra_form_schema and try again.` };
|
|
4616
|
+
}
|
|
4617
|
+
if (schemaField) {
|
|
4618
|
+
if (schemaField.kind !== field.kind) {
|
|
4619
|
+
return { ok: false, error: `Field id ${field.fieldId} resolves to kind "${schemaField.kind}", not ${field.kind}.` };
|
|
4620
|
+
}
|
|
4621
|
+
const suppliedLabel = field.kind === 'toggle' ? field.label : field.fieldLabel;
|
|
4622
|
+
if (normalizeLookupKey(suppliedLabel) !== normalizeLookupKey(schemaField.label)) {
|
|
4623
|
+
return {
|
|
4624
|
+
ok: false,
|
|
4625
|
+
error: `Field id ${field.fieldId} now resolves to "${schemaField.label}", not "${suppliedLabel}". Refresh geometra_form_schema; Geometra refused to downgrade the stale id to a label-only mutation.`,
|
|
4626
|
+
};
|
|
4627
|
+
}
|
|
4628
|
+
}
|
|
4629
|
+
const suppliedLabel = field.kind === 'toggle' ? field.label : field.fieldLabel;
|
|
4630
|
+
const distinctLabelMatches = new Map();
|
|
4631
|
+
for (const candidate of fieldById.values()) {
|
|
4632
|
+
if (normalizeLookupKey(candidate.label) === normalizeLookupKey(suppliedLabel)) {
|
|
4633
|
+
distinctLabelMatches.set(candidate.id, candidate);
|
|
4634
|
+
}
|
|
4635
|
+
}
|
|
4636
|
+
const effectiveFieldKey = field.fieldKey ?? schemaField?.fieldKey;
|
|
4637
|
+
if (!effectiveFieldKey && distinctLabelMatches.size > 1) {
|
|
4638
|
+
return {
|
|
4639
|
+
ok: false,
|
|
4640
|
+
error: schemaField
|
|
4641
|
+
? ambiguousFieldIdentityError(schemaField)
|
|
4642
|
+
: `Field label "${suppliedLabel}" is ambiguous and no exact field identity was supplied. Geometra refused to guess.`,
|
|
4643
|
+
};
|
|
4644
|
+
}
|
|
4645
|
+
if (field.kind === 'choice' && schemaField?.kind === 'choice') {
|
|
4646
|
+
const resolvedChoice = schemaField.choiceType === 'select'
|
|
4647
|
+
? resolveNativeChoiceValue(schemaField, field.value)
|
|
4648
|
+
: { ok: true, value: field.value };
|
|
4649
|
+
if (!resolvedChoice.ok)
|
|
4650
|
+
return { ok: false, error: resolvedChoice.error };
|
|
3799
4651
|
resolved.push({
|
|
3800
4652
|
...field,
|
|
3801
|
-
|
|
4653
|
+
value: resolvedChoice.value,
|
|
4654
|
+
...(resolvedChoice.optionIndex !== undefined ? { optionIndex: resolvedChoice.optionIndex } : {}),
|
|
4655
|
+
...(resolvedChoice.expectedReadback !== undefined ? { expectedReadback: resolvedChoice.expectedReadback } : {}),
|
|
4656
|
+
...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
|
|
4657
|
+
...(field.choiceType === undefined && schemaField.choiceType ? { choiceType: schemaField.choiceType } : {}),
|
|
4658
|
+
...(schemaField.choiceType === 'select' && schemaField.optionDetails?.length ? { exact: true } : {}),
|
|
3802
4659
|
});
|
|
3803
4660
|
}
|
|
3804
|
-
else if (field.kind === 'toggle' &&
|
|
3805
|
-
const schemaField = fieldById.get(field.fieldId);
|
|
4661
|
+
else if (field.kind === 'toggle' && schemaField?.kind === 'toggle') {
|
|
3806
4662
|
resolved.push({
|
|
3807
4663
|
...field,
|
|
3808
|
-
...(schemaField
|
|
4664
|
+
...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
|
|
4665
|
+
...(field.controlType === undefined && schemaField.controlType ? { controlType: schemaField.controlType } : {}),
|
|
3809
4666
|
});
|
|
3810
4667
|
}
|
|
3811
4668
|
else {
|
|
3812
|
-
resolved.push(
|
|
4669
|
+
resolved.push({
|
|
4670
|
+
...field,
|
|
4671
|
+
...(schemaField?.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
|
|
4672
|
+
});
|
|
3813
4673
|
}
|
|
3814
4674
|
continue;
|
|
3815
4675
|
}
|
|
@@ -3825,21 +4685,38 @@ function resolveFillFieldInputs(session, fields) {
|
|
|
3825
4685
|
if (!schemaField) {
|
|
3826
4686
|
return { ok: false, error: `Unknown form field id ${field.fieldId}. Refresh geometra_form_schema and try again.` };
|
|
3827
4687
|
}
|
|
4688
|
+
if (unkeyedSchemaFieldIsAmbiguous(schemaField, schemas)) {
|
|
4689
|
+
return { ok: false, error: ambiguousFieldIdentityError(schemaField) };
|
|
4690
|
+
}
|
|
3828
4691
|
if (field.kind === 'text') {
|
|
3829
4692
|
if (schemaField.kind !== 'text') {
|
|
3830
4693
|
return { ok: false, error: `Field id ${field.fieldId} resolves to kind "${schemaField.kind}", not text.` };
|
|
3831
4694
|
}
|
|
3832
|
-
resolved.push({
|
|
4695
|
+
resolved.push({
|
|
4696
|
+
...field,
|
|
4697
|
+
...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
|
|
4698
|
+
fieldLabel: schemaField.label,
|
|
4699
|
+
});
|
|
3833
4700
|
continue;
|
|
3834
4701
|
}
|
|
3835
4702
|
if (field.kind === 'choice') {
|
|
3836
4703
|
if (schemaField.kind !== 'choice') {
|
|
3837
4704
|
return { ok: false, error: `Field id ${field.fieldId} resolves to kind "${schemaField.kind}", not choice.` };
|
|
3838
4705
|
}
|
|
4706
|
+
const resolvedChoice = schemaField.choiceType === 'select'
|
|
4707
|
+
? resolveNativeChoiceValue(schemaField, field.value)
|
|
4708
|
+
: { ok: true, value: field.value };
|
|
4709
|
+
if (!resolvedChoice.ok)
|
|
4710
|
+
return { ok: false, error: resolvedChoice.error };
|
|
3839
4711
|
resolved.push({
|
|
3840
4712
|
...field,
|
|
4713
|
+
...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
|
|
3841
4714
|
fieldLabel: schemaField.label,
|
|
4715
|
+
value: resolvedChoice.value,
|
|
4716
|
+
...(resolvedChoice.optionIndex !== undefined ? { optionIndex: resolvedChoice.optionIndex } : {}),
|
|
4717
|
+
...(resolvedChoice.expectedReadback !== undefined ? { expectedReadback: resolvedChoice.expectedReadback } : {}),
|
|
3842
4718
|
...(field.choiceType === undefined && schemaField.choiceType ? { choiceType: schemaField.choiceType } : {}),
|
|
4719
|
+
...(schemaField.choiceType === 'select' && schemaField.optionDetails?.length ? { exact: true } : {}),
|
|
3843
4720
|
});
|
|
3844
4721
|
continue;
|
|
3845
4722
|
}
|
|
@@ -3849,19 +4726,26 @@ function resolveFillFieldInputs(session, fields) {
|
|
|
3849
4726
|
}
|
|
3850
4727
|
resolved.push({
|
|
3851
4728
|
...field,
|
|
4729
|
+
...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
|
|
3852
4730
|
label: schemaField.label,
|
|
3853
4731
|
...(field.controlType === undefined && schemaField.controlType ? { controlType: schemaField.controlType } : {}),
|
|
3854
4732
|
});
|
|
3855
4733
|
continue;
|
|
3856
4734
|
}
|
|
3857
|
-
|
|
3858
|
-
ok: false,
|
|
3859
|
-
|
|
3860
|
-
|
|
4735
|
+
if (schemaField.kind !== 'file') {
|
|
4736
|
+
return { ok: false, error: `Field id ${field.fieldId} resolves to kind "${schemaField.kind}", not file.` };
|
|
4737
|
+
}
|
|
4738
|
+
resolved.push({
|
|
4739
|
+
...field,
|
|
4740
|
+
...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
|
|
4741
|
+
fieldLabel: schemaField.label,
|
|
4742
|
+
});
|
|
3861
4743
|
}
|
|
3862
4744
|
return { ok: true, fields: resolved };
|
|
3863
4745
|
}
|
|
3864
4746
|
function canFallbackToSequentialFill(error) {
|
|
4747
|
+
if (ambiguousOutcomeDetails(error))
|
|
4748
|
+
return false;
|
|
3865
4749
|
const message = error instanceof Error ? error.message : String(error);
|
|
3866
4750
|
try {
|
|
3867
4751
|
const parsed = JSON.parse(message);
|
|
@@ -3906,25 +4790,25 @@ function parseProxyFillAckResult(value) {
|
|
|
3906
4790
|
...(invalidFields && invalidFields.length > 0 ? { invalidFields } : {}),
|
|
3907
4791
|
};
|
|
3908
4792
|
}
|
|
3909
|
-
function directLabelBatchFields(valuesByLabel) {
|
|
3910
|
-
const entries = Object.entries(valuesByLabel ?? {});
|
|
3911
|
-
if (entries.length === 0)
|
|
3912
|
-
return null;
|
|
3913
|
-
const fields = [];
|
|
3914
|
-
for (const [fieldLabel, value] of entries) {
|
|
3915
|
-
if (typeof value !== 'string' && typeof value !== 'boolean')
|
|
3916
|
-
return null;
|
|
3917
|
-
fields.push({ kind: 'auto', fieldLabel, value });
|
|
3918
|
-
}
|
|
3919
|
-
return fields;
|
|
3920
|
-
}
|
|
3921
4793
|
async function tryBatchedResolvedFields(session, fields, detail) {
|
|
3922
4794
|
let batchAckResult;
|
|
3923
4795
|
try {
|
|
3924
4796
|
const startRevision = session.updateRevision;
|
|
3925
|
-
const wait = await sendFillFields(session, fields);
|
|
4797
|
+
const wait = await sendFillFields(session, toProxyFillFields(fields));
|
|
4798
|
+
assertActionWaitConfirmed('Batched field fill', wait);
|
|
3926
4799
|
const ackResult = parseProxyFillAckResult(wait.result);
|
|
3927
4800
|
batchAckResult = ackResult;
|
|
4801
|
+
if (fields.some(field => field.kind === 'file')) {
|
|
4802
|
+
if (!ackResult) {
|
|
4803
|
+
throw new AmbiguousActionOutcomeError('Batched file fill', wait, 'Batched file fill was acknowledged without structured proxy confirmation; the upload may already have happened. Do not retry blindly.');
|
|
4804
|
+
}
|
|
4805
|
+
return {
|
|
4806
|
+
ok: true,
|
|
4807
|
+
finalSource: 'proxy',
|
|
4808
|
+
final: ackResult,
|
|
4809
|
+
invalidRemaining: ackResult.invalidCount,
|
|
4810
|
+
};
|
|
4811
|
+
}
|
|
3928
4812
|
if (ackResult && ackResult.invalidCount === 0) {
|
|
3929
4813
|
return {
|
|
3930
4814
|
ok: true,
|
|
@@ -3934,27 +4818,31 @@ async function tryBatchedResolvedFields(session, fields, detail) {
|
|
|
3934
4818
|
};
|
|
3935
4819
|
}
|
|
3936
4820
|
await waitForDeferredBatchUpdate(session, startRevision, wait);
|
|
3937
|
-
await waitForBatchFieldReadback(session, fields);
|
|
4821
|
+
const readbackSnapshot = await waitForBatchFieldReadback(session, fields);
|
|
4822
|
+
if (!readbackSnapshot) {
|
|
4823
|
+
if (!sessionA11y(session)) {
|
|
4824
|
+
throw new AmbiguousActionOutcomeError('Batched field fill', wait, 'Batched field fill was sent, but its read-back evidence became unavailable before confirmation. The fields may already have changed. Do not retry blindly.');
|
|
4825
|
+
}
|
|
4826
|
+
return { ok: false };
|
|
4827
|
+
}
|
|
4828
|
+
const signals = collectSessionSignals(readbackSnapshot);
|
|
4829
|
+
const invalidRemaining = signals.invalidFields.length;
|
|
4830
|
+
if ((!batchAckResult || batchAckResult.invalidCount > 0) && invalidRemaining > 0) {
|
|
4831
|
+
return { ok: false };
|
|
4832
|
+
}
|
|
4833
|
+
return {
|
|
4834
|
+
ok: true,
|
|
4835
|
+
finalSource: 'session',
|
|
4836
|
+
final: sessionSignalsPayload(signals, detail),
|
|
4837
|
+
invalidRemaining,
|
|
4838
|
+
readbackSnapshot,
|
|
4839
|
+
};
|
|
3938
4840
|
}
|
|
3939
4841
|
catch (e) {
|
|
3940
4842
|
if (canFallbackToSequentialFill(e))
|
|
3941
4843
|
return { ok: false };
|
|
3942
4844
|
throw e;
|
|
3943
4845
|
}
|
|
3944
|
-
const after = sessionA11y(session);
|
|
3945
|
-
if (!after)
|
|
3946
|
-
return { ok: false };
|
|
3947
|
-
const signals = collectSessionSignals(after);
|
|
3948
|
-
const invalidRemaining = signals.invalidFields.length;
|
|
3949
|
-
if ((!batchAckResult || batchAckResult.invalidCount > 0) && invalidRemaining > 0) {
|
|
3950
|
-
return { ok: false };
|
|
3951
|
-
}
|
|
3952
|
-
return {
|
|
3953
|
-
ok: true,
|
|
3954
|
-
finalSource: 'session',
|
|
3955
|
-
final: sessionSignalsPayload(signals, detail),
|
|
3956
|
-
invalidRemaining,
|
|
3957
|
-
};
|
|
3958
4846
|
}
|
|
3959
4847
|
async function waitForDeferredBatchUpdate(session, startRevision, wait) {
|
|
3960
4848
|
if (wait.status !== 'acknowledged' || session.updateRevision > startRevision)
|
|
@@ -3962,37 +4850,48 @@ async function waitForDeferredBatchUpdate(session, startRevision, wait) {
|
|
|
3962
4850
|
await waitForUiCondition(session, () => session.updateRevision > startRevision, 750);
|
|
3963
4851
|
}
|
|
3964
4852
|
async function waitForBatchFieldReadback(session, fields) {
|
|
4853
|
+
let matchingSnapshot;
|
|
3965
4854
|
await waitForUiCondition(session, () => {
|
|
3966
4855
|
const a11y = sessionA11y(session);
|
|
3967
4856
|
if (!a11y)
|
|
3968
4857
|
return false;
|
|
3969
|
-
|
|
4858
|
+
if (!fields.every(field => batchFieldReadbackMatches(a11y, field)))
|
|
4859
|
+
return false;
|
|
4860
|
+
matchingSnapshot = a11y;
|
|
4861
|
+
return true;
|
|
3970
4862
|
}, 1500);
|
|
4863
|
+
return matchingSnapshot;
|
|
3971
4864
|
}
|
|
3972
4865
|
function batchFieldReadbackMatches(a11y, field) {
|
|
3973
4866
|
switch (field.kind) {
|
|
3974
4867
|
case 'text': {
|
|
3975
|
-
const matches =
|
|
4868
|
+
const matches = fieldReadbackNodes(a11y, field.fieldLabel, ['textbox'], field.fieldKey);
|
|
3976
4869
|
return matches.some(match => normalizeLookupKey(match.value ?? '') === normalizeLookupKey(field.value));
|
|
3977
4870
|
}
|
|
3978
4871
|
case 'choice': {
|
|
3979
|
-
const directMatches = [
|
|
3980
|
-
...findNodes(a11y, { name: field.fieldLabel, role: 'combobox' }),
|
|
3981
|
-
...findNodes(a11y, { name: field.fieldLabel, role: 'textbox' }),
|
|
3982
|
-
...findNodes(a11y, { name: field.fieldLabel, role: 'button' }),
|
|
3983
|
-
];
|
|
4872
|
+
const directMatches = fieldReadbackNodes(a11y, field.fieldLabel, ['combobox', 'textbox', 'button'], field.fieldKey);
|
|
3984
4873
|
if (directMatches.length === 0)
|
|
3985
|
-
return
|
|
3986
|
-
|
|
4874
|
+
return field.fieldKey === undefined;
|
|
4875
|
+
if (field.optionIndex !== undefined) {
|
|
4876
|
+
return directMatches.some(match => {
|
|
4877
|
+
const selected = match.meta?.options?.find(option => option.selected);
|
|
4878
|
+
return selected
|
|
4879
|
+
? selected.index === field.optionIndex && selected.value === field.value
|
|
4880
|
+
: false;
|
|
4881
|
+
});
|
|
4882
|
+
}
|
|
4883
|
+
const expected = [field.value, field.expectedReadback]
|
|
4884
|
+
.filter((value) => value !== undefined)
|
|
4885
|
+
.map(normalizeLookupKey);
|
|
4886
|
+
return directMatches.some(match => expected.includes(normalizeLookupKey(match.value ?? '')));
|
|
3987
4887
|
}
|
|
3988
4888
|
case 'toggle':
|
|
3989
4889
|
return true;
|
|
3990
4890
|
case 'file': {
|
|
3991
|
-
const matches = [
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
return matches.length === 0 || matches.some(match => Boolean(match.value && match.value.trim()));
|
|
4891
|
+
const matches = fieldReadbackNodes(a11y, field.fieldLabel, ['textbox', 'button'], field.fieldKey);
|
|
4892
|
+
return field.fieldKey
|
|
4893
|
+
? matches.some(match => Boolean(match.value && match.value.trim()))
|
|
4894
|
+
: matches.length === 0 || matches.some(match => Boolean(match.value && match.value.trim()));
|
|
3996
4895
|
}
|
|
3997
4896
|
}
|
|
3998
4897
|
}
|
|
@@ -4013,6 +4912,55 @@ function canDeferInitialFrameForRunActions(actions) {
|
|
|
4013
4912
|
return false;
|
|
4014
4913
|
return first.type === 'fill_fields';
|
|
4015
4914
|
}
|
|
4915
|
+
function assertBatchActionConfirmed(actionType, wait) {
|
|
4916
|
+
assertActionWaitConfirmed(`${actionType} action`, wait);
|
|
4917
|
+
}
|
|
4918
|
+
class AmbiguousActionOutcomeError extends Error {
|
|
4919
|
+
wait;
|
|
4920
|
+
constructor(actionName, wait, message) {
|
|
4921
|
+
super(message ?? `${actionName} timed out after ${wait.timeoutMs}ms; its outcome is unconfirmed. ${ambiguousActionGuidance(wait)}`);
|
|
4922
|
+
this.name = 'AmbiguousActionOutcomeError';
|
|
4923
|
+
this.wait = wait;
|
|
4924
|
+
}
|
|
4925
|
+
}
|
|
4926
|
+
function ambiguousOutcomeDetails(error) {
|
|
4927
|
+
if (error instanceof AmbiguousActionOutcomeError) {
|
|
4928
|
+
return {
|
|
4929
|
+
wait: error.wait,
|
|
4930
|
+
requestId: error.wait.requestId,
|
|
4931
|
+
actionId: error.wait.actionId,
|
|
4932
|
+
};
|
|
4933
|
+
}
|
|
4934
|
+
if (!error || typeof error !== 'object')
|
|
4935
|
+
return undefined;
|
|
4936
|
+
const candidate = error;
|
|
4937
|
+
const code = typeof candidate.code === 'string' ? candidate.code : undefined;
|
|
4938
|
+
const requestId = typeof candidate.requestId === 'string' ? candidate.requestId : undefined;
|
|
4939
|
+
const actionId = typeof candidate.actionId === 'string' ? candidate.actionId : undefined;
|
|
4940
|
+
const message = typeof candidate.message === 'string' ? candidate.message : '';
|
|
4941
|
+
if (code !== 'ACTION_OUTCOME_AMBIGUOUS' &&
|
|
4942
|
+
!(requestId && actionId && message.includes('Outcome is ambiguous')))
|
|
4943
|
+
return undefined;
|
|
4944
|
+
return { ...(requestId ? { requestId } : {}), ...(actionId ? { actionId } : {}), ...(code ? { code } : {}) };
|
|
4945
|
+
}
|
|
4946
|
+
function ambiguousOutcomePayload(details) {
|
|
4947
|
+
return {
|
|
4948
|
+
outcome: 'unconfirmed',
|
|
4949
|
+
retrySafety: 'inspect-first',
|
|
4950
|
+
...(details.wait ? waitStatusPayload(details.wait) : {}),
|
|
4951
|
+
...(!details.wait && details.requestId ? { requestId: details.requestId } : {}),
|
|
4952
|
+
...(!details.wait && details.actionId ? { actionId: details.actionId } : {}),
|
|
4953
|
+
...(details.code ? { code: details.code } : {}),
|
|
4954
|
+
guidance: details.actionId && details.requestId
|
|
4955
|
+
? `Outcome is ambiguous for actionId ${details.actionId} (requestId ${details.requestId}). Do not retry blindly or skip this step; inspect the current UI first.`
|
|
4956
|
+
: 'The action outcome is ambiguous. Do not retry blindly or skip this step; inspect the current UI first.',
|
|
4957
|
+
};
|
|
4958
|
+
}
|
|
4959
|
+
function assertActionWaitConfirmed(actionName, wait) {
|
|
4960
|
+
if (wait.status === 'timed_out') {
|
|
4961
|
+
throw new AmbiguousActionOutcomeError(actionName, wait);
|
|
4962
|
+
}
|
|
4963
|
+
}
|
|
4016
4964
|
async function executeBatchAction(session, action, detail, includeSteps) {
|
|
4017
4965
|
switch (action.type) {
|
|
4018
4966
|
case 'click': {
|
|
@@ -4046,7 +4994,31 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
4046
4994
|
});
|
|
4047
4995
|
if (!resolved.ok)
|
|
4048
4996
|
throw new Error(clickFallbackErrorMessage(resolved));
|
|
4997
|
+
const waitFilter = action.waitFor ? {
|
|
4998
|
+
id: action.waitFor.id,
|
|
4999
|
+
role: action.waitFor.role,
|
|
5000
|
+
name: action.waitFor.name,
|
|
5001
|
+
text: action.waitFor.text,
|
|
5002
|
+
contextText: action.waitFor.contextText,
|
|
5003
|
+
promptText: action.waitFor.promptText,
|
|
5004
|
+
sectionText: action.waitFor.sectionText,
|
|
5005
|
+
itemText: action.waitFor.itemText,
|
|
5006
|
+
value: action.waitFor.value,
|
|
5007
|
+
checked: action.waitFor.checked,
|
|
5008
|
+
disabled: action.waitFor.disabled,
|
|
5009
|
+
focused: action.waitFor.focused,
|
|
5010
|
+
selected: action.waitFor.selected,
|
|
5011
|
+
expanded: action.waitFor.expanded,
|
|
5012
|
+
invalid: action.waitFor.invalid,
|
|
5013
|
+
required: action.waitFor.required,
|
|
5014
|
+
busy: action.waitFor.busy,
|
|
5015
|
+
} : undefined;
|
|
5016
|
+
const waitEvidenceRoot = sessionA11y(session) ?? before;
|
|
5017
|
+
const beforeWaitEvidence = waitFilter && waitEvidenceRoot
|
|
5018
|
+
? captureWaitEvidence(waitEvidenceRoot, waitFilter)
|
|
5019
|
+
: undefined;
|
|
4049
5020
|
const wait = await sendClick(session, resolved.value.x, resolved.value.y, action.timeoutMs);
|
|
5021
|
+
assertBatchActionConfirmed(action.type, wait);
|
|
4050
5022
|
const targetSummary = resolved.value.target
|
|
4051
5023
|
? `Clicked ${describeFormattedNode(resolved.value.target)} at (${resolved.value.x}, ${resolved.value.y}).`
|
|
4052
5024
|
: `Clicked at (${resolved.value.x}, ${resolved.value.y}).`;
|
|
@@ -4054,33 +5026,19 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
4054
5026
|
let postWaitCompact;
|
|
4055
5027
|
if (action.waitFor) {
|
|
4056
5028
|
const postWait = await waitForSemanticCondition(session, {
|
|
4057
|
-
filter:
|
|
4058
|
-
id: action.waitFor.id,
|
|
4059
|
-
role: action.waitFor.role,
|
|
4060
|
-
name: action.waitFor.name,
|
|
4061
|
-
text: action.waitFor.text,
|
|
4062
|
-
contextText: action.waitFor.contextText,
|
|
4063
|
-
promptText: action.waitFor.promptText,
|
|
4064
|
-
sectionText: action.waitFor.sectionText,
|
|
4065
|
-
itemText: action.waitFor.itemText,
|
|
4066
|
-
value: action.waitFor.value,
|
|
4067
|
-
checked: action.waitFor.checked,
|
|
4068
|
-
disabled: action.waitFor.disabled,
|
|
4069
|
-
focused: action.waitFor.focused,
|
|
4070
|
-
selected: action.waitFor.selected,
|
|
4071
|
-
expanded: action.waitFor.expanded,
|
|
4072
|
-
invalid: action.waitFor.invalid,
|
|
4073
|
-
required: action.waitFor.required,
|
|
4074
|
-
busy: action.waitFor.busy,
|
|
4075
|
-
},
|
|
5029
|
+
filter: waitFilter,
|
|
4076
5030
|
present: action.waitFor.present ?? true,
|
|
4077
5031
|
timeoutMs: action.waitFor.timeoutMs ?? 10_000,
|
|
4078
5032
|
});
|
|
4079
5033
|
if (!postWait.ok) {
|
|
4080
5034
|
throw new Error(`Post-click wait failed after ${targetSummary.toLowerCase()}\n${postWait.error}`);
|
|
4081
5035
|
}
|
|
5036
|
+
const fresh = waitConditionHasFreshEvidence(beforeWaitEvidence, sessionA11y(session), postWait.value);
|
|
5037
|
+
if (!fresh) {
|
|
5038
|
+
throw new Error(`Post-click wait matched only pre-existing evidence after ${targetSummary.toLowerCase()}; the click outcome is unconfirmed.`);
|
|
5039
|
+
}
|
|
4082
5040
|
postWaitSummary = `Post-click ${waitConditionSuccessLine(postWait.value)}`;
|
|
4083
|
-
postWaitCompact = waitConditionCompact(postWait.value);
|
|
5041
|
+
postWaitCompact = { ...waitConditionCompact(postWait.value), fresh: true };
|
|
4084
5042
|
}
|
|
4085
5043
|
return {
|
|
4086
5044
|
summary: [targetSummary, postActionSummary(session, before, wait, detail), postWaitSummary].filter(Boolean).join('\n'),
|
|
@@ -4096,6 +5054,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
4096
5054
|
case 'type': {
|
|
4097
5055
|
const before = sessionA11y(session);
|
|
4098
5056
|
const wait = await sendType(session, action.text, action.timeoutMs);
|
|
5057
|
+
assertBatchActionConfirmed(action.type, wait);
|
|
4099
5058
|
return {
|
|
4100
5059
|
summary: `Typed "${action.text}".\n${postActionSummary(session, before, wait, detail)}`,
|
|
4101
5060
|
compact: {
|
|
@@ -4107,6 +5066,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
4107
5066
|
case 'key': {
|
|
4108
5067
|
const before = sessionA11y(session);
|
|
4109
5068
|
const wait = await sendKey(session, action.key, { shift: action.shift, ctrl: action.ctrl, meta: action.meta, alt: action.alt }, action.timeoutMs);
|
|
5069
|
+
assertBatchActionConfirmed(action.type, wait);
|
|
4110
5070
|
return {
|
|
4111
5071
|
summary: `Pressed ${formatKeyCombo(action.key, action)}.\n${postActionSummary(session, before, wait, detail)}`,
|
|
4112
5072
|
compact: {
|
|
@@ -4116,6 +5076,9 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
4116
5076
|
};
|
|
4117
5077
|
}
|
|
4118
5078
|
case 'upload_files': {
|
|
5079
|
+
const contractError = fileUploadContractError(action);
|
|
5080
|
+
if (contractError)
|
|
5081
|
+
throw new Error(contractError);
|
|
4119
5082
|
const before = sessionA11y(session);
|
|
4120
5083
|
const wait = await sendFileUpload(session, action.paths, {
|
|
4121
5084
|
click: action.x !== undefined && action.y !== undefined ? { x: action.x, y: action.y } : undefined,
|
|
@@ -4123,48 +5086,63 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
4123
5086
|
exact: action.exact,
|
|
4124
5087
|
strategy: action.strategy,
|
|
4125
5088
|
drop: action.dropX !== undefined && action.dropY !== undefined ? { x: action.dropX, y: action.dropY } : undefined,
|
|
5089
|
+
contextText: action.contextText,
|
|
5090
|
+
sectionText: action.sectionText,
|
|
4126
5091
|
}, action.timeoutMs ?? 8_000);
|
|
5092
|
+
assertBatchActionConfirmed(action.type, wait);
|
|
4127
5093
|
return {
|
|
4128
5094
|
summary: `Uploaded ${action.paths.length} file(s).\n${postActionSummary(session, before, wait, detail)}`,
|
|
4129
5095
|
compact: {
|
|
4130
5096
|
fileCount: action.paths.length,
|
|
4131
5097
|
...(action.fieldLabel ? { fieldLabel: action.fieldLabel } : {}),
|
|
4132
5098
|
...(action.strategy ? { strategy: action.strategy } : {}),
|
|
5099
|
+
...(action.contextText ? { contextText: action.contextText } : {}),
|
|
5100
|
+
...(action.sectionText ? { sectionText: action.sectionText } : {}),
|
|
4133
5101
|
...waitStatusPayload(wait),
|
|
4134
5102
|
...(action.fieldLabel ? { readback: fieldStatePayload(session, action.fieldLabel) } : {}),
|
|
4135
5103
|
},
|
|
4136
5104
|
};
|
|
4137
5105
|
}
|
|
4138
5106
|
case 'pick_listbox_option': {
|
|
5107
|
+
const contractError = listboxPickContractError(action);
|
|
5108
|
+
if (contractError)
|
|
5109
|
+
throw new Error(contractError);
|
|
4139
5110
|
const before = sessionA11y(session);
|
|
4140
5111
|
const wait = await sendListboxPick(session, action.label, {
|
|
4141
5112
|
exact: action.exact,
|
|
4142
|
-
|
|
5113
|
+
fieldId: action.fieldId,
|
|
5114
|
+
fieldKey: action.fieldKey,
|
|
4143
5115
|
fieldLabel: action.fieldLabel,
|
|
4144
5116
|
query: action.query,
|
|
4145
5117
|
}, action.timeoutMs);
|
|
5118
|
+
assertBatchActionConfirmed(action.type, wait);
|
|
4146
5119
|
const summary = postActionSummary(session, before, wait, detail);
|
|
4147
|
-
const fieldSummary = action.fieldLabel
|
|
5120
|
+
const fieldSummary = action.fieldLabel
|
|
5121
|
+
? summarizeFieldLabelState(session, action.fieldLabel, action.fieldKey)
|
|
5122
|
+
: undefined;
|
|
4148
5123
|
return {
|
|
4149
5124
|
summary: [`Picked listbox option "${action.label}".`, fieldSummary, summary].filter(Boolean).join('\n'),
|
|
4150
5125
|
compact: {
|
|
4151
5126
|
label: action.label,
|
|
5127
|
+
...(action.fieldId ? { fieldId: action.fieldId } : {}),
|
|
5128
|
+
...(action.fieldKey ? { fieldKey: action.fieldKey } : {}),
|
|
4152
5129
|
...(action.fieldLabel ? { fieldLabel: action.fieldLabel } : {}),
|
|
4153
5130
|
...waitStatusPayload(wait),
|
|
4154
|
-
...(action.fieldLabel ? { readback: fieldStatePayload(session, action.fieldLabel) } : {}),
|
|
5131
|
+
...(action.fieldLabel ? { readback: fieldStatePayload(session, action.fieldLabel, action.fieldKey) } : {}),
|
|
4155
5132
|
},
|
|
4156
5133
|
};
|
|
4157
5134
|
}
|
|
4158
5135
|
case 'select_option': {
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
5136
|
+
const contractError = selectOptionContractError(action);
|
|
5137
|
+
if (contractError)
|
|
5138
|
+
throw new Error(contractError);
|
|
4162
5139
|
const before = sessionA11y(session);
|
|
4163
5140
|
const wait = await sendSelectOption(session, action.x, action.y, {
|
|
4164
5141
|
value: action.value,
|
|
4165
5142
|
label: action.label,
|
|
4166
5143
|
index: action.index,
|
|
4167
5144
|
}, action.timeoutMs);
|
|
5145
|
+
assertBatchActionConfirmed(action.type, wait);
|
|
4168
5146
|
return {
|
|
4169
5147
|
summary: `Selected option.\n${postActionSummary(session, before, wait, detail)}`,
|
|
4170
5148
|
compact: {
|
|
@@ -4182,13 +5160,20 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
4182
5160
|
checked: action.checked,
|
|
4183
5161
|
exact: action.exact,
|
|
4184
5162
|
controlType: action.controlType,
|
|
5163
|
+
...(action.fieldKey ? { fieldKey: action.fieldKey } : {}),
|
|
5164
|
+
contextText: action.contextText,
|
|
5165
|
+
sectionText: action.sectionText,
|
|
4185
5166
|
}, action.timeoutMs);
|
|
5167
|
+
assertBatchActionConfirmed(action.type, wait);
|
|
4186
5168
|
return {
|
|
4187
5169
|
summary: `Set ${action.controlType ?? 'checkbox/radio'} "${action.label}" to ${String(action.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`,
|
|
4188
5170
|
compact: {
|
|
4189
5171
|
label: action.label,
|
|
4190
5172
|
checked: action.checked ?? true,
|
|
4191
5173
|
...(action.controlType ? { controlType: action.controlType } : {}),
|
|
5174
|
+
...(action.fieldKey ? { fieldKey: action.fieldKey } : {}),
|
|
5175
|
+
...(action.contextText ? { contextText: action.contextText } : {}),
|
|
5176
|
+
...(action.sectionText ? { sectionText: action.sectionText } : {}),
|
|
4192
5177
|
...waitStatusPayload(wait),
|
|
4193
5178
|
},
|
|
4194
5179
|
};
|
|
@@ -4200,6 +5185,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
4200
5185
|
x: action.x,
|
|
4201
5186
|
y: action.y,
|
|
4202
5187
|
}, action.timeoutMs);
|
|
5188
|
+
assertBatchActionConfirmed(action.type, wait);
|
|
4203
5189
|
return {
|
|
4204
5190
|
summary: `Wheel delta (${action.deltaX ?? 0}, ${action.deltaY}).\n${postActionSummary(session, before, wait, detail)}`,
|
|
4205
5191
|
compact: {
|
|
@@ -4289,13 +5275,13 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
4289
5275
|
if (!resolvedFields.ok)
|
|
4290
5276
|
throw new Error(resolvedFields.error);
|
|
4291
5277
|
const verifyFillsFn = action.verifyFills
|
|
4292
|
-
? () => verifyFormFills(session, resolvedFields.fields.map(field => ({ field, confidence: 1.0, matchMethod: 'label-exact' })))
|
|
5278
|
+
? (readbackSnapshot) => verifyFormFills(session, resolvedFields.fields.map(field => ({ field, confidence: 1.0, matchMethod: 'label-exact' })), readbackSnapshot)
|
|
4293
5279
|
: undefined;
|
|
4294
5280
|
let fallbackFromBatch;
|
|
4295
5281
|
if (!includeSteps) {
|
|
4296
5282
|
const batched = await tryBatchedResolvedFields(session, resolvedFields.fields, detail);
|
|
4297
5283
|
if (batched.ok) {
|
|
4298
|
-
const verification = verifyFillsFn?.();
|
|
5284
|
+
const verification = verifyFillsFn?.(batched.readbackSnapshot);
|
|
4299
5285
|
return {
|
|
4300
5286
|
summary: `Filled ${resolvedFields.fields.length} field(s) in one proxy batch.`,
|
|
4301
5287
|
compact: {
|
|
@@ -4378,7 +5364,7 @@ function suggestRecovery(field, error) {
|
|
|
4378
5364
|
}
|
|
4379
5365
|
}
|
|
4380
5366
|
if (lowerError.includes('timeout')) {
|
|
4381
|
-
return
|
|
5367
|
+
return 'Action timed out and may still complete. Do not retry blindly; inspect the current UI or wait for a loading indicator before repeating the identical action.';
|
|
4382
5368
|
}
|
|
4383
5369
|
return undefined;
|
|
4384
5370
|
}
|
|
@@ -4458,8 +5444,8 @@ export function valuesEquivalent(expected, actual) {
|
|
|
4458
5444
|
const aNorm = actual.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
4459
5445
|
return eNorm === aNorm;
|
|
4460
5446
|
}
|
|
4461
|
-
function verifyFormFills(session, planned) {
|
|
4462
|
-
const a11y = sessionA11y(session);
|
|
5447
|
+
function verifyFormFills(session, planned, readbackSnapshot) {
|
|
5448
|
+
const a11y = readbackSnapshot ?? sessionA11y(session);
|
|
4463
5449
|
if (!a11y)
|
|
4464
5450
|
return { verified: 0, mismatches: [] };
|
|
4465
5451
|
const mismatches = [];
|
|
@@ -4468,13 +5454,27 @@ function verifyFormFills(session, planned) {
|
|
|
4468
5454
|
if (p.field.kind === 'toggle' || p.field.kind === 'file')
|
|
4469
5455
|
continue;
|
|
4470
5456
|
const label = p.field.fieldLabel;
|
|
4471
|
-
const expected = p.field.kind === '
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
];
|
|
5457
|
+
const expected = p.field.kind === 'choice'
|
|
5458
|
+
? p.field.expectedReadback ?? p.field.value
|
|
5459
|
+
: p.field.value;
|
|
5460
|
+
const matches = fieldReadbackNodes(a11y, label, ['textbox', 'combobox'], p.field.fieldKey);
|
|
4476
5461
|
const match = matches[0];
|
|
4477
5462
|
const actual = match?.value?.trim();
|
|
5463
|
+
if (p.field.kind === 'choice' && p.field.optionIndex !== undefined && match?.meta?.options) {
|
|
5464
|
+
const selected = match.meta.options.find(option => option.selected);
|
|
5465
|
+
if (selected?.index === p.field.optionIndex && selected.value === p.field.value) {
|
|
5466
|
+
verified++;
|
|
5467
|
+
}
|
|
5468
|
+
else {
|
|
5469
|
+
mismatches.push({
|
|
5470
|
+
fieldLabel: label,
|
|
5471
|
+
expected,
|
|
5472
|
+
actual: selected?.label ?? actual,
|
|
5473
|
+
...(p.field.fieldId ? { fieldId: p.field.fieldId } : {}),
|
|
5474
|
+
});
|
|
5475
|
+
}
|
|
5476
|
+
continue;
|
|
5477
|
+
}
|
|
4478
5478
|
if (!actual || !expected) {
|
|
4479
5479
|
mismatches.push({ fieldLabel: label, expected, actual, ...(p.field.fieldId ? { fieldId: p.field.fieldId } : {}) });
|
|
4480
5480
|
}
|
|
@@ -4494,10 +5494,12 @@ async function executeFillField(session, field, detail) {
|
|
|
4494
5494
|
const wait = await sendFieldText(session, field.fieldLabel, field.value, {
|
|
4495
5495
|
exact: field.exact,
|
|
4496
5496
|
fieldId: field.fieldId,
|
|
5497
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
4497
5498
|
typingDelayMs: field.typingDelayMs,
|
|
4498
5499
|
imeFriendly: field.imeFriendly,
|
|
4499
5500
|
}, field.timeoutMs);
|
|
4500
|
-
|
|
5501
|
+
assertActionWaitConfirmed(`Text fill for "${field.fieldLabel}"`, wait);
|
|
5502
|
+
const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel, field.fieldKey);
|
|
4501
5503
|
return {
|
|
4502
5504
|
summary: [
|
|
4503
5505
|
`Filled text field "${field.fieldLabel}".`,
|
|
@@ -4506,17 +5508,26 @@ async function executeFillField(session, field, detail) {
|
|
|
4506
5508
|
].filter(Boolean).join('\n'),
|
|
4507
5509
|
compact: {
|
|
4508
5510
|
...(field.fieldId ? { fieldId: field.fieldId } : {}),
|
|
5511
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
4509
5512
|
fieldLabel: field.fieldLabel,
|
|
4510
5513
|
...compactTextValue(field.value),
|
|
4511
5514
|
...waitStatusPayload(wait),
|
|
4512
|
-
readback: fieldStatePayload(session, field.fieldLabel),
|
|
5515
|
+
readback: fieldStatePayload(session, field.fieldLabel, field.fieldKey),
|
|
4513
5516
|
},
|
|
4514
5517
|
};
|
|
4515
5518
|
}
|
|
4516
5519
|
case 'choice': {
|
|
4517
5520
|
const before = sessionA11y(session);
|
|
4518
|
-
const wait = await sendFieldChoice(session, field.fieldLabel, field.value, {
|
|
4519
|
-
|
|
5521
|
+
const wait = await sendFieldChoice(session, field.fieldLabel, field.value, {
|
|
5522
|
+
exact: field.exact,
|
|
5523
|
+
query: field.query,
|
|
5524
|
+
choiceType: field.choiceType,
|
|
5525
|
+
fieldId: field.fieldId,
|
|
5526
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
5527
|
+
...(field.optionIndex !== undefined ? { optionIndex: field.optionIndex } : {}),
|
|
5528
|
+
}, field.timeoutMs);
|
|
5529
|
+
assertActionWaitConfirmed(`Choice fill for "${field.fieldLabel}"`, wait);
|
|
5530
|
+
const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel, field.fieldKey);
|
|
4520
5531
|
return {
|
|
4521
5532
|
summary: [
|
|
4522
5533
|
`Set choice field "${field.fieldLabel}" to "${field.value}".`,
|
|
@@ -4525,21 +5536,31 @@ async function executeFillField(session, field, detail) {
|
|
|
4525
5536
|
].filter(Boolean).join('\n'),
|
|
4526
5537
|
compact: {
|
|
4527
5538
|
...(field.fieldId ? { fieldId: field.fieldId } : {}),
|
|
5539
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
4528
5540
|
fieldLabel: field.fieldLabel,
|
|
4529
5541
|
value: field.value,
|
|
5542
|
+
...(field.optionIndex !== undefined ? { optionIndex: field.optionIndex } : {}),
|
|
4530
5543
|
...(field.choiceType ? { choiceType: field.choiceType } : {}),
|
|
4531
5544
|
...waitStatusPayload(wait),
|
|
4532
|
-
readback: fieldStatePayload(session, field.fieldLabel),
|
|
5545
|
+
readback: fieldStatePayload(session, field.fieldLabel, field.fieldKey),
|
|
4533
5546
|
},
|
|
4534
5547
|
};
|
|
4535
5548
|
}
|
|
4536
5549
|
case 'toggle': {
|
|
4537
5550
|
const before = sessionA11y(session);
|
|
4538
|
-
const wait = await sendSetChecked(session, field.label, {
|
|
5551
|
+
const wait = await sendSetChecked(session, field.label, {
|
|
5552
|
+
checked: field.checked,
|
|
5553
|
+
exact: field.exact,
|
|
5554
|
+
controlType: field.controlType,
|
|
5555
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
5556
|
+
}, field.timeoutMs);
|
|
5557
|
+
assertActionWaitConfirmed(`Toggle fill for "${field.label}"`, wait);
|
|
4539
5558
|
return {
|
|
4540
5559
|
summary: `Set ${field.controlType ?? 'checkbox/radio'} "${field.label}" to ${String(field.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`,
|
|
4541
5560
|
compact: {
|
|
4542
5561
|
label: field.label,
|
|
5562
|
+
...(field.fieldId ? { fieldId: field.fieldId } : {}),
|
|
5563
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
4543
5564
|
checked: field.checked ?? true,
|
|
4544
5565
|
...(field.controlType ? { controlType: field.controlType } : {}),
|
|
4545
5566
|
...waitStatusPayload(wait),
|
|
@@ -4548,8 +5569,14 @@ async function executeFillField(session, field, detail) {
|
|
|
4548
5569
|
}
|
|
4549
5570
|
case 'file': {
|
|
4550
5571
|
const before = sessionA11y(session);
|
|
4551
|
-
const wait = await sendFileUpload(session, field.paths, {
|
|
4552
|
-
|
|
5572
|
+
const wait = await sendFileUpload(session, field.paths, {
|
|
5573
|
+
...(field.fieldId ? { fieldId: field.fieldId } : {}),
|
|
5574
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
5575
|
+
fieldLabel: field.fieldLabel,
|
|
5576
|
+
exact: field.exact,
|
|
5577
|
+
}, field.timeoutMs ?? 8_000);
|
|
5578
|
+
assertActionWaitConfirmed(`File fill for "${field.fieldLabel}"`, wait);
|
|
5579
|
+
const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel, field.fieldKey);
|
|
4553
5580
|
return {
|
|
4554
5581
|
summary: [
|
|
4555
5582
|
`Uploaded ${field.paths.length} file(s) to "${field.fieldLabel}".`,
|
|
@@ -4558,9 +5585,11 @@ async function executeFillField(session, field, detail) {
|
|
|
4558
5585
|
].filter(Boolean).join('\n'),
|
|
4559
5586
|
compact: {
|
|
4560
5587
|
fieldLabel: field.fieldLabel,
|
|
5588
|
+
...(field.fieldId ? { fieldId: field.fieldId } : {}),
|
|
5589
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
4561
5590
|
fileCount: field.paths.length,
|
|
4562
5591
|
...waitStatusPayload(wait),
|
|
4563
|
-
readback: fieldStatePayload(session, field.fieldLabel),
|
|
5592
|
+
readback: fieldStatePayload(session, field.fieldLabel, field.fieldKey),
|
|
4564
5593
|
},
|
|
4565
5594
|
};
|
|
4566
5595
|
}
|
|
@@ -4579,27 +5608,29 @@ function recordWorkflowFill(session, formId, formName, valuesById, valuesByLabel
|
|
|
4579
5608
|
if (!session.workflowState) {
|
|
4580
5609
|
session.workflowState = { pages: [], startedAt: Date.now() };
|
|
4581
5610
|
}
|
|
4582
|
-
const
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
if (typeof v === 'string' || typeof v === 'boolean')
|
|
4589
|
-
filledValues[k] = v;
|
|
4590
|
-
}
|
|
5611
|
+
const fieldIdentities = new Set([
|
|
5612
|
+
...Object.keys(valuesById ?? {}),
|
|
5613
|
+
...Object.keys(valuesByLabel ?? {}),
|
|
5614
|
+
]);
|
|
5615
|
+
const filledFields = Array.from(fieldIdentities).sort();
|
|
5616
|
+
const filledValues = Object.fromEntries(filledFields.map(identity => [identity, '[REDACTED]']));
|
|
4591
5617
|
const a11y = sessionA11y(session);
|
|
4592
|
-
const pageUrl = a11y?.meta?.pageUrl ?? session.url;
|
|
5618
|
+
const pageUrl = workflowUrlOrigin(a11y?.meta?.pageUrl ?? session.url);
|
|
4593
5619
|
session.workflowState.pages.push({
|
|
4594
5620
|
pageUrl,
|
|
4595
5621
|
formId,
|
|
4596
5622
|
formName,
|
|
4597
5623
|
filledValues,
|
|
5624
|
+
filledFields,
|
|
5625
|
+
valuesRedacted: true,
|
|
4598
5626
|
filledAt: Date.now(),
|
|
4599
5627
|
fieldCount,
|
|
4600
5628
|
invalidCount,
|
|
4601
5629
|
});
|
|
4602
5630
|
}
|
|
5631
|
+
function workflowUrlOrigin(rawUrl) {
|
|
5632
|
+
return sanitizeUrlToOrigin(rawUrl) ?? REDACTED_STATE_URL;
|
|
5633
|
+
}
|
|
4603
5634
|
async function captureScreenshotBase64(session) {
|
|
4604
5635
|
try {
|
|
4605
5636
|
const wait = await sendScreenshot(session);
|
|
@@ -4726,8 +5757,8 @@ export function findNodes(node, filter) {
|
|
|
4726
5757
|
walk(node);
|
|
4727
5758
|
return matches;
|
|
4728
5759
|
}
|
|
4729
|
-
function summarizeFieldLabelState(session, fieldLabel) {
|
|
4730
|
-
const payload = fieldStatePayload(session, fieldLabel);
|
|
5760
|
+
function summarizeFieldLabelState(session, fieldLabel, fieldKey) {
|
|
5761
|
+
const payload = fieldStatePayload(session, fieldLabel, fieldKey);
|
|
4731
5762
|
if (!payload)
|
|
4732
5763
|
return undefined;
|
|
4733
5764
|
const parts = [`Field "${fieldLabel}"`];
|