diff --git a/docs/features/agent.md b/docs/features/agent.md index 012768c08..127d117f1 100644 --- a/docs/features/agent.md +++ b/docs/features/agent.md @@ -550,6 +550,8 @@ Content-scope tools are registered under `server/ai/tools/content/`. They use th The content system prompt is markdown-native: it tells the model to exchange body content as standard markdown, to read schemas before writing unfamiliar fields, and to prefer `content_set_document_fields` for whole-post generation. The prompt is built with the same `[staticPrefix, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, dynamicSuffix]` shape as the Site prompt so provider prompt caching works the same way. +Snapshot frame readiness is event-driven: `waitForAgentRenderFrame` observes canvas mounts and readiness markers, with a 250 ms fallback poll. It retains the five-second deadline and rejects frames that become ready after it, including when background-tab timers are throttled. + ### Heavy evidence — image channel + vision gating + elision `site_render_snapshot` (and `site_read_document` / `site_get_node_html`) return large payloads. Five rules keep them from exploding context (a screenshot inlined as base64 JSON text once pushed a single turn past 1M tokens): diff --git a/src/__tests__/agent/renderFrameWait.test.ts b/src/__tests__/agent/renderFrameWait.test.ts new file mode 100644 index 000000000..db2d8c595 --- /dev/null +++ b/src/__tests__/agent/renderFrameWait.test.ts @@ -0,0 +1,69 @@ +/** + * `waitForAgentRenderFrame` must resolve when the frame's readiness marker + * lands, not on a timer tick. It used to poll every 16ms; in a background tab + * chained timers are throttled (to one per minute in Chrome), which stalled + * MCP render snapshots for minutes. + */ +import { afterEach, describe, expect, it } from 'bun:test' +import { waitForAgentRenderFrame } from '@site/agent' + +function mountFrame(breakpointId: string): HTMLIFrameElement { + const frame = document.createElement('div') + frame.dataset.breakpointId = breakpointId + const iframe = document.createElement('iframe') + frame.appendChild(iframe) + document.body.appendChild(frame) + iframe.contentDocument!.body.dataset.breakpointId = breakpointId + return iframe +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('waitForAgentRenderFrame', () => { + it('resolves on the readiness marker even when timers are throttled', async () => { + // Simulate a background tab: every timer fires at least a second late. + const realSetTimeout = globalThis.setTimeout + globalThis.setTimeout = ((fn: () => void, ms?: number) => + realSetTimeout(fn, Math.max(ms ?? 0, 1_000))) as typeof setTimeout + try { + const iframe = mountFrame('desktop') + const startedAt = performance.now() + const waiting = waitForAgentRenderFrame({ breakpointId: 'desktop', source: 'visible' }, 5_000) + await Promise.resolve() + iframe.dataset.instaticCanvasDocumentLoaded = 'true' + expect(await waiting).not.toBeNull() + expect(performance.now() - startedAt).toBeLessThan(500) + } finally { + globalThis.setTimeout = realSetTimeout + } + }) + + it('resolves when the frame mounts after the wait began', async () => { + const waiting = waitForAgentRenderFrame({ breakpointId: 'tablet', source: 'visible' }, 2_000) + const iframe = mountFrame('tablet') + iframe.dataset.instaticCanvasDocumentLoaded = 'true' + expect(await waiting).not.toBeNull() + }) + + it('returns null at the deadline when the frame never becomes ready', async () => { + mountFrame('mobile') + expect(await waitForAgentRenderFrame({ breakpointId: 'mobile', source: 'visible' }, 60)).toBeNull() + }) +}) + +it('rejects a frame that becomes ready after the deadline', async () => { + const originalNow = Date.now + let now = 0 + Date.now = () => now + try { + const iframe = mountFrame('desktop') + const waiting = waitForAgentRenderFrame({ breakpointId: 'desktop', source: 'visible' }, 50) + now = 100 + iframe.dataset.instaticCanvasDocumentLoaded = 'true' + expect(await waiting).toBeNull() + } finally { + Date.now = originalNow + } +}) diff --git a/src/admin/pages/site/agent/renderEvidence.ts b/src/admin/pages/site/agent/renderEvidence.ts index 5a973ac9c..218afb506 100644 --- a/src/admin/pages/site/agent/renderEvidence.ts +++ b/src/admin/pages/site/agent/renderEvidence.ts @@ -14,7 +14,10 @@ const MAX_COMPUTED_BACKGROUND_IMAGE_LENGTH = 500 const COMPUTED_STYLE_TRUNCATION_SUFFIX = '...[truncated]' const OVERFLOW_TOLERANCE_PX = 2 const FRAME_WAIT_TIMEOUT_MS = 5_000 -const FRAME_WAIT_POLL_MS = 16 +// Fallback only: frame readiness normally arrives through the MutationObserver +// in `waitForAgentRenderFrame`, which background-tab timer throttling (down to +// one chained timer per minute in Chrome) does not delay. +const FRAME_WAIT_FALLBACK_POLL_MS = 250 // Anthropic rejects any image dimension > 8000px outright (400), and internally // downsizes the long edge to ~1568px before the model ever sees it. So we cap @@ -167,18 +170,53 @@ export function findAgentRenderFrame({ return null } -/** Wait for React to mount and commit an exact canvas iframe. */ +/** + * Wait for React to mount and commit an exact canvas iframe. + * + * Event-driven: re-checks whenever the canvas DOM or a readiness marker + * (`data-instatic-canvas-document-loaded`, `data-agent-snapshot-ready`) + * changes. A tight timer poll here stalled MCP render snapshots for minutes in + * a background tab, where chained timers are throttled. + */ export async function waitForAgentRenderFrame( query: AgentRenderFrameQuery, timeoutMs: number = FRAME_WAIT_TIMEOUT_MS, ): Promise { const deadline = Date.now() + timeoutMs - while (Date.now() <= deadline) { - const frame = findAgentRenderFrame({ ...query, requireReady: true }) - if (frame) return frame - await new Promise((resolve) => setTimeout(resolve, FRAME_WAIT_POLL_MS)) + let wake: (() => void) | null = null + const observer = new MutationObserver(() => wake?.()) + observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: [ + 'data-instatic-canvas-document-loaded', + 'data-agent-snapshot-ready', + 'data-breakpoint-id', + 'data-agent-snapshot-breakpoint-id', + 'data-agent-snapshot-request-id', + ], + }) + try { + for (;;) { + if (Date.now() > deadline) return null + const frame = findAgentRenderFrame({ ...query, requireReady: true }) + if (frame) return frame + const remaining = deadline - Date.now() + if (remaining <= 0) return null + await new Promise((resolve) => { + const timer = setTimeout(done, Math.min(FRAME_WAIT_FALLBACK_POLL_MS, remaining)) + function done(): void { + clearTimeout(timer) + wake = null + resolve() + } + wake = done + }) + } + } finally { + observer.disconnect() } - return null } function isAgentRenderFrameReady(frame: HTMLElement, requestId?: string): boolean {