pi-agent-browser-native 0.2.43 → 0.2.45

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +26 -16
  3. package/docs/ARCHITECTURE.md +12 -10
  4. package/docs/COMMAND_REFERENCE.md +49 -27
  5. package/docs/ELECTRON.md +1 -1
  6. package/docs/RELEASE.md +16 -9
  7. package/docs/REQUIREMENTS.md +6 -3
  8. package/docs/SUPPORT_MATRIX.md +18 -14
  9. package/docs/TOOL_CONTRACT.md +87 -46
  10. package/docs/platform-smoke.md +15 -9
  11. package/extensions/agent-browser/index.ts +29 -445
  12. package/extensions/agent-browser/lib/bash-guard.ts +205 -0
  13. package/extensions/agent-browser/lib/electron/cdp.ts +69 -0
  14. package/extensions/agent-browser/lib/electron/cleanup.ts +5 -58
  15. package/extensions/agent-browser/lib/electron/discovery.ts +2 -9
  16. package/extensions/agent-browser/lib/electron/launch.ts +11 -65
  17. package/extensions/agent-browser/lib/electron/text.ts +13 -0
  18. package/extensions/agent-browser/lib/fs-utils.ts +18 -0
  19. package/extensions/agent-browser/lib/input-modes/job.ts +207 -21
  20. package/extensions/agent-browser/lib/input-modes/params.ts +17 -7
  21. package/extensions/agent-browser/lib/input-modes/semantic-action.ts +22 -2
  22. package/extensions/agent-browser/lib/input-modes/types.ts +5 -1
  23. package/extensions/agent-browser/lib/input-modes.ts +1 -0
  24. package/extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.ts +82 -11
  25. package/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.ts +153 -30
  26. package/extensions/agent-browser/lib/orchestration/browser-run/final-result.ts +53 -2
  27. package/extensions/agent-browser/lib/orchestration/browser-run/index.ts +1 -0
  28. package/extensions/agent-browser/lib/orchestration/browser-run/prepare.ts +751 -32
  29. package/extensions/agent-browser/lib/orchestration/browser-run/process-output.ts +38 -7
  30. package/extensions/agent-browser/lib/orchestration/browser-run/prompt-guards.ts +0 -46
  31. package/extensions/agent-browser/lib/orchestration/browser-run/session-state.ts +10 -1
  32. package/extensions/agent-browser/lib/orchestration/browser-run/types.ts +28 -1
  33. package/extensions/agent-browser/lib/orchestration/electron-host/index.ts +1 -6
  34. package/extensions/agent-browser/lib/orchestration/input-plan.ts +15 -3
  35. package/extensions/agent-browser/lib/orchestration/output-file.ts +86 -0
  36. package/extensions/agent-browser/lib/pi-tool-rendering.ts +231 -0
  37. package/extensions/agent-browser/lib/playbook.ts +26 -26
  38. package/extensions/agent-browser/lib/process.ts +1 -1
  39. package/extensions/agent-browser/lib/prompt-policy.ts +1 -18
  40. package/extensions/agent-browser/lib/results/artifact-manifest.ts +1 -4
  41. package/extensions/agent-browser/lib/results/artifact-state.ts +7 -3
  42. package/extensions/agent-browser/lib/results/contracts.ts +6 -2
  43. package/extensions/agent-browser/lib/results/envelope.ts +11 -2
  44. package/extensions/agent-browser/lib/results/network-routes.ts +7 -4
  45. package/extensions/agent-browser/lib/results/network.ts +7 -1
  46. package/extensions/agent-browser/lib/results/presentation/artifacts.ts +88 -20
  47. package/extensions/agent-browser/lib/results/presentation/batch.ts +84 -12
  48. package/extensions/agent-browser/lib/results/presentation/diagnostics.ts +81 -26
  49. package/extensions/agent-browser/lib/results/presentation/errors.ts +13 -0
  50. package/extensions/agent-browser/lib/results/presentation/registry.ts +60 -0
  51. package/extensions/agent-browser/lib/results/presentation.ts +10 -1
  52. package/extensions/agent-browser/lib/results/snapshot-high-value-controls.ts +16 -5
  53. package/extensions/agent-browser/lib/results/snapshot.ts +2 -0
  54. package/extensions/agent-browser/lib/runtime.ts +10 -1
  55. package/extensions/agent-browser/lib/session-page-state.ts +15 -6
  56. package/extensions/agent-browser/lib/web-search.ts +1 -1
  57. package/package.json +5 -5
  58. package/platform-smoke.config.mjs +15 -3
  59. package/scripts/doctor.mjs +70 -1
  60. package/scripts/platform-smoke/build-ubuntu-image.mjs +25 -0
  61. package/scripts/platform-smoke/crabbox-runner.mjs +62 -30
  62. package/scripts/platform-smoke/doctor.mjs +28 -11
  63. package/scripts/platform-smoke/linux-image/Dockerfile +3 -5
  64. package/scripts/platform-smoke/targets.mjs +60 -22
  65. package/scripts/platform-smoke.mjs +1 -0
  66. package/extensions/agent-browser/lib/orchestration/browser-run/browser-action-model.ts +0 -154
@@ -13,7 +13,7 @@ import {
13
13
  formatSessionArtifactRetentionSummary,
14
14
  mergeSessionArtifactManifest,
15
15
  } from "../artifact-manifest.js";
16
- import { isPendingRecordingArtifact } from "../artifact-state.js";
16
+ import { isPendingRecordingArtifact, isPendingRecordingCommand } from "../artifact-state.js";
17
17
  import { classifyAgentBrowserSuccessCategory } from "../categories.js";
18
18
  import type {
19
19
  ArtifactVerificationEntry,
@@ -132,9 +132,13 @@ function getArtifactKind(commandInfo: CommandInfo): FileArtifactKind | undefined
132
132
  return undefined;
133
133
  }
134
134
 
