/**
* 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