Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/features/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
69 changes: 69 additions & 0 deletions src/__tests__/agent/renderFrameWait.test.ts
Original file line number Diff line number Diff line change
@@ -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
}
})
52 changes: 45 additions & 7 deletions src/admin/pages/site/agent/renderEvidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<HTMLElement | null> {
const deadline = Date.now() + timeoutMs
while (Date.now() <= deadline) {
const frame = findAgentRenderFrame({ ...query, requireReady: true })
if (frame) return frame
await new Promise<void>((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<void>((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 {
Expand Down
Loading