yunti-browser-runtime 0.1.3 → 0.2.1

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.
@@ -73,12 +73,16 @@ export function createToolDispatcher({
73
73
  result = await evaluateScript(tabId, session, event.arguments || {})
74
74
  } else if (event.tool === "yunti_take_snapshot") {
75
75
  result = await takeSnapshot(tabId, session, event.arguments || {})
76
+ } else if (event.tool === "yunti_observe_page") {
77
+ result = await observePage(tabId, session, event.arguments || {})
76
78
  } else if (event.tool === "yunti_click") {
77
79
  result = await clickByUid(tabId, session, event.arguments || {})
78
80
  } else if (event.tool === "yunti_hover") {
79
81
  result = await hoverByUid(tabId, session, event.arguments || {})
80
82
  } else if (event.tool === "yunti_fill") {
81
83
  result = await fillByUid(tabId, session, event.arguments || {})
84
+ } else if (event.tool === "yunti_select") {
85
+ result = await selectElement(tabId, session, event.arguments || {})
82
86
  } else if (event.tool === "yunti_fill_form") {
83
87
  result = await fillForm(tabId, session, event.arguments || {})
84
88
  } else if (event.tool === "yunti_wait_for") {
@@ -105,6 +109,8 @@ export function createToolDispatcher({
105
109
  result = await typeTextByUid(tabId, session, event.arguments || {})
106
110
  } else if (event.tool === "yunti_press_key") {
107
111
  result = await pressKeyByUid(tabId, session, event.arguments || {})
112
+ } else if (event.tool === "yunti_scroll") {
113
+ result = await scrollPage(tabId, session, event.arguments || {})
108
114
  } else {
109
115
  result = await chrome.tabs.sendMessage(tabId, {
110
116
  type: "yunti_execute_tool",
@@ -343,8 +349,8 @@ export function createToolDispatcher({
343
349
  }
344
350
  }
345
351
 
346
- // Snapshot state — maps uid to element info for click/fill/hover
347
- const snapshotStore = new Map()
352
+ // Latest page uid state from yunti_observe_page or yunti_take_snapshot.
353
+ const pageUidStore = new Map()
348
354
 
349
355
  async function takeSnapshot(tabId, session, args = {}) {
350
356
  const maxElements = Number.isFinite(Number(args.maxElements))
@@ -367,12 +373,7 @@ export function createToolDispatcher({
367
373
  elements = await domFallbackSnapshot(tabId, maxElements)
368
374
  }
369
375
 
370
- // Build uid mapping
371
- const uidMap = {}
372
- for (const el of elements) {
373
- uidMap[el.uid] = { ...el }
374
- }
375
- snapshotStore.set(session.browserSessionId, uidMap)
376
+ storePageUidMap(session, "snapshot", elements)
376
377
 
377
378
  return {
378
379
  browserSessionId: session.browserSessionId,
@@ -383,6 +384,38 @@ export function createToolDispatcher({
383
384
  snapshotId: `snap-${Date.now()}`,
384
385
  }
385
386
  }
387
+
388
+ async function observePage(tabId, session, args = {}) {
389
+ const observation = await chrome.tabs.sendMessage(tabId, {
390
+ type: "yunti_execute_tool",
391
+ tool: "yunti_observe_page",
392
+ arguments: args,
393
+ })
394
+ if (Array.isArray(observation?.elements)) {
395
+ storePageUidMap(session, "observe", [
396
+ ...observation.elements,
397
+ ...(Array.isArray(observation.scrollableContainers) ? observation.scrollableContainers : []),
398
+ ], {
399
+ observationId: observation.observationId,
400
+ uidMapVersion: observation.uidMapVersion,
401
+ })
402
+ }
403
+ return observation
404
+ }
405
+
406
+ function storePageUidMap(session, source, elements, meta = {}) {
407
+ const uidMap = {}
408
+ for (const el of elements || []) {
409
+ if (!el?.uid) continue
410
+ uidMap[el.uid] = { ...el, source }
411
+ }
412
+ pageUidStore.set(session.browserSessionId, {
413
+ source,
414
+ uidMap,
415
+ storedAt: Date.now(),
416
+ ...meta,
417
+ })
418
+ }
386
419
 
387
420
  function flattenAXTree(nodes, maxElements, includeHidden) {
388
421
  const elements = []
@@ -501,7 +534,30 @@ export function createToolDispatcher({
501
534
  async function clickByUid(tabId, session, args = {}) {
502
535
  const uid = String(args.uid || "").trim()
503
536
  if (uid) {
504
- return clickViaSnapshotUid(tabId, session, uid)
537
+ const point = resolveObservedUidCenter(session, uid)
538
+ if (!point.ok) return buildUidPointFailureResult(session, uid, "click", point.error)
539
+ const result = await chrome.tabs.sendMessage(tabId, {
540
+ type: "yunti_execute_tool",
541
+ tool: "yunti_click",
542
+ arguments: { ...args, x: point.x, y: point.y },
543
+ })
544
+ if (!result || typeof result !== "object" || result.clicked !== true) {
545
+ return buildUidPointFailureResult(session, uid, "click", result?.error || "Uid click failed")
546
+ }
547
+ const roundedX = Math.round(point.x)
548
+ const roundedY = Math.round(point.y)
549
+ return {
550
+ ...result,
551
+ uid,
552
+ x: roundedX,
553
+ y: roundedY,
554
+ browserSessionId: session.browserSessionId,
555
+ action: "click",
556
+ target: { uid, x: roundedX, y: roundedY },
557
+ ok: true,
558
+ recoverable: false,
559
+ nextStepHint: "Click dispatched without attaching Chrome debugger. Observe again or read page state to verify the intended change.",
560
+ }
505
561
  }
506
562
  const x = Number(args.x)
507
563
  const y = Number(args.y)
@@ -509,23 +565,80 @@ export function createToolDispatcher({
509
565
  if (!Number.isFinite(x) || !Number.isFinite(y)) {
510
566
  throw new Error("yunti_click coordinate mode requires both x and y. Use yunti_click_at with x/y, or call yunti_take_snapshot and pass uid.")
511
567
  }
512
- return clickAtCoordinate(tabId, session, x, y)
568
+ const result = await chrome.tabs.sendMessage(tabId, {
569
+ type: "yunti_execute_tool",
570
+ tool: "yunti_click",
571
+ arguments: { ...args, x, y },
572
+ })
573
+ if (!result || typeof result !== "object" || result.clicked !== true) return result
574
+ const roundedX = Math.round(x)
575
+ const roundedY = Math.round(y)
576
+ return {
577
+ ...result,
578
+ x: roundedX,
579
+ y: roundedY,
580
+ browserSessionId: session.browserSessionId,
581
+ method: "coordinate",
582
+ action: "click",
583
+ target: { method: "coordinate", x: roundedX, y: roundedY },
584
+ ok: true,
585
+ recoverable: false,
586
+ nextStepHint: "Coordinate click dispatched without attaching Chrome debugger. Observe again, read page state, or use a fresh uid when possible to verify the intended change.",
587
+ }
513
588
  }
514
- if (!String(args.selector || "").trim()) {
589
+ const selector = String(args.selector || "").trim()
590
+ if (!selector) {
515
591
  throw new Error("yunti_click requires uid, selector, or both x and y. Call yunti_take_snapshot to get uid, pass a CSS selector, or use yunti_click_at for coordinate-only clicks.")
516
592
  }
517
593
  // Fall back to content script for selector-based click
518
- return chrome.tabs.sendMessage(tabId, {
594
+ const contentResult = await chrome.tabs.sendMessage(tabId, {
519
595
  type: "yunti_execute_tool",
520
596
  tool: "yunti_click",
521
597
  arguments: args,
522
598
  })
599
+ if (contentResult && typeof contentResult === "object" && contentResult.clicked === true) {
600
+ return {
601
+ ...contentResult,
602
+ selector,
603
+ browserSessionId: session.browserSessionId,
604
+ method: "selector",
605
+ action: "click",
606
+ target: { selector, method: "selector" },
607
+ ok: true,
608
+ recoverable: false,
609
+ nextStepHint: "Selector click dispatched. Observe again, read page state, or use a fresh uid when possible to verify the intended change.",
610
+ }
611
+ }
612
+ return contentResult
523
613
  }
524
614
 
525
615
  async function hoverByUid(tabId, session, args = {}) {
526
616
  const uid = String(args.uid || "").trim()
527
617
  if (uid) {
528
- return hoverViaSnapshotUid(tabId, session, uid)
618
+ const point = resolveObservedUidCenter(session, uid)
619
+ if (!point.ok) return buildUidPointFailureResult(session, uid, "hover", point.error)
620
+ const result = await chrome.tabs.sendMessage(tabId, {
621
+ type: "yunti_execute_tool",
622
+ tool: "yunti_hover",
623
+ arguments: { ...args, x: point.x, y: point.y },
624
+ })
625
+ if (!result || typeof result !== "object" || result.hovered !== true) {
626
+ return buildUidPointFailureResult(session, uid, "hover", result?.error || "Uid hover failed")
627
+ }
628
+ const roundedX = Math.round(point.x)
629
+ const roundedY = Math.round(point.y)
630
+ return {
631
+ ...result,
632
+ uid,
633
+ x: roundedX,
634
+ y: roundedY,
635
+ browserSessionId: session.browserSessionId,
636
+ action: "hover",
637
+ target: { uid, x: roundedX, y: roundedY },
638
+ ok: true,
639
+ recoverable: false,
640
+ nextStepHint: "Hover dispatched without attaching Chrome debugger. Observe again or read page state to verify menus, tooltips, or hover-only controls.",
641
+ }
529
642
  }
530
643
  const x = Number(args.x)
531
644
  const y = Number(args.y)
@@ -533,22 +646,49 @@ export function createToolDispatcher({
533
646
  if (!Number.isFinite(x) || !Number.isFinite(y)) {
534
647
  throw new Error("yunti_hover coordinate mode requires both x and y. Call yunti_take_snapshot to get uid, pass selector, or provide both coordinates.")
535
648
  }
536
- await mouseMove(tabId, x, y)
537
- return { hovered: true, x: Math.round(x), y: Math.round(y), browserSessionId: session.browserSessionId, method: "coordinate" }
649
+ const result = await chrome.tabs.sendMessage(tabId, {
650
+ type: "yunti_execute_tool",
651
+ tool: "yunti_hover",
652
+ arguments: { ...args, x, y },
653
+ })
654
+ if (!result || typeof result !== "object" || result.hovered !== true) return result
655
+ const roundedX = Math.round(x)
656
+ const roundedY = Math.round(y)
657
+ return {
658
+ ...result,
659
+ hovered: true,
660
+ x: roundedX,
661
+ y: roundedY,
662
+ browserSessionId: session.browserSessionId,
663
+ method: "coordinate",
664
+ action: "hover",
665
+ target: { method: "coordinate", x: roundedX, y: roundedY },
666
+ ok: true,
667
+ recoverable: false,
668
+ nextStepHint: "Coordinate hover dispatched without attaching Chrome debugger. Observe again, read page state, or use a fresh uid when possible to verify menus, tooltips, or hover-only controls.",
669
+ }
538
670
  }
539
671
  const selector = String(args.selector || "").trim()
540
672
  if (!selector) {
541
673
  throw new Error("yunti_hover requires uid, selector, or both x and y. Call yunti_take_snapshot to get uid or pass a CSS selector.")
542
674
  }
543
- const point = await resolveSelectorCenter(tabId, selector)
544
- await mouseMove(tabId, point.x, point.y)
675
+ const result = await chrome.tabs.sendMessage(tabId, {
676
+ type: "yunti_execute_tool",
677
+ tool: "yunti_hover",
678
+ arguments: args,
679
+ })
680
+ if (!result || typeof result !== "object" || result.hovered !== true) return result
545
681
  return {
682
+ ...result,
546
683
  hovered: true,
547
684
  selector,
548
- x: Math.round(point.x),
549
- y: Math.round(point.y),
550
685
  browserSessionId: session.browserSessionId,
551
686
  method: "selector",
687
+ action: "hover",
688
+ target: { selector, method: "selector" },
689
+ ok: true,
690
+ recoverable: false,
691
+ nextStepHint: "Selector hover dispatched without attaching Chrome debugger. Observe again, read page state, or use a fresh uid when possible to verify menus, tooltips, or hover-only controls.",
552
692
  }
553
693
  }
554
694
 
@@ -557,76 +697,623 @@ export function createToolDispatcher({
557
697
  const uid = String(args.uid || "").trim()
558
698
  const value = String(args.value)
559
699
  if (uid) {
560
- const { x, y } = await resolveUidCenter(tabId, session, uid)
561
- await mouseClick(tabId, x, y, 1)
562
- await delayCdp(100)
563
- // Clear existing value
564
- const elements = await chromeDebuggerSendCommand(
565
- { tabId },
566
- "Runtime.evaluate",
567
- { expression: `(() => {
568
- const el = document.elementFromPoint(${x}, ${y});
569
- if (!el) return 'not found';
570
- const tag = el.tagName.toLowerCase();
571
- if (tag === 'select') {
572
- const opts = Array.from(el.options);
573
- return { tag: 'select', options: opts.map(o => ({ value: o.value, text: o.text.slice(0, 80) })), selectedIndex: el.selectedIndex };
574
- }
575
- if (el.isContentEditable) {
576
- el.textContent = '';
577
- } else if (tag === 'input' || tag === 'textarea') {
578
- el.focus();
579
- el.select();
580
- }
581
- return { tag, type: el.type, contentEditable: el.isContentEditable };
582
- })()`,
583
- returnByValue: true,
700
+ try {
701
+ const point = resolveObservedUidCenter(session, uid)
702
+ if (!point.ok) {
703
+ return buildUidFillFailureResult(session, uid, {
704
+ code: "UID_COORDINATES_UNAVAILABLE",
705
+ error: point.error,
706
+ })
584
707
  }
585
- )
586
- const elInfo = elements?.result?.value
587
-
588
- if (elInfo === "not found") throw new Error(`No element found at uid ${uid} coordinates`)
589
-
590
- // Handle select element
591
- if (elInfo?.tag === "select") {
592
- const targetOption = elInfo.options?.find(
593
- o => o.value === value || o.text === value
594
- )
595
- if (targetOption) {
596
- await chromeDebuggerSendCommand(
597
- { tabId },
598
- "Runtime.evaluate",
599
- {
600
- expression: `(() => {
601
- const el = document.elementFromPoint(${x}, ${y});
602
- if (el) el.value = ${JSON.stringify(targetOption.value)};
603
- el.dispatchEvent(new Event('change', { bubbles: true }));
604
- el.dispatchEvent(new Event('input', { bubbles: true }));
605
- })()`,
606
- }
607
- )
608
- return { filled: true, uid, method: "select", value: targetOption.value, browserSessionId: session.browserSessionId }
708
+ const result = await chrome.tabs.sendMessage(tabId, {
709
+ type: "yunti_execute_tool",
710
+ tool: "yunti_fill",
711
+ arguments: { ...args, x: point.x, y: point.y },
712
+ })
713
+ if (!result || typeof result !== "object" || result.filled !== true) {
714
+ return buildUidFillFailureResult(session, uid, result)
609
715
  }
610
- throw new Error(`Option '${value}' not found in select at uid ${uid}. Use yunti_take_snapshot to inspect the select element or pass an available option value/text.`)
611
- }
716
+ const method = result.method || "dom"
717
+ if (result.valueApplied === false) {
718
+ return buildUidFillFailureResult(session, uid, {
719
+ ...result,
720
+ code: "VALUE_NOT_APPLIED",
721
+ error: `Filled value did not remain on uid ${uid}`,
722
+ method,
723
+ expectedValueLength: value.length,
724
+ })
725
+ }
726
+ return {
727
+ ...result,
728
+ filled: true,
729
+ uid,
730
+ method,
731
+ value,
732
+ browserSessionId: session.browserSessionId,
733
+ action: "fill",
734
+ target: { uid, method },
735
+ ok: true,
736
+ recoverable: false,
737
+ nextStepHint: method === "contenteditable"
738
+ ? "Contenteditable fill dispatched. Observe again, read page text, or evaluate textContent to verify the intended change."
739
+ : "Fill dispatched without attaching Chrome debugger. Observe again, read page state, or evaluate the field value to verify the intended change.",
740
+ }
741
+ } catch (error) {
742
+ return buildUidFillFailureResult(session, uid, {
743
+ error: error?.message || "Uid fill failed",
744
+ })
745
+ }
746
+ }
612
747
 
613
- // Type text via CDP Input.dispatchKeyEvent
614
- const text = value
615
- for (const char of text) {
616
- await chromeDebuggerSendCommand(
617
- { tabId },
618
- "Input.dispatchKeyEvent",
619
- { type: "char", text: char, unmodifiedText: char }
620
- )
748
+ const selector = String(args.selector || "").trim()
749
+ let result
750
+ try {
751
+ result = await chrome.tabs.sendMessage(tabId, {
752
+ type: "yunti_execute_tool",
753
+ tool: "yunti_fill",
754
+ arguments: args,
755
+ })
756
+ } catch (error) {
757
+ return buildSelectorFillFailureResult(session, selector, {
758
+ error: error?.message || "Selector fill failed",
759
+ })
760
+ }
761
+
762
+ if (!result || typeof result !== "object" || result.filled !== true) {
763
+ return buildSelectorFillFailureResult(session, selector, result)
764
+ }
765
+
766
+ return {
767
+ ...result,
768
+ selector,
769
+ method: result.method || "selector",
770
+ browserSessionId: session.browserSessionId,
771
+ action: "fill",
772
+ target: { selector, method: "selector" },
773
+ ok: true,
774
+ recoverable: false,
775
+ nextStepHint: "Selector fill dispatched. Observe again, read page state, or evaluate the field value to verify the intended change.",
776
+ }
777
+ }
778
+
779
+ function buildSelectorFillFailureResult(session, selector, fillResult = {}) {
780
+ const error = fillResult?.error || "Selector fill failed"
781
+ const code = fillResult?.code || inferFillFailureCode(error, "SELECTOR_FILL_FAILED")
782
+ const recoveryHint = {
783
+ reason: "selector-fill-failed",
784
+ recommendedTools: ["yunti_observe_page", "yunti_take_snapshot", "yunti_evaluate_script", "yunti_fill"],
785
+ nextAction: code === "ELEMENT_NOT_FOUND" ? "observe-again" : "inspect-target-element",
786
+ decision: code === "ELEMENT_NOT_FOUND" ? "refresh-observation-or-selector-before-retry" : "inspect-editability-before-retry",
787
+ selector,
788
+ message: "The selector fill could not be completed. Inspect whether the selector still matches an editable element, observe again for a fresh uid, or evaluate the field before retrying.",
789
+ }
790
+
791
+ return {
792
+ ...(fillResult && typeof fillResult === "object" ? fillResult : {}),
793
+ filled: false,
794
+ selector,
795
+ browserSessionId: session.browserSessionId,
796
+ action: "fill",
797
+ target: { selector, method: "selector" },
798
+ ok: false,
799
+ recoverable: true,
800
+ code,
801
+ error,
802
+ recoveryHint,
803
+ nextStepHint: "Selector fill failed. Observe again for a fresh uid, inspect whether the target is editable, or retry with a stable selector before repeating the same fill.",
804
+ }
805
+ }
806
+
807
+ function buildUidFillFailureResult(session, uid, fillResult = {}) {
808
+ const availableValues = Array.isArray(fillResult?.availableValues) ? fillResult.availableValues : undefined
809
+ const availableTexts = Array.isArray(fillResult?.availableTexts) ? fillResult.availableTexts : undefined
810
+ const error = fillResult?.error || `Cannot fill uid ${uid}`
811
+ const code = fillResult?.code || inferFillFailureCode(error, "UID_FILL_FAILED")
812
+ const recoveryHint = {
813
+ reason: "uid-fill-failed",
814
+ recommendedTools: ["yunti_observe_page", "yunti_take_snapshot", "yunti_evaluate_script", "yunti_fill"],
815
+ nextAction: "inspect-target-element",
816
+ decision: "inspect-editability-before-retry",
817
+ uid,
818
+ ...(availableValues ? { availableValues } : {}),
819
+ ...(availableTexts ? { availableTexts } : {}),
820
+ message: "The uid fill could not be completed. Refresh observation if the uid may be stale, inspect whether the target is editable, or retry with selector fallback.",
821
+ }
822
+
823
+ if (code === "ELEMENT_NOT_FOUND") {
824
+ recoveryHint.nextAction = "observe-again"
825
+ recoveryHint.decision = "refresh-observation-before-retry"
826
+ } else if (code === "OPTION_NOT_FOUND") {
827
+ recoveryHint.nextAction = "inspect-available-options"
828
+ recoveryHint.decision = "inspect-options-before-retry"
829
+ } else if (code === "VALUE_NOT_APPLIED") {
830
+ recoveryHint.nextAction = "verify-field-state"
831
+ recoveryHint.decision = "inspect-controlled-or-masked-field-before-retry"
832
+ recoveryHint.message = "The fill dispatched, but the field value did not remain afterward. Inspect whether the target is framework-controlled, masked, or requires typing/press_key semantics before retrying."
833
+ }
834
+
835
+ return {
836
+ ...(fillResult && typeof fillResult === "object" ? fillResult : {}),
837
+ filled: false,
838
+ uid,
839
+ browserSessionId: session.browserSessionId,
840
+ action: "fill",
841
+ target: { uid, method: "uid" },
842
+ ok: false,
843
+ recoverable: true,
844
+ code,
845
+ error,
846
+ ...(availableValues ? { availableValues } : {}),
847
+ ...(availableTexts ? { availableTexts } : {}),
848
+ recoveryHint,
849
+ nextStepHint: "Uid fill failed. Observe again for a fresh uid, inspect whether the target is editable or a select with available options, or retry with selector fallback before repeating the same fill.",
850
+ }
851
+ }
852
+
853
+ function inferFillFailureCode(error, fallbackCode) {
854
+ const message = String(error || "").toLowerCase()
855
+ if (message.includes("not found") || message.includes("cannot resolve coordinates")) return "ELEMENT_NOT_FOUND"
856
+ if (message.includes("did not remain") || message.includes("did not stick") || message.includes("not applied")) return "VALUE_NOT_APPLIED"
857
+ if (
858
+ message.includes("not editable") ||
859
+ message.includes("no editable target") ||
860
+ message.includes("cannot accept text") ||
861
+ message.includes("disabled") ||
862
+ message.includes("read only") ||
863
+ message.includes("readonly") ||
864
+ message.includes("hidden") ||
865
+ message.includes("no size")
866
+ ) return "TARGET_NOT_EDITABLE"
867
+ return fallbackCode
868
+ }
869
+
870
+ function buildFillEditabilityError(uid, elInfo = {}) {
871
+ const tag = elInfo?.tag || "unknown"
872
+ const type = elInfo?.type ? ` type=${elInfo.type}` : ""
873
+ const reasons = []
874
+ if (elInfo?.hidden) reasons.push("hidden or has no size")
875
+ if (elInfo?.disabled) reasons.push("disabled")
876
+ if (elInfo?.readOnly) reasons.push("readonly")
877
+ if (elInfo?.editable === false) reasons.push("not editable")
878
+ const reason = reasons.length ? reasons.join(", ") : "not editable"
879
+ return `Target at uid ${uid} is not editable (${tag}${type}: ${reason})`
880
+ }
881
+
882
+ function summarizeFillTarget(elInfo = {}) {
883
+ return {
884
+ tag: elInfo?.tag,
885
+ ...(elInfo?.type ? { type: elInfo.type } : {}),
886
+ ...(elInfo?.contentEditable !== undefined ? { contentEditable: Boolean(elInfo.contentEditable) } : {}),
887
+ ...(elInfo?.disabled !== undefined ? { disabled: Boolean(elInfo.disabled) } : {}),
888
+ ...(elInfo?.readOnly !== undefined ? { readOnly: Boolean(elInfo.readOnly) } : {}),
889
+ ...(elInfo?.hidden !== undefined ? { hidden: Boolean(elInfo.hidden) } : {}),
890
+ ...(elInfo?.editable !== undefined ? { editable: Boolean(elInfo.editable) } : {}),
891
+ }
892
+ }
893
+
894
+ async function scrollPage(tabId, session, args = {}) {
895
+ const uid = String(args.uid || "").trim()
896
+ let scrollArgs = args
897
+ if (uid) {
898
+ let point
899
+ try {
900
+ point = await resolveUidCenter(tabId, session, uid)
901
+ } catch (error) {
902
+ return buildUidScrollFailureResult(session, uid, {
903
+ error: error?.message || "Uid scroll failed",
904
+ })
621
905
  }
622
- return { filled: true, uid, method: "keyboard", value: text, browserSessionId: session.browserSessionId }
906
+ const { x, y } = point
907
+ scrollArgs = { ...args, x, y }
623
908
  }
624
-
625
- return chrome.tabs.sendMessage(tabId, {
909
+ const result = await chrome.tabs.sendMessage(tabId, {
626
910
  type: "yunti_execute_tool",
627
- tool: "yunti_fill",
628
- arguments: args,
911
+ tool: "yunti_scroll",
912
+ arguments: scrollArgs,
629
913
  })
914
+
915
+ if (!result || typeof result !== "object" || result.scrolled !== true) {
916
+ return result
917
+ }
918
+
919
+ const hasComparableScrollPosition =
920
+ Number.isFinite(Number(result.before?.left)) &&
921
+ Number.isFinite(Number(result.before?.top)) &&
922
+ Number.isFinite(Number(result.after?.left)) &&
923
+ Number.isFinite(Number(result.after?.top))
924
+ const moved = hasComparableScrollPosition
925
+ ? Number(result.before.left) !== Number(result.after.left) ||
926
+ Number(result.before.top) !== Number(result.after.top)
927
+ : undefined
928
+ const noMovement = moved === false
929
+ const edgeHint = noMovement ? inferScrollEdgeHint(result.deltaX, result.deltaY) : undefined
930
+ const recoveryHint = noMovement ? buildScrollRecoveryHint(session, uid, edgeHint, result) : undefined
931
+ const partialMovement = moved === true ? buildScrollPartialMovementHint(result, scrollArgs) : undefined
932
+ const coordinateFallbackHint = !uid ? buildCoordinateScrollFallbackHint(result) : undefined
933
+
934
+ return {
935
+ ...result,
936
+ browserSessionId: session.browserSessionId,
937
+ action: "scroll",
938
+ ...(uid ? { uid, method: "uid" } : {}),
939
+ ...(uid ? { scrollTarget: { uid, method: "uid" } } : {}),
940
+ ...(moved !== undefined ? { moved } : {}),
941
+ ...(noMovement ? { code: "NO_SCROLL_MOVEMENT" } : {}),
942
+ ...(edgeHint ? { edgeHint } : {}),
943
+ ...(recoveryHint ? { recoveryHint } : {}),
944
+ ...(partialMovement ? { partialMovement } : {}),
945
+ ...(coordinateFallbackHint ? { coordinateFallbackHint } : {}),
946
+ ok: !noMovement,
947
+ recoverable: noMovement,
948
+ nextStepHint: noMovement
949
+ ? `Scroll dispatched but before/after positions did not change${edgeHint ? ` (${edgeHint})` : ""}. Observe again, inspect scroll boundaries, try the nearest scrollable container uid, or stop repeating the same scroll.`
950
+ : partialMovement
951
+ ? `Scroll moved partially${partialMovement.edgeHint ? ` (${partialMovement.edgeHint})` : ""}. Observe again and compare scroll positions before repeating the same scroll.`
952
+ : coordinateFallbackHint
953
+ ? "Coordinate scroll fell back to document scrolling. Observe again, inspect scrollableContainers[], and prefer a fresh scrollable container uid if a nested panel was intended."
954
+ : uid
955
+ ? "Uid-targeted scroll dispatched. Observe again or read page state to verify the intended container position."
956
+ : "Scroll dispatched. Observe again or read page state to verify the intended viewport or container position.",
957
+ }
958
+ }
959
+
960
+ function buildCoordinateScrollFallbackHint(result = {}) {
961
+ if (result.coordinateScrollFallback !== "document") return undefined
962
+ return {
963
+ reason: "coordinate-scroll-document-fallback",
964
+ nextAction: "observe-for-scrollable-container",
965
+ decision: "prefer-fresh-scrollable-container-uid",
966
+ recommendedTools: ["yunti_observe_page", "yunti_scroll"],
967
+ ...(result.coordinateTarget ? { coordinateTarget: result.coordinateTarget } : {}),
968
+ message: "The coordinate scroll did not find a nested scrollable container and fell back to document scrolling. Observe again and choose a fresh scrollableContainers[] uid when a panel or sidebar was intended.",
969
+ }
970
+ }
971
+
972
+ function buildUidScrollFailureResult(session, uid, scrollResult = {}) {
973
+ const error = scrollResult?.error || `Cannot scroll uid ${uid}`
974
+ const code = scrollResult?.code || inferScrollFailureCode(error)
975
+ const recoveryHint = {
976
+ reason: "uid-scroll-failed",
977
+ recommendedTools: ["yunti_observe_page", "yunti_take_snapshot", "yunti_scroll"],
978
+ nextAction: "observe-again",
979
+ decision: "refresh-scrollable-container-uid-before-retry",
980
+ uid,
981
+ message: "The uid scroll could not resolve a current target. Refresh observation, choose a fresh scrollable container uid from scrollableContainers[], or use document/coordinate scroll fallback before retrying.",
982
+ }
983
+
984
+ return {
985
+ ...(scrollResult && typeof scrollResult === "object" ? scrollResult : {}),
986
+ scrolled: false,
987
+ uid,
988
+ browserSessionId: session.browserSessionId,
989
+ action: "scroll",
990
+ target: { uid, method: "uid" },
991
+ ok: false,
992
+ recoverable: true,
993
+ code,
994
+ error,
995
+ recoveryHint,
996
+ nextStepHint: "Uid scroll failed. Observe again for a fresh scrollable container uid, inspect scrollableContainers[], or retry with document/coordinate fallback before repeating the same uid scroll.",
997
+ }
998
+ }
999
+
1000
+ function inferScrollFailureCode(error) {
1001
+ const message = String(error || "").toLowerCase()
1002
+ if (message.includes("not found") || message.includes("uid") && message.includes("latest page uid map")) {
1003
+ return "UID_NOT_FOUND"
1004
+ }
1005
+ if (message.includes("cannot resolve coordinates") || message.includes("off-screen") || message.includes("hidden") || message.includes("stale")) {
1006
+ return "UID_COORDINATES_UNAVAILABLE"
1007
+ }
1008
+ return "UID_SCROLL_FAILED"
1009
+ }
1010
+
1011
+ function inferScrollEdgeHint(deltaX, deltaY) {
1012
+ const x = Number(deltaX)
1013
+ const y = Number(deltaY)
1014
+ if (Number.isFinite(y) && y > 0) return "possible-bottom-edge"
1015
+ if (Number.isFinite(y) && y < 0) return "possible-top-edge"
1016
+ if (Number.isFinite(x) && x > 0) return "possible-right-edge"
1017
+ if (Number.isFinite(x) && x < 0) return "possible-left-edge"
1018
+ return "no-delta"
1019
+ }
1020
+
1021
+ function buildScrollPartialMovementHint(result = {}, args = {}) {
1022
+ const beforeLeft = Number(result.before?.left)
1023
+ const beforeTop = Number(result.before?.top)
1024
+ const afterLeft = Number(result.after?.left)
1025
+ const afterTop = Number(result.after?.top)
1026
+ if (
1027
+ !Number.isFinite(beforeLeft) ||
1028
+ !Number.isFinite(beforeTop) ||
1029
+ !Number.isFinite(afterLeft) ||
1030
+ !Number.isFinite(afterTop)
1031
+ ) return undefined
1032
+
1033
+ const requestedDeltaX = Number(result.deltaX ?? args.deltaX ?? 0)
1034
+ const requestedDeltaY = Number(result.deltaY ?? args.deltaY ?? 0)
1035
+ const actualDeltaX = afterLeft - beforeLeft
1036
+ const actualDeltaY = afterTop - beforeTop
1037
+ const axes = []
1038
+ if (isPartialScrollMovement(requestedDeltaX, actualDeltaX)) axes.push("horizontal")
1039
+ if (isPartialScrollMovement(requestedDeltaY, actualDeltaY)) axes.push("vertical")
1040
+ if (!axes.length) return undefined
1041
+
1042
+ return {
1043
+ reason: "partial-scroll-movement",
1044
+ axes,
1045
+ requestedDeltaX,
1046
+ requestedDeltaY,
1047
+ actualDeltaX,
1048
+ actualDeltaY,
1049
+ edgeHint: inferScrollEdgeHint(requestedDeltaX, requestedDeltaY),
1050
+ nextAction: "observe-again",
1051
+ decision: "observe-before-continuing-scroll",
1052
+ message: "The scroll position changed, but less than the requested delta. Observe again before repeating the same scroll to verify whether the target container hit an edge or a different container should be used.",
1053
+ }
1054
+ }
1055
+
1056
+ function isPartialScrollMovement(requestedDelta, actualDelta) {
1057
+ if (!Number.isFinite(requestedDelta) || !Number.isFinite(actualDelta)) return false
1058
+ if (requestedDelta === 0 || actualDelta === 0) return false
1059
+ if (Math.sign(requestedDelta) !== Math.sign(actualDelta)) return false
1060
+ return Math.abs(actualDelta) < Math.abs(requestedDelta)
1061
+ }
1062
+
1063
+ function buildScrollRecoveryHint(session, uid, edgeHint, result = {}) {
1064
+ const hint = {
1065
+ reason: "no-scroll-movement",
1066
+ recommendedTools: ["yunti_observe_page", "yunti_scroll"],
1067
+ nextAction: "observe-again",
1068
+ }
1069
+ if (edgeHint) hint.edgeHint = edgeHint
1070
+ if (uid) {
1071
+ hint.uid = uid
1072
+ hint.currentTarget = "scrollable-container"
1073
+ const target = pageUidStore.get(session.browserSessionId)?.uidMap?.[uid]
1074
+ if (target) {
1075
+ hint.lastObservedContainer = {
1076
+ uid,
1077
+ canScrollVertical: Boolean(target.canScrollVertical),
1078
+ canScrollHorizontal: Boolean(target.canScrollHorizontal),
1079
+ ...(Number.isFinite(Number(target.pixelsAbove)) ? { pixelsAbove: Number(target.pixelsAbove) } : {}),
1080
+ ...(Number.isFinite(Number(target.pixelsBelow)) ? { pixelsBelow: Number(target.pixelsBelow) } : {}),
1081
+ ...(Number.isFinite(Number(target.pixelsLeft)) ? { pixelsLeft: Number(target.pixelsLeft) } : {}),
1082
+ ...(Number.isFinite(Number(target.pixelsRight)) ? { pixelsRight: Number(target.pixelsRight) } : {}),
1083
+ }
1084
+ }
1085
+ } else {
1086
+ hint.currentTarget = "document-or-coordinate-container"
1087
+ }
1088
+ if (edgeHint === "possible-bottom-edge" || edgeHint === "possible-top-edge") {
1089
+ hint.nextAction = uid ? "try-opposite-direction-or-nearest-container" : "observe-for-scrollable-container"
1090
+ } else if (edgeHint === "possible-right-edge" || edgeHint === "possible-left-edge") {
1091
+ hint.nextAction = uid ? "try-opposite-horizontal-direction-or-nearest-container" : "observe-for-horizontal-container"
1092
+ } else if (edgeHint === "no-delta") {
1093
+ hint.nextAction = "provide-nonzero-scroll-delta"
1094
+ }
1095
+ const decision = buildScrollRecoveryDecision(uid, edgeHint)
1096
+ if (decision) hint.decision = decision
1097
+ const suggestedRetry = buildScrollSuggestedRetry(uid, edgeHint, result)
1098
+ if (suggestedRetry) hint.suggestedRetry = suggestedRetry
1099
+ hint.message = "The scroll command dispatched, but the scroll position did not change. Refresh observation before retrying, inspect scroll boundaries, or target a different scrollable container uid."
1100
+ return hint
1101
+ }
1102
+
1103
+ function buildScrollRecoveryDecision(uid, edgeHint) {
1104
+ if (edgeHint === "no-delta") return "provide-nonzero-delta"
1105
+ if (edgeHint === "possible-bottom-edge" || edgeHint === "possible-top-edge") {
1106
+ return uid ? "retry-opposite-vertical-on-same-container-once" : "observe-for-scrollable-container"
1107
+ }
1108
+ if (edgeHint === "possible-right-edge" || edgeHint === "possible-left-edge") {
1109
+ return uid ? "retry-opposite-horizontal-on-same-container-once" : "observe-for-horizontal-scrollable-container"
1110
+ }
1111
+ return undefined
1112
+ }
1113
+
1114
+ function buildScrollSuggestedRetry(uid, edgeHint, result = {}) {
1115
+ const retry = {}
1116
+ const deltaX = Number(result.deltaX)
1117
+ const deltaY = Number(result.deltaY)
1118
+ if (edgeHint === "possible-bottom-edge" || edgeHint === "possible-top-edge") {
1119
+ if (!Number.isFinite(deltaY) || deltaY === 0) return undefined
1120
+ retry.deltaY = -deltaY
1121
+ } else if (edgeHint === "possible-right-edge" || edgeHint === "possible-left-edge") {
1122
+ if (!Number.isFinite(deltaX) || deltaX === 0) return undefined
1123
+ retry.deltaX = -deltaX
1124
+ } else {
1125
+ return undefined
1126
+ }
1127
+ if (uid) retry.uid = uid
1128
+ retry.note = uid
1129
+ ? "Try the opposite direction once on the same observed container, then observe again or switch to a nearer scrollable container if it still does not move."
1130
+ : "Observe first for a scrollable container uid; only use this opposite delta if document scrolling is still the intended target."
1131
+ return retry
1132
+ }
1133
+
1134
+ async function selectElement(tabId, session, args = {}) {
1135
+ const uid = String(args.uid || "").trim()
1136
+ const value = String(args.value ?? "")
1137
+ const text = String(args.text ?? "")
1138
+ if (uid) {
1139
+ const matchMode = text ? "text" : "value"
1140
+ const targetOption = text || value
1141
+ const point = resolveObservedUidCenter(session, uid)
1142
+ if (!point.ok) {
1143
+ return buildUidSelectFailureResult(session, uid, matchMode, targetOption, {
1144
+ code: "UID_COORDINATES_UNAVAILABLE",
1145
+ error: point.error,
1146
+ })
1147
+ }
1148
+ const selectResult = await chrome.tabs.sendMessage(tabId, {
1149
+ type: "yunti_execute_tool",
1150
+ tool: "yunti_select",
1151
+ arguments: { ...args, x: point.x, y: point.y },
1152
+ })
1153
+ if (!selectResult?.selected) {
1154
+ return buildUidSelectFailureResult(session, uid, matchMode, targetOption, selectResult)
1155
+ }
1156
+ return {
1157
+ ...selectResult,
1158
+ selected: true,
1159
+ uid,
1160
+ value: selectResult.value,
1161
+ text: selectResult.text,
1162
+ selectedIndex: selectResult.selectedIndex,
1163
+ browserSessionId: session.browserSessionId,
1164
+ action: "select",
1165
+ target: { uid, method: matchMode === "text" ? "uid.text" : "uid.value" },
1166
+ ok: true,
1167
+ recoverable: false,
1168
+ nextStepHint: matchMode === "text"
1169
+ ? "Uid select dispatched by visible option text without attaching Chrome debugger. Observe again, read page state, or evaluate the select value to verify the intended change."
1170
+ : "Uid select dispatched by option value without attaching Chrome debugger. Observe again, read page state, or evaluate the select value to verify the intended change.",
1171
+ }
1172
+ }
1173
+
1174
+ const selector = String(args.selector || "").trim()
1175
+ let result
1176
+ try {
1177
+ result = await chrome.tabs.sendMessage(tabId, {
1178
+ type: "yunti_execute_tool",
1179
+ tool: "yunti_select",
1180
+ arguments: args,
1181
+ })
1182
+ } catch (error) {
1183
+ return buildSelectorSelectFailureResult(session, selector, args.value, {
1184
+ code: "SELECTOR_SELECT_FAILED",
1185
+ error: error?.message || "Selector select failed",
1186
+ })
1187
+ }
1188
+
1189
+ if (!result || typeof result !== "object" || result.selected !== true) {
1190
+ return buildSelectorSelectFailureResult(session, selector, args.value, result)
1191
+ }
1192
+
1193
+ const requestedValue = String(args.value ?? "")
1194
+ const disabledOption = Array.isArray(result.options)
1195
+ ? result.options.find((option) => String(option?.value ?? "") === requestedValue && option.disabled === true)
1196
+ : undefined
1197
+ if (requestedValue && disabledOption) {
1198
+ return buildSelectorSelectFailureResult(session, selector, requestedValue, {
1199
+ ...result,
1200
+ selected: false,
1201
+ code: "OPTION_DISABLED",
1202
+ error: "Option value is disabled",
1203
+ disabledValue: String(disabledOption.value ?? ""),
1204
+ disabledText: String(disabledOption.text ?? ""),
1205
+ })
1206
+ }
1207
+ if (requestedValue && String(result.value ?? "") !== requestedValue) {
1208
+ return buildSelectorSelectFailureResult(session, selector, requestedValue, {
1209
+ ...result,
1210
+ code: "OPTION_NOT_FOUND",
1211
+ error: "Option value not found",
1212
+ actualValue: result.value,
1213
+ })
1214
+ }
1215
+
1216
+ return {
1217
+ ...result,
1218
+ selector,
1219
+ browserSessionId: session.browserSessionId,
1220
+ action: "select",
1221
+ target: { selector, method: "selector" },
1222
+ ok: true,
1223
+ recoverable: false,
1224
+ nextStepHint: "Select dispatched. Observe again, read page state, or evaluate the select value to verify the intended change.",
1225
+ }
1226
+ }
1227
+
1228
+ function buildSelectorSelectFailureResult(session, selector, targetOption, selectResult = {}) {
1229
+ const availableValues = Array.isArray(selectResult?.availableValues) ? selectResult.availableValues : undefined
1230
+ const availableTexts = Array.isArray(selectResult?.availableTexts) ? selectResult.availableTexts : undefined
1231
+ const disabledValue = selectResult?.disabledValue !== undefined ? String(selectResult.disabledValue) : undefined
1232
+ const disabledText = selectResult?.disabledText !== undefined ? String(selectResult.disabledText) : undefined
1233
+ const code = selectResult?.code || (selectResult?.selected === false ? "SELECTOR_SELECT_FAILED" : "SELECTOR_SELECT_FAILED")
1234
+ const error = selectResult?.error || "Selector select failed"
1235
+ const recoveryHint = {
1236
+ reason: "selector-select-failed",
1237
+ recommendedTools: ["yunti_observe_page", "yunti_take_snapshot", "yunti_evaluate_script", "yunti_select"],
1238
+ nextAction: code === "OPTION_NOT_FOUND" || code === "OPTION_DISABLED" ? "inspect-available-options" : "inspect-target-element",
1239
+ decision: code === "OPTION_DISABLED" ? "choose-enabled-option-or-unlock-field" : code === "OPTION_NOT_FOUND" ? "inspect-options-before-retry" : "use-select-element-or-uid-fallback",
1240
+ selector,
1241
+ matchMode: "value",
1242
+ targetOption: String(targetOption ?? ""),
1243
+ ...(availableValues ? { availableValues } : {}),
1244
+ ...(availableTexts ? { availableTexts } : {}),
1245
+ ...(disabledValue ? { disabledValue } : {}),
1246
+ ...(disabledText ? { disabledText } : {}),
1247
+ message: "The selector select could not be completed. Inspect the target select element and available options before retrying, or use a fresh uid/value fallback.",
1248
+ }
1249
+
1250
+ return {
1251
+ ...(selectResult && typeof selectResult === "object" ? selectResult : {}),
1252
+ selected: false,
1253
+ selector,
1254
+ browserSessionId: session.browserSessionId,
1255
+ action: "select",
1256
+ target: { selector, method: "selector" },
1257
+ ok: false,
1258
+ recoverable: true,
1259
+ code,
1260
+ error,
1261
+ matchMode: "value",
1262
+ targetOption: String(targetOption ?? ""),
1263
+ ...(availableValues ? { availableValues } : {}),
1264
+ ...(availableTexts ? { availableTexts } : {}),
1265
+ recoveryHint,
1266
+ nextStepHint: "Selector select failed. Inspect available options, observe again for a fresh uid, or retry with uid/value fallback before repeating the same selector select.",
1267
+ }
1268
+ }
1269
+
1270
+ function buildUidSelectFailureResult(session, uid, matchMode, targetOption, selectResult = {}) {
1271
+ const availableValues = Array.isArray(selectResult.availableValues) ? selectResult.availableValues : undefined
1272
+ const availableTexts = Array.isArray(selectResult.availableTexts) ? selectResult.availableTexts : undefined
1273
+ const disabledValue = selectResult?.disabledValue !== undefined ? String(selectResult.disabledValue) : undefined
1274
+ const disabledText = selectResult?.disabledText !== undefined ? String(selectResult.disabledText) : undefined
1275
+ const recoveryHint = {
1276
+ reason: "uid-select-failed",
1277
+ recommendedTools: ["yunti_observe_page", "yunti_take_snapshot", "yunti_evaluate_script", "yunti_select"],
1278
+ nextAction: "inspect-available-options",
1279
+ decision: selectResult.code === "OPTION_DISABLED" ? "choose-enabled-option-or-unlock-field" : "inspect-options-before-retry",
1280
+ uid,
1281
+ matchMode,
1282
+ targetOption,
1283
+ ...(availableValues ? { availableValues } : {}),
1284
+ ...(availableTexts ? { availableTexts } : {}),
1285
+ ...(disabledValue ? { disabledValue } : {}),
1286
+ ...(disabledText ? { disabledText } : {}),
1287
+ message: "The select option could not be matched. Inspect available options before retrying, refresh observation if the uid may be stale, or use selector/value fallback.",
1288
+ }
1289
+
1290
+ if (selectResult.code === "ELEMENT_NOT_FOUND") {
1291
+ recoveryHint.nextAction = "observe-again"
1292
+ recoveryHint.decision = "refresh-observation-before-retry"
1293
+ } else if (selectResult.code === "NOT_SELECT") {
1294
+ recoveryHint.nextAction = "inspect-target-element"
1295
+ recoveryHint.decision = "use-select-element-or-selector-fallback"
1296
+ }
1297
+
1298
+ return {
1299
+ selected: false,
1300
+ uid,
1301
+ browserSessionId: session.browserSessionId,
1302
+ action: "select",
1303
+ target: { uid, method: matchMode === "text" ? "uid.text" : "uid.value" },
1304
+ ok: false,
1305
+ recoverable: true,
1306
+ code: selectResult.code || "UID_SELECT_FAILED",
1307
+ error: selectResult.error || `Cannot select option at uid ${uid}`,
1308
+ matchMode,
1309
+ targetOption,
1310
+ ...(availableValues ? { availableValues } : {}),
1311
+ ...(availableTexts ? { availableTexts } : {}),
1312
+ ...(disabledValue ? { disabledValue } : {}),
1313
+ ...(disabledText ? { disabledText } : {}),
1314
+ recoveryHint,
1315
+ nextStepHint: "Uid select failed. Inspect available options, observe again for a fresh uid, or retry with selector/value fallback before repeating the same select.",
1316
+ }
630
1317
  }
631
1318
 
632
1319
  function validateFillArgs(args = {}) {
@@ -648,18 +1335,57 @@ export function createToolDispatcher({
648
1335
  async function clickViaSnapshotUid(tabId, session, uid) {
649
1336
  const { x, y } = await resolveUidCenter(tabId, session, uid)
650
1337
  await mouseClick(tabId, x, y, 1)
651
- return { clicked: true, uid, x: Math.round(x), y: Math.round(y), browserSessionId: session.browserSessionId }
1338
+ const roundedX = Math.round(x)
1339
+ const roundedY = Math.round(y)
1340
+ return {
1341
+ clicked: true,
1342
+ uid,
1343
+ x: roundedX,
1344
+ y: roundedY,
1345
+ browserSessionId: session.browserSessionId,
1346
+ action: "click",
1347
+ target: { uid, x: roundedX, y: roundedY },
1348
+ ok: true,
1349
+ recoverable: false,
1350
+ nextStepHint: "Click dispatched. Observe again or read page state to verify the intended change.",
1351
+ }
652
1352
  }
653
1353
 
654
1354
  async function hoverViaSnapshotUid(tabId, session, uid) {
655
1355
  const { x, y } = await resolveUidCenter(tabId, session, uid)
656
1356
  await mouseMove(tabId, x, y)
657
- return { hovered: true, uid, x: Math.round(x), y: Math.round(y), browserSessionId: session.browserSessionId }
1357
+ const roundedX = Math.round(x)
1358
+ const roundedY = Math.round(y)
1359
+ return {
1360
+ hovered: true,
1361
+ uid,
1362
+ x: roundedX,
1363
+ y: roundedY,
1364
+ browserSessionId: session.browserSessionId,
1365
+ action: "hover",
1366
+ target: { uid, x: roundedX, y: roundedY },
1367
+ ok: true,
1368
+ recoverable: false,
1369
+ nextStepHint: "Hover dispatched. Observe again or read page state to verify menus, tooltips, or hover-only controls.",
1370
+ }
658
1371
  }
659
1372
 
660
1373
  async function clickAtCoordinate(tabId, session, x, y) {
661
1374
  await mouseClick(tabId, x, y, 1)
662
- return { clicked: true, x: Math.round(x), y: Math.round(y), browserSessionId: session.browserSessionId, method: "coordinate" }
1375
+ const roundedX = Math.round(x)
1376
+ const roundedY = Math.round(y)
1377
+ return {
1378
+ clicked: true,
1379
+ x: roundedX,
1380
+ y: roundedY,
1381
+ browserSessionId: session.browserSessionId,
1382
+ method: "coordinate",
1383
+ action: "click",
1384
+ target: { method: "coordinate", x: roundedX, y: roundedY },
1385
+ ok: true,
1386
+ recoverable: false,
1387
+ nextStepHint: "Coordinate click dispatched. Observe again, read page state, or use a fresh uid when possible to verify the intended change.",
1388
+ }
663
1389
  }
664
1390
 
665
1391
  async function resolveSelectorCenter(tabId, selector) {
@@ -695,36 +1421,72 @@ export function createToolDispatcher({
695
1421
  }
696
1422
 
697
1423
  async function resolveUidCenter(tabId, session, uid) {
698
- const uidMap = snapshotStore.get(session.browserSessionId)
1424
+ const point = resolveObservedUidCenter(session, uid)
1425
+ if (point.ok) return { x: point.x, y: point.y }
1426
+ throw new Error(point.error)
1427
+ }
1428
+
1429
+ function resolveObservedUidCenter(session, uid) {
1430
+ const uidState = pageUidStore.get(session.browserSessionId)
1431
+ const uidMap = uidState?.uidMap
699
1432
  if (!uidMap || !uidMap[uid]) {
700
- throw new Error(`uid ${uid} not found. Run yunti_take_snapshot first.`)
1433
+ return {
1434
+ ok: false,
1435
+ error: `uid ${uid} not found in the latest page uid map. Run yunti_observe_page again before retrying.`,
1436
+ }
701
1437
  }
702
1438
  const el = uidMap[uid]
703
1439
 
704
1440
  if (el.rect && el.rect.width > 0 && el.rect.height > 0) {
705
1441
  return {
1442
+ ok: true,
706
1443
  x: el.rect.x + el.rect.width / 2,
707
1444
  y: el.rect.y + el.rect.height / 2,
708
1445
  }
709
1446
  }
710
-
711
- // Resolve coordinates via CDP DOM.getBoxModel using backendNodeId
712
- if (el.backendNodeId) {
713
- await ensureCdpAttached(tabId, "1.3")
714
- const boxModel = await chromeDebuggerSendCommand(
715
- { tabId },
716
- "DOM.getBoxModel",
717
- { backendNodeId: el.backendNodeId }
718
- )
719
- const content = boxModel?.model?.content
720
- if (content && content.length >= 4) {
721
- const x = (content[0] + content[4]) / 2
722
- const y = (content[1] + content[5]) / 2
723
- return { x, y }
724
- }
1447
+
1448
+ return {
1449
+ ok: false,
1450
+ error: `Cannot resolve coordinates for uid ${uid} without Chrome debugger. Run yunti_observe_page with includeRects enabled, scroll the element into view, or use an explicit selector fallback.`,
1451
+ }
1452
+ }
1453
+
1454
+ function buildUidPointFailureResult(session, uid, action, error) {
1455
+ const code = /not found|latest page uid map/i.test(String(error || "")) ? "UID_NOT_FOUND" : "UID_COORDINATES_UNAVAILABLE"
1456
+ const resultFieldByAction = {
1457
+ click: "clicked",
1458
+ hover: "hovered",
1459
+ type: "typed",
1460
+ press: "pressed",
1461
+ }
1462
+ const resultField = resultFieldByAction[action] || `${action}ed`
1463
+ const recommendedToolByAction = {
1464
+ click: "yunti_click",
1465
+ hover: "yunti_hover",
1466
+ type: "yunti_type_text",
1467
+ press: "yunti_press_key",
1468
+ }
1469
+ const recommendedTool = recommendedToolByAction[action] || "yunti_observe_page"
1470
+ return {
1471
+ [resultField]: false,
1472
+ uid,
1473
+ browserSessionId: session.browserSessionId,
1474
+ action,
1475
+ target: { uid, method: "uid" },
1476
+ ok: false,
1477
+ recoverable: true,
1478
+ code,
1479
+ error: error || `Cannot ${action} uid ${uid}`,
1480
+ recoveryHint: {
1481
+ reason: `uid-${action}-failed`,
1482
+ recommendedTools: ["yunti_observe_page", recommendedTool],
1483
+ nextAction: "observe-again",
1484
+ decision: "refresh-observation-before-retry",
1485
+ uid,
1486
+ message: "The uid could not be resolved without attaching Chrome debugger. Observe again with rects enabled, scroll the target into view, or retry with selector fallback.",
1487
+ },
1488
+ nextStepHint: `Uid ${action} failed without attaching Chrome debugger. Observe again for a fresh visible uid before retrying.`,
725
1489
  }
726
-
727
- throw new Error(`Cannot resolve coordinates for uid ${uid}. Element may be off-screen or hidden.`)
728
1490
  }
729
1491
 
730
1492
  async function mouseClick(tabId, x, y, clickCount = 1) {
@@ -756,24 +1518,60 @@ export function createToolDispatcher({
756
1518
  for (const field of fields) {
757
1519
  try {
758
1520
  const uid = String(field.uid || "").trim()
1521
+ let fieldResult
759
1522
  if (uid) {
760
- await fillByUid(tabId, session, { uid, value: String(field.value || "") })
1523
+ fieldResult = await fillByUid(tabId, session, { uid, value: String(field.value || "") })
761
1524
  } else {
762
- await chrome.tabs.sendMessage(tabId, {
763
- type: "yunti_execute_tool",
764
- tool: "yunti_fill",
765
- arguments: { selector: field.selector, value: field.value },
766
- })
1525
+ fieldResult = await fillByUid(tabId, session, { selector: field.selector, value: field.value })
767
1526
  }
768
- filled++
769
- results.push({ uid: field.uid, selector: field.selector, ok: true })
1527
+ const normalized = buildFillFormFieldResult(field, fieldResult)
1528
+ if (normalized.ok) filled++
1529
+ else failed++
1530
+ results.push(normalized)
770
1531
  } catch (err) {
771
1532
  failed++
772
1533
  results.push({ uid: field.uid, selector: field.selector, ok: false, error: err?.message || String(err) })
773
1534
  }
774
1535
  }
775
1536
 
776
- return { filled, failed, results, browserSessionId: session.browserSessionId }
1537
+ return {
1538
+ filled,
1539
+ failed,
1540
+ results,
1541
+ browserSessionId: session.browserSessionId,
1542
+ action: "fill_form",
1543
+ target: {
1544
+ fieldCount: fields.length,
1545
+ filled,
1546
+ failed,
1547
+ },
1548
+ ok: failed === 0,
1549
+ recoverable: failed > 0,
1550
+ nextStepHint: failed === 0
1551
+ ? "Form fill dispatched. Observe again, read page state, or evaluate field values to verify the intended changes."
1552
+ : "Form fill partially failed. Inspect per-field results, observe again for fresh uids, or retry failed fields with selector fallback.",
1553
+ }
1554
+ }
1555
+
1556
+ function buildFillFormFieldResult(field, fieldResult = {}) {
1557
+ const ok = Boolean(fieldResult?.filled || fieldResult?.ok === true)
1558
+ if (ok) return { uid: field.uid, selector: field.selector, ok: true }
1559
+
1560
+ const diagnostics = fieldResult && typeof fieldResult === "object" ? fieldResult : {}
1561
+ const preservedKeys = [
1562
+ "code",
1563
+ "error",
1564
+ "recoveryHint",
1565
+ "availableValues",
1566
+ "availableTexts",
1567
+ "nextStepHint",
1568
+ ]
1569
+ const result = { uid: field.uid, selector: field.selector, ok: false }
1570
+ for (const key of preservedKeys) {
1571
+ if (diagnostics[key] !== undefined) result[key] = diagnostics[key]
1572
+ }
1573
+ if (!result.error) result.error = "fill failed"
1574
+ return result
777
1575
  }
778
1576
 
779
1577
  async function waitForCondition(tabId, session, args = {}) {
@@ -795,7 +1593,12 @@ export function createToolDispatcher({
795
1593
  if (urlContains) {
796
1594
  const tab = await chrome.tabs.get(tabId)
797
1595
  if (tab.url && tab.url.includes(urlContains)) {
798
- return { found: true, condition: "urlContains", value: urlContains, waitedMs: Date.now() - startTime, browserSessionId: session.browserSessionId }
1596
+ return buildWaitForSuccess(
1597
+ session,
1598
+ { text, selector, urlContains, timeoutMs },
1599
+ { found: true, condition: "urlContains", value: urlContains },
1600
+ Date.now() - startTime
1601
+ )
799
1602
  }
800
1603
  }
801
1604
 
@@ -811,16 +1614,72 @@ export function createToolDispatcher({
811
1614
  "Runtime.evaluate",
812
1615
  { expression, returnByValue: true }
813
1616
  )
814
-
1617
+
815
1618
  if (result?.result?.value) {
816
- return { found: true, ...result.result.value, waitedMs: Date.now() - startTime, browserSessionId: session.browserSessionId }
1619
+ return buildWaitForSuccess(
1620
+ session,
1621
+ { text, selector, urlContains, timeoutMs },
1622
+ result.result.value,
1623
+ Date.now() - startTime
1624
+ )
817
1625
  }
818
1626
  }
819
1627
 
820
1628
  await delayCdp(200)
821
1629
  }
822
1630
 
823
- return { found: false, waitedMs: timeoutMs, browserSessionId: session.browserSessionId }
1631
+ return buildWaitForTimeout(session, { text, selector, urlContains, timeoutMs }, timeoutMs)
1632
+ }
1633
+
1634
+ function buildWaitForTarget({ text, selector, urlContains, timeoutMs } = {}) {
1635
+ const target = {}
1636
+ if (text) target.text = text
1637
+ if (selector) target.selector = selector
1638
+ if (urlContains) target.urlContains = urlContains
1639
+ if (timeoutMs !== undefined) target.timeoutMs = timeoutMs
1640
+ return target
1641
+ }
1642
+
1643
+ function buildWaitForSuccess(session, args, match = {}, waitedMs = 0) {
1644
+ const condition = typeof match.condition === "string"
1645
+ ? match.condition
1646
+ : match.found === "selector" || match.found === "text"
1647
+ ? match.found
1648
+ : undefined
1649
+ const result = {
1650
+ found: true,
1651
+ ...match,
1652
+ waitedMs,
1653
+ browserSessionId: session.browserSessionId,
1654
+ action: "wait_for",
1655
+ target: buildWaitForTarget(args),
1656
+ ok: true,
1657
+ recoverable: false,
1658
+ nextStepHint: "Wait condition matched. Call yunti_observe_page and continue with a fresh uid before acting on newly rendered content.",
1659
+ }
1660
+ if (condition && result.condition === undefined) result.condition = condition
1661
+ return result
1662
+ }
1663
+
1664
+ function buildWaitForTimeout(session, args, waitedMs) {
1665
+ return {
1666
+ found: false,
1667
+ waitedMs,
1668
+ browserSessionId: session.browserSessionId,
1669
+ action: "wait_for",
1670
+ target: buildWaitForTarget(args),
1671
+ ok: false,
1672
+ recoverable: true,
1673
+ code: "WAIT_TIMEOUT",
1674
+ recoveryHint: {
1675
+ reason: "condition-not-met-before-timeout",
1676
+ recommendedTools: ["yunti_observe_page", "yunti_get_page_snapshot", "yunti_take_screenshot"],
1677
+ nextAction: "observe-or-adjust-condition",
1678
+ decision: "observe-before-retry",
1679
+ message: "The expected text, selector, or URL state did not appear before the timeout. Observe the page or adjust the wait condition before repeating the same wait.",
1680
+ },
1681
+ nextStepHint: "Wait timed out. Observe the current page, inspect whether the condition changed, or adjust the wait target before retrying.",
1682
+ }
824
1683
  }
825
1684
 
826
1685
  async function handleDialog(tabId, session, args = {}) {
@@ -1000,7 +1859,20 @@ export function createToolDispatcher({
1000
1859
  type: "mouseReleased", x: toX, y: toY, button, clickCount: 1,
1001
1860
  })
1002
1861
 
1003
- return { dragged: true, from: { x: Math.round(fromX), y: Math.round(fromY) }, to: { x: Math.round(toX), y: Math.round(toY) }, steps, browserSessionId: session.browserSessionId }
1862
+ const from = { x: Math.round(fromX), y: Math.round(fromY) }
1863
+ const to = { x: Math.round(toX), y: Math.round(toY) }
1864
+ return {
1865
+ dragged: true,
1866
+ from,
1867
+ to,
1868
+ steps,
1869
+ browserSessionId: session.browserSessionId,
1870
+ action: "drag",
1871
+ target: { method: "coordinate", from, to },
1872
+ ok: true,
1873
+ recoverable: false,
1874
+ nextStepHint: "Drag dispatched. Observe again or read page state to verify the intended movement or drop result.",
1875
+ }
1004
1876
  }
1005
1877
 
1006
1878
  async function uploadFile(tabId, session, args = {}) {
@@ -1036,77 +1908,139 @@ export function createToolDispatcher({
1036
1908
  backendNodeId,
1037
1909
  })
1038
1910
 
1039
- return { uploaded: true, fileCount: filePaths.length, filePaths, browserSessionId: session.browserSessionId }
1911
+ return {
1912
+ uploaded: true,
1913
+ fileCount: filePaths.length,
1914
+ filePaths,
1915
+ browserSessionId: session.browserSessionId,
1916
+ action: "upload_file",
1917
+ target: uid ? { uid, method: "uid" } : { selector, method: "selector" },
1918
+ ok: true,
1919
+ recoverable: false,
1920
+ nextStepHint: "File upload dispatched. Observe again, read page state, or verify the selected file input before submitting any form.",
1921
+ }
1040
1922
  }
1041
1923
 
1042
1924
  async function typeTextByUid(tabId, session, args = {}) {
1043
1925
  const uid = String(args.uid || "").trim()
1044
1926
  if (!uid) {
1045
1927
  // Fall back to content script for selector/coordinate-based typing
1046
- return chrome.tabs.sendMessage(tabId, {
1928
+ const selector = String(args.selector || "").trim()
1929
+ const hasX = Number.isFinite(Number(args.x))
1930
+ const hasY = Number.isFinite(Number(args.y))
1931
+ const result = await chrome.tabs.sendMessage(tabId, {
1047
1932
  type: "yunti_execute_tool",
1048
1933
  tool: "yunti_type_text",
1049
1934
  arguments: args,
1050
1935
  })
1936
+
1937
+ if (!result || typeof result !== "object" || result.typed !== true) {
1938
+ return result
1939
+ }
1940
+
1941
+ const target =
1942
+ selector
1943
+ ? { selector, method: "selector" }
1944
+ : hasX && hasY
1945
+ ? { method: "coordinate", x: Math.round(Number(args.x)), y: Math.round(Number(args.y)) }
1946
+ : { method: "focused" }
1947
+
1948
+ return {
1949
+ ...result,
1950
+ browserSessionId: session.browserSessionId,
1951
+ action: "type_text",
1952
+ target,
1953
+ ok: true,
1954
+ recoverable: false,
1955
+ nextStepHint: "Type text dispatched. Observe again, read page state, or evaluate the active field value to verify the intended change.",
1956
+ }
1051
1957
  }
1052
1958
 
1053
1959
  const text = String(args.text || "")
1054
1960
  if (!text) throw new Error("text is required")
1055
1961
 
1056
- // Click the uid element to focus it
1057
- const { x, y } = await resolveUidCenter(tabId, session, uid)
1058
- await mouseClick(tabId, x, y, 1)
1059
- await delayCdp(100)
1962
+ const point = resolveObservedUidCenter(session, uid)
1963
+ if (!point.ok) return buildUidPointFailureResult(session, uid, "type", point.error)
1964
+ const result = await chrome.tabs.sendMessage(tabId, {
1965
+ type: "yunti_execute_tool",
1966
+ tool: "yunti_type_text",
1967
+ arguments: { ...args, x: point.x, y: point.y },
1968
+ })
1969
+ if (!result || typeof result !== "object" || result.typed !== true) return result
1060
1970
 
1061
- // Type each character via CDP
1062
- for (const char of text) {
1063
- await chromeDebuggerSendCommand(
1064
- { tabId },
1065
- "Input.dispatchKeyEvent",
1066
- { type: "char", text: char, unmodifiedText: char }
1067
- )
1971
+ return {
1972
+ ...result,
1973
+ typed: true,
1974
+ uid,
1975
+ text,
1976
+ method: result.method || "dom",
1977
+ browserSessionId: session.browserSessionId,
1978
+ action: "type_text",
1979
+ target: { uid, method: result.method || "dom" },
1980
+ ok: true,
1981
+ recoverable: false,
1982
+ nextStepHint: "Type text dispatched without attaching Chrome debugger. Observe again, read page state, or evaluate the field value to verify the intended change.",
1068
1983
  }
1069
-
1070
- return { typed: true, uid, text, method: "cdp.keyboard", browserSessionId: session.browserSessionId }
1071
1984
  }
1072
1985
 
1073
1986
  async function pressKeyByUid(tabId, session, args = {}) {
1074
1987
  const uid = String(args.uid || "").trim()
1075
1988
  if (!uid) {
1076
- return chrome.tabs.sendMessage(tabId, {
1989
+ const selector = String(args.selector || "").trim()
1990
+ const hasX = Number.isFinite(Number(args.x))
1991
+ const hasY = Number.isFinite(Number(args.y))
1992
+ const result = await chrome.tabs.sendMessage(tabId, {
1077
1993
  type: "yunti_execute_tool",
1078
1994
  tool: "yunti_press_key",
1079
1995
  arguments: args,
1080
1996
  })
1997
+
1998
+ if (!result || typeof result !== "object" || result.pressed !== true) {
1999
+ return result
2000
+ }
2001
+
2002
+ const target =
2003
+ selector
2004
+ ? { selector, method: "selector" }
2005
+ : hasX && hasY
2006
+ ? { method: "coordinate", x: Math.round(Number(args.x)), y: Math.round(Number(args.y)) }
2007
+ : { method: "focused" }
2008
+
2009
+ return {
2010
+ ...result,
2011
+ browserSessionId: session.browserSessionId,
2012
+ action: "press_key",
2013
+ target,
2014
+ ok: true,
2015
+ recoverable: false,
2016
+ nextStepHint: "Key press dispatched. Observe again, read page state, or evaluate the active field value to verify the intended effect.",
2017
+ }
1081
2018
  }
1082
2019
 
1083
2020
  const key = String(args.key || "").trim()
1084
2021
  if (!key) throw new Error("key is required")
1085
2022
 
1086
- // Focus the uid element
1087
- const { x, y } = await resolveUidCenter(tabId, session, uid)
1088
- await mouseClick(tabId, x, y, 1)
1089
- await delayCdp(50)
1090
-
1091
- // Map common key names to CDP key events
1092
- const keyDef = normalizeKey(key)
1093
-
1094
- await chromeDebuggerSendCommand({ tabId }, "Input.dispatchKeyEvent", {
1095
- type: "keyDown",
1096
- key: keyDef.key,
1097
- code: keyDef.code,
1098
- windowsVirtualKeyCode: keyDef.keyCode,
1099
- nativeVirtualKeyCode: keyDef.keyCode,
1100
- })
1101
- await chromeDebuggerSendCommand({ tabId }, "Input.dispatchKeyEvent", {
1102
- type: "keyUp",
1103
- key: keyDef.key,
1104
- code: keyDef.code,
1105
- windowsVirtualKeyCode: keyDef.keyCode,
1106
- nativeVirtualKeyCode: keyDef.keyCode,
2023
+ const point = resolveObservedUidCenter(session, uid)
2024
+ if (!point.ok) return buildUidPointFailureResult(session, uid, "press", point.error)
2025
+ const result = await chrome.tabs.sendMessage(tabId, {
2026
+ type: "yunti_execute_tool",
2027
+ tool: "yunti_press_key",
2028
+ arguments: { ...args, x: point.x, y: point.y },
1107
2029
  })
2030
+ if (!result || typeof result !== "object" || result.pressed !== true) return result
1108
2031
 
1109
- return { pressed: true, uid, key, browserSessionId: session.browserSessionId }
2032
+ return {
2033
+ ...result,
2034
+ pressed: true,
2035
+ uid,
2036
+ key,
2037
+ browserSessionId: session.browserSessionId,
2038
+ action: "press_key",
2039
+ target: { uid, method: "dom" },
2040
+ ok: true,
2041
+ recoverable: false,
2042
+ nextStepHint: "Key press dispatched without attaching Chrome debugger. Observe again, read page state, or evaluate the field value to verify the intended effect.",
2043
+ }
1110
2044
  }
1111
2045
 
1112
2046
  const KEY_MAP = {