pi-agent-browser-native 0.2.26 → 0.2.28

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.
@@ -6,8 +6,10 @@
6
6
  * Invariants/Assumptions: agent-browser is installed separately on PATH, the wrapper targets the current locally installed upstream version only, and no backward-compatibility shims are provided.
7
7
  */
8
8
 
9
- import { copyFile, mkdir, readFile, readdir, rm, stat } from "node:fs/promises";
10
- import { dirname, extname, isAbsolute, join, resolve } from "node:path";
9
+ import { constants as fsConstants } from "node:fs";
10
+ import { access, copyFile, mkdir, readFile, readdir, rm, stat } from "node:fs/promises";
11
+ import { delimiter, dirname, extname, isAbsolute, join, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
11
13
 
12
14
  import { StringEnum } from "@earendil-works/pi-ai";
13
15
  import {
@@ -30,6 +32,7 @@ import {
30
32
  buildAgentBrowserNextActions,
31
33
  buildAgentBrowserResultCategoryDetails,
32
34
  buildToolPresentation,
35
+ compareRefIds,
33
36
  getAgentBrowserErrorText,
34
37
  parseAgentBrowserEnvelope,
35
38
  type AgentBrowserBatchResult,
@@ -84,6 +87,7 @@ const PACKAGE_NAME = "pi-agent-browser-native";
84
87
  const AGENT_BROWSER_SEMANTIC_ACTIONS = ["check", "click", "fill", "select", "uncheck"] as const;
85
88
  const AGENT_BROWSER_SEMANTIC_LOCATORS = ["alt", "label", "placeholder", "role", "testid", "text", "title"] as const;
86
89
  const AGENT_BROWSER_JOB_STEP_ACTIONS = ["open", "click", "fill", "wait", "assertText", "assertUrl", "waitForDownload", "screenshot"] as const;
90
+ const AGENT_BROWSER_QA_LOAD_STATES = ["domcontentloaded", "load", "networkidle"] as const;
87
91
  const SOURCE_LOOKUP_WORKSPACE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);
88
92
  const SOURCE_LOOKUP_IGNORED_DIRECTORIES = new Set([".git", "node_modules", "dist", "build", "coverage", ".next", "out", "tmp", "temp"]);
89
93
  const SOURCE_LOOKUP_DEFAULT_MAX_WORKSPACE_FILES = 2_000;
@@ -92,6 +96,7 @@ const SOURCE_LOOKUP_MAX_WORKSPACE_FILES = 5_000;
92
96
  type AgentBrowserSemanticActionName = (typeof AGENT_BROWSER_SEMANTIC_ACTIONS)[number];
93
97
  type AgentBrowserSemanticLocator = (typeof AGENT_BROWSER_SEMANTIC_LOCATORS)[number];
94
98
  type AgentBrowserJobStepAction = (typeof AGENT_BROWSER_JOB_STEP_ACTIONS)[number];
99
+ type AgentBrowserQaLoadState = (typeof AGENT_BROWSER_QA_LOAD_STATES)[number];
95
100
  type AgentBrowserSourceLookupStatus = "candidates-found" | "no-candidates" | "unsupported";
96
101
  type AgentBrowserNetworkSourceLookupStatus = "failed-requests-found" | "no-failed-requests" | "no-candidates";
97
102
 
@@ -111,6 +116,48 @@ interface CompiledAgentBrowserSemanticAction {
111
116
  args: string[];
112
117
  }
113
118
 
119
+ interface ScrollPositionSnapshot {
120
+ containerCount: number;
121
+ containers: Array<{ id: string; scrollLeft: number; scrollTop: number }>;
122
+ innerHeight: number;
123
+ innerWidth: number;
124
+ scrollHeight: number;
125
+ scrollWidth: number;
126
+ scrollX: number;
127
+ scrollY: number;
128
+ }
129
+
130
+ interface ScrollNoopDiagnostic {
131
+ after: ScrollPositionSnapshot;
132
+ before: ScrollPositionSnapshot;
133
+ message: string;
134
+ reason: "no-observed-scroll-position-change";
135
+ recommendations: string[];
136
+ }
137
+
138
+ interface ComboboxFocusDiagnostic {
139
+ activeElement: {
140
+ expanded?: string;
141
+ hasPopup?: string;
142
+ name?: string;
143
+ role?: string;
144
+ tagName?: string;
145
+ };
146
+ message: string;
147
+ reason: "focused-combobox-without-visible-options";
148
+ recommendations: string[];
149
+ visibleListboxCount: number;
150
+ visibleOptionCount: number;
151
+ }
152
+
153
+ interface RecordingDependencyWarning {
154
+ command: "record start" | "record restart";
155
+ dependency: "ffmpeg";
156
+ message: string;
157
+ reason: "ffmpeg-missing-for-recording";
158
+ recommendations: string[];
159
+ }
160
+
114
161
  interface CompiledAgentBrowserJobStep {
115
162
  action: AgentBrowserJobStepAction;
116
163
  args: string[];
@@ -127,6 +174,7 @@ interface CompiledAgentBrowserQaPreset extends CompiledAgentBrowserJob {
127
174
  checkConsole: boolean;
128
175
  checkErrors: boolean;
129
176
  checkNetwork: boolean;
177
+ loadState: AgentBrowserQaLoadState;
130
178
  expectedText: string[];
131
179
  expectedSelector?: string;
132
180
  screenshotPath?: string;
@@ -238,6 +286,7 @@ const AGENT_BROWSER_PARAMS = Type.Object({
238
286
  checkConsole: Type.Optional(Type.Boolean({ description: "Whether to fail on console error messages. Defaults to true." })),
239
287
  checkErrors: Type.Optional(Type.Boolean({ description: "Whether to fail on page errors. Defaults to true." })),
240
288
  checkNetwork: Type.Optional(Type.Boolean({ description: "Whether to inspect network requests and fail on actionable request failures; benign icon misses warn. Defaults to true." })),
289
+ loadState: Type.Optional(StringEnum(AGENT_BROWSER_QA_LOAD_STATES, { description: "Page readiness state for the QA preset before assertions and diagnostics. Defaults to domcontentloaded; use networkidle only for pages without long-lived background requests." })),
241
290
  }),
242
291
  ),
243
292
  sourceLookup: Type.Optional(
@@ -450,16 +499,21 @@ function compileAgentBrowserQaPreset(input: unknown): { compiled?: CompiledAgent
450
499
  return { error: `qa.${field} must be a boolean when provided.` };
451
500
  }
452
501
  }
502
+ const rawLoadState = input.loadState;
503
+ if (rawLoadState !== undefined && (typeof rawLoadState !== "string" || !AGENT_BROWSER_QA_LOAD_STATES.includes(rawLoadState as AgentBrowserQaLoadState))) {
504
+ return { error: `qa.loadState must be one of: ${AGENT_BROWSER_QA_LOAD_STATES.join(", ")}.` };
505
+ }
453
506
  const checkConsole = input.checkConsole !== false;
454
507
  const checkErrors = input.checkErrors !== false;
455
508
  const checkNetwork = input.checkNetwork !== false;
509
+ const loadState = (rawLoadState as AgentBrowserQaLoadState | undefined) ?? "domcontentloaded";
456
510
  const steps: CompiledAgentBrowserJobStep[] = [];
457
511
  if (checkNetwork) steps.push({ action: "wait", args: ["network", "requests", "--clear"] });
458
512
  if (checkConsole) steps.push({ action: "wait", args: ["console", "--clear"] });
459
513
  if (checkErrors) steps.push({ action: "wait", args: ["errors", "--clear"] });
460
514
  steps.push(
461
515
  { action: "open", args: ["open", url] },
462
- { action: "wait", args: ["wait", "--load", "networkidle"] },
516
+ { action: "wait", args: ["wait", "--load", loadState] },
463
517
  );
464
518
  for (const text of expectedText) {
465
519
  steps.push({ action: "assertText", args: ["wait", "--text", text] });
@@ -474,7 +528,7 @@ function compileAgentBrowserQaPreset(input: unknown): { compiled?: CompiledAgent
474
528
  return {
475
529
  compiled: {
476
530
  args: ["batch"],
477
- checks: { checkConsole, checkErrors, checkNetwork, expectedSelector, expectedText, screenshotPath, url },
531
+ checks: { checkConsole, checkErrors, checkNetwork, expectedSelector, expectedText, loadState, screenshotPath, url },
478
532
  stdin: JSON.stringify(steps.map((step) => step.args)),
479
533
  steps,
480
534
  },
@@ -969,6 +1023,61 @@ function buildSemanticActionCandidateActions(compiled: CompiledAgentBrowserSeman
969
1023
  return [];
970
1024
  }
971
1025
 
1026
+ function normalizeSemanticActionAccessibleName(name: string): string {
1027
+ return name.replace(/\s+/g, " ").trim().toLowerCase();
1028
+ }
1029
+
1030
+ function semanticActionNameMatches(candidateName: string, targetName: string): boolean {
1031
+ const normalizedCandidate = normalizeSemanticActionAccessibleName(candidateName);
1032
+ const normalizedTarget = normalizeSemanticActionAccessibleName(targetName);
1033
+ return normalizedCandidate === normalizedTarget || normalizedCandidate.startsWith(`${normalizedTarget} `);
1034
+ }
1035
+
1036
+ function getCompiledSemanticActionRoleTarget(compiled: CompiledAgentBrowserSemanticAction): { role: string; targetName: string } | undefined {
1037
+ if (compiled.locator !== "role" || !["check", "click", "uncheck"].includes(compiled.action)) return undefined;
1038
+ const findIndex = compiled.args.indexOf("find");
1039
+ if (findIndex < 0 || compiled.args[findIndex + 1] !== "role") return undefined;
1040
+ const role = compiled.args[findIndex + 2];
1041
+ const nameFlagIndex = compiled.args.indexOf("--name");
1042
+ const targetName = nameFlagIndex >= 0 ? compiled.args[nameFlagIndex + 1] : undefined;
1043
+ if (!role || !targetName) return undefined;
1044
+ return { role, targetName };
1045
+ }
1046
+
1047
+ function findSemanticActionRefInSnapshot(compiled: CompiledAgentBrowserSemanticAction, snapshotData: unknown): string | undefined {
1048
+ const target = getCompiledSemanticActionRoleTarget(compiled);
1049
+ const refs = getSnapshotRefRecord(snapshotData);
1050
+ if (!target || !refs) return undefined;
1051
+ const candidates = Object.entries(refs).flatMap(([ref, entry]) => {
1052
+ if (!/^e\d+$/.test(ref) || !isRecord(entry)) return [];
1053
+ const role = typeof entry.role === "string" ? entry.role : undefined;
1054
+ const name = typeof entry.name === "string" ? entry.name : undefined;
1055
+ if (!role || !name || role.toLowerCase() !== target.role.toLowerCase() || !semanticActionNameMatches(name, target.targetName)) return [];
1056
+ return [{ exact: normalizeSemanticActionAccessibleName(name) === normalizeSemanticActionAccessibleName(target.targetName), name, ref }];
1057
+ });
1058
+ candidates.sort((left, right) => Number(right.exact) - Number(left.exact) || left.name.length - right.name.length || compareRefIds(left.ref, right.ref));
1059
+ return candidates[0]?.ref;
1060
+ }
1061
+
1062
+ interface SemanticActionVisibleRefResolution {
1063
+ args: string[];
1064
+ snapshot: SessionRefSnapshot;
1065
+ }
1066
+
1067
+ async function resolveSemanticActionVisibleRefArgs(options: {
1068
+ compiled: CompiledAgentBrowserSemanticAction | undefined;
1069
+ cwd: string;
1070
+ sessionName?: string;
1071
+ signal?: AbortSignal;
1072
+ }): Promise<SemanticActionVisibleRefResolution | undefined> {
1073
+ if (!options.compiled || !options.sessionName || !getCompiledSemanticActionRoleTarget(options.compiled)) return undefined;
1074
+ const snapshotData = await runSessionCommandData({ args: ["snapshot", "-i"], cwd: options.cwd, sessionName: options.sessionName, signal: options.signal });
1075
+ const ref = findSemanticActionRefInSnapshot(options.compiled, snapshotData);
1076
+ const snapshot = extractRefSnapshotFromData(snapshotData);
1077
+ if (!ref || !snapshot) return undefined;
1078
+ return { args: [...getCompiledSemanticActionSessionPrefix(options.compiled), options.compiled.action, `@${ref}`], snapshot };
1079
+ }
1080
+
972
1081
  function compileAgentBrowserSemanticAction(input: unknown): { compiled?: CompiledAgentBrowserSemanticAction; error?: string } {
973
1082
  if (!isRecord(input)) {
974
1083
  return { error: "semanticAction must be an object." };
@@ -2615,6 +2724,140 @@ async function collectNavigationSummary(options: {
2615
2724
  return { title, url };
2616
2725
  }
2617
2726
 
2727
+ function extractScrollPositionSnapshot(data: unknown): ScrollPositionSnapshot | undefined {
2728
+ const result = isRecord(data) && isRecord(data.result) ? data.result : data;
2729
+ if (!isRecord(result)) return undefined;
2730
+ const scrollX = typeof result.scrollX === "number" ? result.scrollX : undefined;
2731
+ const scrollY = typeof result.scrollY === "number" ? result.scrollY : undefined;
2732
+ const innerHeight = typeof result.innerHeight === "number" ? result.innerHeight : undefined;
2733
+ const innerWidth = typeof result.innerWidth === "number" ? result.innerWidth : undefined;
2734
+ const scrollHeight = typeof result.scrollHeight === "number" ? result.scrollHeight : undefined;
2735
+ const scrollWidth = typeof result.scrollWidth === "number" ? result.scrollWidth : undefined;
2736
+ if (scrollX === undefined || scrollY === undefined || innerHeight === undefined || innerWidth === undefined || scrollHeight === undefined || scrollWidth === undefined) return undefined;
2737
+ const containers = Array.isArray(result.containers)
2738
+ ? result.containers.flatMap((entry, index): ScrollPositionSnapshot["containers"] => {
2739
+ if (!isRecord(entry)) return [];
2740
+ const rawId = typeof entry.id === "string" ? entry.id : undefined;
2741
+ const id = rawId && /^\d+:[a-z][a-z0-9-]*(?:\[role=[a-z-]+\])?$/i.test(rawId) ? rawId : `sample-${index}`;
2742
+ const scrollTop = typeof entry.scrollTop === "number" ? entry.scrollTop : undefined;
2743
+ const scrollLeft = typeof entry.scrollLeft === "number" ? entry.scrollLeft : undefined;
2744
+ return scrollTop !== undefined && scrollLeft !== undefined ? [{ id, scrollLeft, scrollTop }] : [];
2745
+ })
2746
+ : [];
2747
+ return {
2748
+ containerCount: typeof result.containerCount === "number" ? result.containerCount : containers.length,
2749
+ containers,
2750
+ innerHeight,
2751
+ innerWidth,
2752
+ scrollHeight,
2753
+ scrollWidth,
2754
+ scrollX,
2755
+ scrollY,
2756
+ };
2757
+ }
2758
+
2759
+ const SCROLL_POSITION_EVAL = `(() => {
2760
+ const viewport = {
2761
+ scrollX: window.scrollX,
2762
+ scrollY: window.scrollY,
2763
+ innerHeight: window.innerHeight,
2764
+ innerWidth: window.innerWidth,
2765
+ scrollHeight: Math.max(document.documentElement?.scrollHeight || 0, document.body?.scrollHeight || 0),
2766
+ scrollWidth: Math.max(document.documentElement?.scrollWidth || 0, document.body?.scrollWidth || 0),
2767
+ };
2768
+ const describe = (element, index) => {
2769
+ const role = element.getAttribute("role") || "";
2770
+ const id = element.tagName.toLowerCase();
2771
+ return {
2772
+ id: String(index) + ":" + id + (role ? "[role=" + role + "]" : ""),
2773
+ scrollTop: element.scrollTop,
2774
+ scrollLeft: element.scrollLeft,
2775
+ area: element.clientWidth * element.clientHeight,
2776
+ };
2777
+ };
2778
+ const containers = Array.from(document.querySelectorAll("body *"))
2779
+ .filter((element) => element instanceof HTMLElement && (element.scrollHeight > element.clientHeight + 1 || element.scrollWidth > element.clientWidth + 1))
2780
+ .map(describe)
2781
+ .sort((left, right) => right.area - left.area)
2782
+ .slice(0, 10)
2783
+ .map(({ area, ...entry }) => entry);
2784
+ return { ...viewport, containerCount: containers.length, containers };
2785
+ })()`;
2786
+
2787
+ async function collectScrollPositionSnapshot(options: {
2788
+ cwd: string;
2789
+ sessionName?: string;
2790
+ signal?: AbortSignal;
2791
+ }): Promise<ScrollPositionSnapshot | undefined> {
2792
+ return extractScrollPositionSnapshot(await runSessionCommandData({
2793
+ args: ["eval", "--stdin"],
2794
+ cwd: options.cwd,
2795
+ sessionName: options.sessionName,
2796
+ signal: options.signal,
2797
+ stdin: SCROLL_POSITION_EVAL,
2798
+ }));
2799
+ }
2800
+
2801
+ function sameScrollPositionSnapshot(left: ScrollPositionSnapshot, right: ScrollPositionSnapshot): boolean {
2802
+ if (
2803
+ left.scrollX !== right.scrollX ||
2804
+ left.scrollY !== right.scrollY ||
2805
+ left.scrollHeight !== right.scrollHeight ||
2806
+ left.scrollWidth !== right.scrollWidth ||
2807
+ left.containers.length !== right.containers.length
2808
+ ) {
2809
+ return false;
2810
+ }
2811
+ return left.containers.every((container, index) => {
2812
+ const other = right.containers[index];
2813
+ return other?.id === container.id && other.scrollTop === container.scrollTop && other.scrollLeft === container.scrollLeft;
2814
+ });
2815
+ }
2816
+
2817
+ function buildScrollNoopDiagnostic(before: ScrollPositionSnapshot | undefined, after: ScrollPositionSnapshot | undefined): ScrollNoopDiagnostic | undefined {
2818
+ if (!before || !after || !sameScrollPositionSnapshot(before, after)) return undefined;
2819
+ return {
2820
+ after,
2821
+ before,
2822
+ message: "Scroll reported success, but the viewport and sampled scrollable containers did not change position.",
2823
+ reason: "no-observed-scroll-position-change",
2824
+ recommendations: [
2825
+ "Run snapshot -i or screenshot to confirm what is visible before choosing the next action.",
2826
+ "On dashboards and panes with nested scrolling, use scrollintoview <@ref> for a visible target or target the actual scrollable region instead of repeating page scrolls.",
2827
+ ],
2828
+ };
2829
+ }
2830
+
2831
+ function buildScrollNoopNextActions(sessionName: string | undefined): AgentBrowserNextAction[] {
2832
+ const withSession = (args: string[]): string[] => sessionName ? ["--session", sessionName, ...args] : args;
2833
+ return [
2834
+ {
2835
+ id: "inspect-after-noop-scroll",
2836
+ params: { args: withSession(["snapshot", "-i"]) },
2837
+ reason: "Refresh interactive refs and inspect whether the intended target is inside a nested scroll container.",
2838
+ safety: "Do not assume repeated page scrolls will move dashboard panels or nested panes.",
2839
+ tool: "agent_browser",
2840
+ },
2841
+ {
2842
+ id: "verify-noop-scroll-visually",
2843
+ params: { args: withSession(["screenshot"]) },
2844
+ reason: "Capture the current viewport to verify whether the scroll actually changed visible content.",
2845
+ safety: "Use screenshot evidence before concluding a dense dashboard did or did not move.",
2846
+ tool: "agent_browser",
2847
+ },
2848
+ ];
2849
+ }
2850
+
2851
+ function formatScrollNoopDiagnosticText(diagnostic: ScrollNoopDiagnostic | undefined): string | undefined {
2852
+ if (!diagnostic) return undefined;
2853
+ return [
2854
+ "Scroll diagnostic: no observed scroll movement.",
2855
+ `Reason: ${diagnostic.message}`,
2856
+ `Sampled scrollable containers: ${diagnostic.after.containers.length}/${diagnostic.after.containerCount}.`,
2857
+ ...diagnostic.recommendations.map((recommendation) => `- ${recommendation}`),
2858
+ ].join("\n");
2859
+ }
2860
+
2618
2861
  function mergeNavigationSummaryIntoData(data: unknown, navigationSummary: NavigationSummary): unknown {
2619
2862
  if (isRecord(data)) {
2620
2863
  return { ...data, navigationSummary };
@@ -2622,12 +2865,187 @@ function mergeNavigationSummaryIntoData(data: unknown, navigationSummary: Naviga
2622
2865
  return { navigationSummary, result: data };
2623
2866
  }
2624
2867
 
2868
+ const COMBOBOX_FOCUS_EVAL = `(() => {
2869
+ const isVisible = (element) => {
2870
+ if (!(element instanceof HTMLElement)) return false;
2871
+ const style = window.getComputedStyle(element);
2872
+ if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return false;
2873
+ return element.getClientRects().length > 0;
2874
+ };
2875
+ const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
2876
+ const role = active?.getAttribute("role") || undefined;
2877
+ const hasPopup = active?.getAttribute("aria-haspopup") || undefined;
2878
+ const expanded = active?.getAttribute("aria-expanded") || undefined;
2879
+ const tagName = active?.tagName.toLowerCase();
2880
+ const name = (active?.getAttribute("aria-label") || active?.getAttribute("placeholder") || active?.getAttribute("title") || active?.textContent || "").trim().slice(0, 80) || undefined;
2881
+ const visibleListboxCount = Array.from(document.querySelectorAll('[role="listbox"], [role="menu"]')).filter(isVisible).length;
2882
+ const visibleOptionCount = Array.from(document.querySelectorAll('[role="option"], option, [role="menuitem"]')).filter(isVisible).length;
2883
+ const comboboxLike = role === "combobox" || hasPopup === "listbox" || hasPopup === "menu" || tagName === "select" || active?.getAttribute("aria-autocomplete") !== null;
2884
+ return { activeElement: active ? { expanded, hasPopup, name, role, tagName } : undefined, comboboxLike, visibleListboxCount, visibleOptionCount };
2885
+ })()`;
2886
+
2887
+ function extractComboboxFocusDiagnostic(data: unknown): ComboboxFocusDiagnostic | undefined {
2888
+ const result = isRecord(data) && isRecord(data.result) ? data.result : data;
2889
+ if (!isRecord(result) || result.comboboxLike !== true || !isRecord(result.activeElement)) return undefined;
2890
+ const visibleListboxCount = typeof result.visibleListboxCount === "number" ? result.visibleListboxCount : 0;
2891
+ const visibleOptionCount = typeof result.visibleOptionCount === "number" ? result.visibleOptionCount : 0;
2892
+ const expanded = typeof result.activeElement.expanded === "string" ? result.activeElement.expanded : undefined;
2893
+ if ((expanded !== "false" && expanded !== "true") || visibleListboxCount > 0 || visibleOptionCount > 0) return undefined;
2894
+ return {
2895
+ activeElement: {
2896
+ expanded,
2897
+ hasPopup: typeof result.activeElement.hasPopup === "string" ? result.activeElement.hasPopup : undefined,
2898
+ name: typeof result.activeElement.name === "string" ? redactSensitiveText(result.activeElement.name) : undefined,
2899
+ role: typeof result.activeElement.role === "string" ? result.activeElement.role : undefined,
2900
+ tagName: typeof result.activeElement.tagName === "string" ? result.activeElement.tagName : undefined,
2901
+ },
2902
+ message: "A combobox-like control is focused, but no listbox or option elements are visibly open.",
2903
+ reason: "focused-combobox-without-visible-options",
2904
+ recommendations: [
2905
+ "Run snapshot -i to inspect whether options appeared under a different role or portal.",
2906
+ "Try ArrowDown or Enter to open the option list before selecting, or use select/visible option refs when available.",
2907
+ ],
2908
+ visibleListboxCount,
2909
+ visibleOptionCount,
2910
+ };
2911
+ }
2912
+
2913
+ function isComboboxFocusDiagnosticCommand(command: string | undefined, commandTokens: string[]): boolean {
2914
+ const explicitlyTargetsCombobox = commandTokens.some((token) => /^(?:combobox|listbox)$/i.test(token));
2915
+ if (!explicitlyTargetsCombobox) return false;
2916
+ if (command === "click" || command === "fill") return true;
2917
+ return command === "find" && commandTokens.some((token) => ["click", "fill", "select"].includes(token));
2918
+ }
2919
+
2920
+ function getCompiledSemanticActionRoleValue(compiled: CompiledAgentBrowserSemanticAction): string | undefined {
2921
+ if (compiled.locator !== "role") return undefined;
2922
+ const findIndex = compiled.args.indexOf("find");
2923
+ if (findIndex < 0 || compiled.args[findIndex + 1] !== "role") return undefined;
2924
+ return compiled.args[findIndex + 2];
2925
+ }
2926
+
2927
+ function isComboboxFocusDiagnosticSemanticAction(compiled: CompiledAgentBrowserSemanticAction | undefined): boolean {
2928
+ if (!compiled || !["click", "fill", "select"].includes(compiled.action)) return false;
2929
+ return /^(?:combobox|listbox)$/i.test(getCompiledSemanticActionRoleValue(compiled) ?? "");
2930
+ }
2931
+
2932
+ async function collectComboboxFocusDiagnostic(options: {
2933
+ command?: string;
2934
+ commandTokens: string[];
2935
+ cwd: string;
2936
+ semanticAction?: CompiledAgentBrowserSemanticAction;
2937
+ sessionName?: string;
2938
+ signal?: AbortSignal;
2939
+ }): Promise<ComboboxFocusDiagnostic | undefined> {
2940
+ if (!isComboboxFocusDiagnosticCommand(options.command, options.commandTokens) && !isComboboxFocusDiagnosticSemanticAction(options.semanticAction)) return undefined;
2941
+ return extractComboboxFocusDiagnostic(await runSessionCommandData({
2942
+ args: ["eval", "--stdin"],
2943
+ cwd: options.cwd,
2944
+ sessionName: options.sessionName,
2945
+ signal: options.signal,
2946
+ stdin: COMBOBOX_FOCUS_EVAL,
2947
+ }));
2948
+ }
2949
+
2950
+ function buildComboboxFocusNextActions(sessionName: string | undefined): AgentBrowserNextAction[] {
2951
+ const withSession = (args: string[]): string[] => sessionName ? ["--session", sessionName, ...args] : args;
2952
+ return [
2953
+ {
2954
+ id: "inspect-focused-combobox",
2955
+ params: { args: withSession(["snapshot", "-i"]) },
2956
+ reason: "Inspect the focused combobox and any portal/listbox refs before choosing an option.",
2957
+ safety: "Prefer visible option refs or select when a native/selectable option list is exposed.",
2958
+ tool: "agent_browser",
2959
+ },
2960
+ {
2961
+ id: "try-open-combobox-with-arrow",
2962
+ params: { args: withSession(["press", "ArrowDown"]) },
2963
+ reason: "Many searchable comboboxes open their option list with ArrowDown after focus.",
2964
+ safety: "Use only when the focused combobox is still the intended control, then re-snapshot before selecting.",
2965
+ tool: "agent_browser",
2966
+ },
2967
+ {
2968
+ id: "try-open-combobox-with-enter",
2969
+ params: { args: withSession(["press", "Enter"]) },
2970
+ reason: "Some comboboxes open or confirm their option list with Enter after focus.",
2971
+ safety: "Enter may select a highlighted/default option; prefer ArrowDown first unless Enter is the app's expected opener.",
2972
+ tool: "agent_browser",
2973
+ },
2974
+ ];
2975
+ }
2976
+
2977
+ function formatComboboxFocusDiagnosticText(diagnostic: ComboboxFocusDiagnostic | undefined): string | undefined {
2978
+ if (!diagnostic) return undefined;
2979
+ const label = diagnostic.activeElement.name ? ` (${diagnostic.activeElement.name})` : "";
2980
+ return [
2981
+ `Combobox diagnostic: focused combobox did not expose visible options${label}.`,
2982
+ `Reason: ${diagnostic.message}`,
2983
+ ...diagnostic.recommendations.map((recommendation) => `- ${recommendation}`),
2984
+ ].join("\n");
2985
+ }
2986
+
2987
+ function getRecordStartLikeCommand(command: string | undefined, commandTokens: string[]): RecordingDependencyWarning["command"] | undefined {
2988
+ if (command !== "record") return undefined;
2989
+ const subcommand = commandTokens[1]?.toLowerCase();
2990
+ if (subcommand === "start") return "record start";
2991
+ if (subcommand === "restart") return "record restart";
2992
+ return undefined;
2993
+ }
2994
+
2995
+ async function executableExistsOnPath(command: string): Promise<boolean> {
2996
+ const pathValue = process.env.PATH ?? "";
2997
+ const extensions = process.platform === "win32"
2998
+ ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)
2999
+ : [""];
3000
+ for (const directory of pathValue.split(delimiter).filter(Boolean)) {
3001
+ for (const extension of extensions) {
3002
+ try {
3003
+ const candidate = join(directory, `${command}${extension}`);
3004
+ await access(candidate, fsConstants.X_OK);
3005
+ if ((await stat(candidate)).isFile()) return true;
3006
+ } catch {
3007
+ // Try the next candidate.
3008
+ }
3009
+ }
3010
+ }
3011
+ return false;
3012
+ }
3013
+
3014
+ async function collectRecordingDependencyWarning(options: {
3015
+ command: string | undefined;
3016
+ commandTokens: string[];
3017
+ succeeded: boolean;
3018
+ }): Promise<RecordingDependencyWarning | undefined> {
3019
+ if (!options.succeeded) return undefined;
3020
+ const recordCommand = getRecordStartLikeCommand(options.command, options.commandTokens);
3021
+ if (!recordCommand) return undefined;
3022
+ if (await executableExistsOnPath("ffmpeg")) return undefined;
3023
+ return {
3024
+ command: recordCommand,
3025
+ dependency: "ffmpeg",
3026
+ message: `${recordCommand} can begin recording, but record stop needs ffmpeg on PATH to encode the WebM output.`,
3027
+ reason: "ffmpeg-missing-for-recording",
3028
+ recommendations: [
3029
+ "Install ffmpeg before relying on this recording workflow; on macOS with Homebrew, brew install ffmpeg or brew install ffmpeg-full.",
3030
+ "If ffmpeg was just installed, restart pi or ensure the PATH visible to pi includes the ffmpeg binary before running record stop.",
3031
+ ],
3032
+ };
3033
+ }
3034
+
3035
+ function formatRecordingDependencyWarningText(warning: RecordingDependencyWarning | undefined): string | undefined {
3036
+ if (!warning) return undefined;
3037
+ return [
3038
+ "Recording dependency warning: ffmpeg not found on PATH.",
3039
+ `Reason: ${warning.message}`,
3040
+ ...warning.recommendations.map((recommendation) => `- ${recommendation}`),
3041
+ ].join("\n");
3042
+ }
3043
+
2625
3044
  function getSnapshotRefRecord(data: unknown): Record<string, unknown> | undefined {
2626
3045
  return isRecord(data) && isRecord(data.refs) ? data.refs : undefined;
2627
3046
  }
2628
3047
 
2629
3048
  const OVERLAY_CLOSE_NAME_PATTERN = /(?:\b(?:close|dismiss|no thanks|not now|maybe later|hide|skip|continue without|x)\b|^\s*×\s*$)/i;
2630
- const OVERLAY_CONTEXT_NAME_PATTERN = /\b(?:banner|modal|dialog|popup|pop-up|overlay|donat(?:e|ion)|subscribe|sign in|login|cookie|privacy|consent)\b/i;
2631
3049
  const OVERLAY_CONTEXT_ROLES = new Set(["alertdialog", "dialog"]);
2632
3050
  const OVERLAY_ACTION_ROLES = new Set(["button", "link", "menuitem"]);
2633
3051
  const OVERLAY_BLOCKER_CANDIDATE_LIMIT = 3;
@@ -2638,8 +3056,7 @@ function getOverlayBlockerCandidates(snapshotData: unknown): OverlayBlockerCandi
2638
3056
  const hasOverlayContext = Object.values(refs).some((entry) => {
2639
3057
  if (!isRecord(entry)) return false;
2640
3058
  const role = typeof entry.role === "string" ? entry.role : "";
2641
- const name = typeof entry.name === "string" ? entry.name : "";
2642
- return OVERLAY_CONTEXT_ROLES.has(role.toLowerCase()) || OVERLAY_CONTEXT_NAME_PATTERN.test(name);
3059
+ return OVERLAY_CONTEXT_ROLES.has(role.toLowerCase());
2643
3060
  });
2644
3061
  if (!hasOverlayContext) return [];
2645
3062
  const candidates: OverlayBlockerCandidate[] = [];
@@ -2810,16 +3227,27 @@ function formatEvalStdinHintText(hint: EvalStdinHint | undefined): string | unde
2810
3227
  return hint ? `Eval stdin hint: ${hint.reason} ${hint.suggestion}` : undefined;
2811
3228
  }
2812
3229
 
2813
- function getArtifactCleanupGuidance(options: { command?: string; manifest?: SessionArtifactManifest; succeeded: boolean }): ArtifactCleanupGuidance | undefined {
3230
+ async function getArtifactCleanupGuidance(options: { command?: string; cwd: string; manifest?: SessionArtifactManifest; succeeded: boolean }): Promise<ArtifactCleanupGuidance | undefined> {
2814
3231
  if (!options.succeeded || options.command !== "close" || !options.manifest || options.manifest.entries.length === 0) return undefined;
2815
- const explicitArtifactPaths = options.manifest.entries
2816
- .filter((entry) => entry.storageScope === "explicit-path")
2817
- .map((entry) => entry.path)
2818
- .filter((path, index, paths) => paths.indexOf(path) === index)
2819
- .slice(0, 10);
3232
+ const explicitEntries = options.manifest.entries.filter((entry) => entry.storageScope === "explicit-path");
3233
+ const explicitArtifactPaths: string[] = [];
3234
+ const seenPaths = new Set<string>();
3235
+ for (const entry of explicitEntries) {
3236
+ if (explicitArtifactPaths.length >= 10) break;
3237
+ const displayPath = entry.path;
3238
+ if (seenPaths.has(displayPath)) continue;
3239
+ const absolutePath = entry.absolutePath ?? (isAbsolute(entry.path) ? entry.path : resolve(options.cwd, entry.path));
3240
+ try {
3241
+ await stat(absolutePath);
3242
+ } catch {
3243
+ continue;
3244
+ }
3245
+ seenPaths.add(displayPath);
3246
+ explicitArtifactPaths.push(displayPath);
3247
+ }
2820
3248
  return {
2821
3249
  explicitArtifactPaths,
2822
- note: "Closing the browser session does not delete explicit screenshots, downloads, PDFs, traces, HAR files, or recordings; clean those paths with host file tools when no longer needed.",
3250
+ note: "Closing the browser session does not delete explicit screenshots, downloads, PDFs, traces, HAR files, or recordings; clean existing paths with host file tools when no longer needed.",
2823
3251
  owner: "host-file-tools",
2824
3252
  summary: formatSessionArtifactRetentionSummary(options.manifest),
2825
3253
  };
@@ -3283,10 +3711,19 @@ async function closeManagedSession(options: { cwd: string; sessionName: string;
3283
3711
  }
3284
3712
  }
3285
3713
 
3714
+ function getInstalledDocsPaths(): { readmePath: string; commandReferencePath: string; toolContractPath: string } {
3715
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
3716
+ return {
3717
+ readmePath: join(packageRoot, "README.md"),
3718
+ commandReferencePath: join(packageRoot, "docs", "COMMAND_REFERENCE.md"),
3719
+ toolContractPath: join(packageRoot, "docs", "TOOL_CONTRACT.md"),
3720
+ };
3721
+ }
3722
+
3286
3723
  export default function agentBrowserExtension(pi: ExtensionAPI) {
3287
3724
  const ephemeralSessionSeed = createEphemeralSessionSeed();
3288
3725
  const hasBraveApiKey = hasUsableBraveApiKey();
3289
- const toolPromptGuidelines = buildToolPromptGuidelines({ includeBraveSearch: hasBraveApiKey });
3726
+ const toolPromptGuidelines = buildToolPromptGuidelines({ includeBraveSearch: hasBraveApiKey, docs: getInstalledDocsPaths() });
3290
3727
  const implicitSessionIdleTimeoutMs = String(getImplicitSessionIdleTimeoutMs());
3291
3728
  const implicitSessionCloseTimeoutMs = getImplicitSessionCloseTimeoutMs();
3292
3729
  let managedSessionActive = false;
@@ -3457,12 +3894,29 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
3457
3894
  const runTool = async (): Promise<AgentBrowserToolResult> => {
3458
3895
  const sessionMode = params.sessionMode ?? DEFAULT_SESSION_MODE;
3459
3896
  const freshSessionName = createFreshSessionName(managedSessionBaseName, ephemeralSessionSeed, freshSessionOrdinal + 1);
3460
- const executionPlan = buildExecutionPlan(preparedArgs.args, {
3897
+ let executionPlan = buildExecutionPlan(preparedArgs.args, {
3461
3898
  freshSessionName,
3462
3899
  managedSessionActive,
3463
3900
  managedSessionName,
3464
3901
  sessionMode,
3465
3902
  });
3903
+ let semanticActionVisibleRefResolution: SemanticActionVisibleRefResolution | undefined;
3904
+ if (!executionPlan.validationError && executionPlan.managedSessionName !== freshSessionName) {
3905
+ semanticActionVisibleRefResolution = await resolveSemanticActionVisibleRefArgs({
3906
+ compiled: compiledSemanticAction,
3907
+ cwd: ctx.cwd,
3908
+ sessionName: executionPlan.sessionName,
3909
+ signal,
3910
+ });
3911
+ if (semanticActionVisibleRefResolution) {
3912
+ executionPlan = buildExecutionPlan(semanticActionVisibleRefResolution.args, {
3913
+ freshSessionName,
3914
+ managedSessionActive,
3915
+ managedSessionName,
3916
+ sessionMode,
3917
+ });
3918
+ }
3919
+ }
3466
3920
  const redactedEffectiveArgs = redactInvocationArgs(executionPlan.effectiveArgs);
3467
3921
  const redactedRecoveryHint = redactRecoveryHint(executionPlan.recoveryHint);
3468
3922
  const compatibilityWorkaround: CompatibilityWorkaround | undefined = executionPlan.compatibilityWorkaround;
@@ -3490,7 +3944,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
3490
3944
  };
3491
3945
  }
3492
3946
 
3493
- const commandTokens = extractCommandTokens(preparedArgs.args);
3947
+ const commandTokens = semanticActionVisibleRefResolution ? extractCommandTokens(semanticActionVisibleRefResolution.args) : extractCommandTokens(preparedArgs.args);
3494
3948
  const exactSensitiveValues = getExactSensitiveStdinValues({
3495
3949
  command: executionPlan.commandInfo.command,
3496
3950
  commandTokens,
@@ -3560,10 +4014,13 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
3560
4014
  const priorSessionTabTargetState = executionPlan.sessionName ? sessionTabTargets.get(executionPlan.sessionName) : undefined;
3561
4015
  const priorSessionTabTarget = priorSessionTabTargetState?.target;
3562
4016
  const priorRefSnapshotState = executionPlan.sessionName ? sessionRefSnapshots.get(executionPlan.sessionName) : undefined;
4017
+ const resolvedSemanticActionRefSnapshot = semanticActionVisibleRefResolution?.snapshot
4018
+ ? { ...semanticActionVisibleRefResolution.snapshot, target: semanticActionVisibleRefResolution.snapshot.target ?? priorSessionTabTarget }
4019
+ : undefined;
3563
4020
  const staleRefPreflight = buildStaleRefPreflight({
3564
4021
  commandTokens,
3565
4022
  currentTarget: priorSessionTabTarget,
3566
- refSnapshot: priorRefSnapshotState,
4023
+ refSnapshot: resolvedSemanticActionRefSnapshot ?? priorRefSnapshotState,
3567
4024
  stdin: toolStdin,
3568
4025
  });
3569
4026
  if (staleRefPreflight) {
@@ -3668,6 +4125,14 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
3668
4125
  }
3669
4126
  }
3670
4127
  const redactedProcessArgs = redactInvocationArgs(processArgs);
4128
+ const shouldProbeScrollNoop = executionPlan.commandInfo.command === "scroll" && executionPlan.startupScopedFlags.length === 0;
4129
+ const scrollPositionBefore = shouldProbeScrollNoop
4130
+ ? await collectScrollPositionSnapshot({
4131
+ cwd: ctx.cwd,
4132
+ sessionName: executionPlan.sessionName,
4133
+ signal,
4134
+ })
4135
+ : undefined;
3671
4136
 
3672
4137
  onUpdate?.({
3673
4138
  content: [{ type: "text", text: `Running agent-browser ${buildInvocationPreview(redactedProcessArgs)}` }],
@@ -3921,6 +4386,31 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
3921
4386
  signal,
3922
4387
  });
3923
4388
  }
4389
+ const comboboxFocusDiagnostic = succeeded
4390
+ ? await collectComboboxFocusDiagnostic({
4391
+ command: executionPlan.commandInfo.command,
4392
+ commandTokens,
4393
+ cwd: ctx.cwd,
4394
+ semanticAction: compiledSemanticAction,
4395
+ sessionName: executionPlan.sessionName,
4396
+ signal,
4397
+ })
4398
+ : undefined;
4399
+ const recordingDependencyWarning = await collectRecordingDependencyWarning({
4400
+ command: executionPlan.commandInfo.command,
4401
+ commandTokens,
4402
+ succeeded,
4403
+ });
4404
+ const scrollNoopDiagnostic = succeeded && shouldProbeScrollNoop
4405
+ ? buildScrollNoopDiagnostic(
4406
+ scrollPositionBefore,
4407
+ await collectScrollPositionSnapshot({
4408
+ cwd: ctx.cwd,
4409
+ sessionName: executionPlan.sessionName,
4410
+ signal,
4411
+ }),
4412
+ )
4413
+ : undefined;
3924
4414
  let currentRefSnapshot: SessionRefSnapshot | undefined;
3925
4415
  if (executionPlan.sessionName) {
3926
4416
  const activeSessionTabTargetState = sessionTabTargets.get(executionPlan.sessionName);
@@ -3937,7 +4427,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
3937
4427
  ? extractRefSnapshotFromData(presentationEnvelope?.data)
3938
4428
  : executionPlan.commandInfo.command === "batch"
3939
4429
  ? extractRefSnapshotFromBatchResults(presentationEnvelope?.data)
3940
- : overlayBlockerDiagnostic?.snapshot
4430
+ : resolvedSemanticActionRefSnapshot ?? overlayBlockerDiagnostic?.snapshot
3941
4431
  : undefined;
3942
4432
  if (refSnapshot && shouldApplySessionTabTargetUpdate({ current: sessionRefSnapshots.get(executionPlan.sessionName), updateOrder: tabTargetUpdateOrder })) {
3943
4433
  currentRefSnapshot = { ...refSnapshot, target: refSnapshot.target ?? currentSessionTabTarget };
@@ -4082,8 +4572,9 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
4082
4572
  stdin: toolStdin,
4083
4573
  });
4084
4574
  const resultArtifactManifest = presentation.artifactManifest ?? artifactManifest;
4085
- const artifactCleanup = getArtifactCleanupGuidance({
4575
+ const artifactCleanup = await getArtifactCleanupGuidance({
4086
4576
  command: executionPlan.commandInfo.command,
4577
+ cwd: ctx.cwd,
4087
4578
  manifest: resultArtifactManifest,
4088
4579
  succeeded,
4089
4580
  });
@@ -4147,6 +4638,12 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
4147
4638
  if (selectorTextVisibilityDiagnostics.length > 0) {
4148
4639
  (nextActions ??= []).push(...buildSelectorTextVisibilityNextActions({ diagnostics: selectorTextVisibilityDiagnostics, sessionName: executionPlan.sessionName }));
4149
4640
  }
4641
+ if (scrollNoopDiagnostic) {
4642
+ (nextActions ??= []).push(...buildScrollNoopNextActions(executionPlan.sessionName));
4643
+ }
4644
+ if (comboboxFocusDiagnostic) {
4645
+ (nextActions ??= []).push(...buildComboboxFocusNextActions(executionPlan.sessionName));
4646
+ }
4150
4647
  if (categoryDetails.failureCategory === "stale-ref" && redactedCompiledSemanticAction) {
4151
4648
  (nextActions ??= []).push({
4152
4649
  id: "retry-semantic-action-after-stale-ref",
@@ -4156,6 +4653,9 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
4156
4653
  tool: "agent_browser" as const,
4157
4654
  });
4158
4655
  }
4656
+ const pageChangeSummary = (scrollNoopDiagnostic || comboboxFocusDiagnostic) && presentation.pageChangeSummary
4657
+ ? { ...presentation.pageChangeSummary, nextActionIds: nextActions?.map((action) => action.id) }
4658
+ : presentation.pageChangeSummary;
4159
4659
  const details = {
4160
4660
  args: redactedArgs,
4161
4661
  compiledJob: redactedCompiledJob,
@@ -4189,8 +4689,11 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
4189
4689
  imagePath: presentation.imagePath,
4190
4690
  imagePaths: presentation.imagePaths,
4191
4691
  nextActions,
4192
- pageChangeSummary: presentation.pageChangeSummary,
4692
+ pageChangeSummary,
4193
4693
  overlayBlockers: overlayBlockerDiagnostic,
4694
+ comboboxFocus: comboboxFocusDiagnostic,
4695
+ recordingDependencyWarning,
4696
+ scrollNoop: scrollNoopDiagnostic,
4194
4697
  qaPreset,
4195
4698
  selectorTextVisibility: selectorTextVisibilityDiagnostics[0],
4196
4699
  selectorTextVisibilityAll: selectorTextVisibilityDiagnostics.length > 1 ? selectorTextVisibilityDiagnostics : undefined,
@@ -4218,11 +4721,14 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
4218
4721
  const semanticActionCandidateText = nextActions ? formatSemanticActionCandidateText(nextActions) : undefined;
4219
4722
  const overlayBlockerText = overlayBlockerDiagnostic ? formatOverlayBlockerText(overlayBlockerDiagnostic) : undefined;
4220
4723
  const selectorTextVisibilityText = formatSelectorTextVisibilityText(selectorTextVisibilityDiagnostics);
4724
+ const scrollNoopDiagnosticText = formatScrollNoopDiagnosticText(scrollNoopDiagnostic);
4725
+ const comboboxFocusDiagnosticText = formatComboboxFocusDiagnosticText(comboboxFocusDiagnostic);
4726
+ const recordingDependencyWarningText = formatRecordingDependencyWarningText(recordingDependencyWarning);
4221
4727
  const evalStdinHintText = formatEvalStdinHintText(evalStdinHint);
4222
4728
  const artifactCleanupText = formatArtifactCleanupGuidanceText(artifactCleanup);
4223
4729
  const timeoutPartialProgressText = timeoutPartialProgress ? formatTimeoutPartialProgressText(timeoutPartialProgress) : undefined;
4224
4730
  const managedSessionOutcomeText = formatManagedSessionOutcomeText(managedSessionOutcome);
4225
- const rawAppendedDiagnosticText = [semanticActionCandidateText, overlayBlockerText, selectorTextVisibilityText, evalStdinHintText, artifactCleanupText, timeoutPartialProgressText, managedSessionOutcomeText].filter((item): item is string => item !== undefined).join("\n\n");
4731
+ const rawAppendedDiagnosticText = [semanticActionCandidateText, overlayBlockerText, selectorTextVisibilityText, scrollNoopDiagnosticText, comboboxFocusDiagnosticText, recordingDependencyWarningText, evalStdinHintText, artifactCleanupText, timeoutPartialProgressText, managedSessionOutcomeText].filter((item): item is string => item !== undefined).join("\n\n");
4226
4732
  const appendedDiagnosticText = redactSensitiveText(redactExactSensitiveText(rawAppendedDiagnosticText, exactSensitiveValues));
4227
4733
  const shouldAppendDiagnosticText = appendedDiagnosticText.length > 0 && (!userRequestedJson || plainTextInspection);
4228
4734
  const content = shouldAppendDiagnosticText && redactedContent[0]?.type === "text"