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.
- package/CHANGELOG.md +37 -0
- package/README.md +26 -16
- package/docs/ARCHITECTURE.md +12 -10
- package/docs/COMMAND_REFERENCE.md +49 -27
- package/docs/ELECTRON.md +1 -1
- package/docs/RELEASE.md +16 -9
- package/docs/REQUIREMENTS.md +6 -3
- package/docs/SUPPORT_MATRIX.md +18 -14
- package/docs/TOOL_CONTRACT.md +87 -46
- package/docs/platform-smoke.md +15 -9
- package/extensions/agent-browser/index.ts +29 -445
- package/extensions/agent-browser/lib/bash-guard.ts +205 -0
- package/extensions/agent-browser/lib/electron/cdp.ts +69 -0
- package/extensions/agent-browser/lib/electron/cleanup.ts +5 -58
- package/extensions/agent-browser/lib/electron/discovery.ts +2 -9
- package/extensions/agent-browser/lib/electron/launch.ts +11 -65
- package/extensions/agent-browser/lib/electron/text.ts +13 -0
- package/extensions/agent-browser/lib/fs-utils.ts +18 -0
- package/extensions/agent-browser/lib/input-modes/job.ts +207 -21
- package/extensions/agent-browser/lib/input-modes/params.ts +17 -7
- package/extensions/agent-browser/lib/input-modes/semantic-action.ts +22 -2
- package/extensions/agent-browser/lib/input-modes/types.ts +5 -1
- package/extensions/agent-browser/lib/input-modes.ts +1 -0
- package/extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.ts +82 -11
- package/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.ts +153 -30
- package/extensions/agent-browser/lib/orchestration/browser-run/final-result.ts +53 -2
- package/extensions/agent-browser/lib/orchestration/browser-run/index.ts +1 -0
- package/extensions/agent-browser/lib/orchestration/browser-run/prepare.ts +751 -32
- package/extensions/agent-browser/lib/orchestration/browser-run/process-output.ts +38 -7
- package/extensions/agent-browser/lib/orchestration/browser-run/prompt-guards.ts +0 -46
- package/extensions/agent-browser/lib/orchestration/browser-run/session-state.ts +10 -1
- package/extensions/agent-browser/lib/orchestration/browser-run/types.ts +28 -1
- package/extensions/agent-browser/lib/orchestration/electron-host/index.ts +1 -6
- package/extensions/agent-browser/lib/orchestration/input-plan.ts +15 -3
- package/extensions/agent-browser/lib/orchestration/output-file.ts +86 -0
- package/extensions/agent-browser/lib/pi-tool-rendering.ts +231 -0
- package/extensions/agent-browser/lib/playbook.ts +26 -26
- package/extensions/agent-browser/lib/process.ts +1 -1
- package/extensions/agent-browser/lib/prompt-policy.ts +1 -18
- package/extensions/agent-browser/lib/results/artifact-manifest.ts +1 -4
- package/extensions/agent-browser/lib/results/artifact-state.ts +7 -3
- package/extensions/agent-browser/lib/results/contracts.ts +6 -2
- package/extensions/agent-browser/lib/results/envelope.ts +11 -2
- package/extensions/agent-browser/lib/results/network-routes.ts +7 -4
- package/extensions/agent-browser/lib/results/network.ts +7 -1
- package/extensions/agent-browser/lib/results/presentation/artifacts.ts +88 -20
- package/extensions/agent-browser/lib/results/presentation/batch.ts +84 -12
- package/extensions/agent-browser/lib/results/presentation/diagnostics.ts +81 -26
- package/extensions/agent-browser/lib/results/presentation/errors.ts +13 -0
- package/extensions/agent-browser/lib/results/presentation/registry.ts +60 -0
- package/extensions/agent-browser/lib/results/presentation.ts +10 -1
- package/extensions/agent-browser/lib/results/snapshot-high-value-controls.ts +16 -5
- package/extensions/agent-browser/lib/results/snapshot.ts +2 -0
- package/extensions/agent-browser/lib/runtime.ts +10 -1
- package/extensions/agent-browser/lib/session-page-state.ts +15 -6
- package/extensions/agent-browser/lib/web-search.ts +1 -1
- package/package.json +5 -5
- package/platform-smoke.config.mjs +15 -3
- package/scripts/doctor.mjs +70 -1
- package/scripts/platform-smoke/build-ubuntu-image.mjs +25 -0
- package/scripts/platform-smoke/crabbox-runner.mjs +62 -30
- package/scripts/platform-smoke/doctor.mjs +28 -11
- package/scripts/platform-smoke/linux-image/Dockerfile +3 -5
- package/scripts/platform-smoke/targets.mjs +60 -22
- package/scripts/platform-smoke.mjs +1 -0
- 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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
410
|
-
`Status: ${artifact.status ??
|
|
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
|
|
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
|
-
:
|
|
93
|
-
?
|
|
93
|
+
: label
|
|
94
|
+
? label
|
|
94
95
|
: typeof tab.index === "number"
|
|
95
96
|
? String(tab.index)
|
|
96
97
|
: String(index);
|
|
97
|
-
|
|
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
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
383
|
-
|
|
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,
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
|
|
591
|
-
|
|
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
|
-
|
|
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 {
|