evals: fix false-negative regex checks with the agentic LLM judge (#95440)

Fixes false-negative regex assertions — observed rejecting correct
solutions in recent eval runs — by grading those specific checks with
`@vercel/agent-eval`'s agentic LLM judge instead. Only the assertions
with demonstrated false negatives change; every deterministic check
stays.

- `agent-034-async-cookies`: `/await\s+cookies\s*\(\s*\)/` only matched
the naive `await cookies()` and rejected the correct (arguably better)
`await Promise.all([cookies(), headers()])` — observed failing most runs
of a strong model — and the no-sync-call lookbehind wrongly flagged the
bare `cookies()` inside the array. The four await-mechanics tests
collapse into one judge criterion: the promises must actually be awaited
before use, in any correct form. The theme-cookie and Accept-Language
content checks stay regex.
- `agent-030-app-router-migration-hard`: the home-page test rejected
solutions that didn't match one exact shape (e.g. data fetching
extracted to a helper means no literal `fetch(` in `page.tsx`). Its body
becomes one judge criterion (async Server Component, fetches during
server render, no `getServerSideProps`); the file-existence check and
the other seven tests stay deterministic.

The judge is pinned to **`claude-haiku-4-5`** in the generated
experiment configs — a cheap fixed grader, identical for every run
regardless of the model under test. Each criterion states the
requirement semantically, includes one reference solution to ground the
small grader, and fixes the standard of judgment (runtime correctness,
not style) — no enumerated failure modes, so the judge stays flexible
about equivalent forms.

Supporting changes: bumps `@vercel/agent-eval` `0.9.5` → `1.3.0` (ships
the judge runtime) and adapts `run-evals.js` to the 1.3 CLI (`run
baseline agents-md --force`; `--dry` maps to `status`); generated
experiments now run through the Vercel AI Gateway so `vc env pull` is
the only credential setup; converted fixtures exclude `EVAL.ts` from
tsconfig (the `@vercel/agent-eval/eval` import has no type declarations
— vercel-labs/agent-eval#166 — and `next build` type-checks the fixture,
so experiments running `scripts: ['build']` would otherwise fail on it,
same reason `EVAL.tsx` was already excluded). Not converted: `agent-040`
(already redesigned upstream in #94578) and `agent-041` (no diagnosed
false negative in the report).

Verified end-to-end from this repo: converted `agent-034` with codegen
`claude-sonnet-4-5` via the AI Gateway in a Vercel sandbox, judge pinned
to `claude-haiku-4-5` — passes, with the judge clause executing
in-sandbox.

**Running it:**

```bash
pnpm install                 # picks up @vercel/agent-eval 1.3.0
pnpm --filter=next build     # pnpm eval packs the locally built next
vc env pull .env.local       # the only credential: sandbox + AI Gateway (codegen and judge)

pnpm eval agent-034-async-cookies         # baseline + AGENTS.md variants
pnpm eval agent-034-async-cookies --dry   # preview only
```

