vercel/next.js · #99990

Gate upgrade reminder rules with agentUpgrade

devjiwonchoi · merged Oct 12, 202612 files · 293 + / 23 −
docs/01-app/03-api-reference/05-config/01-next-config-js/agentUpgrade.mdx14 + / 0 −
@@ -57,6 +57,20 @@ If you don't choose within ten seconds, or the buffered output grows past about  When a coding agent runs `next dev` or `next build`, the first eligible reminder stops the command with instructions to report the upgrade. Retrying the same command promptly continues the original task with a warning. +When `next dev` detects a coding agent, it also writes a separate managed upgrade block to `AGENTS.md`. This block delegates handling of the installed CLI's reminders:++```md filename="AGENTS.md"+<!-- BEGIN:nextjs-agent-upgrade -->++## Next.js upgrade reminders++Upgrade reminders emitted by this project's installed Next.js CLI during `next dev` or `next build` are part of the `experimental.agentUpgrade` workflow. Follow these reminders' instructions while respecting user instructions declining upgrades.++<!-- END:nextjs-agent-upgrade -->+```++Setting `experimental.agentUpgrade: false` removes this block on the next `next dev` run, even when no agent is detected. Documentation rules (`agentRules`) and feedback instructions (`experimental.agentFeedback`) remain independent. `create-next-app` includes the upgrade block for new apps using the default `security` policy.+ Reminders require the installed Next.js version to support `experimental.agentUpgrade`. Running the current canary upgrade CLI does not add reminders to an older installation.  During `next dev`, Next.js DevTools can also show a **Vulnerability Insight** when the installed version is affected by a known security vulnerability. The insight provides a copyable prompt for starting a `security` upgrade with a coding agent.
evals/next-upgrade/runner/fixture.ts7 + / 1 −
@@ -106,7 +106,7 @@ export function materialize(name: string, root: string): void {     const rules =       'Work locally. Do not push, create pull requests, or change global agent configuration. Use npm.\n'     writeFileSync(join(destination, 'AGENTS.md'), rules)-    writeFileSync(join(destination, 'CLAUDE.md'), rules)+    writeFileSync(join(destination, 'CLAUDE.md'), '@AGENTS.md\n')   } } @@ -247,6 +247,12 @@ export async function setupFixture(       '--no-fund',       `@next/swc-linux-x64-gnu@${process.env.NEXT_UPGRADE_EVAL_NATIVE_BINDING_VERSION}`,     ])+    // Load only the upgrade block before agent startup; documentation rules stay disabled.+    await run('node', [+      '-e',+      "require('next/dist/server/lib/generate-agent-files').writeAgentUpgradeFiles(process.cwd())",+    ])+    await run('git', ['add', 'AGENTS.md'])     await run('mkdir', ['-p', '/tmp/next-upgrade-baseline'])     await run('cp', [       'package-lock.json',
packages/create-next-app/create-app.ts5 + / 1 −
@@ -266,7 +266,11 @@ export async function createApp({    if (agentsMd) {     // Examples don't get `experimental.agentFeedback` written to their config.-    generateAgentFiles(root, { agentFeedback: !example && agentFeedback })+    generateAgentFiles(root, {+      agentFeedback: !example && agentFeedback,+      // Examples can override the policy; next dev will sync their block.+      agentUpgrade: !example,+    })   }    if (disableGit) {
packages/create-next-app/helpers/generate-agent-files.ts21 + / 7 −
@@ -15,6 +15,14 @@ This block is written and re-added by \`next dev\` — verify at \`node_modules/  <!-- END:nextjs-agent-rules -->` +const AGENT_UPGRADE_BLOCK = `<!-- BEGIN:nextjs-agent-upgrade -->++## Next.js upgrade reminders++Upgrade reminders emitted by this project's installed Next.js CLI during \`next dev\` or \`next build\` are part of the \`experimental.agentUpgrade\` workflow. Follow these reminders' instructions while respecting user instructions declining upgrades.++<!-- END:nextjs-agent-upgrade -->`+ const AGENT_FEEDBACK_BLOCK = `<!-- BEGIN:nextjs-agent-feedback -->  ## Report Next.js feedback@@ -37,17 +45,23 @@ Before the final response, run one reporting pass. If candidates remain, from th <!-- END:nextjs-agent-feedback -->`  /**- * Generate AGENTS.md in the project root. When `experimental.agentFeedback`- * is enabled, the feedback block is included up front so agents see it before- * `next dev` first runs, and the initial commit matches what `next dev` writes.+ * Generate AGENTS.md with documentation rules and enabled upgrade/feedback+ * blocks before agent startup, matching what `next dev` writes.  */ export function generateAgentFiles(   root: string,-  { agentFeedback }: { agentFeedback: boolean }+  {+    agentFeedback,+    agentUpgrade,+  }: { agentFeedback: boolean; agentUpgrade: boolean } ): void {-  const blocks = agentFeedback-    ? [AGENT_RULES_BLOCK, AGENT_FEEDBACK_BLOCK]-    : [AGENT_RULES_BLOCK]+  const blocks = [AGENT_RULES_BLOCK]+  if (agentUpgrade) {+    blocks.push(AGENT_UPGRADE_BLOCK)+  }+  if (agentFeedback) {+    blocks.push(AGENT_FEEDBACK_BLOCK)+  }    const agentsMdPath = path.join(root, 'AGENTS.md') 
packages/next/src/server/lib/app-info-log.ts21 + / 0 −
@@ -7,6 +7,9 @@ import { experimentalSchema } from '../config-schema' import { getAgentName } from '../../telemetry/agent-name' import { bundlerName, getBundlerFromEnv } from '../../lib/bundler' import {+  hasCurrentAgentUpgrade,+  removeAgentUpgradeFiles,+  writeAgentUpgradeFiles,   hasCurrentAgentFeedback,   hasCurrentAgentRules,   removeAgentFeedbackFiles,@@ -149,6 +152,24 @@ export async function syncAgentFeedbackForDev(   return writeAgentFeedbackFiles(dir) } +/**+ * Sync upgrade delegation using the reminder policy independently of agentRules.+ * Disabling reminders removes the block even without a detected agent.+ */+export async function syncAgentUpgradeForDev(+  dir: string,+  enabled: boolean+): Promise<AgentFilesResult | null> {+  if (!enabled) {+    return removeAgentUpgradeFiles(dir)+  }+  if ((await getAgentName()) === null || hasCurrentAgentUpgrade(dir)) {+    return null+  }++  return writeAgentUpgradeFiles(dir)+}+ /**  * Gets environment info for logging. Fast operation that doesn't require config.  */
packages/next/src/server/lib/generate-agent-files.test.ts60 + / 0 −
@@ -5,6 +5,11 @@ import {   AGENT_FEEDBACK_END_MARKER,   AGENT_FEEDBACK_START_MARKER,   AGENT_RULES_START_MARKER,+  AGENT_UPGRADE_START_MARKER,+  AGENT_UPGRADE_END_MARKER,+  hasCurrentAgentUpgrade,+  removeAgentUpgradeFiles,+  writeAgentUpgradeFiles,   removeAgentFeedbackFiles,   writeAgentFeedbackFiles,   writeAgentFiles,@@ -99,6 +104,7 @@ describe('removeAgentFeedbackFiles', () => { describe.each([   ['agent rules', writeAgentFiles, AGENT_RULES_START_MARKER],   ['agent feedback', writeAgentFeedbackFiles, AGENT_FEEDBACK_START_MARKER],+  ['agent upgrade', writeAgentUpgradeFiles, AGENT_UPGRADE_START_MARKER], ] as const)('%s file generation', (_name, writeFiles, marker) => {   let dir: string @@ -147,3 +153,57 @@ describe('agent feedback block wording', () => {     )   }) })++// Turning off upgrade reminders must preserve documentation, feedback and user rules.+describe('independent upgrade instructions', () => {+  let dir: string++  beforeEach(() => {+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-upgrade-files-'))+  })++  afterEach(() => {+    fs.rmSync(dir, { recursive: true, force: true })+  })++  it('removes only upgrade instructions and restores them when enabled again', () => {+    const filePath = path.join(dir, 'AGENTS.md')+    fs.writeFileSync(filePath, '# Team rules\n')+    writeAgentFiles(dir)+    writeAgentFeedbackFiles(dir)+    const baseline = fs.readFileSync(filePath, 'utf-8')++    expect(baseline).toContain(AGENT_RULES_START_MARKER)+    expect(baseline).toContain(AGENT_FEEDBACK_START_MARKER)+    writeAgentUpgradeFiles(dir)+    expect(hasCurrentAgentUpgrade(dir)).toBe(true)+    expect(removeAgentUpgradeFiles(dir).agentsMd).toBe('removed')+    expect(fs.readFileSync(filePath, 'utf-8')).toBe(baseline)+    expect(hasCurrentAgentUpgrade(dir)).toBe(false)++    writeAgentUpgradeFiles(dir)+    expect(hasCurrentAgentUpgrade(dir)).toBe(true)+    expect(writeAgentFiles(dir).agentsMd).toBe('unchanged')+    expect(writeAgentFeedbackFiles(dir).agentsMd).toBe('unchanged')+  })++  it('removes an upgrade-only file and skips a missing file', () => {+    const filePath = path.join(dir, 'AGENTS.md')+    writeAgentUpgradeFiles(dir)+    expect(removeAgentUpgradeFiles(dir).agentsMd).toBe('removed')+    expect(fs.existsSync(filePath)).toBe(false)+    expect(removeAgentUpgradeFiles(dir).agentsMd).toBe('skipped')+  })++  it('preserves CRLF user rules when removing the upgrade block', () => {+    const filePath = path.join(dir, 'AGENTS.md')+    fs.writeFileSync(+      filePath,+      `# Team rules\r\n\r\n${AGENT_UPGRADE_START_MARKER}\r\nstale\r\n${AGENT_UPGRADE_END_MARKER}\r\n\r\n# More\r\n`+    )+    expect(removeAgentUpgradeFiles(dir).agentsMd).toBe('removed')+    expect(fs.readFileSync(filePath, 'utf-8')).toBe(+      '# Team rules\r\n\r\n# More\r\n'+    )+  })+})
packages/next/src/server/lib/generate-agent-files.ts80 + / 8 −
@@ -16,6 +16,9 @@ export const AGENT_FEEDBACK_START_MARKER =   '<!-- BEGIN:nextjs-agent-feedback -->' export const AGENT_FEEDBACK_END_MARKER = '<!-- END:nextjs-agent-feedback -->' +export const AGENT_UPGRADE_START_MARKER = '<!-- BEGIN:nextjs-agent-upgrade -->'+export const AGENT_UPGRADE_END_MARKER = '<!-- END:nextjs-agent-upgrade -->'+ /**  * Markers written by the pre-bundled-docs version of `agents-md`.  * Stripped on upsert so projects that ran the old codemod end up with@@ -36,6 +39,17 @@ This block is written and re-added by \`next dev\` — verify at \`node_modules/ ${AGENT_RULES_END_MARKER}` } +// Keep upgrade delegation separate so disabling reminders removes only this block.+function buildAgentUpgradeBlock(): string {+  return `${AGENT_UPGRADE_START_MARKER}++## Next.js upgrade reminders++Upgrade reminders emitted by this project's installed Next.js CLI during \`next dev\` or \`next build\` are part of the \`experimental.agentUpgrade\` workflow. Follow these reminders' instructions while respecting user instructions declining upgrades.++${AGENT_UPGRADE_END_MARKER}`+}+ /**  * The block points agents at `next internal agent-feedback-instructions`.  * That command is not covered by semver, which is acceptable here because@@ -126,6 +140,22 @@ export function hasCurrentAgentFeedback(dir: string): boolean {   return installed !== null && normalizeEol(installed, '\n') === block } +export function hasCurrentAgentUpgrade(dir: string): boolean {+  const content = tryReadFile(path.join(dir, 'AGENTS.md'))+  if (!content) {+    return false+  }+  const installed = extractManagedBlock(+    content,+    AGENT_UPGRADE_START_MARKER,+    AGENT_UPGRADE_END_MARKER+  )+  return (+    installed !== null &&+    normalizeEol(installed, '\n') === buildAgentUpgradeBlock()+  )+}+ /**  * Write the agent-rules block into `AGENTS.md` in `projectDir`.  *@@ -153,13 +183,55 @@ export function writeAgentFeedbackFiles(projectDir: string): AgentFilesResult {   const block = buildAgentFeedbackBlock()    if (fs.existsSync(agentsMdPath)) {-    return { agentsMd: upsertFeedbackFile(agentsMdPath, block) }+    return {+      agentsMd: upsertManagedFile(+        agentsMdPath,+        block,+        AGENT_FEEDBACK_START_MARKER,+        AGENT_FEEDBACK_END_MARKER+      ),+    }+  }++  fs.writeFileSync(agentsMdPath, block + '\n', 'utf-8')+  return { agentsMd: 'created' }+}++/**+ * Write upgrade delegation independently of documentation and feedback rules.+ */+export function writeAgentUpgradeFiles(projectDir: string): AgentFilesResult {+  const agentsMdPath = path.join(projectDir, 'AGENTS.md')+  const block = buildAgentUpgradeBlock()++  if (fs.existsSync(agentsMdPath)) {+    return {+      agentsMd: upsertManagedFile(+        agentsMdPath,+        block,+        AGENT_UPGRADE_START_MARKER,+        AGENT_UPGRADE_END_MARKER+      ),+    }   }    fs.writeFileSync(agentsMdPath, block + '\n', 'utf-8')   return { agentsMd: 'created' } } +/**+ * Remove only upgrade delegation when reminders are disabled.+ */+export function removeAgentUpgradeFiles(projectDir: string): AgentFilesResult {+  return {+    agentsMd: removeManagedBlockFromFile(+      path.join(projectDir, 'AGENTS.md'),+      AGENT_UPGRADE_START_MARKER,+      AGENT_UPGRADE_END_MARKER+    ),+  }+}+ /**  * Remove only the managed agent-feedback block, leaving all other content.  * A file that held nothing but the block is deleted rather than left empty.@@ -208,14 +280,14 @@ function upsertFile(filePath: string, block: string): AgentFileAction {   return 'updated' } -function upsertFeedbackFile(filePath: string, block: string): AgentFileAction {+function upsertManagedFile(+  filePath: string,+  block: string,+  startMarker: string,+  endMarker: string+): AgentFileAction {   const existing = fs.readFileSync(filePath, 'utf-8')-  const updated = upsertManagedBlock(-    existing,-    block,-    AGENT_FEEDBACK_START_MARKER,-    AGENT_FEEDBACK_END_MARKER-  )+  const updated = upsertManagedBlock(existing, block, startMarker, endMarker)   if (updated === existing) return 'unchanged'   fs.writeFileSync(filePath, updated, 'utf-8')   return 'updated'
packages/next/src/server/lib/render-server.ts3 + / 0 −
@@ -28,6 +28,8 @@ export type ServerInitResult = {   agentRules?: boolean   // Whether managed agent-feedback instructions are enabled (default false)   agentFeedback?: boolean+  // Whether managed upgrade instructions are enabled by the reminder policy+  agentUpgrade: boolean | undefined   // Whether the development server memory threshold restart is enabled   devMemoryThresholdRestart: boolean }@@ -190,6 +192,7 @@ async function initializeImpl(opts: {     experimentalFeatures: opts.experimentalFeatures,     cacheComponents: opts.cacheComponents,     partialPrefetching: opts.partialPrefetching,+    agentUpgrade: undefined,     devMemoryThresholdRestart: opts.devMemoryThresholdRestart,   } }
packages/next/src/server/lib/router-server.ts3 + / 0 −
@@ -1165,6 +1165,9 @@ export async function initialize(opts: {     partialPrefetching: config.partialPrefetching,     agentRules: config.agentRules,     agentFeedback: config.experimental.agentFeedback,+    agentUpgrade: development+      ? development.config.experimental.agentUpgrade !== false+      : undefined,     devMemoryThresholdRestart,   } }
packages/next/src/server/lib/start-server.ts12 + / 0 −
@@ -33,6 +33,7 @@ import {   getEnvInfo,   logExperimentalInfo,   logStartInfo,+  syncAgentUpgradeForDev,   syncAgentFeedbackForDev,   syncAgentRulesForDev, } from './app-info-log'@@ -528,6 +529,17 @@ export async function startServer(               `Removed agent rules from ${files} because \`agentRules\` is disabled.`           ) +          logAgentFileSync(+            await syncAgentUpgradeForDev(+              dir,+              initResult.agentUpgrade !== false+            ),+            (files) =>+              `Generated upgrade instructions in ${files}. Set \`experimental.agentUpgrade: false\` in next.config to disable.`,+            (files) =>+              `Removed upgrade instructions from ${files} because \`experimental.agentUpgrade\` is disabled.`+          )+           if (!isCI) {             const { traceGlobals } =               require('../../trace/shared') as typeof import('../../trace/shared')
test/development/app-dir/agent-rules-auto-generate/agent-rules-auto-generate.test.ts60 + / 6 −
@@ -6,6 +6,7 @@ import { writeAgentFiles } from 'next/dist/server/lib/generate-agent-files'  const AGENT_RULES_MARKER = '<!-- BEGIN:nextjs-agent-rules -->' const AGENT_FEEDBACK_MARKER = '<!-- BEGIN:nextjs-agent-feedback -->'+const AGENT_UPGRADE_MARKER = '<!-- BEGIN:nextjs-agent-upgrade -->'  /** Clears every variable `@vercel/detect-agent` inspects so no agent is detected. */ const NO_AGENT_ENV = {@@ -103,9 +104,14 @@ describe('agent-rules auto-generate on next dev (agentRules: false)', () => {     },   }) -  it('does not generate files when agentRules is disabled in next.config', async () => {+  it('creates upgrade instructions independently from disabled agent rules', async () => {     await next.fetch('/')-    expect(fs.existsSync(path.join(next.testDir, 'AGENTS.md'))).toBe(false)+    const content = fs.readFileSync(+      path.join(next.testDir, 'AGENTS.md'),+      'utf-8'+    )+    expect(content).toContain(AGENT_UPGRADE_MARKER)+    expect(content).not.toContain(AGENT_RULES_MARKER)   }) }) @@ -371,9 +377,14 @@ describe('agent-feedback auto-generate on next dev (telemetry disabled)', () =>     },   }) -  it('does not create feedback instructions', async () => {+  it('creates upgrade instructions without feedback instructions', async () => {     await next.fetch('/')-    expect(fs.existsSync(path.join(next.testDir, 'AGENTS.md'))).toBe(false)+    const content = fs.readFileSync(+      path.join(next.testDir, 'AGENTS.md'),+      'utf-8'+    )+    expect(content).toContain(AGENT_UPGRADE_MARKER)+    expect(content).not.toContain(AGENT_FEEDBACK_MARKER)   }) }) @@ -389,8 +400,51 @@ describe('agent-feedback auto-generate on next dev (CI)', () => {     },   }) -  it('does not create feedback instructions', async () => {+  it('creates upgrade instructions without feedback instructions', async () => {     await next.fetch('/')-    expect(fs.existsSync(path.join(next.testDir, 'AGENTS.md'))).toBe(false)+    const content = fs.readFileSync(+      path.join(next.testDir, 'AGENTS.md'),+      'utf-8'+    )+    expect(content).toContain(AGENT_UPGRADE_MARKER)+    expect(content).not.toContain(AGENT_FEEDBACK_MARKER)+  })+})++// Disabling upgrade reminders must remove only their block, including without an agent.+describe.each([+  ['agent detected', { ...NO_AGENT_ENV, CLAUDECODE: '1' }],+  ['no agent', NO_AGENT_ENV],+])('agent-upgrade disabled on next dev (%s)', (_name, agentEnv) => {+  const { next } = nextTestSetup({+    files: __dirname,+    env: { ...NON_CI_ENV, ...agentEnv },+    nextConfig: {+      experimental: {+        agentUpgrade: false,+        agentFeedback: true,+      },+    },+    skipStart: true,+  })++  beforeAll(async () => {+    await next.patchFile(+      'AGENTS.md',+      `# Team rules\n\n${currentAgentRulesBlock()}\n\n${staleAgentFeedbackBlock()}\n\n${AGENT_UPGRADE_MARKER}\nstale upgrade instructions\n<!-- END:nextjs-agent-upgrade -->\n`+    )+    await next.start()+  })++  it('removes upgrade instructions and preserves user, documentation and feedback rules', async () => {+    await next.fetch('/')+    const content = fs.readFileSync(+      path.join(next.testDir, 'AGENTS.md'),+      'utf-8'+    )+    expect(content).toContain('# Team rules')+    expect(content).toContain(AGENT_RULES_MARKER)+    expect(content).toContain(AGENT_FEEDBACK_MARKER)+    expect(content).not.toContain(AGENT_UPGRADE_MARKER)   }) })
test/production/create-next-app/index.test.ts7 + / 0 −
@@ -3,6 +3,7 @@ import { join } from 'path' import {   writeAgentFeedbackFiles,   writeAgentFiles,+  writeAgentUpgradeFiles, } from 'next/dist/server/lib/generate-agent-files' import {   resolveNextTgzFilename,@@ -200,6 +201,10 @@ describe('create-next-app', () => {       )       expect(agentsMd).toContain('<!-- BEGIN:nextjs-agent-rules -->')       expect(agentsMd).not.toContain('nextjs-agent-feedback')+      expect(agentsMd).toContain('<!-- BEGIN:nextjs-agent-upgrade -->')+      expect(writeAgentUpgradeFiles(join(cwd, projectName)).agentsMd).toBe(+        'unchanged'+      )     })   }) @@ -242,6 +247,8 @@ describe('create-next-app', () => {       // file must match byte-for-byte or the initial commit is immediately dirty.       expect(writeAgentFiles(projectDir).agentsMd).toBe('unchanged')       expect(writeAgentFeedbackFiles(projectDir).agentsMd).toBe('unchanged')+      expect(agentsMd).toContain('<!-- BEGIN:nextjs-agent-upgrade -->')+      expect(writeAgentUpgradeFiles(projectDir).agentsMd).toBe('unchanged')     })   })