@geometra/mcp 1.63.0 → 1.64.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/dist/server.js CHANGED
@@ -3,7 +3,7 @@ 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 { connect, connectThroughProxy, 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, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, parseSectionId, findNodeByPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
6
+ import { connect, connectThroughProxy, 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
7
  function checkedStateInput() {
8
8
  return z
9
9
  .union([z.boolean(), z.literal('mixed')])
@@ -282,11 +282,14 @@ function capBatchActionTimeouts(action, capMs) {
282
282
  return action;
283
283
  }
284
284
  }
285
+ const nonEmptyFieldIdSchema = z.string().trim().min(1, 'fieldId must not be empty');
286
+ const nonEmptyFieldLabelSchema = z.string().trim().min(1, 'fieldLabel must not be empty');
287
+ const nonEmptyToggleLabelSchema = z.string().trim().min(1, 'toggle label must not be empty');
285
288
  const fillFieldSchema = z.union([
286
289
  z.object({
287
290
  kind: z.literal('text'),
288
- fieldId: z.string().optional().describe('Optional stable field id from geometra_form_schema'),
289
- fieldLabel: z.string().describe('Visible field label / accessible name. Optional to duplicate when fieldId is present.'),
291
+ fieldId: nonEmptyFieldIdSchema.optional().describe('Optional stable field id from geometra_form_schema'),
292
+ fieldLabel: nonEmptyFieldLabelSchema.describe('Visible field label / accessible name. Optional to duplicate when fieldId is present.'),
290
293
  value: z.string().describe('Text value to set'),
291
294
  exact: z.boolean().optional().describe('Exact label match'),
292
295
  typingDelayMs: z
@@ -301,11 +304,11 @@ const fillFieldSchema = z.union([
301
304
  .optional()
302
305
  .describe('Use composition-friendly events for IME-heavy controlled fields.'),
303
306
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
304
- }),
307
+ }).strict(),
305
308
  z.object({
306
309
  kind: z.literal('text'),
307
- fieldId: z.string().describe('Stable field id from geometra_form_schema'),
308
- fieldLabel: z.string().optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
310
+ fieldId: nonEmptyFieldIdSchema.describe('Stable field id from geometra_form_schema'),
311
+ fieldLabel: nonEmptyFieldLabelSchema.optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
309
312
  value: z.string().describe('Text value to set'),
310
313
  exact: z.boolean().optional().describe('Exact label match'),
311
314
  typingDelayMs: z
@@ -320,12 +323,13 @@ const fillFieldSchema = z.union([
320
323
  .optional()
321
324
  .describe('Use composition-friendly events for IME-heavy controlled fields.'),
322
325
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
323
- }),
326
+ }).strict(),
324
327
  z.object({
325
328
  kind: z.literal('choice'),
326
- fieldId: z.string().optional().describe('Optional stable field id from geometra_form_schema'),
327
- fieldLabel: z.string().describe('Visible field label / accessible name. Optional to duplicate when fieldId is present.'),
329
+ fieldId: nonEmptyFieldIdSchema.optional().describe('Optional stable field id from geometra_form_schema'),
330
+ fieldLabel: nonEmptyFieldLabelSchema.describe('Visible field label / accessible name. Optional to duplicate when fieldId is present.'),
328
331
  value: z.string().describe('Desired option value / answer label'),
332
+ optionIndex: z.number().int().min(0).optional().describe('Exact native <select> option index from optionDetails'),
329
333
  query: z.string().optional().describe('Optional search text for searchable comboboxes'),
330
334
  choiceType: z
331
335
  .enum(['select', 'group', 'listbox'])
@@ -333,12 +337,13 @@ const fillFieldSchema = z.union([
333
337
  .describe('Optional choice subtype hint. Use `group` for repeated radio/button answers, `select` for native selects, and `listbox` for searchable dropdowns.'),
334
338
  exact: z.boolean().optional().describe('Exact label match'),
335
339
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
336
- }),
340
+ }).strict(),
337
341
  z.object({
338
342
  kind: z.literal('choice'),
339
- fieldId: z.string().describe('Stable field id from geometra_form_schema'),
340
- fieldLabel: z.string().optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
343
+ fieldId: nonEmptyFieldIdSchema.describe('Stable field id from geometra_form_schema'),
344
+ fieldLabel: nonEmptyFieldLabelSchema.optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
341
345
  value: z.string().describe('Desired option value / answer label'),
346
+ optionIndex: z.number().int().min(0).optional().describe('Exact native <select> option index from optionDetails'),
342
347
  query: z.string().optional().describe('Optional search text for searchable comboboxes'),
343
348
  choiceType: z
344
349
  .enum(['select', 'group', 'listbox'])
@@ -346,42 +351,51 @@ const fillFieldSchema = z.union([
346
351
  .describe('Optional choice subtype hint. Use `group` for repeated radio/button answers, `select` for native selects, and `listbox` for searchable dropdowns.'),
347
352
  exact: z.boolean().optional().describe('Exact label match'),
348
353
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
349
- }),
354
+ }).strict(),
350
355
  z.object({
351
356
  kind: z.literal('toggle'),
352
- fieldId: z.string().optional().describe('Optional stable field id from geometra_form_schema'),
353
- label: z.string().describe('Visible checkbox/radio label to set. Optional to duplicate when fieldId is present.'),
357
+ fieldId: nonEmptyFieldIdSchema.optional().describe('Optional stable field id from geometra_form_schema'),
358
+ label: nonEmptyToggleLabelSchema.describe('Visible checkbox/radio label to set. Optional to duplicate when fieldId is present.'),
354
359
  checked: z.boolean().optional().default(true).describe('Desired checked state (default true)'),
355
360
  exact: z.boolean().optional().describe('Exact label match'),
356
361
  controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
357
362
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
358
- }),
363
+ }).strict(),
359
364
  z.object({
360
365
  kind: z.literal('toggle'),
361
- fieldId: z.string().describe('Stable field id from geometra_form_schema'),
362
- label: z.string().optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
366
+ fieldId: nonEmptyFieldIdSchema.describe('Stable field id from geometra_form_schema'),
367
+ label: nonEmptyToggleLabelSchema.optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
363
368
  checked: z.boolean().optional().default(true).describe('Desired checked state (default true)'),
364
369
  exact: z.boolean().optional().describe('Exact label match'),
365
370
  controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
366
371
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
367
- }),
372
+ }).strict(),
368
373
  z.object({
369
374
  kind: z.literal('file'),
370
- fieldId: z.string().optional().describe('Optional stable field id from geometra_form_schema'),
371
- fieldLabel: z.string().describe('Visible file-field label / accessible name'),
375
+ fieldId: nonEmptyFieldIdSchema.optional().describe('Optional stable field id from geometra_form_schema'),
376
+ fieldLabel: nonEmptyFieldLabelSchema.describe('Visible file-field label / accessible name'),
372
377
  paths: z.array(z.string()).min(1).describe('Absolute paths on the proxy machine'),
373
378
  exact: z.boolean().optional().describe('Exact label match'),
374
379
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
375
- }),
380
+ }).strict(),
376
381
  z.object({
377
382
  kind: z.literal('file'),
378
- fieldId: z.string().describe('Stable field id from geometra_form_schema'),
379
- fieldLabel: z.string().optional().describe('Optional when fieldId is present; MCP resolves the current label when file fields are exposed by geometra_form_schema'),
383
+ fieldId: nonEmptyFieldIdSchema.describe('Stable field id from geometra_form_schema'),
384
+ fieldLabel: nonEmptyFieldLabelSchema.optional().describe('Optional when fieldId is present; MCP resolves the current label when file fields are exposed by geometra_form_schema'),
380
385
  paths: z.array(z.string()).min(1).describe('Absolute paths on the proxy machine'),
381
386
  exact: z.boolean().optional().describe('Exact label match'),
382
387
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
383
- }),
388
+ }).strict(),
384
389
  ]);
390
+ function toProxyFillFields(fields) {
391
+ return fields.map(field => {
392
+ const proxyField = { ...field };
393
+ delete proxyField.timeoutMs;
394
+ if (proxyField.kind === 'choice')
395
+ delete proxyField.expectedReadback;
396
+ return proxyField;
397
+ });
398
+ }
385
399
  const formValueSchema = z.union([
386
400
  z.string(),
387
401
  z.boolean(),
@@ -398,14 +412,14 @@ const batchActionSchema = z.discriminatedUnion('type', [
398
412
  fullyVisible: z.boolean().optional().describe('When clicking by semantic target, require full visibility before clicking (default true)'),
399
413
  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
414
  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'),
415
+ waitFor: z.object(waitConditionShape()).strict().optional().describe('Optional semantic condition to wait for after the click'),
402
416
  timeoutMs: timeoutMsInput,
403
- }),
417
+ }).strict(),
404
418
  z.object({
405
419
  type: z.literal('type'),
406
420
  text: z.string(),
407
421
  timeoutMs: timeoutMsInput,
408
- }),
422
+ }).strict(),
409
423
  z.object({
410
424
  type: z.literal('key'),
411
425
  key: z.string(),
@@ -414,7 +428,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
414
428
  meta: z.boolean().optional(),
415
429
  alt: z.boolean().optional(),
416
430
  timeoutMs: timeoutMsInput,
417
- }),
431
+ }).strict(),
418
432
  z.object({
419
433
  type: z.literal('upload_files'),
420
434
  paths: z.array(z.string()).min(1),
@@ -426,17 +440,19 @@ const batchActionSchema = z.discriminatedUnion('type', [
426
440
  dropX: z.number().optional(),
427
441
  dropY: z.number().optional(),
428
442
  timeoutMs: timeoutMsInput,
429
- }),
443
+ }).strict(),
430
444
  z.object({
431
445
  type: z.literal('pick_listbox_option'),
432
446
  label: z.string(),
433
447
  exact: z.boolean().optional(),
434
448
  openX: z.number().optional(),
435
449
  openY: z.number().optional(),
450
+ fieldId: z.string().trim().min(1).optional(),
451
+ fieldKey: z.string().trim().min(1).optional(),
436
452
  fieldLabel: z.string().optional(),
437
453
  query: z.string().optional(),
438
454
  timeoutMs: timeoutMsInput,
439
- }),
455
+ }).strict(),
440
456
  z.object({
441
457
  type: z.literal('select_option'),
442
458
  x: z.number(),
@@ -445,15 +461,18 @@ const batchActionSchema = z.discriminatedUnion('type', [
445
461
  label: z.string().optional(),
446
462
  index: z.number().int().min(0).optional(),
447
463
  timeoutMs: timeoutMsInput,
448
- }),
464
+ }).strict(),
449
465
  z.object({
450
466
  type: z.literal('set_checked'),
451
- label: z.string(),
467
+ label: z.string().trim().min(1, 'set_checked label must not be empty'),
452
468
  checked: z.boolean().optional(),
453
469
  exact: z.boolean().optional(),
454
470
  controlType: z.enum(['checkbox', 'radio']).optional(),
471
+ fieldKey: z.string().trim().min(1).optional(),
472
+ contextText: z.string().trim().min(1).optional(),
473
+ sectionText: z.string().trim().min(1).optional(),
455
474
  timeoutMs: timeoutMsInput,
456
- }),
475
+ }).strict(),
457
476
  z.object({
458
477
  type: z.literal('wheel'),
459
478
  deltaY: z.number(),
@@ -461,13 +480,13 @@ const batchActionSchema = z.discriminatedUnion('type', [
461
480
  x: z.number().optional(),
462
481
  y: z.number().optional(),
463
482
  timeoutMs: timeoutMsInput,
464
- }),
483
+ }).strict(),
465
484
  z.object({
466
485
  type: z.literal('wait_for'),
467
486
  ...nodeFilterShape(),
468
487
  present: z.boolean().optional(),
469
488
  timeoutMs: timeoutMsInput,
470
- }),
489
+ }).strict(),
471
490
  z.object({
472
491
  type: z.literal('fill_fields'),
473
492
  fields: z.array(fillFieldSchema).min(1).max(80),
@@ -475,7 +494,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
475
494
  .boolean()
476
495
  .optional()
477
496
  .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
- }),
497
+ }).strict(),
479
498
  z.object({
480
499
  type: z.literal('expand_section'),
481
500
  id: z.string().describe('Stable section id from geometra_page_model (e.g. fm:1.0, ls:2.1).'),
@@ -492,7 +511,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
492
511
  itemOffset: z.number().int().min(0).optional(),
493
512
  maxTextPreview: z.number().int().min(0).max(20).optional(),
494
513
  includeBounds: z.boolean().optional(),
495
- }),
514
+ }).strict(),
496
515
  ]);
