Files
odysseus/scripts/verify_agent_turn_contract.mjs
T

653 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/** Real 7011 DOM → chat_stream → SSE → persisted history verification.
* node scripts/verify_agent_turn_contract.mjs --families notes --max-turns 4
* node scripts/verify_agent_turn_contract.mjs --max-turns 80 --total-ms 900000
* --base-url http://100.113.161.2:7011 --preflight-only true checks auth/DOM, no chats.
* --families all includes supplemental theme/research/sessions/contacts/browser probes.
* No app imports, fixture seeding, personal auth, cleanup deletes, approvals,
* model launches, or provider configuration writes. New test chats are retained.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(2);
const options = new Map();
for (let i = 0; i < args.length; i += 2) {
if (!args[i].startsWith('--') || !args[i + 1]) throw Error('Options require --name value');
options.set(args[i].slice(2), args[i + 1]);
}
const known = new Set(['families', 'max-turns', 'total-ms', 'turn-ms', 'cookie-file', 'endpoint', 'endpoint-id', 'model', 'report', 'matrix-only', 'email-process', 'base-url', 'preflight-only', 'self-test', 'analyze-report', 'pairs', 'email-metadata-only', 'sample-stream', 'picker-route']);
for (const k of options.keys()) if (!known.has(k)) throw Error(`Unknown option ${k}`);
const opt = (k, fallback) => options.get(k) ?? fallback;
const number = (k, fallback, max) => {
const n = Number(opt(k, fallback));
if (!Number.isInteger(n) || n < 1 || n > max) throw Error(`Invalid ${k}: ${n}`);
return n;
};
const baseURL = new URL(opt('base-url', 'http://127.0.0.1:7011'));
if (baseURL.protocol !== 'http:' || baseURL.port !== '7011' || baseURL.pathname !== '/' || baseURL.search || baseURL.hash || baseURL.username || baseURL.password
|| !/^(?:127\.0\.0\.1|100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3})$/.test(baseURL.hostname)) throw Error('Base URL must be loopback or Tailscale HTTP port 7011');
const base = baseURL.origin;
const preflightOnly = opt('preflight-only', 'false') === 'true';
const emailMetadataOnly = opt('email-metadata-only', 'false') === 'true';
const emailPrompts = ['List my email accounts.', 'Show my email accounts.'];
// Force direct connections for both Playwright's Node HTTP client and Chromium.
// Do not record proxy URLs: they may contain credentials.
const inheritedProxyKeys = Object.keys(process.env).filter(k => /^(https?_proxy|all_proxy|no_proxy)$/i.test(k));
for (const k of inheritedProxyKeys) delete process.env[k];
process.env.NO_PROXY = '*';
process.env.no_proxy = '*';
const owner = 'sft_alex_creator';
const data = '/home/pewds/odysseus-cookbook-fresh/data';
const turnMs = number('turn-ms', 45000, 120000);
const totalMs = number('total-ms', 600000, 1800000);
const maxTurns = number('max-turns', 80, 120);
// Explicit core matrix: shell_files is the tenth family; theme is supplemental.
const families = {
notes: ['List my notes. Return at most three titles.', ['manage_notes']],
calendar: ['List my calendar events. Return at most three titles.', ['manage_calendar']],
email: ['List my email accounts. Return only their names.', ['list_email_accounts']],
tasks: ['List my scheduled tasks. Return at most three names and statuses.', ['manage_tasks']],
documents: ['List my documents. Return at most three titles.', ['manage_documents']],
memory: ['List my saved memories. Return at most three short entries.', ['manage_memory']],
skills: ['List my skills. Return at most three names.', ['manage_skills']],
cookbook: ['List configured Cookbook servers. Return only names and status.', ['list_cookbook_servers']],
search: ['Search the web for GPT-4. Return one official source link.', ['web_search']],
shell_files: ["Use bash to run this read-only command and report its actual marker and hostname output:\n```sh\nprintf '%s\\n' ODY_SHELL_FILES_READONLY; cat /etc/hostname\n```", ['bash']],
theme: ['Open the theme settings panel.', ['ui_control']],
research: ['List my saved research reports. Return at most three titles.', ['manage_research']],
sessions: ['List my chat sessions. Return at most three names.', ['list_sessions']],
contacts: ['List my contacts. Return at most three names.', ['manage_contact']],
notes_search: ['Search my notes for weekly review. Return at most three matching titles.', ['manage_notes']],
browser: ['Open https://example.com in the private browser and report its heading.', ['private_browser']],
typo_notes: ['Show my noes.', ['manage_notes']],
typo_calendar: ["What's my caledar this week?", ['manage_calendar']],
typo_email: ['What emil accounts do I have?', ['list_email_accounts']],
typo_tasks: ['List my scheduled taks.', ['manage_tasks']],
typo_documents: ['List my documnts.', ['manage_documents']],
typo_memory: ['List my saved memo ries.', ['manage_memory']],
typo_skills: ['List my skils.', ['manage_skills']],
typo_cookbook: ['Show cookbok servers.', ['list_cookbook_servers']],
typo_search: ['Seach the web for the official Python packaging guide.', ['web_search']],
typo_shell_files: ['Use bssh to run this read-only command: pwd', ['bash']],
news_followup: ['Latest news in Japan', ['web_search'], 'Tell me more about the flooding?'],
ambiguous_calendar: ['List my calendar events. Return at most three titles and times.', ['manage_calendar'], 'What time was the second one again?'],
ambiguous_notes: ['List my notes. Return at most three titles.', ['manage_notes'], 'Show me the second one again.'],
ambiguous_tasks: ['List my scheduled tasks. Return at most three names and statuses.', ['manage_tasks'], 'What is the status of the second one?'],
ambiguous_documents: ['List my documents. Return at most three titles.', ['manage_documents'], 'Read the second document and summarize it.', ['manage_documents'], ['documents', 'documents']],
ambiguous_skills: ['List my skills. Return at most three names.', ['manage_skills'], 'Show me the second skill.', ['manage_skills'], ['skills', 'skills']],
cookbook_detail: ['List configured Cookbook servers. Return only names and status.', ['list_cookbook_servers'], 'Which one is the default server?'],
email_inbox: ['List my latest three emails.', ['list_emails'], 'Read the second email and summarize it.', ['read_email'], ['email', 'email']],
search_open_result: ['Search the web for the official Python packaging guide. Return one official link.', ['web_search'], 'Open that official result and summarize its main recommendation.', ['web_fetch'], ['search_browser', 'search_browser']],
browser_navigation: ['Open https://example.com in the private browser and report its heading.', ['private_browser'], 'Open the More information link from that page and report the destination heading.', ['private_browser'], ['search_browser', 'search_browser']],
search_ai: ['Latest news in AI?', ['web_search']],
search_quantum: ['Any latest info on quantum physics', ['web_search']],
search_history: ['What year did Ethiopia become independent?', [], 'Can you search'],
search_comparison: ['What country has best meat?', [], 'Can you look up'],
search_to_notes: ['look up news in germany', ['web_search'], 'whats my notes', ['manage_notes'], ['search_browser', 'notes']],
notes_to_search: ['Show my notes. Return at most three titles.', ['manage_notes'], 'seach current stock mraket news', ['web_search'], ['notes', 'search_browser']],
calendar_to_notes: ['List my calendar events.', ['manage_calendar'], 'now show my noes', ['manage_notes'], ['calendar', 'notes']],
email_to_calendar_schedule: ['List my email accounts.', ['list_email_accounts'], 'whats my schedule this week?', ['manage_calendar'], ['email', 'calendar']],
greeting_to_notes: ['hi', [], 'whats my notes', ['manage_notes'], [null, 'notes']],
browser_to_notes: ['Open https://example.com in the private browser and report its heading.', ['private_browser'], 'Now show my notes. Return at most three titles.', ['manage_notes'], ['search_browser', 'notes']],
};
const core = Object.keys(families).slice(0, 10);
const listFamilies = new Set(['notes', 'calendar', 'email', 'tasks', 'documents', 'memory', 'skills', 'cookbook', 'research', 'sessions', 'contacts']);
for (const family of ['notes', 'calendar', 'email', 'tasks', 'documents', 'memory', 'skills', 'cookbook']) listFamilies.add(`typo_${family}`);
for (const family of ['ambiguous_calendar', 'ambiguous_notes']) listFamilies.add(family);
const webTools = new Set(['web_search', 'web_fetch', 'private_browser', 'youtube_tool']);
const capabilityName = family => ({ search: 'search_browser', browser: 'search_browser', cookbook: 'cookbook_admin', contacts: 'contacts', notes_search: 'notes',
typo_notes: 'notes', typo_calendar: 'calendar', typo_email: 'email', typo_tasks: 'tasks', typo_documents: 'documents', typo_memory: 'memory',
typo_skills: 'skills', typo_cookbook: 'cookbook_admin', typo_search: 'search_browser', typo_shell_files: 'shell_files',
news_followup: 'search_browser', search_ai: 'search_browser', search_quantum: 'search_browser', search_history: 'search_browser', search_comparison: 'search_browser', ambiguous_calendar: 'calendar', ambiguous_notes: 'notes',
ambiguous_tasks: 'tasks', ambiguous_documents: 'documents', ambiguous_skills: 'skills', cookbook_detail: 'cookbook_admin',
email_inbox: 'email', search_open_result: 'search_browser', browser_navigation: 'search_browser', browser_to_notes: 'search_browser' }[family] || family);
const selected = opt('families', core.join(',')) === 'all' ? Object.keys(families) : opt('families', core.join(',')).split(',');
for (const f of selected) if (!families[f]) throw Error(`Unknown family ${f}`);
const run = new Date().toISOString().replace(/[:.]/g, '-');
const reportPath = path.resolve(root, opt('report', `reports/agent-turn-contract-${run}.json`));
if (!reportPath.startsWith(path.join(root, 'reports') + path.sep)) throw Error('Reports must be under reports/');
const availablePairs = selected.flatMap(family => ['00', '01', '10', '11'].map(combo => ({ family, combo })));
const requestedPairs = options.has('pairs') ? opt('pairs').split(',') : null;
if (requestedPairs) for (const pair of requestedPairs) if (!availablePairs.some(p => `${p.family}:${p.combo}` === pair)) throw Error(`Unknown selected pair ${pair}`);
const matrix = availablePairs.filter(p => !requestedPairs || requestedPairs.includes(`${p.family}:${p.combo}`));
const report = { run, base, owner, core_source: 'User-required ten families; shell_files executes bash reading /etc/hostname; theme is supplemental',
core_families: core, network: { proxy: 'disabled', inherited_proxy_keys: inheritedProxyKeys, chromium: '--no-proxy-server', no_proxy: '*' },
search_probe_query: 'GPT-4', search_probe_basis: 'Alternate public query; changed probe, not a corrected or proven seeded fixture. Original IANA failure retained: irrelevant returned search data, cause unresolved.',
email_scope: emailMetadataOnly ? 'Exact account-metadata prompts only; referential email followup untested; automatic /api/email reads blocked' : 'Email requires separately verified runtime fixture mode',
limits: { maxTurns, turnMs, totalMs }, matrix, planned_turns: matrix.length * 2,
sessions: [], turns: [], blocked: [], guarded_requests: [], not_run: [], status: 'running',
limitations: ['Prompts and browser request guards are not a server-side tool sandbox.',
'Only test chats are created; fixture rows are not seeded or deleted.',
'List-family followups are referential and require the same family tool; search/browser followups summarize without new tools.',
'No claim of full family coverage when cases are blocked or budget-limited.',
'shell_files requires real bash output; SFT policy refusal is a failure, never a substitute pass. Dedicated file tools may remain disabled.'] };
const save = () => { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n'); };
const check = (condition, message) => { if (!condition) throw Error(message); };
const bounded = async (promise, ms, name) => {
let timer;
try { return await Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(Error(`${name} timeout (${ms}ms)`)), ms); })]); }
finally { clearTimeout(timer); }
};
const normalize = s => String(s || '').replace(/\s+/g, ' ').trim();
const countWords = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10 };
function boundedSubsetFromHistory(prompt, answer, prior) {
const match = String(prompt || '').match(/\bat most\s+(\d+|one|two|three|four|five|six|seven|eight|nine|ten)\b/i);
if (!match || !answer || !prior) return false;
const limit = /^\d+$/.test(match[1]) ? Number(match[1]) : countWords[match[1].toLowerCase()];
const tail = String(answer).includes(':') ? String(answer).split(':').slice(1).join(':') : String(answer);
let items = tail.split(/\n/).map(s => s.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, '').trim()).filter(Boolean);
if (items.length === 1 && items[0].includes(',')) items = items[0].split(',').map(s => s.trim()).filter(Boolean);
const comparable = value => normalize(value).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
items = items.map(s => comparable(s).replace(/[.;]+$/, '')).filter(Boolean);
const haystack = comparable(prior);
return items.length > 0 && items.length <= limit && items.every(item => haystack.includes(item));
}
// Conservative fixture-specific detection, including a preamble below a thinking label.
const noVisibleLeak = text => !/<think>|Thinking Process:|UNTRUSTED SOURCE DATA|(?:^|\n)\s*(?:The user (?:wants|is asking|requests)\b|Analyze the Request:)/i.test(text);
// Playwright errors may embed request headers (including session cookies).
const safeError = error => String(error).split('\n')[0].replace(/odysseus_session=[^\s;]+/g, 'odysseus_session=[REDACTED]');
const bare = s => String(s || '').replace(/^mcp__email__/, '');
function parseSSE(body) {
return body.replace(/\r\n/g, '\n').split('\n\n').flatMap(frame => {
const raw = frame.split('\n').filter(l => l.startsWith('data:')).map(l => l.slice(5).trimStart()).join('\n');
if (!raw) return [];
if (raw === '[DONE]') return [{ type: 'done' }];
try { return [JSON.parse(raw)]; } catch { return [{ type: 'invalid_sse' }]; }
});
}
function formFields(request) {
const body = request.postData() || '';
const fields = {};
for (const name of ['session', 'session_id', 'mode', 'allow_web_search', 'use_web', 'use_research', 'plan_mode', 'allow_bash', 'endpoint_id', 'model', 'thinking_mode']) {
fields[name] = body.match(new RegExp(`name="${name}"\\r?\\n\\r?\\n([^\\r\\n]*)`))?.[1] ?? null;
}
return fields;
}
async function snapshot(page) {
return page.locator('#chat-history').evaluate(el => {
const visible = n => !!(n.getClientRects().length) && getComputedStyle(n).visibility !== 'hidden';
const users = [...el.querySelectorAll('.msg-user')].filter(visible);
const last = users.at(-1);
const after = n => last && !!(last.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_FOLLOWING);
const bubbles = [...el.querySelectorAll('.msg-ai')].filter(n => visible(n) && after(n));
return { users: users.length,
bubbles: bubbles.map(n => ({ text: (n.querySelector('.body')?.innerText || '').trim(), raw: n.dataset.raw || '', db_id: n.dataset.dbId || '' })),
anchors: [...el.querySelectorAll('a[href]')].filter(n => visible(n) && after(n)).map(n => ({ text: n.innerText, href: n.getAttribute('href') })),
tool_cards: [...el.querySelectorAll('.agent-thread')].filter(n => visible(n) && after(n)).length,
streaming: el.querySelectorAll('.streaming').length };
});
}
let browser;
let context;
let stopTimer;
let activeTurn;
let attempted = 0;
const started = Date.now();
try {
if (options.has('analyze-report')) {
const sourcePath = path.resolve(root, opt('analyze-report'));
check(sourcePath.startsWith(path.join(root, 'reports') + path.sep) && sourcePath !== reportPath, 'Analysis needs a distinct source report under reports/');
const source = JSON.parse(fs.readFileSync(sourcePath, 'utf8'));
Object.assign(report, source);
report.blocked = (source.blocked || []).filter(item => !(
item.request === '/api/client-perf'
&& item.method === 'POST'
&& item.reason === 'Browser write guard'
));
report.analysis = { source: sourcePath, at: new Date().toISOString(), source_status: source.status,
method: 'Offline re-score of captured DOM; no browser or inference requests. Original checks retained; harmless blocked client performance telemetry is reclassified as guarded.',
notes_limit_attribution: 'Existing canonical deterministic-summary shortcut; harness functional failure, not attributed to model.' };
report.turns = source.turns.map(t => {
const contract = t.sse?.audits.find(e => e.type === 'turn_contract');
const shape = contract && ['required', 'offered', 'executable', 'capabilities'].every(k => Array.isArray(contract[k]));
const evidence = { contract_captured: !!contract,
set_invariant: !!shape && contract.required.every(n => contract.offered.includes(n)) && contract.offered.every(n => contract.executable.includes(n)),
capability: !!shape && (!t.expected_capability || contract.capabilities.includes(t.expected_capability)),
forbidden_offers_absent: !!shape && contract.offered.every(n => !(t.forbidden_tools || []).includes(bare(n))),
execution_within_offered: !!shape && (t.sse?.tools || []).filter(e => e.type === 'tool_start').every(e => contract.offered.map(bare).includes(bare(e.tool))) };
if (!t.dom || !t.checks) return { ...t, contract_evidence_analysis: evidence };
const checks = { ...t.checks, no_visible_leak: noVisibleLeak(t.dom.bubbles.map(b => b.text).join('\n')) };
return { ...t, contract_evidence_analysis: evidence, original_checks: t.checks, original_status: t.status, checks,
status: Object.values(checks).every(Boolean) ? 'passed' : 'failed' };
});
attempted = source.attempted_turns ?? source.turns.length;
report.status = source.status === 'running' ? 'analysis-in-progress'
: report.not_run.length || report.blocked.length ? 'incomplete'
: report.turns.every(t => t.status === 'passed') ? 'passed' : 'failed';
} else if (opt('self-test', 'false') === 'true') {
check(core.length === 10 && core[9] === 'shell_files' && !core.includes('theme'), 'Core matrix mismatch');
check(matrix.length === (requestedPairs ? new Set(requestedPairs).size : selected.length * 4), 'Toggle matrix mismatch');
check(parseSSE('data: {"type":"tool_start","tool":"bash"}\r\n\r\ndata: [DONE]\r\n\r\n').at(-1).type === 'done', 'SSE framing regression');
check(parseSSE('data: broken\n\n')[0].type === 'invalid_sse', 'Malformed SSE must fail');
check(formFields({ postData: () => 'name="allow_web_search"\r\n\r\nfalse\r\n' }).allow_web_search === 'false', 'Toggle field parser');
check(!safeError('Timeout\n cookie: odysseus_session=secret').includes('secret'), 'Error redaction');
check(!noVisibleLeak('View thinking process\n\nThe user wants a list of emails'), 'Fixture reasoning preamble detection');
check(noVisibleLeak('Here are your three notes.'), 'Normal fixture answer must not trigger leakage');
check(boundedSubsetFromHistory('List those again, at most three.', 'Servers: kierkegaard, Odysseus, Ajax.', 'Servers: kierkegaard (local), Odysseus, Ajax, kierk.'), 'Grounded bounded subset detection');
check(boundedSubsetFromHistory('List those again, at most three.', '- Search seed prompts [Pinned]', '- [opaque-id] **Search seed prompts** [PINNED]'), 'Grounded structured tool-output detection');
check(!boundedSubsetFromHistory('List those again, at most two.', 'Servers: kierkegaard, Odysseus, Ajax.', 'Servers: kierkegaard, Odysseus, Ajax.'), 'Bounded subset limit enforcement');
report.self_tests = 11; report.status = 'self-test-passed';
} else if (opt('matrix-only', 'false') === 'true') {
report.status = 'matrix-only';
} else {
const cookieFile = opt('cookie-file', `${data}/sessions.json`);
const sessions = JSON.parse(fs.readFileSync(cookieFile, 'utf8'));
const token = Object.entries(sessions).find(([, v]) => v?.username === owner)?.[0];
check(token, `No existing ${owner} auth session; refusing personal fallback`);
const endpoint = opt('endpoint', 'http://100.118.44.115:18182/v1/chat/completions');
const endpointURL = new URL(endpoint);
check(endpointURL.protocol === 'http:' && (/^(127\.|10\.|192\.168\.|100\.)/.test(endpointURL.hostname) || endpointURL.hostname === 'odysseus.tailb895f4.ts.net'), 'Only explicit local/private inference endpoints allowed');
report.model = { endpoint, endpoint_id: opt('endpoint-id', 'preheret'), model: opt('model', 'odysseus-qwen3.5-tools-pre-heretic') };
browser = await chromium.launch({ headless: true, timeout: 15000, args: ['--no-proxy-server'] });
context = await browser.newContext({ serviceWorkers: 'block', extraHTTPHeaders: { 'Accept-Encoding': 'identity' } });
context.setDefaultTimeout(10000);
await context.addCookies([{ name: 'odysseus_session', value: token, url: base }]);
const statusRes = await context.request.get(`${base}/api/auth/status`, { timeout: 10000 });
const status = await statusRes.json();
check(status.authenticated && status.username === owner, 'Authenticated identity mismatch');
report.auth = { username: status.username, authenticated: status.authenticated, is_admin: status.is_admin };
const versionRes = await context.request.get(`${base}/api/version`, { timeout: 5000 });
report.deployment = versionRes.ok() ? await versionRes.json() : { status: versionRes.status() };
const fixture = JSON.parse(fs.readFileSync(`${data}/fixture_email_messages.json`, 'utf8'));
report.fixture_email_rows = fixture.messages.filter(m => m.owner === owner).length;
let emailSafe = false;
if (options.has('email-process')) {
const pid = opt('email-process'); check(/^\d+$/.test(pid), 'Invalid email PID');
const env = fs.readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0');
emailSafe = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').includes('email_server.py')
&& env.includes('ODYSSEUS_EMAIL_FIXTURE=1') && env.includes(`ODYSSEUS_DATA_DIR=${data}`)
&& report.fixture_email_rows > 0 && fixture.messages.every(m => m.owner);
}
report.email_fixture_runtime_verified = emailSafe;
// Observe the original request; never inject mode/toggle fields or fake SSE.
await context.route('**/*', async route => {
const req = route.request(); const url = new URL(req.url());
if (url.origin === base && url.pathname.startsWith('/api/email/')) {
report.guarded_requests.push({ request: url.pathname, method: req.method(), reason: 'No mailbox network calls: block automatic email UI requests' });
await route.abort('blockedbyclient'); return;
}
const writing = !['GET', 'HEAD', 'OPTIONS'].includes(req.method());
const allowed = !preflightOnly && url.origin === base && (url.pathname === '/api/session' && req.method() === 'POST'
|| url.pathname === '/api/chat_stream' && req.method() === 'POST'
|| report.sessions.some(s => url.pathname === `/api/session/${s.id}`) && ['PUT', 'PATCH'].includes(req.method())
|| report.sessions.some(s => url.pathname === `/api/session/${s.id}/generation-settings`) && req.method() === 'POST');
if (writing && !allowed) {
const guarded = { request: url.pathname, method: req.method(), reason: 'Browser write guard' };
report.guarded_requests.push(guarded);
// Expected background writes are intentionally suppressed, not missing tests.
// Keep unexpected blocked requests visible as readiness blockers.
if (!['/api/activity/heartbeat', '/api/calendar/sync', '/api/tasks/notification-logs', '/api/client-perf'].includes(url.pathname)) report.blocked.push(guarded);
await route.abort('blockedbyclient'); return;
}
if (url.pathname === '/api/chat_stream' && activeTurn) activeTurn.requests.push(formFields(req));
await route.continue();
});
let page = await context.newPage();
const observePage = p => p.on('pageerror', error => { if (activeTurn) (activeTurn.page_errors ||= []).push(error.message); });
observePage(page);
stopTimer = setTimeout(() => { report.blocked.push({ reason: 'Global deadline; browser closed, server cancellation not guaranteed' }); void browser.close().catch(() => {}); }, Math.max(1, totalMs - (Date.now() - started)));
if (preflightOnly) {
await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 20000 });
await page.waitForFunction(() => window.sessionModule?.loadSessions && window.chatModule);
if (!report.loaded_scripts) report.loaded_scripts = await page.locator('script[src]').evaluateAll(nodes => nodes.map(n => n.getAttribute('src')));
report.dom_preflight = {};
for (const selector of ['textarea#message:visible', '#chat-history', '#mode-agent-btn', '#web-toggle', '#web-toggle-btn', '#bash-toggle', '#bash-toggle-btn']) {
report.dom_preflight[selector] = await page.locator(selector).count();
check(report.dom_preflight[selector] === 1, `Missing or duplicated DOM anchor ${selector}`);
}
}
for (const item of preflightOnly ? [] : matrix) {
if (attempted >= maxTurns || Date.now() - started + turnMs * Math.min(2, maxTurns - attempted) > totalMs) {
report.not_run.push({ ...item, reason: 'Call/time budget' }); continue;
}
if (item.family === 'email' && !emailSafe && !emailMetadataOnly) {
report.blocked.push({ ...item, reason: 'Email runtime fixture mode not proven; no email turn sent' }); continue;
}
let caseSession;
try {
await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 20000 });
await page.waitForFunction(() => window.sessionModule?.loadSessions && window.chatModule);
if (!report.loaded_scripts) report.loaded_scripts = await page.locator('script[src]').evaluateAll(nodes => nodes.map(n => n.getAttribute('src')));
const id = await page.evaluate(async ({ name, model }) => {
const body = new FormData();
for (const [k, v] of Object.entries({ name, endpoint_url: model.endpoint, endpoint_id: model.endpoint_id, model: model.model, skip_validation: 'true', rag: 'false' })) body.append(k, v);
const res = await fetch('/api/session', { method: 'POST', body, signal: AbortSignal.timeout(10000) });
if (!res.ok) throw Error(`Session creation HTTP ${res.status}`);
return (await res.json()).id;
}, { name: `[verify-agent-contract ${run}] ${item.family}-${item.combo}`, model: opt('picker-route', 'false') === 'true'
? { ...report.model, endpoint: 'http://100.118.44.115:18182/v1/chat/completions', endpoint_id: 'preheret' } : report.model });
check(id, 'Missing session ID'); caseSession = id; report.sessions.push({ ...item, id }); save();
await page.evaluate(async sid => { await window.sessionModule.loadSessions(); await window.sessionModule.selectSession(sid, { showLoading: false }); }, id);
await page.waitForFunction(sid => window.sessionModule.getCurrentSessionId() === sid, id);
if (opt('picker-route', 'false') === 'true') {
check(report.model.endpoint_id === 'cleanv3', 'Picker test requires the cleanv3 target');
await page.locator('#model-picker-btn').click();
await page.locator('#model-picker-search').fill('No-RAG preview');
const target = page.locator('#model-picker-menu .model-switch-item').filter({ hasText: 'Tools v3 — No-RAG preview' }).first();
await target.waitFor({ state: 'visible' });
await target.click();
await page.waitForFunction(() => !window.__odysseusModelSwitchPromise);
await page.waitForFunction(() => document.querySelector('#model-picker-label')?.textContent.includes('No-RAG preview'));
report.sessions.at(-1).picker_click_verified = true;
}
await page.locator('#chat-context-pill:not(.loading)').click();
const thinkingSwitch = page.locator('.chat-context-popup .chat-context-toggle-row').filter({ hasText: 'Thinking' }).locator('[role="switch"]');
const originalThinking = await thinkingSwitch.getAttribute('aria-checked');
check(['true', 'false'].includes(originalThinking), 'Cannot establish UI thinking state');
if (originalThinking === 'true') {
const updated = page.waitForResponse(r => new URL(r.url()).pathname === `/api/session/${id}/generation-settings` && r.request().method() === 'POST');
updated.catch(() => {});
await thinkingSwitch.click();
check((await updated).ok(), 'Test-session thinking-off update failed');
}
check(await thinkingSwitch.getAttribute('aria-checked') === 'false', 'UI thinking switch must be off');
const generationResponse = await context.request.get(`${base}/api/session/${id}/context`, { timeout: 10000 });
check(generationResponse.ok(), 'Cannot read test-session generation settings');
const generation = await generationResponse.json();
check(generation.thinking_mode === 'off', 'Stored test-session thinking mode must be off');
const generationEvidence = { thinking_mode: generation.thinking_mode, ui_thinking_before: originalThinking,
ui_thinking_after: 'false', changed_test_session_only: originalThinking === 'true',
temperature_override: generation.temperature_override, max_tokens_override: generation.max_tokens_override };
report.sessions.at(-1).generation_settings = generationEvidence;
await page.locator('textarea#message:visible').click();
const agent = page.locator('#mode-agent-btn');
if (await agent.getAttribute('aria-pressed') !== 'true') await agent.click();
for (const [toggle, button] of [['research-toggle', 'research-toggle-btn'], ['rag-toggle', 'rag-indicator-btn'], ['bash-toggle', 'bash-toggle-btn']]) {
const el = page.locator(`#${toggle}`);
if (await el.count() && await el.isChecked()) {
await page.locator(`#${button}`).click();
check(!await el.isChecked(), `Could not disable ${toggle}`);
}
}
if (['shell_files', 'typo_shell_files'].includes(item.family) && !await page.locator('#bash-toggle').isChecked()) {
await page.locator('#bash-toggle-btn').click();
check(await page.locator('#bash-toggle').isChecked(), 'Shell toggle did not enable');
}
for (let turn = 0; turn < 2; turn++) {
if (attempted >= maxTurns) { report.not_run.push({ ...item, turn, reason: 'Call budget' }); break; }
const web = item.combo[turn] === '1';
if (await page.locator('#web-toggle').isChecked() !== web) await page.locator('#web-toggle-btn').click();
check(await page.locator('#web-toggle').isChecked() === web, 'Web toggle click did not update checkbox');
const regression = ['search_ai', 'search_quantum', 'search_history', 'search_comparison', 'greeting_to_notes'].includes(item.family);
const prompt = item.family === 'email' && emailMetadataOnly ? emailPrompts[turn] : turn === 0 ? (regression ? families[item.family][0] : `${families[item.family][0]} Read-only inspection; do not change data or send messages. Keep the answer concise.`)
: families[item.family][2] ? families[item.family][2]
: listFamilies.has(item.family) ? 'List those again, at most three. Read-only; do not change data or send messages.'
: ['shell_files', 'typo_shell_files'].includes(item.family) ? 'Run that same read-only command again and report its actual output.'
: 'Summarize your preceding result in one sentence. Do not use any tools.';
const inheritedTool = turn === 1 && (families[item.family][3] || (listFamilies.has(item.family) && item.family !== 'ambiguous_calendar')
|| ['shell_files', 'typo_shell_files', 'news_followup', 'search_history', 'search_comparison'].includes(item.family));
const expected = turn === 1 && families[item.family][3] ? families[item.family][3] : regression && inheritedTool ? ['web_search'] : (turn === 1 && item.family === 'ambiguous_calendar')
|| turn === 1 && !inheritedTool || ['search', 'typo_search'].includes(item.family) && !web ? [] : families[item.family][1];
const current = { ...item, turn, session_id: id, web, prompt, expected_tools: expected,
generation_settings: generationEvidence,
expected_capability: (turn === 0 || inheritedTool) && expected.length ? (families[item.family][4]?.[turn] || capabilityName(item.family)) : null,
forbidden_tools: ['notes', 'calendar', 'email', 'tasks', 'documents', 'memory', 'skills', 'cookbook', 'shell_files'].includes(item.family) ? [...webTools] : [],
followup_contract: turn === 0 ? null : item.family === 'email' && emailMetadataOnly ? 'explicit-account-metadata; referential-untested' : inheritedTool ? 'inherited-read-only-capability' : 'summarize-no-tools', requests: [], status: 'running' };
activeTurn = current; report.turns.push(current); attempted++; save();
const turnStart = Date.now();
try {
await bounded((async () => {
const before = await page.locator('#chat-history .msg-user').count();
if (opt('sample-stream', 'false') === 'true') await page.evaluate(expectedUsers => {
clearInterval(window.__verifyLengthTimer);
window.__verifyRoundOneObserver?.disconnect();
const started = performance.now();
window.__verifyLengthSamples = [];
window.__verifyRoundOneIdentity = { initial_seen: false, first_token_seen: false, replaced_before_first_token: false, same_node_at_first_token: false };
window.__verifyInitialRoundBubble = null;
const inspectRoundOne = () => {
const root = document.querySelector('#chat-history');
const users = root?.querySelectorAll('.msg-user');
if (!users || users.length < expectedUsers) return;
const user = users[users.length - 1];
const bubbles = [...root.querySelectorAll('.msg-ai')].filter(node => user.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING);
const latest = bubbles.at(-1) || null;
if (!window.__verifyInitialRoundBubble && latest) {
window.__verifyInitialRoundBubble = latest;
window.__verifyRoundOneIdentity.initial_seen = true;
}
const first = window.__verifyInitialRoundBubble;
const hasFirstToken = bubbles.some(node => String(node.querySelector('.stream-content')?.textContent || '').length > 0);
if (first && !first.isConnected && !window.__verifyRoundOneIdentity.first_token_seen) {
window.__verifyRoundOneIdentity.replaced_before_first_token = true;
}
if (hasFirstToken && !window.__verifyRoundOneIdentity.first_token_seen) {
window.__verifyRoundOneIdentity.first_token_seen = true;
window.__verifyRoundOneIdentity.same_node_at_first_token = first === latest;
}
};
window.__verifyRoundOneObserver = new MutationObserver(inspectRoundOne);
window.__verifyRoundOneObserver.observe(document.querySelector('#chat-history'), { childList: true, subtree: true, characterData: true });
window.__verifyLengthTimer = setInterval(() => {
inspectRoundOne();
const root = document.querySelector('#chat-history');
const users = root?.querySelectorAll('.msg-user');
if (!users || users.length < expectedUsers) return;
const user = users[users.length - 1];
let length = 0;
for (const body of root.querySelectorAll('.msg-ai .body')) {
if (!(user.compareDocumentPosition(body) & Node.DOCUMENT_POSITION_FOLLOWING) || !body.getClientRects().length) continue;
length += body.innerText.length;
}
// Telemetry stores no response text; cap memory for interrupted turns.
if (window.__verifyLengthSamples.length < 1000) window.__verifyLengthSamples.push({ ms: Math.round(performance.now() - started), length });
}, 200);
}, before + 1);
const responsePromise = page.waitForResponse(r => new URL(r.url()).pathname === '/api/chat_stream' && r.request().method() === 'POST', { timeout: turnMs });
// Attach rejection handler before interacting; no dangling rejection on UI failure.
responsePromise.catch(() => {});
await page.locator('textarea#message:visible').fill(prompt);
await page.locator('textarea#message:visible').press('Enter');
const response = await responsePromise;
current.http = { status: response.status(), headers_ms: Date.now() - turnStart,
headers: Object.fromEntries(Object.entries(response.headers()).filter(([k]) => ['content-type', 'content-encoding', 'cache-control', 'x-accel-buffering', 'x-odysseus-run-id'].includes(k))) };
save();
if (!response.ok()) {
current.http.error_body = (await response.text()).slice(0, 1000);
throw Error(`Chat HTTP ${response.status()} before SSE/DOM validation`);
}
check(/text\/event-stream/.test(response.headers()['content-type'] || ''), 'Chat response is not SSE');
const events = parseSSE(await response.text());
current.response_complete_ms = Date.now() - turnStart;
current.sse = { events: events.length, types: events.reduce((a, e) => { a[e.type || 'delta'] = (a[e.type || 'delta'] || 0) + 1; return a; }, {}),
tools: events.filter(e => ['tool_start', 'tool_output'].includes(e.type)).map(e => ({ type: e.type, tool: e.tool, exit_code: e.exit_code, command: e.command, output: e.output, error: e.error })),
metrics: events.filter(e => e.type === 'metrics'),
audits: events.filter(e => /contract|routing|resolution/i.test(e.type || '')),
mode_and_model: events.filter(e => ['turn_mode', 'model_info'].includes(e.type)),
final: events.filter(e => e.type === 'final_response').map(e => e.content || ''),
errors: events.filter(e => ['error', 'invalid_sse', 'tool_approval_required'].includes(e.type)) };
// SSE audits survive DOM failures; stale classes are a separate UI check.
current.contract = current.sse.audits.find(e => e.type === 'turn_contract') || null;
save();
if (item.family === 'email' && emailMetadataOnly) {
const offered = current.contract?.offered?.map(bare);
check(Array.isArray(offered) && offered.includes('list_email_accounts') && offered.every(n => ['list_email_accounts', 'ask_user', 'update_plan'].includes(n)), 'EMAIL_SAFETY: actual offered tools exceed verified metadata-only scope');
}
try { await page.waitForFunction(() => !document.querySelector('#chat-history .streaming'), null, { timeout: 10000 }); }
catch (error) { current.dom_settle_error = safeError(error); }
current.dom = await snapshot(page);
const turnMetrics = page.locator('#chat-history .response-metrics').last();
if (await turnMetrics.count()) {
current.metrics_ui = { footer: (await turnMetrics.innerText()).trim() };
await turnMetrics.click();
const popup = page.locator('body > .ctx-popup').last();
if (await popup.count()) current.metrics_ui.details = (await popup.innerText()).trim();
await page.keyboard.press('Escape');
}
if (opt('sample-stream', 'false') === 'true') {
current.round_one_identity = await page.evaluate(() => {
window.__verifyRoundOneObserver?.disconnect();
return window.__verifyRoundOneIdentity || null;
});
}
const historyRes = await context.request.get(`${base}/api/history/${encodeURIComponent(id)}`, { timeout: 10000 });
check(historyRes.ok(), 'History request failed');
const history = (await historyRes.json()).history || [];
const lastUser = history.map(r => r.role).lastIndexOf('user');
const assistants = history.slice(lastUser + 1).filter(r => r.role === 'assistant');
current.history = assistants.map(r => ({ content: r.content, tool_events: r.tool_events || r.metadata?.tool_events || [], metadata: { actual_model: r.metadata?.actual_model, requested_model: r.metadata?.requested_model } }));
const text = current.dom.bubbles.map(b => b.text).filter(Boolean);
const canonicalRaw = assistants.at(-1)?.content || '';
const canonical = normalize(canonicalRaw);
const tools = current.sse.tools.filter(e => e.type === 'tool_start').map(e => bare(e.tool));
const contract = current.sse.audits.find(e => e.type === 'turn_contract');
current.contract = contract || null;
const contractShape = contract && ['capabilities', 'required', 'offered', 'executable'].every(k => Array.isArray(contract[k]));
const dupParagraphs = text.flatMap(t => t.split(/\n\s*\n/).map(normalize)).filter(t => t.length >= 60);
const cleanPreview = contract?.selection_mode === 'clean_compact_v3_preview';
const priorTurn = report.turns.find(t => t.session_id === id && t.turn === 0 && t !== current);
const priorEvidence = [priorTurn?.history?.at(-1)?.content,
...(priorTurn?.history?.at(-1)?.tool_events || []).map(event => event.output)].filter(Boolean);
const repeatedReadFromHistory = cleanPreview && turn === 1 && inheritedTool && tools.length === 0
&& !!canonical && priorEvidence.some(evidence =>
canonical === normalize(evidence) || boundedSubsetFromHistory(prompt, canonicalRaw, evidence));
current.grounding_evidence = turn === 1 && inheritedTool ? {
clean_preview: cleanPreview,
no_new_tool: tools.length === 0,
canonical_answer: Boolean(canonical),
prior_evidence_count: priorEvidence.length,
evidence_matches: priorEvidence.map(evidence =>
canonical === normalize(evidence) || boundedSubsetFromHistory(prompt, canonicalRaw, evidence)),
accepted: repeatedReadFromHistory,
} : null;
const expectedToolObserved = expected.length
? tools.some(t => expected.includes(t))
|| (inheritedTool && current.expected_capability === 'search_browser' && tools.some(t => webTools.has(t)))
: tools.length === 0;
current.checks = {
http_ok: response.ok(), sse_type: /text\/event-stream/.test(response.headers()['content-type'] || ''),
terminal: events.some(e => e.type === 'done'), no_sse_errors: current.sse.errors.length === 0,
one_post: current.requests.length === 1,
agent_request: current.requests[0]?.mode === 'agent',
thinking_off: current.generation_settings.thinking_mode === 'off' && current.generation_settings.ui_thinking_after === 'false' && current.requests[0]?.thinking_mode !== 'on',
web_request: current.requests[0]?.allow_web_search === String(web),
no_presearch_or_research: current.requests[0]?.use_web !== 'true' && current.requests[0]?.use_research !== 'true',
session_request: [current.requests[0]?.session, current.requests[0]?.session_id].includes(id),
one_new_user: current.dom.users === before + 1,
dom_idle: current.dom.streaming === 0,
visible_answer: text.length > 0,
no_canned_failure: !/currently permitted tools|can[']?t perform that operation in this preview|no changes were made|search query likely needs better terms|not enough clear evidence|model provider returned no usable output/i.test(canonical),
no_duplicate_bubbles: new Set(text.map(normalize)).size === text.length,
no_duplicate_paragraphs: new Set(dupParagraphs).size === dupParagraphs.length,
history_answer_visible: !!canonical && current.dom.bubbles.some(b => normalize(b.raw) === canonical || normalize(b.text) === canonical),
expected_tool: repeatedReadFromHistory || expectedToolObserved,
no_forbidden_tools: tools.every(t => !current.forbidden_tools.includes(t)),
contract_audit_captured: current.sse.audits.some(e => /contract/i.test(e.type || '')),
requested_preview_active: report.model.endpoint_id !== 'cleanv3' || current.contract?.selection_mode === 'clean_compact_v3_preview',
contract_set_invariant: !!contractShape && contract.required.every(t => contract.offered.includes(t)) && contract.offered.every(t => contract.executable.includes(t)),
contract_family: !!contractShape && (!current.expected_capability || contract.capabilities.includes(current.expected_capability)),
contract_no_forbidden_offers: !!contractShape && contract.offered.every(t => cleanPreview
? (web || item.family.startsWith('browser') || !['web_search', 'web_fetch', 'private_browser', 'youtube_tool', 'pdf_extract', 'search_hf_models'].includes(bare(t)))
: !current.forbidden_tools.includes(bare(t))),
executed_within_contract: !!contractShape && tools.every(t => contract.offered.map(bare).includes(t)),
at_most_three_notes: item.family !== 'notes' || new Set(current.dom.anchors.filter(a => a.href.startsWith('#note-') && a.text.trim()).map(a => a.href)).size <= 3,
shell_request: item.family !== 'shell_files' || current.requests[0]?.allow_bash === 'true',
shell_executed: item.family !== 'shell_files' || current.sse.tools.some(e => e.type === 'tool_output' && bare(e.tool) === 'bash' && e.exit_code === 0 && String(e.output).includes('ODY_SHELL_FILES_READONLY')),
tool_success: current.sse.tools.every(e => e.exit_code == null || e.exit_code === 0),
no_visible_leak: noVisibleLeak(text.join('\n')),
anchors_resolved: current.dom.anchors.every(a => !!a.href && !/^javascript:/i.test(a.href) && !/__PLACEHOLDER__|undefined/.test(a.href)),
followup_grounded: repeatedReadFromHistory || turn === 0 || (inheritedTool ? expectedToolObserved : !/no preceding|no previous|no prior/i.test(text.join(' ')) && text.some(t => normalize(t).length > 0)),
first_round_node_stable: opt('sample-stream', 'false') !== 'true' || !!(
current.round_one_identity?.initial_seen
&& current.round_one_identity?.first_token_seen
&& !current.round_one_identity?.replaced_before_first_token
&& (tools.length > 0 || current.round_one_identity?.same_node_at_first_token)
),
};
current.status = Object.values(current.checks).every(Boolean) ? 'passed' : 'failed';
})(), turnMs, 'Turn');
} catch (error) {
current.status = 'failed'; current.error = safeError(error);
current.failure_stage = current.http?.status >= 400 ? 'server-http' : current.sse ? 'dom-or-history' : 'request-or-stream';
throw error;
} finally {
if (opt('sample-stream', 'false') === 'true') {
try {
current.visible_length_samples = await bounded(page.evaluate(() => { clearInterval(window.__verifyLengthTimer); return window.__verifyLengthSamples || []; }), 1500, 'Length samples');
const beforeEnd = current.visible_length_samples.filter(s => s.ms < (current.response_complete_ms || 0));
current.intermediate_visible_growth = beforeEnd.some((s, i) => i > 0 && beforeEnd[i - 1].length > 0 && s.length > beforeEnd[i - 1].length);
} catch (error) { current.length_sample_error = safeError(error); }
}
current.elapsed_ms = Date.now() - turnStart; save();
console.log(JSON.stringify({ family: item.family, combo: item.combo, turn, status: current.status, failed: Object.entries(current.checks || {}).filter(([, v]) => !v).map(([k]) => k), error: current.error }));
}
if (turn === 0 && opt('picker-route', 'false') === 'true') {
// selectSession is used by this driver without router navigation;
// reload the actual chat URL, as a user does from its permalink.
await page.evaluate(sid => history.replaceState(null, '', `/#${sid}`), id);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(sid => window.sessionModule?.getCurrentSessionId() === sid, id, { timeout: 20000 });
await page.waitForFunction(() => document.querySelector('#model-picker-label')?.textContent.includes('No-RAG preview'));
const savedFirstAnswer = current.history?.at(-1)?.content || '';
await page.waitForFunction(expected => [...document.querySelectorAll('#chat-history .msg-ai')]
.some(n => (n.dataset.raw || n.querySelector('.body')?.textContent || '').trim() === expected.trim()), savedFirstAnswer);
await page.locator('#chat-context-pill:not(.loading)').waitFor({ state: 'visible' });
report.sessions.at(-1).picker_reload_verified = true;
save();
}
}
} catch (error) {
const reason = safeError(error);
if (reason.includes('EMAIL_SAFETY:')) throw error;
let inactive = !caseSession;
let streamStatus = { status: 'no-session-created' };
if (caseSession) {
try {
const statusResponse = await context.request.get(`${base}/api/chat/stream_status/${encodeURIComponent(caseSession)}`, { timeout: 5000 });
streamStatus = statusResponse.status() === 404 ? { status: 'no-active-stream', http: 404 } : await statusResponse.json();
inactive = statusResponse.status() === 404 || statusResponse.ok() && ['done', 'error'].includes(streamStatus.status);
} catch (statusError) { streamStatus = { status: 'unknown', error: safeError(statusError) }; }
}
// Task-authorized cleanup: only this created session and its captured run ID.
if (!inactive && streamStatus.status === 'streaming' && report.sessions.some(s => s.id === caseSession)
&& activeTurn?.session_id === caseSession && activeTurn.http?.headers?.['x-odysseus-run-id']) {
const runId = activeTurn.http.headers['x-odysseus-run-id'];
const stopped = await context.request.post(`${base}/api/chat/stop/${encodeURIComponent(caseSession)}`, {
headers: { 'X-Odysseus-Run-Id': runId }, timeout: 5000 });
const cleanup = { session_id: caseSession, run_id: runId, http: stopped.status(), result: await stopped.json() };
for (let attempt = 0; attempt < 5; attempt++) {
const verification = await context.request.get(`${base}/api/chat/stream_status/${encodeURIComponent(caseSession)}`, { timeout: 3000 });
cleanup.verified_status_http = verification.status();
if (verification.status() === 404) { inactive = true; streamStatus = { status: 'no-active-stream', http: 404 }; break; }
await new Promise(resolve => setTimeout(resolve, 200));
}
activeTurn.exact_run_cleanup = cleanup;
}
report.blocked.push({ ...item, reason, session_id: caseSession, stream_status: streamStatus, safe_to_continue: inactive });
save();
if (!inactive) throw Error(`Cannot continue safely: active/unknown stream for ${caseSession}`);
// Cancel any outstanding client-side UI work before the independent pair.
await page.close().catch(() => {});
page = await context.newPage(); observePage(page); activeTurn = undefined;
console.log(JSON.stringify({ ...item, status: 'case-failed-continuing', reason, stream_status: streamStatus.status }));
}
}
report.status = preflightOnly ? 'preflight-passed' : report.not_run.length || report.blocked.length ? 'incomplete' : report.turns.every(t => t.status === 'passed') ? 'passed' : 'failed';
}
} catch (error) {
report.status = 'blocked'; report.blocked.push({ reason: safeError(error) });
} finally {
clearTimeout(stopTimer);
if (browser) await bounded(browser.close(), 10000, 'Browser close').catch(() => {});
if (!options.has('analyze-report')) report.elapsed_ms = Date.now() - started;
report.attempted_turns = attempted;
report.unattempted_turns = report.planned_turns - attempted;
const coverageMatrix = options.has('analyze-report') ? report.matrix : matrix;
report.coverage = coverageMatrix.flatMap(item => [0, 1].map(turn => {
const result = report.turns.find(t => t.family === item.family && t.combo === item.combo && t.turn === turn);
const skipped = report.blocked.find(t => t.family === item.family && t.combo === item.combo)
|| report.not_run.find(t => t.family === item.family && t.combo === item.combo);
return { ...item, turn, status: result?.status || (skipped && report.blocked.includes(skipped) ? 'blocked' : 'not-run'),
reason: result?.error || skipped?.reason || (!result ? `Run status: ${report.status}` : undefined),
failed_checks: Object.entries(result?.checks || {}).filter(([, value]) => !value).map(([name]) => name) };
}));
report.coverage_counts = report.coverage.reduce((counts, row) => { counts[row.status] = (counts[row.status] || 0) + 1; return counts; }, {});
save();
console.log(JSON.stringify({ status: report.status, attempted, report: reportPath }));
process.exitCode = ['passed', 'matrix-only', 'self-test-passed', 'preflight-passed'].includes(report.status) ? 0 : 1;
}