135
+ function isNonFileArtifactPathCandidate(path: string): boolean {
136
+ return /^(?:data|blob|https?|javascript|mailto):/i.test(path.trim());
137
+ }
138
+
135
139
  function extractPathStrings(data: unknown): string[] {
136
140
  if (typeof data === "string") {
137
- return data.trim().length > 0 ? [data] : [];
141
+ return data.trim().length > 0 && !isNonFileArtifactPathCandidate(data) ? [data] : [];
138
142
  }
139
143
  if (!isRecord(data)) {
140
144
  return [];
@@ -143,12 +147,12 @@ function extractPathStrings(data: unknown): string[] {
143
147
  const paths: string[] = [];
144
148
  for (const key of PATH_FIELD_CANDIDATES) {
145
149
  const value = data[key];
146
- if (typeof value === "string" && value.trim().length > 0) {
150
+ if (typeof value === "string" && value.trim().length > 0 && !isNonFileArtifactPathCandidate(value)) {
147
151
  paths.push(value);
148
152
  }
149
153
  if (Array.isArray(value)) {
150
154
  for (const item of value) {
151
- if (typeof item === "string" && item.trim().length > 0) {
155
+ if (typeof item === "string" && item.trim().length > 0 && !isNonFileArtifactPathCandidate(item)) {
152
156
  paths.push(item);
153
157
  }
154
158
  }
@@ -179,14 +183,17 @@ async function buildFileArtifactMetadata(options: {
179
183
  const absolutePath = options.artifactRequest?.absolutePath ?? resolve(options.cwd, options.path);
180
184
  const displayPath = options.artifactRequest?.path ?? options.path;
181
185
  const extension = extname(absolutePath || options.path).toLowerCase() || undefined;
186
+ const pendingRecording = isPendingRecordingCommand(options.commandInfo.command, options.commandInfo.subcommand, kind);
182
187
  let exists: boolean | undefined;
183
188
  let sizeBytes: number | undefined;
184
- try {
185
- const fileStats = await stat(absolutePath);
186
- exists = true;
187
- sizeBytes = fileStats.size;
188
- } catch {
189
- exists = false;
189
+ if (!pendingRecording) {
190
+ try {
191
+ const fileStats = await stat(absolutePath);
192
+ exists = true;
193
+ sizeBytes = fileStats.size;
194
+ } catch {
195
+ exists = false;
196
+ }
190
197
  }
191
198
 
192
199
  return {
@@ -199,16 +206,60 @@ async function buildFileArtifactMetadata(options: {
199
206
  kind,
200
207
  mediaType: extension ? ARTIFACT_EXTENSION_TO_MEDIA_TYPE[extension] : undefined,
201
208
  path: displayPath,
209
+ recordingState: pendingRecording ? "openRecording" : undefined,
202
210
  requestedPath: options.artifactRequest?.path,
203
211
  session: options.sessionName,
204
212
  sizeBytes,
205
- status: options.artifactRequest?.status ?? (exists === false ? "missing" : "saved"),
213
+ status: options.artifactRequest?.status ?? (pendingRecording ? "pending" : exists === false ? "missing" : "saved"),
206
214
  subcommand: options.commandInfo.subcommand,
207
215
  tempPath: options.artifactRequest?.tempPath,
216
+ willExistOnStop: pendingRecording ? true : undefined,
208
217
  };
209
218
  }
210
219
 
220
+ async function buildPreviousRestartRecordingArtifact(options: {
221
+ artifactManifest?: SessionArtifactManifest;
222
+ commandInfo: CommandInfo;
223
+ currentPaths: ReadonlySet<string>;
224
+ cwd: string;
225
+ sessionName?: string;
226
+ }): Promise<FileArtifactMetadata | undefined> {
227
+ if (options.commandInfo.command !== "record" || options.commandInfo.subcommand !== "restart") return undefined;
228
+ const previousRecording = options.artifactManifest?.entries.find((entry) => (
229
+ entry.command === "record" &&
230
+ (entry.subcommand === "start" || entry.subcommand === "restart") &&
231
+ entry.kind === "video" &&
232
+ (!options.sessionName || !entry.session || entry.session === options.sessionName) &&
233
+ !options.currentPaths.has(entry.path) &&
234
+ (!entry.absolutePath || !options.currentPaths.has(entry.absolutePath))
235
+ ));
236
+ if (!previousRecording) return undefined;
237
+ const absolutePath = previousRecording.absolutePath ?? resolve(options.cwd, previousRecording.path);
238
+ try {
239
+ const fileStats = await stat(absolutePath);
240
+ return {
241
+ absolutePath,
242
+ artifactType: "video",
243
+ command: "record",
244
+ cwd: previousRecording.cwd ?? options.cwd,
245
+ exists: true,
246
+ extension: previousRecording.extension ?? (extname(absolutePath).toLowerCase() || undefined),
247
+ kind: "video",
248
+ mediaType: previousRecording.mediaType,
249
+ path: previousRecording.path,
250
+ requestedPath: previousRecording.requestedPath,
251
+ session: previousRecording.session ?? options.sessionName,
252
+ sizeBytes: fileStats.size,
253
+ status: "saved",
254
+ subcommand: "restart-previous",
255
+ };
256
+ } catch {
257
+ return undefined;
258
+ }
259
+ }
260
+
211
261
  export async function extractFileArtifacts(options: {
262
+ artifactManifest?: SessionArtifactManifest;
212
263
  artifactRequest?: ArtifactRequestContext;
213
264
  commandInfo: CommandInfo;
214
265
  cwd: string;
@@ -216,8 +267,10 @@ export async function extractFileArtifacts(options: {
216
267
  sessionName?: string;
217
268
  }): Promise<FileArtifactMetadata[]> {
218
269
  const candidates = extractPathStrings(options.data);
219
- const artifacts = await Promise.all(candidates.map((path) => buildFileArtifactMetadata({ ...options, path })));
220
- return artifacts.filter((artifact): artifact is FileArtifactMetadata => artifact !== undefined);
270
+ const currentArtifacts = (await Promise.all(candidates.map((path) => buildFileArtifactMetadata({ ...options, path })))).filter((artifact): artifact is FileArtifactMetadata => artifact !== undefined);
271
+ const currentPaths = new Set(currentArtifacts.flatMap((artifact) => [artifact.path, artifact.absolutePath]));
272
+ const previousRestartRecordingArtifact = await buildPreviousRestartRecordingArtifact({ artifactManifest: options.artifactManifest, commandInfo: options.commandInfo, currentPaths, cwd: options.cwd, sessionName: options.sessionName });
273
+ return previousRestartRecordingArtifact ? [previousRestartRecordingArtifact, ...currentArtifacts] : currentArtifacts;
221
274
  }
222
275
 
223
276
  export function buildManifestEntriesForFileArtifacts(artifacts: FileArtifactMetadata[], nowMs = Date.now()): SessionArtifactManifestEntry[] {
@@ -241,7 +294,7 @@ export function buildManifestEntriesForFileArtifacts(artifacts: FileArtifactMeta
241
294
  }
242
295
 
243
296
  export function isManifestFileArtifact(artifact: FileArtifactMetadata): boolean {
244
- return !isPendingRecordingArtifact(artifact);
297
+ return artifact.kind === "video" && artifact.command === "record" ? true : !isPendingRecordingArtifact(artifact);
245
298
  }
246
299
 
247
300
  function getArtifactVerificationEntry(artifact: FileArtifactMetadata): ArtifactVerificationEntry {
@@ -253,12 +306,14 @@ function getArtifactVerificationEntry(artifact: FileArtifactMetadata): ArtifactV
253
306
  limitation: "Recording output is pending until record stop completes.",
254
307
  mediaType: artifact.mediaType,
255
308
  path: artifact.path,
309
+ recordingState: artifact.recordingState ?? "openRecording",
256
310
  requestedPath: artifact.requestedPath,
257
311
  retentionState: undefined,
258
312
  sizeBytes: artifact.sizeBytes,
259
313
  state: "pending",
260
- status: artifact.status,
314
+ status: artifact.status ?? "pending",
261
315
  storageScope: undefined,
316
+ willExistOnStop: artifact.willExistOnStop ?? true,
262
317
  };
263
318
  }
264
319
  const state = artifact.exists === true
@@ -369,12 +424,16 @@ export function classifyPresentationSuccessCategory(options: {
369
424
  function formatArtifactLabel(artifact: FileArtifactMetadata): string {
370
425
  switch (artifact.kind) {
371
426
  case "download":
372
- return artifact.command === "wait" && artifact.subcommand === "--download" ? "Download completed" : "Downloaded file";
427
+ if (artifact.exists !== true) {
428
+ return artifact.command === "wait" && artifact.subcommand === "--download" ? "Download event reported; file not verified" : "Download reported; file not verified";
429
+ }
430
+ return artifact.command === "wait" && artifact.subcommand === "--download" ? "Download saved and verified" : "Downloaded file verified";
373
431
  case "file":
374
432
  return artifact.command === "state" ? "State file" : "Saved file";
375
433
  case "har":
376
434
  return "Saved HAR";
377
435
  case "image":
436
+ if (artifact.exists !== true) return artifact.command === "diff" && artifact.subcommand === "screenshot" ? "Diff image reported; file not verified" : "Image reported; file not verified";
378
437
  return artifact.command === "diff" && artifact.subcommand === "screenshot" ? "Saved diff image" : "Saved image";
379
438
  case "pdf":
380
439
  return "Saved PDF";
@@ -383,7 +442,9 @@ function formatArtifactLabel(artifact: FileArtifactMetadata): string {
383
442
  case "trace":
384
443
  return "Saved trace";
385
444
  case "video":
386
- return isPendingRecordingArtifact(artifact) ? "Recording started; output will be written on stop" : "Saved recording";
445
+ if (artifact.command === "record" && artifact.subcommand === "restart-previous") return "Previous recording saved";
446
+ if (!isPendingRecordingArtifact(artifact)) return "Saved recording";
447
+ return artifact.subcommand === "restart" ? "Recording restarted; output will be written on stop" : "Recording started; output will be written on stop";
387
448
  }
388
449
  }
389
450
 
@@ -395,6 +456,11 @@ export function formatArtifactSummary(artifacts: FileArtifactMetadata[]): string
395
456
  const artifact = artifacts[0];
396
457
  return `${formatArtifactLabel(artifact)}: ${artifact.path}`;
397
458
  }
459
+ const restartArtifact = artifacts.find((artifact) => isPendingRecordingArtifact(artifact) && artifact.subcommand === "restart");
460
+ const previousRecordingArtifacts = artifacts.filter((artifact) => artifact.command === "record" && artifact.subcommand === "restart-previous");
461
+ if (restartArtifact && previousRecordingArtifacts.length > 0) {
462
+ return [...previousRecordingArtifacts, restartArtifact].map((artifact) => `${formatArtifactLabel(artifact)}: ${artifact.path}`).join("\n");
463
+ }
398
464
  return `Saved ${artifacts.length} artifacts: ${artifacts.map((artifact) => `${artifact.kind} ${artifact.path}`).join(", ")}`;
399
465
  }
400
466
 
@@ -406,8 +472,10 @@ export function formatArtifactMetadataLines(artifacts: FileArtifactMetadata[]):
406
472
  `Artifact type: ${artifact.kind}`,
407
473
  `Requested path: ${artifact.requestedPath ?? artifact.path}`,
408
474
  `Absolute path: ${artifact.absolutePath}`,
409
- `Exists: ${artifact.exists === true}`,
410
- `Status: ${artifact.status ?? (artifact.exists === false ? "missing" : "saved")}`,
475
+ "Exists: pending until record stop",
476
+ `Status: ${artifact.status ?? "pending"}`,
477
+ `Recording state: ${artifact.recordingState ?? "openRecording"}`,
478
+ `Will exist on stop: ${artifact.willExistOnStop !== false}`,
411
479
  artifact.session ? `Session: ${artifact.session}` : undefined,
412
480
  artifact.cwd ? `CWD: ${artifact.cwd}` : undefined,
413
481
  `Machine data: details.artifacts[${index}]`,
@@ -443,7 +511,7 @@ function extractSavedFilePath(data: Record<string, unknown>): string | undefined
443
511
 
444
512
  export function getSavedFileDetails(commandInfo: CommandInfo, data: Record<string, unknown>): SavedFilePresentationDetails | undefined {
445
513
  const path = extractSavedFilePath(data);
446
- if (!path) {
514
+ if (!path || isNonFileArtifactPathCandidate(path)) {
447
515
  return undefined;
448
516
  }
449
517
  const savedFileCommand = isDownloadWaitCommand(commandInfo)
@@ -117,6 +117,89 @@ function redactExactValues(value: unknown, sensitiveValues: string[]): unknown {
117
117
  return redactSensitiveValue(Object.fromEntries(Object.entries(value).map(([key, entryValue]) => [key, redactExactValues(entryValue, sensitiveValues)])));
118
118
  }
119
119
 
120
+ function getTypedTextLength(command: string[] | undefined): number | undefined {
121
+ return command?.[0] === "keyboard" && command[1] === "type" && typeof command[2] === "string"
122
+ ? Array.from(command[2]).length
123
+ : undefined;
124
+ }
125
+
126
+ function getWaitDelayMs(command: string[] | undefined): string | undefined {
127
+ return command?.[0] === "wait" && typeof command[1] === "string" && /^\d+$/.test(command[1]) ? command[1] : undefined;
128
+ }
129
+
130
+ function formatBatchStepDetails(details: BatchStepPresentationDetails, presentation: ToolPresentation): string {
131
+ const inlineImageCount = getPresentationImages(presentation).length;
132
+ const status = details.success ? "succeeded" : "failed";
133
+ const lines = [`Step ${details.index + 1} — ${details.commandText} (${status})`];
134
+ if (details.text.length > 0) lines.push(details.text);
135
+ if (inlineImageCount > 0) lines.push(`(${inlineImageCount} inline image attachment${inlineImageCount === 1 ? "" : "s"} below)`);
136
+ return lines.join("\n");
137
+ }
138
+
139
+ function formatTypedSequenceSummary(steps: Array<{ details: BatchStepPresentationDetails; presentation: ToolPresentation }>, startIndex: number): { nextIndex: number; text: string } | undefined {
140
+ let index = startIndex;
141
+ const firstDetails = steps[index]?.details;
142
+ if (!firstDetails?.success) return undefined;
143
+ let target: string | undefined;
144
+ if (firstDetails.command?.[0] === "focus" && typeof firstDetails.command[1] === "string") {
145
+ target = firstDetails.command[1];
146
+ index += 1;
147
+ }
148
+ let typedCharCount = 0;
149
+ let typedStepCount = 0;
150
+ let delayMs: string | undefined;
151
+ while (index < steps.length) {
152
+ const details = steps[index]?.details;
153
+ if (!details?.success) break;
154
+ const typedLength = getTypedTextLength(details.command);
155
+ if (typedLength === undefined) break;
156
+ typedCharCount += typedLength;
157
+ typedStepCount += 1;
158
+ index += 1;
159
+ const nextDelay = getWaitDelayMs(steps[index]?.details.command);
160
+ const followingTypedLength = getTypedTextLength(steps[index + 1]?.details.command);
161
+ if (nextDelay !== undefined && followingTypedLength !== undefined && steps[index]?.details.success && steps[index + 1]?.details.success) {
162
+ if (delayMs !== undefined && delayMs !== nextDelay) return undefined;
163
+ delayMs = nextDelay;
164
+ index += 1;
165
+ }
166
+ }
167
+ if (typedStepCount < 2) return undefined;
168
+ let pressedKey: string | undefined;
169
+ const pressDetails = steps[index]?.details;
170
+ if (pressDetails?.success && pressDetails.command?.[0] === "press" && typeof pressDetails.command[1] === "string") {
171
+ pressedKey = pressDetails.command[1];
172
+ index += 1;
173
+ }
174
+ const firstStep = firstDetails.index + 1;
175
+ const lastStep = steps[index - 1]?.details.index + 1;
176
+ const stepRange = lastStep && lastStep > firstStep ? `${firstStep}-${lastStep}` : String(firstStep);
177
+ const commandLabel = target ? `type ${target}` : "keyboard type";
178
+ const lines = [
179
+ `Step ${stepRange} — ${commandLabel} (succeeded)`,
180
+ `Typed ${typedCharCount} char${typedCharCount === 1 ? "" : "s"}${delayMs ? ` with delayMs=${delayMs}` : ""}.`,
181
+ ];
182
+ if (pressedKey) lines.push(`Pressed ${pressedKey}.`);
183
+ return { nextIndex: index, text: lines.join("\n") };
184
+ }
185
+
186
+ function formatBatchStepsText(steps: Array<{ details: BatchStepPresentationDetails; presentation: ToolPresentation }>): string {
187
+ if (steps.length === 0) return "(no batch steps)";
188
+ const lines: string[] = [];
189
+ for (let index = 0; index < steps.length;) {
190
+ const typedSequence = formatTypedSequenceSummary(steps, index);
191
+ if (typedSequence) {
192
+ lines.push(typedSequence.text);
193
+ index = typedSequence.nextIndex;
194
+ continue;
195
+ }
196
+ const step = steps[index];
197
+ if (step) lines.push(formatBatchStepDetails(step.details, step.presentation));
198
+ index += 1;
199
+ }
200
+ return lines.join("\n\n");
201
+ }
202
+
120
203
  async function buildBatchStepPresentation(options: {
121
204
  artifactManifest?: SessionArtifactManifest;
122
205
  artifactRequest?: ArtifactRequestContext;
@@ -307,18 +390,7 @@ export async function buildBatchPresentation(options: {
307
390
  ? { command: details.command, result: details.data, success: true }
308
391
  : { command: details.command, error: details.text, success: false }
309
392
  ));
310
- const stepText = steps.length === 0
311
- ? "(no batch steps)"
312
- : steps
313
- .map(({ details, presentation }) => {
314
- const inlineImageCount = getPresentationImages(presentation).length;
315
- const status = details.success ? "succeeded" : "failed";
316
- const lines = [`Step ${details.index + 1} — ${details.commandText} (${status})`];
317
- if (details.text.length > 0) lines.push(details.text);
318
- if (inlineImageCount > 0) lines.push(`(${inlineImageCount} inline image attachment${inlineImageCount === 1 ? "" : "s"} below)`);
319
- return lines.join("\n");
320
- })
321
- .join("\n\n");
393
+ const stepText = formatBatchStepsText(steps);
322
394
  const batchSummary = batchFailure === undefined
323
395
  ? summary
324
396
  : `Batch failed: ${batchFailure.successCount}/${batchFailure.totalCount} succeeded`;
@@ -7,7 +7,7 @@
7
7
  import { isRecord } from "../../parsing.js";
8
8
  import { redactSensitiveText, redactSensitiveValue, type CommandInfo } from "../../runtime.js";
9
9
  import type { AgentBrowserNextAction, NetworkRouteDiagnostic } from "../contracts.js";
10
- import { classifyNetworkRequestFailure, isApiLikeNetworkRequest, summarizeNetworkFailures } from "../network.js";
10
+ import { classifyNetworkRequestFailure, isApiLikeNetworkRequest, isNetworkArtifactNoiseRequest, summarizeNetworkFailures } from "../network.js";
11
11
  import { withOptionalSessionArgs } from "../next-actions.js";
12
12
  import { stringifyUnknown, truncateText } from "../text.js";
13
13
  import {
@@ -86,15 +86,17 @@ export function getTabSummary(data: Record<string, unknown>): string | undefined
86
86
  const marker = tab.active === true ? "*" : "-";
87
87
  const title = typeof tab.title === "string" ? tab.title : "(untitled)";
88
88
  const url = typeof tab.url === "string" ? tab.url : "(no url)";
89
+ const label = typeof tab.label === "string" && tab.label.trim().length > 0 ? tab.label.trim() : undefined;
89
90
  const tabSelector =
90
91
  typeof tab.tabId === "string" && tab.tabId.trim().length > 0
91
92
  ? tab.tabId.trim()
92
- : typeof tab.label === "string" && tab.label.trim().length > 0
93
- ? tab.label.trim()
93
+ : label
94
+ ? label
94
95
  : typeof tab.index === "number"
95
96
  ? String(tab.index)
96
97
  : String(index);
97
- return `${marker} [${tabSelector}] ${title} ${url}`;
98
+ const labelText = label && label !== tabSelector ? ` label=${redactModelFacingText(label)}` : "";
99
+ return `${marker} [${tabSelector}]${labelText} ${title} — ${url}`;
98
100
  });
99
101
  return lines.join("\n");
100
102
  }
@@ -280,11 +282,19 @@ function formatSessionText(data: Record<string, unknown>): string | undefined {
280
282
  .map((item, index) => {
281
283
  if (!isRecord(item)) return `${index + 1}. ${stringifyModelFacing(item)}`;
282
284
  const name = redactModelFacingText(getStringField(item, "name") ?? getStringField(item, "session") ?? getStringField(item, "id") ?? `(session ${index + 1})`);
283
- const active = item.active === true ? " *active*" : "";
284
- const details = [getStringField(item, "url"), getStringField(item, "title")]
285
- .flatMap((detail) => (detail ? [redactModelFacingTextIfSensitive(detail)] : []))
286
- .join(" ");
287
- return details ? `${index + 1}. ${name}${active} ${details}` : `${index + 1}. ${name}${active}`;
285
+ const active = item.active === true;
286
+ const url = getStringField(item, "url");
287
+ const title = getStringField(item, "title");
288
+ const label = getStringField(item, "label");
289
+ const tabCount = typeof item.tabCount === "number" ? `${item.tabCount} tab${item.tabCount === 1 ? "" : "s"}` : undefined;
290
+ const metadata = [
291
+ `active=${active ? "true" : "false"}`,
292
+ label ? `label=${redactModelFacingText(label)}` : undefined,
293
+ title ? `title=${redactModelFacingTextIfSensitive(title)}` : undefined,
294
+ url ? `url=${redactModelFacingTextIfSensitive(url)}` : undefined,
295
+ tabCount,
296
+ ].filter(Boolean).join("; ");
297
+ return `${index + 1}. name=${name}${active ? " *active*" : ""}; ${metadata}`;
288
298
  })
289
299
  .join("\n");
290
300
  }
@@ -355,18 +365,24 @@ function formatNetworkRequestLine(item: Record<string, unknown>, index: number):
355
365
  function formatNetworkRequestsText(data: Record<string, unknown>): string | undefined {
356
366
  const requests = getArrayField(data, "requests");
357
367
  if (!requests) return undefined;
358
- if (requests.length === 0) return "No network requests captured.";
359
- const networkFailureSummary = summarizeNetworkFailures(requests);
360
- const shown = networkFailureSummary.totalCount > 0
361
- ? [`Network failure summary: ${networkFailureSummary.actionableCount} actionable, ${networkFailureSummary.benignCount} benign low-impact (${networkFailureSummary.totalCount} total).`]
362
- : [];
368
+ if (requests.length === 0) return "No network requests captured. Scope: upstream session aggregate unless the upstream command output says it was cleared or filtered for this page.";
369
+ const shown = ["Scope: upstream session aggregate unless the upstream command output says it was cleared or filtered for this page; do not attribute old requests to the current page without URL/time evidence."];
363
370
  const indexedRequests = requests.map((item, index) => ({ index, item }));
371
+ const artifactNoiseRequests = indexedRequests.filter((indexed) => isRecord(indexed.item) && isNetworkArtifactNoiseRequest(indexed.item));
372
+ const previewRequests = indexedRequests.filter((indexed) => !(isRecord(indexed.item) && isNetworkArtifactNoiseRequest(indexed.item)));
373
+ const networkFailureSummary = summarizeNetworkFailures(previewRequests.map((indexed) => indexed.item));
374
+ if (networkFailureSummary.totalCount > 0) {
375
+ shown.push(`Network failure summary: ${networkFailureSummary.actionableCount} actionable, ${networkFailureSummary.benignCount} benign low-impact (${networkFailureSummary.totalCount} total).`);
376
+ }
364
377
  const failedRequests: typeof indexedRequests = [];
365
378
  const normalRequests: typeof indexedRequests = [];
366
- for (const indexed of indexedRequests) {
379
+ for (const indexed of previewRequests) {
367
380
  if (isRecord(indexed.item) && classifyNetworkRequestFailure(indexed.item)) failedRequests.push(indexed);
368
381
  else normalRequests.push(indexed);
369
382
  }
383
+ if (artifactNoiseRequests.length > 0) {
384
+ shown.push(`Diagnostic noise hidden from preview: ${artifactNoiseRequests.length} data:image/artifact request row${artifactNoiseRequests.length === 1 ? "" : "s"}; raw rows remain in details.data.requests.`);
385
+ }
370
386
  failedRequests.sort((left, right) => {
371
387
  const leftClassification = isRecord(left.item) ? classifyNetworkRequestFailure(left.item) : undefined;
372
388
  const rightClassification = isRecord(right.item) ? classifyNetworkRequestFailure(right.item) : undefined;
@@ -379,8 +395,9 @@ function formatNetworkRequestsText(data: Record<string, unknown>): string | unde
379
395
  if (!isRecord(item)) return [`${index + 1}. ${stringifyModelFacing(item)}`];
380
396
  return formatNetworkRequestLine(item, index);
381
397
  }));
382
- if (requests.length > DIAGNOSTIC_REQUEST_PREVIEW_LIMIT) {
383
- shown.push(`... (${requests.length - DIAGNOSTIC_REQUEST_PREVIEW_LIMIT} additional requests omitted from preview; failed requests are shown first when present)`);
398
+ const omittedPreviewCount = Math.max(0, prioritizedRequests.length - DIAGNOSTIC_REQUEST_PREVIEW_LIMIT);
399
+ if (omittedPreviewCount > 0) {
400
+ shown.push(`... (${omittedPreviewCount} additional non-noise requests omitted from preview; failed requests are shown first when present)`);
384
401
  }
385
402
  return shown.join("\n");
386
403
  }
@@ -441,6 +458,7 @@ function getNetworkRequestPathFilter(item: Record<string, unknown>): string | un
441
458
  }
442
459
 
443
460
  function getNetworkRequestActionCandidate(item: Record<string, unknown>): NetworkRequestActionCandidate | undefined {
461
+ if (isNetworkArtifactNoiseRequest(item)) return undefined;
444
462
  const requestId = getNetworkRequestId(item);
445
463
  if (!requestId) return undefined;
446
464
  const classification = classifyNetworkRequestFailure(item);
@@ -481,7 +499,7 @@ export function formatNetworkRouteDiagnosticsText(diagnostics: NetworkRouteDiagn
481
499
  const target = diagnostic.requestId ? `[${diagnostic.requestId}] ${diagnostic.requestUrl ?? "request"}` : diagnostic.requestUrl ?? "request";
482
500
  lines.push(`- ${diagnostic.reason}: ${target} matched route ${diagnostic.routePattern} (${diagnostic.mode}).`);
483
501
  }
484
- lines.push("If this route is intended as a mock, verify the page origin/CORS headers and inspect the request before assuming the mock fulfilled normally.");
502
+ lines.push("If this route is intended as a mock, inspect the request/headers and treat failed, pending, or CORS-looking rows as unfulfilled until a mocked response is observed.");
485
503
  return lines.join("\n");
486
504
  }
487
505
 
@@ -491,10 +509,10 @@ export function buildNetworkRouteDiagnosticsNextActions(diagnostics: NetworkRout
491
509
  const actions: AgentBrowserNextAction[] = [];
492
510
  if (diagnostic.requestId) {
493
511
  actions.push({
494
- id: "inspect-pending-routed-network-request",
512
+ id: "inspect-routed-network-request",
495
513
  params: { args: withOptionalSessionArgs(sessionName, ["network", "request", diagnostic.requestId]) },
496
514
  reason: `Inspect the routed request ${diagnostic.requestId} before assuming the route mock fulfilled normally.`,
497
- safety: "Read-only request diagnostic; look for pending state, CORS/preflight errors, response status, and headers.",
515
+ safety: "Read-only request diagnostic; look for failed status, pending state, CORS/preflight errors, response body, and headers.",
498
516
  tool: "agent_browser",
499
517
  });
500
518
  }
@@ -547,6 +565,13 @@ export function buildNetworkRequestsNextActions(data: unknown, sessionName: stri
547
565
  tool: "agent_browser",
548
566
  });
549
567
  }
568
+ actions.push({
569
+ id: "clear-network-requests-before-repro",
570
+ params: { args: withOptionalSessionArgs(sessionName, ["network", "requests", "--clear"]) },
571
+ reason: "Clear the aggregate request buffer before reproducing the current-page network behavior.",
572
+ safety: "This mutates only diagnostic buffers for the session; capture or inspect needed old rows first.",
573
+ tool: "agent_browser",
574
+ });
550
575
  actions.push({
551
576
  id: "start-network-har-capture",
552
577
  params: { args: withOptionalSessionArgs(sessionName, ["network", "har", "start"]) },
@@ -580,15 +605,17 @@ export function buildStreamNextActions(commandInfo: CommandInfo, data: unknown,
580
605
  function formatConsoleText(data: Record<string, unknown>): string | undefined {
581
606
  const messages = getArrayField(data, "messages");
582
607
  if (!messages) return undefined;
583
- if (messages.length === 0) return "No console messages.";
584
- const shown = messages.slice(0, DIAGNOSTIC_LOG_PREVIEW_LIMIT).map((item, index) => {
608
+ if (messages.length === 0) return "No console messages. Scope: upstream session aggregate unless the upstream command output says it was cleared or filtered for this page.";
609
+ const shown = ["Scope: upstream session aggregate unless the upstream command output says it was cleared or filtered for this page; do not attribute old messages to the current page without URL/time evidence."];
610
+ shown.push(...messages.slice(0, DIAGNOSTIC_LOG_PREVIEW_LIMIT).map((item, index) => {
585
611
  if (!isRecord(item)) return `${index + 1}. ${stringifyModelFacing(item)}`;
586
612
  const type = redactModelFacingText(getStringField(item, "type") ?? "message");
587
613
  const text = getStringField(item, "text") ?? stringifyModelFacing(item);
588
614
  return `${index + 1}. [${type}] ${firstLine(redactModelFacingText(text).replace(/\s+/g, " ").trim(), 220)}`;
589
- });
590
- if (messages.length > shown.length) {
591
- shown.push(`... (${messages.length - shown.length} additional console messages omitted from preview)`);
615
+ }));
616
+ const previewedMessageCount = Math.min(messages.length, DIAGNOSTIC_LOG_PREVIEW_LIMIT);
617
+ if (messages.length > previewedMessageCount) {
618
+ shown.push(`... (${messages.length - previewedMessageCount} additional console messages omitted from preview)`);
592
619
  }
593
620
  return shown.join("\n");
594
621
  }
@@ -640,10 +667,38 @@ function formatDoctorText(data: Record<string, unknown>): string | undefined {
640
667
  const lines: string[] = [];
641
668
  const status = getStringField(data, "status") ?? getStringField(data, "result");
642
669
  if (status) lines.push(`Status: ${redactModelFacingText(status)}`);
643
- for (const key of ["checks", "issues", "problems"] as const) {
670
+ const summary = isRecord(data.summary) ? data.summary : undefined;
671
+ if (summary) {
672
+ const parts = ["pass", "warn", "fail"].flatMap((key) => typeof summary[key] === "number" ? [`${key}:${summary[key]}`] : []);
673
+ if (parts.length > 0) lines.push(`Summary: ${parts.join(", ")}`);
674
+ }
675
+ const checks = getArrayField(data, "checks");
676
+ if (checks) {
677
+ lines.push(`Checks: ${checks.length}`);
678
+ for (const [index, item] of checks.slice(0, 30).entries()) {
679
+ if (!isRecord(item)) {
680
+ lines.push(`${index + 1}. ${stringifyModelFacing(item)}`);
681
+ continue;
682
+ }
683
+ const checkStatus = getStringField(item, "status") ?? "info";
684
+ const id = getStringField(item, "id");
685
+ const category = getStringField(item, "category");
686
+ const message = getStringField(item, "message") ?? getStringField(item, "name") ?? getStringField(item, "title") ?? getStringField(item, "check") ?? stringifyModelFacing(item);
687
+ const label = [category, id].filter(Boolean).join("/");
688
+ lines.push(`${index + 1}. [${redactModelFacingText(checkStatus)}]${label ? ` ${redactModelFacingText(label)}:` : ""} ${firstLine(redactModelFacingText(message), 220)}`);
689
+ const fix = getStringField(item, "fix");
690
+ if (fix) lines.push(` fix: ${redactModelFacingText(fix)}`);
691
+ }
692
+ if (checks.length > 30) lines.push(`... (${checks.length - 30} additional checks omitted from preview)`);
693
+ }
694
+ for (const key of ["issues", "problems"] as const) {
644
695
  const items = getArrayField(data, key);
645
696
  if (items) lines.push(`${key}: ${items.length}`);
646
697
  }
698
+ if (lines.length === 0) {
699
+ const keys = Object.keys(data).filter((key) => key !== "success");
700
+ if (keys.length > 0) return `Doctor diagnostics returned unrecognized fields: ${keys.map(redactModelFacingText).join(", ")}. See details.data for structured diagnostics.`;
701
+ }
647
702
  return lines.length > 0 ? lines.join("\n") : undefined;
648
703
  }
649
704
 
@@ -24,6 +24,11 @@ const CLIPBOARD_PERMISSION_ERROR_HINT = [
24
24
  "If true clipboard access is required, retry in a browser/profile/session with explicit clipboard permission on a normal http(s) page.",
25
25
  ].join(" ");
26
26
 
27
+ const KEYBOARD_PRESS_ERROR_HINT = [
28
+ "Agent-browser keyboard hint: upstream keyboard commands are `keyboard type <text>` and `keyboard inserttext <text>`; `keyboard press` is not a supported subcommand in the targeted upstream version.",
29
+ 'For Enter in text fields, use `keyboard type "\\n"` after focusing the intended control, then verify with a fresh snapshot, URL, or page-state check.',
30
+ ].join(" ");
31
+
27
32
  function isRecord(value: unknown): value is Record<string, unknown> {
28
33
  return typeof value === "object" && value !== null;
29
34
  }
@@ -61,6 +66,12 @@ function getClipboardPermissionHint(commandInfo: CommandInfo, errorText: string)
61
66
  return CLIPBOARD_PERMISSION_ERROR_HINT;
62
67
  }
63
68
 
69
+ function getKeyboardPressHint(commandInfo: CommandInfo, errorText: string): string | undefined {
70
+ if (commandInfo.command !== "keyboard" || commandInfo.subcommand !== "press") return undefined;
71
+ if (!/\bunknown\s+subcommand\b|\bvalid options?\b/i.test(errorText)) return undefined;
72
+ return KEYBOARD_PRESS_ERROR_HINT;
73
+ }
74
+
64
75
  export function redactClipboardPermissionEcho(commandInfo: CommandInfo, errorText: string): string {
65
76
  if (commandInfo.command !== "clipboard") return errorText;
66
77
  return errorText
@@ -181,12 +192,14 @@ export function buildErrorPresentation(options: {
181
192
  const browserProfileConfigRecovery = buildBrowserProfileConfigRecovery({ args, commandInfo, errorText: safeErrorText });
182
193
  const localhostNavigationHint = getLocalhostNavigationHint(commandInfo, safeErrorText);
183
194
  const clipboardPermissionHint = getClipboardPermissionHint(commandInfo, safeErrorText);
195
+ const keyboardPressHint = getKeyboardPressHint(commandInfo, safeErrorText);
184
196
  const hintedErrorParts = [
185
197
  selectorHintedErrorText,
186
198
  unknownCommandSuggestionText && !selectorHintedErrorText.includes("Agent-browser hint:") ? unknownCommandSuggestionText : undefined,
187
199
  browserProfileConfigRecovery?.hint,
188
200
  localhostNavigationHint,
189
201
  clipboardPermissionHint,
202
+ keyboardPressHint,
190
203
  ].filter((part): part is string => Boolean(part));
191
204
  const hintedErrorText = hintedErrorParts.join("\n\n");
192
205
  const categoryDetails = buildAgentBrowserResultCategoryDetails({
@@ -33,6 +33,58 @@ function formatConfirmationRequiredSummary(confirmation: ConfirmationRequiredPre
33
33
  return `Confirmation required: ${confirmation.id}`;
34
34
  }
35
35
 
36
+ const VITALS_METRICS = ["lcp", "fcp", "ttfb", "inp", "cls"] as const;
37
+
38
+ function coerceVitalsMetricValue(value: unknown): number | undefined {
39
+ if (typeof value === "number" && Number.isFinite(value)) return value;
40
+ if (isRecord(value)) {
41
+ for (const nestedKey of ["value", "duration", "startTime", "score"] as const) {
42
+ const nestedValue = value[nestedKey];
43
+ if (typeof nestedValue === "number" && Number.isFinite(nestedValue)) return nestedValue;
44
+ }
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ function getVitalsMetric(data: Record<string, unknown>, key: string): number | undefined {
50
+ const metrics = isRecord(data.metrics) ? data.metrics : undefined;
51
+ return coerceVitalsMetricValue(data[key] ?? data[key.toUpperCase()] ?? metrics?.[key] ?? metrics?.[key.toUpperCase()]);
52
+ }
53
+
54
+ function formatVitalsMetric(key: string, value: number): string {
55
+ return key === "cls" ? `${key.toUpperCase()}: ${value}` : `${key.toUpperCase()}: ${Math.round(value)}ms`;
56
+ }
57
+
58
+ function getVitalsMetrics(data: Record<string, unknown>): string[] {
59
+ return VITALS_METRICS.flatMap((key) => {
60
+ const value = getVitalsMetric(data, key);
61
+ return value === undefined ? [] : [formatVitalsMetric(key, value)];
62
+ });
63
+ }
64
+
65
+ function getVitalsUnavailableReason(data: Record<string, unknown>): string {
66
+ for (const key of ["reason", "message", "error", "status"] as const) {
67
+ const value = data[key];
68
+ if (typeof value === "string" && value.trim().length > 0) return redactModelFacingText(value.trim());
69
+ }
70
+ return "No Core Web Vitals metric fields were present in the upstream result.";
71
+ }
72
+
73
+ function formatVitalsText(data: Record<string, unknown>): string {
74
+ const url = typeof data.url === "string" && data.url.trim().length > 0 ? redactModelFacingText(data.url.trim()) : undefined;
75
+ const metrics = getVitalsMetrics(data);
76
+ const lines = [url ? `Vitals for ${url}` : "Vitals result"];
77
+ if (metrics.length > 0) lines.push(...metrics.map((metric) => `- ${metric}`));
78
+ else lines.push(`Metrics unavailable: ${getVitalsUnavailableReason(data)}`);
79
+ return lines.join("\n");
80
+ }
81
+
82
+ function formatVitalsSummary(data: Record<string, unknown>): string | undefined {
83
+ const metrics = getVitalsMetrics(data);
84
+ if (metrics.length > 0) return `Vitals: ${metrics.join(", ")}`;
85
+ return "Vitals: metrics unavailable";
86
+ }
87
+
36
88
  function formatConfirmationRequiredText(confirmation: ConfirmationRequiredPresentation): string {
37
89
  const lines = [
38
90
  "Confirmation required.",
@@ -87,6 +139,14 @@ const COMMAND_PRESENTERS: Record<string, CommandPresenter> = {
87
139
  summary: (_commandInfo, data) => isRecord(data) && Array.isArray(data.tabs) ? `Tabs: ${data.tabs.length}` : undefined,
88
140
  text: (_commandInfo, data) => isRecord(data) ? getTabSummary(data) : undefined,
89
141
  },
142
+ vitals: {
143
+ summary: (_commandInfo, data) => isRecord(data) ? formatVitalsSummary(data) : undefined,
144
+ text: (_commandInfo, data) => isRecord(data) ? formatVitalsText(data) : undefined,
145
+ },
146
+ "web-vitals": {
147
+ summary: (_commandInfo, data) => isRecord(data) ? formatVitalsSummary(data) : undefined,
148
+ text: (_commandInfo, data) => isRecord(data) ? formatVitalsText(data) : undefined,
149
+ },
90
150
  };
91
151
 
92
152
  function formatBatchSummary(data: unknown): string | undefined {