ptywright 0.1.1 → 0.3.0

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 (67) hide show
  1. package/README.md +318 -1
  2. package/dist/agent.mjs +2 -0
  3. package/dist/bin/ptywright.mjs +6 -0
  4. package/dist/cli-CfvlbRoZ.mjs +3585 -0
  5. package/dist/cli.mjs +2 -0
  6. package/{src/index.ts → dist/index.mjs} +7 -9
  7. package/dist/mcp.mjs +2 -0
  8. package/dist/pty-cassette.mjs +24 -0
  9. package/dist/pty_like-Cpkh_O9B.mjs +404 -0
  10. package/dist/runner-zApMYWZx.mjs +3257 -0
  11. package/dist/runner-zi0nItvB.mjs +1874 -0
  12. package/dist/script.mjs +2 -0
  13. package/dist/server-BC3yo-dq.mjs +3068 -0
  14. package/dist/session.mjs +2 -0
  15. package/dist/terminal_session-DopC7Xg6.mjs +893 -0
  16. package/package.json +28 -21
  17. package/schemas/ptywright-agent-cassette.schema.json +57 -0
  18. package/schemas/ptywright-agent-check.schema.json +122 -0
  19. package/schemas/ptywright-agent-manifest.schema.json +107 -0
  20. package/schemas/ptywright-agent-promote.schema.json +146 -0
  21. package/schemas/ptywright-agent-replay-summary.schema.json +140 -0
  22. package/schemas/ptywright-agent-run.schema.json +126 -0
  23. package/schemas/ptywright-agent.schema.json +166 -0
  24. package/schemas/ptywright-pty-cassette.schema.json +86 -0
  25. package/schemas/ptywright-script-manifest.schema.json +75 -0
  26. package/schemas/ptywright-script-run-summary.schema.json +114 -0
  27. package/schemas/ptywright-script.schema.json +55 -3
  28. package/bin/ptywright +0 -4
  29. package/src/cli.ts +0 -414
  30. package/src/generator/doc_parser.ts +0 -341
  31. package/src/generator/generate.ts +0 -161
  32. package/src/generator/index.ts +0 -10
  33. package/src/generator/script_generator.ts +0 -209
  34. package/src/generator/step_extractor.ts +0 -397
  35. package/src/mcp/http_server.ts +0 -174
  36. package/src/mcp/script_recording.ts +0 -238
  37. package/src/mcp/server.ts +0 -1348
  38. package/src/pty/bun_pty_adapter.ts +0 -34
  39. package/src/pty/bun_terminal_adapter.ts +0 -149
  40. package/src/pty/pty_adapter.ts +0 -31
  41. package/src/script/dsl.ts +0 -188
  42. package/src/script/module.ts +0 -43
  43. package/src/script/path.ts +0 -151
  44. package/src/script/run.ts +0 -108
  45. package/src/script/run_all.ts +0 -229
  46. package/src/script/runner.ts +0 -983
  47. package/src/script/schema.ts +0 -237
  48. package/src/script/steps/assert_snapshot_equals.ts +0 -21
  49. package/src/script/steps/index.ts +0 -2
  50. package/src/script/suite_report.ts +0 -626
  51. package/src/session/session_manager.ts +0 -145
  52. package/src/session/terminal_session.ts +0 -473
  53. package/src/terminal/ansi.ts +0 -142
  54. package/src/terminal/keys.ts +0 -180
  55. package/src/terminal/mask.ts +0 -70
  56. package/src/terminal/mouse.ts +0 -75
  57. package/src/terminal/snapshot.ts +0 -196
  58. package/src/terminal/style.ts +0 -121
  59. package/src/terminal/view.ts +0 -49
  60. package/src/trace/asciicast.ts +0 -20
  61. package/src/trace/asciinema_player_assets.ts +0 -44
  62. package/src/trace/cast_to_txt.ts +0 -116
  63. package/src/trace/recorder.ts +0 -110
  64. package/src/trace/report.ts +0 -2092
  65. package/src/types.ts +0 -86
  66. package/src/util/hash.ts +0 -8
  67. package/src/util/sleep.ts +0 -5