497
516
  export function createServer() {
498
517
  const server = new McpServer({ name: 'geometra', version: '1.19.26' }, { capabilities: { tools: {} } });
@@ -576,6 +595,7 @@ Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth
576
595
  onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields when returnForms=true'),
577
596
  onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields when returnForms=true'),
578
597
  includeOptions: z.boolean().optional().default(false).describe('Include explicit choice option labels in returned form schemas'),
598
+ 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
599
  includeContext: formSchemaContextInput(),
580
600
  sinceSchemaId: z.string().optional().describe('If the current schema matches this id, return changed=false without resending forms'),
581
601
  schemaFormat: formSchemaFormatInput(),
@@ -596,6 +616,7 @@ Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth
596
616
  onlyRequiredFields: input.onlyRequiredFields,
597
617
  onlyInvalidFields: input.onlyInvalidFields,
598
618
  includeOptions: input.includeOptions,
619
+ includeFormGraph: input.includeFormGraph,
599
620
  includeContext: input.includeContext,
600
621
  sinceSchemaId: input.sinceSchemaId,
601
622
  format: input.schemaFormat,
@@ -1079,7 +1100,7 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
1079
1100
  const successCount = steps.filter(step => step.ok === true).length;
1080
1101
  const errorCount = steps.length - successCount;
1081
1102
  const payload = {
1082
- completed: stoppedAt === undefined && steps.length === resolvedFields.fields.length,
1103
+ completed: errorCount === 0 && stoppedAt === undefined && steps.length === resolvedFields.fields.length,
1083
1104
  fieldCount: resolvedFields.fields.length,
1084
1105
  successCount,
1085
1106
  errorCount,
@@ -1146,9 +1167,6 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1146
1167
  const browser = resolveBrowserStealth({ stealth, browserMode });
1147
1168
  if (!browser.ok)
1148
1169
  return err(browser.error);
1149
- const directFields = !includeSteps && !formId && Object.keys(valuesById ?? {}).length === 0
1150
- ? directLabelBatchFields(valuesByLabel)
1151
- : null;
1152
1170
  const resolved = await ensureToolSession({
1153
1171
  sessionId,
1154
1172
  url,
@@ -1160,7 +1178,6 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1160
1178
  slowMo,
1161
1179
  stealth: browser.stealth,
1162
1180
  isolated,
1163
- awaitInitialFrame: directFields ? false : undefined,
1164
1181
  }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_fill_form.');
1165
1182
  if (!resolved.ok)
1166
1183
  return err(resolved.error);
@@ -1170,50 +1187,6 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1170
1187
  if (entryCount === 0) {
1171
1188
  return err('Provide at least one value in valuesById or valuesByLabel');
1172
1189
  }
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
1190
  if (!session.tree || !session.layout) {
1218
1191
  await waitForUiCondition(session, () => Boolean(session.tree && session.layout), 2_000);
1219
1192
  }
@@ -1231,7 +1204,8 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1231
1204
  if (!resolution.ok)
1232
1205
  return err(resolution.error);
1233
1206
  const schema = resolution.schema;
1234
- const planned = planFormFill(schema, { valuesById, valuesByLabel });
1207
+ const targetFormPath = parseSectionId(schema.formId)?.path;
1208
+ const planned = planFormFill(schema, { valuesById, valuesByLabel }, schemas);
1235
1209
  if (!planned.ok)
1236
1210
  return err(planned.error);
1237
1211
  let skippedCount = 0;
@@ -1265,27 +1239,27 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1265
1239
  let fallbackFromBatch;
1266
1240
  if (!includeSteps) {
1267
1241
  let usedBatch = false;
1268
- let batchAckResult;
1269
1242
  try {
1270
1243
  const startRevision = session.updateRevision;
1271
- const wait = await sendFillFields(session, planned.fields);
1244
+ const wait = await sendFillFields(session, toProxyFillFields(planned.fields));
1272
1245
  const ackResult = parseProxyFillAckResult(wait.result);
1273
- batchAckResult = ackResult;
1274
1246
  if (ackResult && ackResult.invalidCount === 0) {
1275
1247
  usedBatch = true;
1276
- const payload = {
1277
- ...connection,
1278
- completed: true,
1279
- execution: 'batched',
1280
- finalSource: 'proxy',
1281
- formId: schema.formId,
1282
- requestedValueCount: entryCount,
1283
- fieldCount: planned.fields.length,
1284
- successCount: planned.fields.length,
1285
- errorCount: 0,
1286
- final: ackResult,
1287
- };
1288
- return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1248
+ if (!verifyFills) {
1249
+ const payload = {
1250
+ ...connection,
1251
+ completed: true,
1252
+ execution: 'batched',
1253
+ finalSource: 'proxy',
1254
+ formId: schema.formId,
1255
+ requestedValueCount: entryCount,
1256
+ fieldCount: planned.fields.length,
1257
+ successCount: planned.fields.length,
1258
+ errorCount: 0,
1259
+ final: ackResult,
1260
+ };
1261
+ return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1262
+ }
1289
1263
  }
1290
1264
  await waitForDeferredBatchUpdate(session, startRevision, wait);
1291
1265
  await waitForBatchFieldReadback(session, planned.fields);
@@ -1300,17 +1274,22 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1300
1274
  }
1301
1275
  if (usedBatch) {
1302
1276
  const after = sessionA11y(session);
1303
- const signals = after ? collectSessionSignals(after) : undefined;
1277
+ const signals = after
1278
+ ? scopeSessionSignalsToForm(after, targetFormPath ? formNodeAtPath(after, targetFormPath) : undefined, targetFormPath !== undefined)
1279
+ : undefined;
1304
1280
  const invalidRemaining = signals?.invalidFields.length ?? 0;
1305
- if ((!batchAckResult || batchAckResult.invalidCount > 0) && invalidRemaining > 0) {
1281
+ if (invalidRemaining > 0) {
1306
1282
  usedBatch = false;
1307
1283
  fallbackFromBatch = { attempted: true, used: true, reason: 'batched-invalid-readback', attempts: 2 };
1308
1284
  }
1309
1285
  }
1310
1286
  if (usedBatch) {
1311
1287
  const after = sessionA11y(session);
1312
- const signals = after ? collectSessionSignals(after) : undefined;
1288
+ const signals = after
1289
+ ? scopeSessionSignalsToForm(after, targetFormPath ? formNodeAtPath(after, targetFormPath) : undefined, targetFormPath !== undefined)
1290
+ : undefined;
1313
1291
  const invalidRemaining = signals?.invalidFields.length ?? 0;
1292
+ const verification = verifyFills ? verifyFormFills(session, planned.planned) : undefined;
1314
1293
  const payload = {
1315
1294
  ...connection,
1316
1295
  completed: true,
@@ -1324,6 +1303,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1324
1303
  minConfidence: planned.planned.length > 0
1325
1304
  ? Number(Math.min(...planned.planned.map(p => p.confidence)).toFixed(2))
1326
1305
  : undefined,
1306
+ ...(verification ? { verification } : {}),
1327
1307
  ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
1328
1308
  };
1329
1309
  recordWorkflowFill(session, schema.formId, schema.name, valuesById, valuesByLabel, invalidRemaining, planned.fields.length);
@@ -1358,14 +1338,16 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1358
1338
  }
1359
1339
  }
1360
1340
  const after = sessionA11y(session);
1361
- const signals = after ? collectSessionSignals(after) : undefined;
1341
+ const signals = after
1342
+ ? scopeSessionSignalsToForm(after, targetFormPath ? formNodeAtPath(after, targetFormPath) : undefined, targetFormPath !== undefined)
1343
+ : undefined;
1362
1344
  const invalidRemaining = signals?.invalidFields.length ?? 0;
1363
1345
  const successCount = steps.filter(step => step.ok === true).length;
1364
1346
  const errorCount = steps.length - successCount;
1365
1347
  const verification = verifyFills ? verifyFormFills(session, planned.planned) : undefined;
1366
1348
  const payload = {
1367
1349
  ...connection,
1368
- completed: stoppedAt === undefined && (startIndex + steps.length) === planned.fields.length,
1350
+ completed: errorCount === 0 && stoppedAt === undefined && (startIndex + steps.length) === planned.fields.length,
1369
1351
  execution: 'sequential',
1370
1352
  formId: schema.formId,
1371
1353
  requestedValueCount: entryCount,
@@ -1424,13 +1406,13 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1424
1406
  formId: z.string().optional().describe('Optional form id from geometra_form_schema or geometra_page_model'),
1425
1407
  valuesById: formValuesRecordSchema.optional().describe('Form values keyed by stable field id from geometra_form_schema'),
1426
1408
  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"}.'),
1409
+ submit: z.object(nodeFilterShape()).strict().optional().describe('Semantic target for the submit button. Defaults to {role: "button", name: "Submit"}.'),
1428
1410
  submitIndex: z.number().int().min(0).optional().default(0).describe('Which matching submit target to click after sorting top-to-bottom (default 0)'),
1429
1411
  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, navigation, submit gone, etc.)'),
1412
+ waitFor: z.object(waitConditionShape()).strict().optional().describe('Optional semantic condition to wait for after the submit click (success banner, submit gone, etc.)'),
1431
1413
  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
1414
  softTimeoutMs: softTimeoutMsInput(),
1433
- failOnInvalid: z.boolean().optional().default(false).describe('Return an error if invalid fields remain after the submit wait resolves.'),
1415
+ 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
1416
  detail: detailInput(),
1435
1417
  sessionId: sessionIdInput,
1436
1418
  }, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, browserMode, isolated, formId, valuesById, valuesByLabel, submit, submitIndex, submitTimeoutMs, waitFor, skipFill, softTimeoutMs, failOnInvalid, detail, sessionId }) => {
@@ -1445,11 +1427,13 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1445
1427
  return err(resolved.error);
1446
1428
  const session = resolved.session;
1447
1429
  const connection = autoConnectionPayload(resolved);
1430
+ let targetFormId = formId;
1448
1431
  let fillSummary;
1449
1432
  let fillFallback;
1450
1433
  const pausedPayload = (phase, resumeHint, extra) => ({
1451
1434
  ...connection,
1452
1435
  completed: false,
1436
+ outcome: 'unconfirmed',
1453
1437
  paused: true,
1454
1438
  phase,
1455
1439
  pauseReason: 'soft-timeout',
@@ -1466,7 +1450,6 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1466
1450
  const entryA11y = sessionA11y(session);
1467
1451
  if (!entryA11y)
1468
1452
  return err('No UI tree available for form submission');
1469
- const entryUrl = entryA11y.meta?.pageUrl;
1470
1453
  if (!hasSoftBudget(deadlineAt)) {
1471
1454
  return ok(JSON.stringify(pausedPayload('before-fill', { retrySameCall: true }), null, detail === 'verbose' ? 2 : undefined));
1472
1455
  }
@@ -1482,13 +1465,14 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1482
1465
  if (!resolution.ok)
1483
1466
  return err(resolution.error);
1484
1467
  const schema = resolution.schema;
1485
- const planned = planFormFill(schema, { valuesById, valuesByLabel });
1468
+ targetFormId = schema.formId;
1469
+ const planned = planFormFill(schema, { valuesById, valuesByLabel }, schemas);
1486
1470
  if (!planned.ok)
1487
1471
  return err(planned.error);
1488
1472
  let usedBatch = true;
1489
1473
  try {
1490
1474
  const startRevision = session.updateRevision;
1491
- const wait = await sendFillFields(session, planned.fields.map(field => capFillFieldTimeout(field, timeoutCapFromDeadline(deadlineAt))), capTimeoutMs(undefined, timeoutCapFromDeadline(deadlineAt), HOST_SAFE_TOOL_TIMEOUT_MS));
1475
+ const wait = await sendFillFields(session, toProxyFillFields(planned.fields), capTimeoutMs(undefined, timeoutCapFromDeadline(deadlineAt), HOST_SAFE_TOOL_TIMEOUT_MS));
1492
1476
  const ack = parseProxyFillAckResult(wait.result);
1493
1477
  await waitForDeferredBatchUpdate(session, startRevision, wait);
1494
1478
  fillSummary = {
@@ -1497,15 +1481,14 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1497
1481
  fieldCount: planned.fields.length,
1498
1482
  ...waitStatusPayload(wait),
1499
1483
  ...(ack ? { invalidCount: ack.invalidCount, alertCount: ack.alertCount } : {}),
1484
+ ...(ack?.invalidFields ? { invalidFields: ack.invalidFields } : {}),
1500
1485
  ...(entryCount !== planned.fields.length ? { requestedValueCount: entryCount } : {}),
1501
1486
  };
1502
1487
  if (wait.status === 'timed_out') {
1503
1488
  return ok(JSON.stringify(pausedPayload('fill', {
1504
1489
  tool: 'geometra_submit_form',
1505
- skipFill: true,
1506
- submit: submit ?? { role: 'button', name: 'Submit' },
1507
- submitIndex,
1508
- ...(waitFor ? { waitFor } : {}),
1490
+ retrySameCall: true,
1491
+ skipFill: false,
1509
1492
  }), null, detail === 'verbose' ? 2 : undefined));
1510
1493
  }
1511
1494
  }
@@ -1531,10 +1514,8 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1531
1514
  };
1532
1515
  return ok(JSON.stringify(pausedPayload('fill', {
1533
1516
  tool: 'geometra_submit_form',
1534
- skipFill: true,
1535
- submit: submit ?? { role: 'button', name: 'Submit' },
1536
- submitIndex,
1537
- ...(waitFor ? { waitFor } : {}),
1517
+ retrySameCall: true,
1518
+ skipFill: false,
1538
1519
  }), null, detail === 'verbose' ? 2 : undefined));
1539
1520
  }
1540
1521
  try {
@@ -1546,16 +1527,26 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1546
1527
  break;
1547
1528
  }
1548
1529
  }
1549
- if (firstErr !== undefined && successCount === 0) {
1550
- return err(`Failed to fill form before submit (sequential fallback): ${firstErr}`);
1551
- }
1552
1530
  fillSummary = {
1553
1531
  formId: schema.formId,
1554
1532
  execution: 'sequential',
1555
1533
  fieldCount: planned.fields.length,
1556
1534
  successCount,
1535
+ ...(firstErr !== undefined ? { error: firstErr } : {}),
1557
1536
  ...(entryCount !== planned.fields.length ? { requestedValueCount: entryCount } : {}),
1558
1537
  };
1538
+ if (firstErr !== undefined) {
1539
+ return err(JSON.stringify({
1540
+ ...connection,
1541
+ completed: false,
1542
+ execution: 'unconfirmed',
1543
+ outcome: 'unconfirmed',
1544
+ phase: 'fill',
1545
+ fill: fillSummary,
1546
+ ...(fillFallback ? { fill_fallback: fillFallback } : {}),
1547
+ submit: { attempted: false, reason: 'Fill phase did not complete successfully.' },
1548
+ }, null, detail === 'verbose' ? 2 : undefined));
1549
+ }
1559
1550
  }
