aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/domains/form-batch.ts113
-rw-r--r--src/domains/form.ts112
-rw-r--r--src/domains/forms.ts375
-rw-r--r--src/registry.ts5
4 files changed, 229 insertions, 376 deletions
diff --git a/src/domains/form-batch.ts b/src/domains/form-batch.ts
new file mode 100644
index 0000000..b926025
--- /dev/null
+++ b/src/domains/form-batch.ts
@@ -0,0 +1,113 @@
+import { Type } from "typebox";
+import { type Result, ok } from "../util/result";
+import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
+import { detailsOf, resolveAndCall } from "./form";
+
+const FILL_FN = `
+function(value) {
+ const el = this;
+ if (!el || el.nodeType !== 1) return { ok: false, reason: "ref does not point to an element" };
+ if (el.disabled) return { ok: false, reason: "element is disabled" };
+ const tag = (el.tagName || "").toLowerCase();
+ const type = (el.type || "").toLowerCase();
+ try { el.focus(); } catch (e) {}
+ const fire = function(t) { el.dispatchEvent(new Event(t, { bubbles: true })); };
+
+ if (tag === "select") {
+ const want = String(value);
+ let matched = null;
+ for (let i = 0; i < el.options.length; i++) {
+ const o = el.options[i];
+ if (o.value === want || o.label === want || o.text === want) { matched = o; break; }
+ }
+ if (!matched) {
+ const opts = [];
+ for (let i = 0; i < el.options.length; i++) opts.push({ value: el.options[i].value, text: el.options[i].text });
+ return { ok: false, reason: "no matching option", kind: "select", options: opts };
+ }
+ el.value = matched.value;
+ fire("input"); fire("change");
+ return { ok: true, kind: "select", value: el.value, text: matched.text };
+ }
+
+ if (tag === "input" && (type === "checkbox" || type === "radio")) {
+ const want = value === true || value === "true" || value === "on" || value === 1;
+ if (el.checked !== want) {
+ try { el.click(); } catch (e) {}
+ if (el.checked !== want) { el.checked = want; fire("input"); fire("change"); }
+ }
+ return { ok: true, kind: type, checked: el.checked };
+ }
+
+ if (el.isContentEditable) {
+ el.textContent = String(value);
+ fire("input"); fire("change");
+ return { ok: true, kind: "contenteditable", value: el.textContent };
+ }
+
+ if (tag === "input" || tag === "textarea") {
+ const proto = tag === "textarea" ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
+ const desc = Object.getOwnPropertyDescriptor(proto, "value");
+ if (desc && desc.set) desc.set.call(el, String(value));
+ else el.value = String(value);
+ fire("input"); fire("change");
+ return { ok: true, kind: tag, value: el.value };
+ }
+
+ return { ok: false, reason: "element is not a fillable field (tag=" + tag + ")" };
+}`;
+
+const FieldValue = Type.Union([Type.String(), Type.Boolean()], {
+ description: "Value to set. String for text inputs / textareas / selects / contenteditable; boolean for checkboxes & radios.",
+});
+
+const FillFormArgs = Type.Object({
+ fields: Type.Array(
+ Type.Object({
+ ref: Type.String({ description: "Stable element ref from browser_snapshot (e.g. 'e7')." }),
+ value: FieldValue,
+ }),
+ { minItems: 1, description: "Fields to fill in one batch, each by ref." },
+ ),
+});
+
+export const fillFormTool = defineBrowserTool({
+ name: "browser_fill_form",
+ label: "Browser Fill Form",
+ description:
+ "Fill multiple form fields in one call — the efficient way to complete a form. Each field is { ref, value }; refs come from browser_snapshot's [eN]. Handles text inputs, textareas, selects, checkboxes, radios, and contenteditable, drives React/Vue controlled inputs correctly, and reports the resulting value of every field.",
+ promptSnippet: "Fill many form fields at once by ref — React-safe, self-confirming",
+ promptGuidelines: [
+ "Preferred for forms: snapshot once, then fill all fields in a single browser_fill_form call.",
+ "Each field: { ref, value }. value is a string for text/select/contenteditable, boolean for checkbox/radio.",
+ "The result summarizes how many fields filled cleanly and flags any value mismatches or per-field errors.",
+ "Re-run browser_snapshot afterwards to verify the form state if needed.",
+ ],
+ parameters: FillFormArgs,
+ concurrency: "serialized",
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
+ const results: Array<Readonly<Record<string, unknown>>> = [];
+ let okCount = 0;
+ const issues: string[] = [];
+ for (const f of args.fields) {
+ const r = await resolveAndCall(client, f.ref, FILL_FN, [f.value]);
+ if (!r.success) {
+ results.push({ ref: f.ref, ok: false, error: r.error.message });
+ issues.push(`ref ${f.ref} (${r.error.message})`);
+ continue;
+ }
+ const res = r.data;
+ const wanted = typeof f.value === "boolean" ? undefined : String(f.value);
+ const got = res.value !== undefined ? String(res.value) : undefined;
+ const mismatch = wanted !== undefined && got !== undefined && got !== wanted;
+ const fieldOk = res.ok === true && !mismatch;
+ if (fieldOk) okCount++;
+ else issues.push(`ref ${f.ref} (${res.ok === false ? (res.reason ?? "failed") : "value mismatch"})`);
+ results.push({ ref: f.ref, ok: fieldOk, mismatch, ...detailsOf(f.ref, res) });
+ }
+ const text =
+ `Filled ${okCount}/${args.fields.length} fields` +
+ (issues.length > 0 ? `; issues: ${issues.join(", ")}` : "");
+ return ok({ text, details: { results } });
+ },
+});
diff --git a/src/domains/form.ts b/src/domains/form.ts
index a1ae7f5..26268d9 100644
--- a/src/domains/form.ts
+++ b/src/domains/form.ts
@@ -311,3 +311,115 @@ export const selectOptionTool = defineBrowserTool({
});
},
});
+
+export type PageResult = {
+ readonly ok: boolean;
+ readonly reason?: string;
+ readonly kind?: string;
+ readonly value?: unknown;
+ readonly text?: string;
+ readonly checked?: boolean;
+ readonly changed?: boolean;
+ readonly options?: ReadonlyArray<{ value: string; text: string }>;
+};
+
+const isOption = (v: unknown): v is { readonly value: string; readonly text: string } =>
+ typeof v === "object" && v !== null
+ && "value" in v && typeof v.value === "string"
+ && "text" in v && typeof v.text === "string";
+
+const isPageResult = (v: unknown): v is PageResult => {
+ if (typeof v !== "object" || v === null) return false;
+ if (!("ok" in v) || typeof v.ok !== "boolean") return false;
+ if ("reason" in v && typeof v.reason !== "string") return false;
+ if ("kind" in v && typeof v.kind !== "string") return false;
+ if ("text" in v && typeof v.text !== "string") return false;
+ if ("checked" in v && typeof v.checked !== "boolean") return false;
+ if ("changed" in v && typeof v.changed !== "boolean") return false;
+ if ("options" in v && !(Array.isArray(v.options) && v.options.every(isOption))) return false;
+ return true;
+};
+
+export const resolveAndCall = async (
+ client: BrowserClient,
+ ref: string,
+ functionDeclaration: string,
+ args: ReadonlyArray<unknown>,
+): Promise<Result<PageResult, ToolErr>> => {
+ const objectId = await resolveRefToObjectId(client, ref);
+ if (!objectId.success) return objectId;
+ const handle = objectId.data;
+ try {
+ const called = await client.session().call("Runtime.callFunctionOn", {
+ objectId: handle,
+ functionDeclaration,
+ arguments: args.map((v) => ({ value: v })),
+ returnByValue: true,
+ awaitPromise: true,
+ });
+ if (!called.success) return err({ kind: "cdp_error", message: called.error.message, details: { ref } });
+ if (called.data.exceptionDetails !== undefined) {
+ return err({ kind: "cdp_error", message: `page function threw: ${JSON.stringify(called.data.exceptionDetails)}`, details: { ref } });
+ }
+ const value = called.data.result.value;
+ if (!isPageResult(value)) {
+ return err({ kind: "internal", message: "page function returned no result object", details: { ref } });
+ }
+ return ok(value);
+ } finally {
+ await client.session().call("Runtime.releaseObject", { objectId: handle });
+ }
+};
+
+export const detailsOf = (ref: string, res: PageResult, extra?: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> => {
+ const { ok: _ok, ...rest } = res;
+ return { ref, ...rest, ...(extra ?? {}) };
+};
+
+const CHECK_FN = `
+function(checked) {
+ const el = this;
+ const tag = (el.tagName || "").toLowerCase();
+ const type = (el.type || "").toLowerCase();
+ if (tag !== "input" || (type !== "checkbox" && type !== "radio")) return { ok: false, reason: "ref is not a checkbox or radio" };
+ if (el.disabled) return { ok: false, reason: "element is disabled" };
+ const want = checked === true;
+ let changed = false;
+ if (el.checked !== want) {
+ try { el.click(); } catch (e) {}
+ if (el.checked !== want) { el.checked = want; el.dispatchEvent(new Event("change", { bubbles: true })); }
+ changed = true;
+ }
+ return { ok: true, checked: el.checked, changed: changed };
+}`;
+
+const SetCheckedArgs = Type.Object({
+ ref: Type.String({ description: "Stable element ref of a checkbox or radio from browser_snapshot (e.g. 'e7')." }),
+ checked: Type.Boolean({ description: "Desired checked state." }),
+});
+
+export const setCheckedTool = defineBrowserTool({
+ name: "browser_set_checked",
+ label: "Browser Set Checked",
+ description:
+ "Set a checkbox or radio to a desired checked state by ref. Only acts if the current state differs, fires change, and reports the final state. Get the ref from browser_snapshot's [eN].",
+ promptSnippet: "Set a checkbox/radio checked state by ref",
+ promptGuidelines: [
+ "checked:true ticks the box, checked:false unticks it. Idempotent — no-op if already in the desired state.",
+ "For radios, set checked:true on the option you want; the group deselects the others.",
+ ],
+ parameters: SetCheckedArgs,
+ concurrency: "serialized",
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
+ const r = await resolveAndCall(client, args.ref, CHECK_FN, [args.checked]);
+ if (!r.success) return r;
+ const res = r.data;
+ if (!res.ok) {
+ return err({ kind: "invalid_state", message: `Could not set checked on ref ${args.ref}: ${res.reason ?? "unknown reason"}`, details: detailsOf(args.ref, res) });
+ }
+ return ok({
+ text: `Set ref ${args.ref} checked=${res.checked}` + (res.changed === false ? " (already)" : ""),
+ details: detailsOf(args.ref, res),
+ });
+ },
+});
diff --git a/src/domains/forms.ts b/src/domains/forms.ts
deleted file mode 100644
index ed64a74..0000000
--- a/src/domains/forms.ts
+++ /dev/null
@@ -1,375 +0,0 @@
-import { Type } from "typebox";
-import type { BrowserClient } from "../client";
-import { type Result, err, ok } from "../util/result";
-import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
-
-// ── Page-side functions ──────────────────────────────────────────────────────
-// These run inside the page via Runtime.callFunctionOn with `this` bound to the
-// resolved element. They are NOT string-interpolated with user data — the value
-// is passed as a structured `arguments` entry, so safeJs is unnecessary here.
-
-/**
- * Universal field setter. Auto-detects the element type and applies the right
- * strategy, then reads the resulting value back so the caller can confirm.
- *
- * The <input>/<textarea> branch uses the "native setter" trick: React (and
- * other frameworks) overload the element's `value` setter to track state, so a
- * plain `el.value = x` updates the DOM but not the framework. Calling the
- * prototype's original setter and then dispatching a bubbling `input` event is
- * what makes React's onChange fire.
- */
-const FILL_FN = `
-function(value) {
- const el = this;
- if (!el || el.nodeType !== 1) return { ok: false, reason: "ref does not point to an element" };
- if (el.disabled) return { ok: false, reason: "element is disabled" };
- const tag = (el.tagName || "").toLowerCase();
- const type = (el.type || "").toLowerCase();
- try { el.focus(); } catch (e) {}
- const fire = function(t) { el.dispatchEvent(new Event(t, { bubbles: true })); };
-
- if (tag === "select") {
- const want = String(value);
- let matched = null;
- for (let i = 0; i < el.options.length; i++) {
- const o = el.options[i];
- if (o.value === want || o.label === want || o.text === want) { matched = o; break; }
- }
- if (!matched) {
- const opts = [];
- for (let i = 0; i < el.options.length; i++) opts.push({ value: el.options[i].value, text: el.options[i].text });
- return { ok: false, reason: "no matching option", kind: "select", options: opts };
- }
- el.value = matched.value;
- fire("input"); fire("change");
- return { ok: true, kind: "select", value: el.value, text: matched.text };
- }
-
- if (tag === "input" && (type === "checkbox" || type === "radio")) {
- const want = value === true || value === "true" || value === "on" || value === 1;
- if (el.checked !== want) {
- try { el.click(); } catch (e) {}
- if (el.checked !== want) { el.checked = want; fire("input"); fire("change"); }
- }
- return { ok: true, kind: type, checked: el.checked };
- }
-
- if (el.isContentEditable) {
- el.textContent = String(value);
- fire("input"); fire("change");
- return { ok: true, kind: "contenteditable", value: el.textContent };
- }
-
- if (tag === "input" || tag === "textarea") {
- const proto = tag === "textarea" ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
- const desc = Object.getOwnPropertyDescriptor(proto, "value");
- if (desc && desc.set) desc.set.call(el, String(value));
- else el.value = String(value);
- fire("input"); fire("change");
- return { ok: true, kind: tag, value: el.value };
- }
-
- return { ok: false, reason: "element is not a fillable field (tag=" + tag + ")" };
-}`;
-
-/** Specialized <select> setter — matches by value first, then by label/text. */
-const SELECT_FN = `
-function(value, label) {
- const el = this;
- if (!el || (el.tagName || "").toLowerCase() !== "select") return { ok: false, reason: "ref is not a <select> element" };
- if (el.disabled) return { ok: false, reason: "select is disabled" };
- let matched = null;
- for (let i = 0; i < el.options.length; i++) {
- const o = el.options[i];
- if (value != null && o.value === String(value)) { matched = o; break; }
- if (label != null && (o.text === String(label) || o.label === String(label))) { matched = o; break; }
- }
- if (!matched) {
- const opts = [];
- for (let i = 0; i < el.options.length; i++) opts.push({ value: el.options[i].value, text: el.options[i].text });
- return { ok: false, reason: "no matching option", options: opts };
- }
- try { el.focus(); } catch (e) {}
- el.value = matched.value;
- el.dispatchEvent(new Event("input", { bubbles: true }));
- el.dispatchEvent(new Event("change", { bubbles: true }));
- return { ok: true, value: el.value, text: matched.text };
-}`;
-
-/** Specialized checkbox/radio setter — only acts when the state differs. */
-const CHECK_FN = `
-function(checked) {
- const el = this;
- const tag = (el.tagName || "").toLowerCase();
- const type = (el.type || "").toLowerCase();
- if (tag !== "input" || (type !== "checkbox" && type !== "radio")) return { ok: false, reason: "ref is not a checkbox or radio" };
- if (el.disabled) return { ok: false, reason: "element is disabled" };
- const want = checked === true;
- let changed = false;
- if (el.checked !== want) {
- try { el.click(); } catch (e) {}
- if (el.checked !== want) { el.checked = want; el.dispatchEvent(new Event("change", { bubbles: true })); }
- changed = true;
- }
- return { ok: true, checked: el.checked, changed: changed };
-}`;
-
-// ── Resolver ─────────────────────────────────────────────────────────────────
-
-/** Shape returned by the page-side functions above. */
-type PageResult = {
- readonly ok: boolean;
- readonly reason?: string;
- readonly kind?: string;
- readonly value?: unknown;
- readonly text?: string;
- readonly checked?: boolean;
- readonly changed?: boolean;
- readonly options?: ReadonlyArray<{ value: string; text: string }>;
-};
-
-const isOption = (v: unknown): v is { readonly value: string; readonly text: string } =>
- typeof v === "object" && v !== null
- && "value" in v && typeof v.value === "string"
- && "text" in v && typeof v.text === "string";
-
-const isPageResult = (v: unknown): v is PageResult => {
- if (typeof v !== "object" || v === null) return false;
- if (!("ok" in v) || typeof v.ok !== "boolean") return false;
- if ("reason" in v && typeof v.reason !== "string") return false;
- if ("kind" in v && typeof v.kind !== "string") return false;
- if ("text" in v && typeof v.text !== "string") return false;
- if ("checked" in v && typeof v.checked !== "boolean") return false;
- if ("changed" in v && typeof v.changed !== "boolean") return false;
- if ("options" in v && !(Array.isArray(v.options) && v.options.every(isOption))) return false;
- return true;
-};
-
-/**
- * Resolve a ref (CDP backendDOMNodeId) to a live JS handle and invoke a
- * page-side function on it. DOM + Runtime are enabled on every session
- * (see cdp/session.ts enableDomains), so no extra setup is required.
- */
-const resolveAndCall = async (
- client: BrowserClient,
- ref: number,
- functionDeclaration: string,
- args: ReadonlyArray<unknown>,
-): Promise<Result<PageResult, ToolErr>> => {
- const session = client.session();
-
- const resolved = await session.call("DOM.resolveNode", { backendNodeId: ref });
- if (!resolved.success) {
- return err({
- kind: "invalid_state",
- message: `ref ${ref} could not be resolved — the page may have changed. Re-run browser_snapshot to get fresh refs.`,
- details: { ref },
- });
- }
- const objectId = resolved.data.object.objectId;
- if (objectId === undefined) {
- return err({
- kind: "invalid_state",
- message: `ref ${ref} resolved to no JS object — re-run browser_snapshot to get fresh refs.`,
- details: { ref },
- });
- }
-
- try {
- const called = await session.call("Runtime.callFunctionOn", {
- objectId,
- functionDeclaration,
- arguments: args.map((v) => ({ value: v })),
- returnByValue: true,
- awaitPromise: true,
- });
- if (!called.success) return err({ kind: "cdp_error", message: called.error.message, details: { ref } });
- if (called.data.exceptionDetails !== undefined) {
- return err({ kind: "cdp_error", message: `page function threw: ${JSON.stringify(called.data.exceptionDetails)}`, details: { ref } });
- }
- const value = called.data.result.value;
- if (!isPageResult(value)) {
- return err({ kind: "internal", message: "page function returned no result object", details: { ref } });
- }
- return ok(value);
- } finally {
- // Best-effort: release the remote handle so it can be GC'd. Failure is
- // harmless (the page may have navigated), so we ignore the result.
- await session.call("Runtime.releaseObject", { objectId });
- }
-};
-
-/** Drop the `ok` flag before stashing a page result in tool `details` — the
- * tool-result envelope reserves `ok` for its own success discriminant. */
-const detailsOf = (ref: number, res: PageResult, extra?: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> => {
- const { ok: _ok, ...rest } = res;
- return { ref, ...rest, ...(extra ?? {}) };
-};
-
-// ── Tools ────────────────────────────────────────────────────────────────────
-
-const FieldValue = Type.Union([Type.String(), Type.Boolean()], {
- description: "Value to set. String for text inputs / textareas / selects / contenteditable; boolean for checkboxes & radios.",
-});
-
-const FillArgs = Type.Object({
- ref: Type.Integer({ description: "Element ref from browser_snapshot ([ref=N])." }),
- value: FieldValue,
-});
-
-export const fillTool = defineBrowserTool({
- name: "browser_fill",
- label: "Browser Fill",
- description:
- "Fill a single form field by ref. Auto-detects input/textarea/select/checkbox/radio/contenteditable, clears then sets the value, fires input+change events (drives React/Vue controlled inputs correctly), and reads the value back to confirm. Get the ref from browser_snapshot's [ref=N].",
- promptSnippet: "Fill a form field by ref (text, select, checkbox) — React-safe, self-confirming",
- promptGuidelines: [
- "Get the ref from browser_snapshot's [ref=N] for the target field. No click-to-focus needed.",
- "value is a string for text fields/selects/contenteditable, or a boolean for checkboxes/radios.",
- "The result reports the value the field actually holds after filling — check it matches what you intended.",
- "For autocomplete/typeahead that needs real keystrokes, click the field's @(x,y) then use browser_type instead.",
- "If a ref no longer resolves, re-run browser_snapshot to get fresh refs.",
- ],
- parameters: FillArgs,
- concurrency: "serialized",
- async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
- const r = await resolveAndCall(client, args.ref, FILL_FN, [args.value]);
- if (!r.success) return r;
- const res = r.data;
- if (!res.ok) {
- return err({ kind: "invalid_state", message: `Could not fill ref ${args.ref}: ${res.reason ?? "unknown reason"}`, details: detailsOf(args.ref, res) });
- }
- const wanted = typeof args.value === "boolean" ? undefined : String(args.value);
- const got = res.value !== undefined ? String(res.value) : undefined;
- const mismatch = wanted !== undefined && got !== undefined && got !== wanted;
- let text: string;
- if (mismatch) {
- text = `Filled ref ${args.ref}, but it now reads ${JSON.stringify(got)} (wanted ${JSON.stringify(wanted)})`;
- } else if (got !== undefined) {
- text = `Filled ref ${args.ref} = ${JSON.stringify(got)}`;
- } else if (res.checked !== undefined) {
- text = `Filled ref ${args.ref} (checked=${res.checked})`;
- } else {
- text = `Filled ref ${args.ref}`;
- }
- return ok({ text, details: detailsOf(args.ref, res, { mismatch }) });
- },
-});
-
-const FillFormArgs = Type.Object({
- fields: Type.Array(
- Type.Object({
- ref: Type.Integer({ description: "Element ref from browser_snapshot ([ref=N])." }),
- value: FieldValue,
- }),
- { minItems: 1, description: "Fields to fill in one batch, each by ref." },
- ),
-});
-
-export const fillFormTool = defineBrowserTool({
- name: "browser_fill_form",
- label: "Browser Fill Form",
- description:
- "Fill multiple form fields in one call — the efficient way to complete a form. Each field is { ref, value }; refs come from browser_snapshot's [ref=N]. Handles text inputs, textareas, selects, checkboxes, radios, and contenteditable, drives React/Vue controlled inputs correctly, and reports the resulting value of every field.",
- promptSnippet: "Fill many form fields at once by ref — React-safe, self-confirming",
- promptGuidelines: [
- "Preferred for forms: snapshot once, then fill all fields in a single browser_fill_form call.",
- "Each field: { ref, value }. value is a string for text/select/contenteditable, boolean for checkbox/radio.",
- "The result summarizes how many fields filled cleanly and flags any value mismatches or per-field errors.",
- "Re-run browser_snapshot afterwards to verify the form state if needed.",
- ],
- parameters: FillFormArgs,
- concurrency: "serialized",
- async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
- const results: Array<Readonly<Record<string, unknown>>> = [];
- let okCount = 0;
- const issues: string[] = [];
- for (const f of args.fields) {
- const r = await resolveAndCall(client, f.ref, FILL_FN, [f.value]);
- if (!r.success) {
- results.push({ ref: f.ref, ok: false, error: r.error.message });
- issues.push(`ref ${f.ref} (${r.error.message})`);
- continue;
- }
- const res = r.data;
- const wanted = typeof f.value === "boolean" ? undefined : String(f.value);
- const got = res.value !== undefined ? String(res.value) : undefined;
- const mismatch = wanted !== undefined && got !== undefined && got !== wanted;
- const fieldOk = res.ok === true && !mismatch;
- if (fieldOk) okCount++;
- else issues.push(`ref ${f.ref} (${res.ok === false ? (res.reason ?? "failed") : "value mismatch"})`);
- results.push({ ref: f.ref, ok: fieldOk, mismatch, ...detailsOf(f.ref, res) });
- }
- const text =
- `Filled ${okCount}/${args.fields.length} fields` +
- (issues.length > 0 ? `; issues: ${issues.join(", ")}` : "");
- return ok({ text, details: { results } });
- },
-});
-
-const SelectOptionArgs = Type.Object({
- ref: Type.Integer({ description: "Element ref of a <select> from browser_snapshot ([ref=N])." }),
- value: Type.Optional(Type.String({ description: "Option value attribute to select." })),
- label: Type.Optional(Type.String({ description: "Visible option text/label to select (used if value is omitted)." })),
-});
-
-export const selectOptionTool = defineBrowserTool({
- name: "browser_select_option",
- label: "Browser Select Option",
- description:
- "Select an option in a native <select> dropdown by ref. Matches by option value, then by visible label/text, and fires change. Native selects cannot be driven by clicking coordinates — use this instead. Get the ref from browser_snapshot's [ref=N].",
- promptSnippet: "Choose an option in a <select> dropdown by ref",
- promptGuidelines: [
- "Provide value (the option's value attribute) or label (the visible text). value wins if both given.",
- "On no match, the result lists the available options so you can retry with a valid one.",
- "Use this for native <select> elements — clicking coordinates does not work for OS dropdowns.",
- ],
- parameters: SelectOptionArgs,
- concurrency: "serialized",
- async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
- if (args.value === undefined && args.label === undefined) {
- return err({ kind: "invalid_state", message: "Provide value or label to select an option." });
- }
- const r = await resolveAndCall(client, args.ref, SELECT_FN, [args.value ?? null, args.label ?? null]);
- if (!r.success) return r;
- const res = r.data;
- if (!res.ok) {
- return err({ kind: "invalid_state", message: `Could not select option on ref ${args.ref}: ${res.reason ?? "unknown reason"}`, details: detailsOf(args.ref, res) });
- }
- return ok({
- text: `Selected ${JSON.stringify(res.text)} (value=${JSON.stringify(res.value)}) on ref ${args.ref}`,
- details: detailsOf(args.ref, res),
- });
- },
-});
-
-const SetCheckedArgs = Type.Object({
- ref: Type.Integer({ description: "Element ref of a checkbox or radio from browser_snapshot ([ref=N])." }),
- checked: Type.Boolean({ description: "Desired checked state." }),
-});
-
-export const setCheckedTool = defineBrowserTool({
- name: "browser_set_checked",
- label: "Browser Set Checked",
- description:
- "Set a checkbox or radio to a desired checked state by ref. Only acts if the current state differs, fires change, and reports the final state. Get the ref from browser_snapshot's [ref=N].",
- promptSnippet: "Set a checkbox/radio checked state by ref",
- promptGuidelines: [
- "checked:true ticks the box, checked:false unticks it. Idempotent — no-op if already in the desired state.",
- "For radios, set checked:true on the option you want; the group deselects the others.",
- ],
- parameters: SetCheckedArgs,
- concurrency: "serialized",
- async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
- const r = await resolveAndCall(client, args.ref, CHECK_FN, [args.checked]);
- if (!r.success) return r;
- const res = r.data;
- if (!res.ok) {
- return err({ kind: "invalid_state", message: `Could not set checked on ref ${args.ref}: ${res.reason ?? "unknown reason"}`, details: detailsOf(args.ref, res) });
- }
- return ok({
- text: `Set ref ${args.ref} checked=${res.checked}` + (res.changed === false ? " (already)" : ""),
- details: detailsOf(args.ref, res),
- });
- },
-});
diff --git a/src/registry.ts b/src/registry.ts
index 8301d29..69a1845 100644
--- a/src/registry.ts
+++ b/src/registry.ts
@@ -3,7 +3,8 @@ import type { BrowserClient } from "./client";
import { type AnyBrowserToolDefinition, registerBrowserTool } from "./util/tool";
import { clickTool } from "./domains/click";
import { typeTool, pressKeyTool, dispatchKeyTool } from "./domains/keyboard";
-import { fillTool, selectOptionTool, focusTool } from "./domains/form";
+import { fillTool, selectOptionTool, focusTool, setCheckedTool } from "./domains/form";
+import { fillFormTool } from "./domains/form-batch";
import { pageInfoTool, waitTool, waitForLoadTool, waitForTool } from "./domains/page";
import { scrollTool } from "./domains/scroll";
import { handleDialogTool } from "./domains/dialog";
@@ -27,7 +28,9 @@ export const ALL_TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
clickTool,
typeTool,
fillTool,
+ fillFormTool,
selectOptionTool,
+ setCheckedTool,
focusTool,
pressKeyTool,
dispatchKeyTool,