Skip to content
Merged
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
7 changes: 7 additions & 0 deletions plugins/playgrounds/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,13 @@ export const CONTROLS_RUNTIME = String.raw`
function define(name, spec) {
if (!/^[A-Za-z][\w-]*$/.test(name)) throw new Error("Parameter names are letters, digits, - and _: " + name);
const s = { label: name, ...spec };
// Programmatic declarations commonly use JavaScript type names and bare
// choices. Normalize them to the same schema as data-pg controls before
// validating values, so a valid choice cannot silently reset to default.
if (s.type === "string") s.type = "text";
if (s.type === "boolean") s.type = "toggle";
if (Array.isArray(s.options)) s.options = s.options.map((option) =>
typeof option === "string" || typeof option === "number" ? { value: option, label: String(option) } : option);
if (s.type === "grid") s.default = s.default || Array.from({ length: s.rows }, () => Array(s.steps).fill(false));
else if (s.type === "board") s.default = Array.isArray(s.default) ? s.default : Array.from({ length: s.rows }, () => Array(s.cols).fill(""));
else if (s.type === "text") s.default = s.default === undefined ? "" : String(s.default);
Expand Down
13 changes: 10 additions & 3 deletions plugins/playgrounds/evals/launch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ and the actual dev UI. Infrastructure failures here abort without counting
forty spurious product failures. The tiny preflight fixture is not an eval prompt
and is never given to an agent. Generic fixtures also verify live pointer,
click-only and keyboard-only surfaces, output changes while a page resizes, and
geometry inside the tested drawing. Matching inert fixtures change only
geometry inside the tested drawing, and independent `role="status"` readouts. Matching inert fixtures change only
hover/focus/color/padding; they must stay unproven.
Mixed stable/ticking readouts, selected sibling choices, and a runtime exception
with an otherwise working control are covered too. Saved-card replay scripts can
Expand All @@ -73,8 +73,15 @@ non-default model for Personal, reset the **isolated instance** before measuring

- Time starts immediately before the HTTP prompt send. The real thread page is
opened in Chrome. A loaded iframe or native card must be on screen, with a
nonblank screenshot. A tool return or stored HTML alone never counts. The
observer polls at 150 ms; loading and browser startup stay in the measurement.
nonblank screenshot of its exposed interior. The capture keeps the same DOM
node, source, bounds, scroll position and layout version before and after the
screenshot; a replaced/moved frame is retried, never replaced by a body capture.
Surrounding thread text and host overlays cannot supply the required pixels.
Time ends at accepted screenshot completion, an observed upper bound. The
iframe load time is retained separately. A tool return or stored HTML alone
never counts; loading and browser startup stay in the measurement. Generic
preflight cases cover blank frames, delayed paint, replacement and scrolling.
`visible-capture.json` records every attempt and rejection.
- Progress evidence contains assistant text observed in visible message columns
before the card, matched to real timeline rows. Tool logs are not progress.
- Question evidence includes structured `user_question` interactions and assistant
Expand Down
2 changes: 1 addition & 1 deletion plugins/playgrounds/evals/launch/controls.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function inventory(scope = 'body') {
export function captureEvidence(scope,probeId,frameIndex) {
const root=document.querySelector(scope),rootRect=root.getBoundingClientRect();
const ordinary='button,input,select,textarea,label,[role="button"],[role="switch"],[role="checkbox"],[role="radio"],[role="tab"]';
const candidates=[...root.querySelectorAll('svg,canvas,output,[data-readout],[aria-live],.pg-sentence,.pg-readouts dd,p,dd,td')]
const candidates=[...root.querySelectorAll('svg,canvas,output,[data-readout],[aria-live],[role="status"],.pg-sentence,.pg-readouts dd,p,dd,td')]
.filter(el => !el.closest(ordinary) && !el.parentElement?.closest('svg,canvas'));
// Prefer the outer named output if nested selectors describe the same text.
const outputs=candidates.filter(el => !candidates.some(parent => parent!==el && parent.contains(el)));
Expand Down
2 changes: 2 additions & 0 deletions plugins/playgrounds/evals/launch/fixtures.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Generic harness checks, never supplied to an agent or copied into a skill.
export const proofFixtures = [
{name:'status-readout',expectedPass:true,html:`<!doctype html><body style="margin:16px;width:300px;background:white;color:black"><button>Change output</button><div role="status" style="font-size:36px">Initial result</div><script>document.querySelector('button').onclick=()=>document.querySelector('[role=status]').textContent='Changed result'</script>`},
{name:'status-cosmetic-only',expectedPass:false,html:`<!doctype html><body style="margin:16px;width:300px;background:white;color:black"><button>Change style</button><div role="status" style="font-size:36px">Unchanged result</div><script>document.querySelector('button').onclick=()=>document.querySelector('[role=status]').style.background='red'</script>`},
{name:'native-disclosure',expectedPass:true,html:`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><body style="margin:16px;width:300px;background:white;color:black"><details><summary>Show explanation</summary><p>Additional explanation.</p></details><output style="display:block;font-size:36px">Explanation hidden</output><script>const details=document.querySelector('details');details.ontoggle=()=>{document.querySelector('output').textContent=details.open?'Explanation visible':'Explanation hidden'}</script>`},
{name:'caught-error-after-output',expectedPass:false,expectedStatus:'runtime-error',html:`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><body style="margin:16px;width:300px;background:white;color:black"><button>Change output</button><output style="display:block;font-size:42px">Initial result</output><script>document.querySelector('button').onclick=()=>{try{document.querySelector('output').textContent='Changed result';throw new Error('Fixture subscriber failed after partial render')}catch(error){console.error(error)}}</script>`},
{name:'runtime-error-with-working-control',expectedPass:false,expectedStatus:'runtime-error',html:`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><body style="margin:16px;width:300px;background:white;color:black"><button>Change output</button><output style="display:block;font-size:42px">Initial result</output><script>document.querySelector('button').onclick=()=>{document.querySelector('output').textContent='Changed result'};throw new Error('Fixture initialization failed after registering one handler')</script>`},
Expand Down
40 changes: 16 additions & 24 deletions plugins/playgrounds/evals/launch/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { checkControls } from './controls.mjs';
import { hasInk } from './pixels.mjs';
import { captureVisible, discoverCard } from './visibility-capture.mjs';
import { visibilityFixtures } from './visibility-fixtures.mjs';
import { failureScore, providerSummary, resumeMismatch } from './metrics.mjs';
import { renderWindow } from './observations.mjs';
import { proofFixtures } from './fixtures.mjs';
Expand Down Expand Up @@ -44,7 +46,7 @@ const manifestPath = join(out, 'manifest.json');
const commit = args.commit || (() => { try { return execFileSync('git', ['-C', pluginRoot, 'rev-parse', 'HEAD'], {encoding:'utf8'}).trim(); } catch { return 'unverified'; } })();
const manifest = { label: args.label, url: base.origin, host: args.host, pluginRoot, commit, capMs, renderGraceMs: 30_000,
prompts, providers, modelSelection: 'omitted: default for each provider in fresh Personal project',
concurrency: 1, timing: 'HTTP prompt send until loaded card is visible in real thread DOM; polling resolution 150 ms, screenshot corroboration',
concurrency: 1, timing: 'HTTP prompt send until accepted screenshot of a loaded card in the real thread; stable DOM identity, layout and visible interior; screenshot completion is the observed upper bound',
createdAt: new Date().toISOString() };
if (existsSync(manifestPath)) {
const old = JSON.parse(readFileSync(manifestPath, 'utf8'));
Expand Down Expand Up @@ -83,27 +85,17 @@ function questionEvidence(timeline, interactions) {
return { structured, textQuestions, count: structured.length + textQuestions.filter(q => q.likely).length,
needsReview: textQuestions.some(q => !q.likely) };
}
function monitor(threadId, capAt, renderWindow) {
const visible = el => {
const r = el.getBoundingClientRect();
return r.width > 80 && r.height > 40 && r.bottom > 0 && r.top < innerHeight && getComputedStyle(el).visibility !== 'hidden';
};
const loaded = new WeakSet();
window.__launch = { proof: null, progress: [], errors: [], done: false, agentCompletedAt: null, terminalObservedAt: null, completionSource: null };
document.addEventListener('load', event => { if (event.target?.tagName === 'IFRAME' && event.target.src) loaded.add(event.target); }, true);
function monitor(threadId, capAt, renderWindow, discoverCard) {
if (window.top !== window) return;
const loaded = new WeakMap(), identities = {elements:new WeakMap(),next:0};
window.__launch = { proof: null, candidate: null, progress: [], errors: [], done: false, agentCompletedAt: null, terminalObservedAt: null, completionSource: null };
document.addEventListener('load', event => { if (event.target?.tagName === 'IFRAME' && event.target.src) loaded.set(event.target, Date.now()); }, true);
setInterval(() => {
const state = window.__launch;
const windowState = renderWindow({now:Date.now(),capAt,agentCompletedAt:state.agentCompletedAt,visibleAt:state.proof?.at ?? null});
state.done = windowState.done;
if (state.done) return;
const frame = document.querySelector('.pg-host .pg-widget iframe');
const card = frame || document.querySelector('.pg-host .pg-answer:not([role="status"]):not(.pg-error)');
if (!card) return;
card.scrollIntoView({block:'center'});
if (!visible(card) || (frame && !loaded.has(frame))) return;
card.dataset.launchCard = 'visible';
window.__launch.proof = { at: Date.now(), kind: frame ? 'html' : 'document', target: frame?.src || location.href,
width: card.getBoundingClientRect().width, height: card.getBoundingClientRect().height };
state.candidate = discoverCard(loaded, identities);
}, 150);
const flatten = rows => rows.flatMap(row => [row, ...flatten(row.children || row.childRows || [])]);
const normalize = text => text.replace(/[*_`#\[\]]/g, '').replace(/\s+/g, ' ').trim();
Expand Down Expand Up @@ -173,6 +165,7 @@ function summarize() {
const defaultModels = new Map();
const preflightDir = join(out,'preflight',String(Date.now())); mkdirSync(preflightDir,{recursive:true});
save(join(preflightDir,'server-identity.json'), await verifyIsolation(base, args['data-dir'], args.host));
await visibilityFixtures({dir:join(preflightDir,'visibility'),tempRoot:chromeTempDir});
await api('/projects/proj_personal');
const plugins = await api('/plugins'); save(join(preflightDir,'plugins.json'),plugins);
if (!JSON.stringify(plugins).includes('playgrounds')) throw new Error('Preflight: Playgrounds is not installed');
Expand All @@ -193,7 +186,8 @@ for (const axis of ['x','y']) for (const live of [true, false]) {
const dragFixture = join(dragDir, 'fixture.html');
writeFileSync(dragFixture, `<!doctype html><html><body style="margin:16px;width:300px;height:260px;background:white;color:black"><style>[role=slider]{width:280px;height:90px;background:#ddd;cursor:grab}[role=slider]:hover{background:#acf}</style><div role="slider" tabindex="0" aria-label="Position"></div><output style="display:block;font-size:32px">0</output><svg width="280" height="100"><rect width="20" height="90" fill="blue"/></svg><script>const surface=document.querySelector('[role=slider]');let timer;const draw=n=>{document.querySelector('output').textContent=n;document.querySelector('rect').setAttribute('width',20+n*2)};if(${live}){surface.addEventListener('pointermove',e=>{clearTimeout(timer);draw(Math.round(${axis === 'x' ? '(e.clientX-surface.getBoundingClientRect().left)/2.8' : '(e.clientY-surface.getBoundingClientRect().top)/0.9'}))});surface.addEventListener('pointerleave',()=>{timer=setTimeout(()=>draw(0),600)})}</script></body></html>`);
const checked = await checkControls({target:dragFixture,width:480,dir:dragDir,drive});
if (checked.pass !== live || checked.controls.length !== 1 || (!live && checked.controls[0].reason !== 'no proven output change outside controls'))
// Structured verdicts are the contract; diagnostic wording may change.
if (checked.pass !== live || checked.controls.length !== 1 || (!live && (checked.status !== 'unproven' || checked.controls[0].status !== 'unproven' || checked.runtimeErrors.length !== 0)))
throw new Error(`Preflight: drag ${live ? 'live' : 'inert'} regression failed; see ${dragDir}/controls.json`);
}
// A button can change geometry inside a separate interactive drawing. Keep
Expand Down Expand Up @@ -266,12 +260,10 @@ for (const provider of providers) for (const prompt of selected) {
}
if (interrupted) throw new Error('Runner interrupted');
const preload = join(dir,'monitor.js');
writeFileSync(preload, `(${monitor.toString()})(${JSON.stringify(thread.id)},${result.sentAt+capMs},${renderWindow.toString()});`);
const remaining = Math.max(1, capMs - (Date.now()-result.sentAt));
const visible = await drive(new URL(`/threads/${thread.id}`,base).href,
[['until','window.__launch?.proof || window.__launch?.done',remaining], ['eval','window.__launch'], ['shot',join(dir,'visible.png'),'[data-launch-card="visible"]']],
join(dir,'visible'),prompt.width || 1280,{preload,timeout:remaining+20_000});
const measurement = visible.evals.find(v => v && Object.hasOwn(v,'proof'));
writeFileSync(preload, `(${monitor.toString()})(${JSON.stringify(thread.id)},${result.sentAt+capMs},${renderWindow.toString()},${discoverCard.toString()});`);
const visible = await captureVisible({target:new URL(`/threads/${thread.id}`,base).href,
preload,dir,tempRoot:chromeTempDir,capAt:result.sentAt+capMs,width:prompt.width || 1280});
const measurement = visible.measurement;
const proof = measurement?.proof;
result.progress = measurement?.progress || [];
result.browserErrors = measurement?.errors || [];
Expand Down
Loading
Loading