<!-- NEXT_JS_LLM_PR -->
This commit is contained in:
Jude Gao
2026-07-06 12:27:32 -04:00
committed by GitHub
parent 100606da48
commit 0240fd39a5
9 changed files with 67 additions and 93 deletions
+4
View File
@@ -15,6 +15,10 @@ export default defineConfig([
// files. Keep both in sync if you change the globs.
ignores: [
'bench/**/*',
// Eval fixtures are sandbox workspaces with their own package.json and
// tsconfig, not repo code — EVAL.ts files may import modules that only
// resolve inside the sandbox (e.g. @vercel/agent-eval/eval).
'evals/evals/**/*',
'examples/**/*',
'test/**/*',
'**/*.d.ts',
+4
View File
@@ -404,6 +404,10 @@ export default defineConfig([
files: ['**/*.ts', '**/*.tsx'],
ignores: [
'bench/**/*',
// Eval fixtures are sandbox workspaces with their own package.json and
// tsconfig, not repo code — EVAL.ts files may import modules that only
// resolve inside the sandbox (e.g. @vercel/agent-eval/eval).
'evals/evals/**/*',
'examples/**/*',
'test/**/*',
'**/*.d.ts',
@@ -13,6 +13,7 @@
import { expect, test } from 'vitest'
import { readFileSync, existsSync } from 'fs'
import { join } from 'path'
import { environment } from '@vercel/agent-eval/eval'
/** Strip JS/TS comments so we only test actual code, not migration notes */
function stripComments(code: string): string {
@@ -36,25 +37,28 @@ test('Root layout exists and replaces _app/_document', () => {
expect(layoutContent).toMatch(/children.*ReactNode/)
})
test('Home page migrated to Server Component with async data fetching', () => {
// The "is it a Server Component fetching data" check is semantic, so it uses the
// agentic LLM judge rather than regex. The old regexes rejected correct solutions
// that didn't match one exact shape — e.g. data fetching extracted to a helper
// (no literal `fetch(` in page.tsx) or a component not declared with the
// `export default async function` form.
test('Home page migrated to Server Component with async data fetching', async () => {
const pagePath = join(process.cwd(), 'app', 'page.tsx')
expect(existsSync(pagePath)).toBe(true)
const pageContent = readFileSync(pagePath, 'utf-8')
await expect(environment).toSatisfyCriterion(
`app/page.tsx is the home page migrated to the App Router: an async Server Component that fetches its data during server render.
// Should be async Server Component
expect(pageContent).toMatch(
/export\s+default\s+async\s+function|async\s+function.*Page/
For reference, one correct solution shape:
// app/page.tsx
export default async function HomePage() {
const posts = await getPosts() // fetched inline or via an imported helper
return <main>{/* renders the fetched data */}</main>
}
Judge runtime behavior, not style: any organization that renders the fetched data from a Server Component is correct.`
)
// Should NOT have 'use client' directive
expect(pageContent).not.toMatch(/['"]use client['"];?/)
// Should use fetch instead of getServerSideProps
expect(pageContent).toMatch(/await\s+fetch|fetch\(/)
// Should not have getServerSideProps in actual code (comments OK)
expect(stripComments(pageContent)).not.toMatch(/getServerSideProps/)
})
test('Blog index migrated with ISR equivalent', () => {
@@ -23,5 +23,5 @@
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "EVAL.ts"]
}
+17 -35
View File
@@ -6,11 +6,19 @@
*
* Tricky because agents trained on Next.js 15 call cookies()/headers()
* synchronously — Next.js 16 removed synchronous access entirely.
*
* The awaited-correctly check is semantic, so it uses the agentic LLM judge
* rather than regex. The old /await\s+cookies\(\)/ regex only matched the naive
* `await cookies()` form and rejected correct (arguably better) code like
* `await Promise.all([cookies(), headers()])`, and its no-sync-call lookbehind
* wrongly flagged the bare `cookies()` inside that array. The judge reasons
* about whether the promises are actually awaited before use, whatever the form.
*/
import { expect, test } from 'vitest'
import { readFileSync, existsSync, readdirSync } from 'fs'
import { join } from 'path'
import { environment } from '@vercel/agent-eval/eval'
function readAppFiles(): string {
const appDir = join(process.cwd(), 'app')
@@ -20,29 +28,18 @@ function readAppFiles(): string {
return files.map((f) => readFileSync(join(appDir, f), 'utf-8')).join('\n')
}
test('Component uses await with cookies()', () => {
const content = readAppFiles()
test('cookies() and headers() are consumed as async APIs', async () => {
await expect(environment).toSatisfyCriterion(
`Next.js 16's cookies() and headers() (from 'next/headers') are async: they return Promises. In the app/ directory, the code must consume them correctly at runtime — every read of the cookie store or headers list operates on a resolved value, never on a pending Promise.
// In Next.js 16, cookies() returns a Promise and MUST be awaited
// Correct: const cookieStore = await cookies()
// Wrong: const cookieStore = cookies()
expect(content).toMatch(/await\s+cookies\s*\(\s*\)/)
})
For reference, one correct solution:
test('Component uses await with headers()', () => {
const content = readAppFiles()
const [cookieStore, headersList] = await Promise.all([cookies(), headers()])
const theme = cookieStore.get('theme')?.value
const lang = headersList.get('accept-language')
// In Next.js 16, headers() returns a Promise and MUST be awaited
// Correct: const headersList = await headers()
// Wrong: const headersList = headers()
expect(content).toMatch(/await\s+headers\s*\(\s*\)/)
})
test('Component is async', () => {
const content = readAppFiles()
// Component must be async to use await
expect(content).toMatch(/async\s+function|export\s+default\s+async/)
Judge runtime correctness, not style: any form that resolves the promises before using the values is correct, even if unidiomatic or redundant.`
)
})
test('Component reads theme cookie', () => {
@@ -61,18 +58,3 @@ test('Component reads Accept-Language header', () => {
// Should read Accept-Language header
expect(content).toMatch(/accept-language/i)
})
test('Does NOT use synchronous cookies() pattern', () => {
const content = readAppFiles()
// Should NOT have synchronous pattern like:
// const cookieStore = cookies()
// (without await)
// This regex matches "cookies()" that is NOT preceded by "await"
// We check that every cookies() call is preceded by await
const syncCookiesPattern = /(?<!await\s)cookies\s*\(\s*\)(?!\s*\.then)/
// Verify the synchronous cookies() pattern is NOT used
expect(content).not.toMatch(syncCookiesPattern)
})
@@ -23,5 +23,5 @@
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "EVAL.ts"]
}
+1 -1
View File
@@ -167,7 +167,7 @@
"@types/string-hash": "1.1.1",
"@types/trusted-types": "2.0.3",
"@types/yargs": "16.0.11",
"@vercel/agent-eval": "0.9.5",
"@vercel/agent-eval": "1.3.0",
"@vercel/blob": "2.3.2",
"@vercel/devlow-bench": "workspace:*",
"@vercel/kv": "3.0.0",
+8 -36
View File
@@ -244,8 +244,8 @@ importers:
specifier: 16.0.11
version: 16.0.11
'@vercel/agent-eval':
specifier: 0.9.5
version: 0.9.5
specifier: 1.3.0
version: 1.3.0
'@vercel/blob':
specifier: 2.3.2
version: 2.3.2(patch_hash=cb53bfa5effb15b3a361e176003df667cadf757b27b7c9b458c2f0fce86ea893)
@@ -6971,8 +6971,8 @@ packages:
'@upstash/[email protected]':
resolution: {integrity: sha512-hSjv66NOuahW3MisRGlSgoszU2uONAY2l5Qo3Sae8OT3/Tng9K+2/cBRuyPBX8egwEGcNNCF9+r0V6grNnhL+w==}
'@vercel/agent-eval@0.9.5':
resolution: {integrity: sha512-wSs0K39zZFQDNniG0UNeZMy3GgVDSuTyhVKZVqxm33nu3sq2KJjOSJCpvoFaXKUWkk2Ea3DTaxJsVgp8b1Xq5w==}
'@vercel/agent-eval@1.3.0':
resolution: {integrity: sha512-x8mxdvteWp7z4I5VuOm2mknlyMUTevbbp7RCiPg7gsVSknOcVxzJxBCaccenHoux2JKwK63svnzfoeIfB2dDWg==}
engines: {node: '>=18.0.0'}
hasBin: true
@@ -8204,10 +8204,6 @@ packages:
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
[email protected]:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
@@ -12664,10 +12660,6 @@ packages:
resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-niTvB4gqvtof056rRIrTZvjNYE4rCUzO6X/X+kYjd7WFxXeJ0NwEFnRxX6ehkvv3jTwrXnNdtAak5XYZuIyPFw==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
engines: {node: '>=18'}
@@ -15756,10 +15748,6 @@ packages:
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
[email protected]:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
@@ -17655,6 +17643,7 @@ packages:
[email protected]:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
[email protected]:
@@ -23684,7 +23673,7 @@ snapshots:
dependencies:
uncrypto: 0.1.3
'@vercel/agent-eval@0.9.5':
'@vercel/agent-eval@1.3.0':
dependencies:
'@ai-sdk/anthropic': 1.2.12([email protected])
'@vercel/sandbox': 1.8.0
@@ -25174,10 +25163,6 @@ snapshots:
dependencies:
restore-cursor: 3.1.0
[email protected]:
dependencies:
restore-cursor: 4.0.0
[email protected]:
dependencies:
restore-cursor: 5.1.0
@@ -30649,7 +30634,7 @@ snapshots:
cli-truncate: 4.0.0
colorette: 2.0.20
eventemitter3: 5.0.1
log-update: 6.0.0
log-update: 6.1.0
rfdc: 1.3.0
wrap-ansi: 9.0.0
@@ -30844,14 +30829,6 @@ snapshots:
slice-ansi: 4.0.0
wrap-ansi: 6.2.0
[email protected]:
dependencies:
ansi-escapes: 6.2.1
cli-cursor: 4.0.0
slice-ansi: 7.1.0
strip-ansi: 7.1.0
wrap-ansi: 9.0.0
[email protected]:
dependencies:
ansi-escapes: 7.3.0
@@ -34767,11 +34744,6 @@ snapshots:
onetime: 5.1.2
signal-exit: 3.0.7
[email protected]:
dependencies:
onetime: 5.1.2
signal-exit: 3.0.7
[email protected]:
dependencies:
onetime: 7.0.0
@@ -37540,7 +37512,7 @@ snapshots:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
strip-ansi: 6.0.0
[email protected]:
dependencies:
+13 -5
View File
@@ -72,8 +72,13 @@ function writeExperiments(evalName) {
${v.imports}
const config: ExperimentConfig = {
agent: 'claude-code',
model: 'claude-opus-4-6',${evalsField}
// Via the Vercel AI Gateway, so the OIDC token from \`vc env pull\` is the only
// credential needed (it auths the sandbox, the codegen model, and the judge).
agent: 'vercel-ai-gateway/claude-code',
model: 'claude-opus-4-8',${evalsField}
// Cheap fixed grader for the agentic judge clauses in EVAL.ts files — every
// run is graded by the same model regardless of the model under test.
judge: { model: 'claude-haiku-4-5' },
scripts: ['build'],
runs: 1,
earlyExit: true,
@@ -136,8 +141,11 @@ function main() {
/** @type {string | null} */
const evalName = argv.all ? null : /** @type {string} */ (argv.evalName)
// Flags not consumed here are forwarded to agent-eval.
const forward = argv.dry ? ['--dry'] : []
// agent-eval 1.3 dropped run-all/--dry: `run` takes explicit experiment names,
// and `status` is the read-only preview.
const agentEvalArgs = argv.dry
? ['status']
: ['run', ...VARIANTS.map((v) => v.suffix), '--force']
if (!fs.existsSync(path.join(ROOT, 'packages/next/dist'))) {
console.error(
@@ -180,7 +188,7 @@ function main() {
// the bin directly rather than via `pnpm exec` because pnpm resets cwd to
// the workspace root, but agent-eval resolves experiments/ from process.cwd().
const bin = path.join(ROOT, 'node_modules/.bin/agent-eval')
const result = spawnSync(bin, ['run-all', '--force', ...forward], {
const result = spawnSync(bin, agentEvalArgs, {
cwd: EVALS_DIR,
stdio: 'inherit',
env: { ...process.env, NEXT_EVAL_TARBALL: TARBALL },