1560
1551
  }
1561
1552
  const submitResumeHint = {
@@ -1579,6 +1570,7 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1579
1570
  const payload = {
1580
1571
  ...connection,
1581
1572
  completed: false,
1573
+ outcome: 'unconfirmed',
1582
1574
  ...(fillSummary ? { fill: fillSummary } : {}),
1583
1575
  ...(fillFallback ? { fill_fallback: fillFallback } : {}),
1584
1576
  submit: { ok: false, error: `Submit target not found: ${resolvedClick.error}` },
@@ -1586,35 +1578,66 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1586
1578
  };
1587
1579
  return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1588
1580
  }
1581
+ const preSubmitA11y = sessionA11y(session);
1582
+ const submitFormNode = preSubmitA11y
1583
+ ? resolveSubmitFormNode(preSubmitA11y, targetFormId, resolvedClick.value.target?.path)
1584
+ : undefined;
1585
+ const submitFormPath = submitFormNode?.path;
1586
+ const preSubmitSignals = preSubmitA11y
1587
+ ? scopeSessionSignalsToForm(preSubmitA11y, submitFormNode)
1588
+ : undefined;
1589
+ if (preSubmitSignals && preSubmitSignals.invalidFields.length > 0) {
1590
+ const payload = {
1591
+ ...connection,
1592
+ completed: false,
1593
+ execution: 'not_started',
1594
+ outcome: 'validation_failed',
1595
+ ...(fillSummary ? { fill: fillSummary } : {}),
1596
+ ...(fillFallback ? { fill_fallback: fillFallback } : {}),
1597
+ submit: {
1598
+ attempted: false,
1599
+ reason: 'Invalid fields remain in the target form before submit; the submit control was not clicked.',
1600
+ },
1601
+ final: sessionSignalsPayload(preSubmitSignals, detail),
1602
+ };
1603
+ const serialized = JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined);
1604
+ return failOnInvalid ? err(serialized) : ok(serialized);
1605
+ }
1589
1606
  if (!hasSoftBudget(deadlineAt)) {
1590
1607
  return ok(JSON.stringify(pausedPayload('submit', submitResumeHint), null, detail === 'verbose' ? 2 : undefined));
1591
1608
  }
1592
1609
  const beforeSubmit = sessionA11y(session);
1610
+ const beforeClickUrl = beforeSubmit?.meta?.pageUrl;
1611
+ const waitFilter = {
1612
+ id: waitFor?.id,
1613
+ role: waitFor?.role,
1614
+ name: waitFor?.name,
1615
+ text: waitFor?.text,
1616
+ contextText: waitFor?.contextText,
1617
+ promptText: waitFor?.promptText,
1618
+ sectionText: waitFor?.sectionText,
1619
+ itemText: waitFor?.itemText,
1620
+ value: waitFor?.value,
1621
+ checked: waitFor?.checked,
1622
+ disabled: waitFor?.disabled,
1623
+ focused: waitFor?.focused,
1624
+ selected: waitFor?.selected,
1625
+ expanded: waitFor?.expanded,
1626
+ invalid: waitFor?.invalid,
1627
+ required: waitFor?.required,
1628
+ busy: waitFor?.busy,
1629
+ };
1630
+ const beforeWaitEvidence = waitFor && beforeSubmit
1631
+ ? captureWaitEvidence(beforeSubmit, waitFilter)
1632
+ : undefined;
1593
1633
  const clickWait = await sendClick(session, resolvedClick.value.x, resolvedClick.value.y, capTimeoutMs(submitTimeoutMs, timeoutCapFromDeadline(deadlineAt), 15_000));
1594
1634
  let waitResult;
1635
+ let waitError;
1595
1636
  if (waitFor) {
1596
1637
  if (!hasSoftBudget(deadlineAt)) {
1597
1638
  return ok(JSON.stringify(pausedPayload('wait_for', {
1598
1639
  tool: 'geometra_wait_for',
1599
- filter: compactFilterPayload({
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
- }),
1640
+ filter: compactFilterPayload(waitFilter),
1618
1641
  present: waitFor.present ?? true,
1619
1642
  }, {
1620
1643
  submit: {
@@ -1625,54 +1648,53 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1625
1648
  }), null, detail === 'verbose' ? 2 : undefined));
1626
1649
  }
1627
1650
  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
- },
1651
+ filter: waitFilter,
1647
1652
  present: waitFor.present ?? true,
1648
1653
  timeoutMs: capTimeoutMs(waitFor.timeoutMs, timeoutCapFromDeadline(deadlineAt), 15_000),
1649
1654
  });
1650
1655
  if (!postWait.ok) {
1651
- const preErrFallbacks = [];
1652
- if (fillFallback)
1653
- preErrFallbacks.push({ phase: 'fill', ...fillFallback });
1654
- if (resolvedClick.fallback)
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));
1656
+ waitError = postWait.error;
1657
+ }
1658
+ else {
1659
+ waitResult = postWait.value;
1669
1660
  }
1670
- waitResult = postWait.value;
1671
1661
  }
1672
1662
  const after = sessionA11y(session);
1673
- const signals = after ? collectSessionSignals(after) : undefined;
1674
- const afterUrl = after?.meta?.pageUrl;
1675
- const navigated = Boolean(afterUrl && entryUrl && afterUrl !== entryUrl);
1663
+ const signals = after
1664
+ ? scopeSessionSignalsToForm(after, submitFormPath ? formNodeAtPath(after, submitFormPath) : undefined, submitFormPath !== undefined)
1665
+ : undefined;
1666
+ const clickResult = clickWait.result && typeof clickWait.result === 'object'
1667
+ ? clickWait.result
1668
+ : undefined;
1669
+ const clickAfterUrl = typeof clickResult?.pageUrl === 'string' ? clickResult.pageUrl : undefined;
1670
+ const clickBeforeUrl = typeof clickResult?.urlBefore === 'string' ? clickResult.urlBefore : beforeClickUrl;
1671
+ const observedAfterUrl = after?.meta?.pageUrl;
1672
+ const navigatedByClick = Boolean(clickResult?.navigated === true &&
1673
+ clickAfterUrl &&
1674
+ clickBeforeUrl &&
1675
+ clickAfterUrl !== clickBeforeUrl);
1676
+ const navigatedByTree = Boolean(observedAfterUrl && beforeClickUrl && observedAfterUrl !== beforeClickUrl);
1677
+ const navigated = navigatedByClick || navigatedByTree;
1678
+ const afterUrl = navigatedByClick && clickAfterUrl
1679
+ ? clickAfterUrl
1680
+ : observedAfterUrl ?? clickAfterUrl;
1681
+ const waitEvidenceFresh = waitResult
1682
+ ? waitConditionHasFreshEvidence(beforeWaitEvidence, after, waitResult)
1683
+ : false;
1684
+ const execution = clickWait.status === 'timed_out' ? 'unconfirmed' : 'completed';
1685
+ const invalidCount = signals?.invalidFields.length ?? 0;
1686
+ const outcome = invalidCount > 0
1687
+ ? 'validation_failed'
1688
+ : execution === 'unconfirmed' || Boolean(waitError)
1689
+ ? 'unconfirmed'
1690
+ : waitFor
1691
+ ? waitEvidenceFresh
1692
+ ? 'submitted'
1693
+ : 'unconfirmed'
1694
+ : navigated
1695
+ ? 'submitted'
1696
+ : 'unconfirmed';
1697
+ const completed = outcome === 'submitted';
1676
1698
  // Aggregate all fallback usage into a single top-level `fallbacks[]`
1677
1699
  // array so this tool matches the shape emitted by `geometra_run_actions`
1678
1700
  // and can be aggregated the same way by operators.
@@ -1685,16 +1707,22 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1685
1707
  }
1686
1708
  const payload = {
1687
1709
  ...connection,
1688
- completed: true,
1710
+ completed,
1711
+ execution,
1712
+ outcome,
1689
1713
  ...(fillSummary ? { fill: fillSummary } : {}),
1690
1714
  submit: {
1691
1715
  at: { x: resolvedClick.value.x, y: resolvedClick.value.y },
1692
1716
  ...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target), revealSteps: resolvedClick.value.revealAttempts ?? 0 } : {}),
1693
1717
  ...waitStatusPayload(clickWait),
1694
1718
  },
1695
- ...(waitResult ? { waitFor: waitConditionCompact(waitResult) } : {}),
1719
+ ...(waitResult
1720
+ ? { waitFor: { ...waitConditionCompact(waitResult), fresh: waitEvidenceFresh } }
1721
+ : waitError
1722
+ ? { waitFor: { ok: false, error: waitError } }
1723
+ : {}),
1696
1724
  ...(fallbackRecords.length > 0 ? { fallbacks: fallbackRecords } : {}),
1697
- ...(navigated ? { navigated: true, afterUrl } : {}),
1725
+ ...(navigated ? { navigated: true, ...(afterUrl ? { afterUrl } : {}) } : {}),
1698
1726
  ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
1699
1727
  };
1700
1728
  // Pull in page model hints on navigation to mirror fill_form behavior.
@@ -1705,15 +1733,12 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1705
1733
  if (model.verification)
1706
1734
  payload.verification = model.verification;
1707
1735
  }
1708
- if (failOnInvalid && signals && signals.invalidFields.length > 0) {
1709
- return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1710
- }
1711
- // Swallow the unused `beforeSubmit` binding; it anchors that the a11y tree was
1712
- // captured pre-click and keeps the pattern consistent with other tools that
1713
- // diff before/after for summaries (we rely on the waitFor / final signals
1714
- // for the actual comparison here).
1715
- void beforeSubmit;
1716
- return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1736
+ const serialized = JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined);
1737
+ if (waitError)
1738
+ return err(serialized);
1739
+ if (failOnInvalid && outcome === 'validation_failed')
1740
+ return err(serialized);
1741
+ return ok(serialized);
1717
1742
  });
1718
1743
  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
1744
 
@@ -1874,7 +1899,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1874
1899
  const successCount = steps.filter(step => step.ok === true).length;
1875
1900
  const errorCount = steps.length - successCount;
1876
1901
  const elapsedMs = Number((performance.now() - toolStartedAt).toFixed(1));
1877
- const completed = stoppedAt === undefined && pausedAt === undefined && startIndex + steps.length >= actions.length;
1902
+ const completed = errorCount === 0 && stoppedAt === undefined && pausedAt === undefined && startIndex + steps.length >= actions.length;
1878
1903
  const resumePayload = {
1879
1904
  ...(startIndex > 0 ? { resumedFromIndex: startIndex } : {}),
1880
1905
  ...(pausedAt !== undefined
@@ -1892,6 +1917,8 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1892
1917
  ? {
1893
1918
  ...connection,
1894
1919
  completed,
1920
+ successCount,
1921
+ errorCount,
1895
1922
  ...resumePayload,
1896
1923
  ...(stoppedAt !== undefined ? { stoppedAt } : {}),
1897
1924
  ...(fallbackRecords.length > 0 ? { fallbacks: fallbackRecords } : {}),
@@ -1979,11 +2006,12 @@ Unlike geometra_expand_section, this collapses repeated radio/button groups into
1979
2006
  onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
1980
2007
  onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields'),
1981
2008
  includeOptions: z.boolean().optional().default(false).describe('Include explicit choice option labels'),
2009
+ includeFormGraph: z.boolean().optional().default(false).describe('Also include FormGraph 0.1-compatible graphs for the returned forms'),
1982
2010
  includeContext: formSchemaContextInput(),
1983
2011
  sinceSchemaId: z.string().optional().describe('If the current schema matches this id, return changed=false without resending forms'),
1984
2012
  format: formSchemaFormatInput(),
1985
2013
  sessionId: sessionIdInput,
1986
- }, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, browserMode, isolated, formId, maxFields, onlyRequiredFields, onlyInvalidFields, includeOptions, includeContext, sinceSchemaId, format, sessionId }) => {
2014
+ }, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, browserMode, isolated, formId, maxFields, onlyRequiredFields, onlyInvalidFields, includeOptions, includeFormGraph, includeContext, sinceSchemaId, format, sessionId }) => {
1987
2015
  const browser = resolveBrowserStealth({ stealth, browserMode });
1988
2016
  if (!browser.ok)
1989
2017
  return err(browser.error);
@@ -2000,6 +2028,7 @@ Unlike geometra_expand_section, this collapses repeated radio/button groups into
2000
2028
  onlyRequiredFields,
2001
2029
  onlyInvalidFields,
2002
2030
  includeOptions,
2031
+ includeFormGraph,
2003
2032
  includeContext,
2004
2033
  sinceSchemaId,
2005
2034
  format,
@@ -2132,7 +2161,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
2132
2161
  .optional()
2133
2162
  .default(2_500)
2134
2163
  .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'),
2164
+ waitFor: z.object(waitConditionShape()).strict().optional().describe('Optional semantic condition to wait for after the click'),
2136
2165
  timeoutMs: z
2137
2166
  .number()
2138
2167
  .int()
@@ -2177,6 +2206,29 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
2177
2206
  });
