@@ -2042,3 +2233,91 @@ describe('describeFocusedEditable', () => {
expect(describeFocusedEditable()).toEqual({ editable: true, kind: 'canvas' })
})
})
+
+describe('setFocusedInputValue', () => {
+ for (const [type, value] of [
+ ['date', '2026-09-15'],
+ ['time', '15:48'],
+ ['datetime-local', '2026-09-15T15:48'],
+ ['month', '2026-09'],
+ ['week', '2026-W38'],
+ ['color', '#aabbcc'],
+ ['range', '42'],
+ ]) {
+ it(`sets a validated ${type} value through the native setter`, () => {
+ document.body.innerHTML = `
`
+ const input = visible(document.querySelector('input') as HTMLInputElement)
+ register(input)
+ input.focus()
+ const events: string[] = []
+ input.addEventListener('input', () => events.push('input'))
+ input.addEventListener('change', () => events.push('change'))
+ expect(focusElementForTyping(0)).toMatchObject({ valueInput: true })
+ expect(runSerialized(setFocusedInputValue, [0, value])).toEqual({ dispatched: true })
+ expect(input.value).toBe(value)
+ expect(events).toEqual(['input', 'change'])
+ })
+ }
+
+ it('accepts native datetime normalization and bypasses an overridden value setter', () => {
+ document.body.innerHTML = '
'
+ const input = document.querySelector('input') as HTMLInputElement
+ register(input)
+ input.focus()
+ const setter = vi.fn()
+ Object.defineProperty(input, 'value', {
+ configurable: true,
+ get() {
+ return Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.get?.call(this)
+ },
+ set: setter,
+ })
+ expect(setFocusedInputValue(0, '2026-09-15T15:48:00')).toEqual({ dispatched: true })
+ expect(input.value).toBe('2026-09-15T15:48')
+ expect(setter).not.toHaveBeenCalled()
+ })
+
+ it('does not write to a newly focused input inside a registered container', () => {
+ document.body.innerHTML = '
'
+ const container = visible(document.querySelector('div') as HTMLDivElement)
+ visible(document.querySelector('input') as HTMLInputElement)
+ register(container)
+ expect(focusElementForTyping(0)).toMatchObject({ valueInput: true })
+ const other = document.createElement('input')
+ other.type = 'date'
+ container.append(other)
+ other.focus()
+ expect(setFocusedInputValue(0, '2026-09-15')).toHaveProperty('error')
+ expect(other.value).toBe('')
+ })
+
+ it('rejects malformed values before changing the field or emitting events', () => {
+ document.body.innerHTML = '
'
+ const input = document.querySelector('input') as HTMLInputElement
+ register(input)
+ input.focus()
+ const changed = vi.fn()
+ input.addEventListener('input', changed)
+ expect(setFocusedInputValue(0, '2026-02-30')).toMatchObject({
+ error: expect.stringContaining('Invalid value'),
+ })
+ expect(input.value).toBe('2026-01-01')
+ expect(changed).not.toHaveBeenCalled()
+ })
+
+ it('refuses changed focus, readonly fields, and credential hints', () => {
+ document.body.innerHTML = '
'
+ const [input, other] = Array.from(document.querySelectorAll('input'))
+ register(input)
+ other.focus()
+ expect(setFocusedInputValue(0, '2026-09-15')).toEqual({ error: 'different' })
+ input.focus()
+ input.readOnly = true
+ expect(setFocusedInputValue(0, '2026-09-15')).toEqual({ error: 'readonly' })
+ input.readOnly = false
+ input.autocomplete = 'current-password'
+ expect(setFocusedInputValue(0, '2026-09-15')).toEqual({ error: 'password' })
+ expect(input.value).toBe('')
+ expect(other.value).toBe('')
+ })
+})
diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts
index 419ae827182..f2e4ff93a4e 100644
--- a/apps/desktop/src/main/browser-agent/page-functions.ts
+++ b/apps/desktop/src/main/browser-agent/page-functions.ts
@@ -163,8 +163,8 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn
const lines: string[] = []
let truncated = false
let refCount = 0
- let textRefCount = 0
- const textRefCap = 120
+ let textLineCount = 0
+ const textLineCap = 120
let visitedNodes = 0
const previousElementId = window.__simAgentNextElementId
const safePreviousElementId =
@@ -480,6 +480,9 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn
if (el.getAttribute('aria-required') === 'true') parts.push('aria-required')
if (tag === 'INPUT') {
const input = el as HTMLInputElement
+ if (!['text', 'checkbox', 'radio', 'submit', 'button', 'reset'].includes(input.type)) {
+ parts.push(`type=${quote(input.type)}`)
+ }
if (input.type === 'checkbox' || input.type === 'radio') {
parts.push(input.indeterminate ? 'mixed' : input.checked ? 'checked' : 'unchecked')
}
@@ -489,8 +492,9 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn
const textarea = el as HTMLTextAreaElement
if (textarea.readOnly) parts.push('readonly')
if (textarea.required) parts.push('required')
- } else if (tag === 'SELECT' && (el as HTMLSelectElement).required) {
- parts.push('required')
+ } else if (tag === 'SELECT') {
+ if ((el as HTMLSelectElement).required) parts.push('required')
+ if ((el as HTMLSelectElement).multiple) parts.push('multiple')
}
for (const attribute of ['aria-checked', 'aria-expanded', 'aria-pressed', 'aria-selected']) {
const value = el.getAttribute(attribute)
@@ -506,7 +510,7 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn
}
const emitTextLeaf = (el: Element, indent: string, renderedLabel?: string): void => {
- if (refCount >= refCap || textRefCount >= textRefCap || lines.length >= lineCap) {
+ if (refCount >= refCap || textLineCount >= textLineCap || lines.length >= lineCap) {
truncated = true
return
}
@@ -518,7 +522,7 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn
)
if (!text) return
const id = registerElement(el, roleFor(el), text)
- textRefCount++
+ textLineCount++
const lineIndex = lines.length
if (push(`${indent}- text ${quote(text)} [ref=${id}]`)) refLineIndexes[id] = lineIndex
}
@@ -562,24 +566,47 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn
)
}
- const walk = (elements: Iterable
, depth: number, suppressTextCoveredBy = ''): void => {
+ const walk = (nodes: Iterable, depth: number, suppressTextCoveredBy = ''): void => {
if (refCount >= refCap || depth > depthCap) {
truncated = true
return
}
- for (const el of elements) {
+ for (const node of nodes) {
visitedNodes++
if (refCount >= refCap || visitedNodes > nodeCap) {
truncated = true
return
}
+ const indent = ' '.repeat(depth)
+ if (node.nodeType === Node.TEXT_NODE) {
+ const root = node.getRootNode()
+ const parent = node.parentElement ?? ('host' in root ? (root.host as Element) : null)
+ if (parent?.tagName.toUpperCase() === 'TEXTAREA') continue
+ const text = cut((node.textContent || '').replace(/\s+/g, ' ').trim(), 160)
+ if (
+ text &&
+ parent &&
+ isVisible(parent) &&
+ (!suppressTextCoveredBy || !suppressTextCoveredBy.includes(text))
+ ) {
+ if (textLineCount >= textLineCap) {
+ truncated = true
+ continue
+ }
+ if (!push(`${indent}- text ${quote(text)}`)) return
+ textLineCount++
+ }
+ continue
+ }
+ if (node.nodeType !== Node.ELEMENT_NODE) continue
+ const el = node as Element
const tag = String(el.tagName || '').toUpperCase()
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEMPLATE') continue
- const indent = ' '.repeat(depth)
let childDepth = depth
let emittedInteractive = false
let interactiveName = ''
+ let emittedText = ''
const visible = isVisible(el)
if (el.matches(landmarkSelector) && visible) {
@@ -587,15 +614,15 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn
childDepth = depth + 1
} else {
const level = headingLevel(el)
- if (level !== null && visible) {
- const text = cut(((el as HTMLElement).innerText || '').replace(/\s+/g, ' ').trim(), 160)
- if (text) push(`${indent}- heading ${quote(text)} (h${level})`)
- } else if (visible && (el.matches(interactiveSelector) || pointerBoundary(el))) {
+ if (visible && (el.matches(interactiveSelector) || pointerBoundary(el))) {
emitInteractive(el, indent)
emittedInteractive = true
interactiveName = nameFor(el)
// Interactive containers rarely nest other interactives; still
// recurse so e.g. a clickable card exposes its inner links.
+ } else if (level !== null && visible) {
+ emittedText = cut(((el as HTMLElement).innerText || '').replace(/\s+/g, ' ').trim(), 160)
+ if (emittedText) push(`${indent}- heading ${quote(emittedText)} (h${level})`)
} else if (visible) {
const visibleElementChild = Array.from(el.children).some(isVisible)
const leafLabel = visibleElementChild
@@ -609,18 +636,21 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn
(!suppressTextCoveredBy || !suppressTextCoveredBy.includes(leafLabel))
) {
emitTextLeaf(el, indent, leafLabel)
+ emittedText = leafLabel
}
}
}
- const coveredText = emittedInteractive ? interactiveName : suppressTextCoveredBy
+ const coveredText = emittedInteractive
+ ? interactiveName
+ : emittedText || suppressTextCoveredBy
if (tag === 'IFRAME' || tag === 'FRAME') {
try {
const innerDoc = (el as HTMLIFrameElement).contentDocument
if (innerDoc?.body && isVisible(el)) {
if (!push(`${indent}- iframe:`)) return
- walk(innerDoc.body.children, childDepth + 1, coveredText)
+ walk(innerDoc.body.childNodes, childDepth + 1, coveredText)
} else if (scopedRoot && !innerDoc && visible) {
truncated = true
}
@@ -631,13 +661,13 @@ export function collectSnapshot(startingElementId = 0, elementId?: number): unkn
}
const shadow = (el as HTMLElement).shadowRoot
- if (shadow) walk(shadow.children, childDepth, coveredText)
- walk(el.children, childDepth, coveredText)
+ if (shadow) walk(shadow.childNodes, childDepth, coveredText)
+ walk(el.childNodes, childDepth, coveredText)
}
}
if (scopedRoot) walk([scopedRoot], 0)
- else if (document.body) walk(document.body.children, 0)
+ else if (document.body) walk(document.body.childNodes, 0)
/**
* React commonly replaces a control's DOM node while preserving its
@@ -929,7 +959,8 @@ export function clickElement(
id: number,
dispatchSynthetic = true,
focusForKeyboard = false,
- allowDisabled = false
+ allowDisabled = false,
+ scrollToTarget = false
): unknown {
const isSecretField = (node: Element | null): boolean => {
if (!node || String(node.tagName || '').toUpperCase() !== 'INPUT') return false
@@ -974,7 +1005,10 @@ export function clickElement(
return { error: 'file-input' }
}
}
- el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' })
+ if (scrollToTarget) {
+ el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' })
+ if (!el.isConnected) return { error: 'stale', reason: window.__simAgentStaleReason }
+ }
const view = el.ownerDocument.defaultView
if (!view) return { error: 'stale', reason: window.__simAgentStaleReason }
@@ -1021,7 +1055,11 @@ export function clickElement(
rect.right - rect.left > 1 &&
rect.bottom - rect.top > 1
)
- if (rects.length === 0) return { error: 'not-visible' }
+ if (rects.length === 0) {
+ return scrollToTarget
+ ? { error: 'not-visible' }
+ : clickElement(id, dispatchSynthetic, focusForKeyboard, allowDisabled, true)
+ }
const composedParent = (node: Element): Element | null => {
if (node.parentElement) return node.parentElement
@@ -1202,6 +1240,9 @@ export function clickElement(
if (suggestionsCoverFocusedEditable()) {
return { error: 'suggestions-open', blocker: blockerLabel(blocker) }
}
+ if (!scrollToTarget) {
+ return clickElement(id, dispatchSynthetic, focusForKeyboard, allowDisabled, true)
+ }
// A hit INSIDE the requested element is not an overlay — it is the ref
// wrapping its own control (a row containing a button, a card containing a
// link). hitBelongsToTarget rejects both cases identically, so this was
@@ -1247,6 +1288,9 @@ export function clickElement(
if (parentElementAt) {
const parentHit: Element | null = parentElementAt(pageX, pageY)
if (parentHit !== frame) {
+ if (!scrollToTarget) {
+ return clickElement(id, dispatchSynthetic, focusForKeyboard, allowDisabled, true)
+ }
return { error: 'obstructed', blocker: blockerLabel(parentHit) }
}
}
@@ -1340,6 +1384,7 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown {
.some((token) => token === 'current-password' || token === 'new-password')
}
+ const valueInputTypes = ['date', 'time', 'datetime-local', 'month', 'week', 'color', 'range']
const resolver = window.__simAgentResolveElement
const resolved = resolver?.(id)
const el = resolver ? resolved?.element : (window.__simAgentElements || [])[id]
@@ -1352,7 +1397,7 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown {
if (field.readOnly || field.getAttribute('aria-readonly') === 'true') return 'readonly'
if (String(field.tagName || '').toUpperCase() === 'TEXTAREA') return 'writable'
const type = String((field as HTMLInputElement).type || 'text').toLowerCase()
- return ['text', 'search', 'email', 'url', 'tel', 'number'].includes(type)
+ return ['text', 'search', 'email', 'url', 'tel', 'number', ...valueInputTypes].includes(type)
? 'writable'
: 'not-editable'
}
@@ -1366,7 +1411,16 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown {
if (
tag === 'TEXTAREA' ||
(tag === 'INPUT' &&
- ['text', 'search', 'email', 'url', 'tel', 'number', 'password'].includes(inputType)) ||
+ [
+ 'text',
+ 'search',
+ 'email',
+ 'url',
+ 'tel',
+ 'number',
+ 'password',
+ ...valueInputTypes,
+ ].includes(inputType)) ||
(node as HTMLElement).isContentEditable ||
// An ARIA-only textbox. The snapshot already advertises these as
// `[textbox]` with a ref, and browser_insert_text accepts them, so
@@ -1628,10 +1682,67 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown {
x: chosenPoint.x,
y: chosenPoint.y,
coveredByRelatedPopup,
+ valueInput:
+ editableTag === 'INPUT' && valueInputTypes.includes((editable as HTMLInputElement).type),
refRecovered: resolved?.recovered === true,
}
}
+/** Sets structured native inputs after the driver's ordinary typing actionability checks. */
+export function setFocusedInputValue(id: number, text: string): unknown {
+ const resolver = window.__simAgentResolveElement
+ const resolved = resolver?.(id)
+ const registered = resolver ? resolved?.element : (window.__simAgentElements || [])[id]
+ if (!registered?.isConnected) return { error: 'stale', reason: window.__simAgentStaleReason }
+ let active = registered.ownerDocument.activeElement
+ while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement
+ if (String(registered.tagName || '').toUpperCase() !== 'INPUT') {
+ return {
+ error:
+ 'Structured inputs require the field reference itself, not a container. Take a fresh browser_snapshot.',
+ }
+ }
+ if (active !== registered) return { error: 'different' }
+ const input = active as HTMLInputElement
+ const type = input.type.toLowerCase()
+ const hints = (input.getAttribute('autocomplete') || '').toLowerCase().split(/\s+/)
+ if (
+ type === 'password' ||
+ hints.some((hint) => hint === 'current-password' || hint === 'new-password')
+ ) {
+ return { error: 'password' }
+ }
+ if (!['date', 'time', 'datetime-local', 'month', 'week', 'color', 'range'].includes(type)) {
+ return {
+ error:
+ 'The focused field no longer accepts a structured input value. Take a fresh browser_snapshot.',
+ }
+ }
+ if (input.matches(':disabled') || input.getAttribute('aria-disabled') === 'true')
+ return { error: 'disabled' }
+ if (input.readOnly || input.getAttribute('aria-readonly') === 'true') return { error: 'readonly' }
+ const value = type === 'color' ? text.trim().toLowerCase() : text.trim()
+ const probe = input.cloneNode(false) as HTMLInputElement
+ probe.value = value
+ if (
+ (value !== '' && probe.value === '') ||
+ (['color', 'range'].includes(type) && probe.value !== value)
+ ) {
+ return {
+ error: `Invalid value for input[type=${type}]. Use the native format; the field was not changed.`,
+ }
+ }
+ const view = input.ownerDocument.defaultView
+ if (!view) return { error: 'stale' }
+ const setter = Object.getOwnPropertyDescriptor(view.HTMLInputElement.prototype, 'value')?.set
+ if (!setter)
+ return { error: 'The native input value setter is unavailable; the field was not changed.' }
+ setter.call(input, probe.value)
+ input.dispatchEvent(new view.Event('input', { bubbles: true, composed: true }))
+ input.dispatchEvent(new view.Event('change', { bubbles: true }))
+ return { dispatched: true }
+}
+
/**
* Reads back the focused element's state after a native key/type action so
* the driver can report what actually happened instead of assuming success.
@@ -2657,42 +2768,74 @@ export function scrollPage(direction: string, amount?: number, elementId?: numbe
}
}
-export function selectOptionInElement(id: number, value: string): unknown {
+export function selectOptionInElement(id: number, value: string | string[]): unknown {
const resolver = window.__simAgentResolveElement
const resolved = resolver?.(id)
const el = resolver ? resolved?.element : (window.__simAgentElements || [])[id]
if (!el || !el.isConnected) return { error: 'stale', reason: window.__simAgentStaleReason }
if (String(el.tagName || '').toUpperCase() !== 'SELECT') return { error: 'not-select' }
const select = el as HTMLSelectElement
- if (select.disabled || select.getAttribute('aria-disabled') === 'true') {
+ if (select.matches(':disabled') || select.getAttribute('aria-disabled') === 'true') {
return { error: 'disabled' }
}
- const wanted = value.trim().toLowerCase()
- const option = Array.from(select.options).find(
- (o) => o.value.trim().toLowerCase() === wanted || o.label.trim().toLowerCase() === wanted
- )
- if (!option) {
+ if (Array.isArray(value) && !select.multiple) {
return {
- error: 'no-option',
- options: Array.from(select.options)
- .slice(0, 50)
- .map((o) =>
- o.label
+ error:
+ 'Use value for a single-selection dropdown; values requires a multiple-selection control.',
+ }
+ }
+ const requested = Array.isArray(value) ? value : [value]
+ if (requested.length > 100 || requested.some((entry) => typeof entry !== 'string')) {
+ return { error: 'A selection requires at most 100 string values.' }
+ }
+ const options = Array.from(select.options)
+ const chosen = new Set()
+ for (const entry of requested) {
+ const wanted = entry.trim().toLowerCase()
+ const option = options.find(
+ (candidate) =>
+ candidate.value.trim().toLowerCase() === wanted ||
+ candidate.label.trim().toLowerCase() === wanted
+ )
+ if (!option) {
+ return {
+ error: 'no-option',
+ options: options.slice(0, 50).map((candidate) =>
+ candidate.label
.trim()
.slice(0, 200)
.replace(/[\uD800-\uDBFF]$/, '')
),
+ }
}
+ if (
+ option.disabled ||
+ (option.parentElement as HTMLOptGroupElement | null)?.disabled === true
+ ) {
+ return { error: 'disabled' }
+ }
+ chosen.add(option)
}
- if (option.disabled || (option.parentElement as HTMLOptGroupElement | null)?.disabled === true) {
- return { error: 'disabled' }
+ const selected = options.filter((option) => chosen.has(option))
+ const selection = {
+ selected: selected[0]?.label.trim() || '',
+ value: selected[0]?.value || '',
+ ...(select.multiple
+ ? {
+ values: selected.map((option) => option.value),
+ labels: selected.map((option) => option.label.trim()),
+ }
+ : {}),
+ }
+ if (select.multiple) {
+ for (const option of options) option.selected = chosen.has(option)
+ } else {
+ select.value = selected[0].value
}
- select.value = option.value
select.dispatchEvent(new Event('input', { bubbles: true }))
select.dispatchEvent(new Event('change', { bubbles: true }))
return {
- selected: option.label.trim(),
- value: option.value,
+ ...selection,
refRecovered: resolved?.recovered === true,
}
}
@@ -2804,9 +2947,19 @@ export function readSelectElementState(id: number): unknown {
if (!el || !el.isConnected) return { error: 'stale', reason: window.__simAgentStaleReason }
if (String(el.tagName || '').toUpperCase() !== 'SELECT') return { error: 'not-select' }
const select = el as HTMLSelectElement
+ const values: string[] = []
+ const labels: string[] = []
+ if (select.multiple) {
+ for (const option of select.selectedOptions) {
+ values.push(option.value)
+ labels.push(option.label.trim())
+ if (values.length > 100) break
+ }
+ }
return {
selected: select.selectedOptions[0]?.label.trim() || '',
value: select.value,
+ ...(select.multiple ? { values, labels } : {}),
}
}
diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
index dbf390a7c80..d82e0066cb7 100644
--- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
+++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
@@ -1626,16 +1626,27 @@ export const BrowserSelectOption: ToolCatalogEntry = {
route: 'client',
mode: 'async',
parameters: {
- type: 'object',
+ oneOf: [{ required: ['value'] }, { required: ['values'] }],
properties: {
elementId: {
- type: 'number',
description:
"The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.",
+ type: 'number',
+ },
+ value: {
+ description: "One option's visible label or value. Omit when supplying values.",
+ type: 'string',
+ },
+ values: {
+ description:
+ 'The complete desired selection for a native multiple-selection control: at most 100 visible labels or values. Empty array clears the selection. Omit value when using this field.',
+ items: { type: 'string' },
+ maxItems: 100,
+ type: 'array',
},
- value: { type: 'string', description: "The option's visible label or its value." },
},
- required: ['elementId', 'value'],
+ required: ['elementId'],
+ type: 'object',
},
resultSchema: {
type: 'object',
@@ -1644,6 +1655,12 @@ export const BrowserSelectOption: ToolCatalogEntry = {
type: 'boolean',
description: 'Whether the settled readback retained the requested selection.',
},
+ labels: {
+ type: 'array',
+ description:
+ 'Visible labels for the complete selected set in a multiple-selection control, in option order.',
+ items: { type: 'string' },
+ },
note: { type: 'string', description: 'Guidance when the page reverted the selection.' },
notices: {
type: 'array',
@@ -1655,8 +1672,20 @@ export const BrowserSelectOption: ToolCatalogEntry = {
type: 'object',
description: 'Settled selected label and value.',
properties: {
+ labels: {
+ type: 'array',
+ description:
+ 'Visible labels for the complete selected set in a multiple-selection control, in option order.',
+ items: { type: 'string' },
+ },
selected: { type: 'string', description: 'Settled visible option label.' },
value: { type: 'string', description: 'Settled option value.' },
+ values: {
+ type: 'array',
+ description:
+ 'Selected native option values in DOM order; included for multiple-selection controls.',
+ items: { type: 'string' },
+ },
},
},
refRecovered: {
@@ -1666,6 +1695,12 @@ export const BrowserSelectOption: ToolCatalogEntry = {
},
selected: { type: 'string', description: 'Canonical visible label of the matched option.' },
value: { type: 'string', description: 'Canonical value of the matched option.' },
+ values: {
+ type: 'array',
+ description:
+ 'Selected native option values in DOM order; included for multiple-selection controls.',
+ items: { type: 'string' },
+ },
},
required: ['selected'],
},
@@ -1818,7 +1853,7 @@ export const BrowserType: ToolCatalogEntry = {
text: {
type: 'string',
description:
- "The text to type. Replaces the element's current content. Must be non-empty — an empty string is rejected as a missing parameter; to clear a field, press Mod+A then Backspace with browser_press_key.",
+ 'The replacement value. Empty text clears an ordinary text field. For structured inputs use YYYY-MM-DD (date), HH:mm (time), YYYY-MM-DDTHH:mm (datetime-local), YYYY-MM (month), YYYY-Www (week), #rrggbb (color), or a numeric range value. Alternatively use Mod+A then Backspace to clear ordinary text with browser_press_key.',
},
},
required: ['elementId', 'text'],
diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
index cb977c36ed5..a1b5c10ecb9 100644
--- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
+++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
@@ -1540,19 +1540,36 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
browser_select_option: {
parameters: {
- type: 'object',
+ oneOf: [
+ {
+ required: ['value'],
+ },
+ {
+ required: ['values'],
+ },
+ ],
properties: {
elementId: {
- type: 'number',
description:
"The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.",
+ type: 'number',
},
value: {
+ description: "One option's visible label or value. Omit when supplying values.",
type: 'string',
- description: "The option's visible label or its value.",
+ },
+ values: {
+ description:
+ 'The complete desired selection for a native multiple-selection control: at most 100 visible labels or values. Empty array clears the selection. Omit value when using this field.',
+ items: {
+ type: 'string',
+ },
+ maxItems: 100,
+ type: 'array',
},
},
- required: ['elementId', 'value'],
+ required: ['elementId'],
+ type: 'object',
},
resultSchema: {
type: 'object',
@@ -1561,6 +1578,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
type: 'boolean',
description: 'Whether the settled readback retained the requested selection.',
},
+ labels: {
+ type: 'array',
+ description:
+ 'Visible labels for the complete selected set in a multiple-selection control, in option order.',
+ items: {
+ type: 'string',
+ },
+ },
note: {
type: 'string',
description: 'Guidance when the page reverted the selection.',
@@ -1577,6 +1602,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
type: 'object',
description: 'Settled selected label and value.',
properties: {
+ labels: {
+ type: 'array',
+ description:
+ 'Visible labels for the complete selected set in a multiple-selection control, in option order.',
+ items: {
+ type: 'string',
+ },
+ },
selected: {
type: 'string',
description: 'Settled visible option label.',
@@ -1585,6 +1618,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
type: 'string',
description: 'Settled option value.',
},
+ values: {
+ type: 'array',
+ description:
+ 'Selected native option values in DOM order; included for multiple-selection controls.',
+ items: {
+ type: 'string',
+ },
+ },
},
},
refRecovered: {
@@ -1600,6 +1641,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
type: 'string',
description: 'Canonical value of the matched option.',
},
+ values: {
+ type: 'array',
+ description:
+ 'Selected native option values in DOM order; included for multiple-selection controls.',
+ items: {
+ type: 'string',
+ },
+ },
},
required: ['selected'],
},
@@ -1769,7 +1818,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
text: {
type: 'string',
description:
- "The text to type. Replaces the element's current content. Must be non-empty — an empty string is rejected as a missing parameter; to clear a field, press Mod+A then Backspace with browser_press_key.",
+ 'The replacement value. Empty text clears an ordinary text field. For structured inputs use YYYY-MM-DD (date), HH:mm (time), YYYY-MM-DDTHH:mm (datetime-local), YYYY-MM (month), YYYY-Www (week), #rrggbb (color), or a numeric range value. Alternatively use Mod+A then Backspace to clear ordinary text with browser_press_key.',
},
},
required: ['elementId', 'text'],
diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts
index 26ac1d9200a..1fdb9165092 100644
--- a/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts
+++ b/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts
@@ -581,6 +581,25 @@ describe('executeBrowserToolOnClient', () => {
}
)
+ it('reports an unconfirmed effect without retrying or marking completed input as failed', async () => {
+ const result = { dispatched: true, effectObserved: false, possibleEffectObserved: true }
+ mockExecuteBrowserTool.mockResolvedValue(result)
+ const toolCallId = nextToolCallId()
+
+ executeBrowserToolOnClient(toolCallId, 'browser_click', { elementId: 1 })
+ await flush()
+ executeBrowserToolOnClient(toolCallId, 'browser_click', { elementId: 1 })
+ await flush()
+
+ expect(mockExecuteBrowserTool).toHaveBeenCalledOnce()
+ expect(mockReportCompletion).toHaveBeenCalledWith(
+ toolCallId,
+ 'success',
+ 'Browser input completed; its effect is unconfirmed. Inspect the current state before retrying.',
+ result
+ )
+ })
+
it('uses unload-safe delivery when a stateful replay-guard rejection cannot be reported normally', async () => {
const storageWrite = vi.spyOn(window.sessionStorage, 'setItem').mockImplementation(() => {
throw new DOMException('Quota exceeded', 'QuotaExceededError')
diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts b/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts
index c9b52d8b139..ced31164d4f 100644
--- a/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts
+++ b/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts
@@ -984,6 +984,7 @@ async function doExecuteBrowserTool(
}
nativeActionPending = false
if (cancelled) return
+ const effectUnconfirmed = isRecordLike(result) && result.effectObserved === false
const formStopped =
toolName === 'browser_fill_form' && isRecordLike(result) && result.completed === false
reportTerminalCompletion(
@@ -993,7 +994,9 @@ async function doExecuteBrowserTool(
: ASYNC_TOOL_CONFIRMATION_STATUS.success,
message: formStopped
? 'Form filling stopped; inspect the partial result'
- : 'Browser action completed',
+ : effectUnconfirmed
+ ? 'Browser input completed; its effect is unconfirmed. Inspect the current state before retrying.'
+ : 'Browser action completed',
data: sanitizeResultForModel(toolName, result),
},
'Failed to report successful browser tool completion'
diff --git a/apps/sim/lib/copilot/tools/server/generated-schema.test.ts b/apps/sim/lib/copilot/tools/server/generated-schema.test.ts
index 1df1ba9e75a..3dc3af8aabb 100644
--- a/apps/sim/lib/copilot/tools/server/generated-schema.test.ts
+++ b/apps/sim/lib/copilot/tools/server/generated-schema.test.ts
@@ -5,6 +5,30 @@ import { describe, expect, it } from 'vitest'
import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generated-schema'
import { OrchestrationError } from '@/lib/core/orchestration/types'
+describe('validateGeneratedToolPayload browser_select_option parameters', () => {
+ it.each([
+ { elementId: 0, value: 'a' },
+ { elementId: 0, values: ['a', 'b'] },
+ { elementId: 0, values: [] },
+ ])('accepts a single selection mode %#', (payload) => {
+ expect(validateGeneratedToolPayload('browser_select_option', 'parameters', payload)).toBe(
+ payload
+ )
+ })
+
+ it.each([
+ { elementId: 0 },
+ { elementId: 0, value: 'a', values: ['b'] },
+ { elementId: 0, value: 'a', values: [] },
+ { elementId: 0, values: [1] },
+ { elementId: 0, values: Array.from({ length: 101 }, () => 'a') },
+ ])('rejects missing, conflicting or malformed selection arguments %#', (payload) => {
+ expect(() =>
+ validateGeneratedToolPayload('browser_select_option', 'parameters', payload)
+ ).toThrow(OrchestrationError)
+ })
+})
+
describe('validateGeneratedToolPayload browser_fill_form parameters', () => {
it('accepts mixed fields, including empty text and false checked state', () => {
const payload = {