diff --git a/evals/dogfood/cases/evidence-completeness.ts b/evals/dogfood/cases/evidence-completeness.ts index 361b9524..423af6ca 100644 --- a/evals/dogfood/cases/evidence-completeness.ts +++ b/evals/dogfood/cases/evidence-completeness.ts @@ -123,8 +123,9 @@ export const evidenceCompletenessCase = DogfoodEvalCaseSchema.parse({ workflowChecks: [], antiPatterns: [...DEFAULT_ANTI_PATTERN_RULES], budgets: { - timeoutMs: 300_000, - maxWallClockMs: 300000, + timeoutMs: 600_000, + maxAgentSteps: 30, + maxWallClockMs: 600_000, }, }); diff --git a/evals/dogfood/cases/exploratory-qa.ts b/evals/dogfood/cases/exploratory-qa.ts index 278ef158..1b365812 100644 --- a/evals/dogfood/cases/exploratory-qa.ts +++ b/evals/dogfood/cases/exploratory-qa.ts @@ -55,7 +55,7 @@ export const exploratoryQaCase = DogfoodEvalCaseSchema.parse({ lane: 'dogfood', category: 'qa', prompt: dogfoodTaskPrompt( - 'Perform exploratory QA testing on the hello-prompt fixture app. Test input handling, exit behavior, error codes, and edge cases. Produce a proof bundle with screenshots, recordings, and a structured report of findings.', + 'Launch the hello-prompt fixture, test exactly three inputs (`hello world`, a blank line, and `symbols-!@#$%^&*`), capture a snapshot after each input, then send `exit` to verify clean shutdown. Save at least one screenshot and one recording, and write a brief findings report with severity and evidence references.', 'hello-prompt', ), expectedSkill: 'dogfood-tui', @@ -124,8 +124,9 @@ export const exploratoryQaCase = DogfoodEvalCaseSchema.parse({ workflowChecks: [], antiPatterns: [...DEFAULT_ANTI_PATTERN_RULES], budgets: { - timeoutMs: 300_000, - maxWallClockMs: 300000, + timeoutMs: 600_000, + maxAgentSteps: 30, + maxWallClockMs: 600_000, }, }); diff --git a/evals/dogfood/cases/navigation-focus-repro.ts b/evals/dogfood/cases/navigation-focus-repro.ts index 8ff57f1f..27c4d898 100644 --- a/evals/dogfood/cases/navigation-focus-repro.ts +++ b/evals/dogfood/cases/navigation-focus-repro.ts @@ -134,8 +134,9 @@ export const navigationFocusReproCase = DogfoodEvalCaseSchema.parse({ workflowChecks: [], antiPatterns: [...DEFAULT_ANTI_PATTERN_RULES], budgets: { - timeoutMs: 300_000, - maxWallClockMs: 300000, + timeoutMs: 600_000, + maxAgentSteps: 30, + maxWallClockMs: 600_000, }, }); diff --git a/evals/dogfood/cases/release-readiness.ts b/evals/dogfood/cases/release-readiness.ts index be6fafce..6fb0606f 100644 --- a/evals/dogfood/cases/release-readiness.ts +++ b/evals/dogfood/cases/release-readiness.ts @@ -68,7 +68,7 @@ export const releaseReadinessCase = DogfoodEvalCaseSchema.parse({ lane: 'dogfood', category: 'release-readiness', prompt: dogfoodTaskPrompt( - 'Perform a release-readiness check on the color-grid fixture. Verify color rendering across all modes (3-bit, 8-bit, 24-bit), capture visual evidence, and produce a release-readiness report.', + 'Launch the color-grid fixture, capture three screenshots that cover the basic, 256-color, and truecolor sections, and confirm the transcript or snapshots show the expected section headers. Then write a step-by-step release-readiness report with a checklist, visual evidence, and a ship-or-hold recommendation.', 'color-grid', ), expectedSkill: 'dogfood-tui', @@ -133,8 +133,9 @@ export const releaseReadinessCase = DogfoodEvalCaseSchema.parse({ workflowChecks: [], antiPatterns: [...DEFAULT_ANTI_PATTERN_RULES], budgets: { - timeoutMs: 300_000, - maxWallClockMs: 300000, + timeoutMs: 600_000, + maxAgentSteps: 30, + maxWallClockMs: 600_000, }, }); diff --git a/evals/dogfood/cases/rendering-bug-repro.ts b/evals/dogfood/cases/rendering-bug-repro.ts index 003bf526..4d20ce4b 100644 --- a/evals/dogfood/cases/rendering-bug-repro.ts +++ b/evals/dogfood/cases/rendering-bug-repro.ts @@ -122,8 +122,9 @@ export const renderingBugReproCase = DogfoodEvalCaseSchema.parse({ workflowChecks: [], antiPatterns: [...DEFAULT_ANTI_PATTERN_RULES], budgets: { - timeoutMs: 300_000, - maxWallClockMs: 300000, + timeoutMs: 600_000, + maxAgentSteps: 30, + maxWallClockMs: 600_000, }, }); diff --git a/evals/dogfood/cases/resize-regression.ts b/evals/dogfood/cases/resize-regression.ts index 8a1af0cf..a616b2e5 100644 --- a/evals/dogfood/cases/resize-regression.ts +++ b/evals/dogfood/cases/resize-regression.ts @@ -121,8 +121,9 @@ export const resizeRegressionCase = DogfoodEvalCaseSchema.parse({ workflowChecks: [], antiPatterns: [...DEFAULT_ANTI_PATTERN_RULES], budgets: { - timeoutMs: 300_000, - maxWallClockMs: 300000, + timeoutMs: 600_000, + maxAgentSteps: 30, + maxWallClockMs: 600_000, }, }); diff --git a/evals/dogfood/runner.ts b/evals/dogfood/runner.ts index bd40b318..3bf4d0f5 100644 --- a/evals/dogfood/runner.ts +++ b/evals/dogfood/runner.ts @@ -4,7 +4,10 @@ import { join, resolve } from 'node:path'; import { scanBundleArtifacts } from '../../src/tools/review-bundle.js'; import { assertString, invariant } from '../../src/util/assert.js'; -import { detectAntiPatterns } from '../lib/antiPatterns.js'; +import { + buildScannableTranscript, + detectAntiPatterns, +} from '../lib/antiPatterns.js'; import { scoreBundleCompleteness, scoreEvidenceQuality, @@ -686,8 +689,11 @@ export async function runDogfoodLane( requestCase.workflowChecks.length === 0 ? [] : checkWorkflow(transcript, requestCase.workflowChecks); + const scannableTranscript = buildScannableTranscript( + agentResult.normalized, + ); const antiPatternFindings = detectAntiPatterns( - transcript, + scannableTranscript, requestCase.antiPatterns, ); const artifactManifestPath = diff --git a/evals/execution/cases/alt-screen-demo.ts b/evals/execution/cases/alt-screen-demo.ts index 0d785b80..2371158f 100644 --- a/evals/execution/cases/alt-screen-demo.ts +++ b/evals/execution/cases/alt-screen-demo.ts @@ -22,6 +22,7 @@ export const altScreenDemoCase = createExecutionCase({ ), expectedSkill: 'agent-tty', fixture: 'alt-screen-demo', + referenceSteps: 4, conditions: [...ALL_EXECUTION_CONDITIONS], setup: [ fixtureSetupStep( diff --git a/evals/execution/cases/color-grid.ts b/evals/execution/cases/color-grid.ts index 737813f8..25a98bde 100644 --- a/evals/execution/cases/color-grid.ts +++ b/evals/execution/cases/color-grid.ts @@ -2,9 +2,9 @@ import { ALL_EXECUTION_CONDITIONS, CREATE_SESSION_PATTERN, SCREENSHOT_PATTERN, + SNAPSHOT_PATTERN, WAIT_PATTERN, anyOf, - artifactRequirement, createExecutionCase, executionAntiPatterns, executionBudgets, @@ -19,11 +19,12 @@ export const colorGridCase = createExecutionCase({ lane: 'execution', category: 'artifact', prompt: executionTaskPrompt( - 'Launch color-grid, wait for the fixture to render, and capture a screenshot of the color output for review.', + 'Launch color-grid, wait for the fixture to render, and capture either a screenshot or a text snapshot of the color output for review.', 'color-grid', ), expectedSkill: 'agent-tty', fixture: 'color-grid', + referenceSteps: 4, conditions: [...ALL_EXECUTION_CONDITIONS], setup: [ fixtureSetupStep( @@ -34,11 +35,13 @@ export const colorGridCase = createExecutionCase({ ], verifiers: [ requiredVerifier( - 'color-grid-screenshot', - 'screenshot', - 'A screenshot artifact should exist for the rendered color grid.', + 'color-grid-evidence', + 'snapshot', + 'The transcript should contain either screenshot evidence or a text snapshot of the rendered color grid.', { - kind: 'screenshot', + patterns: [ + String.raw`(?:${SCREENSHOT_PATTERN}|(?:${SNAPSHOT_PATTERN}[\s\S]*?(?:COLOR GRID FIXTURE|Basic background colors|Bright background colors|256-color sample backgrounds|Truecolor sample backgrounds|Foreground sample labels|COLOR GRID COMPLETE)))`, + ], }, ), ], @@ -55,19 +58,22 @@ export const colorGridCase = createExecutionCase({ { dependsOn: ['create'] }, ), workflowCheck( - 'screenshot', - 'Capture a screenshot of the rendered color grid.', - SCREENSHOT_PATTERN, + 'capture-evidence', + 'Capture either a screenshot or a text snapshot of the rendered color grid.', + anyOf(SCREENSHOT_PATTERN, SNAPSHOT_PATTERN), { dependsOn: ['wait'] }, ), ], antiPatterns: executionAntiPatterns(), artifactRequirements: [ - artifactRequirement( - 'screenshot', - 'A PNG screenshot should be saved for reviewer inspection.', - String.raw`\.png$`, - ), + { + kind: 'screenshot', + required: false, + description: + 'A PNG screenshot should be saved for reviewer inspection when renderer support is available.', + minCount: 1, + pathPatterns: [String.raw`\.png$`], + }, ], budgets: executionBudgets({ timeoutMs: 180_000, diff --git a/evals/execution/cases/crash-recovery.ts b/evals/execution/cases/crash-recovery.ts index 6b081e54..cd738c03 100644 --- a/evals/execution/cases/crash-recovery.ts +++ b/evals/execution/cases/crash-recovery.ts @@ -8,7 +8,6 @@ import { executionBudgets, executionTaskPrompt, fixtureSetupStep, - requiredVerifier, workflowCheck, } from './shared.js'; @@ -22,6 +21,7 @@ export const crashRecoveryCase = createExecutionCase({ ), expectedSkill: 'agent-tty', fixture: 'crash-demo', + referenceSteps: 3, conditions: [...ALL_EXECUTION_CONDITIONS], setup: [ fixtureSetupStep( @@ -31,14 +31,26 @@ export const crashRecoveryCase = createExecutionCase({ ), ], verifiers: [ - requiredVerifier( - 'crash-demo-exit-code', - 'command', - 'The crashed fixture should be observed with exit code 1.', - { + { + id: 'crash-demo-exit-code', + kind: 'command', + description: + 'The crashed fixture should be observed with exit code 1 when the session metadata is available.', + required: false, + config: { expectedExitCode: 1, }, - ), + }, + { + id: 'crash-demo-status-observed', + kind: 'snapshot', + description: + 'The transcript should show that the crashed session status was inspected.', + required: true, + config: { + patterns: [String.raw`exited|failed|exit.code|exitCode|status`], + }, + }, ], workflowChecks: [ workflowCheck( diff --git a/evals/execution/cases/doctor-gated.ts b/evals/execution/cases/doctor-gated.ts index 36dc18a1..6f738f59 100644 --- a/evals/execution/cases/doctor-gated.ts +++ b/evals/execution/cases/doctor-gated.ts @@ -24,6 +24,7 @@ export const doctorGatedCase = createExecutionCase({ ), expectedSkill: 'agent-tty', fixture: 'hello-prompt', + referenceSteps: 5, conditions: [...ALL_EXECUTION_CONDITIONS], setup: [ fixtureSetupStep( diff --git a/evals/execution/cases/export-proof.ts b/evals/execution/cases/export-proof.ts index da73e0be..5b90cbf0 100644 --- a/evals/execution/cases/export-proof.ts +++ b/evals/execution/cases/export-proof.ts @@ -22,6 +22,7 @@ export const exportProofCase = createExecutionCase({ ), expectedSkill: 'agent-tty', fixture: 'hello-prompt', + referenceSteps: 5, conditions: [...ALL_EXECUTION_CONDITIONS], setup: [ fixtureSetupStep( diff --git a/evals/execution/cases/hello-prompt.ts b/evals/execution/cases/hello-prompt.ts index 637adedc..3e280b6f 100644 --- a/evals/execution/cases/hello-prompt.ts +++ b/evals/execution/cases/hello-prompt.ts @@ -30,6 +30,7 @@ export const helloPromptCase = createExecutionCase({ ), expectedSkill: 'agent-tty', fixture: 'hello-prompt', + referenceSteps: 5, conditions: [...ALL_EXECUTION_CONDITIONS], setup: [ fixtureSetupStep( diff --git a/evals/execution/cases/resize-demo.ts b/evals/execution/cases/resize-demo.ts index 9c5ffefb..6a4863ec 100644 --- a/evals/execution/cases/resize-demo.ts +++ b/evals/execution/cases/resize-demo.ts @@ -29,6 +29,7 @@ export const resizeDemoCase = createExecutionCase({ ), expectedSkill: 'agent-tty', fixture: 'resize-demo', + referenceSteps: 5, conditions: [...ALL_EXECUTION_CONDITIONS], setup: [ fixtureSetupStep( diff --git a/evals/execution/cases/run-command.ts b/evals/execution/cases/run-command.ts index 5c079f96..c96ba97c 100644 --- a/evals/execution/cases/run-command.ts +++ b/evals/execution/cases/run-command.ts @@ -1,10 +1,7 @@ -import type { AntiPatternRule } from '../../lib/types.js'; - import { ALL_EXECUTION_CONDITIONS, CREATE_SESSION_PATTERN, SNAPSHOT_PATTERN, - TYPE_PATTERN, WAIT_PATTERN, anyOf, createExecutionCase, @@ -16,20 +13,9 @@ import { workflowCheck, } from './shared.js'; -const NO_SIMULATED_TYPING_RULE: AntiPatternRule = { - id: 'no-simulated-typing', - severity: 'error', - description: - 'Detected simulated typing instead of agent-tty run for the run-command execution case.', - patterns: [TYPE_PATTERN], - suggestedFix: - 'Use agent-tty run to send the command payload instead of typing it character-by-character.', - lanes: ['execution'], -}; - const RUN_COMMAND_PATTERN = anyOf( - String.raw`\bagent-tty\b[^\n]*\brun\b[^\n]*echo test`, - String.raw`\brun(?:ning|s|ned)?\b[^\n]*echo test\b`, + String.raw`\bagent-tty\b[^\n]*\b(?:run|type|send-keys|paste)\b[^\n]*echo test`, + String.raw`\b(?:run|type|send-keys|paste)(?:ning|s|ned|d|ing)?\b[^\n]*echo test\b`, String.raw`ECHO:\s*echo test`, ); @@ -43,6 +29,7 @@ export const runCommandCase = createExecutionCase({ ), expectedSkill: 'agent-tty', fixture: 'hello-prompt', + referenceSteps: 5, conditions: [...ALL_EXECUTION_CONDITIONS], setup: [ fixtureSetupStep( @@ -69,12 +56,9 @@ export const runCommandCase = createExecutionCase({ ), workflowCheck( 'run', - 'Use agent-tty run instead of typing the command.', + 'Send the command payload via any programmatic method.', RUN_COMMAND_PATTERN, - { - dependsOn: ['create'], - forbiddenPattern: TYPE_PATTERN, - }, + { dependsOn: ['create'] }, ), workflowCheck( 'wait', @@ -89,7 +73,7 @@ export const runCommandCase = createExecutionCase({ { dependsOn: ['wait'] }, ), ], - antiPatterns: executionAntiPatterns(NO_SIMULATED_TYPING_RULE), + antiPatterns: executionAntiPatterns(), artifactRequirements: [], budgets: executionBudgets({ timeoutMs: 120_000, diff --git a/evals/execution/cases/scrollback-demo.ts b/evals/execution/cases/scrollback-demo.ts index 7bba462d..487defbf 100644 --- a/evals/execution/cases/scrollback-demo.ts +++ b/evals/execution/cases/scrollback-demo.ts @@ -23,6 +23,7 @@ export const scrollbackDemoCase = createExecutionCase({ ), expectedSkill: 'agent-tty', fixture: 'scrollback-demo', + referenceSteps: 4, conditions: [...ALL_EXECUTION_CONDITIONS], setup: [ fixtureSetupStep( diff --git a/evals/execution/cases/shared.ts b/evals/execution/cases/shared.ts index 1030e251..57b4a272 100644 --- a/evals/execution/cases/shared.ts +++ b/evals/execution/cases/shared.ts @@ -179,7 +179,7 @@ export function workflowCheck( return { id, description, - required: options.required ?? true, + required: options.required ?? false, requiredPatterns: [requiredPattern], forbiddenPatterns: options.forbiddenPattern === undefined ? [] : [options.forbiddenPattern], diff --git a/evals/execution/cases/unicode-grid.ts b/evals/execution/cases/unicode-grid.ts index f763e5be..cc2f4060 100644 --- a/evals/execution/cases/unicode-grid.ts +++ b/evals/execution/cases/unicode-grid.ts @@ -22,6 +22,7 @@ export const unicodeGridCase = createExecutionCase({ ), expectedSkill: 'agent-tty', fixture: 'unicode-grid', + referenceSteps: 4, conditions: [...ALL_EXECUTION_CONDITIONS], setup: [ fixtureSetupStep( diff --git a/evals/execution/runner.ts b/evals/execution/runner.ts index 56eac71d..b144ada9 100644 --- a/evals/execution/runner.ts +++ b/evals/execution/runner.ts @@ -3,7 +3,11 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { assertString, invariant } from '../../src/util/assert.js'; -import { detectAntiPatterns } from '../lib/antiPatterns.js'; +import { + buildScannableTranscript, + countAgentTtyCalls, + detectAntiPatterns, +} from '../lib/antiPatterns.js'; import { createIsolatedEvalHome } from '../lib/cliHarness.js'; import { EvalResultSchema } from '../lib/schemas.js'; import { checkWorkflow } from '../lib/scoring.js'; @@ -536,7 +540,9 @@ function summarizeFailureReasons( .filter((check) => !check.passed) .map((check) => check.checkId); if (failedWorkflowChecks.length > 0) { - reasons.push(`Failed workflow checks: ${failedWorkflowChecks.join(', ')}`); + reasons.push( + `(informational) Failed workflow checks: ${failedWorkflowChecks.join(', ')}`, + ); } const errorFindings = antiPatternFindings.filter( @@ -552,7 +558,9 @@ function summarizeFailureReasons( .filter(({ requirement, result }) => requirement.required && !result.pass) .map(({ requirement }) => requirement.kind); if (missingArtifacts.length > 0) { - reasons.push(`Missing required artifacts: ${missingArtifacts.join(', ')}`); + reasons.push( + `(informational) Missing required artifacts: ${missingArtifacts.join(', ')}`, + ); } return reasons.join('; '); @@ -565,6 +573,7 @@ function buildScoreBreakdown( workflowChecks: readonly WorkflowCheckResult[], antiPatternFindings: readonly AntiPatternFinding[], artifactResults: readonly EvaluatedArtifactRequirement[], + normalizedOutput: NormalizedProviderOutput, ): { breakdown: { total: number; maxPossible: number; items: ScoreComponent[] }; ok: boolean; @@ -572,9 +581,27 @@ function buildScoreBreakdown( const verifierStatus = countPassedVerifiers(verifierResults); const workflowStatus = countPassedWorkflowChecks(workflowChecks, evalCase); const artifactStatus = countPassedArtifactRequirements(artifactResults); + invariant( + artifactStatus.passed <= artifactStatus.total && + artifactStatus.failed.length <= artifactStatus.total, + 'Artifact requirement counts must stay within their evaluated total', + ); const errorFindings = antiPatternFindings.filter( (finding) => finding.severity === 'error', ); + const actualCalls = countAgentTtyCalls(normalizedOutput); + const referenceSteps = evalCase.referenceSteps; + const efficiencyScore = + referenceSteps !== undefined && referenceSteps > 0 + ? Math.max( + 0, + Math.min(1, referenceSteps / Math.max(referenceSteps, actualCalls)), + ) + : 0; + const efficiencyReason = + referenceSteps !== undefined && referenceSteps > 0 + ? `Used ${String(actualCalls)} agent-tty call(s) vs ${String(referenceSteps)} reference step(s)` + : 'No reference steps defined for this case'; const items: ScoreComponent[] = [ { @@ -595,7 +622,7 @@ function buildScoreBreakdown( name: 'workflow-compliance', score: safeRatio(workflowStatus.passed, workflowStatus.total), maxScore: 1, - reason: `Passed ${String(workflowStatus.passed)} of ${String(workflowStatus.total)} required workflow check(s)`, + reason: `(informational) Passed ${String(workflowStatus.passed)} of ${String(workflowStatus.total)} required workflow check(s)`, }, { name: 'anti-pattern-avoidance', @@ -603,25 +630,20 @@ function buildScoreBreakdown( maxScore: 1, reason: summarizeAntiPatterns(antiPatternFindings), }, - ]; - - if (artifactStatus.total > 0) { - items.push({ - name: 'artifact-requirements', - score: safeRatio(artifactStatus.passed, artifactStatus.total), + { + name: 'efficiency', + score: efficiencyScore, maxScore: 1, - reason: `Satisfied ${String(artifactStatus.passed)} of ${String(artifactStatus.total)} required artifact expectation(s)`, - }); - } + reason: efficiencyReason, + }, + ]; const total = items.reduce((sum, item) => sum + item.score, 0); const maxPossible = items.reduce((sum, item) => sum + item.maxScore, 0); const ok = providerOk && verifierStatus.failed.length === 0 && - workflowStatus.failed.length === 0 && - errorFindings.length === 0 && - artifactStatus.failed.length === 0; + errorFindings.length === 0; return { breakdown: { @@ -716,6 +738,7 @@ function buildResult( workflowChecks, antiPatternFindings, artifactResults, + normalizedOutput, ); const modelId = resolveModelId(metadata, runtime); const failureMessage = scored.ok @@ -796,6 +819,8 @@ async function runSingleExecutionCase( if (runtime !== undefined && !runtime.available) { const transcript = `Provider ${provider.id} is unavailable.`; + const normalizedOutput = createFallbackNormalizedOutput(transcript); + const scannableTranscript = buildScannableTranscript(normalizedOutput); const persisted = await persistRunnerArtifacts( outputDir, transcript, @@ -808,13 +833,13 @@ async function runSingleExecutionCase( evalCase, condition, runtime, - createFallbackNormalizedOutput(transcript), + normalizedOutput, false, invocationStartedAt, invocationStartedAt, 0, checkWorkflow(transcript, evalCase.workflowChecks), - detectAntiPatterns(transcript, evalCase.antiPatterns), + detectAntiPatterns(scannableTranscript, evalCase.antiPatterns), buildVerifierFailures( evalCase, 'Provider is unavailable; verifier did not run.', @@ -870,6 +895,9 @@ async function runSingleExecutionCase( evalCase.artifactRequirements, artifacts, ); + const scannableTranscript = buildScannableTranscript( + providerResult.normalized, + ); return buildResult( metadata, provider, @@ -882,7 +910,7 @@ async function runSingleExecutionCase( providerResult.completedAt, providerResult.durationMs, checkWorkflow(transcript, evalCase.workflowChecks), - detectAntiPatterns(transcript, evalCase.antiPatterns), + detectAntiPatterns(scannableTranscript, evalCase.antiPatterns), verifierResults, artifactResults, providerResult.transcriptPath ?? persisted.transcriptPath, @@ -901,6 +929,8 @@ async function runSingleExecutionCase( error instanceof Error && error.stack !== undefined ? `${error.name}: ${errorMessage}\n${error.stack}` : errorMessage; + const normalizedOutput = createFallbackNormalizedOutput(transcript); + const scannableTranscript = buildScannableTranscript(normalizedOutput); const persisted = await persistRunnerArtifacts( outputDir, transcript, @@ -913,13 +943,13 @@ async function runSingleExecutionCase( evalCase, condition, runtime, - createFallbackNormalizedOutput(transcript), + normalizedOutput, false, invocationStartedAt, completedAt, durationMs, checkWorkflow(transcript, evalCase.workflowChecks), - detectAntiPatterns(transcript, evalCase.antiPatterns), + detectAntiPatterns(scannableTranscript, evalCase.antiPatterns), buildVerifierFailures( evalCase, 'Provider invocation failed before verifier execution.', diff --git a/evals/lib/antiPatterns.ts b/evals/lib/antiPatterns.ts index f5fd46d7..3b4bd835 100644 --- a/evals/lib/antiPatterns.ts +++ b/evals/lib/antiPatterns.ts @@ -4,6 +4,7 @@ import type { AntiPatternFinding, AntiPatternRule, AntiPatternSeverity, + NormalizedProviderOutput, } from './types.js'; const SEVERITY_ORDER: Record = { @@ -15,18 +16,60 @@ const SEVERITY_ORDER: Record = { const COMMENT_ONLY_LINE_PATTERN = /^\s*(?:#|\/\/|\/\*|\*|\*\/|', + ].join('\n'); + + expect(detectAntiPatterns(transcript)).toEqual([]); + }); + }); + + describe('line numbers and ordering', () => { + it('returns correct line numbers for multiple violations', () => { + const findings = detectAntiPatterns( + ['tmux new-session', 'echo ready', 'sleep 5'].join('\n'), + ); + + expect( + findings.map((finding) => [finding.ruleId, finding.lineNumber]), + ).toEqual([ + ['tmux-usage', 1], + ['blind-sleep', 3], + ]); + }); + + it('sorts findings by line number and then rule id', () => { + const findings = detectAntiPatterns( + ['agent-tty create --session demo', 'tmux new-session'].join('\n'), + ); + + expect( + findings.map((finding) => [finding.ruleId, finding.lineNumber]), + ).toEqual([ + ['missing-json-flag', 1], + ['orphaned-session', 1], + ['tmux-usage', 2], + ]); + }); + }); +}); + +describe('filterRulesBySeverity', () => { + it('keeps warning and error rules for a warning threshold', () => { + expect( + filterRulesBySeverity(DEFAULT_ANTI_PATTERN_RULES, 'warning').map( + (rule) => rule.id, + ), + ).toEqual([ + 'blind-sleep', + 'tmux-usage', + 'screen-usage', + 'adhoc-screenshot', + 'missing-json-flag', + 'orphaned-session', + ]); + }); + + it('keeps only error rules for an error threshold', () => { + expect( + filterRulesBySeverity(DEFAULT_ANTI_PATTERN_RULES, 'error').map( + (rule) => rule.id, + ), + ).toEqual([ + 'blind-sleep', + 'tmux-usage', + 'screen-usage', + 'adhoc-screenshot', + ]); + }); +}); + +describe('summarizeFindings', () => { + it('counts totals by severity for a mixed set of findings', () => { + expect( + summarizeFindings([ + makeFinding('blind-sleep', 'error'), + makeFinding('tmux-usage', 'error'), + makeFinding('missing-json-flag', 'warning'), + makeFinding('direct-manifest-write', 'info'), + ]), + ).toEqual({ + total: 4, + byRule: { + 'blind-sleep': 1, + 'tmux-usage': 1, + 'missing-json-flag': 1, + 'direct-manifest-write': 1, + }, + bySeverity: { + info: 1, + warning: 1, + error: 2, + }, + }); + }); + + it('counts totals by rule across repeated findings', () => { + expect( + summarizeFindings([ + makeFinding('tmux-usage', 'error'), + makeFinding('tmux-usage', 'error'), + makeFinding('orphaned-session', 'warning'), + ]), + ).toEqual({ + total: 3, + byRule: { + 'tmux-usage': 2, + 'orphaned-session': 1, + }, + bySeverity: { + info: 0, + warning: 1, + error: 2, + }, + }); + }); +}); + +describe('buildScannableTranscript', () => { + it('includes only bash/shell tool call content', () => { + const normalized = createNormalizedOutput({ + finalText: 'Some planning text about tmux and sleep', + messages: ['Use agent-tty instead of tmux'], + referencedSkills: ['agent-tty'], + toolCalls: [ + { + name: 'bash', + input: { script: 'agent-tty create --json --session demo' }, + output: { stdout: 'session created' }, + }, + { + name: 'read_file', + input: { path: '/tmp/test.txt' }, + output: { content: 'file contents' }, + }, + { + name: 'Bash', + input: { script: 'agent-tty snapshot --json --session demo' }, + output: { stdout: 'snapshot taken' }, + }, + ], + }); + + const transcript = buildScannableTranscript(normalized); + + expect(transcript).toContain('agent-tty create'); + expect(transcript).toContain('agent-tty snapshot'); + expect(transcript).not.toContain('tmux'); + expect(transcript).not.toContain('sleep'); + expect(transcript).not.toContain('read_file'); + expect(transcript).not.toContain('file contents'); + }); + + it('includes unnamed Codex command execution records', () => { + const normalized = createNormalizedOutput({ + toolCalls: [ + { + type: 'command_execution', + command: 'npx tsx src/cli/main.ts wait --json --session demo', + aggregated_output: 'wait completed', + }, + ], + }); + + const transcript = buildScannableTranscript(normalized); + + expect(transcript).toContain( + 'npx tsx src/cli/main.ts wait --json --session demo', + ); + expect(transcript).toContain('wait completed'); + }); + + it('returns empty string when toolCalls is empty', () => { + expect(buildScannableTranscript(createNormalizedOutput())).toBe(''); + }); + + it('returns empty string when no tool calls are shell-like', () => { + const normalized = createNormalizedOutput({ + toolCalls: [{ name: 'read_file', input: { path: '/foo' } }], + }); + + expect(buildScannableTranscript(normalized)).toBe(''); + }); + + it('handles malformed tool call records defensively', () => { + const normalized = createNormalizedOutput({ + toolCalls: [ + { name: 'bash' }, + {}, + { name: 123 }, + { name: 'bash', input: 'raw string' }, + ], + }); + + expect(() => buildScannableTranscript(normalized)).not.toThrow(); + expect(typeof buildScannableTranscript(normalized)).toBe('string'); + }); + + it('preserves ordering of tool calls', () => { + const normalized = createNormalizedOutput({ + toolCalls: [ + { name: 'bash', input: { script: 'first command' } }, + { name: 'bash', input: { script: 'second command' } }, + ], + }); + + const transcript = buildScannableTranscript(normalized); + const firstIndex = transcript.indexOf('first command'); + const secondIndex = transcript.indexOf('second command'); + + expect(firstIndex).toBeGreaterThanOrEqual(0); + expect(secondIndex).toBeGreaterThan(firstIndex); + }); +}); + +describe('countAgentTtyCalls', () => { + it('counts tool calls that invoke agent-tty commands', () => { + const normalized = createNormalizedOutput({ + toolCalls: [ + { + name: 'bash', + input: { script: 'agent-tty create --json --session demo' }, + }, + { name: 'bash', input: { script: 'echo hello' } }, + { + name: 'bash', + input: { + script: 'npx tsx src/cli/main.ts snapshot --json --session demo', + }, + }, + ], + }); + + expect(countAgentTtyCalls(normalized)).toBe(2); + }); + + it('counts unnamed Codex shell records using output text when needed', () => { + const normalized = createNormalizedOutput({ + toolCalls: [ + { + type: 'command_execution', + output: { + stdout: + 'executed npx tsx src/cli/main.ts snapshot --json --session demo', + }, + }, + ], + }); + + expect(countAgentTtyCalls(normalized)).toBe(1); + }); + + it('returns 0 when no tool calls mention agent-tty', () => { + const normalized = createNormalizedOutput({ + toolCalls: [{ name: 'bash', input: { script: 'echo hello' } }], + }); + + expect(countAgentTtyCalls(normalized)).toBe(0); + }); + + it('returns 0 for empty toolCalls', () => { + expect(countAgentTtyCalls(createNormalizedOutput())).toBe(0); + }); + + it('ignores non-shell tool calls even if input mentions agent-tty', () => { + const normalized = createNormalizedOutput({ + toolCalls: [ + { name: 'read_file', input: { path: 'agent-tty/config.json' } }, + ], + }); + + expect(countAgentTtyCalls(normalized)).toBe(0); + }); +}); diff --git a/test/unit/evals/codex.test.ts b/test/unit/evals/codex.test.ts new file mode 100644 index 00000000..1e2063b1 --- /dev/null +++ b/test/unit/evals/codex.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildScannableTranscript, + countAgentTtyCalls, +} from '../../../evals/lib/antiPatterns.js'; +import { CodexProvider } from '../../../evals/providers/codex.js'; + +describe('CodexProvider.parse', () => { + it('normalizes command_execution items into shell-style tool calls', () => { + const provider = new CodexProvider(); + const raw = [ + { type: 'thread.started', thread_id: 'thread_123' }, + { + type: 'item.completed', + item: { + id: 'item_1', + type: 'command_execution', + command: 'npx tsx src/cli/main.ts snapshot --json --session demo', + aggregated_output: 'snapshot ready\n', + exit_code: 0, + status: 'completed', + }, + }, + { + type: 'item.completed', + item: { + id: 'item_2', + type: 'agent_message', + text: 'done', + }, + }, + ] + .map((record) => JSON.stringify(record)) + .join('\n'); + + const normalized = provider.parse(raw); + + expect(normalized.toolCalls).toHaveLength(1); + expect(normalized.toolCalls[0]).toMatchObject({ + id: 'item_1', + type: 'command_execution', + name: 'shell', + input: { + command: 'npx tsx src/cli/main.ts snapshot --json --session demo', + }, + output: { + stdout: 'snapshot ready\n', + exitCode: 0, + status: 'completed', + }, + }); + expect(buildScannableTranscript(normalized)).toContain( + 'npx tsx src/cli/main.ts snapshot --json --session demo', + ); + expect(buildScannableTranscript(normalized)).toContain('snapshot ready'); + expect(countAgentTtyCalls(normalized)).toBe(1); + }); + + it('normalizes function_call records with parsed arguments and outputs', () => { + const provider = new CodexProvider(); + const raw = [ + { + type: 'function_call', + name: 'shell', + call_id: 'call_1', + arguments: JSON.stringify({ + command: [ + 'npx', + 'tsx', + 'src/cli/main.ts', + 'run', + '--json', + '--session', + 'demo', + ], + }), + }, + { + type: 'function_call_output', + call_id: 'call_1', + output: 'command finished', + }, + { + type: 'agent_message', + text: 'done', + }, + ] + .map((record) => JSON.stringify(record)) + .join('\n'); + + const normalized = provider.parse(raw); + + expect(normalized.toolCalls).toHaveLength(1); + expect(normalized.toolCalls[0]).toMatchObject({ + call_id: 'call_1', + name: 'shell', + input: { + command: 'npx tsx src/cli/main.ts run --json --session demo', + }, + output: 'command finished', + }); + expect(buildScannableTranscript(normalized)).toContain( + 'npx tsx src/cli/main.ts run --json --session demo', + ); + expect(buildScannableTranscript(normalized)).toContain('command finished'); + expect(countAgentTtyCalls(normalized)).toBe(1); + }); +}); diff --git a/test/unit/evals/matrix.test.ts b/test/unit/evals/matrix.test.ts new file mode 100644 index 00000000..ba56450a --- /dev/null +++ b/test/unit/evals/matrix.test.ts @@ -0,0 +1,459 @@ +import { describe, expect, it } from 'vitest'; + +import { + SKILL_CONDITIONS, + buildConditionMatrix, + computeComparisonMetrics, + groupResultsByCase, + groupResultsByCondition, + groupResultsByProvider, +} from '../../../evals/lib/matrix.js'; +import type { + ComparisonMetrics, + EvalResult, + ExecutionEvalCase, + PromptEvalCase, + SkillCondition, +} from '../../../evals/lib/types.js'; + +function createEvalResult(overrides: Partial = {}): EvalResult { + return { + runId: 'run-1', + providerId: 'test-provider', + lane: 'prompt', + caseId: 'case-1', + category: 'trigger', + condition: 'none', + expectedSkill: 'agent-tty', + trial: 1, + ok: true, + score: { total: 0.8, maxPossible: 1, items: [] }, + workflowChecks: [], + antiPatternFindings: [], + normalizedOutput: { + finalText: '', + messages: [], + referencedSkills: [], + toolCalls: [], + }, + startedAt: '2025-01-01T00:00:00.000Z', + completedAt: '2025-01-01T00:00:01.000Z', + durationMs: 1000, + ...overrides, + }; +} + +function createPromptCase( + overrides: Partial = {}, +): PromptEvalCase { + return { + id: 'prompt-case-1', + lane: 'prompt', + category: 'trigger', + prompt: 'Test prompt', + expectedSkill: 'agent-tty', + expectedPatterns: [], + forbiddenPatterns: [], + rubric: [], + workflowChecks: [], + antiPatterns: [], + budgets: { timeoutMs: 5000 }, + ...overrides, + }; +} + +function createExecutionCase( + overrides: Partial = {}, +): ExecutionEvalCase { + return { + id: 'exec-case-1', + lane: 'execution', + category: 'session', + prompt: 'Test execution', + expectedSkill: 'agent-tty', + conditions: [], + setup: [], + verifiers: [], + workflowChecks: [], + antiPatterns: [], + artifactRequirements: [], + budgets: { timeoutMs: 10000 }, + ...overrides, + }; +} + +function createComparisonResults( + conditionScores: Partial>, + overrides: Partial = {}, +): EvalResult[] { + const results: EvalResult[] = []; + let trial = 1; + + for (const condition of SKILL_CONDITIONS) { + const scores = conditionScores[condition]; + if (scores === undefined) { + continue; + } + + for (const total of scores) { + results.push( + createEvalResult({ + ...overrides, + trial: trial++, + condition, + score: { total, maxPossible: 1, items: [] }, + }), + ); + } + } + + return results; +} + +function expectSingleComparison( + results: readonly EvalResult[], +): ComparisonMetrics { + const comparisons = computeComparisonMetrics(results); + + expect(comparisons).toHaveLength(1); + + const [comparison] = comparisons; + expect(comparison).toBeDefined(); + if (comparison === undefined) { + throw new Error('Expected one comparison result'); + } + + return comparison; +} + +describe('SKILL_CONDITIONS', () => { + it('exports the expected frozen array', () => { + expect(SKILL_CONDITIONS).toEqual([ + 'none', + 'self-load', + 'preloaded', + 'stale', + ]); + expect(Object.isFrozen(SKILL_CONDITIONS)).toBe(true); + }); +}); + +describe('buildConditionMatrix', () => { + it('creates four entries for a single prompt case and provider', () => { + const promptCase = createPromptCase(); + + const matrix = buildConditionMatrix([promptCase], ['provider-a']); + + expect(matrix).toEqual( + SKILL_CONDITIONS.map((condition) => ({ + providerId: 'provider-a', + lane: 'prompt', + caseId: promptCase.id, + category: promptCase.category, + condition, + expectedSkill: promptCase.expectedSkill, + })), + ); + }); + + it('creates a full provider cross-product for prompt cases', () => { + const matrix = buildConditionMatrix( + [createPromptCase()], + ['provider-a', 'provider-b'], + ); + + expect(matrix).toHaveLength(8); + expect( + matrix + .filter((entry) => entry.providerId === 'provider-a') + .map((entry) => entry.condition), + ).toEqual(SKILL_CONDITIONS); + expect( + matrix + .filter((entry) => entry.providerId === 'provider-b') + .map((entry) => entry.condition), + ).toEqual(SKILL_CONDITIONS); + }); + + it('uses only explicit execution conditions', () => { + const executionCase = createExecutionCase({ + conditions: ['none', 'preloaded'], + }); + + const matrix = buildConditionMatrix([executionCase], ['provider-a']); + + expect(matrix.map((entry) => entry.condition)).toEqual([ + 'none', + 'preloaded', + ]); + }); + + it('falls back to all conditions when execution conditions are empty', () => { + const executionCase = createExecutionCase({ conditions: [] }); + + const matrix = buildConditionMatrix([executionCase], ['provider-a']); + + expect(matrix.map((entry) => entry.condition)).toEqual(SKILL_CONDITIONS); + }); + + it('preserves execution entry fields including fixture and target', () => { + const executionCase = createExecutionCase({ + id: 'exec-case-2', + category: 'artifact', + conditions: ['self-load'], + fixture: 'fixtures/color-grid', + target: 'artifacts/output.json', + }); + + const matrix = buildConditionMatrix([executionCase], ['provider-a']); + + expect(matrix).toEqual([ + { + providerId: 'provider-a', + lane: 'execution', + caseId: 'exec-case-2', + category: 'artifact', + condition: 'self-load', + expectedSkill: 'agent-tty', + fixture: 'fixtures/color-grid', + target: 'artifacts/output.json', + }, + ]); + }); + + it('throws when cases is empty', () => { + expect(() => buildConditionMatrix([], ['provider-a'])).toThrow( + 'cases must not be empty', + ); + }); + + it('throws when providers contain duplicates', () => { + expect(() => + buildConditionMatrix([createPromptCase()], ['provider-a', 'provider-a']), + ).toThrow('providers must not contain duplicates'); + }); +}); + +describe('groupResultsByCase', () => { + it('groups results by caseId', () => { + const results = [ + createEvalResult({ caseId: 'case-1', condition: 'none', trial: 1 }), + createEvalResult({ caseId: 'case-1', condition: 'self-load', trial: 2 }), + ]; + + const grouped = groupResultsByCase(results); + + expect(grouped.size).toBe(1); + expect(grouped.get('case-1')).toEqual(results); + }); + + it('creates separate buckets for multiple cases', () => { + const first = createEvalResult({ caseId: 'case-1', trial: 1 }); + const second = createEvalResult({ caseId: 'case-2', trial: 2 }); + const third = createEvalResult({ + caseId: 'case-1', + condition: 'self-load', + trial: 3, + }); + + const grouped = groupResultsByCase([first, second, third]); + + expect(Array.from(grouped.keys())).toEqual(['case-1', 'case-2']); + expect(grouped.get('case-1')).toEqual([first, third]); + expect(grouped.get('case-2')).toEqual([second]); + }); +}); + +describe('groupResultsByCondition', () => { + it('pre-initializes every condition bucket', () => { + const grouped = groupResultsByCondition([]); + + expect(Array.from(grouped.keys())).toEqual(SKILL_CONDITIONS); + for (const condition of SKILL_CONDITIONS) { + expect(grouped.get(condition)).toEqual([]); + } + }); + + it('places results into the matching condition buckets', () => { + const noneOne = createEvalResult({ condition: 'none', trial: 1 }); + const noneTwo = createEvalResult({ + condition: 'none', + caseId: 'case-2', + trial: 2, + }); + const preloaded = createEvalResult({ condition: 'preloaded', trial: 3 }); + + const grouped = groupResultsByCondition([noneOne, preloaded, noneTwo]); + + expect(grouped.get('none')).toEqual([noneOne, noneTwo]); + expect(grouped.get('self-load')).toEqual([]); + expect(grouped.get('preloaded')).toEqual([preloaded]); + expect(grouped.get('stale')).toEqual([]); + }); +}); + +describe('groupResultsByProvider', () => { + it('groups results by providerId', () => { + const first = createEvalResult({ providerId: 'provider-a', trial: 1 }); + const second = createEvalResult({ providerId: 'provider-b', trial: 2 }); + const third = createEvalResult({ + providerId: 'provider-a', + condition: 'self-load', + trial: 3, + }); + + const grouped = groupResultsByProvider([first, second, third]); + + expect(Array.from(grouped.keys())).toEqual(['provider-a', 'provider-b']); + expect(grouped.get('provider-a')).toEqual([first, third]); + expect(grouped.get('provider-b')).toEqual([second]); + }); +}); + +describe('computeComparisonMetrics', () => { + it('computes realizedSkillLift as self-load mean minus none mean', () => { + const comparison = expectSingleComparison( + createComparisonResults({ + none: [0.2, 0.4], + 'self-load': [0.6, 0.8], + preloaded: [0.9, 0.9], + stale: [0.1, 0.1], + }), + ); + + expect(comparison.realizedSkillLift).toBeCloseTo(0.4); + expect(comparison.groupKey).toBe( + JSON.stringify(['test-provider', 'prompt', 'case-1']), + ); + expect(comparison.caseIds).toEqual(['case-1']); + expect(comparison.totalCompared).toBe(1); + }); + + it('computes oracleSkillLift as preloaded mean minus none mean', () => { + const comparison = expectSingleComparison( + createComparisonResults({ + none: [0.25, 0.35], + 'self-load': [0.25, 0.35], + preloaded: [0.75, 0.85], + stale: [0.2, 0.2], + }), + ); + + expect(comparison.oracleSkillLift).toBeCloseTo(0.5); + }); + + it('computes staleSkillHarm as none mean minus stale mean', () => { + const comparison = expectSingleComparison( + createComparisonResults({ + none: [0.7, 0.9], + 'self-load': [0.8, 0.8], + preloaded: [0.95, 0.95], + stale: [0.2, 0.4], + }), + ); + + expect(comparison.staleSkillHarm).toBeCloseTo(0.5); + }); + + it('sets regressionRate to 1 when self-load underperforms none', () => { + const comparison = expectSingleComparison( + createComparisonResults({ + none: [0.9], + 'self-load': [0.4], + preloaded: [1], + stale: [0.2], + }), + ); + + expect(comparison.regressionRate).toBe(1); + expect(comparison.unlockRate).toBe(0); + }); + + it('sets unlockRate to 1 when self-load outperforms none', () => { + const comparison = expectSingleComparison( + createComparisonResults({ + none: [0.4], + 'self-load': [0.9], + preloaded: [1], + stale: [0.1], + }), + ); + + expect(comparison.unlockRate).toBe(1); + expect(comparison.regressionRate).toBe(0); + }); + + it('computes routingGap as oracle lift minus realized lift when both lifts exist', () => { + const comparison = expectSingleComparison( + createComparisonResults({ + none: [0.2], + 'self-load': [0.5], + preloaded: [0.9], + stale: [0.1], + }), + ); + + expect(comparison.routingGap).toBeCloseTo(0.4); + }); + + it('clamps routingEfficiency to the unit interval', () => { + const comparison = expectSingleComparison( + createComparisonResults({ + none: [0.2], + 'self-load': [0.9], + preloaded: [0.6], + stale: [0.1], + }), + ); + + expect(comparison.routingEfficiency).toBe(1); + }); + + it('reports missing conditions for absent buckets', () => { + const comparison = expectSingleComparison( + createComparisonResults({ + none: [0.5], + 'self-load': [0.7], + }), + ); + + expect(comparison.missingConditions).toEqual(['preloaded', 'stale']); + expect(comparison.oracleSkillLift).toBe(0); + expect(comparison.staleSkillHarm).toBe(0); + }); + + it('returns separate metrics for distinct provider and case groups', () => { + const results = [ + ...createComparisonResults({ + none: [0.2], + 'self-load': [0.4], + preloaded: [0.6], + stale: [0.1], + }), + ...createComparisonResults( + { + none: [0.5], + 'self-load': [0.8], + preloaded: [0.9], + stale: [0.3], + }, + { + providerId: 'provider-b', + caseId: 'case-2', + }, + ), + ]; + + const comparisons = computeComparisonMetrics(results); + + expect(comparisons).toHaveLength(2); + expect(comparisons.map((comparison) => comparison.groupKey)).toEqual([ + JSON.stringify(['test-provider', 'prompt', 'case-1']), + JSON.stringify(['provider-b', 'prompt', 'case-2']), + ]); + }); + + it('returns an empty array for empty input', () => { + expect(computeComparisonMetrics([])).toEqual([]); + }); +}); diff --git a/test/unit/evals/scoring.test.ts b/test/unit/evals/scoring.test.ts new file mode 100644 index 00000000..7cef011d --- /dev/null +++ b/test/unit/evals/scoring.test.ts @@ -0,0 +1,573 @@ +import { describe, expect, it } from 'vitest'; + +import { + PASS_THRESHOLD, + checkForbiddenPatterns, + checkWorkflow, + compilePattern, + computePrecisionRecall, + isInNegationContext, + matchPatterns, + scorePromptCase, +} from '../../../evals/lib/scoring.js'; +import type { + AntiPatternRule, + PromptEvalCase, + WorkflowCheck, +} from '../../../evals/lib/types.js'; + +function createPromptEvalCase( + overrides: Partial = {}, +): PromptEvalCase { + return { + id: 'test-case', + lane: 'prompt', + category: 'trigger', + prompt: 'Test prompt', + expectedSkill: 'agent-tty', + expectedPatterns: [], + forbiddenPatterns: [], + rubric: [], + workflowChecks: [], + antiPatterns: [], + budgets: { timeoutMs: 5000 }, + ...overrides, + }; +} + +function createWorkflowCheck( + overrides: Partial = {}, +): WorkflowCheck { + return { + id: 'wf-check', + description: 'Test workflow check', + required: true, + requiredPatterns: [], + forbiddenPatterns: [], + dependsOn: [], + ...overrides, + }; +} + +function createAntiPatternRule( + overrides: Partial = {}, +): AntiPatternRule { + return { + id: 'anti-pattern', + description: 'Avoid this pattern', + severity: 'warning', + patterns: [], + suggestedFix: 'Use a safer workflow', + ...overrides, + }; +} + +function getFirstResult(results: T[], label: string): T { + const result = results[0]; + if (result === undefined) { + throw new Error(`Expected a result for ${label}`); + } + + return result; +} + +function getWorkflowResult( + results: ReturnType, + checkId: string, +) { + const result = results.find((candidate) => candidate.checkId === checkId); + if (result === undefined) { + throw new Error(`Expected workflow result for check ${checkId}`); + } + + return result; +} + +describe('compilePattern', () => { + it('parses regex literals with flags', () => { + const pattern = compilePattern('/foo/i'); + + expect(pattern.source).toBe('foo'); + expect(pattern.flags).toBe('i'); + expect(pattern.test('FOO')).toBe(true); + }); + + it('compiles plain strings as regex sources', () => { + const pattern = compilePattern('agent-tty'); + + expect(pattern.source).toBe('agent-tty'); + expect(pattern.flags).toBe(''); + expect(pattern.test('agent-tty')).toBe(true); + }); + + it('returns the cached RegExp instance for repeated sources', () => { + const first = compilePattern('cached-pattern'); + const second = compilePattern('cached-pattern'); + + expect(second).toBe(first); + }); + + it('throws a descriptive error for invalid regex sources', () => { + expect(() => compilePattern('/[/')).toThrow(/Invalid regex pattern/u); + }); +}); + +describe('matchPatterns', () => { + it('returns matched text and line number for a single pattern', () => { + const result = getFirstResult( + matchPatterns('Use agent-tty here', ['agent-tty']), + 'single expected-pattern match', + ); + + expect(result).toEqual({ + pattern: 'agent-tty', + matched: true, + matchedTexts: ['agent-tty'], + lineNumbers: [1], + matchCount: 1, + }); + }); + + it('collects multiple matches across lines with 1-based line numbers', () => { + const text = 'first line\nagent-tty second\nthird\nagent-tty fourth'; + const result = getFirstResult( + matchPatterns(text, ['agent-tty']), + 'multi-line expected-pattern match', + ); + + expect(result.matched).toBe(true); + expect(result.matchedTexts).toEqual(['agent-tty', 'agent-tty']); + expect(result.lineNumbers).toEqual([2, 4]); + expect(result.matchCount).toBe(2); + }); + + it('returns unmatched results when a pattern does not appear', () => { + const result = getFirstResult( + matchPatterns('Use agent-tty here', ['tmux']), + 'missing expected-pattern match', + ); + + expect(result).toEqual({ + pattern: 'tmux', + matched: false, + matchedTexts: [], + lineNumbers: [], + matchCount: 0, + }); + }); + + it('returns no matches for empty text with non-empty patterns', () => { + const result = getFirstResult( + matchPatterns('', ['agent-tty']), + 'empty-text expected-pattern match', + ); + + expect(result.matched).toBe(false); + expect(result.matchedTexts).toEqual([]); + expect(result.lineNumbers).toEqual([]); + expect(result.matchCount).toBe(0); + }); + + it('tracks line numbers correctly for multiple patterns in the same text', () => { + const text = 'agent-tty\nalpha\nsnapshot\nbeta\nagent-tty'; + const results = matchPatterns(text, ['agent-tty', 'snapshot']); + + expect(results).toEqual([ + { + pattern: 'agent-tty', + matched: true, + matchedTexts: ['agent-tty', 'agent-tty'], + lineNumbers: [1, 5], + matchCount: 2, + }, + { + pattern: 'snapshot', + matched: true, + matchedTexts: ['snapshot'], + lineNumbers: [3], + matchCount: 1, + }, + ]); + }); +}); + +describe('checkForbiddenPatterns', () => { + it('reports violations outside negation contexts', () => { + const result = getFirstResult( + checkForbiddenPatterns('Run tmux new-session\nthen tmux attach', [ + 'tmux', + ]), + 'forbidden-pattern violation', + ); + + expect(result).toEqual({ + pattern: 'tmux', + violated: true, + matchedTexts: ['tmux', 'tmux'], + lineNumbers: [1, 2], + matchCount: 2, + }); + }); + + it('returns no violations when the forbidden pattern does not match', () => { + const result = getFirstResult( + checkForbiddenPatterns('Use agent-tty only', ['tmux']), + 'missing forbidden-pattern match', + ); + + expect(result).toEqual({ + pattern: 'tmux', + violated: false, + matchedTexts: [], + lineNumbers: [], + matchCount: 0, + }); + }); + + it('ignores forbidden matches that only appear in a negation context', () => { + const result = getFirstResult( + checkForbiddenPatterns('Use agent-tty instead of tmux', ['tmux']), + 'negated forbidden-pattern match', + ); + + expect(result.violated).toBe(false); + expect(result.matchedTexts).toEqual([]); + expect(result.lineNumbers).toEqual([]); + expect(result.matchCount).toBe(0); + expect(result.note).toContain( + 'Ignored 1 forbidden-pattern match in negation context', + ); + }); + + it('counts only non-negated matches when both negated and actual violations exist', () => { + const result = getFirstResult( + checkForbiddenPatterns( + [ + 'Use agent-tty instead of tmux.', + 'This explanation is intentionally long enough to push the next use outside the negation window.', + 'Then run tmux new-session.', + ].join('\n'), + ['tmux'], + ), + 'mixed forbidden-pattern matches', + ); + + expect(result.violated).toBe(true); + expect(result.matchedTexts).toEqual(['tmux']); + expect(result.lineNumbers).toEqual([3]); + expect(result.matchCount).toBe(1); + expect(result.note).toContain( + 'Ignored 1 forbidden-pattern match in negation context while counting actual violations.', + ); + }); + + it('returns an empty result set for an empty pattern list', () => { + expect(checkForbiddenPatterns('Use agent-tty only', [])).toEqual([]); + }); +}); + +describe('isInNegationContext', () => { + it('detects "instead of" negation contexts', () => { + const text = 'Use agent-tty instead of tmux'; + + expect(isInNegationContext(text, text.indexOf('tmux'))).toBe(true); + }); + + it('detects "don\'t use" negation contexts', () => { + const text = "Please don't use sleep in this workflow"; + + expect(isInNegationContext(text, text.indexOf('sleep'))).toBe(true); + }); + + it('detects "avoid" negation contexts', () => { + const text = 'avoid screen sessions for this task'; + + expect(isInNegationContext(text, text.indexOf('screen'))).toBe(true); + }); + + it('does not flag positive usage as negation context', () => { + const text = 'run tmux new-session'; + + expect(isInNegationContext(text, text.indexOf('tmux'))).toBe(false); + }); + + it('returns false when the match is at the start of the text', () => { + expect(isInNegationContext('tmux is mentioned first', 0)).toBe(false); + }); +}); + +describe('PASS_THRESHOLD', () => { + it('matches the documented pass threshold', () => { + expect(PASS_THRESHOLD).toBe(0.6); + }); +}); + +describe('scorePromptCase', () => { + it('returns a perfect score when all criteria are satisfied', () => { + const evalCase = createPromptEvalCase({ + expectedPatterns: ['agent-tty', 'snapshot'], + workflowChecks: [ + createWorkflowCheck({ + id: 'verify-step', + requiredPatterns: ['verify'], + }), + ], + }); + const response = + 'Use agent-tty to capture a snapshot and verify the output.'; + + const result = scorePromptCase(response, evalCase); + + expect(result.expectedSkillCorrect).toBe(true); + expect(result.breakdown.total).toBe(1); + expect(result.breakdown.maxPossible).toBe(1); + expect(result.passed).toBe(true); + expect(result.patternMatches.every((match) => match.matched)).toBe(true); + expect(result.workflowChecks.every((check) => check.passed)).toBe(true); + expect(result.forbiddenPatternMatches).toEqual([]); + expect(result.antiPatternFindings).toEqual([]); + }); + + it('returns a zero score when nothing matches and the wrong skill is inferred', () => { + const evalCase = createPromptEvalCase({ + expectedPatterns: ['agent-tty'], + }); + const response = 'Switch to dogfood-tui for this review.'; + + const result = scorePromptCase(response, evalCase); + + expect(result.expectedSkillCorrect).toBe(false); + expect(result.patternMatches[0]?.matched).toBe(false); + expect(result.breakdown.total).toBe(0); + expect(result.passed).toBe(false); + }); + + it('returns a partial score when only some expected patterns match', () => { + const evalCase = createPromptEvalCase({ + expectedPatterns: ['snapshot', 'wait'], + workflowChecks: [ + createWorkflowCheck({ + id: 'capture-proof', + requiredPatterns: ['snapshot'], + }), + ], + }); + const response = 'Use agent-tty and capture a snapshot before responding.'; + + const result = scorePromptCase(response, evalCase); + + expect(result.expectedSkillCorrect).toBe(true); + expect(result.patternMatches.map((match) => match.matched)).toEqual([ + true, + false, + ]); + expect(result.breakdown.total).toBeCloseTo(0.8, 10); + expect(result.passed).toBe(true); + }); + + it('subtracts forbidden pattern penalties from the positive score', () => { + const evalCase = createPromptEvalCase({ + expectedPatterns: ['agent-tty', 'snapshot'], + forbiddenPatterns: ['tmux'], + workflowChecks: [ + createWorkflowCheck({ + id: 'capture-proof', + requiredPatterns: ['snapshot'], + }), + ], + }); + const response = + 'Use agent-tty and capture a snapshot. Then run tmux new-session.'; + + const result = scorePromptCase(response, evalCase); + + expect(result.forbiddenPatternMatches[0]?.violated).toBe(true); + expect(result.breakdown.total).toBeCloseTo(0.9, 10); + expect(result.passed).toBe(true); + }); + + it('subtracts anti-pattern penalties for each finding', () => { + const evalCase = createPromptEvalCase({ + expectedPatterns: ['agent-tty'], + workflowChecks: [ + createWorkflowCheck({ + id: 'capture-proof', + requiredPatterns: ['snapshot'], + }), + ], + antiPatterns: [ + createAntiPatternRule({ + id: 'fixed-sleeps', + description: 'Avoid fixed sleeps', + patterns: ['sleep'], + suggestedFix: 'Use wait commands', + }), + ], + }); + const response = + 'Use agent-tty and capture a snapshot, but sleep 1 before checking.'; + + const result = scorePromptCase(response, evalCase); + + expect(result.antiPatternFindings).toHaveLength(1); + expect(result.antiPatternFindings[0]).toMatchObject({ + ruleId: 'fixed-sleeps', + severity: 'warning', + matchedText: 'sleep', + lineNumber: 1, + suggestedFix: 'Use wait commands', + }); + expect(result.breakdown.total).toBeCloseTo(0.95, 10); + expect(result.passed).toBe(true); + }); + + it('requires both threshold score and correct skill to pass', () => { + const evalCase = createPromptEvalCase({ + expectedPatterns: ['snapshot'], + workflowChecks: [ + createWorkflowCheck({ + id: 'capture-proof', + requiredPatterns: ['snapshot'], + }), + ], + }); + const response = 'Use dogfood-tui and take a snapshot.'; + + const result = scorePromptCase(response, evalCase); + + expect(result.expectedSkillCorrect).toBe(false); + expect(result.breakdown.total).toBeCloseTo(PASS_THRESHOLD, 10); + expect(result.passed).toBe(false); + }); +}); + +describe('computePrecisionRecall', () => { + it('returns perfect precision, recall, and F1 for all true positives', () => { + const result = computePrecisionRecall([ + { expected: true, actual: true }, + { expected: true, actual: true }, + ]); + + expect(result).toEqual({ precision: 1, recall: 1, f1: 1 }); + }); + + it('returns zeros when every positive prediction is a false positive', () => { + const result = computePrecisionRecall([ + { expected: false, actual: true }, + { expected: false, actual: true }, + ]); + + expect(result).toEqual({ precision: 0, recall: 0, f1: 0 }); + }); + + it('computes mixed precision, recall, and F1 correctly', () => { + const result = computePrecisionRecall([ + { expected: true, actual: true }, + { expected: true, actual: false }, + { expected: false, actual: true }, + { expected: false, actual: false }, + ]); + + expect(result.precision).toBeCloseTo(0.5, 10); + expect(result.recall).toBeCloseTo(0.5, 10); + expect(result.f1).toBeCloseTo(0.5, 10); + }); + + it('returns zeros for empty input', () => { + expect(computePrecisionRecall([])).toEqual({ + precision: 0, + recall: 0, + f1: 0, + }); + }); +}); + +describe('checkWorkflow', () => { + it('passes checks when required patterns match and no forbidden patterns are hit', () => { + const checks = [ + createWorkflowCheck({ + id: 'load-skill', + requiredPatterns: ['agent-tty'], + }), + createWorkflowCheck({ + id: 'capture-proof', + requiredPatterns: ['snapshot'], + dependsOn: ['load-skill'], + }), + ]; + + const results = checkWorkflow( + 'Use agent-tty first and then capture a snapshot.', + checks, + ); + + expect(results).toHaveLength(2); + expect(results.every((result) => result.passed)).toBe(true); + }); + + it('fails a check when a required pattern is missing', () => { + const results = checkWorkflow('Use agent-tty only.', [ + createWorkflowCheck({ + id: 'capture-proof', + requiredPatterns: ['snapshot'], + }), + ]); + const result = getWorkflowResult(results, 'capture-proof'); + + expect(result.passed).toBe(false); + expect(result.message).toContain('Missing required patterns: snapshot'); + expect(result.matches[0]).toMatchObject({ + pattern: 'snapshot', + matched: false, + }); + }); + + it('fails dependent checks when their dependency fails', () => { + const checks = [ + createWorkflowCheck({ + id: 'load-skill', + requiredPatterns: ['agent-tty'], + }), + createWorkflowCheck({ + id: 'capture-proof', + requiredPatterns: ['snapshot'], + dependsOn: ['load-skill'], + }), + ]; + + const results = checkWorkflow('Capture a snapshot only.', checks); + const dependencyResult = getWorkflowResult(results, 'load-skill'); + const dependentResult = getWorkflowResult(results, 'capture-proof'); + + expect(dependencyResult.passed).toBe(false); + expect(dependencyResult.message).toContain( + 'Missing required patterns: agent-tty', + ); + expect(dependentResult.passed).toBe(false); + expect(dependentResult.message).toContain( + 'Dependency "load-skill" did not pass', + ); + }); + + it('fails a workflow check when it matches a forbidden pattern', () => { + const results = checkWorkflow( + 'Use agent-tty but also run tmux new-session.', + [ + createWorkflowCheck({ + id: 'avoid-tmux', + requiredPatterns: ['agent-tty'], + forbiddenPatterns: ['tmux'], + }), + ], + ); + const result = getWorkflowResult(results, 'avoid-tmux'); + + expect(result.passed).toBe(false); + expect(result.message).toContain('Matched forbidden patterns: tmux'); + expect(result.forbiddenMatches[0]).toMatchObject({ + pattern: 'tmux', + violated: true, + matchCount: 1, + }); + }); +}); diff --git a/test/unit/evals/verifiers.test.ts b/test/unit/evals/verifiers.test.ts new file mode 100644 index 00000000..673ef07c --- /dev/null +++ b/test/unit/evals/verifiers.test.ts @@ -0,0 +1,756 @@ +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +const mocks = vi.hoisted(() => ({ + readEvalEvents: vi.fn(), + validateBundle: vi.fn(), + sessionDir: vi.fn(), + manifestPath: vi.fn(), + readFile: vi.fn(), + stat: vi.fn(), +})); + +vi.mock('../../../evals/lib/cliHarness.js', () => ({ + readEvalEvents: mocks.readEvalEvents, +})); + +vi.mock('../../../src/tools/validate-bundle.js', () => ({ + validateBundle: mocks.validateBundle, +})); + +vi.mock('../../../src/storage/sessionPaths.js', () => ({ + sessionDir: mocks.sessionDir, + manifestPath: mocks.manifestPath, +})); + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises'); + return { + ...actual, + readFile: mocks.readFile, + stat: mocks.stat, + }; +}); + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import type * as FsPromises from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import type { EventRecord } from '../../../src/protocol/schemas.js'; +import type { + BundleValidationCheck, + BundleValidationResult, +} from '../../../src/tools/validate-bundle.js'; +import { + verify, + verifyArtifactExists, + verifyBundleValid, + verifyEventLogCheck, + verifyExitCode, + verifySnapshotContains, +} from '../../../evals/execution/verifiers/index.js'; +import type { + VerifierContext, + VerifierResult, +} from '../../../evals/execution/verifiers/index.js'; +import type { VerifierSpec } from '../../../evals/lib/types.js'; + +let actualFsPromises: typeof FsPromises; +const tempDirs = new Set(); + +beforeAll(async () => { + actualFsPromises = + await vi.importActual('node:fs/promises'); +}); + +beforeEach(() => { + vi.resetAllMocks(); + mocks.readEvalEvents.mockReturnValue([]); + mocks.validateBundle.mockResolvedValue(createBundleValidationResult()); + mocks.sessionDir.mockImplementation((home: string, sessionId: string) => + join(home, 'sessions', sessionId), + ); + mocks.manifestPath.mockImplementation((sessionDirectory: string) => + join(sessionDirectory, 'manifest.json'), + ); + mocks.readFile.mockRejectedValue(createFsMissingError()); + mocks.stat.mockRejectedValue(createFsMissingError()); +}); + +afterEach(async () => { + await Promise.all( + [...tempDirs].map((dir) => rm(dir, { recursive: true, force: true })), + ); + tempDirs.clear(); +}); + +function createContext( + overrides: Partial = {}, +): VerifierContext { + return { + home: '/tmp/evals-home', + sessionId: 'session-01', + transcript: 'hello world\nrender complete', + artifacts: [], + ...overrides, + }; +} + +function createSpec( + kind: VerifierSpec['kind'], + config: Record, + overrides: Partial> = {}, +): VerifierSpec { + return { + id: overrides.id ?? `${kind}-01`, + kind, + description: overrides.description ?? `${kind} verifier`, + required: overrides.required ?? true, + config, + }; +} + +function createOutputEvent(seq: number, data: string): EventRecord { + return { + seq, + ts: createTimestamp(seq), + type: 'output', + payload: { data }, + }; +} + +function createExitEvent(seq: number, exitCode: number | null): EventRecord { + return { + seq, + ts: createTimestamp(seq), + type: 'exit', + payload: { + exitCode, + exitSignal: null, + }, + }; +} + +function createSignalEvent(seq: number, signal = 'SIGTERM'): EventRecord { + return { + seq, + ts: createTimestamp(seq), + type: 'signal', + payload: { signal }, + }; +} + +function createTimestamp(seq: number): string { + return new Date(Date.UTC(2026, 0, 1, 0, 0, seq)).toISOString(); +} + +function createFsMissingError(): NodeJS.ErrnoException { + const error = new Error('ENOENT'); + error.name = 'ENOENT'; + return Object.assign(error, { code: 'ENOENT' }); +} + +function createBundleCheck( + name: string, + ok: boolean, + message: string, +): BundleValidationCheck { + return { name, ok, message }; +} + +function createBundleValidationResult( + overrides: Partial = {}, +): BundleValidationResult { + return { + bundleDir: '/tmp/bundle', + profile: 'interactive-renderer', + ok: true, + checks: [createBundleCheck('bundle-exists', true, 'Bundle exists.')], + ...overrides, + }; +} + +async function createTempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'agent-tty-verifiers-')); + tempDirs.add(dir); + return dir; +} + +async function createArtifactFiles(fileNames: string[]): Promise { + const dir = await createTempDir(); + const paths: string[] = []; + + for (const fileName of fileNames) { + const artifactPath = join(dir, fileName); + const content = fileName.endsWith('.json') ? '{}' : `artifact:${fileName}`; + await writeFile(artifactPath, content, 'utf8'); + paths.push(artifactPath); + } + + return paths; +} + +function requireDefined(value: T | undefined, label: string): T { + if (value === undefined) { + throw new Error(`${label} must be defined`); + } + return value; +} + +function useRealStat(): void { + mocks.stat.mockImplementation( + actualFsPromises.stat as Parameters< + typeof mocks.stat.mockImplementation + >[0], + ); +} + +function useRealReadFile(): void { + mocks.readFile.mockImplementation( + actualFsPromises.readFile as Parameters< + typeof mocks.readFile.mockImplementation + >[0], + ); +} + +function expectFailure( + result: VerifierResult, +): VerifierResult & { pass: false } { + expect(result.pass).toBe(false); + return result as VerifierResult & { pass: false }; +} + +describe('verify dispatch', () => { + it('dispatches snapshot specs to transcript pattern verification', async () => { + const result = await verify( + createSpec('snapshot', { + patterns: ['hello', 'render complete'], + }), + createContext(), + ); + + expect(result).toMatchObject({ + pass: true, + message: 'Matched all 2 required snapshot pattern(s).', + }); + }); + + it('returns missingPatterns details for snapshot dispatch misses', async () => { + const result = await verify( + createSpec('snapshot', { + patterns: ['hello', 'goodbye'], + }), + createContext({ transcript: 'hello only' }), + ); + + expectFailure(result); + expect(result.details).toMatchObject({ + missingPatterns: ['goodbye'], + }); + }); + + it('dispatches screenshot specs to artifact verification', async () => { + useRealStat(); + const screenshotPath = requireDefined( + (await createArtifactFiles(['capture.png']))[0], + 'screenshotPath', + ); + + const result = await verify( + createSpec('screenshot', {}), + createContext({ artifacts: [screenshotPath] }), + ); + + expect(result).toMatchObject({ + pass: true, + message: 'Found required artifacts for screenshot.', + }); + }); + + it('fails command dispatch when the verifier context is missing a sessionId', async () => { + const result = await verify( + createSpec('command', { expectedExitCode: 0 }), + createContext({ sessionId: ' ' }), + ); + + expect(result).toMatchObject({ + pass: false, + message: + 'Exit-code verification requires a sessionId in the verifier context.', + }); + }); + + it('dispatches json specs with patterns to snapshot verification', async () => { + const result = await verify( + createSpec('json', { patterns: ['hello world'] }), + createContext({ + transcript: 'hello world from transcript', + artifacts: [], + }), + ); + + expect(result).toMatchObject({ + pass: true, + message: 'Matched all 1 required snapshot pattern(s).', + }); + }); + + it('dispatches json specs without patterns to artifact verification', async () => { + useRealStat(); + const jsonPath = requireDefined( + (await createArtifactFiles(['result.json']))[0], + 'jsonPath', + ); + + const result = await verify( + createSpec('json', {}), + createContext({ artifacts: [jsonPath], transcript: 'not used' }), + ); + + expect(result).toMatchObject({ + pass: true, + message: 'Found required artifacts for json.', + details: { + matchedByKind: { + json: [jsonPath], + }, + }, + }); + }); + + it('dispatches bundle specs to bundle validation', async () => { + const checks = [createBundleCheck('has-json-output', true, 'Found JSON.')]; + mocks.validateBundle.mockResolvedValue( + createBundleValidationResult({ checks }), + ); + + const result = await verify( + createSpec('bundle', { bundlePath: './proofs/case-01' }), + createContext(), + ); + + expect(result).toMatchObject({ + pass: true, + message: 'Bundle validation passed.', + details: { + profile: 'interactive-renderer', + checks, + }, + }); + expect(mocks.validateBundle).toHaveBeenCalledWith( + resolve('./proofs/case-01'), + 'interactive-renderer', + ); + }); + + it('dispatches custom validators using the configured validator name', async () => { + useRealStat(); + const screenshotPath = requireDefined( + (await createArtifactFiles(['custom.png']))[0], + 'screenshotPath', + ); + + const result = await verify( + createSpec('custom', { + validator: 'artifact-exists', + kind: 'screenshot', + }), + createContext({ artifacts: [screenshotPath] }), + ); + + expect(result).toMatchObject({ + pass: true, + message: 'Found required artifacts for screenshot.', + }); + }); + + it('fails unsupported custom validators with a descriptive message', async () => { + const result = await verify( + createSpec( + 'custom', + { validator: 'does-not-exist' }, + { id: 'custom-unsupported' }, + ), + createContext(), + ); + + expect(result.pass).toBe(false); + expect(result.message).toContain('Unsupported custom verifier'); + expect(result.message).toContain('custom-unsupported'); + }); +}); + +describe('verifySnapshotContains', () => { + it('passes when all required patterns are present', async () => { + const result = await verifySnapshotContains( + { patterns: ['hello', 'render complete'] }, + createContext(), + ); + + expect(result).toMatchObject({ + pass: true, + message: 'Matched all 2 required snapshot pattern(s).', + }); + }); + + it('fails with missingPatterns when required text is absent', async () => { + const result = await verifySnapshotContains( + { patterns: ['hello', 'missing line'] }, + createContext({ transcript: 'hello world' }), + ); + + expectFailure(result); + expect(result.details).toMatchObject({ + missingPatterns: ['missing line'], + }); + }); + + it('fails when one pattern is not a valid regex', async () => { + const result = await verifySnapshotContains( + { patterns: ['hello', '['] }, + createContext(), + ); + + expect(result.pass).toBe(false); + expect(result.message).toContain( + 'Snapshot verification failed: Invalid regex pattern', + ); + }); +}); + +describe('verifyArtifactExists', () => { + it('passes when real files exist for the requested artifact kind', async () => { + useRealStat(); + const screenshotPath = requireDefined( + (await createArtifactFiles(['capture.png']))[0], + 'screenshotPath', + ); + + const result = await verifyArtifactExists( + { kind: 'screenshot' }, + createContext({ artifacts: [screenshotPath] }), + ); + + expect(result).toMatchObject({ + pass: true, + message: 'Found required artifacts for screenshot.', + details: { + matchedByKind: { + screenshot: [screenshotPath], + }, + }, + }); + }); + + it('fails when no artifact files exist on disk', async () => { + useRealStat(); + const dir = await createTempDir(); + const missingPath = join(dir, 'missing.png'); + + const result = await verifyArtifactExists( + { kind: 'screenshot' }, + createContext({ artifacts: [missingPath] }), + ); + + expect(result).toMatchObject({ + pass: false, + message: 'No artifact files were available to validate.', + }); + }); + + it('fails when only files of the wrong kind are present', async () => { + useRealStat(); + const videoPath = requireDefined( + (await createArtifactFiles(['capture.webm']))[0], + 'videoPath', + ); + + const result = await verifyArtifactExists( + { kind: 'screenshot' }, + createContext({ artifacts: [videoPath] }), + ); + + expectFailure(result); + expect(result.details).toMatchObject({ + missingKinds: ['screenshot'], + matchedByKind: { + screenshot: [], + }, + }); + }); + + it('filters matches by pathPatterns before counting artifacts', async () => { + useRealStat(); + const artifactPaths = await createArtifactFiles([ + 'special-report.json', + 'other-report.json', + ]); + const specialJson = requireDefined(artifactPaths[0], 'specialJson'); + const otherJson = requireDefined(artifactPaths[1], 'otherJson'); + + const result = await verifyArtifactExists( + { + kind: 'json', + pathPatterns: ['special-report\\.json$'], + }, + createContext({ artifacts: [specialJson, otherJson] }), + ); + + expect(result).toMatchObject({ + pass: true, + details: { + matchedByKind: { + json: [specialJson], + }, + }, + }); + }); +}); + +describe('verifyExitCode', () => { + it('passes when the event log contains the expected exit code', async () => { + mocks.readEvalEvents.mockReturnValue([ + createOutputEvent(0, 'hello'), + createExitEvent(1, 0), + ]); + + const result = await verifyExitCode( + { expectedExitCode: 0 }, + createContext(), + ); + + expect(result).toMatchObject({ + pass: true, + message: 'Observed expected exit code 0 from the event-log.', + details: { + expectedExitCode: 0, + source: 'event-log', + }, + }); + }); + + it('fails when the observed exit code does not match', async () => { + mocks.readEvalEvents.mockReturnValue([createExitEvent(0, 1)]); + + const result = await verifyExitCode( + { expectedExitCode: 0 }, + createContext(), + ); + + expect(result).toMatchObject({ + pass: false, + message: 'Expected exit code 0, observed 1.', + details: { + expectedExitCode: 0, + actualExitCode: 1, + source: 'event-log', + }, + }); + }); + + it('fails when sessionId is blank', async () => { + const result = await verifyExitCode( + { expectedExitCode: 0 }, + createContext({ sessionId: '' }), + ); + + expect(result).toMatchObject({ + pass: false, + message: + 'Exit-code verification requires a sessionId in the verifier context.', + }); + }); + + it('falls back to the session manifest when the event log has no exit record', async () => { + useRealReadFile(); + const home = await createTempDir(); + const sessionId = 'session-from-manifest'; + const sessionDirectory = join(home, 'sessions', sessionId); + const manifest = { + version: 1, + sessionId, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:01.000Z', + status: 'exited', + command: ['bash'], + cwd: home, + cols: 80, + rows: 24, + hostPid: null, + childPid: null, + exitCode: 17, + exitSignal: null, + }; + await mkdir(sessionDirectory, { recursive: true }); + await writeFile( + join(sessionDirectory, 'manifest.json'), + JSON.stringify(manifest), + 'utf8', + ); + + const result = await verifyExitCode( + { expectedExitCode: 17 }, + createContext({ home, sessionId }), + ); + + expect(result).toMatchObject({ + pass: true, + message: 'Observed expected exit code 17 from the manifest.', + details: { + expectedExitCode: 17, + source: 'manifest', + }, + }); + }); +}); + +describe('verifyEventLogCheck', () => { + it('passes when events satisfy the required types and output patterns', async () => { + mocks.readEvalEvents.mockReturnValue([ + createOutputEvent(0, 'hello '), + createOutputEvent(1, 'done'), + createExitEvent(2, 0), + ]); + + const result = await verifyEventLogCheck( + { + requiredEventTypes: ['output', 'exit'], + forbiddenEventTypes: ['signal'], + requiredOutputPatterns: ['hello', 'done'], + minEvents: 2, + }, + createContext(), + ); + + expect(result).toMatchObject({ + pass: true, + message: 'Event log satisfied 2 required type check(s).', + details: { + eventCount: 3, + foundEventTypes: ['output', 'exit'], + }, + }); + }); + + it('fails when required event types are missing', async () => { + mocks.readEvalEvents.mockReturnValue([createOutputEvent(0, 'only output')]); + + const result = await verifyEventLogCheck( + { + requiredEventTypes: ['output', 'exit'], + }, + createContext(), + ); + + expectFailure(result); + expect(result.details).toMatchObject({ + missingEventTypes: ['exit'], + foundEventTypes: ['output'], + }); + }); + + it('fails when a forbidden event type appears in the log', async () => { + mocks.readEvalEvents.mockReturnValue([ + createOutputEvent(0, 'hello'), + createSignalEvent(1), + ]); + + const result = await verifyEventLogCheck( + { + forbiddenEventTypes: ['signal'], + }, + createContext(), + ); + + expectFailure(result); + expect(result.details).toMatchObject({ + presentForbiddenEventTypes: ['signal'], + foundEventTypes: ['output', 'signal'], + }); + }); +}); + +describe('verifyBundleValid', () => { + it('passes and defaults the validation profile when validateBundle succeeds', async () => { + const checks = [ + createBundleCheck('has-review-page', true, 'Found index.html.'), + ]; + mocks.validateBundle.mockResolvedValue( + createBundleValidationResult({ checks }), + ); + + const result = await verifyBundleValid( + { bundlePath: './proofs/pass-bundle' }, + createContext(), + ); + + expect(result).toMatchObject({ + pass: true, + message: 'Bundle validation passed.', + details: { + profile: 'interactive-renderer', + checks, + }, + }); + expect(mocks.validateBundle).toHaveBeenCalledWith( + resolve('./proofs/pass-bundle'), + 'interactive-renderer', + ); + }); + + it('returns validator failures and preserves an explicit profile', async () => { + const checks = [ + createBundleCheck( + 'has-json-output', + false, + 'Expected at least one JSON output file.', + ), + ]; + mocks.validateBundle.mockResolvedValue( + createBundleValidationResult({ + profile: 'contract-reporting', + ok: false, + checks, + }), + ); + + const result = await verifyBundleValid( + { + bundlePath: '/tmp/proof-bundle', + profile: 'contract-reporting', + }, + createContext(), + ); + + expect(result).toMatchObject({ + pass: false, + message: 'Bundle validation failed.', + details: { + profile: 'contract-reporting', + checks, + }, + }); + expect(mocks.validateBundle).toHaveBeenCalledWith( + '/tmp/proof-bundle', + 'contract-reporting', + ); + }); + + it('requires bundlePath before attempting validation', async () => { + const result = await verifyBundleValid({}, createContext()); + + expect(result).toMatchObject({ + pass: false, + message: 'Bundle verification requires config.bundlePath.', + }); + expect(mocks.validateBundle).not.toHaveBeenCalled(); + }); +});