/** * Agent module for the live-mode E2E test suite. * * Two layers: * * 1. `runAgentLoop(opts)` — the deterministic wrapper around the live-mode * poll/wrap/write/accept protocol. This is identical for fake and real * agents; only the variant-content production step differs. * * 2. `createFakeAgent()` — produces canned variants in the EXACT format * `source/skills/impeccable/reference/live.md` describes: a colocated * ` wraps CSS in a template literal so JSX * doesn't choke on the {} in CSS * - non-default visible variants use style={{display: 'none'}} * - inner element class= becomes className= * - data-impeccable-params stays a single-quoted JSON string (JSX-legal) */ function renderVariantsBlock({ sessionId, indent, output, commentSyntax, file }) { const isJsx = commentSyntax.open === '{/*'; const isSvelte = !!file && file.endsWith('.svelte'); const styleLines = isJsx ? [ indent + ' ', ] : [ indent + ' ', ]; const variantBlocks = output.variants.map((v, i) => { const idx = i + 1; const paramsAttr = v.params && v.params.length ? " data-impeccable-params='" + attrEscape(JSON.stringify(v.params), { svelte: isSvelte }) + "'" : ''; let styleAttr = ''; if (i !== 0) styleAttr = isJsx ? " style={{display: 'none'}}" : ' style="display: none"'; const inner = isJsx ? htmlToJsx(v.innerHtml) : v.innerHtml; return [ indent + ' ' + commentSyntax.open + ' Variant ' + idx + ' ' + commentSyntax.close, indent + '
', indent + ' ' + inner, indent + '
', ].join('\n'); }); return [...styleLines, ...variantBlocks].join('\n'); } /** * Read the wrapped file, find the "insert below this line" marker, splice in * the rendered variants block, write back. */ async function spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId, output }) { const filePath = path.join(tmp, wrapInfo.file); const src = await fs.readFile(filePath, 'utf-8'); const lines = src.split('\n'); // Find the "Variants: insert below this line" comment line — definitive // marker, robust to any indentation off-by-one. Matches in any comment // style (HTML / JSX / Astro). const markerIdx = lines.findIndex((l) => l.includes('Variants: insert below this line'), ); if (markerIdx === -1) { throw new Error('insert marker not found in ' + wrapInfo.file); } const indent = (lines[markerIdx].match(/^\s*/) || [''])[0]; // Indent INSIDE the wrapper is one level shallower (the marker is indented // 2 spaces relative to the wrapper opening). Remove the 2-space comment // indent to get the wrapper indent. const wrapperIndent = indent.replace(/ $/, ''); const block = renderVariantsBlock({ sessionId, indent: wrapperIndent, output, commentSyntax: wrapInfo.commentSyntax, file: wrapInfo.file, }); const next = [ ...lines.slice(0, markerIdx + 1), block, ...lines.slice(markerIdx + 1), ]; await fs.writeFile(filePath, next.join('\n'), 'utf-8'); } // --------------------------------------------------------------------------- // Poll loop — the "agent" runs this until aborted // --------------------------------------------------------------------------- /** * @param {object} opts * @param {string} opts.tmp Project tmp dir (cwd for live-* scripts). * @param {string} opts.scriptsDir Path to the impeccable scripts dir. * @param {number} opts.port live-server port. * @param {string} opts.token live-server token. * @param {VariantAgent} opts.agent * @param {AbortSignal} opts.signal * @param {(msg: string) => void} [opts.log] * @param {object} [opts.wrapTarget] Default target for live-wrap when an * element comes from the picker without an * id we can resolve. e.g. {classes:'hero-title', tag:'h1'}. */ export async function runAgentLoop({ tmp, scriptsDir, port, token, agent, signal, log = () => {}, wrapTarget = { classes: 'hero-title', tag: 'h1' }, }) { const base = `http://127.0.0.1:${port}`; while (!signal.aborted) { let event; try { const res = await fetch(`${base}/poll?token=${token}&timeout=5000`, { signal }); event = await res.json(); } catch (err) { if (signal.aborted) return; log('poll error: ' + err.message); await new Promise((r) => setTimeout(r, 200)); continue; } if (event.type === 'timeout') continue; if (event.type === 'exit') return; if (event.type === 'prefetch') continue; if (event.type === 'connected') continue; if (event.type === 'generate') { log(`generate id=${event.id} action=${event.action} count=${event.count}`); try { // 1. Wrap the original element in the variant scaffold (deterministic CLI) // wrapTarget can be a static {classes, tag, elementId} (test fixtures // know what they pick) or a function (event) => target (real-use // sessions: the agent must derive the selector from the picked // element on the fly). const target = typeof wrapTarget === 'function' ? wrapTarget(event) : wrapTarget; // Pull textContent from the picker event so wrap can disambiguate // when sibling elements share classes/tag (issue #114). Fixtures can // still override by including `text` in their wrapTarget. const text = target.text ?? (event.element?.textContent || '').trim(); const wrapInfo = await runWrap({ tmp, scriptsDir, id: event.id, count: event.count, ...target, text, }); log(`wrapped: ${wrapInfo.file} insertLine=${wrapInfo.insertLine}`); // 2. Agent generates variant content (LLM-pluggable seam) const output = await agent.generateVariants(event, { wrapTarget }); if (output.variants.length !== event.count) { log(`warning: agent returned ${output.variants.length} variants, expected ${event.count}`); } // 3. Splice variants block into the wrapper (deterministic fs) await spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId: event.id, output }); if (process.env.IMPECCABLE_E2E_DEBUG) { const post = await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8'); log(`--- post-splice (variants written) ---\n${post}`); } // 4. Tell the server we're done (broadcasts SSE done → browser settles to CYCLING) await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, type: 'done', id: event.id }), signal, }); } catch (err) { if (signal.aborted) return; log('generate failed: ' + err.message); await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, type: 'error', id: event.id, message: err.message }), signal, }).catch(() => {}); } continue; } if (event.type === 'accept') { log(`accept id=${event.id} variantId=${event.variantId}`); try { const acceptResult = await runAccept({ tmp, scriptsDir, id: event.id, variant: event.variantId, paramValues: event.paramValues, }); // Carbonize cleanup — required after accept per the live skill spec. // For the fake agent, we perform a faithful but minimal cleanup: // delete the carbonize block (markers + dead variants + inline