2178
2207
  if (!resolved.ok)
2179
2208
  return err(clickFallbackErrorMessage(resolved));
2209
+ const waitFilter = waitFor ? {
2210
+ id: waitFor.id,
2211
+ role: waitFor.role,
2212
+ name: waitFor.name,
2213
+ text: waitFor.text,
2214
+ contextText: waitFor.contextText,
2215
+ promptText: waitFor.promptText,
2216
+ sectionText: waitFor.sectionText,
2217
+ itemText: waitFor.itemText,
2218
+ value: waitFor.value,
2219
+ checked: waitFor.checked,
2220
+ disabled: waitFor.disabled,
2221
+ focused: waitFor.focused,
2222
+ selected: waitFor.selected,
2223
+ expanded: waitFor.expanded,
2224
+ invalid: waitFor.invalid,
2225
+ required: waitFor.required,
2226
+ busy: waitFor.busy,
2227
+ } : undefined;
2228
+ const waitEvidenceRoot = sessionA11y(session) ?? before;
2229
+ const beforeWaitEvidence = waitFilter && waitEvidenceRoot
2230
+ ? captureWaitEvidence(waitEvidenceRoot, waitFilter)
2231
+ : undefined;
2180
2232
  const wait = await sendClick(session, resolved.value.x, resolved.value.y, timeoutMs);
2181
2233
  const summary = postActionSummary(session, before, wait, detail);
2182
2234
  const clickLine = !resolved.value.target
@@ -2185,37 +2237,23 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
2185
2237
  const lines = [clickLine, summary];
2186
2238
  if (waitFor) {
2187
2239
  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
- },
2240
+ filter: waitFilter,
2207
2241
  present: waitFor.present ?? true,
2208
2242
  timeoutMs: waitFor.timeoutMs ?? 10_000,
2209
2243
  });
2210
2244
  if (!postWait.ok)
2211
2245
  return err([...lines, postWait.error].join('\n'));
2246
+ const fresh = waitConditionHasFreshEvidence(beforeWaitEvidence, sessionA11y(session), postWait.value);
2247
+ if (!fresh) {
2248
+ return err([...lines, 'Post-click condition matched only pre-existing evidence; the click outcome is unconfirmed.'].join('\n'));
2249
+ }
2212
2250
  lines.push(`Post-click ${waitConditionSuccessLine(postWait.value)}`);
2213
2251
  const compact = {
2214
2252
  at: { x: resolved.value.x, y: resolved.value.y },
2215
2253
  ...(resolved.value.target ? { target: compactNodeReference(resolved.value.target), revealSteps: resolved.value.revealAttempts ?? 0 } : {}),
2216
2254
  ...waitStatusPayload(wait),
2217
2255
  ...(resolved.fallback ? { fallback: resolved.fallback } : {}),
2218
- postWait: waitConditionCompact(postWait.value),
2256
+ postWait: { ...waitConditionCompact(postWait.value), fresh: true },
2219
2257
  };
2220
2258
  return ok(detailText(lines.filter(Boolean).join('\n'), compact, detail));
2221
2259
  }
@@ -2225,6 +2263,9 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
2225
2263
  ...waitStatusPayload(wait),
2226
2264
  ...(resolved.fallback ? { fallback: resolved.fallback } : {}),
2227
2265
  };
2266
+ if (wait.status === 'timed_out') {
2267
+ return err(detailText(lines.filter(Boolean).join('\n'), { completed: false, outcome: 'unconfirmed', ...compact }, detail));
2268
+ }
2228
2269
  return ok(detailText(lines.filter(Boolean).join('\n'), compact, detail));
2229
2270
  });
2230
2271
  // ── type ─────────────────────────────────────────────────────
@@ -2249,10 +2290,10 @@ Each character is sent as a key event through the geometry protocol. Returns a c
2249
2290
  const before = sessionA11y(session);
2250
2291
  const wait = await sendType(session, text, timeoutMs);
2251
2292
  const summary = postActionSummary(session, before, wait, detail);
2252
- return ok(detailText(`Typed "${text}".\n${summary}`, {
2293
+ return actionWaitResponse(wait, `Typed "${text}".\n${summary}`, {
2253
2294
  ...compactTextValue(text),
2254
2295
  ...waitStatusPayload(wait),
2255
- }, detail));
2296
+ }, detail);
2256
2297
  });
2257
2298
  // ── key ──────────────────────────────────────────────────────
2258
2299
  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 +2319,10 @@ Each character is sent as a key event through the geometry protocol. Returns a c
2278
2319
  const before = sessionA11y(session);
2279
2320
  const wait = await sendKey(session, key, { shift, ctrl, meta, alt }, timeoutMs);
2280
2321
  const summary = postActionSummary(session, before, wait, detail);
2281
- return ok(detailText(`Pressed ${formatKeyCombo(key, { shift, ctrl, meta, alt })}.\n${summary}`, {
2322
+ return actionWaitResponse(wait, `Pressed ${formatKeyCombo(key, { shift, ctrl, meta, alt })}.\n${summary}`, {
2282
2323
  key: formatKeyCombo(key, { shift, ctrl, meta, alt }),
2283
2324
  ...waitStatusPayload(wait),
2284
- }, detail));
2325
+ }, detail);
2285
2326
  });
2286
2327
  // ── fill OTP / verification-code box group ─────────────────────
2287
2328
  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,13 +2352,13 @@ Detection is fully generic (no site branding). It refuses to run if the detected
2311
2352
  const wait = await sendFillOtp(session, value, { fieldLabel, perCharDelayMs }, timeoutMs);
2312
2353
  const summary = postActionSummary(session, before, wait, detail);
2313
2354
  const result = wait.result;
2314
- return ok(detailText(`Filled OTP code (${value.length} chars).\n${summary}`, {
2355
+ return actionWaitResponse(wait, `Filled OTP code (${value.length} chars).\n${summary}`, {
2315
2356
  ...compactTextValue(value),
2316
2357
  ...(fieldLabel ? { fieldLabel } : {}),
2317
2358
  ...(result?.cellCount !== undefined ? { cellCount: result.cellCount } : {}),
2318
2359
  ...(result?.filledCount !== undefined ? { filledCount: result.filledCount } : {}),
2319
2360
  ...waitStatusPayload(wait),
2320
- }, detail));
2361
+ }, detail);
2321
2362
  }
2322
2363
  catch (e) {
2323
2364
  return err(e.message);
@@ -2364,13 +2405,13 @@ Strategies: **auto** (default) tries chooser click if x,y given, else a labeled
2364
2405
  drop: dropX !== undefined && dropY !== undefined ? { x: dropX, y: dropY } : undefined,
2365
2406
  }, timeoutMs ?? 8_000);
2366
2407
  const summary = postActionSummary(session, before, wait, detail);
2367
- return ok(detailText(`Uploaded ${paths.length} file(s).\n${summary}`, {
2408
+ return actionWaitResponse(wait, `Uploaded ${paths.length} file(s).\n${summary}`, {
2368
2409
  fileCount: paths.length,
2369
2410
  ...(fieldLabel ? { fieldLabel } : {}),
2370
2411
  ...(strategy ? { strategy } : {}),
2371
2412
  ...waitStatusPayload(wait),
2372
2413
  ...(fieldLabel ? { readback: fieldStatePayload(session, fieldLabel) } : {}),
2373
- }, detail));
2414
+ }, detail);
2374
2415
  }
