#!/usr/bin/env node /** Real 7011 three-turn A -> B -> A follow-up and contract-isolation replay. */ import fs from 'node:fs'; import path from 'node:path'; import { chromium } from 'playwright'; import { capabilityAvailable, skillDetailEvidence } from './tool_followup_oracle.mjs'; const root = path.resolve(new URL('..', import.meta.url).pathname); const base = process.env.BASE_URL || 'http://127.0.0.1:7011'; const endpointId = process.env.ENDPOINT_ID || '1d1022ef'; const endpointUrl = process.env.ENDPOINT_URL || 'http://100.67.207.85:19184/v1/chat/completions'; const model = process.env.MODEL || 'odysseus-qwen3.5-tools-pre-heretic'; const owner = process.env.OWNER || 'sft_alex_creator'; const routingMode = process.env.ROUTING_MODE || 'baseline'; if (!['baseline', 'recent', 'all', 'default'].includes(routingMode)) throw Error('Invalid routing mode'); const expectedMode = routingMode === 'default' ? 'recent_model_choice' : routingMode; const run = new Date().toISOString().replace(/[:.]/g, '-'); const reportPath = path.resolve(process.env.REPORT_PATH || path.join(root, `reports/interleaved-followups-${run}.json`)); if (!reportPath.startsWith(path.join(root, 'reports') + path.sep) || fs.existsSync(reportPath)) throw Error('Report path must be new and under reports/'); const selected = new Set((process.env.CHAINS || '').split(',').map(x => x.trim()).filter(Boolean)); const keepSession = process.env.KEEP_SESSION === 'true'; const auth = JSON.parse(fs.readFileSync('/home/pewds/odysseus-cookbook-fresh/data/sessions.json', 'utf8')); const token = Object.entries(auth).find(([, value]) => value?.username === owner)?.[0]; if (!token) throw Error(`No active ${owner} session`); const chains = [ { name: 'shell-failure-recovery', publicAnswerAudit: true, expectedExitCodes: {0: 7, 2: 0}, noToolTurns: [1], expectedAnswers: [['PHASE_ONE_DONE', 'CHECK_FAILED', '7'], ['7'], ['RECOVERY_OK']], turns: [ ["Use bash to run this read-only test once: printf 'PHASE_ONE_DONE\\n'; printf 'CHECK_FAILED\\n' >&2; exit 7\nReport stdout, stderr, and the exit code. Do not retry automatically.", 'shell_files', ['bash'], false, true], ['What was its exit code? Do not run anything again.', 'shell_files', [], false, true], ["Now run this corrected read-only command and show its output: printf 'RECOVERY_OK\\n'", 'shell_files', ['bash'], false, true], ]}, { name: 'shell-output-followup', publicAnswerAudit: true, expectedAnswers: [['FOLLOWUP_SHELL_OK'], ['FOLLOWUP_SHELL_OK'], ['FOLLOWUP_SHELL_OK']], turns: [ ["Use bash to run this read-only command and report its output: printf 'FOLLOWUP_SHELL_OK\\n'", 'shell_files', ['bash'], false, true], ['What did it print? Quote the output.', 'shell_files', [], false, true], ['Run it again and show the output, not just whether it succeeded.', 'shell_files', ['bash'], false, true], ]}, { name: 'browser-keyboard-followup', publicAnswerAudit: true, expectedAnswers: [['Cedar', 'Harbor', '219', '349'], ['Cedar', '219']], turns: [ ['Use the private browser to open http://127.0.0.1:7011/static/test-fixtures/browser-catalog.html, fill the search field with orange, and submit with the Enter key. Report the sofa names and prices.', 'search_browser', ['private_browser'], false, false], ['Which of those costs less?', 'search_browser', [], false, false], ]}, { name: 'typo-calendar-notes-calendar', turns: [ ['show my calnedar for this week', 'calendar', ['manage_calendar'], false, false], ['now show my notse', 'notes', ['manage_notes'], false, false], ['back to calnedar whats on next week?', 'calendar', ['manage_calendar'], false, false], ]}, { name: 'typo-email-notes-email', turns: [ ['whats my emaol adress?', 'email', ['list_email_accounts'], false, false], ['show my notse now', 'notes', ['manage_notes'], false, false], ['back to emaol show 2 latest', 'email', ['list_emails'], false, false], ]}, { name: 'web-toggle-revocation', publicAnswerAudit: true, deniedTools: {1: ['web_search', 'web_fetch']}, turns: [ ['Search the web for the official IANA reserved domains page. Return the source.', 'search_browser', ['web_search'], true, false], ['Search again for that same page.', 'search_browser', [], false, false], ['Search again for that same page.', 'search_browser', ['web_search'], true, false], ['Web search is enabled again. Search for that same page.', 'search_browser', ['web_search'], true, false], ]}, { name: 'shell-toggle-revocation', publicAnswerAudit: true, expectedAnswers: [['TOGGLE_SHELL_OK'], [], ['TOGGLE_SHELL_OK'], ['TOGGLE_SHELL_OK']], deniedTools: {1: ['bash']}, turns: [ ["Use bash to run this read-only command and report its output: printf 'TOGGLE_SHELL_OK\\n'", 'shell_files', ['bash'], false, true], ['Run that same read-only shell command again.', 'shell_files', [], false, false], ['Run that same read-only shell command again.', 'shell_files', ['bash'], false, true], ['Bash is enabled again. Run that same read-only shell command.', 'shell_files', ['bash'], false, true], ]}, { name: 'browser-link-followup', publicAnswerAudit: true, expectedAnswers: [['Example Domain'], ['Example Domains']], turns: [ ['Open https://example.com in the private browser and report its heading.', 'search_browser', ['private_browser'], false, false], ['Return to that browser page, open the Learn more link, and report the destination heading.', 'search_browser', ['private_browser'], false, false], ]}, { name: 'browser-controlled-overlay', publicAnswerAudit: true, expectedAnswers: [['Cedar', 'Harbor', '219', '349'], ['Cedar', '219'], ['Cedar', '219']], turns: [ ['Go to http://127.0.0.1:7011/static/test-fixtures/browser-catalog.html?overlay=delayed and find orange sofas. Give their names and prices. Do not accept optional cookies.', 'search_browser', ['private_browser'], false, false], ['Which of those is cheaper?', 'search_browser', [], false, false], ['Try again on that page and compare the prices.', 'search_browser', ['private_browser'], false, false], ]}, { name: 'browser-controlled-catalog', publicAnswerAudit: true, expectedAnswers: [['Cedar', 'Harbor', '219', '349'], ['Cedar', '219']], turns: [ ['Go to http://127.0.0.1:7011/static/test-fixtures/browser-catalog.html and find orange sofas. Give their names and prices.', 'search_browser', ['private_browser'], false, false], ['Which of those is cheaper?', 'search_browser', [], false, false], ]}, { name: 'browser-nitori-domain', publicAnswerAudit: true, turns: [ ['Go to nitori.jp and find orange couch', 'search_browser', ['private_browser'], false, false], ]}, { name: 'browser-navigation-wording', publicAnswerAudit: true, turns: [ ['Go to ikea and find sofa yelloe', 'search_browser', ['private_browser'], false, false], ['Go to ikea.com find a yellow sofa', 'search_browser', ['private_browser'], false, false], ]}, { name: 'greeting-url-question', publicAnswerAudit: true, turns: [ ['Yo', null, [], false, false], ['Where', null, [], false, false], ['https://consumerrights.wiki/w/Sony_PlayStation_digital_game_ownership_lawsuit whays this web', 'search_browser', ['web_fetch'], true, false], ['What else?', 'search_browser', [], true, false], ]}, { name: 'url-typo-question', publicAnswerAudit: true, turns: [ ['https://consumerrights.wiki/w/Sony_PlayStation_digital_game_ownership_lawsuit whays this web', 'search_browser', ['web_fetch'], true, false], ['What else?', 'search_browser', [], true, false], ]}, { name: 'url-only', publicAnswerAudit: true, turns: [ ['https://consumerrights.wiki/w/Sony_PlayStation_digital_game_ownership_lawsuit', 'search_browser', ['web_fetch'], true, false], ]}, { name: 'url-suffix-summary', publicAnswerAudit: true, turns: [ ['https://consumerrights.wiki/w/Sony_PlayStation_digital_game_ownership_lawsuit summarize', 'search_browser', ['web_fetch'], true, false], ]}, { name: 'youtube-summary', publicAnswerAudit: true, turns: [ ['Summarize this video https://youtu.be/jNQXAC9IVRw', 'search_browser', ['youtube_tool'], true, false], ]}, { name: 'url-summary-followup', publicAnswerAudit: true, turns: [ ['Can u summarize this https://investors.bendingspoons.com/newsroom/bending-spoons-agrees-to-acquire-miro', 'search_browser', ['web_fetch'], true, false], ['What else', 'search_browser', [], true, false], ]}, { name: 'email-latest-notes', turns: [ ['whats my email?', 'email', ['list_email_accounts'], false, false], ['whats my 5 latest', 'email', ['list_emails'], false, false], ['what about my notes', 'notes', ['manage_notes'], false, false], ]}, { name: 'calendar-notes-calendar', turns: [ ['List my next three calendar events with their times.', 'calendar', ['manage_calendar'], false, false], ['Now list my first three notes.', 'notes', ['manage_notes'], false, false], ['What time was the second calendar event from earlier? Check my calendar again.', 'calendar', ['manage_calendar'], false, false], ]}, { name: 'notes-tasks-notes', turns: [ ['List my first three notes.', 'notes', ['manage_notes'], false, false], ['Now list my first three scheduled tasks and statuses.', 'tasks', ['manage_tasks'], false, false], ['Open the second note from the earlier note list.', 'notes', ['manage_notes'], false, false], ]}, { name: 'tasks-memory-tasks', turns: [ ['List my first three scheduled tasks and statuses.', 'tasks', ['manage_tasks'], false, false], ['Now list my first three saved memories.', 'memory', ['manage_memory'], false, false], ['What is the status of the second scheduled task from earlier? Check it again.', 'tasks', ['manage_tasks'], false, false], ]}, { name: 'documents-skills-documents', turns: [ ['List my first three documents.', 'documents', ['manage_documents'], false, false], ['Now list my first three skills.', 'skills', ['manage_skills'], false, false], ['Read the second document from the earlier document list and summarize it.', 'documents', ['manage_documents'], false, false], ]}, { name: 'skills-cookbook-skills', turns: [ ['List my first three skills.', 'skills', ['manage_skills'], false, false], ['Now list configured Cookbook servers and their status.', 'cookbook_admin', ['list_cookbook_servers'], false, false], ['Show the second skill from the earlier skill list.', 'skills', ['manage_skills'], false, false], ['Now read its full procedure and verification steps. Do not execute the procedure.', 'skills', ['manage_skills'], false, false], ]}, { name: 'cookbook-calendar-cookbook', turns: [ ['List configured Cookbook servers and their status.', 'cookbook_admin', ['list_cookbook_servers'], false, false], ['Now list my next three calendar events.', 'calendar', ['manage_calendar'], false, false], ['Which Cookbook server from earlier is the default? Check the server list again.', 'cookbook_admin', ['list_cookbook_servers'], false, false], ]}, { name: 'email-calendar-email', turns: [ ['List my latest three inbox emails with sender and subject.', 'email', ['list_emails'], false, false], ['Now list my next three calendar events.', 'calendar', ['manage_calendar'], false, false], ['Read the second email from the earlier inbox list and summarize it.', 'email', ['read_email'], false, false], ]}, { name: 'search-notes-search', turns: [ ['Search the web for the official IANA reserved domains page. Return the source.', 'search_browser', ['web_search'], true, false], ['Now list my first three notes.', 'notes', ['manage_notes'], false, false], ['Open the first web result from earlier and summarize it.', 'search_browser', ['web_fetch'], true, false], ]}, { name: 'browser-notes-browser', turns: [ ['Open https://example.com in the private browser and report its heading.', 'search_browser', ['private_browser'], false, false], ['Now list my first three notes.', 'notes', ['manage_notes'], false, false], ['Return to that browser page, open the Learn more link, and report the destination heading.', 'search_browser', ['private_browser'], false, false], ]}, { name: 'shell-notes-shell', turns: [ ["Use bash to run this read-only command and report its output: printf 'INTERLEAVED_SHELL_OK\\n'", 'shell_files', ['bash'], false, true], ['Now list my first three notes.', 'notes', ['manage_notes'], false, false], ['Run that same read-only shell command again and report its output.', 'shell_files', ['bash'], false, true], ]}, ].filter(chain => !selected.size || selected.has(chain.name)); if (!chains.length) throw Error('No matching chains selected'); const report = { run, owner, model, routing_mode: routingMode, endpoint_id: endpointId, status: 'running', chains: [], privacy: 'Public-only chains retain answer text and bounded public tool diagnostics. Mixed/private chains retain checks and argument keys, not private outputs or answers.', }; const save = () => { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n'); }; save(); const bare = value => String(value || '').replace(/^mcp__email__/, ''); const parseSSE = body => body.replace(/\r\n/g, '\n').split('\n\n').flatMap(frame => { const raw = frame.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n'); if (!raw || raw === '[DONE]') return []; try { return [JSON.parse(raw)]; } catch { return [{ type: 'invalid_sse' }]; } }); const noLeak = text => !/|Thinking Process:|UNTRUSTED SOURCE DATA|Analyze the Request:/i.test(String(text || '')); let browser, context, page; try { browser = await chromium.launch({ headless: true, args: ['--no-proxy-server'] }); context = await browser.newContext({ serviceWorkers: 'block', extraHTTPHeaders: { 'Accept-Encoding': 'identity', ...(routingMode === 'default' ? {} : {'x-odysseus-routing-experiment': routingMode}), } }); await context.addCookies([{ name: 'odysseus_session', value: token, url: base }]); for (const spec of chains) { const chain = { name: spec.name, status: 'running', turns: [], cleanup: false }; report.chains.push(chain); save(); let session; let previousEmailUids = []; let previousSkillRows = []; let previousSkillDetail = ''; try { const createStarted = performance.now(); let created; try { created = await context.request.post(`${base}/api/session`, { multipart: { name: `[interleaved-followup] ${spec.name}-${run}`, model, endpoint_id: endpointId, endpoint_url: endpointUrl, skip_validation: 'true', rag: 'false', }}); } finally { chain.session_create_ms = Math.round(performance.now() - createStarted); save(); } if (!created.ok()) throw Error(`Session create HTTP ${created.status()}`); session = (await created.json()).id; page = await context.newPage(); const pageErrors = []; page.on('pageerror', error => pageErrors.push(String(error).split('\n')[0].slice(0, 300))); page.on('console', message => { if (message.type() === 'error') pageErrors.push(message.text().slice(0, 300)); }); const historyReady = page.waitForResponse(r => { const url = new URL(r.url()); return url.pathname.startsWith('/api/history') && r.request().method() === 'GET'; }, { timeout: 30000 }).catch(() => null); await page.goto(`${base}/#${session}`, { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForFunction(id => window.sessionModule?.getCurrentSessionId() === id, session); await historyReady; await page.waitForFunction(id => { const history = document.querySelector('#chat-history'); return window.__odysseusSessionReadyId === id && history && !history.querySelector('.session-loading-state') && !history.classList.contains('no-animate') && getComputedStyle(history).opacity === '1'; }, session, { timeout: 30000 }); const agent = page.locator('#mode-agent-btn'); if (await agent.getAttribute('aria-pressed') !== 'true') await agent.click(); for (let index = 0; index < spec.turns.length; index++) { const [prompt, capability, expected, web, shell] = spec.turns[index]; if (expected.includes('read_email') && previousEmailUids.length < 2) { throw Error('PRECONDITION: fewer than two verified email results; ordinal replay is invalid'); } if (await page.locator('#web-toggle').isChecked() !== web) await page.locator('#web-toggle-btn').click(); if (await page.locator('#bash-toggle').isChecked() !== shell) await page.locator('#bash-toggle-btn').click(); const toggleStateBeforeSend = {web: await page.locator('#web-toggle').isChecked(), shell: await page.locator('#bash-toggle').isChecked()}; const waiting = page.waitForResponse(r => new URL(r.url()).pathname === '/api/chat_stream' && r.request().method() === 'POST', { timeout: 120000 }); const beforeUsers = await page.locator('#chat-history .msg-user').count(); await page.locator('textarea#message:visible').fill(prompt); await page.locator('textarea#message:visible').press('Enter'); const response = await waiting; const submitted = response.request().postData() || ''; const submittedToggle = name => { const match = submitted.match(new RegExp(`name="${name}"\\r?\\n\\r?\\n(true|false)`)); return match ? match[1] === 'true' : null; }; const events = parseSSE(await response.text()); await page.waitForFunction(n => document.querySelectorAll('#chat-history .msg-user').length === n && !document.querySelector('#chat-history .streaming'), beforeUsers + 1, { timeout: 15000 }).catch(() => {}); const contract = events.find(x => x.type === 'turn_contract') || {}; const metrics = events.findLast(x => x.type === 'metrics') || {}; const startEvents = events.filter(x => x.type === 'tool_start'); const starts = startEvents.map(x => bare(x.tool)); const calls = startEvents.map(x => { const raw = x.command ?? x.arguments ?? x.args ?? {}; let args = raw; if (typeof raw === 'string') { try { args = JSON.parse(raw); } catch { args = {}; } } const uid = String(args?.uid || ''); return { tool: bare(x.tool), argument_keys: Object.keys(args || {}).sort(), previous_email_uid_count: previousEmailUids.length, email_uid_ordinal: uid ? (previousEmailUids.indexOf(uid) + 1 || null) : null, email_account_present: Boolean(args?.account), ...(spec.name === 'skills-cookbook-skills' && bare(x.tool) === 'manage_skills' ? {skill_action: args?.action || null, skill_matches_second: Boolean(previousSkillRows[1] && (args?.name || args?.skill_id) === previousSkillRows[1].name)} : {}), }; }); const outputs = events.filter(x => x.type === 'tool_output').map(x => { const detail = String(x.output || x.error_message || ''); const backendError = /EMAIL ACCOUNT ERRORS|connection refused|connection timed out/i.test(detail); const ok = !backendError && !x.error && (x.exit_code == null || x.exit_code === 0); const failure_category = ok ? null : /not found|no such|unknown (?:uid|id)|does not exist/i.test(detail) ? 'not_found' : /invalid|missing|required|argument|json|parse/i.test(detail) ? 'invalid_arguments' : /connection|unavailable|timeout|refused/i.test(detail) ? 'backend_unavailable' : /permission|not offered|not permitted|denied/i.test(detail) ? 'permission_denied' : 'other'; return { tool: bare(x.tool), ok, failure_category, ...(bare(x.tool) === 'web_search' ? {evidence_status: x.evidence_status || null} : {}) }; }); for (const output of events.filter(x => x.type === 'tool_output' && bare(x.tool) === 'list_emails' && !x.error)) { let detail = String(output.output || ''); try { detail = JSON.parse(detail).stdout || detail; } catch {} previousEmailUids = [...detail.matchAll(/^\s*UID:\s*(\S+)/gmi)].map(match => match[1]); } const final = events.filter(x => x.type === 'final_response').map(x => x.content || '').join('') || events.filter(x => typeof x.delta === 'string').map(x => x.delta).join(''); if (spec.name === 'skills-cookbook-skills' && index === 0) { // Compare in memory only: never retain private skill names/content. previousSkillRows = events.filter(x => x.type === 'tool_output' && bare(x.tool) === 'manage_skills') .flatMap(x => { let detail = String(x.output || ''); try { const parsed = JSON.parse(detail); detail = parsed.stdout || parsed.results || detail; } catch {} return [...detail.matchAll(/^- \*\*([^*]+)\*\*[^\n]*?:\s*([^\n]*)/gm)] .map(match => ({name: match[1], description: match[2]})); }).filter(row => final.includes(row.name)) .sort((a, b) => final.indexOf(a.name) - final.indexOf(b.name)); } const offered = (contract.offered || []).map(bare); const priorCapability = index > 0 ? spec.turns[index - 1][1] : null; const priorFamilyTools = priorCapability ? { calendar: ['manage_calendar'], notes: ['manage_notes'], tasks: ['manage_tasks'], memory: ['manage_memory'], documents: ['manage_documents', 'create_document', 'edit_document'], skills: ['manage_skills'], cookbook_admin: ['list_cookbook_servers'], email: ['list_emails', 'read_email', 'search_emails', 'list_email_accounts'], search_browser: ['web_search', 'web_fetch', 'private_browser', 'pdf_extract', 'youtube_tool'], shell_files: ['bash'], }[priorCapability] || [] : []; const afterUsers = await page.locator('#chat-history .msg-user').count(); if (spec.name === 'skills-cookbook-skills' && index >= 2 && previousSkillRows[1]) { for (const event of events.filter(x => x.type === 'tool_output' && bare(x.tool) === 'manage_skills' && !x.error && (x.exit_code == null || x.exit_code === 0))) { let args = event.command || {}; if (typeof args === 'string') { try { args = JSON.parse(args); } catch { args = {}; } } if (args.action === 'view' && (args.name || args.skill_id) === previousSkillRows[1].name) { previousSkillDetail = String(event.output || ''); } } } const detailEvidence = skillDetailEvidence(previousSkillDetail, final); const reusedSkillDetail = spec.name === 'skills-cookbook-skills' && index === 3 && starts.length === 0 && detailEvidence.covered; const reusedSkillSummary = spec.name === 'skills-cookbook-skills' && index === 2 && starts.length === 0 && Boolean(previousSkillRows[1]?.description && final.includes(previousSkillRows[1].name) && final.includes(previousSkillRows[1].description)) && !/\b(?:cannot|can't|unable|not available|don't have|do not have)\b/i.test(final); const domClasses = afterUsers === beforeUsers ? await page.locator('#chat-history > *').evaluateAll(nodes => nodes.slice(-8).map(node => String(node.className || node.tagName || '').slice(0, 120)) ) : []; const checks = { experiment_selected: contract.routing_experiment === expectedMode, http_ok: response.ok(), terminal: response.ok() && !events.some(x => x.type === 'invalid_sse'), clean_route: contract.selection_mode === 'clean_compact_v3_preview', capability: Boolean(spec.deniedTools?.[index]) || capabilityAvailable(contract, capability, expected) || (spec.noToolTurns?.includes(index) && starts.length === 0) // An intentionally ambiguous continuation can use the retained // family without the classifier guessing a fresh active topic. || (!expected.length && index > 0 && routingMode !== 'baseline' && priorCapability === capability && priorFamilyTools.some(name => offered.includes(name))), expected_offered: !expected.length || expected.some(name => offered.includes(name)), expected_called: reusedSkillSummary || reusedSkillDetail || !expected.length || expected.some(name => starts.includes(name)), expected_execution_outcome: spec.expectedExitCodes?.[index] !== undefined ? events.filter(e => e.type === 'tool_output' && expected.includes(bare(e.tool))).length === 1 && events.some(e => e.type === 'tool_output' && expected.includes(bare(e.tool)) && e.exit_code === spec.expectedExitCodes[index]) : reusedSkillSummary || reusedSkillDetail || !expected.length || outputs.some(x => expected.includes(x.tool) && x.ok), requested_execution_count: spec.name !== 'shell-failure-recovery' || starts.length === (index === 1 ? 0 : 1), failed_execution_provenance: !(spec.expectedExitCodes?.[index] > 0) || events.some(e => e.type === 'tool_output' && expected.includes(bare(e.tool)) && e.exit_code === spec.expectedExitCodes[index] && e.execution_attempted === true && e.blocked === false), saved_failure_status: !(spec.expectedExitCodes?.[index] > 0) || (metrics.data?.clean_v3_turn || metrics.clean_v3_turn || []).some(m => { if (m.role !== 'tool') return false; try { return JSON.parse(m.content).exit_code === spec.expectedExitCodes[index]; } catch { return false; } }), exact_skill_detail_reference: spec.name !== 'skills-cookbook-skills' || index !== 3 || reusedSkillDetail || calls.some(call => call.tool === 'manage_skills' && call.skill_action === 'view' && call.skill_matches_second), skill_detail_answer_evidence: spec.name !== 'skills-cookbook-skills' || index !== 3 || detailEvidence.covered, no_prior_family_leak: routingMode !== 'baseline' || index === 0 || priorCapability === capability || offered.every(name => !priorFamilyTools.includes(name) || expected.includes(name)), one_user_turn: afterUsers === beforeUsers + 1, visible_answer: final.trim().length > 0, no_reasoning_leak: noLeak(final), no_canned_failure: Boolean(spec.deniedTools?.[index]) || !/can[’']?t perform that operation|no changes were made|currently permitted tools/i.test(final), no_tool_errors: outputs.every(item => item.ok || ( spec.deniedTools?.[index]?.includes(item.tool) && item.failure_category === 'permission_denied' && starts.length === 0) || (spec.expectedExitCodes?.[index] > 0 && expected.includes(item.tool) && events.some(e => e.type === 'tool_output' && bare(e.tool) === item.tool && e.exit_code === spec.expectedExitCodes[index]))), expected_answer_evidence: !spec.expectedAnswers || spec.expectedAnswers[index].every(value => final.toLowerCase().includes(value.toLowerCase())), disabled_tools_absent: !spec.deniedTools?.[index] || spec.deniedTools[index].every(name => !offered.includes(name)), disabled_request_not_executed: !spec.deniedTools?.[index] || starts.length === 0, submitted_shell_matches_toggle: submittedToggle('allow_bash') === toggleStateBeforeSend.shell, submitted_web_matches_toggle: submittedToggle('allow_web_search') === toggleStateBeforeSend.web, disabled_request_explained: !spec.deniedTools?.[index] || /disabled|not enabled|turn.{0,10}on|enable|can[’']?t|cannot|permission|turned off/i.test(final), }; const turn = { index, capability, expected, web, shell, offered, tools: starts, calls, outputs, classifier_capabilities: contract.active_capabilities || contract.capabilities || [], toggle_state_before_send: toggleStateBeforeSend, submitted_toggles: {allow_bash: submittedToggle('allow_bash'), allow_web_search: submittedToggle('allow_web_search')}, proposals: events.filter(x => x.type === 'model_tool_proposal').map(x => { let args; try { args = JSON.parse(x.function?.arguments || '{}'); } catch { args = null; } return {round: x.round, tool: bare(x.function?.name), valid_json: args !== null, argument_keys: args && typeof args === 'object' ? Object.keys(args).sort() : []}; }), recovered: events.some(x => x.type === 'completion_recovery') || outputs.some(x => !x.ok), metrics: Object.fromEntries(['input_tokens', 'output_tokens', 'injected_tokens', 'time_to_first_token', 'response_time'].map(key => [key, metrics[key] ?? metrics.data?.[key] ?? null])), user_count_before: beforeUsers, user_count_after: afterUsers, dom_classes_on_user_mismatch: domClasses, page_errors: pageErrors.splice(0), unavailable: contract.unavailable || [], checks, status: Object.values(checks).every(Boolean) ? 'passed' : 'failed' }; if (spec.publicAnswerAudit) { // Only explicitly public-only chains retain bounded answer/tool traces. turn.public_answer = final; turn.semantic_review = 'pending'; turn.public_tool_diagnostics = events.filter(event => event.type === 'tool_output' && (['private_browser', 'web_fetch', 'web_search', 'youtube_tool'].includes(bare(event.tool)) || (['shell-toggle-revocation', 'shell-output-followup', 'shell-failure-recovery'].includes(spec.name) && bare(event.tool) === 'bash'))) .map(event => ({tool: bare(event.tool), ...(spec.name.startsWith('browser-controlled-') || ['browser-link-followup', 'browser-keyboard-followup', 'browser-navigation-wording', 'web-toggle-revocation'].includes(spec.name) ? {command: event.command} : {}), observation_chars: String(event.output || '').length, exit_code: event.exit_code ?? null, execution_attempted: event.execution_attempted ?? null, blocked: event.blocked ?? null, observation_truncated: /\[.*truncated/i.test(String(event.output || '')), dialog_lines: String(event.output || '').split('\n').filter(line => /\bdialog\b|\bbutton\b.*(?:cookie|consent|accept|reject|拒否|同意)/i.test(line)).slice(0, 12).map(line => line.slice(0, 180)), output: String(event.output || event.error || '').slice(0, 2200)})); } if (spec.name === 'skills-cookbook-skills' && index >= 2) { const target = previousSkillRows[1]; turn.skill_followup_audit = { initial_named_rows: previousSkillRows.length, second_identity_in_answer: Boolean(target && final.includes(target.name)), prior_description_in_answer: Boolean(target?.description && final.includes(target.description)), explicit_inability: /\b(?:cannot|can't|unable|not available|don't have|do not have)\b/i.test(final), answer_chars: final.length, verified_summary_reuse: reusedSkillSummary, verified_detail_reuse: reusedSkillDetail, source_steps: detailEvidence.steps, all_source_steps_in_answer: detailEvidence.covered, }; } chain.turns.push(turn); save(); } chain.status = chain.turns.length === spec.turns.length && chain.turns.every(turn => turn.status === 'passed') ? 'passed' : 'failed'; } catch (error) { chain.status = 'failed'; chain.error = String(error).split('\n')[0].slice(0, 400); chain.infrastructure_failure = /PRECONDITION|Timeout|ECONN|HTTP 5/.test(chain.error); } finally { if (page) { await page.close(); page = null; } if (session && keepSession) { chain.debug_session = session; chain.cleanup = true; } else if (session) { chain.cleanup = (await context.request.delete(`${base}/api/session/${encodeURIComponent(session)}`)).ok(); } if (!chain.cleanup) chain.status = 'failed'; save(); } } } catch (error) { report.error = String(error).split('\n')[0].slice(0, 400); } finally { if (page) await page.close(); if (browser) await browser.close(); } report.status = report.chains.length === chains.length && report.chains.every(chain => chain.status === 'passed') ? 'passed' : 'failed'; report.summary = { passed: report.chains.filter(chain => chain.status === 'passed').length, total: chains.length, turns: report.chains.reduce((n, chain) => n + chain.turns.length, 0) }; save(); console.log(JSON.stringify({ report: path.relative(root, reportPath), status: report.status, summary: report.summary })); if (report.status !== 'passed') process.exitCode = 1;