/** * 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 * `skill/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=, style="..." becomes JSX style={{ ... }} * - data-impeccable-params stays a single-quoted JSON string (JSX-legal) */ function renderVariantsBlock({ sessionId, indent, output, commentSyntax, file, styleMode }) { const isJsx = commentSyntax.open === '{/*'; const isSvelte = !!file && file.endsWith('.svelte'); const isAstroGlobalCss = styleMode === 'astro-global-prefixed'; const styleLines = isJsx ? [ indent + ' ', ] : [ indent + ' ', ...output.scopedCss.split('\n').map((l) => indent + ' ' + l), 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, styleMode: wrapInfo.styleMode, }); const next = [ ...lines.slice(0, markerIdx + 1), block, ...lines.slice(markerIdx + 1), ]; await fs.writeFile(filePath, next.join('\n'), 'utf-8'); } async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output }) { const manifestPath = path.join(tmp, wrapInfo.file); const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8')); const componentDir = path.join(tmp, manifest.componentDir); const isInsert = manifest.mode === 'insert'; const contract = Array.isArray(manifest.propContract) ? manifest.propContract : []; const propNames = contract.map((entry) => entry.prop); const baseMarkup = isInsert ? '' : substituteSvelteExprsWithProps(manifest.originalMarkup || '', contract).trim(); const textValues = isInsert ? [] : extractTextPieces(event.element?.outerHTML || event.element?.textContent || ''); const paramsByVariant = {}; for (let i = 0; i < output.variants.length; i++) { const variantId = i + 1; const variant = output.variants[i]; const tag = firstTagName(variant.innerHtml) || firstTagName(baseMarkup) || 'div'; let markup = substituteLiveTextWithProps(variant.innerHtml || '', contract, textValues).trim(); if (!isInsert && contract.length > 0 && !propNames.some((name) => markup.includes(`{${name}}`))) { markup = mergeTopLevelAttrs(baseMarkup, variant.innerHtml || '') || baseMarkup; } if (isInsert && !variantMarkupHasVisibleContent(markup)) { throw new Error(`Svelte insert variant ${variantId} has no visible content`); } if (isInsert && /\bdata-impeccable-[\w-]*\s*=/.test(markup)) { throw new Error(`Svelte insert variant ${variantId} contains preview-only data-impeccable attributes`); } const css = svelteCssForVariant(output.scopedCss || '', variantId, tag); const component = [ buildSveltePropsScript(contract), '', markup || baseMarkup || '
', '', '', '', ].join('\n'); await fs.writeFile(path.join(componentDir, `v${variantId}.svelte`), component, 'utf-8'); paramsByVariant[String(variantId)] = Array.isArray(variant.params) ? variant.params : []; } await fs.writeFile(path.join(componentDir, 'params.json'), JSON.stringify(paramsByVariant, null, 2) + '\n', 'utf-8'); } function variantMarkupHasVisibleContent(markup) { const text = String(markup || '') .replace(//gi, '') .replace(//gi, '') .replace(//g, '') .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim(); if (text.length > 0) return true; return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || ''); } function buildSveltePropsScript(contract) { if (!contract.length) return ''; return ``; } function substituteSvelteExprsWithProps(markup, contract) { let out = String(markup || ''); for (const entry of contract) { out = out.split(`{${entry.expr}}`).join(`{${entry.prop}}`); } return out; } function substituteLiveTextWithProps(markup, contract, textValues) { let out = String(markup || ''); for (let i = 0; i < contract.length; i++) { const value = textValues[i]; if (!value) continue; out = out.split(htmlEscape(value)).join(`{${contract[i].prop}}`); out = out.split(value).join(`{${contract[i].prop}}`); } return out; } function extractTextPieces(html) { return String(html || '') .replace(//gi, '') .replace(//gi, '') .split(/<[^>]+>/) .map((text) => text.replace(/\s+/g, ' ').trim()) .filter(Boolean); } function firstTagName(markup) { const match = String(markup || '').match(/<([A-Za-z][\w:-]*)\b/); return match ? match[1].toLowerCase() : null; } function mergeTopLevelAttrs(baseMarkup, variantMarkup) { const base = String(baseMarkup || ''); const variant = String(variantMarkup || ''); const baseOpen = base.match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*)(>)/); const variantOpen = variant.match(/^\s*<([A-Za-z][\w:-]*)([^>]*)(>)/); if (!baseOpen || !variantOpen || baseOpen[2].toLowerCase() !== variantOpen[1].toLowerCase()) return base; return base.replace(baseOpen[0], `${baseOpen[1]}${baseOpen[2]}${variantOpen[2]}${baseOpen[4]}`); } function svelteCssForVariant(scopedCss, variantId, tag) { const css = String(scopedCss || ''); const chunks = extractVariantCssChunks(css, variantId); const rewritten = chunks .join('\n') .replace(new RegExp(String.raw`\\[data-impeccable-variant=["']${variantId}["']\\]\\s*>\\s*`, 'g'), '') .replace(new RegExp(String.raw`\\[data-impeccable-variant=["']${variantId}["']\\][^{]*>\\s*`, 'g'), '') .replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '') .replace(/:scope(?:\[[^\]]+\])?/g, tag) .split('\n') .map((line) => line.trimEnd()) .filter((line) => line.trim()) .join('\n') .trim(); return rewritten || `${tag} {}`; } function extractVariantCssChunks(css, variantId) { const lines = String(css || '').split('\n'); const chunks = []; let collecting = false; let depth = 0; for (const line of lines) { if (line.includes(`[data-impeccable-variant="${variantId}"]`) || line.includes(`[data-impeccable-variant='${variantId}']`)) { collecting = true; depth = 0; if (!line.trim().startsWith('@scope')) chunks.push(line); depth += braceDelta(line); if (depth <= 0) collecting = false; continue; } if (!collecting) continue; const before = depth; depth += braceDelta(line); if (before === 1 && depth === 0 && line.trim() === '}') { collecting = false; continue; } chunks.push(line); if (depth <= 0) collecting = false; } return chunks; } function braceDelta(line) { return (line.match(/\{/g) || []).length - (line.match(/\}/g) || []).length; } // --------------------------------------------------------------------------- // 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 {LiveAgent} opts.agent * @param {AbortSignal} opts.signal * @param {(msg: string) => void} [opts.log] * @param {object} [opts.steerSourceFile] Optional relative source path for steer edits. * @param {object} [opts.steerTarget] Optional { classes, tag } for steer target discovery. */ export async function runAgentLoop({ tmp, scriptsDir, port, token, agent, signal, log = () => {}, wrapTarget = { classes: 'hero-title', tag: 'h1' }, steerSourceFile, steerTarget, }) { 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 === 'steer') { log(`steer id=${event.id} message=${JSON.stringify(event.message)}`); try { const target = typeof wrapTarget === 'function' ? wrapTarget(event) : wrapTarget; const steerCtxTarget = steerTarget || target; const steerContext = buildSteerContext({ tmp, event, wrapTarget: steerCtxTarget, sourceFile: steerSourceFile, }); let toast = 'Hero marked'; if (typeof agent.handleSteer === 'function') { const result = await agent.handleSteer(event, steerContext); toast = result?.message || toast; } else { await handleSteerDeterministic(steerContext); } await fetch(`${base}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, type: 'steer_done', id: event.id, message: toast, file: steerContext.targetFile, }), signal, }); } catch (err) { if (signal.aborted) return; log('steer 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 === 'generate') { const isInsert = event.mode === 'insert'; log(`generate id=${event.id} mode=${isInsert ? 'insert' : 'replace'}${isInsert ? '' : ` action=${event.action}`} count=${event.count}`); try { let wrapInfo; if (isInsert) { const insertTarget = insertTargetFromEvent(event); wrapInfo = await runInsert({ tmp, scriptsDir, id: event.id, count: event.count, ...insertTarget, }); } else { // 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(); wrapInfo = await runWrap({ tmp, scriptsDir, id: event.id, count: event.count, ...target, text, }); } log(`scaffolded: ${wrapInfo.file} insertLine=${wrapInfo.insertLine}`); // 2. Agent generates variant content (LLM-pluggable seam) let output = await agent.generateVariants(event, { wrapTarget, wrapInfo }); output = normalizeVariantOutput(output, wrapInfo); if (output.variants.length !== event.count) { log(`warning: agent returned ${output.variants.length} variants, expected ${event.count}`); } // 3. Write variants into the deterministic preview target. if (wrapInfo.previewMode === 'svelte-component') { await writeSvelteComponentVariants({ tmp, wrapInfo, event, output }); } else { 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, file: wrapInfo.file }), 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 === 'manual_edit_apply') { const entryCount = event.batch?.entries?.length || 0; const opCount = (event.batch?.entries || []).reduce((sum, entry) => sum + (entry.ops?.length || 0), 0) || entryCount; const chunkLabel = event.chunk ? ` (chunk ${event.chunk.index}/${event.chunk.total})` : ''; const applyFiles = formatManualApplyFiles(event.batch); log(`Applying ${opCount} staged copy edit(s)${chunkLabel} across ${applyFiles}.`); try { if (typeof agent.applyManualEdits !== 'function') { throw new Error('agent does not implement applyManualEdits'); } log("Using source hints first; I'll only touch the hinted copy."); const result = await agent.applyManualEdits(event, { tmp, scriptsDir }); if (process.env.IMPECCABLE_E2E_DEBUG) { log(`manual_edit_apply result: ${JSON.stringify(result)}`); } await runPollReply({ tmp, scriptsDir, id: event.id, status: 'done', data: result, }); const appliedCount = result.appliedEntryIds?.length || 0; const failedCount = result.failed?.length || Math.max(0, entryCount - appliedCount); if (failedCount > 0) { log(`Applied ${appliedCount}/${entryCount} edit(s); ${failedCount} stayed staged because ${result.failed?.[0]?.reason || 'one or more entries failed'}.`); } else if (event.chunk) { const finalChunk = event.chunk.index === event.chunk.total; log(`Applied ${appliedCount}/${entryCount} entry(s) for chunk ${event.chunk.index}/${event.chunk.total}; ${finalChunk ? 'waiting for server verification.' : 'polling for the next Apply chunk.'}`); } else { log(`Applied ${appliedCount}/${entryCount} edit(s) and cleared the Apply stash.`); } } catch (err) { if (signal.aborted) return; log('manual_edit_apply failed: ' + err.message); const failedEntries = (event.batch?.entries || []).map((entry) => ({ entryId: entry.id, reason: err.message || 'manual_edit_apply_failed', candidates: [], })).filter((item) => item.entryId); await runPollReply({ tmp, scriptsDir, id: event.id, status: 'done', data: { status: 'error', appliedEntryIds: [], failed: failedEntries, files: [], notes: [], message: err.message, }, }).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, pageUrl: event.pageUrl, }); // 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