@geometra/mcp 1.52.0 → 1.54.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/__tests__/server-batch-results.test.js +273 -0
- package/dist/server.js +265 -5
- package/package.json +1 -1
|
@@ -982,6 +982,279 @@ describe('batch MCP result shaping', () => {
|
|
|
982
982
|
});
|
|
983
983
|
});
|
|
984
984
|
});
|
|
985
|
+
describe('submit_form tool', () => {
|
|
986
|
+
beforeEach(() => {
|
|
987
|
+
vi.clearAllMocks();
|
|
988
|
+
resetMockSessionCaches();
|
|
989
|
+
mockState.formSchemas = [];
|
|
990
|
+
});
|
|
991
|
+
it('combines fill + submit-click + post-submit wait in one call', async () => {
|
|
992
|
+
const handler = getToolHandler('geometra_submit_form');
|
|
993
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
994
|
+
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
995
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
996
|
+
children: [
|
|
997
|
+
node('textbox', 'Full name', { value: '', path: [0] }),
|
|
998
|
+
node('textbox', 'Email', { value: '', path: [1] }),
|
|
999
|
+
node('button', 'Submit application', {
|
|
1000
|
+
bounds: { x: 60, y: 480, width: 180, height: 40 },
|
|
1001
|
+
path: [2],
|
|
1002
|
+
}),
|
|
1003
|
+
],
|
|
1004
|
+
});
|
|
1005
|
+
mockState.formSchemas = [{
|
|
1006
|
+
formId: 'fm:0',
|
|
1007
|
+
name: 'Application',
|
|
1008
|
+
fieldCount: 2,
|
|
1009
|
+
requiredCount: 2,
|
|
1010
|
+
invalidCount: 2,
|
|
1011
|
+
fields: [
|
|
1012
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Full name' },
|
|
1013
|
+
{ id: 'ff:0.1', kind: 'text', label: 'Email' },
|
|
1014
|
+
],
|
|
1015
|
+
}];
|
|
1016
|
+
mockState.sendFillFields.mockImplementationOnce(async () => ({
|
|
1017
|
+
status: 'acknowledged',
|
|
1018
|
+
timeoutMs: 6000,
|
|
1019
|
+
result: { invalidCount: 0, alertCount: 0, dialogCount: 0, busyCount: 0, pageUrl: 'https://jobs.example.com/application' },
|
|
1020
|
+
}));
|
|
1021
|
+
mockState.sendClick.mockImplementationOnce(async () => {
|
|
1022
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
1023
|
+
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
1024
|
+
meta: { pageUrl: 'https://jobs.example.com/confirm', scrollX: 0, scrollY: 0 },
|
|
1025
|
+
children: [
|
|
1026
|
+
node('dialog', 'Application submitted', {
|
|
1027
|
+
bounds: { x: 240, y: 140, width: 420, height: 260 },
|
|
1028
|
+
path: [0],
|
|
1029
|
+
}),
|
|
1030
|
+
],
|
|
1031
|
+
});
|
|
1032
|
+
bumpMockUiRevision();
|
|
1033
|
+
return { status: 'updated', timeoutMs: 2000 };
|
|
1034
|
+
});
|
|
1035
|
+
const result = await handler({
|
|
1036
|
+
valuesByLabel: { 'Full name': 'Taylor Applicant', Email: 'taylor@example.com' },
|
|
1037
|
+
submit: { role: 'button', name: 'Submit application' },
|
|
1038
|
+
waitFor: { role: 'dialog', name: 'Application submitted', timeoutMs: 5000 },
|
|
1039
|
+
detail: 'minimal',
|
|
1040
|
+
});
|
|
1041
|
+
const payload = JSON.parse(result.content[0].text);
|
|
1042
|
+
expect(payload).toMatchObject({
|
|
1043
|
+
completed: true,
|
|
1044
|
+
fill: { fieldCount: 2, formId: 'fm:0' },
|
|
1045
|
+
submit: { target: { role: 'button', name: 'Submit application' } },
|
|
1046
|
+
waitFor: { present: true, matchCount: 1 },
|
|
1047
|
+
navigated: true,
|
|
1048
|
+
afterUrl: 'https://jobs.example.com/confirm',
|
|
1049
|
+
});
|
|
1050
|
+
expect(mockState.sendFillFields).toHaveBeenCalledTimes(1);
|
|
1051
|
+
expect(mockState.sendClick).toHaveBeenCalledTimes(1);
|
|
1052
|
+
});
|
|
1053
|
+
it('rejects missing values when skipFill is false', async () => {
|
|
1054
|
+
const handler = getToolHandler('geometra_submit_form');
|
|
1055
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
1056
|
+
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
1057
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
1058
|
+
children: [
|
|
1059
|
+
node('button', 'Submit', { bounds: { x: 60, y: 480, width: 100, height: 40 }, path: [0] }),
|
|
1060
|
+
],
|
|
1061
|
+
});
|
|
1062
|
+
const result = await handler({ detail: 'minimal' });
|
|
1063
|
+
expect(result.content[0].text).toContain('Provide at least one value');
|
|
1064
|
+
});
|
|
1065
|
+
it('skipFill: true goes straight to submit + wait', async () => {
|
|
1066
|
+
const handler = getToolHandler('geometra_submit_form');
|
|
1067
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
1068
|
+
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
1069
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
1070
|
+
children: [
|
|
1071
|
+
node('button', 'Submit', { bounds: { x: 60, y: 480, width: 100, height: 40 }, path: [0] }),
|
|
1072
|
+
],
|
|
1073
|
+
});
|
|
1074
|
+
mockState.sendClick.mockImplementationOnce(async () => {
|
|
1075
|
+
bumpMockUiRevision();
|
|
1076
|
+
return { status: 'updated', timeoutMs: 2000 };
|
|
1077
|
+
});
|
|
1078
|
+
const result = await handler({
|
|
1079
|
+
skipFill: true,
|
|
1080
|
+
submit: { role: 'button', name: 'Submit' },
|
|
1081
|
+
detail: 'minimal',
|
|
1082
|
+
});
|
|
1083
|
+
const payload = JSON.parse(result.content[0].text);
|
|
1084
|
+
expect(payload).toMatchObject({ completed: true });
|
|
1085
|
+
expect(payload).not.toHaveProperty('fill');
|
|
1086
|
+
expect(mockState.sendFillFields).not.toHaveBeenCalled();
|
|
1087
|
+
expect(mockState.sendClick).toHaveBeenCalledTimes(1);
|
|
1088
|
+
});
|
|
1089
|
+
});
|
|
1090
|
+
describe('fill transparent fallback', () => {
|
|
1091
|
+
beforeEach(() => {
|
|
1092
|
+
vi.clearAllMocks();
|
|
1093
|
+
resetMockSessionCaches();
|
|
1094
|
+
});
|
|
1095
|
+
it('geometra_run_actions aggregates step-level fill fallback metadata into top-level fallbacks', async () => {
|
|
1096
|
+
const handler = getToolHandler('geometra_run_actions');
|
|
1097
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
1098
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
1099
|
+
children: [
|
|
1100
|
+
node('textbox', 'Full name', { value: '', path: [0] }),
|
|
1101
|
+
],
|
|
1102
|
+
});
|
|
1103
|
+
// Force the batched path to reject with a recoverable error so the
|
|
1104
|
+
// fill_fields step falls through to the sequential loop and tags the step.
|
|
1105
|
+
mockState.sendFillFields.mockRejectedValue(new Error('Unsupported client message type "fillFields"'));
|
|
1106
|
+
// includeSteps:false makes the fill_fields step handler prefer the batched
|
|
1107
|
+
// fast-path. When that path is unavailable, the step flips to sequential
|
|
1108
|
+
// and emits fallback metadata that run_actions lifts into the top-level
|
|
1109
|
+
// `fallbacks` array.
|
|
1110
|
+
const result = await handler({
|
|
1111
|
+
actions: [
|
|
1112
|
+
{
|
|
1113
|
+
type: 'fill_fields',
|
|
1114
|
+
fields: [{ kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' }],
|
|
1115
|
+
},
|
|
1116
|
+
],
|
|
1117
|
+
stopOnError: true,
|
|
1118
|
+
includeSteps: false,
|
|
1119
|
+
detail: 'minimal',
|
|
1120
|
+
});
|
|
1121
|
+
const payload = JSON.parse(result.content[0].text);
|
|
1122
|
+
expect(payload).toMatchObject({
|
|
1123
|
+
completed: true,
|
|
1124
|
+
stepCount: 1,
|
|
1125
|
+
successCount: 1,
|
|
1126
|
+
fallbacks: [
|
|
1127
|
+
{ stepIndex: 0, type: 'fill_fields', attempted: true, used: true, reason: 'batched-unavailable', attempts: 2 },
|
|
1128
|
+
],
|
|
1129
|
+
});
|
|
1130
|
+
});
|
|
1131
|
+
it('geometra_fill_fields surfaces fallback metadata when the batched path is unavailable', async () => {
|
|
1132
|
+
const handler = getToolHandler('geometra_fill_fields');
|
|
1133
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
1134
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
1135
|
+
children: [
|
|
1136
|
+
node('textbox', 'Full name', { value: '', path: [0] }),
|
|
1137
|
+
],
|
|
1138
|
+
});
|
|
1139
|
+
// Force the batched path to throw a recoverable error so the handler
|
|
1140
|
+
// falls through to the sequential loop and tags the fallback.
|
|
1141
|
+
mockState.sendFillFields.mockRejectedValueOnce(new Error('Unsupported client message type "fillFields"'));
|
|
1142
|
+
const result = await handler({
|
|
1143
|
+
fields: [{ kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' }],
|
|
1144
|
+
stopOnError: true,
|
|
1145
|
+
failOnInvalid: false,
|
|
1146
|
+
includeSteps: false,
|
|
1147
|
+
detail: 'minimal',
|
|
1148
|
+
});
|
|
1149
|
+
const payload = JSON.parse(result.content[0].text);
|
|
1150
|
+
expect(payload).toMatchObject({
|
|
1151
|
+
completed: true,
|
|
1152
|
+
fieldCount: 1,
|
|
1153
|
+
successCount: 1,
|
|
1154
|
+
errorCount: 0,
|
|
1155
|
+
fallback: { attempted: true, used: true, reason: 'batched-unavailable', attempts: 2 },
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
it('geometra_fill_form surfaces fallback metadata when batched throws recoverable error', async () => {
|
|
1159
|
+
const handler = getToolHandler('geometra_fill_form');
|
|
1160
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
1161
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
1162
|
+
children: [
|
|
1163
|
+
node('textbox', 'Full name', { value: '', path: [0] }),
|
|
1164
|
+
],
|
|
1165
|
+
});
|
|
1166
|
+
mockState.formSchemas = [{
|
|
1167
|
+
formId: 'fm:0',
|
|
1168
|
+
name: 'Application',
|
|
1169
|
+
fieldCount: 1,
|
|
1170
|
+
requiredCount: 1,
|
|
1171
|
+
invalidCount: 1,
|
|
1172
|
+
fields: [
|
|
1173
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Full name' },
|
|
1174
|
+
],
|
|
1175
|
+
}];
|
|
1176
|
+
// Both the batched-direct path and the schema-backed batched path call
|
|
1177
|
+
// sendFillFields. Reject all calls so both hit the recoverable-error
|
|
1178
|
+
// branch and the handler lands in the sequential loop.
|
|
1179
|
+
mockState.sendFillFields.mockRejectedValue(new Error('Unsupported client message type "fillFields"'));
|
|
1180
|
+
const result = await handler({
|
|
1181
|
+
valuesByLabel: { 'Full name': 'Taylor Applicant' },
|
|
1182
|
+
includeSteps: false,
|
|
1183
|
+
detail: 'minimal',
|
|
1184
|
+
});
|
|
1185
|
+
const payload = JSON.parse(result.content[0].text);
|
|
1186
|
+
expect(payload).toMatchObject({
|
|
1187
|
+
execution: 'sequential',
|
|
1188
|
+
fallback: { attempted: true, used: true, reason: 'batched-threw', attempts: 2 },
|
|
1189
|
+
});
|
|
1190
|
+
});
|
|
1191
|
+
});
|
|
1192
|
+
describe('click transparent fallback', () => {
|
|
1193
|
+
beforeEach(() => {
|
|
1194
|
+
vi.clearAllMocks();
|
|
1195
|
+
resetMockSessionCaches();
|
|
1196
|
+
});
|
|
1197
|
+
it('surfaces fallback.attempted:false when click fallback attempted and failed', async () => {
|
|
1198
|
+
const handler = getToolHandler('geometra_click');
|
|
1199
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
1200
|
+
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
1201
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
1202
|
+
children: [],
|
|
1203
|
+
});
|
|
1204
|
+
const result = await handler({
|
|
1205
|
+
role: 'button',
|
|
1206
|
+
name: 'Does not exist',
|
|
1207
|
+
fullyVisible: true,
|
|
1208
|
+
maxRevealSteps: 1,
|
|
1209
|
+
revealTimeoutMs: 50,
|
|
1210
|
+
detail: 'terse',
|
|
1211
|
+
});
|
|
1212
|
+
// Fallback was attempted (both revision-retry — if mockWaitForUiCondition
|
|
1213
|
+
// returns true — and relaxed-visibility) but neither phase recovered the
|
|
1214
|
+
// missing target, so the handler returns a structured error carrying the
|
|
1215
|
+
// attempted-but-failed telemetry.
|
|
1216
|
+
const errorText = result.content[0].text;
|
|
1217
|
+
expect(errorText).toContain('"fallback"');
|
|
1218
|
+
const parsed = JSON.parse(errorText);
|
|
1219
|
+
expect(parsed.fallback).toMatchObject({ attempted: true, used: false });
|
|
1220
|
+
expect(parsed.fallback.reasonsTried.length).toBeGreaterThan(0);
|
|
1221
|
+
});
|
|
1222
|
+
it('surfaces fallback.used when relaxed-visibility lets an offscreen submit resolve', async () => {
|
|
1223
|
+
const handler = getToolHandler('geometra_click');
|
|
1224
|
+
// First tree: target exists but is offscreen below the viewport, so a
|
|
1225
|
+
// fullyVisible requirement cannot be satisfied before the reveal budget runs out.
|
|
1226
|
+
// The relaxed-visibility fallback drops the fullyVisible requirement and tries
|
|
1227
|
+
// once more with a larger reveal budget.
|
|
1228
|
+
const offscreenTree = node('group', undefined, {
|
|
1229
|
+
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
1230
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
1231
|
+
children: [
|
|
1232
|
+
node('button', 'Submit', {
|
|
1233
|
+
// Starts fully offscreen-below and wheel stubs don't move it in tests,
|
|
1234
|
+
// so the fullyVisible attempt will fail. Relaxed-visibility sees it
|
|
1235
|
+
// intersect enough to count as revealed.
|
|
1236
|
+
bounds: { x: 60, y: 780, width: 180, height: 60 },
|
|
1237
|
+
path: [0],
|
|
1238
|
+
}),
|
|
1239
|
+
],
|
|
1240
|
+
});
|
|
1241
|
+
mockState.currentA11yRoot = offscreenTree;
|
|
1242
|
+
mockState.sendClick.mockResolvedValueOnce({ status: 'updated', timeoutMs: 2000 });
|
|
1243
|
+
const result = await handler({
|
|
1244
|
+
role: 'button',
|
|
1245
|
+
name: 'Submit',
|
|
1246
|
+
fullyVisible: true,
|
|
1247
|
+
maxRevealSteps: 1,
|
|
1248
|
+
revealTimeoutMs: 100,
|
|
1249
|
+
detail: 'terse',
|
|
1250
|
+
});
|
|
1251
|
+
const payload = JSON.parse(result.content[0].text);
|
|
1252
|
+
expect(payload).toMatchObject({
|
|
1253
|
+
target: { role: 'button', name: 'Submit' },
|
|
1254
|
+
fallback: { attempted: true, used: true, reason: 'relaxed-visibility' },
|
|
1255
|
+
});
|
|
1256
|
+
});
|
|
1257
|
+
});
|
|
985
1258
|
describe('query and reveal tools', () => {
|
|
986
1259
|
beforeEach(() => {
|
|
987
1260
|
vi.clearAllMocks();
|
package/dist/server.js
CHANGED
|
@@ -330,7 +330,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
330
330
|
}),
|
|
331
331
|
]);
|
|
332
332
|
export function createServer() {
|
|
333
|
-
const server = new McpServer({ name: 'geometra', version: '1.19.
|
|
333
|
+
const server = new McpServer({ name: 'geometra', version: '1.19.23' }, { capabilities: { tools: {} } });
|
|
334
334
|
const sessionIdInput = z.string().optional().describe('Session identifier returned by geometra_connect. Omit to use the most recent session.');
|
|
335
335
|
// ── connect ──────────────────────────────────────────────────
|
|
336
336
|
server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
|
|
@@ -774,6 +774,7 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
|
|
|
774
774
|
const resolvedFields = resolveFillFieldInputs(session, fields);
|
|
775
775
|
if (!resolvedFields.ok)
|
|
776
776
|
return err(resolvedFields.error);
|
|
777
|
+
let fallbackFromBatch;
|
|
777
778
|
if (!includeSteps) {
|
|
778
779
|
try {
|
|
779
780
|
const batched = await tryBatchedResolvedFields(session, resolvedFields.fields, detail);
|
|
@@ -792,6 +793,10 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
|
|
|
792
793
|
}
|
|
793
794
|
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
794
795
|
}
|
|
796
|
+
// Batched path returned {ok: false} — this is the transparent fallback
|
|
797
|
+
// case. Continue into the sequential loop and mark the result so
|
|
798
|
+
// operators can aggregate how often it fires.
|
|
799
|
+
fallbackFromBatch = { attempted: true, used: true, reason: 'batched-unavailable', attempts: 2 };
|
|
795
800
|
}
|
|
796
801
|
catch (e) {
|
|
797
802
|
const message = e instanceof Error ? e.message : String(e);
|
|
@@ -847,6 +852,7 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
|
|
|
847
852
|
errorCount,
|
|
848
853
|
...(includeSteps ? { steps } : {}),
|
|
849
854
|
...(stoppedAt !== undefined ? { stoppedAt } : {}),
|
|
855
|
+
...(fallbackFromBatch ? { fallback: fallbackFromBatch } : {}),
|
|
850
856
|
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
851
857
|
};
|
|
852
858
|
if (failOnInvalid && invalidRemaining > 0) {
|
|
@@ -1017,6 +1023,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1017
1023
|
planned.fields = planned.planned.map(p => p.field);
|
|
1018
1024
|
}
|
|
1019
1025
|
}
|
|
1026
|
+
let fallbackFromBatch;
|
|
1020
1027
|
if (!includeSteps) {
|
|
1021
1028
|
let usedBatch = false;
|
|
1022
1029
|
let batchAckResult;
|
|
@@ -1050,6 +1057,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1050
1057
|
const message = e instanceof Error ? e.message : String(e);
|
|
1051
1058
|
return err(message);
|
|
1052
1059
|
}
|
|
1060
|
+
fallbackFromBatch = { attempted: true, used: true, reason: 'batched-threw', attempts: 2 };
|
|
1053
1061
|
}
|
|
1054
1062
|
if (usedBatch) {
|
|
1055
1063
|
const after = sessionA11y(session);
|
|
@@ -1057,6 +1065,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1057
1065
|
const invalidRemaining = signals?.invalidFields.length ?? 0;
|
|
1058
1066
|
if ((!batchAckResult || batchAckResult.invalidCount > 0) && invalidRemaining > 0) {
|
|
1059
1067
|
usedBatch = false;
|
|
1068
|
+
fallbackFromBatch = { attempted: true, used: true, reason: 'batched-invalid-readback', attempts: 2 };
|
|
1060
1069
|
}
|
|
1061
1070
|
}
|
|
1062
1071
|
if (usedBatch) {
|
|
@@ -1131,6 +1140,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1131
1140
|
...(startIndex > 0 ? { resumedFromIndex: startIndex } : {}),
|
|
1132
1141
|
...(includeSteps ? { steps } : {}),
|
|
1133
1142
|
...(stoppedAt !== undefined ? { stoppedAt, resumeFromIndex: stoppedAt + 1 } : {}),
|
|
1143
|
+
...(fallbackFromBatch ? { fallback: fallbackFromBatch } : {}),
|
|
1134
1144
|
...(verification ? { verification } : {}),
|
|
1135
1145
|
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
1136
1146
|
};
|
|
@@ -1156,6 +1166,164 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1156
1166
|
}
|
|
1157
1167
|
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
1158
1168
|
});
|
|
1169
|
+
// ── fill + submit + wait ──────────────────────────────────────
|
|
1170
|
+
server.tool('geometra_submit_form', `Fill a form, click its submit button, and optionally wait for the post-submit UI state — all in one MCP call. This is the preferred path for the canonical ATS / sign-in flow when the whole sequence should run server-side.
|
|
1171
|
+
|
|
1172
|
+
Pass \`valuesById\` or \`valuesByLabel\` to populate fields, \`submit\` to target the submit button (default: semantic \`{ role: 'button', name: 'Submit' }\`), and \`waitFor\` to block on the post-submit state (success banner, navigation, submit button gone, etc.). Navigation is detected automatically and surfaced as \`navigated: true\` with \`afterUrl\`.
|
|
1173
|
+
|
|
1174
|
+
Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: true\` for safe parallel submissions.`, {
|
|
1175
|
+
url: z.string().optional().describe('Optional target URL. Use a ws:// Geometra server URL or an http(s) page URL to auto-connect before submitting.'),
|
|
1176
|
+
pageUrl: z.string().optional().describe('Optional http(s) page URL to auto-connect before submitting. Prefer this over url for browser pages.'),
|
|
1177
|
+
port: z.number().int().min(0).max(65535).optional().describe('Preferred local port for an auto-spawned proxy (default: ephemeral OS-assigned port).'),
|
|
1178
|
+
headless: z.boolean().optional().describe('Run Chromium headless when auto-spawning a proxy (default false = visible window).'),
|
|
1179
|
+
width: z.number().int().positive().optional().describe('Viewport width for auto-connected sessions.'),
|
|
1180
|
+
height: z.number().int().positive().optional().describe('Viewport height for auto-connected sessions.'),
|
|
1181
|
+
slowMo: z.number().int().nonnegative().optional().describe('Playwright slowMo (ms) when auto-spawning a proxy.'),
|
|
1182
|
+
isolated: z.boolean().optional().default(false).describe('When auto-connecting via pageUrl/url, request an isolated proxy. Required for safe parallel form submission.'),
|
|
1183
|
+
formId: z.string().optional().describe('Optional form id from geometra_form_schema or geometra_page_model'),
|
|
1184
|
+
valuesById: formValuesRecordSchema.optional().describe('Form values keyed by stable field id from geometra_form_schema'),
|
|
1185
|
+
valuesByLabel: formValuesRecordSchema.optional().describe('Form values keyed by schema field label'),
|
|
1186
|
+
submit: z.object(nodeFilterShape()).optional().describe('Semantic target for the submit button. Defaults to {role: "button", name: "Submit"}.'),
|
|
1187
|
+
submitIndex: z.number().int().min(0).optional().default(0).describe('Which matching submit target to click after sorting top-to-bottom (default 0)'),
|
|
1188
|
+
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.'),
|
|
1189
|
+
waitFor: z.object(waitConditionShape()).optional().describe('Optional semantic condition to wait for after the submit click (success banner, navigation, submit gone, etc.)'),
|
|
1190
|
+
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.'),
|
|
1191
|
+
failOnInvalid: z.boolean().optional().default(false).describe('Return an error if invalid fields remain after the submit wait resolves.'),
|
|
1192
|
+
detail: detailInput(),
|
|
1193
|
+
sessionId: sessionIdInput,
|
|
1194
|
+
}, async ({ url, pageUrl, port, headless, width, height, slowMo, isolated, formId, valuesById, valuesByLabel, submit, submitIndex, submitTimeoutMs, waitFor, skipFill, failOnInvalid, detail, sessionId }) => {
|
|
1195
|
+
const resolved = await ensureToolSession({ sessionId, url, pageUrl, port, headless, width, height, slowMo, isolated }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_submit_form.');
|
|
1196
|
+
if (!resolved.ok)
|
|
1197
|
+
return err(resolved.error);
|
|
1198
|
+
const session = resolved.session;
|
|
1199
|
+
const connection = autoConnectionPayload(resolved);
|
|
1200
|
+
if (!session.tree || !session.layout) {
|
|
1201
|
+
await waitForUiCondition(session, () => Boolean(session.tree && session.layout), 2_000);
|
|
1202
|
+
}
|
|
1203
|
+
const entryA11y = sessionA11y(session);
|
|
1204
|
+
if (!entryA11y)
|
|
1205
|
+
return err('No UI tree available for form submission');
|
|
1206
|
+
const entryUrl = entryA11y.meta?.pageUrl;
|
|
1207
|
+
let fillSummary;
|
|
1208
|
+
if (!skipFill) {
|
|
1209
|
+
const entryCount = Object.keys(valuesById ?? {}).length + Object.keys(valuesByLabel ?? {}).length;
|
|
1210
|
+
if (entryCount === 0) {
|
|
1211
|
+
return err('Provide at least one value in valuesById or valuesByLabel, or set skipFill: true to submit already-filled values.');
|
|
1212
|
+
}
|
|
1213
|
+
const schemas = getSessionFormSchemas(session, { includeOptions: true, includeContext: 'auto' });
|
|
1214
|
+
if (schemas.length === 0)
|
|
1215
|
+
return err('No forms found in the current UI');
|
|
1216
|
+
const resolution = resolveTargetFormSchema(schemas, { formId, valuesById, valuesByLabel });
|
|
1217
|
+
if (!resolution.ok)
|
|
1218
|
+
return err(resolution.error);
|
|
1219
|
+
const schema = resolution.schema;
|
|
1220
|
+
const planned = planFormFill(schema, { valuesById, valuesByLabel });
|
|
1221
|
+
if (!planned.ok)
|
|
1222
|
+
return err(planned.error);
|
|
1223
|
+
try {
|
|
1224
|
+
const startRevision = session.updateRevision;
|
|
1225
|
+
const wait = await sendFillFields(session, planned.fields);
|
|
1226
|
+
const ack = parseProxyFillAckResult(wait.result);
|
|
1227
|
+
await waitForDeferredBatchUpdate(session, startRevision, wait);
|
|
1228
|
+
fillSummary = {
|
|
1229
|
+
formId: schema.formId,
|
|
1230
|
+
fieldCount: planned.fields.length,
|
|
1231
|
+
...(ack ? { invalidCount: ack.invalidCount, alertCount: ack.alertCount } : {}),
|
|
1232
|
+
...(entryCount !== planned.fields.length ? { requestedValueCount: entryCount } : {}),
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
catch (e) {
|
|
1236
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1237
|
+
return err(`Failed to fill form before submit: ${message}`);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
const submitFilter = submit ?? { role: 'button', name: 'Submit' };
|
|
1241
|
+
const resolvedClick = await resolveClickLocation(session, {
|
|
1242
|
+
filter: submitFilter,
|
|
1243
|
+
index: submitIndex,
|
|
1244
|
+
fullyVisible: true,
|
|
1245
|
+
revealTimeoutMs: 2_500,
|
|
1246
|
+
});
|
|
1247
|
+
if (!resolvedClick.ok)
|
|
1248
|
+
return err(`Submit target not found: ${resolvedClick.error}`);
|
|
1249
|
+
const beforeSubmit = sessionA11y(session);
|
|
1250
|
+
const clickWait = await sendClick(session, resolvedClick.value.x, resolvedClick.value.y, submitTimeoutMs);
|
|
1251
|
+
let waitResult;
|
|
1252
|
+
if (waitFor) {
|
|
1253
|
+
const postWait = await waitForSemanticCondition(session, {
|
|
1254
|
+
filter: {
|
|
1255
|
+
id: waitFor.id,
|
|
1256
|
+
role: waitFor.role,
|
|
1257
|
+
name: waitFor.name,
|
|
1258
|
+
text: waitFor.text,
|
|
1259
|
+
contextText: waitFor.contextText,
|
|
1260
|
+
promptText: waitFor.promptText,
|
|
1261
|
+
sectionText: waitFor.sectionText,
|
|
1262
|
+
itemText: waitFor.itemText,
|
|
1263
|
+
value: waitFor.value,
|
|
1264
|
+
checked: waitFor.checked,
|
|
1265
|
+
disabled: waitFor.disabled,
|
|
1266
|
+
focused: waitFor.focused,
|
|
1267
|
+
selected: waitFor.selected,
|
|
1268
|
+
expanded: waitFor.expanded,
|
|
1269
|
+
invalid: waitFor.invalid,
|
|
1270
|
+
required: waitFor.required,
|
|
1271
|
+
busy: waitFor.busy,
|
|
1272
|
+
},
|
|
1273
|
+
present: waitFor.present ?? true,
|
|
1274
|
+
timeoutMs: waitFor.timeoutMs ?? 15_000,
|
|
1275
|
+
});
|
|
1276
|
+
if (!postWait.ok) {
|
|
1277
|
+
const payload = {
|
|
1278
|
+
...connection,
|
|
1279
|
+
completed: false,
|
|
1280
|
+
...(fillSummary ? { fill: fillSummary } : {}),
|
|
1281
|
+
submit: {
|
|
1282
|
+
at: { x: resolvedClick.value.x, y: resolvedClick.value.y },
|
|
1283
|
+
...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target) } : {}),
|
|
1284
|
+
...waitStatusPayload(clickWait),
|
|
1285
|
+
},
|
|
1286
|
+
waitFor: { ok: false, error: postWait.error },
|
|
1287
|
+
};
|
|
1288
|
+
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
1289
|
+
}
|
|
1290
|
+
waitResult = postWait.value;
|
|
1291
|
+
}
|
|
1292
|
+
const after = sessionA11y(session);
|
|
1293
|
+
const signals = after ? collectSessionSignals(after) : undefined;
|
|
1294
|
+
const afterUrl = after?.meta?.pageUrl;
|
|
1295
|
+
const navigated = Boolean(afterUrl && entryUrl && afterUrl !== entryUrl);
|
|
1296
|
+
const payload = {
|
|
1297
|
+
...connection,
|
|
1298
|
+
completed: true,
|
|
1299
|
+
...(fillSummary ? { fill: fillSummary } : {}),
|
|
1300
|
+
submit: {
|
|
1301
|
+
at: { x: resolvedClick.value.x, y: resolvedClick.value.y },
|
|
1302
|
+
...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target), revealSteps: resolvedClick.value.revealAttempts ?? 0 } : {}),
|
|
1303
|
+
...waitStatusPayload(clickWait),
|
|
1304
|
+
},
|
|
1305
|
+
...(waitResult ? { waitFor: waitConditionCompact(waitResult) } : {}),
|
|
1306
|
+
...(navigated ? { navigated: true, afterUrl } : {}),
|
|
1307
|
+
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
1308
|
+
};
|
|
1309
|
+
// Pull in page model hints on navigation to mirror fill_form behavior.
|
|
1310
|
+
if (navigated && after) {
|
|
1311
|
+
const model = buildPageModel(after);
|
|
1312
|
+
if (model.captcha)
|
|
1313
|
+
payload.captcha = model.captcha;
|
|
1314
|
+
if (model.verification)
|
|
1315
|
+
payload.verification = model.verification;
|
|
1316
|
+
}
|
|
1317
|
+
if (failOnInvalid && signals && signals.invalidFields.length > 0) {
|
|
1318
|
+
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
1319
|
+
}
|
|
1320
|
+
// Swallow the unused `beforeSubmit` binding; it anchors that the a11y tree was
|
|
1321
|
+
// captured pre-click and keeps the pattern consistent with other tools that
|
|
1322
|
+
// diff before/after for summaries (we rely on the waitFor / final signals
|
|
1323
|
+
// for the actual comparison here).
|
|
1324
|
+
void beforeSubmit;
|
|
1325
|
+
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
1326
|
+
});
|
|
1159
1327
|
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.
|
|
1160
1328
|
|
|
1161
1329
|
Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_listbox_option\`, \`select_option\`, \`set_checked\`, \`wheel\`, \`wait_for\`, \`expand_section\`, and \`fill_fields\`. \`click\` steps can also carry a nested \`waitFor\` condition. \`fill_fields\` steps can carry \`verifyFills: true\` to batch fill + read-back verification in one step (same semantics as \`geometra_fill_form\`'s \`verifyFills\`). \`expand_section\` takes a stable section id from \`geometra_page_model\` and returns the same payload as \`geometra_expand_section\`, eliminating a round-trip when drilling into a form/dialog before acting on it. Pass \`pageUrl\` / \`url\` to auto-connect so an entire flow can run in one MCP call.`, {
|
|
@@ -1201,6 +1369,10 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
|
|
|
1201
1369
|
const steps = [];
|
|
1202
1370
|
let stoppedAt;
|
|
1203
1371
|
const batchStartedAt = performance.now();
|
|
1372
|
+
// Collect transparent-fallback signals from each step so run_actions
|
|
1373
|
+
// surfaces them at top level regardless of `includeSteps` — otherwise
|
|
1374
|
+
// the telemetry is dead code when callers opt out of the steps listing.
|
|
1375
|
+
const fallbackRecords = [];
|
|
1204
1376
|
for (let index = 0; index < actions.length; index++) {
|
|
1205
1377
|
const action = actions[index];
|
|
1206
1378
|
const startedAt = performance.now();
|
|
@@ -1212,6 +1384,17 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
|
|
|
1212
1384
|
uiTreeWaitMs = performance.now() - uiTreeWaitStartedAt;
|
|
1213
1385
|
}
|
|
1214
1386
|
const result = await executeBatchAction(session, action, detail, includeSteps);
|
|
1387
|
+
const stepFallback = result.compact.fallback;
|
|
1388
|
+
if (stepFallback?.used) {
|
|
1389
|
+
fallbackRecords.push({
|
|
1390
|
+
stepIndex: index,
|
|
1391
|
+
type: action.type,
|
|
1392
|
+
attempted: true,
|
|
1393
|
+
used: true,
|
|
1394
|
+
reason: stepFallback.reason,
|
|
1395
|
+
attempts: stepFallback.attempts,
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1215
1398
|
const elapsedMs = Number((performance.now() - startedAt).toFixed(1));
|
|
1216
1399
|
const cumulativeMs = Number((performance.now() - batchStartedAt).toFixed(1));
|
|
1217
1400
|
const stepSignals = includeSteps ? (() => {
|
|
@@ -1270,6 +1453,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
|
|
|
1270
1453
|
...connection,
|
|
1271
1454
|
completed: stoppedAt === undefined && steps.length === actions.length,
|
|
1272
1455
|
...(stoppedAt !== undefined ? { stoppedAt } : {}),
|
|
1456
|
+
...(fallbackRecords.length > 0 ? { fallbacks: fallbackRecords } : {}),
|
|
1273
1457
|
...(after ? { final: sessionSignalsPayload(collectSessionSignals(after), detail) } : {}),
|
|
1274
1458
|
}
|
|
1275
1459
|
: {
|
|
@@ -1280,6 +1464,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
|
|
|
1280
1464
|
errorCount,
|
|
1281
1465
|
...(includeSteps ? { steps } : {}),
|
|
1282
1466
|
...(stoppedAt !== undefined ? { stoppedAt } : {}),
|
|
1467
|
+
...(fallbackRecords.length > 0 ? { fallbacks: fallbackRecords } : {}),
|
|
1283
1468
|
...(after ? { final: sessionSignalsPayload(collectSessionSignals(after), detail) } : {}),
|
|
1284
1469
|
};
|
|
1285
1470
|
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
@@ -1508,7 +1693,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
1508
1693
|
return sessionResult.error;
|
|
1509
1694
|
const session = sessionResult.session;
|
|
1510
1695
|
const before = sessionA11y(session);
|
|
1511
|
-
const resolved = await
|
|
1696
|
+
const resolved = await resolveClickLocationWithFallback(session, {
|
|
1512
1697
|
x,
|
|
1513
1698
|
y,
|
|
1514
1699
|
filter: {
|
|
@@ -1536,7 +1721,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
1536
1721
|
revealTimeoutMs,
|
|
1537
1722
|
});
|
|
1538
1723
|
if (!resolved.ok)
|
|
1539
|
-
return err(resolved
|
|
1724
|
+
return err(clickFallbackErrorMessage(resolved));
|
|
1540
1725
|
const wait = await sendClick(session, resolved.value.x, resolved.value.y, timeoutMs);
|
|
1541
1726
|
const summary = postActionSummary(session, before, wait, detail);
|
|
1542
1727
|
const clickLine = !resolved.value.target
|
|
@@ -1574,6 +1759,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
1574
1759
|
at: { x: resolved.value.x, y: resolved.value.y },
|
|
1575
1760
|
...(resolved.value.target ? { target: compactNodeReference(resolved.value.target), revealSteps: resolved.value.revealAttempts ?? 0 } : {}),
|
|
1576
1761
|
...waitStatusPayload(wait),
|
|
1762
|
+
...(resolved.fallback ? { fallback: resolved.fallback } : {}),
|
|
1577
1763
|
postWait: waitConditionCompact(postWait.value),
|
|
1578
1764
|
};
|
|
1579
1765
|
return ok(detailText(lines.filter(Boolean).join('\n'), compact, detail));
|
|
@@ -1582,6 +1768,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
1582
1768
|
at: { x: resolved.value.x, y: resolved.value.y },
|
|
1583
1769
|
...(resolved.value.target ? { target: compactNodeReference(resolved.value.target), revealSteps: resolved.value.revealAttempts ?? 0 } : {}),
|
|
1584
1770
|
...waitStatusPayload(wait),
|
|
1771
|
+
...(resolved.fallback ? { fallback: resolved.fallback } : {}),
|
|
1585
1772
|
};
|
|
1586
1773
|
return ok(detailText(lines.filter(Boolean).join('\n'), compact, detail));
|
|
1587
1774
|
});
|
|
@@ -2817,6 +3004,72 @@ async function resolveClickLocation(session, options) {
|
|
|
2817
3004
|
},
|
|
2818
3005
|
};
|
|
2819
3006
|
}
|
|
3007
|
+
async function resolveClickLocationWithFallback(session, options) {
|
|
3008
|
+
const first = await resolveClickLocation(session, options);
|
|
3009
|
+
if (first.ok)
|
|
3010
|
+
return first;
|
|
3011
|
+
// Fallback only applies to semantic resolves. Explicit coordinates never enter
|
|
3012
|
+
// the reveal path, so there is nothing to retry.
|
|
3013
|
+
const hasExplicitCoordinates = options.x !== undefined || options.y !== undefined;
|
|
3014
|
+
if (hasExplicitCoordinates)
|
|
3015
|
+
return first;
|
|
3016
|
+
if (!hasNodeFilter(options.filter))
|
|
3017
|
+
return first;
|
|
3018
|
+
const reasonsTried = [];
|
|
3019
|
+
let attempts = 1;
|
|
3020
|
+
const startRevision = session.updateRevision;
|
|
3021
|
+
const revisionAdvanced = await waitForUiCondition(session, () => session.updateRevision > startRevision, 600);
|
|
3022
|
+
if (revisionAdvanced) {
|
|
3023
|
+
attempts += 1;
|
|
3024
|
+
reasonsTried.push('revision-retry');
|
|
3025
|
+
const retry = await resolveClickLocation(session, options);
|
|
3026
|
+
if (retry.ok) {
|
|
3027
|
+
return {
|
|
3028
|
+
ok: true,
|
|
3029
|
+
value: retry.value,
|
|
3030
|
+
fallback: { attempted: true, used: true, reason: 'revision-retry', attempts },
|
|
3031
|
+
};
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
if (options.fullyVisible !== false) {
|
|
3035
|
+
attempts += 1;
|
|
3036
|
+
reasonsTried.push('relaxed-visibility');
|
|
3037
|
+
const relaxed = await resolveClickLocation(session, {
|
|
3038
|
+
...options,
|
|
3039
|
+
fullyVisible: false,
|
|
3040
|
+
maxRevealSteps: Math.max(options.maxRevealSteps ?? 0, 24),
|
|
3041
|
+
});
|
|
3042
|
+
if (relaxed.ok) {
|
|
3043
|
+
return {
|
|
3044
|
+
ok: true,
|
|
3045
|
+
value: relaxed.value,
|
|
3046
|
+
fallback: { attempted: true, used: true, reason: 'relaxed-visibility', attempts },
|
|
3047
|
+
};
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
// All fallback phases tried and none recovered. Carry the trace of what we
|
|
3051
|
+
// tried so operators see the attempted-but-failed signal alongside the
|
|
3052
|
+
// successful-recovery signal.
|
|
3053
|
+
if (reasonsTried.length === 0)
|
|
3054
|
+
return first;
|
|
3055
|
+
return {
|
|
3056
|
+
ok: false,
|
|
3057
|
+
error: first.error,
|
|
3058
|
+
fallback: { attempted: true, used: false, reasonsTried, attempts },
|
|
3059
|
+
};
|
|
3060
|
+
}
|
|
3061
|
+
/**
|
|
3062
|
+
* Expose the attempted-but-failed fallback telemetry on error responses. When
|
|
3063
|
+
* `resolved.fallback` is present on a failure, return a structured JSON error
|
|
3064
|
+
* so operators can aggregate recovery failures the same way they aggregate
|
|
3065
|
+
* successful recoveries. Plain-text errors are preserved for the no-fallback
|
|
3066
|
+
* case to avoid churning that error contract.
|
|
3067
|
+
*/
|
|
3068
|
+
function clickFallbackErrorMessage(resolved) {
|
|
3069
|
+
if (!resolved.fallback)
|
|
3070
|
+
return resolved.error;
|
|
3071
|
+
return JSON.stringify({ error: resolved.error, fallback: resolved.fallback });
|
|
3072
|
+
}
|
|
2820
3073
|
function describeFormattedNode(node) {
|
|
2821
3074
|
return `${node.role}${node.name ? ` ${JSON.stringify(node.name)}` : ''} (${node.id})`;
|
|
2822
3075
|
}
|
|
@@ -3286,7 +3539,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
3286
3539
|
switch (action.type) {
|
|
3287
3540
|
case 'click': {
|
|
3288
3541
|
const before = sessionA11y(session);
|
|
3289
|
-
const resolved = await
|
|
3542
|
+
const resolved = await resolveClickLocationWithFallback(session, {
|
|
3290
3543
|
x: action.x,
|
|
3291
3544
|
y: action.y,
|
|
3292
3545
|
filter: {
|
|
@@ -3314,7 +3567,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
3314
3567
|
revealTimeoutMs: action.revealTimeoutMs,
|
|
3315
3568
|
});
|
|
3316
3569
|
if (!resolved.ok)
|
|
3317
|
-
throw new Error(resolved
|
|
3570
|
+
throw new Error(clickFallbackErrorMessage(resolved));
|
|
3318
3571
|
const wait = await sendClick(session, resolved.value.x, resolved.value.y, action.timeoutMs);
|
|
3319
3572
|
const targetSummary = resolved.value.target
|
|
3320
3573
|
? `Clicked ${describeFormattedNode(resolved.value.target)} at (${resolved.value.x}, ${resolved.value.y}).`
|
|
@@ -3357,6 +3610,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
3357
3610
|
at: { x: resolved.value.x, y: resolved.value.y },
|
|
3358
3611
|
...(resolved.value.target ? { target: compactNodeReference(resolved.value.target), revealSteps: resolved.value.revealAttempts ?? 0 } : {}),
|
|
3359
3612
|
...waitStatusPayload(wait),
|
|
3613
|
+
...(resolved.fallback ? { fallback: resolved.fallback } : {}),
|
|
3360
3614
|
...(postWaitCompact ? { postWait: postWaitCompact } : {}),
|
|
3361
3615
|
},
|
|
3362
3616
|
};
|
|
@@ -3559,6 +3813,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
3559
3813
|
const verifyFillsFn = action.verifyFills
|
|
3560
3814
|
? () => verifyFormFills(session, resolvedFields.fields.map(field => ({ field, confidence: 1.0, matchMethod: 'label-exact' })))
|
|
3561
3815
|
: undefined;
|
|
3816
|
+
let fallbackFromBatch;
|
|
3562
3817
|
if (!includeSteps) {
|
|
3563
3818
|
const batched = await tryBatchedResolvedFields(session, resolvedFields.fields, detail);
|
|
3564
3819
|
if (batched.ok) {
|
|
@@ -3574,6 +3829,10 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
3574
3829
|
},
|
|
3575
3830
|
};
|
|
3576
3831
|
}
|
|
3832
|
+
// Batched path unavailable — fall through into sequential and tag the
|
|
3833
|
+
// step so `geometra_run_actions` result aggregation matches the shape
|
|
3834
|
+
// emitted by standalone `geometra_fill_fields` / `geometra_fill_form`.
|
|
3835
|
+
fallbackFromBatch = { attempted: true, used: true, reason: 'batched-unavailable', attempts: 2 };
|
|
3577
3836
|
}
|
|
3578
3837
|
const steps = [];
|
|
3579
3838
|
for (let index = 0; index < resolvedFields.fields.length; index++) {
|
|
@@ -3602,6 +3861,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
3602
3861
|
compact: {
|
|
3603
3862
|
fieldCount: resolvedFields.fields.length,
|
|
3604
3863
|
...(includeSteps ? { steps } : {}),
|
|
3864
|
+
...(fallbackFromBatch ? { fallback: fallbackFromBatch } : {}),
|
|
3605
3865
|
...(verification ? { verification } : {}),
|
|
3606
3866
|
},
|
|
3607
3867
|
};
|
package/package.json
CHANGED