package/src/mcp/server.ts DELETED
@@ -1,1348 +0,0 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
3
- import { z } from "zod";
4
-
5
- import { SessionManager } from "../session/session_manager";
6
- import { formatSnapshotView } from "../terminal/view";
7
- import { runScriptPath } from "../script/path";
8
- import { runAllScripts } from "../script/run_all";
9
- import { ensureAsciinemaPlayerAssets } from "../trace/asciinema_player_assets";
10
- import { generateTraceReportHtml } from "../trace/report";
11
- import { generateTestFromDoc } from "../generator/generate";
12
- import { ScriptRecordingManager } from "./script_recording";
13
- import { writeFileSync, mkdirSync } from "node:fs";
14
- import { join } from "node:path";
15
- import { tmpdir } from "node:os";
16
- import pkg from "../../package.json";
17
-
18
- export type PtywrightCapability = "core" | "debug" | "script" | "recording" | "all";
19
-
20
- export type PtywrightServerOptions = {
21
- sessionManager?: SessionManager;
22
- capabilities?: PtywrightCapability[];
23
- };
24
-
25
- const textMaskRuleSchema = z.object({
26
- regex: z.string().min(1),
27
- flags: z.string().optional(),
28
- replacement: z.string().optional(),
29
- preserveLength: z.boolean().optional(),
30
- });
31
-
32
- export function createPtywrightServer(options?: PtywrightServerOptions): {
33
- server: McpServer;
34
- sessions: SessionManager;
35
- } {
36
- const sessions = options?.sessionManager ?? new SessionManager();
37
- const recordings = new ScriptRecordingManager();
38
-
39
- const server = new McpServer({
40
- name: "ptywright",
41
- version: pkg.version,
42
- });
43
-
44
- const caps = resolveCapabilities(options?.capabilities, process.env.PTYWRIGHT_CAPS);
45
-
46
- type ToolExtra = { sessionId?: string };
47
-
48
- const selectedSessionByTransport = new Map<string, string>();
49
-
50
- function transportKey(extra: ToolExtra): string {
51
- return extra.sessionId ?? "default";
52
- }
53
-
54
- function getSelectedSessionId(extra: ToolExtra): string | undefined {
55
- return selectedSessionByTransport.get(transportKey(extra));
56
- }
57
-
58
- function setSelectedSessionId(extra: ToolExtra, sessionId: string): void {
59
- selectedSessionByTransport.set(transportKey(extra), sessionId);
60
- }
61
-
62
- function clearSelectedSessionId(extra: ToolExtra): void {
63
- selectedSessionByTransport.delete(transportKey(extra));
64
- }
65
-
66
- function isEnabled(category: Exclude<PtywrightCapability, "all">): boolean {
67
- return caps.all || caps.enabled.has(category);
68
- }
69
-
70
- function tool<Shape extends z.ZodRawShape>(
71
- category: Exclude<PtywrightCapability, "all">,
72
- name: string,
73
- description: string,
74
- schema: Shape,
75
- annotations: ToolAnnotations | undefined,
76
- handler: (args: z.infer<z.ZodObject<Shape>>, extra: ToolExtra) => unknown,
77
- ): void {
78
- if (!isEnabled(category)) return;
79
- const { title, ...rest } = annotations ?? {};
80
- const cleanedAnnotations = Object.keys(rest).length ? (rest as ToolAnnotations) : undefined;
81
-
82
- server.registerTool(
83
- name,
84
- {
85
- title,
86
- description,
87
- inputSchema: schema,
88
- annotations: cleanedAnnotations,
89
- _meta: { category },
90
- },
91
- handler as any,
92
- );
93
- }
94
-
95
- tool(
96
- "core",
97
- "select_session",
98
- "Select the default session for subsequent tool calls (so other tools can omit sessionId).",
99
- {
100
- sessionId: z.string().min(1),
101
- },
102
- { title: "Select Session" },
103
- async (args, extra) => {
104
- const session = sessions.getSession(args.sessionId);
105
- if (!session) {
106
- return toolError(`session not found: ${args.sessionId}`);
107
- }
108
- setSelectedSessionId(extra, args.sessionId);
109
- return {
110
- content: [{ type: "text", text: `selected ${args.sessionId}` }],
111
- structuredContent: { sessionId: args.sessionId },
112
- };
113
- },
114
- );
115
-
116
- tool(
117
- "core",
118
- "launch_session",
119
- "Start here! Launch a CLI/TUI command to begin testing (e.g., 'vim', 'top', 'npm start'). Returns a sessionId required for other tools.",
120
- {
121
- command: z.string().min(1),
122
- args: z.array(z.string()).optional(),
123
- cwd: z.string().optional(),
124
- env: z.record(z.string()).optional(),
125
- cols: z.number().int().optional(),
126
- rows: z.number().int().optional(),
127
- name: z.string().optional(),
128
- },
129
- {
130
- title: "Launch Session",
131
- openWorldHint: true,
132
- },
133
- async (args, extra) => {
134
- const session = sessions.launchSession(args);
135
- setSelectedSessionId(extra, session.id);
136
- recordings.recordLaunch(
137
- {
138
- command: args.command,
139
- args: args.args,
140
- cwd: args.cwd,
141
- env: args.env,
142
- cols: args.cols,
143
- rows: args.rows,
144
- name: args.name,
145
- },
146
- session.id,
147
- );
148
- return {
149
- content: [{ type: "text", text: `launched ${session.id}` }],
150
- structuredContent: {
151
- sessionId: session.id,
152
- },
153
- };
154
- },
155
- );
156
-
157
- tool(
158
- "core",
159
- "send_text",
160
- "Send text input to a session (optionally press Enter).",
161
- {
162
- sessionId: z.string().min(1).optional(),
163
- text: z.string(),
164
- enter: z.boolean().optional(),
165
- },
166
- { title: "Send Text" },
167
- async (args, extra) => {
168
- const sessionId = args.sessionId ?? getSelectedSessionId(extra);
169
- if (!sessionId) {
170
- return toolError("sessionId is required (provide sessionId or call select_session)");
171
- }
172
-
173
- const session = sessions.getSession(sessionId);
174
- if (!session) {
175
- return toolError(`session not found: ${sessionId}`);
176
- }
177
- session.sendText(args.text, { enter: args.enter });
178
- recordings.recordStep({ type: "sendText", text: args.text, enter: args.enter });
179
- return { content: [{ type: "text", text: "ok" }] };
180
- },
181
- );
182
-
183
- tool(
184
- "core",
185
- "press_key",
186
- "Send a key or key chord to a session (e.g. Enter, Ctrl+C, Shift+Tab).",
187
- {
188
- sessionId: z.string().min(1).optional(),
189
- key: z.string().min(1),
190
- },
191
- { title: "Press Key" },
192
- async (args, extra) => {
193
- const sessionId = args.sessionId ?? getSelectedSessionId(extra);
194
- if (!sessionId) {
195
- return toolError("sessionId is required (provide sessionId or call select_session)");
196
- }
197
-
198
- const session = sessions.getSession(sessionId);
199
- if (!session) {
200
- return toolError(`session not found: ${sessionId}`);
201
- }
202
- try {
203
- session.pressKey(args.key);
204
- recordings.recordStep({ type: "pressKey", key: args.key });
205
- return { content: [{ type: "text", text: "ok" }] };
206
- } catch (error) {
207
- return toolError((error as Error).message);
208
- }
209
- },
210
- );
211
-
212
- // Hidden low-level tool: send_mouse
213
- /*
214
- tool(
215
- "core",
216
- "send_mouse",
217
- ...
218
- );
219
- */
220
-
221
- // Hidden low-level tool: resize
222
- /*
223
- tool(
224
- "core",
225
- "resize",
226
- ...
227
- );
228
- */
229
-
230
- tool(
231
- "core",
232
- "snapshot_text",
233
- "Capture plain text from the visible screen or full buffer (best for stable assertions/goldens).",
234
- {
235
- sessionId: z.string().min(1).optional(),
236
- scope: z.enum(["visible", "buffer"]).optional(),
237
- trimRight: z.boolean().optional(),
238
- trimBottom: z.boolean().optional(),
239
- maxLines: z.number().int().positive().optional(),
240
- tailLines: z.number().int().positive().optional(),
241
- mask: z.array(textMaskRuleSchema).optional(),
242
- },
243
- {
244
- title: "Snapshot Text",
245
- readOnlyHint: true,
246
- idempotentHint: true,
247
- },
248
- async (args, extra) => {
249
- const sessionId = args.sessionId ?? getSelectedSessionId(extra);
250
- if (!sessionId) {
251
- return toolError("sessionId is required (provide sessionId or call select_session)");
252
- }
253
-
254
- const session = sessions.getSession(sessionId);
255
- if (!session) {
256
- return toolError(`session not found: ${sessionId}`);
257
- }
258
- if (args.maxLines !== undefined && args.tailLines !== undefined) {
259
- return toolError("snapshot_text: maxLines and tailLines are mutually exclusive");
260
- }
261
- let text: string;
262
- let hash: string;
263
- try {
264
- ({ text, hash } = await session.snapshotText({
265
- scope: args.scope,
266
- trimRight: args.trimRight ?? true,
267
- trimBottom: args.trimBottom ?? true,
268
- maxLines: args.maxLines,
269
- tailLines: args.tailLines,
270
- mask: args.mask,
271
- }));
272
- } catch (error) {
273
- return toolError((error as Error).message);
274
- }
275
- return {
276
- content: [{ type: "text", text }],
277
- structuredContent: { sessionId, hash },
278
- };
279
- },
280
- );
281
-
282
- tool(
283
- "debug",
284
- "snapshot_ansi",
285
- "Capture ANSI-rendered snapshot (debug/human inspection; less stable than plain text).",
286
- {
287
- sessionId: z.string().min(1).optional(),
288
- scope: z.enum(["visible", "buffer"]).optional(),
289
- trimRight: z.boolean().optional(),
290
- trimBottom: z.boolean().optional(),
291
- maxLines: z.number().int().positive().optional(),
292
- tailLines: z.number().int().positive().optional(),
293
- mask: z.array(textMaskRuleSchema).optional(),
294
- },
295
- {
296
- title: "Snapshot ANSI",
297
- readOnlyHint: true,
298
- idempotentHint: true,
299
- },
300
- async (args, extra) => {
301
- const sessionId = args.sessionId ?? getSelectedSessionId(extra);
302
- if (!sessionId) {
303
- return toolError("sessionId is required (provide sessionId or call select_session)");
304
- }
305
-
306
- const session = sessions.getSession(sessionId);
307
- if (!session) {
308
- return toolError(`session not found: ${sessionId}`);
309
- }
310
- if (args.maxLines !== undefined && args.tailLines !== undefined) {
311
- return toolError("snapshot_ansi: maxLines and tailLines are mutually exclusive");
312
- }
313
-
314
- let ansi: string;
315
- let plain: string;
316
- let hash: string;
317
- try {
318
- ({ ansi, plain, hash } = await session.snapshotAnsi({
319
- scope: args.scope,
320
- trimRight: args.trimRight ?? true,
321
- trimBottom: args.trimBottom ?? true,
322
- maxLines: args.maxLines,
323
- tailLines: args.tailLines,
324
- mask: args.mask,
325
- }));
326
- } catch (error) {
327
- return toolError((error as Error).message);
328
- }
329
-
330
- return {
331
- content: [{ type: "text", text: ansi }],
332
- structuredContent: { sessionId, hash, plain },
333
- };
334
- },
335
- );
336
-
337
- // Hidden low-level tool: snapshot_grid (use snapshot_view instead)
338
- // Hidden low-level tool: snapshot_cast (used internally for reports)
339
-
340
- tool(
341
- "recording",
342
- "mark",
343
- "Add a marker to the session trace (used for recording/checkpoints).",
344
- {
345
- sessionId: z.string().min(1).optional(),
346
- label: z.string().optional(),
347
- },
348
- { title: "Mark Trace" },
349
- async (args, extra) => {
350
- const sessionId = args.sessionId ?? getSelectedSessionId(extra);
351
- if (!sessionId) {
352
- return toolError("sessionId is required (provide sessionId or call select_session)");
353
- }
354
-
355
- const session = sessions.getSession(sessionId);
356
- if (!session) {
357
- return toolError(`session not found: ${sessionId}`);
358
- }
359
-
360
- session.mark(args.label);
361
- recordings.recordStep({ type: "mark", label: args.label });
362
- await recordings.recordCheckpoint({ session, label: args.label });
363
- return { content: [{ type: "text", text: "ok" }] };
364
- },
365
- );
366
-
367
- tool(
368
- "core",
369
- "wait_for_text",
370
- "Wait until a text/regex appears in the session (polling).",
371
- {
372
- sessionId: z.string().min(1).optional(),
373
- scope: z.enum(["visible", "buffer"]).optional(),
374
- text: z.string().optional(),
375
- regex: z.string().optional(),
376
- timeoutMs: z.number().int().optional(),
377
- intervalMs: z.number().int().optional(),
378
- includeText: z.boolean().optional(),
379
- },
380
- {
381
- title: "Wait For Text",
382
- readOnlyHint: true,
383
- idempotentHint: true,
384
- },
385
- async (args, extra) => {
386
- const sessionId = args.sessionId ?? getSelectedSessionId(extra);
387
- if (!sessionId) {
388
- return toolError("sessionId is required (provide sessionId or call select_session)");
389
- }
390
-
391
- const session = sessions.getSession(sessionId);
392
- if (!session) {
393
- return toolError(`session not found: ${sessionId}`);
394
- }
395
-
396
- if (!args.text && !args.regex) {
397
- return toolError("either text or regex must be provided");
398
- }
399
-
400
- const regex = args.regex ? new RegExp(args.regex) : undefined;
401
- const result = await session.waitForText({
402
- scope: args.scope,
403
- text: args.text,
404
- regex,
405
- timeoutMs: args.timeoutMs ?? 10_000,
406
- intervalMs: args.intervalMs ?? 100,
407
- });
408
- recordings.recordStep({
409
- type: "waitForText",
410
- scope: args.scope,
411
- text: args.text,
412
- regex: args.regex,
413
- timeoutMs: args.timeoutMs,
414
- intervalMs: args.intervalMs,
415
- });
416
-
417
- const structuredContent: Record<string, unknown> = {
418
- sessionId,
419
- found: result.found,
420
- hash: result.hash,
421
- };
422
- if (args.includeText ?? false) {
423
- structuredContent.text = result.text;
424
- }
425
-
426
- return {
427
- content: [{ type: "text", text: result.found ? "found" : "not_found" }],
428
- structuredContent,
429
- };
430
- },
431
- );
432
-
433
- tool(
434
- "core",
435
- "wait_for_stable_screen",
436
- "Wait until consecutive text snapshots remain unchanged for a quiet window (reduce flakiness).",
437
- {
438
- sessionId: z.string().min(1).optional(),
439
- timeoutMs: z.number().int().optional(),
440
- quietMs: z.number().int().optional(),
441
- intervalMs: z.number().int().optional(),
442
- includeText: z.boolean().optional(),
443
- },
444
- {
445
- title: "Wait For Stable Screen",
446
- readOnlyHint: true,
447
- idempotentHint: true,
448
- },
449
- async (args, extra) => {
450
- const sessionId = args.sessionId ?? getSelectedSessionId(extra);
451
- if (!sessionId) {
452
- return toolError("sessionId is required (provide sessionId or call select_session)");
453
- }
454
-
455
- const session = sessions.getSession(sessionId);
456
- if (!session) {
457
- return toolError(`session not found: ${sessionId}`);
458
- }
459
-
460
- const result = await session.waitForStableScreen({
461
- timeoutMs: args.timeoutMs ?? 10_000,
462
- quietMs: args.quietMs ?? 400,
463
- intervalMs: args.intervalMs ?? 80,
464
- });
465
- recordings.recordStep({ type: "waitForStableScreen", ...args });
466
-
467
- const structuredContent: Record<string, unknown> = {
468
- sessionId,
469
- stable: result.stable,
470
- hash: result.hash,
471
- };
472
- if (args.includeText ?? false) {
473
- structuredContent.text = result.text;
474
- }
475
-
476
- return {
477
- content: [{ type: "text", text: result.stable ? "stable" : "unstable" }],
478
- structuredContent,
479
- };
480
- },
481
- );
482
-
483
- tool(
484
- "core",
485
- "assert",
486
- "Verify screen content. Use this to check if a test passed or failed (e.g., 'check if X is visible'). Supports exact text, regex, or semantic AI verification.",
487
- {
488
- sessionId: z.string().min(1).optional(),
489
- scope: z.enum(["visible", "buffer"]).optional(),
490
- description: z.string().optional(),
491
- text: z.string().optional(),
492
- regex: z.string().optional(),
493
- useAI: z.boolean().optional(),
494
- },
495
- {
496
- title: "Assert",
497
- readOnlyHint: true,
498
- idempotentHint: true,
499
- },
500
- async (args, extra) => {
501
- const sessionId = args.sessionId ?? getSelectedSessionId(extra);
502
- if (!sessionId) {
503
- return toolError("sessionId is required (provide sessionId or call select_session)");
504
- }
505
-
506
- const session = sessions.getSession(sessionId);
507
- if (!session) {
508
- return toolError(`session not found: ${sessionId}`);
509
- }
510
-
511
- if (!args.text && !args.regex && !args.useAI) {
512
- return toolError("must provide text, regex, or useAI: true");
513
- }
514
-
515
- const snapshot = await session.snapshotText({
516
- scope: args.scope ?? "visible",
517
- captureFrame: true,
518
- });
519
-
520
- // Pattern semantics match script runner: text OR regex.
521
- let found = true;
522
- if (args.text || args.regex) {
523
- found = false;
524
-
525
- if (args.text && snapshot.text.includes(args.text)) {
526
- found = true;
527
- }
528
-
529
- if (args.regex) {
530
- let re: RegExp;
531
- try {
532
- re = new RegExp(args.regex);
533
- } catch {
534
- return toolError(`invalid regex: ${args.regex}`);
535
- }
536
-
537
- if (re.test(snapshot.text)) {
538
- found = true;
539
- }
540
- }
541
- }
542
-
543
- // Record for the script: keep playback deterministic.
544
- if (args.text || args.regex) {
545
- recordings.recordStep({
546
- type: "assert",
547
- scope: args.scope,
548
- text: args.text,
549
- regex: args.regex,
550
- description: args.description,
551
- });
552
- }
553
-
554
- if (args.useAI) {
555
- recordings.recordStep({
556
- type: "assertSemantic",
557
- prompt: args.description || "Check screen content",
558
- description: args.description,
559
- });
560
- }
561
-
562
- if (!found) {
563
- return toolError(`assertion failed: ${args.description || "pattern match"}`, {
564
- sessionId,
565
- text: snapshot.text,
566
- });
567
- }
568
-
569
- return {
570
- content: [{ type: "text", text: "ok" }],
571
- structuredContent: { sessionId, found },
572
- };
573
- },
574
- );
575
-
576
- tool(
577
- "core",
578
- "snapshot_view",
579
- "Capture a formatted, human-readable snapshot view (includes meta + optional line numbers).",
580
- {
581
- sessionId: z.string().min(1).optional(),
582
- scope: z.enum(["visible", "buffer"]).optional(),
583
- trimRight: z.boolean().optional(),
584
- trimBottom: z.boolean().optional(),
585
- maxLines: z.number().int().positive().optional(),
586
- tailLines: z.number().int().positive().optional(),
587
- lineNumbers: z.boolean().optional(),
588
- mask: z.array(textMaskRuleSchema).optional(),
589
- },
590
- {
591
- title: "Snapshot View",
592
- readOnlyHint: true,
593
- idempotentHint: true,
594
- },
595
- async (args, extra) => {
596
- const sessionId = args.sessionId ?? getSelectedSessionId(extra);
597
- if (!sessionId) {
598
- return toolError("sessionId is required (provide sessionId or call select_session)");
599
- }
600
-
601
- const session = sessions.getSession(sessionId);
602
- if (!session) {
603
- return toolError(`session not found: ${sessionId}`);
604
- }
605
- if (args.maxLines !== undefined && args.tailLines !== undefined) {
606
- return toolError("snapshot_view: maxLines and tailLines are mutually exclusive");
607
- }
608
-
609
- let text: string;
610
- let hash: string;
611
- try {
612
- ({ text, hash } = await session.snapshotText({
613
- scope: args.scope,
614
- trimRight: args.trimRight,
615
- trimBottom: args.trimBottom ?? true,
616
- maxLines: args.maxLines,
617
- tailLines: args.tailLines,
618
- mask: args.mask,
619
- }));
620
- } catch (error) {
621
- return toolError((error as Error).message);
622
- }
623
-
624
- const view = formatSnapshotView({
625
- sessionId,
626
- scope: args.scope ?? "visible",
627
- hash,
628
- lines: text.split("\n"),
629
- meta: session.getMeta(),
630
- lineNumbers: args.lineNumbers,
631
- });
632
-
633
- return {
634
- content: [{ type: "text", text: view }],
635
- structuredContent: { sessionId, hash },
636
- };
637
- },
638
- );
639
-
640
- tool(
641
- "debug",
642
- "snapshot_view_ansi",
643
- "Capture a formatted ANSI snapshot view (includes meta + optional line numbers).",
644
- {
645
- sessionId: z.string().min(1).optional(),
646
- scope: z.enum(["visible", "buffer"]).optional(),
647
- trimRight: z.boolean().optional(),
648
- trimBottom: z.boolean().optional(),
649
- maxLines: z.number().int().positive().optional(),
650
- tailLines: z.number().int().positive().optional(),
651
- lineNumbers: z.boolean().optional(),
652
- mask: z.array(textMaskRuleSchema).optional(),
653
- },
654
- {
655
- title: "Snapshot View (ANSI)",
656
- readOnlyHint: true,
657
- idempotentHint: true,
658
- },
659
- async (args, extra) => {
660
- const sessionId = args.sessionId ?? getSelectedSessionId(extra);
661
- if (!sessionId) {
662
- return toolError("sessionId is required (provide sessionId or call select_session)");
663
- }
664
-
665
- const session = sessions.getSession(sessionId);
666
- if (!session) {
667
- return toolError(`session not found: ${sessionId}`);
668
- }
669
- if (args.maxLines !== undefined && args.tailLines !== undefined) {
670
- return toolError("snapshot_view_ansi: maxLines and tailLines are mutually exclusive");
671
- }
672
-
673
- let ansi: string;
674
- let hash: string;
675
- try {
676
- ({ ansi, hash } = await session.snapshotAnsi({
677
- scope: args.scope,
678
- trimRight: args.trimRight,
679
- trimBottom: args.trimBottom ?? true,
680
- maxLines: args.maxLines,
681
- tailLines: args.tailLines,
682
- mask: args.mask,
683
- }));
684
- } catch (error) {
685
- return toolError((error as Error).message);
686
- }
687
-
688
- const view = formatSnapshotView({
689
- sessionId,
690
- scope: args.scope ?? "visible",
691
- hash,
692
- lines: ansi.split("\n"),
693
- meta: session.getMeta(),
694
- lineNumbers: args.lineNumbers,
695
- });
696
-
697
- return {
698
- content: [{ type: "text", text: view }],
699
- structuredContent: { sessionId, hash },
700
- };
701
- },
702
- );
703
-
704
- tool(
705
- "script",
706
- "run_script",
707
- "Run a JSON/TS script via the runner and return artifact paths (prefer this for regression runs).",
708
- {
709
- scriptPath: z.string().min(1),
710
- artifactsDir: z.string().optional(),
711
- stepsPath: z.string().optional(),
712
- updateGoldens: z.boolean().optional(),
713
- },
714
- {
715
- title: "Run Script",
716
- openWorldHint: true,
717
- destructiveHint: true,
718
- },
719
- async (args) => {
720
- const result = await runScriptPath(args.scriptPath, {
721
- artifactsDir: args.artifactsDir,
722
- stepsPath: args.stepsPath,
723
- updateGoldens: args.updateGoldens,
724
- });
725
-
726
- if (!result.ok) {
727
- return toolError(result.error, {
728
- scriptName: result.scriptName,
729
- artifactsDir: result.artifactsDir,
730
- castPath: result.castPath,
731
- reportPath: result.reportPath,
732
- failureArtifacts: result.failureArtifacts,
733
- });
734
- }
735
-
736
- return {
737
- content: [{ type: "text", text: `ok artifacts=${result.artifactsDir}` }],
738
- structuredContent: result,
739
- };
740
- },
741
- );
742
-
743
- tool(
744
- "script",
745
- "run_all_scripts",
746
- "Run all ptywright scripts (JSON/TS) recursively and generate a Playwright-like suite report: index.html + run.summary.json. 用于:一键批量回归 / 生成总览报告 / CI。Call with no args to use defaults (dir='scripts', suite report in .tmp/run-all/). Returns reportPath+summaryPath; open reportPath in a browser to view. Tip: keep includeEntries='failures' (default) and maxEntries to avoid context bloat.",
747
- {
748
- dir: z.string().optional(),
749
- artifactsRoot: z.string().optional(),
750
- stepsPath: z.string().optional(),
751
- updateGoldens: z.boolean().optional(),
752
- includeEntries: z.enum(["none", "failures", "all"]).optional(),
753
- maxEntries: z.number().int().nonnegative().optional(),
754
- },
755
- {
756
- title: "Run All Scripts (Suite Report)",
757
- openWorldHint: true,
758
- destructiveHint: true,
759
- },
760
- async (args) => {
761
- try {
762
- const includeEntries = args.includeEntries ?? "failures";
763
- const maxEntries = args.maxEntries ?? 20;
764
-
765
- const result = await runAllScripts({
766
- dir: args.dir,
767
- artifactsRoot: args.artifactsRoot,
768
- stepsPath: args.stepsPath,
769
- updateGoldens: args.updateGoldens,
770
- });
771
-
772
- const failures = result.entries.filter((e) => !e.result.ok);
773
- let entries: typeof result.entries = [];
774
- if (includeEntries === "all") entries = result.entries;
775
- else if (includeEntries === "failures") entries = failures;
776
-
777
- let truncatedCount = 0;
778
- if (entries.length > maxEntries) {
779
- truncatedCount = entries.length - maxEntries;
780
- entries = entries.slice(0, maxEntries);
781
- }
782
-
783
- const summaryLines = [
784
- result.ok ? "ok" : "failed",
785
- `count=${result.entries.length}`,
786
- `failures=${failures.length}`,
787
- `dir=${result.dir}`,
788
- `entries=${entries.length}`,
789
- `report=${result.reportPath}`,
790
- `summary=${result.summaryPath}`,
791
- truncatedCount > 0 ? `truncated=${truncatedCount}` : null,
792
- ];
793
-
794
- if (entries.length > 0 && failures.length > 0) {
795
- for (const f of entries) {
796
- if (f.result.ok) continue;
797
- summaryLines.push(`- ${f.filePath}: ${f.result.error}`);
798
- }
799
- }
800
-
801
- return {
802
- content: [{ type: "text", text: summaryLines.filter(Boolean).join("\n") }],
803
- structuredContent: {
804
- ok: result.ok,
805
- dir: result.dir,
806
- suiteDir: result.suiteDir,
807
- reportPath: result.reportPath,
808
- summaryPath: result.summaryPath,
809
- totalCount: result.entries.length,
810
- failureCount: failures.length,
811
- includeEntries,
812
- maxEntries,
813
- truncatedCount,
814
- entries,
815
- },
816
- };
817
- } catch (error) {
818
- return toolError((error as Error).message);
819
- }
820
- },
821
- );
822
-
823
- tool(
824
- "script",
825
- "generate_test_from_doc",
826
- "Generate test script from documentation (local file or URL). Parses Markdown/HTML/JSON docs to extract test steps and generates executable ptywright scripts.",
827
- {
828
- source: z.string().min(1).describe("Document path (local file) or URL"),
829
- sourceType: z
830
- .enum(["local", "url", "auto"])
831
- .optional()
832
- .describe("Source type (auto-detected if not specified)"),
833
- outputDir: z.string().optional().describe("Output directory for generated scripts"),
834
- outputFormat: z
835
- .enum(["json", "ts", "both"])
836
- .optional()
837
- .describe("Output format (default: both)"),
838
- targetCommand: z
839
- .string()
840
- .optional()
841
- .describe("Command to test (overrides auto-detected command)"),
842
- targetArgs: z.array(z.string()).optional().describe("Arguments for target command"),
843
- name: z
844
- .string()
845
- .optional()
846
- .describe("Test name (auto-generated from doc title if not specified)"),
847
- cols: z.number().int().positive().optional().describe("Terminal columns (default: 80)"),
848
- rows: z.number().int().positive().optional().describe("Terminal rows (default: 24)"),
849
- },
850
- {
851
- title: "Generate Test from Documentation",
852
- openWorldHint: true,
853
- destructiveHint: true,
854
- },
855
- async (args) => {
856
- try {
857
- const result = await generateTestFromDoc({
858
- source: args.source,
859
- sourceType: args.sourceType,
860
- outputDir: args.outputDir,
861
- outputFormat: args.outputFormat,
862
- targetCommand: args.targetCommand,
863
- targetArgs: args.targetArgs,
864
- name: args.name,
865
- cols: args.cols,
866
- rows: args.rows,
867
- });
868
-
869
- if (!result.ok) {
870
- return toolError(result.error ?? "Failed to generate test", {
871
- warnings: result.warnings,
872
- parsed: result.parsed,
873
- });
874
- }
875
-
876
- const summaryLines = [
877
- `Generated test: ${result.name}`,
878
- `Steps: ${result.stepCount}`,
879
- result.jsonPath ? `JSON: ${result.jsonPath}` : null,
880
- result.tsPath ? `TypeScript: ${result.tsPath}` : null,
881
- result.warnings.length > 0 ? `Warnings: ${result.warnings.join("; ")}` : null,
882
- ].filter(Boolean);
883
-
884
- return {
885
- content: [{ type: "text", text: summaryLines.join("\n") }],
886
- structuredContent: result,
887
- };
888
- } catch (error) {
889
- return toolError((error as Error).message);
890
- }
891
- },
892
- );
893
-
894
- tool(
895
- "recording",
896
- "start_script_recording",
897
- "Start recording MCP tool calls into a replayable script (with optional golden checkpoints via mark()).",
898
- {
899
- name: z.string().min(1),
900
- outPath: z.string().optional(),
901
- goldenDir: z.string().optional(),
902
- overwrite: z.boolean().optional(),
903
- scope: z.enum(["visible", "buffer"]).optional(),
904
- trimRight: z.boolean().optional(),
905
- trimBottom: z.boolean().optional(),
906
- mask: z.array(textMaskRuleSchema).optional(),
907
- },
908
- {
909
- title: "Start Script Recording",
910
- openWorldHint: true,
911
- },
912
- async (args) => {
913
- try {
914
- const status = recordings.start({
915
- name: args.name,
916
- outPath: args.outPath,
917
- goldenDir: args.goldenDir,
918
- overwrite: args.overwrite,
919
- checkpoint: {
920
- scope: args.scope ?? "visible",
921
- trimRight: args.trimRight ?? true,
922
- trimBottom: args.trimBottom ?? true,
923
- mask: args.mask,
924
- },
925
- });
926
- return {
927
- content: [{ type: "text", text: `recording ${status.recordingId}` }],
928
- structuredContent: status,
929
- };
930
- } catch (error) {
931
- return toolError((error as Error).message);
932
- }
933
- },
934
- );
935
-
936
- tool(
937
- "recording",
938
- "stop_script_recording",
939
- "Stop recording and optionally write the script + goldens to disk.",
940
- {
941
- recordingId: z.string().min(1),
942
- writeFiles: z.boolean().optional(),
943
- },
944
- {
945
- title: "Stop Script Recording",
946
- destructiveHint: true,
947
- },
948
- async (args) => {
949
- try {
950
- const result = recordings.stop({
951
- recordingId: args.recordingId,
952
- writeFiles: args.writeFiles,
953
- });
954
- return {
955
- content: [{ type: "text", text: `ok script=${result.scriptPath ?? ""}` }],
956
- structuredContent: {
957
- scriptPath: result.scriptPath,
958
- goldenPaths: result.goldenPaths,
959
- },
960
- };
961
- } catch (error) {
962
- return toolError((error as Error).message);
963
- }
964
- },
965
- );
966
-
967
- tool(
968
- "core",
969
- "close_session",
970
- "Close a running session.",
971
- {
972
- sessionId: z.string().min(1).optional(),
973
- },
974
- {
975
- title: "Close Session",
976
- destructiveHint: true,
977
- },
978
- async (args, extra) => {
979
- const sessionId = args.sessionId ?? getSelectedSessionId(extra);
980
- if (!sessionId) {
981
- return toolError("sessionId is required (provide sessionId or call select_session)");
982
- }
983
-
984
- const ok = sessions.closeSession(sessionId);
985
- if (!ok) {
986
- return toolError(`session not found: ${sessionId}`);
987
- }
988
- if (getSelectedSessionId(extra) === sessionId) {
989
- clearSelectedSessionId(extra);
990
- }
991
- return { content: [{ type: "text", text: "closed" }] };
992
- },
993
- );
994
-
995
- tool(
996
- "core",
997
- "list_sessions",
998
- "List all active sessions.",
999
- {},
1000
- { title: "List Sessions" },
1001
- async (_args, _extra) => {
1002
- const all = sessions.listSessions().map((s) => ({
1003
- id: s.id,
1004
- cols: s.cols,
1005
- rows: s.rows,
1006
- meta: s.getMeta(),
1007
- }));
1008
-
1009
- return {
1010
- content: [{ type: "text", text: JSON.stringify(all, null, 2) }],
1011
- structuredContent: { sessions: all },
1012
- };
1013
- },
1014
- );
1015
-
1016
- // High-level tool: run_routine - batch execute steps with full snapshots
1017
- const routineStepSchema = z.object({
1018
- action: z.enum(["sendText", "pressKey", "wait", "assert", "snapshot"]),
1019
- text: z.string().optional(),
1020
- enter: z.boolean().optional(),
1021
- key: z.string().optional(),
1022
- waitFor: z.string().optional(),
1023
- regex: z.string().optional(),
1024
- timeoutMs: z.number().int().optional(),
1025
- description: z.string().optional(),
1026
- });
1027
-
1028
- tool(
1029
- "script",
1030
- "run_routine",
1031
- "PRIMARY INTERACTION TOOL. Execute a multi-step test scenario (type, key, wait, assert) in one go. Use this whenever asked to 'test', 'verify', 'do', or 'check' a workflow. It handles delays and snapshots automatically.",
1032
- {
1033
- sessionId: z.string().min(1).optional(),
1034
- steps: z.array(routineStepSchema).min(1),
1035
- saveReport: z.boolean().optional(),
1036
- reportPath: z.string().optional(),
1037
- },
1038
- {
1039
- title: "Run Routine",
1040
- openWorldHint: true,
1041
- },
1042
- async (args, extra) => {
1043
- const sessionId = args.sessionId ?? getSelectedSessionId(extra);
1044
- if (!sessionId) {
1045
- return toolError("sessionId is required (provide sessionId or call select_session)");
1046
- }
1047
-
1048
- const session = sessions.getSession(sessionId);
1049
- if (!session) {
1050
- return toolError(`session not found: ${sessionId}`);
1051
- }
1052
-
1053
- type StepResult = {
1054
- index: number;
1055
- action: string;
1056
- description?: string;
1057
- ok: boolean;
1058
- error?: string;
1059
- snapshot?: string;
1060
- hash?: string;
1061
- };
1062
-
1063
- const results: StepResult[] = [];
1064
- let failed = false;
1065
- let failedStep: number | null = null;
1066
-
1067
- for (let i = 0; i < args.steps.length; i++) {
1068
- const step = args.steps[i];
1069
- if (!step) continue;
1070
-
1071
- const result: StepResult = {
1072
- index: i + 1,
1073
- action: step.action,
1074
- description: step.description,
1075
- ok: true,
1076
- };
1077
-
1078
- try {
1079
- if (step.action === "sendText" && step.text !== undefined) {
1080
- session.sendText(step.text, { enter: step.enter });
1081
- } else if (step.action === "pressKey" && step.key) {
1082
- session.pressKey(step.key);
1083
- } else if (step.action === "wait") {
1084
- if (step.waitFor || step.regex) {
1085
- const regex = step.regex ? new RegExp(step.regex) : undefined;
1086
- const waitResult = await session.waitForText({
1087
- text: step.waitFor,
1088
- regex,
1089
- timeoutMs: step.timeoutMs ?? 10_000,
1090
- intervalMs: 100,
1091
- });
1092
- if (!waitResult.found) {
1093
- throw new Error(`wait failed: ${step.waitFor || step.regex}`);
1094
- }
1095
- } else {
1096
- await session.waitForStableScreen({
1097
- timeoutMs: step.timeoutMs ?? 5_000,
1098
- quietMs: 300,
1099
- intervalMs: 80,
1100
- });
1101
- }
1102
- } else if (step.action === "assert") {
1103
- const regex = step.regex ? new RegExp(step.regex) : undefined;
1104
- const assertResult = await session.waitForText({
1105
- text: step.waitFor,
1106
- regex,
1107
- timeoutMs: 0,
1108
- intervalMs: 0,
1109
- });
1110
- if (!assertResult.found) {
1111
- throw new Error(`assert failed: ${step.description || step.waitFor || step.regex}`);
1112
- }
1113
- }
1114
-
1115
- // Always capture snapshot after each step
1116
- const snapshot = await session.snapshotText({
1117
- scope: "visible",
1118
- trimRight: true,
1119
- trimBottom: true,
1120
- captureFrame: true,
1121
- });
1122
- result.snapshot = snapshot.text;
1123
- result.hash = snapshot.hash;
1124
- } catch (error) {
1125
- result.ok = false;
1126
- result.error = (error as Error).message;
1127
- failed = true;
1128
- failedStep = i + 1;
1129
-
1130
- // Capture snapshot on failure too
1131
- try {
1132
- const snapshot = await session.snapshotText({
1133
- scope: "visible",
1134
- trimRight: true,
1135
- trimBottom: true,
1136
- captureFrame: true,
1137
- });
1138
- result.snapshot = snapshot.text;
1139
- result.hash = snapshot.hash;
1140
- } catch {
1141
- // ignore
1142
- }
1143
- }
1144
-
1145
- results.push(result);
1146
-
1147
- if (failed) break;
1148
- }
1149
-
1150
- const summary = failed
1151
- ? `failed at step ${failedStep}: ${results[results.length - 1]?.error}`
1152
- : `ok, ${results.length} steps completed`;
1153
-
1154
- let reportPath = args.reportPath;
1155
- // Default to saving report unless explicitly disabled (though schema only allows true/false/undefined)
1156
- // Actually let's just default to true if undefined
1157
- const saveReport = args.saveReport ?? true;
1158
-
1159
- if (saveReport) {
1160
- try {
1161
- // Generate an ad-hoc report using the captured steps and snapshots.
1162
- // Use the session trace so Cast Playback is fully playable.
1163
- const castSnapshot = await session.snapshotCast();
1164
-
1165
- const html = await generateTraceReportHtml(castSnapshot.cast, {
1166
- scriptName: "routine",
1167
- result: { ok: !failed, error: results[results.length - 1]?.error },
1168
- steps: results.map((r) => ({
1169
- index: r.index,
1170
- step: { type: r.action, description: r.description },
1171
- ok: r.ok,
1172
- error: r.error,
1173
- after: r.snapshot
1174
- ? { text: r.snapshot, hash: r.hash ?? "", kind: "view" }
1175
- : undefined,
1176
- })),
1177
- });
1178
-
1179
- if (!reportPath) {
1180
- const tmpDir = join(tmpdir(), "ptywright-routines");
1181
- mkdirSync(tmpDir, { recursive: true });
1182
- reportPath = join(tmpDir, `routine-${sessionId}-${Date.now()}.html`);
1183
- }
1184
-
1185
- writeFileSync(reportPath, html);
1186
- ensureAsciinemaPlayerAssets(reportPath);
1187
- } catch {
1188
- // Don't fail the routine if reporting fails, but log it
1189
- // In MCP we can append to the text content
1190
- // summary += `\n(Report generation failed: ${(err as Error).message})`;
1191
- }
1192
- }
1193
-
1194
- const contentText = reportPath
1195
- ? `${summary}
1196
-
1197
- Report generated: ${reportPath}
1198
- Open in browser to view step-by-step timeline.`
1199
- : summary;
1200
-
1201
- return {
1202
- content: [{ type: "text", text: contentText }],
1203
- structuredContent: {
1204
- sessionId,
1205
- ok: !failed,
1206
- stepCount: results.length,
1207
- failedStep,
1208
- reportPath,
1209
- results,
1210
- },
1211
- };
1212
- },
1213
- );
1214
-
1215
- tool(
1216
- "core",
1217
- "inspect_failure",
1218
- "Inspect the last failure state of a session. Returns the last screen snapshot and any error information captured.",
1219
- {
1220
- sessionId: z.string().min(1).optional(),
1221
- includeFrames: z.boolean().optional(),
1222
- maxFrames: z.number().int().positive().optional(),
1223
- },
1224
- {
1225
- title: "Inspect Failure",
1226
- readOnlyHint: true,
1227
- idempotentHint: true,
1228
- },
1229
- async (args, extra) => {
1230
- const sessionId = args.sessionId ?? getSelectedSessionId(extra);
1231
- if (!sessionId) {
1232
- return toolError("sessionId is required (provide sessionId or call select_session)");
1233
- }
1234
-
1235
- const session = sessions.getSession(sessionId);
1236
- if (!session) {
1237
- return toolError(`session not found: ${sessionId}`);
1238
- }
1239
-
1240
- const closeReason = session.getCloseReason();
1241
- const meta = session.getMeta();
1242
-
1243
- let currentSnapshot: { text: string; hash: string } | null = null;
1244
- try {
1245
- currentSnapshot = await session.snapshotText({
1246
- scope: "visible",
1247
- trimRight: true,
1248
- trimBottom: true,
1249
- captureFrame: true,
1250
- });
1251
- } catch {
1252
- // Session may be closed
1253
- }
1254
-
1255
- const frames = session.getSnapshotFrames();
1256
- const includeFrames = args.includeFrames ?? false;
1257
- const maxFrames = args.maxFrames ?? 5;
1258
-
1259
- const recentFrames = includeFrames ? frames.slice(-maxFrames) : [];
1260
-
1261
- const view = currentSnapshot
1262
- ? formatSnapshotView({
1263
- sessionId,
1264
- scope: "visible",
1265
- hash: currentSnapshot.hash,
1266
- lines: currentSnapshot.text.split("\n"),
1267
- meta,
1268
- lineNumbers: true,
1269
- })
1270
- : "(no snapshot available)";
1271
-
1272
- return {
1273
- content: [{ type: "text", text: view }],
1274
- structuredContent: {
1275
- sessionId,
1276
- closeReason,
1277
- meta,
1278
- currentSnapshot: currentSnapshot
1279
- ? { text: currentSnapshot.text, hash: currentSnapshot.hash }
1280
- : null,
1281
- recentFrames: recentFrames.map((f) => ({ atMs: f.atMs, hash: f.hash })),
1282
- isClosed: session.isClosed(),
1283
- },
1284
- };
1285
- },
1286
- );
1287
-
1288
- return { server, sessions };
1289
- }
1290
-
1291
- function resolveCapabilities(
1292
- capabilities: PtywrightCapability[] | undefined,
1293
- envValue: string | undefined,
1294
- ): { all: boolean; enabled: Set<Exclude<PtywrightCapability, "all">> } {
1295
- const requested = capabilities?.length ? capabilities : parseCapabilitiesEnv(envValue);
1296
- const normalized = new Set<Exclude<PtywrightCapability, "all">>();
1297
- let all = false;
1298
-
1299
- for (const cap of requested) {
1300
- if (cap === "all") {
1301
- all = true;
1302
- continue;
1303
- }
1304
- normalized.add(cap);
1305
- }
1306
-
1307
- if (!all && normalized.size === 0) {
1308
- all = true;
1309
- }
1310
-
1311
- return { all, enabled: normalized };
1312
- }
1313
-
1314
- function parseCapabilitiesEnv(envValue: string | undefined): PtywrightCapability[] {
1315
- if (!envValue?.trim()) return [];
1316
- const parts = envValue
1317
- .split(/[,\s]+/g)
1318
- .map((p) => p.trim().toLowerCase())
1319
- .filter(Boolean);
1320
-
1321
- const out: PtywrightCapability[] = [];
1322
-
1323
- for (const p of parts) {
1324
- if (p === "all") out.push("all");
1325
- else if (p === "core") out.push("core");
1326
- else if (p === "debug") out.push("debug");
1327
- else if (p === "script" || p === "scripts" || p === "runner" || p === "run") out.push("script");
1328
- else if (p === "recording" || p === "record" || p === "rec") out.push("recording");
1329
- else throw new Error(`unknown PTYWRIGHT_CAPS capability: ${p}`);
1330
- }
1331
-
1332
- return out;
1333
- }
1334
-
1335
- function toolError(
1336
- message: string,
1337
- extra: Record<string, unknown> = {},
1338
- ): {
1339
- isError: true;
1340
- content: { type: "text"; text: string }[];
1341
- structuredContent: Record<string, unknown> & { error: string };
1342
- } {
1343
- return {
1344
- isError: true,
1345
- content: [{ type: "text", text: message }],
1346
- structuredContent: { error: message, ...extra },
1347
- };
1348
- }