2375
2416
  catch (e) {
2376
2417
  return err(e.message);
@@ -2383,6 +2424,8 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
2383
2424
  exact: z.boolean().optional().describe('Exact name match'),
2384
2425
  openX: z.number().optional().describe('Click to open dropdown'),
2385
2426
  openY: z.number().optional().describe('Click to open dropdown'),
2427
+ fieldId: z.string().trim().min(1).optional().describe('Stable field id from geometra_form_schema'),
2428
+ fieldKey: z.string().trim().min(1).optional().describe('Authored field key from geometra_form_schema for exact resolution'),
2386
2429
  fieldLabel: z.string().optional().describe('Field label of the dropdown/combobox to open semantically (e.g. "Location")'),
2387
2430
  contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated dropdowns with the same label'),
2388
2431
  sectionText: z.string().optional().describe('Containing section text to disambiguate repeated dropdowns'),
@@ -2396,32 +2439,39 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
2396
2439
  .describe('Optional action wait timeout for slow dropdowns / remote search results'),
2397
2440
  detail: detailInput(),
2398
2441
  sessionId: sessionIdInput,
2399
- }, async ({ label, exact, openX, openY, fieldLabel, contextText: _contextText, sectionText: _sectionText, query, timeoutMs, detail, sessionId }) => {
2442
+ }, async ({ label, exact, openX, openY, fieldId, fieldKey, fieldLabel, contextText, sectionText, query, timeoutMs, detail, sessionId }) => {
2400
2443
  const sessionResult = resolveToolSession(sessionId);
2401
2444
  if ('error' in sessionResult)
2402
2445
  return sessionResult.error;
2403
2446
  const session = sessionResult.session;
2404
2447
  const before = sessionA11y(session);
2448
+ if (contextText || sectionText) {
2449
+ return err('contextText/sectionText are not supported for listbox resolution; pass fieldKey from geometra_form_schema instead.');
2450
+ }
2405
2451
  try {
2406
2452
  const wait = await sendListboxPick(session, label, {
2407
2453
  exact,
2408
2454
  open: openX !== undefined && openY !== undefined ? { x: openX, y: openY } : undefined,
2455
+ fieldId,
2456
+ fieldKey,
2409
2457
  fieldLabel,
2410
2458
  query,
2411
2459
  }, timeoutMs);
2412
2460
  const summary = postActionSummary(session, before, wait, detail);
2413
- const fieldSummary = fieldLabel ? summarizeFieldLabelState(session, fieldLabel) : undefined;
2461
+ const fieldSummary = fieldLabel ? summarizeFieldLabelState(session, fieldLabel, fieldKey) : undefined;
2414
2462
  const summaryText = [
2415
2463
  `Picked listbox option "${label}".`,
2416
2464
  fieldSummary,
2417
2465
  summary,
2418
2466
  ].filter(Boolean).join('\n');
2419
- return ok(detailText(summaryText, {
2467
+ return actionWaitResponse(wait, summaryText, {
2420
2468
  label,
2469
+ ...(fieldId ? { fieldId } : {}),
2470
+ ...(fieldKey ? { fieldKey } : {}),
2421
2471
  ...(fieldLabel ? { fieldLabel } : {}),
2422
2472
  ...waitStatusPayload(wait),
2423
- ...(fieldLabel ? { readback: fieldStatePayload(session, fieldLabel) } : {}),
2424
- }, detail));
2473
+ ...(fieldLabel ? { readback: fieldStatePayload(session, fieldLabel, fieldKey) } : {}),
2474
+ }, detail);
2425
2475
  }
2426
2476
  catch (e) {
2427
2477
  return err(e.message);
@@ -2459,51 +2509,65 @@ Custom React/Vue dropdowns are not supported here — use \`geometra_pick_listbo
2459
2509
  try {
2460
2510
  const wait = await sendSelectOption(session, x, y, { value, label, index }, timeoutMs);
2461
2511
  const summary = postActionSummary(session, before, wait, detail);
2462
- return ok(detailText(`Selected option.\n${summary}`, {
2512
+ return actionWaitResponse(wait, `Selected option.\n${summary}`, {
2463
2513
  at: { x, y },
2464
2514
  ...(value !== undefined ? { value } : {}),
2465
2515
  ...(label !== undefined ? { label } : {}),
2466
2516
  ...(index !== undefined ? { index } : {}),
2467
2517
  ...waitStatusPayload(wait),
2468
- }, detail));
2518
+ }, detail);
2469
2519
  }
2470
2520
  catch (e) {
2471
2521
  return err(e.message);
2472
2522
  }
2473
2523
  });
2474
- server.tool('geometra_set_checked', `Set a checkbox or radio by label. Requires \`@geometra/proxy\`.
2524
+ server.registerTool('geometra_set_checked', {
2525
+ description: `Set a checkbox or radio by label. Requires \`@geometra/proxy\`.
2475
2526
 
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
- label: z.string().describe('Accessible label or visible option text to match'),
2478
- checked: z.boolean().optional().default(true).describe('Desired checked state (radios only support true)'),
2479
- exact: z.boolean().optional().describe('Exact label match'),
2480
- controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
2481
- contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated checkboxes/radios'),
2482
- sectionText: z.string().optional().describe('Containing section text to disambiguate repeated checkboxes/radios'),
2483
- timeoutMs: z
2484
- .number()
2485
- .int()
2486
- .min(50)
2487
- .max(60_000)
2488
- .optional()
2489
- .describe('Optional action wait timeout'),
2490
- detail: detailInput(),
2491
- sessionId: sessionIdInput,
2492
- }, async ({ label, checked, exact, controlType, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail, sessionId }) => {
2527
+ 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.`,
2528
+ inputSchema: z.object({
2529
+ label: z.string().trim().min(1, 'label must not be empty').describe('Accessible label or visible option text to match'),
2530
+ checked: z.boolean().optional().default(true).describe('Desired checked state (radios only support true)'),
2531
+ exact: z.boolean().optional().describe('Exact label match'),
2532
+ controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
2533
+ fieldKey: z.string().trim().min(1).optional().describe('Authored field key from geometra_form_schema for exact resolution'),
2534
+ contextText: z.string().trim().min(1).optional().describe('Ancestor / prompt text to disambiguate repeated checkboxes/radios'),
2535
+ sectionText: z.string().trim().min(1).optional().describe('Containing section text to disambiguate repeated checkboxes/radios'),
2536
+ timeoutMs: z
2537
+ .number()
2538
+ .int()
2539
+ .min(50)
2540
+ .max(60_000)
2541
+ .optional()
2542
+ .describe('Optional action wait timeout'),
2543
+ detail: detailInput(),
2544
+ sessionId: sessionIdInput,
2545
+ }).strict(),
2546
+ }, async ({ label, checked, exact, controlType, fieldKey, contextText, sectionText, timeoutMs, detail, sessionId }) => {
2493
2547
  const sessionResult = resolveToolSession(sessionId);
2494
2548
  if ('error' in sessionResult)
2495
2549
  return sessionResult.error;
2496
2550
  const session = sessionResult.session;
2497
2551
  const before = sessionA11y(session);
2498
2552
  try {
2499
- const wait = await sendSetChecked(session, label, { checked, exact, controlType }, timeoutMs);
2553
+ const wait = await sendSetChecked(session, label, {
2554
+ checked,
2555
+ exact,
2556
+ controlType,
2557
+ ...(fieldKey ? { fieldKey } : {}),
2558
+ contextText,
2559
+ sectionText,
2560
+ }, timeoutMs);
2500
2561
  const summary = postActionSummary(session, before, wait, detail);
2501
- return ok(detailText(`Set ${controlType ?? 'checkbox/radio'} "${label}" to ${String(checked ?? true)}.\n${summary}`, {
2562
+ return actionWaitResponse(wait, `Set ${controlType ?? 'checkbox/radio'} "${label}" to ${String(checked ?? true)}.\n${summary}`, {
2502
2563
  label,
2503
2564
  checked: checked ?? true,
2504
2565
  ...(controlType ? { controlType } : {}),
2566
+ ...(fieldKey ? { fieldKey } : {}),
2567
+ ...(contextText ? { contextText } : {}),
2568
+ ...(sectionText ? { sectionText } : {}),
2505
2569
  ...waitStatusPayload(wait),
2506
- }, detail));
2570
+ }, detail);
2507
2571
  }
2508
2572
  catch (e) {
2509
2573
  return err(e.message);
@@ -2533,12 +2597,12 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
2533
2597
  try {
2534
2598
  const wait = await sendWheel(session, deltaY, { deltaX, x, y }, timeoutMs);
2535
2599
  const summary = postActionSummary(session, before, wait, detail);
2536
- return ok(detailText(`Wheel delta (${deltaX ?? 0}, ${deltaY}).\n${summary}`, {
2600
+ return actionWaitResponse(wait, `Wheel delta (${deltaX ?? 0}, ${deltaY}).\n${summary}`, {
2537
2601
  deltaY,
2538
2602
  ...(deltaX !== undefined ? { deltaX } : {}),
2539
2603
  ...(x !== undefined && y !== undefined ? { at: { x, y } } : {}),
2540
2604
  ...waitStatusPayload(wait),
2541
- }, detail));
2605
+ }, detail);
2542
2606
  }
2543
2607
  catch (e) {
2544
2608
  return err(e.message);
@@ -2883,6 +2947,7 @@ function packedFormSchemas(forms) {
2883
2947
  ic: form.invalidCount,
2884
2948
  f: form.fields.map(field => ({
2885
2949
  i: field.id,
2950
+ ...(field.fieldKey ? { fk: field.fieldKey } : {}),
2886
2951
  k: field.kind,
2887
2952
  l: field.label,
2888
2953
  ...(field.required ? { r: 1 } : {}),
@@ -2891,6 +2956,8 @@ function packedFormSchemas(forms) {
2891
2956
  ...(field.booleanChoice ? { b: 1 } : {}),
2892
2957
  ...(field.controlType ? { t: field.controlType } : {}),
2893
2958
  ...(field.optionCount !== undefined ? { oc: field.optionCount } : {}),
2959
+ ...(field.options ? { o: field.options } : {}),
2960
+ ...(field.optionDetails ? { od: field.optionDetails } : {}),
2894
2961
  ...(field.value ? { v: field.value } : {}),
2895
2962
  ...(field.valueLength !== undefined ? { vl: field.valueLength } : {}),
2896
2963
  ...(field.checked !== undefined ? { c: field.checked ? 1 : 0 } : {}),
@@ -2904,7 +2971,10 @@ function packedFormSchemas(forms) {
2904
2971
  }
2905
2972
  function formSchemaResponsePayload(session, opts) {
2906
2973
  const forms = getSessionFormSchemas(session, opts);
2907
- const schemaJson = JSON.stringify(forms);
2974
+ const graphForms = opts.includeFormGraph && !opts.includeOptions
2975
+ ? getSessionFormSchemas(session, { ...opts, includeOptions: true })
2976
+ : forms;
2977
+ const schemaJson = JSON.stringify(forms) + (opts.includeFormGraph ? '|formgraph' : '');
2908
2978
  const schemaId = `fs:${shortHash(schemaJson)}`;
2909
2979
  if (opts.sinceSchemaId && opts.sinceSchemaId === schemaId) {
2910
2980
  return {
@@ -2914,12 +2984,14 @@ function formSchemaResponsePayload(session, opts) {
2914
2984
  format: opts.format ?? 'compact',
2915
2985
  };
2916
2986
  }
2987
+ const pageUrl = sessionA11y(session)?.meta?.pageUrl;
2917
2988
  return {
2918
2989
  schemaId,
2919
2990
  changed: true,
2920
2991
  formCount: forms.length,
2921
2992
  format: opts.format ?? 'compact',
2922
2993
  forms: (opts.format ?? 'compact') === 'packed' ? packedFormSchemas(forms) : forms,
2994
+ ...(opts.includeFormGraph ? { formGraphs: graphForms.map(form => formSchemaToFormGraph(form, pageUrl)) } : {}),
2923
2995
  };
2924
2996
  }
2925
2997
  function totalReturnedSchemaFields(forms) {
@@ -3131,6 +3203,33 @@ function summarizeCompactContext(context) {
3131
3203
  }
3132
3204
  return parts.length > 0 ? `Context: ${parts.join(' | ')}` : '';
3133
3205
  }
3206
+ function formNodeAtPath(root, path) {
3207
+ const node = findNodeByPath(root, path);
3208
+ return node?.role === 'form' ? node : undefined;
3209
+ }
3210
+ function resolveSubmitFormNode(root, formId, submitPath) {
3211
+ const parsedForm = formId ? parseSectionId(formId) : null;
3212
+ if (parsedForm?.kind === 'form') {
3213
+ const explicit = formNodeAtPath(root, parsedForm.path);
3214
+ if (explicit)
3215
+ return explicit;
3216
+ }
3217
+ if (!submitPath)
3218
+ return undefined;
3219
+ for (let length = submitPath.length; length >= 0; length--) {
3220
+ const candidate = formNodeAtPath(root, submitPath.slice(0, length));
3221
+ if (candidate)
3222
+ return candidate;
3223
+ }
3224
+ return undefined;
3225
+ }
3226
+ function scopeSessionSignalsToForm(pageRoot, formNode, targetFormWasKnown = false) {
3227
+ const pageSignals = collectSessionSignals(pageRoot);
3228
+ if (formNode) {
3229
+ return { ...pageSignals, invalidFields: collectSessionSignals(formNode).invalidFields };
3230
+ }
3231
+ return targetFormWasKnown ? { ...pageSignals, invalidFields: [] } : pageSignals;
3232
+ }
3134
3233
  function collectSessionSignals(root) {
3135
3234
  const signals = {
3136
3235
  ...(root.meta?.pageUrl ? { pageUrl: root.meta.pageUrl } : {}),
@@ -3143,6 +3242,17 @@ function collectSessionSignals(root) {
3143
3242
  };
3144
3243
  const seenAlerts = new Set();
3145
3244
  const seenInvalidIds = new Set();
3245
+ const formControlRoles = new Set([
3246
+ 'textbox',
3247
+ 'combobox',
3248
+ 'checkbox',
3249
+ 'radio',
3250
+ 'spinbutton',
3251
+ 'listbox',
3252
+ 'searchbox',
3253
+ 'slider',
3254
+ 'switch',
3255
+ ]);
3146
3256
  const captchaPattern = /recaptcha|g-recaptcha|hcaptcha|h-captcha|turnstile|cf-turnstile|captcha/i;
3147
3257
  const captchaTypes = {
3148
3258
  recaptcha: 'recaptcha', 'g-recaptcha': 'recaptcha',
@@ -3169,7 +3279,11 @@ function collectSessionSignals(root) {
3169
3279
  signals.alerts.push(text);
3170
3280
  }
3171
3281
  }
3172
- if ((node.role === 'textbox' || node.role === 'combobox' || node.role === 'checkbox' || node.role === 'radio') && node.state?.invalid) {
3282
+ const isFormControl = formControlRoles.has(node.role) ||
3283
+ node.meta?.controlTag === 'input' ||
3284
+ node.meta?.controlTag === 'textarea' ||
3285
+ node.meta?.controlTag === 'select';
3286
+ if (isFormControl && (node.state?.invalid === true || Boolean(node.validation?.error))) {
3173
3287
  const id = nodeIdForPath(node.path);
3174
3288
  if (!seenInvalidIds.has(id)) {
3175
3289
  seenInvalidIds.add(id);
@@ -3274,20 +3388,26 @@ function compactTextValue(value, inlineLimit = 48) {
3274
3388
  ? { value: normalized }
3275
3389
  : { valueLength: value.length };
3276
3390
  }
3277
- function fieldStatePayload(session, fieldLabel) {
3391
+ function fieldReadbackNodes(a11y, fieldLabel, roles, fieldKey) {
3392
+ if (fieldKey) {
3393
+ const matches = [];
3394
+ const allowedRoles = new Set(roles);
3395
+ const walk = (node) => {
3396
+ if (allowedRoles.has(node.role) && node.meta?.controlKey === fieldKey)
3397
+ matches.push(node);
3398
+ for (const child of node.children)
3399
+ walk(child);
3400
+ };
3401
+ walk(a11y);
3402
+ return matches;
3403
+ }
3404
+ return roles.flatMap(role => findNodes(a11y, { name: fieldLabel, role }));
3405
+ }
3406
+ function fieldStatePayload(session, fieldLabel, fieldKey) {
3278
3407
  const a11y = sessionA11y(session);
3279
3408
  if (!a11y)
3280
3409
  return undefined;
3281
- const matches = findNodes(a11y, {
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
- }
3410
+ const matches = fieldReadbackNodes(a11y, fieldLabel, ['combobox', 'textbox', 'button'], fieldKey);
3291
3411
  const match = matches[0];
3292
3412
  if (!match)
3293
3413
  return undefined;
@@ -3372,6 +3492,45 @@ function waitConditionCompact(result) {
3372
3492
  ...(result.present ? { matchCount: result.matchCount } : {}),
3373
3493
  };
3374
3494
  }
3495
+ function waitEvidenceNodeSnapshot(node, filter) {
3496
+ const relevantState = {
3497
+ ...(filter.checked !== undefined ? { checked: node.state?.checked } : {}),
3498
+ ...(filter.disabled !== undefined ? { disabled: node.state?.disabled } : {}),
3499
+ ...(filter.focused !== undefined ? { focused: node.state?.focused } : {}),
3500
+ ...(filter.selected !== undefined ? { selected: node.state?.selected } : {}),
3501
+ ...(filter.expanded !== undefined ? { expanded: node.state?.expanded } : {}),
3502
+ ...(filter.invalid !== undefined ? { invalid: node.state?.invalid } : {}),
3503
+ ...(filter.required !== undefined ? { required: node.state?.required } : {}),
3504
+ ...(filter.busy !== undefined ? { busy: node.state?.busy } : {}),
3505
+ };
3506
+ return {
3507
+ role: node.role,
3508
+ name: node.name,
3509
+ value: node.value,
3510
+ validation: node.validation,
3511
+ ...(Object.keys(relevantState).length > 0 ? { state: relevantState } : {}),
3512
+ };
3513
+ }
3514
+ function captureWaitEvidence(root, filter) {
3515
+ const matches = findNodes(root, filter);
3516
+ return {
3517
+ matchCount: matches.length,
3518
+ fingerprints: new Set(matches.map(node => JSON.stringify(waitEvidenceNodeSnapshot(node, filter)))),
3519
+ };
3520
+ }
3521
+ function waitConditionHasFreshEvidence(before, after, result) {
3522
+ if (!before || !after)
3523
+ return false;
3524
+ const current = captureWaitEvidence(after, result.filter);
3525
+ if (!result.present) {
3526
+ return before.matchCount > 0 && current.matchCount === 0;
3527
+ }
3528
+ if (current.matchCount === 0)
3529
+ return false;
3530
+ if (before.matchCount === 0)
3531
+ return true;
3532
+ return [...current.fingerprints].some(fingerprint => !before.fingerprints.has(fingerprint));
3533
+ }
3375
3534
  function inferRevealStepBudget(target, viewport) {
3376
3535
  const verticalSteps = Math.ceil(Math.abs(target.scrollHint.revealDeltaY) / Math.max(1, viewport.height * 0.75));
3377
3536
  const horizontalSteps = Math.ceil(Math.abs(target.scrollHint.revealDeltaX) / Math.max(1, viewport.width * 0.7));
@@ -3570,6 +3729,16 @@ function compactFormattedNode(node) {
3570
3729
  function detailText(summary, compact, detail) {
3571
3730
  return detail === 'terse' ? JSON.stringify(compact) : summary;
3572
3731
  }
3732
+ function actionWaitResponse(wait, summary, compact, detail) {
3733
+ if (wait.status === 'timed_out') {
3734
+ return err(detailText(summary, {
3735
+ completed: false,
3736
+ outcome: 'unconfirmed',
3737
+ ...compact,
3738
+ }, detail));
3739
+ }
3740
+ return ok(detailText(summary, compact, detail));
3741
+ }
3573
3742
  function normalizeLookupKey(value) {
3574
3743
  return value.replace(/\s+/g, ' ').trim().toLowerCase();
3575
3744
  }
@@ -3613,6 +3782,57 @@ function coerceChoiceValue(field, value) {
3613
3782
  const option = field.options?.find(option => normalizeLookupKey(option) === desired);
3614
3783
  return option ?? (value ? 'Yes' : 'No');
3615
3784
  }
3785
+ function resolveNativeChoiceValue(field, value) {
3786
+ const options = field.optionDetails;
3787
+ if (!options || options.length === 0)
3788
+ return { ok: true, value };
3789
+ const resolved = (option) => ({
3790
+ ok: true,
3791
+ value: option.value,
3792
+ optionIndex: option.index,
3793
+ expectedReadback: option.label,
3794
+ });
3795
+ const enabled = options.filter(option => !option.disabled);
3796
+ const exactValue = enabled.filter(option => option.value === value);
3797
+ if (exactValue.length > 0)
3798
+ return resolved(exactValue[0]);
3799
+ const lookup = normalizeLookupKey(value);
3800
+ const normalizedValues = enabled.filter(option => normalizeLookupKey(option.value) === lookup);
3801
+ if (normalizedValues.length === 1)
3802
+ return resolved(normalizedValues[0]);
3803
+ if (normalizedValues.length > 1) {
3804
+ const distinct = new Set(normalizedValues.map(option => option.value));
3805
+ if (distinct.size === 1)
3806
+ return resolved(normalizedValues[0]);
3807
+ return {
3808
+ ok: false,
3809
+ error: `Option value "${value}" is ambiguous for field "${field.label}". Use an exact option value from optionDetails.`,
3810
+ };
3811
+ }
3812
+ const labelMatches = enabled.filter(option => normalizeLookupKey(option.label) === lookup);
3813
+ if (labelMatches.length === 1)
3814
+ return resolved(labelMatches[0]);
3815
+ if (labelMatches.length > 1) {
3816
+ const distinctValues = new Set(labelMatches.map(option => option.value));
3817
+ if (distinctValues.size === 1)
3818
+ return resolved(labelMatches[0]);
3819
+ return {
3820
+ ok: false,
3821
+ error: `Option label "${value}" maps to multiple submitted values for field "${field.label}". Use an exact option value from optionDetails.`,
3822
+ };
3823
+ }
3824
+ const disabledMatch = options.some(option => option.disabled && (option.value === value ||
3825
+ normalizeLookupKey(option.value) === lookup ||
3826
+ normalizeLookupKey(option.label) === lookup));
3827
+ if (disabledMatch) {
3828
+ return { ok: false, error: `Option "${value}" is disabled for field "${field.label}".` };
3829
+ }
3830
+ const available = enabled.slice(0, 12).map(option => `${option.label} (${option.value})`);
3831
+ return {
3832
+ ok: false,
3833
+ error: `Unknown option "${value}" for field "${field.label}".${available.length > 0 ? ` Available options: ${available.join(', ')}.` : ''}`,
3834
+ };
3835
+ }
3616
3836
  /**
3617
3837
  * Normalize a text value based on field format hints (placeholder, inputType, pattern).
3618
3838
  * Handles common date and phone format conversions.
@@ -3692,24 +3912,46 @@ function plannedFillInputsForField(field, value) {
3692
3912
  if (typeof value !== 'string')
3693
3913
  return { error: `Field "${field.label}" expects a string value` };
3694
3914
  const normalized = normalizeFieldValue(value, field.format);
3695
- return [{ kind: 'text', fieldId: field.id, fieldLabel: field.label, value: normalized }];
3915
+ return [{
3916
+ kind: 'text',
3917
+ fieldId: field.id,
3918
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
3919
+ fieldLabel: field.label,
3920
+ value: normalized,
3921
+ }];
3696
3922
  }
3697
3923
  if (field.kind === 'choice') {
3698
3924
  const coerced = coerceChoiceValue(field, value);
3699
- if (!coerced)
3925
+ if (coerced === null)
3700
3926
  return { error: `Field "${field.label}" expects a string value` };
3927
+ const resolvedChoice = field.choiceType === 'select'
3928
+ ? resolveNativeChoiceValue(field, coerced)
3929
+ : { ok: true, value: coerced };
3930
+ if (!resolvedChoice.ok)
3931
+ return { error: resolvedChoice.error };
3701
3932
  return [{
3702
3933
  kind: 'choice',
3703
3934
  fieldId: field.id,
3935
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
3704
3936
  fieldLabel: field.label,
3705
- value: coerced,
3937
+ value: resolvedChoice.value,
3938
+ ...(resolvedChoice.optionIndex !== undefined ? { optionIndex: resolvedChoice.optionIndex } : {}),
3939
+ ...(resolvedChoice.expectedReadback !== undefined ? { expectedReadback: resolvedChoice.expectedReadback } : {}),
3940
+ ...(field.choiceType === 'select' && field.optionDetails?.length ? { exact: true } : {}),
3706
3941
  ...(field.choiceType ? { choiceType: field.choiceType } : {}),
3707
3942
  }];
3708
3943
  }
3709
3944
  if (field.kind === 'toggle') {
3710
3945
  if (typeof value !== 'boolean')
3711
3946
  return { error: `Field "${field.label}" expects a boolean value` };
3712
- return [{ kind: 'toggle', fieldId: field.id, label: field.label, checked: value, controlType: field.controlType }];
3947
+ return [{
3948
+ kind: 'toggle',
3949
+ fieldId: field.id,
3950
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
3951
+ label: field.label,
3952
+ checked: value,
3953
+ controlType: field.controlType,
3954
+ }];
3713
3955
  }
3714
3956
  const selected = Array.isArray(value) ? value : typeof value === 'string' ? [value] : null;
3715
3957
  if (!selected || selected.length === 0)
@@ -3721,12 +3963,29 @@ function plannedFillInputsForField(field, value) {
3721
3963
  return field.options.map(option => ({
3722
3964
  kind: 'toggle',
3723
3965
  fieldId: field.id,
3966
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
3724
3967
  label: option,
3725
3968
  checked: selectedKeys.has(normalizeLookupKey(option)),
3726
3969
  controlType: 'checkbox',
3727
3970
  }));
3728
3971
  }
3729
- function planFormFill(schema, opts) {
3972
+ function unkeyedSchemaFieldIsAmbiguous(field, schemas) {
3973
+ if (field.fieldKey)
3974
+ return false;
3975
+ const matchingIds = new Set();
3976
+ const label = normalizeLookupKey(field.label);
3977
+ for (const schema of schemas) {
3978
+ for (const candidate of schema.fields) {
3979
+ if (normalizeLookupKey(candidate.label) === label)
3980
+ matchingIds.add(candidate.id);
3981
+ }
3982
+ }
3983
+ return matchingIds.size > 1;
3984
+ }
3985
+ function ambiguousFieldIdentityError(field) {
3986
+ 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.`;
3987
+ }
3988
+ function planFormFill(schema, opts, pageSchemas = [schema]) {
3730
3989
  const fieldById = new Map(schema.fields.map(field => [field.id, field]));
3731
3990
  const fieldsByLabel = new Map();
3732
3991
  for (const field of schema.fields) {
@@ -3743,6 +4002,9 @@ function planFormFill(schema, opts) {
3743
4002
  const field = fieldById.get(fieldId);
3744
4003
  if (!field)
3745
4004
  return { ok: false, error: `Unknown form field id ${fieldId}. Refresh geometra_form_schema and try again.` };
4005
+ if (unkeyedSchemaFieldIsAmbiguous(field, pageSchemas)) {
4006
+ return { ok: false, error: ambiguousFieldIdentityError(field) };
4007
+ }
3746
4008
  const next = plannedFillInputsForField(field, value);
3747
4009
  if ('error' in next)
3748
4010
  return { ok: false, error: next.error };
@@ -3758,6 +4020,9 @@ function planFormFill(schema, opts) {
3758
4020
  return { ok: false, error: `Label "${label}" is ambiguous in form ${schema.formId}. Use valuesById for this field.` };
3759
4021
  }
3760
4022
  const field = matches[0];
4023
+ if (unkeyedSchemaFieldIsAmbiguous(field, pageSchemas)) {
4024
+ return { ok: false, error: ambiguousFieldIdentityError(field) };
4025
+ }
3761
4026
  if (seenFieldIds.has(field.id)) {
3762
4027
  return { ok: false, error: `Field "${label}" was provided in both valuesById and valuesByLabel` };
3763
4028
  }
@@ -3780,36 +4045,78 @@ function isResolvedFillFieldInput(field) {
3780
4045
  }
3781
4046
  function resolveFillFieldInputs(session, fields) {
3782
4047
  const unresolved = fields.filter(field => !isResolvedFillFieldInput(field));
3783
- if (unresolved.length === 0) {
4048
+ const a11y = sessionA11y(session);
4049
+ if (unresolved.length === 0 && !a11y) {
3784
4050
  return { ok: true, fields: fields };
3785
4051
  }
3786
- const a11y = sessionA11y(session);
3787
- if (!a11y)
4052
+ if (!a11y) {
4053
+ if (unresolved.length === 0)
4054
+ return { ok: true, fields: fields };
3788
4055
  return { ok: false, error: 'No UI tree available to resolve fieldId entries from geometra_form_schema' };
4056
+ }
4057
+ const schemas = buildFormSchemas(a11y, { includeOptions: true, includeContext: 'always' });
3789
4058
  const fieldById = new Map();
3790
- for (const schema of buildFormSchemas(a11y, { includeOptions: true, includeContext: 'always' })) {
4059
+ for (const schema of schemas) {
3791
4060
  for (const field of schema.fields)
3792
4061
  fieldById.set(field.id, field);
3793
4062
  }
3794
4063
  const resolved = [];
3795
4064
  for (const field of fields) {
3796
4065
  if (isResolvedFillFieldInput(field)) {
3797
- if (field.kind === 'choice' && field.fieldId && field.choiceType === undefined) {
3798
- const schemaField = fieldById.get(field.fieldId);
4066
+ const schemaField = field.fieldId ? fieldById.get(field.fieldId) : undefined;
4067
+ if (field.fieldId && !schemaField) {
4068
+ return { ok: false, error: `Unknown form field id ${field.fieldId}. Refresh geometra_form_schema and try again.` };
4069
+ }
4070
+ if (schemaField) {
4071
+ const expectedKind = field.kind === 'file' ? 'file' : field.kind;
4072
+ if (field.kind !== 'file' && schemaField.kind !== expectedKind) {
4073
+ return { ok: false, error: `Field id ${field.fieldId} resolves to kind "${schemaField.kind}", not ${field.kind}.` };
4074
+ }
4075
+ }
4076
+ const suppliedLabel = field.kind === 'toggle' ? field.label : field.fieldLabel;
4077
+ const distinctLabelMatches = new Map();
4078
+ for (const candidate of fieldById.values()) {
4079
+ if (normalizeLookupKey(candidate.label) === normalizeLookupKey(suppliedLabel)) {
4080
+ distinctLabelMatches.set(candidate.id, candidate);
4081
+ }
4082
+ }
4083
+ const effectiveFieldKey = field.fieldKey ?? schemaField?.fieldKey;
4084
+ if (!effectiveFieldKey && distinctLabelMatches.size > 1) {
4085
+ return {
4086
+ ok: false,
4087
+ error: schemaField
4088
+ ? ambiguousFieldIdentityError(schemaField)
4089
+ : `Field label "${suppliedLabel}" is ambiguous and no exact field identity was supplied. Geometra refused to guess.`,
4090
+ };
4091
+ }
4092
+ if (field.kind === 'choice' && schemaField?.kind === 'choice') {
4093
+ const resolvedChoice = schemaField.choiceType === 'select'
4094
+ ? resolveNativeChoiceValue(schemaField, field.value)
4095
+ : { ok: true, value: field.value };
4096
+ if (!resolvedChoice.ok)
4097
+ return { ok: false, error: resolvedChoice.error };
3799
4098
  resolved.push({
3800
4099
  ...field,
3801
- ...(schemaField?.kind === 'choice' && schemaField.choiceType ? { choiceType: schemaField.choiceType } : {}),
4100
+ value: resolvedChoice.value,
4101
+ ...(resolvedChoice.optionIndex !== undefined ? { optionIndex: resolvedChoice.optionIndex } : {}),
4102
+ ...(resolvedChoice.expectedReadback !== undefined ? { expectedReadback: resolvedChoice.expectedReadback } : {}),
4103
+ ...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
4104
+ ...(field.choiceType === undefined && schemaField.choiceType ? { choiceType: schemaField.choiceType } : {}),
4105
+ ...(schemaField.choiceType === 'select' && schemaField.optionDetails?.length ? { exact: true } : {}),
3802
4106
  });
3803
4107
  }
3804
- else if (field.kind === 'toggle' && field.fieldId && field.controlType === undefined) {
3805
- const schemaField = fieldById.get(field.fieldId);
4108
+ else if (field.kind === 'toggle' && schemaField?.kind === 'toggle') {
3806
4109
  resolved.push({
3807
4110
  ...field,
3808
- ...(schemaField?.kind === 'toggle' && schemaField.controlType ? { controlType: schemaField.controlType } : {}),
4111
+ ...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
4112
+ ...(field.controlType === undefined && schemaField.controlType ? { controlType: schemaField.controlType } : {}),
3809
4113
  });
3810
4114
  }
3811
4115
  else {
3812
- resolved.push(field);
4116
+ resolved.push({
4117
+ ...field,
4118
+ ...(schemaField?.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
4119
+ });
3813
4120
  }
3814
4121
  continue;
3815
4122
  }
@@ -3825,21 +4132,38 @@ function resolveFillFieldInputs(session, fields) {
3825
4132
  if (!schemaField) {
3826
4133
  return { ok: false, error: `Unknown form field id ${field.fieldId}. Refresh geometra_form_schema and try again.` };
3827
4134
  }
4135
+ if (unkeyedSchemaFieldIsAmbiguous(schemaField, schemas)) {
4136
+ return { ok: false, error: ambiguousFieldIdentityError(schemaField) };
4137
+ }
3828
4138
  if (field.kind === 'text') {
3829
4139
  if (schemaField.kind !== 'text') {
3830
4140
  return { ok: false, error: `Field id ${field.fieldId} resolves to kind "${schemaField.kind}", not text.` };
3831
4141
  }
3832
- resolved.push({ ...field, fieldLabel: schemaField.label });
4142
+ resolved.push({
4143
+ ...field,
4144
+ ...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
4145
+ fieldLabel: schemaField.label,
4146
+ });
3833
4147
  continue;
3834
4148
  }
3835
4149
  if (field.kind === 'choice') {
3836
4150
  if (schemaField.kind !== 'choice') {
3837
4151
  return { ok: false, error: `Field id ${field.fieldId} resolves to kind "${schemaField.kind}", not choice.` };
3838
4152
  }
4153
+ const resolvedChoice = schemaField.choiceType === 'select'
4154
+ ? resolveNativeChoiceValue(schemaField, field.value)
4155
+ : { ok: true, value: field.value };
4156
+ if (!resolvedChoice.ok)
4157
+ return { ok: false, error: resolvedChoice.error };
3839
4158
  resolved.push({
3840
4159
  ...field,
4160
+ ...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
3841
4161
  fieldLabel: schemaField.label,
4162
+ value: resolvedChoice.value,
4163
+ ...(resolvedChoice.optionIndex !== undefined ? { optionIndex: resolvedChoice.optionIndex } : {}),
4164
+ ...(resolvedChoice.expectedReadback !== undefined ? { expectedReadback: resolvedChoice.expectedReadback } : {}),
3842
4165
  ...(field.choiceType === undefined && schemaField.choiceType ? { choiceType: schemaField.choiceType } : {}),
4166
+ ...(schemaField.choiceType === 'select' && schemaField.optionDetails?.length ? { exact: true } : {}),
3843
4167
  });
3844
4168
  continue;
3845
4169
  }
@@ -3849,6 +4173,7 @@ function resolveFillFieldInputs(session, fields) {
3849
4173
  }
3850
4174
  resolved.push({
3851
4175
  ...field,
4176
+ ...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
3852
4177
  label: schemaField.label,
3853
4178
  ...(field.controlType === undefined && schemaField.controlType ? { controlType: schemaField.controlType } : {}),
3854
4179
  });
@@ -3906,23 +4231,13 @@ function parseProxyFillAckResult(value) {
3906
4231
  ...(invalidFields && invalidFields.length > 0 ? { invalidFields } : {}),
3907
4232
  };
3908
4233
  }
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
4234
  async function tryBatchedResolvedFields(session, fields, detail) {
3922
4235
  let batchAckResult;
3923
4236
  try {
3924
4237
  const startRevision = session.updateRevision;
3925
- const wait = await sendFillFields(session, fields);
4238
+ const wait = await sendFillFields(session, toProxyFillFields(fields));
4239
+ if (wait.status === 'timed_out')
4240
+ return { ok: false };
3926
4241
  const ackResult = parseProxyFillAckResult(wait.result);
3927
4242
  batchAckResult = ackResult;
3928
4243
  if (ackResult && ackResult.invalidCount === 0) {
@@ -3972,27 +4287,33 @@ async function waitForBatchFieldReadback(session, fields) {
3972
4287
  function batchFieldReadbackMatches(a11y, field) {
3973
4288
  switch (field.kind) {
3974
4289
  case 'text': {
3975
- const matches = findNodes(a11y, { name: field.fieldLabel, role: 'textbox' });
4290
+ const matches = fieldReadbackNodes(a11y, field.fieldLabel, ['textbox'], field.fieldKey);
3976
4291
  return matches.some(match => normalizeLookupKey(match.value ?? '') === normalizeLookupKey(field.value));
3977
4292
  }
3978
4293
  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
- ];
4294
+ const directMatches = fieldReadbackNodes(a11y, field.fieldLabel, ['combobox', 'textbox', 'button'], field.fieldKey);
3984
4295
  if (directMatches.length === 0)
3985
- return true;
3986
- return directMatches.some(match => normalizeLookupKey(match.value ?? '') === normalizeLookupKey(field.value));
4296
+ return field.fieldKey === undefined;
4297
+ if (field.optionIndex !== undefined) {
4298
+ return directMatches.some(match => {
4299
+ const selected = match.meta?.options?.find(option => option.selected);
4300
+ return selected
4301
+ ? selected.index === field.optionIndex && selected.value === field.value
4302
+ : false;
4303
+ });
4304
+ }
4305
+ const expected = [field.value, field.expectedReadback]
4306
+ .filter((value) => value !== undefined)
4307
+ .map(normalizeLookupKey);
4308
+ return directMatches.some(match => expected.includes(normalizeLookupKey(match.value ?? '')));
3987
4309
  }
3988
4310
  case 'toggle':
3989
4311
  return true;
3990
4312
  case 'file': {
3991
- const matches = [
3992
- ...findNodes(a11y, { name: field.fieldLabel, role: 'textbox' }),
3993
- ...findNodes(a11y, { name: field.fieldLabel, role: 'button' }),
3994
- ];
3995
- return matches.length === 0 || matches.some(match => Boolean(match.value && match.value.trim()));
4313
+ const matches = fieldReadbackNodes(a11y, field.fieldLabel, ['textbox', 'button'], field.fieldKey);
4314
+ return field.fieldKey
4315
+ ? matches.some(match => Boolean(match.value && match.value.trim()))
4316
+ : matches.length === 0 || matches.some(match => Boolean(match.value && match.value.trim()));
3996
4317
  }
3997
4318
  }
3998
4319
  }
@@ -4013,6 +4334,14 @@ function canDeferInitialFrameForRunActions(actions) {
4013
4334
  return false;
4014
4335
  return first.type === 'fill_fields';
4015
4336
  }
4337
+ function assertBatchActionConfirmed(actionType, wait) {
4338
+ assertActionWaitConfirmed(`${actionType} action`, wait);
4339
+ }
4340
+ function assertActionWaitConfirmed(actionName, wait) {
4341
+ if (wait.status === 'timed_out') {
4342
+ throw new Error(`${actionName} timed out after ${wait.timeoutMs}ms; its outcome is unconfirmed.`);
4343
+ }
4344
+ }
4016
4345
  async function executeBatchAction(session, action, detail, includeSteps) {
4017
4346
  switch (action.type) {
4018
4347
  case 'click': {
@@ -4046,7 +4375,31 @@ async function executeBatchAction(session, action, detail, includeSteps) {
4046
4375
  });
4047
4376
  if (!resolved.ok)
4048
4377
  throw new Error(clickFallbackErrorMessage(resolved));
4378
+ const waitFilter = action.waitFor ? {
4379
+ id: action.waitFor.id,
4380
+ role: action.waitFor.role,
4381
+ name: action.waitFor.name,
4382
+ text: action.waitFor.text,
4383
+ contextText: action.waitFor.contextText,
4384
+ promptText: action.waitFor.promptText,
4385
+ sectionText: action.waitFor.sectionText,
4386
+ itemText: action.waitFor.itemText,
4387
+ value: action.waitFor.value,
4388
+ checked: action.waitFor.checked,
4389
+ disabled: action.waitFor.disabled,
4390
+ focused: action.waitFor.focused,
4391
+ selected: action.waitFor.selected,
4392
+ expanded: action.waitFor.expanded,
4393
+ invalid: action.waitFor.invalid,
4394
+ required: action.waitFor.required,
4395
+ busy: action.waitFor.busy,
4396
+ } : undefined;
4397
+ const waitEvidenceRoot = sessionA11y(session) ?? before;
4398
+ const beforeWaitEvidence = waitFilter && waitEvidenceRoot
4399
+ ? captureWaitEvidence(waitEvidenceRoot, waitFilter)
4400
+ : undefined;
4049
4401
  const wait = await sendClick(session, resolved.value.x, resolved.value.y, action.timeoutMs);
4402
+ assertBatchActionConfirmed(action.type, wait);
4050
4403
  const targetSummary = resolved.value.target
4051
4404
  ? `Clicked ${describeFormattedNode(resolved.value.target)} at (${resolved.value.x}, ${resolved.value.y}).`
4052
4405
  : `Clicked at (${resolved.value.x}, ${resolved.value.y}).`;
@@ -4054,33 +4407,19 @@ async function executeBatchAction(session, action, detail, includeSteps) {
4054
4407
  let postWaitCompact;
4055
4408
  if (action.waitFor) {
4056
4409
  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
- },
4410
+ filter: waitFilter,
4076
4411
  present: action.waitFor.present ?? true,
4077
4412
  timeoutMs: action.waitFor.timeoutMs ?? 10_000,
4078
4413
  });
4079
4414
  if (!postWait.ok) {
4080
4415
  throw new Error(`Post-click wait failed after ${targetSummary.toLowerCase()}\n${postWait.error}`);
4081
4416
  }
4417
+ const fresh = waitConditionHasFreshEvidence(beforeWaitEvidence, sessionA11y(session), postWait.value);
4418
+ if (!fresh) {
4419
+ throw new Error(`Post-click wait matched only pre-existing evidence after ${targetSummary.toLowerCase()}; the click outcome is unconfirmed.`);
4420
+ }
4082
4421
  postWaitSummary = `Post-click ${waitConditionSuccessLine(postWait.value)}`;
4083
- postWaitCompact = waitConditionCompact(postWait.value);
4422
+ postWaitCompact = { ...waitConditionCompact(postWait.value), fresh: true };
4084
4423
  }
4085
4424
  return {
4086
4425
  summary: [targetSummary, postActionSummary(session, before, wait, detail), postWaitSummary].filter(Boolean).join('\n'),
@@ -4096,6 +4435,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
4096
4435
  case 'type': {
4097
4436
  const before = sessionA11y(session);
4098
4437
  const wait = await sendType(session, action.text, action.timeoutMs);
4438
+ assertBatchActionConfirmed(action.type, wait);
4099
4439
  return {
4100
4440
  summary: `Typed "${action.text}".\n${postActionSummary(session, before, wait, detail)}`,
4101
4441
  compact: {
@@ -4107,6 +4447,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
4107
4447
  case 'key': {
4108
4448
  const before = sessionA11y(session);
4109
4449
  const wait = await sendKey(session, action.key, { shift: action.shift, ctrl: action.ctrl, meta: action.meta, alt: action.alt }, action.timeoutMs);
4450
+ assertBatchActionConfirmed(action.type, wait);
4110
4451
  return {
4111
4452
  summary: `Pressed ${formatKeyCombo(action.key, action)}.\n${postActionSummary(session, before, wait, detail)}`,
4112
4453
  compact: {
@@ -4124,6 +4465,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
4124
4465
  strategy: action.strategy,
4125
4466
  drop: action.dropX !== undefined && action.dropY !== undefined ? { x: action.dropX, y: action.dropY } : undefined,
4126
4467
  }, action.timeoutMs ?? 8_000);
4468
+ assertBatchActionConfirmed(action.type, wait);
4127
4469
  return {
4128
4470
  summary: `Uploaded ${action.paths.length} file(s).\n${postActionSummary(session, before, wait, detail)}`,
4129
4471
  compact: {
@@ -4140,18 +4482,25 @@ async function executeBatchAction(session, action, detail, includeSteps) {
4140
4482
  const wait = await sendListboxPick(session, action.label, {
4141
4483
  exact: action.exact,
4142
4484
  open: action.openX !== undefined && action.openY !== undefined ? { x: action.openX, y: action.openY } : undefined,
4485
+ fieldId: action.fieldId,
4486
+ fieldKey: action.fieldKey,
4143
4487
  fieldLabel: action.fieldLabel,
4144
4488
  query: action.query,
4145
4489
  }, action.timeoutMs);
4490
+ assertBatchActionConfirmed(action.type, wait);
4146
4491
  const summary = postActionSummary(session, before, wait, detail);
4147
- const fieldSummary = action.fieldLabel ? summarizeFieldLabelState(session, action.fieldLabel) : undefined;
4492
+ const fieldSummary = action.fieldLabel
4493
+ ? summarizeFieldLabelState(session, action.fieldLabel, action.fieldKey)
4494
+ : undefined;
4148
4495
  return {
4149
4496
  summary: [`Picked listbox option "${action.label}".`, fieldSummary, summary].filter(Boolean).join('\n'),
4150
4497
  compact: {
4151
4498
  label: action.label,
4499
+ ...(action.fieldId ? { fieldId: action.fieldId } : {}),
4500
+ ...(action.fieldKey ? { fieldKey: action.fieldKey } : {}),
4152
4501
  ...(action.fieldLabel ? { fieldLabel: action.fieldLabel } : {}),
4153
4502
  ...waitStatusPayload(wait),
4154
- ...(action.fieldLabel ? { readback: fieldStatePayload(session, action.fieldLabel) } : {}),
4503
+ ...(action.fieldLabel ? { readback: fieldStatePayload(session, action.fieldLabel, action.fieldKey) } : {}),
4155
4504
  },
4156
4505
  };
4157
4506
  }
@@ -4165,6 +4514,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
4165
4514
  label: action.label,
4166
4515
  index: action.index,
4167
4516
  }, action.timeoutMs);
4517
+ assertBatchActionConfirmed(action.type, wait);
4168
4518
  return {
4169
4519
  summary: `Selected option.\n${postActionSummary(session, before, wait, detail)}`,
4170
4520
  compact: {
@@ -4182,13 +4532,20 @@ async function executeBatchAction(session, action, detail, includeSteps) {
4182
4532
  checked: action.checked,
4183
4533
  exact: action.exact,
4184
4534
  controlType: action.controlType,
4535
+ ...(action.fieldKey ? { fieldKey: action.fieldKey } : {}),
4536
+ contextText: action.contextText,
4537
+ sectionText: action.sectionText,
4185
4538
  }, action.timeoutMs);
4539
+ assertBatchActionConfirmed(action.type, wait);
4186
4540
  return {
4187
4541
  summary: `Set ${action.controlType ?? 'checkbox/radio'} "${action.label}" to ${String(action.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`,
4188
4542
  compact: {
4189
4543
  label: action.label,
4190
4544
  checked: action.checked ?? true,
4191
4545
  ...(action.controlType ? { controlType: action.controlType } : {}),
4546
+ ...(action.fieldKey ? { fieldKey: action.fieldKey } : {}),
4547
+ ...(action.contextText ? { contextText: action.contextText } : {}),
4548
+ ...(action.sectionText ? { sectionText: action.sectionText } : {}),
4192
4549
  ...waitStatusPayload(wait),
4193
4550
  },
4194
4551
  };
@@ -4200,6 +4557,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
4200
4557
  x: action.x,
4201
4558
  y: action.y,
4202
4559
  }, action.timeoutMs);
4560
+ assertBatchActionConfirmed(action.type, wait);
4203
4561
  return {
4204
4562
  summary: `Wheel delta (${action.deltaX ?? 0}, ${action.deltaY}).\n${postActionSummary(session, before, wait, detail)}`,
4205
4563
  compact: {
@@ -4468,13 +4826,27 @@ function verifyFormFills(session, planned) {
4468
4826
  if (p.field.kind === 'toggle' || p.field.kind === 'file')
4469
4827
  continue;
4470
4828
  const label = p.field.fieldLabel;
4471
- const expected = p.field.kind === 'text' ? p.field.value : p.field.value;
4472
- const matches = [
4473
- ...findNodes(a11y, { name: label, role: 'textbox' }),
4474
- ...findNodes(a11y, { name: label, role: 'combobox' }),
4475
- ];
4829
+ const expected = p.field.kind === 'choice'
4830
+ ? p.field.expectedReadback ?? p.field.value
4831
+ : p.field.value;
4832
+ const matches = fieldReadbackNodes(a11y, label, ['textbox', 'combobox'], p.field.fieldKey);
4476
4833
  const match = matches[0];
4477
4834
  const actual = match?.value?.trim();
4835
+ if (p.field.kind === 'choice' && p.field.optionIndex !== undefined && match?.meta?.options) {
4836
+ const selected = match.meta.options.find(option => option.selected);
4837
+ if (selected?.index === p.field.optionIndex && selected.value === p.field.value) {
4838
+ verified++;
4839
+ }
4840
+ else {
4841
+ mismatches.push({
4842
+ fieldLabel: label,
4843
+ expected,
4844
+ actual: selected?.label ?? actual,
4845
+ ...(p.field.fieldId ? { fieldId: p.field.fieldId } : {}),
4846
+ });
4847
+ }
4848
+ continue;
4849
+ }
4478
4850
  if (!actual || !expected) {
4479
4851
  mismatches.push({ fieldLabel: label, expected, actual, ...(p.field.fieldId ? { fieldId: p.field.fieldId } : {}) });
4480
4852
  }
@@ -4494,10 +4866,12 @@ async function executeFillField(session, field, detail) {
4494
4866
  const wait = await sendFieldText(session, field.fieldLabel, field.value, {
4495
4867
  exact: field.exact,
4496
4868
  fieldId: field.fieldId,
4869
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
4497
4870
  typingDelayMs: field.typingDelayMs,
4498
4871
  imeFriendly: field.imeFriendly,
4499
4872
  }, field.timeoutMs);
4500
- const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
4873
+ assertActionWaitConfirmed(`Text fill for "${field.fieldLabel}"`, wait);
4874
+ const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel, field.fieldKey);
4501
4875
  return {
4502
4876
  summary: [
4503
4877
  `Filled text field "${field.fieldLabel}".`,
@@ -4506,17 +4880,26 @@ async function executeFillField(session, field, detail) {
4506
4880
  ].filter(Boolean).join('\n'),
4507
4881
  compact: {
4508
4882
  ...(field.fieldId ? { fieldId: field.fieldId } : {}),
4883
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
4509
4884
  fieldLabel: field.fieldLabel,
4510
4885
  ...compactTextValue(field.value),
4511
4886
  ...waitStatusPayload(wait),
4512
- readback: fieldStatePayload(session, field.fieldLabel),
4887
+ readback: fieldStatePayload(session, field.fieldLabel, field.fieldKey),
4513
4888
  },
4514
4889
  };
4515
4890
  }
4516
4891
  case 'choice': {
4517
4892
  const before = sessionA11y(session);
4518
- const wait = await sendFieldChoice(session, field.fieldLabel, field.value, { exact: field.exact, query: field.query, choiceType: field.choiceType, fieldId: field.fieldId }, field.timeoutMs);
4519
- const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
4893
+ const wait = await sendFieldChoice(session, field.fieldLabel, field.value, {
4894
+ exact: field.exact,
4895
+ query: field.query,
4896
+ choiceType: field.choiceType,
4897
+ fieldId: field.fieldId,
4898
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
4899
+ ...(field.optionIndex !== undefined ? { optionIndex: field.optionIndex } : {}),
4900
+ }, field.timeoutMs);
4901
+ assertActionWaitConfirmed(`Choice fill for "${field.fieldLabel}"`, wait);
4902
+ const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel, field.fieldKey);
4520
4903
  return {
4521
4904
  summary: [
4522
4905
  `Set choice field "${field.fieldLabel}" to "${field.value}".`,
@@ -4525,21 +4908,31 @@ async function executeFillField(session, field, detail) {
4525
4908
  ].filter(Boolean).join('\n'),
4526
4909
  compact: {
4527
4910
  ...(field.fieldId ? { fieldId: field.fieldId } : {}),
4911
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
4528
4912
  fieldLabel: field.fieldLabel,
4529
4913
  value: field.value,
4914
+ ...(field.optionIndex !== undefined ? { optionIndex: field.optionIndex } : {}),
4530
4915
  ...(field.choiceType ? { choiceType: field.choiceType } : {}),
4531
4916
  ...waitStatusPayload(wait),
4532
- readback: fieldStatePayload(session, field.fieldLabel),
4917
+ readback: fieldStatePayload(session, field.fieldLabel, field.fieldKey),
4533
4918
  },
4534
4919
  };
4535
4920
  }
4536
4921
  case 'toggle': {
4537
4922
  const before = sessionA11y(session);
4538
- const wait = await sendSetChecked(session, field.label, { checked: field.checked, exact: field.exact, controlType: field.controlType }, field.timeoutMs);
4923
+ const wait = await sendSetChecked(session, field.label, {
4924
+ checked: field.checked,
4925
+ exact: field.exact,
4926
+ controlType: field.controlType,
4927
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
4928
+ }, field.timeoutMs);
4929
+ assertActionWaitConfirmed(`Toggle fill for "${field.label}"`, wait);
4539
4930
  return {
4540
4931
  summary: `Set ${field.controlType ?? 'checkbox/radio'} "${field.label}" to ${String(field.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`,
4541
4932
  compact: {
4542
4933
  label: field.label,
4934
+ ...(field.fieldId ? { fieldId: field.fieldId } : {}),
4935
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
4543
4936
  checked: field.checked ?? true,
4544
4937
  ...(field.controlType ? { controlType: field.controlType } : {}),
4545
4938
  ...waitStatusPayload(wait),
@@ -4548,8 +4941,14 @@ async function executeFillField(session, field, detail) {
4548
4941
  }
4549
4942
  case 'file': {
4550
4943
  const before = sessionA11y(session);
4551
- const wait = await sendFileUpload(session, field.paths, { fieldLabel: field.fieldLabel, exact: field.exact }, field.timeoutMs ?? 8_000);
4552
- const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
4944
+ const wait = await sendFileUpload(session, field.paths, {
4945
+ ...(field.fieldId ? { fieldId: field.fieldId } : {}),
4946
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
4947
+ fieldLabel: field.fieldLabel,
4948
+ exact: field.exact,
4949
+ }, field.timeoutMs ?? 8_000);
4950
+ assertActionWaitConfirmed(`File fill for "${field.fieldLabel}"`, wait);
4951
+ const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel, field.fieldKey);
4553
4952
  return {
4554
4953
  summary: [
4555
4954
  `Uploaded ${field.paths.length} file(s) to "${field.fieldLabel}".`,
@@ -4558,9 +4957,11 @@ async function executeFillField(session, field, detail) {
4558
4957
  ].filter(Boolean).join('\n'),
4559
4958
  compact: {
4560
4959
  fieldLabel: field.fieldLabel,
4960
+ ...(field.fieldId ? { fieldId: field.fieldId } : {}),
4961
+ ...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
4561
4962
  fileCount: field.paths.length,
4562
4963
  ...waitStatusPayload(wait),
4563
- readback: fieldStatePayload(session, field.fieldLabel),
4964
+ readback: fieldStatePayload(session, field.fieldLabel, field.fieldKey),
4564
4965
  },
4565
4966
  };
4566
4967
  }
@@ -4726,8 +5127,8 @@ export function findNodes(node, filter) {
4726
5127
  walk(node);
4727
5128
  return matches;
4728
5129
  }
4729
- function summarizeFieldLabelState(session, fieldLabel) {
4730
- const payload = fieldStatePayload(session, fieldLabel);
5130
+ function summarizeFieldLabelState(session, fieldLabel, fieldKey) {
5131
+ const payload = fieldStatePayload(session, fieldLabel, fieldKey);
4731
5132
  if (!payload)
4732
5133
  return undefined;
4733
5134
  const parts = [`Field "${fieldLabel}"`];