dsh-browser-playwright-codex 0.5.1 → 0.5.3

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/lib/session.js ADDED
@@ -0,0 +1,633 @@
1
+ import { BrowserError } from "./errors.js";
2
+ import { asTimeoutError, isAbortError, isContextDestroyed, isCrashError } from "./page-diagnostics.js";
3
+ import { consoleMessages, networkRequests, pageData, trackPage } from "./page-diagnostics.js";
4
+ import { elementStabilityProbe, pageStabilityProbe } from "./page-probes.js";
5
+ import { assertActionable, back, clickAt, clickAtRef, clickLocator, clickText, closeTab, fill, forward, hoverLocator, hoverText, navigate, openTab, press, resolveClickPoint, runAction, scroll, switchTab, verifyFill, withAbort, } from "./page-actions.js";
6
+ import { assertLive, attachDiff, buildRefSignatures, captureAria, captureLegacy, refLocator, resolveSnapshotEngine, settleAndSnapshot, snapshot, } from "./page-snapshot.js";
7
+ import { assertAllowedUrl } from "./url-policy.js";
8
+ /** Live session over one browser context owned by one caller. */
9
+ export class PlaywrightSession {
10
+ provider;
11
+ owner;
12
+ context;
13
+ currentIndex = 0;
14
+ closed = false;
15
+ /** Set while answering a dialog or reporting it: page access must not be refused then. */
16
+ bypassDialogGuard = false;
17
+ /** Reused CDP session for the current page (DOM nodeIds are session-bound). */
18
+ cdpSession;
19
+ /** The page the cached CDP session is attached to. */
20
+ cdpPage;
21
+ /** Pages already wired for console/network capture (WeakSet: pages stay collectable). */
22
+ trackedPages = new WeakSet();
23
+ /** Stable id per page, so diagnostics can be reported for one page only. */
24
+ pageIds = new WeakMap();
25
+ /** Ids handed out so far (monotonic within the session). */
26
+ pageIdSeq = 0;
27
+ /** Bounded console + pageerror ring, shared by every tracked page of this session. */
28
+ consoleLog = [];
29
+ /** Bounded network ring, shared by every tracked page of this session. */
30
+ networkLog = [];
31
+ /** Monotonic sequence handed to each captured entry. */
32
+ diagSeq = 0;
33
+ /** Evicted entries, so truncation is visible instead of silent. */
34
+ diagDropped = { console: 0, network: 0 };
35
+ /** In-flight requests keyed by request object, for status and duration attribution. */
36
+ requestEntries = new WeakMap();
37
+ /** Ring capacity per stream. */
38
+ diagLimit = 200;
39
+ /** ref -> feature signature of the previous capture (diff baseline). */
40
+ lastRefSignatures = undefined;
41
+ /** Nonce of the baseline ref space; a change means navigation reset. */
42
+ lastRefNonce = undefined;
43
+ constructor(provider, owner, context) {
44
+ this.provider = provider;
45
+ this.owner = owner;
46
+ this.context = context;
47
+ }
48
+ /** Run one operation under busy tracking: idle disposal defers until it settles. */
49
+ run(operation) {
50
+ this.provider.beginOp(this.owner);
51
+ return Promise.resolve()
52
+ .then(operation)
53
+ .catch((error) => {
54
+ if (error instanceof BrowserError || isAbortError(error))
55
+ throw error;
56
+ // The browser or its page died mid-operation: forget this owner's
57
+ // session handle so the next acquire relaunches the window (the
58
+ // profile and state file restore the login). Surface a clear code
59
+ // instead of a raw protocol error.
60
+ if (isCrashError(error)) {
61
+ this.closed = true;
62
+ this.provider.invalidateOwner(this.owner);
63
+ throw new BrowserError('BROWSER_CRASHED', 'the browser crashed or disconnected during the operation; the next browser call reopens it with the saved login state. Original error: ' +
64
+ (error.message ?? String(error)));
65
+ }
66
+ throw error;
67
+ })
68
+ .finally(() => this.provider.endOp(this.owner));
69
+ }
70
+ /** Fast actionability pre-check (visible/enabled/editable) before an action. */
71
+ async assertActionable(locator, options = {}) {
72
+ return assertActionable(this, locator, options);
73
+ }
74
+ /** Wait for an in-flight navigation (started by a click) to settle. */
75
+ async settleNavigation(page) {
76
+ await page.waitForLoadState('domcontentloaded', { timeout: this.timeoutMs() }).catch(() => { });
77
+ }
78
+ /** Page-level render stability, bounded — a busy page never stalls the agent. */
79
+ async waitForPageStable(page, timeoutMs = 1200) {
80
+ await page.evaluate(pageStabilityProbe, { frames: 2, timeoutMs }).catch(() => false);
81
+ }
82
+ /** Element-level stability before a pointer/keyboard action, bounded. Returns true when stable. */
83
+ async waitForElementStable(locator) {
84
+ const ok = await locator.evaluate(elementStabilityProbe, { frames: 2, timeoutMs: 1500 }).catch(() => false);
85
+ return ok === true;
86
+ }
87
+ /** Post-action settle: wait out a real navigation, then render-stabilize before snapshotting. */
88
+ async settleAndSnapshot(page, navChanged) {
89
+ return settleAndSnapshot(this, page, navChanged);
90
+ }
91
+ /**
92
+ * Snapshot substitute while a native dialog blocks the page: only locally
93
+ * cached data (page.url() needs no round trip) — page.evaluate() and
94
+ * page.title() would both stall behind the dialog.
95
+ */
96
+ blockedSnapshot(page, pending) {
97
+ return {
98
+ url: page.url(),
99
+ title: '',
100
+ nodes: [],
101
+ totalRefs: 0,
102
+ truncated: false,
103
+ dialogNote: this.provider.dialogNoteFor(pending),
104
+ };
105
+ }
106
+ /**
107
+ * Run one page action that may raise a native dialog. Such an action never
108
+ * resolves while the dialog is up, so race it against the dialog appearing
109
+ * and hand the caller the pending state instead (Playwright-MCP pattern).
110
+ */
111
+ async runAction(page, action) {
112
+ return runAction(this, page, action);
113
+ }
114
+ /**
115
+ * Answer the pending native dialog and let the action blocked behind it
116
+ * finish. This is the only place a dialog is ever accepted or dismissed.
117
+ */
118
+ async resolveDialog(accept, promptText, signal) {
119
+ return this.run(async () => {
120
+ this.assertLive();
121
+ if (typeof accept !== 'boolean') {
122
+ throw new BrowserError('PARAM_MISSING', 'browser_dialog needs accept: true (confirm) or false (cancel)');
123
+ }
124
+ const page = await this.withDialogGuardLifted(() => this.ensurePage());
125
+ // Atomic take: two concurrent answers cannot both resolve one dialog.
126
+ const pending = this.provider.takePendingDialog(page, this.owner);
127
+ if (pending === undefined) {
128
+ throw new BrowserError('NO_DIALOG', 'no native dialog is pending on this page: there is nothing to accept or dismiss');
129
+ }
130
+ const urlBefore = page.url();
131
+ try {
132
+ await withAbort(accept === true
133
+ ? promptText !== undefined
134
+ ? pending.dialog.accept(promptText)
135
+ : pending.dialog.accept()
136
+ : pending.dialog.dismiss(), signal);
137
+ }
138
+ catch (error) {
139
+ if (isAbortError(error))
140
+ throw error;
141
+ throw asTimeoutError('action', error);
142
+ }
143
+ const snap = await this.settleAndSnapshot(page, page.url() !== urlBefore);
144
+ snap.landingNote =
145
+ '[dialog] ' +
146
+ pending.type +
147
+ ' "' +
148
+ pending.message.replace(/\s+/g, ' ').trim().slice(0, 300) +
149
+ '" was ' +
150
+ (accept === true ? 'ACCEPTED' : 'DISMISSED') +
151
+ ' on request; the action blocked behind it has now resumed.';
152
+ return snap;
153
+ });
154
+ }
155
+ /** Read the snapshot tree from one page (must run inside a run()). */
156
+ async capture(page, opts) {
157
+ this.provider.armDialogGuard(page);
158
+ const raw = resolveSnapshotEngine(this.provider.config.snapshot.engine) === 'aria'
159
+ ? await this.captureAria(page, opts)
160
+ : await this.captureLegacy(page, opts);
161
+ return this.attachDiff(raw);
162
+ }
163
+ /**
164
+ * Semantic-tree capture via Playwright's official ariaSnapshot(mode:'ai').
165
+ * An unavailable or unreadable aria tree (`missing`) degrades to the legacy
166
+ * DOM walker instead of returning an empty snapshot: the model must never
167
+ * receive "the page has no elements" because the engine failed to parse.
168
+ */
169
+ async captureAria(page, opts) {
170
+ return captureAria(this, page, opts);
171
+ }
172
+ /** Legacy capture: injected DOM walker (default engine, byte-identical output). */
173
+ async captureLegacy(page, opts) {
174
+ return captureLegacy(this, page, opts);
175
+ }
176
+ /**
177
+ * Attach an incremental diff when snapshot.diff is enabled. Absence of the
178
+ * diff field means "full snapshot follows": first capture of the session,
179
+ * navigation reset, or a truncated tree. The diff is keyed by the stable
180
+ * data-dsh-ref, so legacy and aria engines share the same contract.
181
+ */
182
+ attachDiff(snap) {
183
+ return attachDiff(this, snap);
184
+ }
185
+ /** Build ref -> feature signature map from a captured tree, with parent refs. */
186
+ buildRefSignatures(nodes) {
187
+ return buildRefSignatures(this, nodes);
188
+ }
189
+ /** The document nonce shared by this snapshot's refs, or undefined. */
190
+ refNonce(nodes) {
191
+ for (const node of nodes) {
192
+ if (node.ref !== undefined && node.ref.length >= 10)
193
+ return node.ref.slice(1, 10);
194
+ const nested = this.refNonce(node.children ?? []);
195
+ if (nested !== undefined)
196
+ return nested;
197
+ }
198
+ return undefined;
199
+ }
200
+ async navigate(url, waitUntil, signal) {
201
+ return navigate(this, url, waitUntil, signal);
202
+ }
203
+ async snapshot(opts) {
204
+ return snapshot(this, opts);
205
+ }
206
+ /**
207
+ * Codex-style click-point resolution. Tries a scroll-alignment ladder
208
+ * (centre, then end, then start). For each alignment: scroll the element
209
+ * into view with a native scrollIntoView, wait for its bounding rect to
210
+ * stop moving, sample the rect centre in-page (sub-pixel, no screenshot
211
+ * measurement), then hit-test that exact point with elementFromPoint — so
212
+ * an overlaying element can never swallow the click silently. Returns the
213
+ * first unobstructed {x, y}, or throws describing what intercepted it.
214
+ */
215
+ async resolveClickPoint(locator, signal) {
216
+ return resolveClickPoint(this, locator, signal);
217
+ }
218
+ async click(ref, signal) {
219
+ return this.run(async () => {
220
+ const locator = await this.refLocator(ref);
221
+ return this.clickLocator(locator, signal, 'ref ' + ref, 0);
222
+ });
223
+ }
224
+ /**
225
+ * Click an element the snapshot gave no ref, addressed by its visible label
226
+ * (e.g. a menu item revealed by browser_hover that is plain text). The
227
+ * label must equal an element's own direct text; the deepest match wins,
228
+ * and the landing note names what was actually clicked plus how many
229
+ * elements shared the label.
230
+ */
231
+ async clickText(text, signal) {
232
+ return clickText(this, text, signal);
233
+ }
234
+ /**
235
+ * Enforce the navigation policy for the link a click is about to follow.
236
+ * Click navigation does not run through navigate(), so this is the only place
237
+ * the rule lives and every click path (ref, text, geometric ref, raw x/y)
238
+ * calls it — a new path cannot quietly skip the allow-list.
239
+ *
240
+ * The policy is about which HOSTS this browser may visit, so it applies to
241
+ * http(s) destinations. An in-page `javascript:` link runs the page's own
242
+ * code without navigating anywhere and is allowed (refusing it made ordinary
243
+ * JS links unclickable); every other scheme — file:, data:, mailto:, tel:,
244
+ * custom ones — stays refused.
245
+ */
246
+ assertClickTargetAllowed(href) {
247
+ if (href === null || href === '')
248
+ return;
249
+ let target;
250
+ try {
251
+ target = new URL(href, this.currentPageSync().url());
252
+ }
253
+ catch {
254
+ throw new BrowserError('URL_NOT_ALLOWED', 'invalid link target: ' + href);
255
+ }
256
+ if (target.protocol === 'javascript:')
257
+ return;
258
+ assertAllowedUrl(target.toString(), this.provider.config.allowedDomains ?? []);
259
+ }
260
+ /**
261
+ * Enforce the host policy on a tab before this session starts driving it. A
262
+ * page can open a tab by itself (`target="_blank"`, `window.open`) and the
263
+ * plugin never sees that navigation, so the policy is applied at the point of
264
+ * use instead: a tab outside the allow-list stays open — the user may well be
265
+ * using it — but this agent refuses to switch to it.
266
+ *
267
+ * http(s) is checked against allowedDomains; `about:blank` is allowed because
268
+ * it is the plugin's own interim state and has no host; every other scheme
269
+ * (file:, data:, chrome:, devtools:) is refused outright.
270
+ */
271
+ assertTabAllowed(page) {
272
+ const raw = page.url();
273
+ if (raw === '')
274
+ return;
275
+ let target;
276
+ try {
277
+ target = new URL(raw);
278
+ }
279
+ catch {
280
+ return;
281
+ }
282
+ if (target.protocol === 'about:')
283
+ return;
284
+ assertAllowedUrl(target.toString(), this.provider.config.allowedDomains ?? []);
285
+ }
286
+ /**
287
+ * Shared click core: enforce the URL policy for link targets, run the
288
+ * Codex-style geometry ladder with the Playwright actionability fallback,
289
+ * then report what actually received the click. The label names the target
290
+ * in the landing note; shared is how many elements matched it (0 for refs).
291
+ */
292
+ async clickLocator(locator, signal, label, shared) {
293
+ return clickLocator(this, locator, signal, label, shared);
294
+ }
295
+ async clickAt(x, y, signal) {
296
+ return clickAt(this, x, y, signal);
297
+ }
298
+ async clickAtRef(ref, signal) {
299
+ return clickAtRef(this, ref, signal);
300
+ }
301
+ async fill(ref, text, signal) {
302
+ return fill(this, ref, text, signal);
303
+ }
304
+ /** Read the field back and decide whether the fill landed. */
305
+ async verifyFill(locator, tag, text) {
306
+ return verifyFill(this, locator, tag, text);
307
+ }
308
+ async press(ref, key, signal) {
309
+ return press(this, ref, key, signal);
310
+ }
311
+ /**
312
+ * Hover the referenced element: rest the pointer on it so hover-revealed
313
+ * content appears (CSS :hover rules, JS mouseenter handlers, submenus,
314
+ * tooltips, chart values, player controls). Hover never activates
315
+ * anything: it only reveals. The ref count before and after the pointer
316
+ * moves is the only cheap proof that something appeared, so the returned
317
+ * snapshot carries a note comparing them.
318
+ */
319
+ async hover(ref, signal) {
320
+ return this.run(async () => {
321
+ const locator = await this.refLocator(ref);
322
+ return this.hoverLocator(locator, signal, 'ref ' + ref, 0);
323
+ });
324
+ }
325
+ /**
326
+ * Hover a trigger the snapshot gave no ref, addressed by its visible label
327
+ * (e.g. a plain span that reveals a menu on mouseenter). The label must
328
+ * equal an element's own direct text; the deepest match wins, and the
329
+ * landing note names what was hovered plus how many elements shared it.
330
+ */
331
+ async hoverText(text, signal) {
332
+ return hoverText(this, text, signal);
333
+ }
334
+ /**
335
+ * Shared hover core: rest the pointer on a resolved locator, then report
336
+ * what the actionable ref count did. The label names the target in the
337
+ * landing note; shared is how many elements matched that label (0 when the
338
+ * target came from a ref).
339
+ */
340
+ async hoverLocator(locator, signal, label, shared) {
341
+ return hoverLocator(this, locator, signal, label, shared);
342
+ }
343
+ async scroll(direction, amount, ref, signal) {
344
+ return scroll(this, direction, amount, ref, signal);
345
+ }
346
+ async back(signal) {
347
+ return back(this, signal);
348
+ }
349
+ async forward(signal) {
350
+ return forward(this, signal);
351
+ }
352
+ async wait(ms, signal) {
353
+ return this.run(async () => {
354
+ this.provider.touch(this.owner);
355
+ await withAbort(new Promise((resolve) => setTimeout(resolve, ms)), signal);
356
+ });
357
+ }
358
+ async tabs() {
359
+ return this.run(async () => {
360
+ this.assertLive();
361
+ this.provider.touch(this.owner);
362
+ const pages = this.context.pages();
363
+ // The tab the other tools act on, so the model never has to guess.
364
+ const activeIndex = Math.min(this.currentIndex, Math.max(0, pages.length - 1));
365
+ const out = [];
366
+ for (let index = 0; index < pages.length; index += 1) {
367
+ const page = pages[index];
368
+ if (page === undefined)
369
+ continue;
370
+ this.guardBlockedDialog(page);
371
+ out.push({ index, url: page.url(), title: await page.title(), active: index === activeIndex });
372
+ }
373
+ return out;
374
+ });
375
+ }
376
+ async switchTab(index, signal) {
377
+ return switchTab(this, index, signal);
378
+ }
379
+ async openTab(url, waitUntil, signal) {
380
+ return openTab(this, url, waitUntil, signal);
381
+ }
382
+ async closeTab(index, signal) {
383
+ return closeTab(this, index, signal);
384
+ }
385
+ /**
386
+ * Screenshots get their own floor instead of reusing the navigation budget: a
387
+ * full-page capture is sized by the page and encoded by the CPU, so a loaded machine
388
+ * legitimately needs seconds (2026-10-05: the Windows runner blew through the then-2500ms
389
+ * navigation-timeout budget while encoding a full-page PNG — a slow runner, not a defect).
390
+ * The floor only bounds failures; a capture that is ready returns immediately.
391
+ */
392
+ screenshotTimeoutMs() {
393
+ return Math.max(this.timeoutMs(), 10_000);
394
+ }
395
+ async screenshot(opts, signal) {
396
+ return this.run(async () => {
397
+ this.assertLive();
398
+ this.provider.touch(this.owner);
399
+ const page = await this.ensurePage();
400
+ if (opts?.ref !== undefined) {
401
+ const bytes = await withAbort((await this.refLocator(opts.ref)).screenshot({ type: 'png', timeout: this.screenshotTimeoutMs() }), signal);
402
+ return { mime: 'image/png', bytes };
403
+ }
404
+ const bytes = await withAbort(page.screenshot({
405
+ type: 'png',
406
+ fullPage: opts?.fullPage ?? false,
407
+ animations: 'disabled',
408
+ caret: 'hide',
409
+ timeout: this.screenshotTimeoutMs(),
410
+ }), signal);
411
+ return { mime: 'image/png', bytes };
412
+ });
413
+ }
414
+ async pageData() {
415
+ return pageData(this);
416
+ }
417
+ async evaluate(expression, signal) {
418
+ return this.run(async () => {
419
+ this.assertLive();
420
+ this.provider.touch(this.owner);
421
+ const page = await this.ensurePage();
422
+ const fn = new Function('return (' + expression + ')');
423
+ try {
424
+ return await withAbort(page.evaluate(fn), signal);
425
+ }
426
+ catch (error) {
427
+ if (!isContextDestroyed(error) || signal?.aborted === true)
428
+ throw error;
429
+ await this.settleNavigation(page);
430
+ return withAbort(page.evaluate(fn), signal);
431
+ }
432
+ });
433
+ }
434
+ /**
435
+ * Send one raw CDP command on the current page's session and return its
436
+ * result. This is the "direct protocol" escape hatch behind every other
437
+ * tool — the model can inspect geometry, dispatch trusted input events or
438
+ * query the DOM the same way Chrome DevTools does. Policy (which methods
439
+ * are allowed) lives in the tool layer via lib/cdp-policy.js; this method
440
+ * itself performs no filtering so internal callers keep full control.
441
+ */
442
+ async cdp(method, params, signal) {
443
+ return this.run(async () => {
444
+ this.assertLive();
445
+ this.provider.touch(this.owner);
446
+ const page = await this.ensurePage();
447
+ // Reuse the cached CDP session when still on the same page: DOM
448
+ // nodeIds and execution contexts are session-bound, so creating a
449
+ // fresh session per call would invalidate ids from earlier calls.
450
+ if (this.cdpSession === undefined || this.cdpSession === null || this.cdpPage !== page) {
451
+ await this.cdpSession?.detach().catch(() => { });
452
+ this.cdpSession = await this.context.newCDPSession(page);
453
+ this.cdpPage = page;
454
+ }
455
+ try {
456
+ // CDPSession.send is a prototype method: it needs its receiver, so bind it
457
+ // before handing it to withAbort. An unbound reference dies on `this._channel`
458
+ // inside the driver, and the failure gets reported as a protocol error.
459
+ // CDPSession.send is a prototype method: it needs its receiver, so bind it
460
+ // before handing it to withAbort. An unbound reference dies on `this._channel`
461
+ // inside the driver, and the failure gets reported as a protocol error.
462
+ const session = this.cdpSession;
463
+ const send = session.send.bind(session);
464
+ return await withAbort(send(method, params ?? {}), signal);
465
+ }
466
+ catch (error) {
467
+ if (isAbortError(error))
468
+ throw error;
469
+ // CDP protocol errors are already structured ("Protocol error
470
+ // (DOM.foo): ..."); wrap non-abort failures in a stable code.
471
+ const detail = error instanceof Error ? error.message : String(error);
472
+ if (/Protocol error/.test(detail)) {
473
+ const m = /\(([^)]+)\): ([\s\S]+)/.exec(detail);
474
+ throw new BrowserError('CDP_ERROR', m
475
+ ? 'CDP command ' + (m[1] ?? '') + ' failed: ' + (m[2] ?? '').trim().slice(0, 400)
476
+ : 'CDP command failed: ' + detail.slice(0, 400));
477
+ }
478
+ throw new BrowserError('CDP_ERROR', 'CDP command ' + method + ' failed: ' + detail.slice(0, 400));
479
+ }
480
+ });
481
+ }
482
+ async close() {
483
+ if (this.closed)
484
+ return;
485
+ this.closed = true;
486
+ await this.cdpSession?.detach().catch(() => { });
487
+ this.cdpSession = undefined;
488
+ await this.provider.disposeOwner(this.owner);
489
+ }
490
+ timeoutMs() {
491
+ return this.provider.config.launch.navigationTimeoutMs;
492
+ }
493
+ async ensurePage() {
494
+ this.assertLive();
495
+ const pages = this.context.pages();
496
+ if (pages.length === 0) {
497
+ const created = await this.context.newPage();
498
+ this.currentIndex = 0;
499
+ this.provider.armDialogGuard(created);
500
+ this.guardBlockedDialog(created);
501
+ this.trackPage(created);
502
+ return created;
503
+ }
504
+ this.currentIndex = Math.min(this.currentIndex, pages.length - 1);
505
+ const page = pages[this.currentIndex] ?? pages[0];
506
+ if (page === undefined) {
507
+ const created = await this.context.newPage();
508
+ this.currentIndex = 0;
509
+ this.provider.armDialogGuard(created);
510
+ this.guardBlockedDialog(created);
511
+ this.trackPage(created);
512
+ return created;
513
+ }
514
+ this.provider.armDialogGuard(page);
515
+ this.guardBlockedDialog(page);
516
+ this.trackPage(page);
517
+ return page;
518
+ }
519
+ /**
520
+ * Wire bounded console/network capture onto one page, exactly once. Capture is
521
+ * best-effort observation for the model: it must never throw into a page that is
522
+ * otherwise healthy, and it must never grow without a bound.
523
+ */
524
+ trackPage(page) {
525
+ return trackPage(this, page);
526
+ }
527
+ /** Stable id for one page of this session (used to scope diagnostics). */
528
+ pageIdFor(page) {
529
+ const existing = this.pageIds.get(page);
530
+ if (existing !== undefined)
531
+ return existing;
532
+ this.pageIdSeq += 1;
533
+ this.pageIds.set(page, this.pageIdSeq);
534
+ return this.pageIdSeq;
535
+ }
536
+ /** Id of the page this session is driving, or undefined when there is none. */
537
+ currentPageId() {
538
+ try {
539
+ const page = this.pageAt(this.currentIndex);
540
+ return page === undefined ? undefined : this.pageIdFor(page);
541
+ }
542
+ catch {
543
+ return undefined;
544
+ }
545
+ }
546
+ /**
547
+ * Split diagnostics into "the page this session is driving" and "everything
548
+ * else". The rings keep every page's entries so chronology and eviction stay
549
+ * honest, but a reader that promises one tab must not serve another tab's
550
+ * lines: that is how a model diagnoses an error that belongs to a page it is
551
+ * not even looking at.
552
+ */
553
+ scopeToCurrentPage(entries) {
554
+ const currentId = this.currentPageId();
555
+ if (currentId === undefined)
556
+ return { kept: entries.slice(), otherPages: 0 };
557
+ const kept = [];
558
+ let otherPages = 0;
559
+ for (const entry of entries) {
560
+ if (entry.page === currentId)
561
+ kept.push(entry);
562
+ else
563
+ otherPages += 1;
564
+ }
565
+ return { kept, otherPages };
566
+ }
567
+ /** URL of the tab this session is driving right now, or null. */
568
+ currentUrl() {
569
+ try {
570
+ return this.pageAt(this.currentIndex)?.url() ?? null;
571
+ }
572
+ catch {
573
+ return null;
574
+ }
575
+ }
576
+ /** Recent console messages and uncaught page errors, newest last. */
577
+ consoleMessages(args) {
578
+ return consoleMessages(this, args);
579
+ }
580
+ /** Recent network requests, newest last, with status and duration where known. */
581
+ networkRequests(args) {
582
+ return networkRequests(this, args);
583
+ }
584
+ /**
585
+ * Refuse page access while a native dialog blocks the page: every call
586
+ * would otherwise stall until the action timeout. Only browser_dialog and
587
+ * the blocked-snapshot report are allowed through (DIALOG-POLICY.md).
588
+ */
589
+ guardBlockedDialog(page) {
590
+ if (this.bypassDialogGuard === true)
591
+ return;
592
+ const pending = this.provider.pendingDialogFor(page);
593
+ if (pending !== undefined)
594
+ throw new BrowserError('DIALOG_PENDING', this.provider.dialogNoteFor(pending));
595
+ }
596
+ /** Refuse context-wide tab work while any page in it has a parked dialog. */
597
+ guardBlockedContext() {
598
+ if (this.bypassDialogGuard === true)
599
+ return;
600
+ for (const page of this.context.pages()) {
601
+ const pending = this.provider.pendingDialogFor(page);
602
+ if (pending !== undefined)
603
+ throw new BrowserError('DIALOG_PENDING', this.provider.dialogNoteFor(pending));
604
+ }
605
+ }
606
+ /** Run one internal step with the dialog guard lifted. */
607
+ async withDialogGuardLifted(fn) {
608
+ this.bypassDialogGuard = true;
609
+ try {
610
+ return await fn();
611
+ }
612
+ finally {
613
+ this.bypassDialogGuard = false;
614
+ }
615
+ }
616
+ pageAt(index) {
617
+ return this.context.pages()[index];
618
+ }
619
+ async refLocator(ref) {
620
+ return refLocator(this, ref);
621
+ }
622
+ currentPageSync() {
623
+ const pages = this.context.pages();
624
+ const page = pages[Math.min(this.currentIndex, Math.max(0, pages.length - 1))];
625
+ if (page === undefined)
626
+ throw new BrowserError('SESSION_CLOSED', 'the browser session has no open page');
627
+ this.guardBlockedDialog(page);
628
+ return page;
629
+ }
630
+ assertLive() {
631
+ return assertLive(this);
632
+ }
633
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 把工具的返回值渲染成模型可见的内容:快照树、标签列表、截图(含附件)、诊断摘要。
3
+ * @module dsh-browser-playwright-codex/tool-render
4
+ */
5
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm';
6
+ import type { BrowserSnapshot, DiagnosticsValue } from './types.ts';
7
+ import type { SnapshotValue, ScreenshotValue, TabsValue } from './tool-types.ts';
8
+ /**
9
+ * Render one diagnostics payload as one line per entry. Diagnostics are read when
10
+ * something already looks wrong, so the compact form matters more than the rich
11
+ * one: the model needs the level, the message and the source, not a JSON tree.
12
+ */
13
+ export declare function renderDiagnostics(kind: string, value: DiagnosticsValue): string;
14
+ /** Project a provider snapshot into its canonical tool value. */
15
+ export declare function snapshotValue(snapshot: BrowserSnapshot): SnapshotValue;
16
+ /** Render a snapshot value as one text block. */
17
+ export declare function renderSnapshotValue(_args: unknown, value: SnapshotValue): ContentBlock[];
18
+ export declare function renderTabsValue(_args: unknown, value: TabsValue): ContentBlock[];
19
+ /** Render a screenshot as its summary plus the image block itself. */
20
+ export declare function renderScreenshotValue(_args: unknown, value: ScreenshotValue): ContentBlock[];